feat(lancamentos): aprimora parcelamentos e protecoes

This commit is contained in:
Felipe Coutinho
2026-05-21 13:47:14 +00:00
parent b6659ef66e
commit 4e8f9cc5fa
16 changed files with 275 additions and 66 deletions

View File

@@ -43,6 +43,15 @@ type PageProps = {
const capitalize = (value: string) => const capitalize = (value: string) =>
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value; value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
const resolveDefaultPaymentMethod = (
accountType: string | null | undefined,
) => {
if (accountType === "Dinheiro") return "Dinheiro";
if (accountType === "Pré-Pago | VR/VA") return "Pré-Pago | VR/VA";
return "Pix";
};
export default async function Page({ params, searchParams }: PageProps) { export default async function Page({ params, searchParams }: PageProps) {
await connection(); await connection();
const { accountId } = await params; const { accountId } = await params;
@@ -197,7 +206,11 @@ export default async function Page({ params, searchParams }: PageProps) {
accountId: account.id, accountId: account.id,
settledOnly: true, settledOnly: true,
}} }}
allowCreate={false} allowCreate
defaultAccountId={account.id}
defaultPaymentMethod={resolveDefaultPaymentMethod(
account.accountType,
)}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50} attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}

View File

@@ -130,7 +130,7 @@ export function InstallmentAnalysisPage({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Card de resumo principal */} {/* Card de resumo principal */}
<Card className="border-none bg-primary/10 dark:bg-primary/10"> <Card className="border-none bg-primary/10 shadow-none">
<CardContent className="flex flex-col items-start justify-center gap-2 py-2"> <CardContent className="flex flex-col items-start justify-center gap-2 py-2">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Se você pagar tudo que está selecionado: Se você pagar tudo que está selecionado:

View File

@@ -64,8 +64,8 @@ export function InstallmentGroupCard({
const hasSelection = selectedInstallments.size > 0; const hasSelection = selectedInstallments.size > 0;
const progress = const progress =
group.totalInstallments > 0 group.trackedInstallments > 0
? (group.paidInstallments / group.totalInstallments) * 100 ? (group.paidInstallments / group.trackedInstallments) * 100
: 0; : 0;
const selectedAmount = group.pendingInstallments const selectedAmount = group.pendingInstallments
@@ -83,6 +83,10 @@ export function InstallmentGroupCard({
); );
const cardLogoSrc = resolveLogoSrc(group.cartaoLogo); const cardLogoSrc = resolveLogoSrc(group.cartaoLogo);
const cardName = group.cartaoName ?? "Compra parcelada"; const cardName = group.cartaoName ?? "Compra parcelada";
const untrackedLabel =
group.untrackedInstallments === 1
? "1 parcela anterior fora do acompanhamento"
: `${group.untrackedInstallments} parcelas anteriores fora do acompanhamento`;
return ( return (
<> <>
@@ -153,7 +157,7 @@ export function InstallmentGroupCard({
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-primary/5 mb-4"> <div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-primary/5 mb-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground font-medium"> <p className="text-xs text-muted-foreground font-medium">
Valor total Valor acompanhado
</p> </p>
<MoneyValues <MoneyValues
amount={totalAmount} amount={totalAmount}
@@ -180,8 +184,8 @@ export function InstallmentGroupCard({
<div className="flex items-center gap-1 text-muted-foreground"> <div className="flex items-center gap-1 text-muted-foreground">
<RiCheckboxCircleFill className="size-3.5 text-success" /> <RiCheckboxCircleFill className="size-3.5 text-success" />
<span> <span>
{group.paidInstallments} de {group.totalInstallments} parcelas {group.paidInstallments} de {group.trackedInstallments}{" "}
pagas parcelas acompanhadas pagas
</span> </span>
</div> </div>
{unpaidCount > 0 && ( {unpaidCount > 0 && (
@@ -198,6 +202,9 @@ export function InstallmentGroupCard({
className="h-2.5 bg-muted" className="h-2.5 bg-muted"
indicatorClassName="bg-success" indicatorClassName="bg-success"
/> />
{group.untrackedInstallments > 0 && (
<p className="text-xs text-muted-foreground">{untrackedLabel}</p>
)}
</div> </div>
{/* Valor selecionado */} {/* Valor selecionado */}

View File

@@ -51,6 +51,9 @@ export type InstallmentGroup = {
cartaoDueDay: string | null; cartaoDueDay: string | null;
cartaoLogo: string | null; cartaoLogo: string | null;
totalInstallments: number; totalInstallments: number;
trackedStartInstallment: number;
trackedInstallments: number;
untrackedInstallments: number;
paidInstallments: number; paidInstallments: number;
pendingInstallments: InstallmentDetail[]; pendingInstallments: InstallmentDetail[];
totalPendingAmount: number; totalPendingAmount: number;
@@ -153,6 +156,12 @@ export async function fetchInstallmentAnalysis(
cartaoDueDay: row.cartaoDueDay, cartaoDueDay: row.cartaoDueDay,
cartaoLogo: row.cartaoLogo, cartaoLogo: row.cartaoLogo,
totalInstallments: row.installmentCount ?? 0, totalInstallments: row.installmentCount ?? 0,
trackedStartInstallment: installmentDetail.currentInstallment,
trackedInstallments: 1,
untrackedInstallments: Math.max(
0,
installmentDetail.currentInstallment - 1,
),
paidInstallments: 0, paidInstallments: 0,
pendingInstallments: [installmentDetail], pendingInstallments: [installmentDetail],
totalPendingAmount: amount, totalPendingAmount: amount,
@@ -168,7 +177,13 @@ export async function fetchInstallmentAnalysis(
const paidCount = group.pendingInstallments.filter( const paidCount = group.pendingInstallments.filter(
(i) => i.isSettled, (i) => i.isSettled,
).length; ).length;
const trackedStartInstallment = Math.min(
...group.pendingInstallments.map((i) => i.currentInstallment),
);
group.paidInstallments = paidCount; group.paidInstallments = paidCount;
group.trackedStartInstallment = trackedStartInstallment;
group.trackedInstallments = group.pendingInstallments.length;
group.untrackedInstallments = Math.max(0, trackedStartInstallment - 1);
return group; return group;
}) })
// Filtrar apenas séries que têm pelo menos uma parcela em aberto (não paga) // Filtrar apenas séries que têm pelo menos uma parcela em aberto (não paga)

View File

@@ -7,6 +7,7 @@ import {
TRANSACTION_CONDITIONS, TRANSACTION_CONDITIONS,
TRANSACTION_TYPES, TRANSACTION_TYPES,
} from "@/features/transactions/lib/constants"; } from "@/features/transactions/lib/constants";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { handleActionError } from "@/shared/lib/actions/helpers"; import { handleActionError } from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
@@ -30,6 +31,7 @@ import {
fetchOwnedPayerIds, fetchOwnedPayerIds,
formatPaidInvoicePeriods, formatPaidInvoicePeriods,
getPaidInvoicePeriods, getPaidInvoicePeriods,
isInitialBalanceTransaction,
type MassAddInput, type MassAddInput,
massAddSchema, massAddSchema,
resolvePeriod, resolvePeriod,
@@ -47,6 +49,19 @@ const getPeriodOffset = (basePeriod: string, targetPeriod: string) => {
return (target.year - base.year) * 12 + (target.month - base.month); return (target.year - base.year) * 12 + (target.month - base.month);
}; };
type ProtectedTransactionCandidate = {
note: string | null;
transactionType: string | null;
condition: string | null;
paymentMethod: string | null;
};
const isProtectedTransaction = (
record: ProtectedTransactionCandidate,
): boolean =>
Boolean(record.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
isInitialBalanceTransaction(record);
export async function deleteTransactionBulkAction( export async function deleteTransactionBulkAction(
input: DeleteBulkInput, input: DeleteBulkInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
@@ -61,6 +76,9 @@ export async function deleteTransactionBulkAction(
seriesId: true, seriesId: true,
period: true, period: true,
condition: true, condition: true,
transactionType: true,
paymentMethod: true,
note: true,
}, },
where: and( where: and(
eq(transactions.id, data.id), eq(transactions.id, data.id),
@@ -79,6 +97,13 @@ export async function deleteTransactionBulkAction(
}; };
} }
if (isProtectedTransaction(existing)) {
return {
success: false,
error: "Lançamentos protegidos não podem ser removidos em massa.",
};
}
let scopeFilter: ReturnType<typeof and>; let scopeFilter: ReturnType<typeof and>;
let successMessage: string; let successMessage: string;
@@ -171,6 +196,7 @@ export async function updateTransactionBulkAction(
purchaseDate: true, purchaseDate: true,
payerId: true, payerId: true,
cardId: true, cardId: true,
note: true,
}, },
where: and( where: and(
eq(transactions.id, data.id), eq(transactions.id, data.id),
@@ -189,6 +215,13 @@ export async function updateTransactionBulkAction(
}; };
} }
if (isProtectedTransaction(existing)) {
return {
success: false,
error: "Lançamentos protegidos não podem ser atualizados em massa.",
};
}
const baseUpdatePayload: Record<string, unknown> = { const baseUpdatePayload: Record<string, unknown> = {
name: data.name, name: data.name,
categoryId: data.categoryId ?? null, categoryId: data.categoryId ?? null,
@@ -753,6 +786,13 @@ export async function deleteMultipleTransactionsAction(
return { success: false, error: "Nenhum lançamento encontrado." }; return { success: false, error: "Nenhum lançamento encontrado." };
} }
if (existing.some(isProtectedTransaction)) {
return {
success: false,
error: "Lançamentos protegidos não podem ser removidos em massa.",
};
}
const linkedAttachments = await db const linkedAttachments = await db
.select({ id: attachments.id, fileKey: attachments.fileKey }) .select({ id: attachments.id, fileKey: attachments.fileKey })
.from(transactionAttachments) .from(transactionAttachments)

View File

@@ -335,6 +335,12 @@ const baseFields = z.object({
.min(1, "Selecione uma quantidade válida.") .min(1, "Selecione uma quantidade válida.")
.max(60, "Selecione uma quantidade válida.") .max(60, "Selecione uma quantidade válida.")
.optional(), .optional(),
startInstallment: z.coerce
.number()
.int()
.min(1, "Selecione uma parcela válida.")
.max(60, "Selecione uma parcela válida.")
.optional(),
recurrenceCount: z.coerce recurrenceCount: z.coerce
.number() .number()
.int() .int()
@@ -415,6 +421,15 @@ const refineLancamento = (
path: ["installmentCount"], path: ["installmentCount"],
message: "Selecione pelo menos duas parcelas.", message: "Selecione pelo menos duas parcelas.",
}); });
} else if (
data.startInstallment &&
data.startInstallment > data.installmentCount
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["startInstallment"],
message: "A parcela inicial não pode ser maior que o total.",
});
} }
} }
@@ -651,24 +666,27 @@ export const buildTransactionRecords = ({
if (data.condition === "Parcelado") { if (data.condition === "Parcelado") {
const installmentTotal = data.installmentCount ?? 0; const installmentTotal = data.installmentCount ?? 0;
const startInstallment = data.startInstallment ?? 1;
const amountsByShare = shares.map((share) => const amountsByShare = shares.map((share) =>
splitAmount(share.amountCents, installmentTotal), splitAmount(share.amountCents, installmentTotal),
); );
for ( for (
let installment = 0; let index = 0;
installment < installmentTotal; index <= installmentTotal - startInstallment;
installment += 1 index += 1
) { ) {
const installmentPeriod = addMonthsToPeriod(period, installment); const currentInstallment = startInstallment + index;
const installmentPeriod = addMonthsToPeriod(period, index);
const installmentDueDate = dueDate const installmentDueDate = dueDate
? addMonthsToDate(dueDate, installment) ? addMonthsToDate(dueDate, index)
: null; : null;
const splitGroupId = cycleSplitGroupId(); const splitGroupId = cycleSplitGroupId();
shares.forEach((share, shareIndex) => { shares.forEach((share, shareIndex) => {
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0; const amountCents =
const settled = resolveSettledValue(installment); amountsByShare[shareIndex]?.[currentInstallment - 1] ?? 0;
const settled = resolveSettledValue(index);
records.push({ records.push({
...basePayload, ...basePayload,
amount: centsToDecimalString(amountCents * amountSign), amount: centsToDecimalString(amountCents * amountSign),
@@ -677,7 +695,7 @@ export const buildTransactionRecords = ({
period: installmentPeriod, period: installmentPeriod,
isSettled: settled, isSettled: settled,
installmentCount: installmentTotal, installmentCount: installmentTotal,
currentInstallment: installment + 1, currentInstallment,
recurrenceCount: null, recurrenceCount: null,
dueDate: installmentDueDate, dueDate: installmentDueDate,
splitGroupId, splitGroupId,

View File

@@ -8,6 +8,7 @@ import {
transactionAttachments, transactionAttachments,
transactions, transactions,
} from "@/db/schema"; } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { handleActionError } from "@/shared/lib/actions/helpers"; import { handleActionError } from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
@@ -230,13 +231,6 @@ export async function updateTransactionAction(
eq(transactions.id, data.id), eq(transactions.id, data.id),
eq(transactions.userId, user.id), eq(transactions.userId, user.id),
), ),
with: {
category: {
columns: {
name: true,
},
},
},
})) as })) as
| { | {
id: string; id: string;
@@ -248,7 +242,6 @@ export async function updateTransactionAction(
accountId: string | null; accountId: string | null;
cardId: string | null; cardId: string | null;
categoryId: string | null; categoryId: string | null;
category: { name: string } | null;
} }
| undefined; | undefined;
@@ -256,14 +249,17 @@ export async function updateTransactionAction(
return { success: false, error: "Lançamento não encontrado." }; return { success: false, error: "Lançamento não encontrado." };
} }
const categoriasProtegidasEdicao = ["Saldo inicial", "Pagamentos"]; if (existing.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
if (
existing.category?.name &&
categoriasProtegidasEdicao.includes(existing.category.name)
) {
return { return {
success: false, success: false,
error: `Lançamentos com a categoria '${existing.category.name}' não podem ser editados.`, error: "Pagamentos automáticos de fatura não podem ser editados.",
};
}
if (isInitialBalanceTransaction(existing)) {
return {
success: false,
error: "Lançamentos de saldo inicial não podem ser editados.",
}; };
} }
@@ -391,13 +387,6 @@ export async function deleteTransactionAction(
eq(transactions.id, data.id), eq(transactions.id, data.id),
eq(transactions.userId, user.id), eq(transactions.userId, user.id),
), ),
with: {
category: {
columns: {
name: true,
},
},
},
})) as })) as
| { | {
id: string; id: string;
@@ -411,7 +400,6 @@ export async function deleteTransactionAction(
period: string; period: string;
note: string | null; note: string | null;
categoryId: string | null; categoryId: string | null;
category: { name: string } | null;
} }
| undefined; | undefined;
@@ -419,14 +407,17 @@ export async function deleteTransactionAction(
return { success: false, error: "Lançamento não encontrado." }; return { success: false, error: "Lançamento não encontrado." };
} }
const categoriasProtegidasRemocao = ["Saldo inicial", "Pagamentos"]; if (existing.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
if (
existing.category?.name &&
categoriasProtegidasRemocao.includes(existing.category.name)
) {
return { return {
success: false, success: false,
error: `Lançamentos com a categoria '${existing.category.name}' não podem ser removidos.`, error: "Pagamentos automáticos de fatura não podem ser removidos.",
};
}
if (isInitialBalanceTransaction(existing)) {
return {
success: false,
error: "Lançamentos de saldo inicial não podem ser removidos.",
}; };
} }

View File

@@ -1,7 +1,13 @@
"use client"; "use client";
import { useState } from "react";
import { TRANSACTION_CONDITIONS } from "@/features/transactions/lib/constants"; import { TRANSACTION_CONDITIONS } from "@/features/transactions/lib/constants";
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/components/ui/popover";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -14,6 +20,61 @@ import { cn } from "@/shared/utils/ui";
import { ConditionSelectContent } from "../../select-items"; import { ConditionSelectContent } from "../../select-items";
import type { ConditionSectionProps } from "./transaction-dialog-types"; import type { ConditionSectionProps } from "./transaction-dialog-types";
function InlineStartInstallmentPicker({
value,
options,
onChange,
}: {
value: string;
options: number[];
onChange: (value: string) => void;
}) {
const [open, setOpen] = useState(false);
const selected = Number(value || "1");
const selectedLabel =
!Number.isNaN(selected) && selected > 0
? `${selected}ª parcela`
: "1ª parcela";
const disabled = options.length === 0;
return (
<div className="ml-1">
<span className="text-xs text-muted-foreground">Começar em </span>
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="cursor-pointer text-xs text-primary underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:text-muted-foreground disabled:hover:no-underline"
disabled={disabled}
>
{selectedLabel}
</button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start">
<div className="max-h-56 overflow-y-auto">
{options.map((option) => (
<button
key={option}
type="button"
className={cn(
"flex w-full items-center rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground",
option === selected && "font-medium text-primary",
)}
onClick={() => {
onChange(String(option));
setOpen(false);
}}
>
{option}ª parcela
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
);
}
export function ConditionSection({ export function ConditionSection({
formState, formState,
onFieldChange, onFieldChange,
@@ -37,11 +98,17 @@ export function ConditionSection({
const installmentSummary = const installmentSummary =
showInstallments && showInstallments &&
formState.installmentCount && formState.installmentCount &&
amount &&
!Number.isNaN(installmentCount) && !Number.isNaN(installmentCount) &&
installmentCount > 0 installmentCount > 0
? getInstallmentLabel(installmentCount) ? getInstallmentLabel(installmentCount)
: null; : null;
const startInstallmentOptions =
showInstallments &&
formState.installmentCount &&
!Number.isNaN(installmentCount) &&
installmentCount > 0
? Array.from({ length: installmentCount }, (_, index) => index + 1)
: [];
return ( return (
<div className="flex w-full flex-col gap-2 md:flex-row"> <div className="flex w-full flex-col gap-2 md:flex-row">
@@ -96,6 +163,11 @@ export function ConditionSection({
})} })}
</SelectContent> </SelectContent>
</Select> </Select>
<InlineStartInstallmentPicker
value={formState.startInstallment}
options={startInstallmentOptions}
onChange={(value) => onFieldChange("startInstallment", value)}
/>
</div> </div>
) : null} ) : null}

View File

@@ -17,6 +17,7 @@ export interface TransactionDialogProps {
estabelecimentos: string[]; estabelecimentos: string[];
transaction?: TransactionItem; transaction?: TransactionItem;
defaultPeriod?: string; defaultPeriod?: string;
defaultAccountId?: string | null;
defaultCardId?: string | null; defaultCardId?: string | null;
defaultPaymentMethod?: string | null; defaultPaymentMethod?: string | null;
defaultPurchaseDate?: string | null; defaultPurchaseDate?: string | null;

View File

@@ -65,6 +65,7 @@ export function TransactionDialog({
estabelecimentos, estabelecimentos,
transaction, transaction,
defaultPeriod, defaultPeriod,
defaultAccountId,
defaultCardId, defaultCardId,
defaultPaymentMethod, defaultPaymentMethod,
defaultPurchaseDate, defaultPurchaseDate,
@@ -88,6 +89,7 @@ export function TransactionDialog({
const [formState, setFormState] = useState<FormState>(() => const [formState, setFormState] = useState<FormState>(() =>
buildTransactionInitialState(transaction, defaultPayerId, defaultPeriod, { buildTransactionInitialState(transaction, defaultPayerId, defaultPeriod, {
defaultAccountId,
defaultCardId, defaultCardId,
defaultPaymentMethod, defaultPaymentMethod,
defaultPurchaseDate, defaultPurchaseDate,
@@ -112,6 +114,7 @@ export function TransactionDialog({
defaultPayerId, defaultPayerId,
defaultPeriod, defaultPeriod,
{ {
defaultAccountId,
defaultCardId, defaultCardId,
defaultPaymentMethod, defaultPaymentMethod,
defaultPurchaseDate, defaultPurchaseDate,
@@ -151,6 +154,7 @@ export function TransactionDialog({
transaction, transaction,
defaultPayerId, defaultPayerId,
defaultPeriod, defaultPeriod,
defaultAccountId,
defaultCardId, defaultCardId,
defaultPaymentMethod, defaultPaymentMethod,
defaultPurchaseDate, defaultPurchaseDate,
@@ -327,6 +331,12 @@ export function TransactionDialog({
formState.condition === "Parcelado" && formState.installmentCount formState.condition === "Parcelado" && formState.installmentCount
? Number(formState.installmentCount) ? Number(formState.installmentCount)
: undefined, : undefined,
startInstallment:
mode === "create" &&
formState.condition === "Parcelado" &&
formState.startInstallment
? Number(formState.startInstallment)
: undefined,
recurrenceCount: recurrenceCount:
formState.condition === "Recorrente" && formState.recurrenceCount formState.condition === "Recorrente" && formState.recurrenceCount
? Number(formState.recurrenceCount) ? Number(formState.recurrenceCount)

View File

@@ -63,6 +63,7 @@ interface TransactionsPageProps {
categoryFilterOptions: TransactionFilterOption[]; categoryFilterOptions: TransactionFilterOption[];
accountCardFilterOptions: AccountCardFilterOption[]; accountCardFilterOptions: AccountCardFilterOption[];
selectedPeriod: string; selectedPeriod: string;
defaultAccountId?: string | null;
estabelecimentos: string[]; estabelecimentos: string[];
allowCreate?: boolean; allowCreate?: boolean;
noteAsColumn?: boolean; noteAsColumn?: boolean;
@@ -96,6 +97,7 @@ export function TransactionsPage({
categoryFilterOptions, categoryFilterOptions,
accountCardFilterOptions, accountCardFilterOptions,
selectedPeriod, selectedPeriod,
defaultAccountId,
estabelecimentos, estabelecimentos,
allowCreate = true, allowCreate = true,
noteAsColumn = false, noteAsColumn = false,
@@ -562,6 +564,7 @@ export function TransactionsPage({
categoryOptions={categoryOptions} categoryOptions={categoryOptions}
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
defaultAccountId={defaultAccountId}
defaultCardId={defaultCardId} defaultCardId={defaultCardId}
defaultPaymentMethod={defaultPaymentMethod} defaultPaymentMethod={defaultPaymentMethod}
lockCardSelection={lockCardSelection} lockCardSelection={lockCardSelection}
@@ -585,6 +588,7 @@ export function TransactionsPage({
categoryOptions={categoryOptions} categoryOptions={categoryOptions}
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
defaultAccountId={defaultAccountId}
defaultCardId={defaultCardId} defaultCardId={defaultCardId}
defaultPaymentMethod={defaultPaymentMethod} defaultPaymentMethod={defaultPaymentMethod}
lockCardSelection={lockCardSelection} lockCardSelection={lockCardSelection}
@@ -648,6 +652,7 @@ export function TransactionsPage({
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
transaction={transactionToCopy ?? undefined} transaction={transactionToCopy ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
defaultAccountId={defaultAccountId}
maxSizeMb={attachmentMaxSizeMb} maxSizeMb={attachmentMaxSizeMb}
/> />
@@ -669,6 +674,7 @@ export function TransactionsPage({
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
transaction={transactionToImport ?? undefined} transaction={transactionToImport ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
defaultAccountId={defaultAccountId}
isImporting={true} isImporting={true}
maxSizeMb={attachmentMaxSizeMb} maxSizeMb={attachmentMaxSizeMb}
/> />
@@ -697,6 +703,7 @@ export function TransactionsPage({
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
transaction={selectedTransaction ?? undefined} transaction={selectedTransaction ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
defaultAccountId={defaultAccountId}
onBulkEditRequest={handleBulkEditRequest} onBulkEditRequest={handleBulkEditRequest}
onSplitEditRequest={handleSplitEditRequest} onSplitEditRequest={handleSplitEditRequest}
maxSizeMb={attachmentMaxSizeMb} maxSizeMb={attachmentMaxSizeMb}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { RiCalendarCheckLine, RiCloseLine, RiEyeLine } from "@remixicon/react"; import { RiCalendarCheckLine } from "@remixicon/react";
import { format } from "date-fns"; import { format } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import { useTransition } from "react"; import { useTransition } from "react";
@@ -164,16 +164,14 @@ export function AnticipationCard({
onClick={handleViewLancamento} onClick={handleViewLancamento}
disabled={isPending} disabled={isPending}
> >
<RiEyeLine className="mr-2 size-4" /> Cancelar
Ver Lançamento
</Button> </Button>
{canCancel && ( {canCancel && (
<ConfirmActionDialog <ConfirmActionDialog
trigger={ trigger={
<Button variant="destructive" size="sm" disabled={isPending}> <Button variant="destructive" size="sm" disabled={isPending}>
<RiCloseLine className="mr-2 size-4" /> Desfazer Antecipação
Cancelar Antecipação
</Button> </Button>
} }
title="Cancelar antecipação?" title="Cancelar antecipação?"

View File

@@ -426,7 +426,7 @@ function buildColumns({
const initial = displayName.charAt(0).toUpperCase() || "?"; const initial = displayName.charAt(0).toUpperCase() || "?";
const content = ( const content = (
<> <>
<Avatar className="size-7"> <Avatar className="size-8">
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} /> <AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
<AvatarFallback className="text-xs font-medium uppercase"> <AvatarFallback className="text-xs font-medium uppercase">
{initial} {initial}
@@ -477,15 +477,21 @@ function buildColumns({
const content = ( const content = (
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
{logoSrc && ( {logoSrc && (
<Image <Avatar className="size-8">
src={logoSrc} <AvatarImage src={logoSrc} alt={`Logo de ${label}`} />
alt={`Logo de ${label}`} <AvatarFallback className="text-xs font-medium uppercase">
width={30} {label}
height={30} </AvatarFallback>
className="rounded-full" </Avatar>
/>
)} )}
<span className="truncate">{label}</span> <span
className={cn(
"truncate underline-offset-2",
isOwnData && href && "group-hover:underline",
)}
>
{label}
</span>
</span> </span>
); );
@@ -503,7 +509,7 @@ function buildColumns({
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Link href={href} className="hover:underline"> <Link href={href} className="group">
{content} {content}
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
@@ -654,14 +660,14 @@ function buildColumns({
Editar Editar
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{row.original.categoriaName !== "Pagamentos" && {!row.original.readonly &&
row.original.userId === currentUserId && ( row.original.userId === currentUserId && (
<DropdownMenuItem onSelect={() => handleCopy(row.original)}> <DropdownMenuItem onSelect={() => handleCopy(row.original)}>
<RiFileCopyLine className="size-4" /> <RiFileCopyLine className="size-4" />
Copiar Copiar
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{row.original.categoriaName !== "Pagamentos" && {!row.original.readonly &&
row.original.userId !== currentUserId && ( row.original.userId !== currentUserId && (
<DropdownMenuItem onSelect={() => handleImport(row.original)}> <DropdownMenuItem onSelect={() => handleImport(row.original)}>
<RiFileCopyLine className="size-4" /> <RiFileCopyLine className="size-4" />

View File

@@ -174,7 +174,7 @@ export function TransactionsTable({
: getPaginationRowModel(), : getPaginationRowModel(),
manualPagination: isServerPaginated, manualPagination: isServerPaginated,
pageCount: serverPagination?.totalPages, pageCount: serverPagination?.totalPages,
enableRowSelection: true, enableRowSelection: (row) => !row.original.readonly,
}); });
const rowModel = table.getRowModel(); const rowModel = table.getRowModel();

View File

@@ -80,6 +80,7 @@ export type TransactionFormState = {
cardId: string | undefined; cardId: string | undefined;
categoryId: string | undefined; categoryId: string | undefined;
installmentCount: string; installmentCount: string;
startInstallment: string;
recurrenceCount: string; recurrenceCount: string;
dueDate: string; dueDate: string;
boletoPaymentDate: string; boletoPaymentDate: string;
@@ -92,6 +93,7 @@ export type TransactionFormState = {
*/ */
type TransactionFormOverrides = { type TransactionFormOverrides = {
defaultCardId?: string | null; defaultCardId?: string | null;
defaultAccountId?: string | null;
defaultPaymentMethod?: string | null; defaultPaymentMethod?: string | null;
defaultPurchaseDate?: string | null; defaultPurchaseDate?: string | null;
defaultName?: string | null; defaultName?: string | null;
@@ -178,7 +180,9 @@ export function buildTransactionInitialState(
? undefined ? undefined
: isImporting : isImporting
? undefined ? undefined
: (transaction?.accountId ?? undefined), : (transaction?.accountId ??
overrides?.defaultAccountId ??
undefined),
cardId: cardId:
paymentMethod === "Cartão de crédito" paymentMethod === "Cartão de crédito"
? isImporting ? isImporting
@@ -191,6 +195,12 @@ export function buildTransactionInitialState(
installmentCount: transaction?.installmentCount installmentCount: transaction?.installmentCount
? String(transaction.installmentCount) ? String(transaction.installmentCount)
: "", : "",
startInstallment:
isImporting &&
transaction?.condition === "Parcelado" &&
transaction.currentInstallment
? String(transaction.currentInstallment)
: "1",
recurrenceCount: transaction?.recurrenceCount recurrenceCount: transaction?.recurrenceCount
? String(transaction.recurrenceCount) ? String(transaction.recurrenceCount)
: "", : "",
@@ -252,12 +262,25 @@ export function applyFieldDependencies(
if (key === "condition" && typeof value === "string") { if (key === "condition" && typeof value === "string") {
if (value !== "Parcelado") { if (value !== "Parcelado") {
updates.installmentCount = ""; updates.installmentCount = "";
updates.startInstallment = "1";
} }
if (value !== "Recorrente") { if (value !== "Recorrente") {
updates.recurrenceCount = ""; updates.recurrenceCount = "";
} }
} }
if (key === "installmentCount" && typeof value === "string" && value) {
const nextCount = Number.parseInt(value, 10);
const currentStart = Number.parseInt(currentState.startInstallment, 10);
if (
!Number.isNaN(nextCount) &&
!Number.isNaN(currentStart) &&
currentStart > nextCount
) {
updates.startInstallment = String(nextCount);
}
}
// When payment method changes, adjust related fields // When payment method changes, adjust related fields
if (key === "paymentMethod" && typeof value === "string") { if (key === "paymentMethod" && typeof value === "string") {
if (value === "Cartão de crédito") { if (value === "Cartão de crédito") {

View File

@@ -27,7 +27,13 @@ import {
TRANSACTION_CONDITIONS, TRANSACTION_CONDITIONS,
TRANSACTION_TYPES, TRANSACTION_TYPES,
} from "@/features/transactions/lib/constants"; } from "@/features/transactions/lib/constants";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants"; import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE,
INITIAL_BALANCE_PAYMENT_METHOD,
INITIAL_BALANCE_TRANSACTION_TYPE,
} from "@/shared/lib/accounts/constants";
import { import {
PAYER_ROLE_ADMIN, PAYER_ROLE_ADMIN,
PAYER_ROLE_THIRD_PARTY, PAYER_ROLE_THIRD_PARTY,
@@ -551,8 +557,10 @@ export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
hasAttachments: item.hasAttachments ?? false, hasAttachments: item.hasAttachments ?? false,
readonly: readonly:
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) || Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
item.category?.name === "Saldo inicial" || (item.note === INITIAL_BALANCE_NOTE &&
item.category?.name === "Pagamentos", item.transactionType === INITIAL_BALANCE_TRANSACTION_TYPE &&
item.condition === INITIAL_BALANCE_CONDITION &&
item.paymentMethod === INITIAL_BALANCE_PAYMENT_METHOD),
})); }));
const sortByLabel = <T extends { label: string }>(items: T[]) => const sortByLabel = <T extends { label: string }>(items: T[]) =>