fix(lançamentos): reforçar validações e revisar formulário

This commit is contained in:
Felipe Coutinho
2026-04-03 18:10:50 +00:00
parent 549a5bdba1
commit 1b4dfaaba7
12 changed files with 678 additions and 461 deletions

View File

@@ -16,6 +16,7 @@ import {
} from "@/shared/lib/payers/notifications"; } from "@/shared/lib/payers/notifications";
import type { ActionResult } from "@/shared/lib/types/actions"; import type { ActionResult } from "@/shared/lib/types/actions";
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date"; import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
import { addMonthsToPeriod, parsePeriod } from "@/shared/utils/period";
import { import {
centsToDecimalString, centsToDecimalString,
type DeleteBulkInput, type DeleteBulkInput,
@@ -26,6 +27,8 @@ import {
fetchOwnedCardIds, fetchOwnedCardIds,
fetchOwnedCategoryIds, fetchOwnedCategoryIds,
fetchOwnedPayerIds, fetchOwnedPayerIds,
formatPaidInvoicePeriods,
getPaidInvoicePeriods,
type MassAddInput, type MassAddInput,
massAddSchema, massAddSchema,
resolvePeriod, resolvePeriod,
@@ -37,6 +40,12 @@ import {
validateAllOwnership, validateAllOwnership,
} from "./core"; } from "./core";
const getPeriodOffset = (basePeriod: string, targetPeriod: string) => {
const base = parsePeriod(basePeriod);
const target = parsePeriod(targetPeriod);
return (target.year - base.year) * 12 + (target.month - base.month);
};
export async function deleteTransactionBulkAction( export async function deleteTransactionBulkAction(
input: DeleteBulkInput, input: DeleteBulkInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
@@ -164,8 +173,10 @@ export async function updateTransactionBulkAction(
period: true, period: true,
condition: true, condition: true,
transactionType: true, transactionType: true,
paymentMethod: true,
purchaseDate: true, purchaseDate: true,
payerId: true, payerId: true,
cardId: true,
}, },
where: and( where: and(
eq(transactions.id, data.id), eq(transactions.id, data.id),
@@ -204,6 +215,8 @@ export async function updateTransactionBulkAction(
const hasDueDateUpdate = data.dueDate !== undefined; const hasDueDateUpdate = data.dueDate !== undefined;
const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined; const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined;
const hasPurchaseDateUpdate = data.purchaseDate !== undefined;
const hasPeriodUpdate = data.period !== undefined;
const baseDueDate = const baseDueDate =
hasDueDateUpdate && data.dueDate hasDueDateUpdate && data.dueDate
@@ -218,8 +231,13 @@ export async function updateTransactionBulkAction(
: hasBoletoPaymentDateUpdate : hasBoletoPaymentDateUpdate
? null ? null
: undefined; : undefined;
const referencePurchaseDate = existing.purchaseDate ?? null;
const basePurchaseDate = existing.purchaseDate ?? null; const basePurchaseDate =
hasPurchaseDateUpdate && data.purchaseDate
? parseLocalDateString(data.purchaseDate)
: undefined;
const basePeriod = hasPeriodUpdate ? data.period : undefined;
const targetCardId = data.cardId ?? existing.cardId ?? null;
const buildDueDateForRecord = (recordPurchaseDate: Date | null) => { const buildDueDateForRecord = (recordPurchaseDate: Date | null) => {
if (!hasDueDateUpdate) { if (!hasDueDateUpdate) {
@@ -230,18 +248,48 @@ export async function updateTransactionBulkAction(
return null; return null;
} }
if (!basePurchaseDate || !recordPurchaseDate) { if (!referencePurchaseDate || !recordPurchaseDate) {
return baseDueDate; return baseDueDate;
} }
const monthDiff = const monthDiff =
(recordPurchaseDate.getFullYear() - basePurchaseDate.getFullYear()) * (recordPurchaseDate.getFullYear() -
referencePurchaseDate.getFullYear()) *
12 + 12 +
(recordPurchaseDate.getMonth() - basePurchaseDate.getMonth()); (recordPurchaseDate.getMonth() - referencePurchaseDate.getMonth());
return addMonthsToDate(baseDueDate, monthDiff); return addMonthsToDate(baseDueDate, monthDiff);
}; };
const buildPurchaseDateForRecord = (record: {
purchaseDate: Date | null;
period: string;
}) => {
if (!basePurchaseDate) {
return undefined;
}
if (existing.condition === "Recorrente" && existing.period) {
const offset = getPeriodOffset(existing.period, record.period);
return addMonthsToDate(basePurchaseDate, offset);
}
return basePurchaseDate;
};
const buildPeriodForRecord = (record: { period: string }) => {
if (!basePeriod) {
return undefined;
}
if (existing.period) {
const offset = getPeriodOffset(existing.period, record.period);
return addMonthsToPeriod(basePeriod, offset);
}
return basePeriod;
};
const serializeDateKey = (value: Date | null | undefined) => { const serializeDateKey = (value: Date | null | undefined) => {
if (value === undefined) { if (value === undefined) {
return "undefined"; return "undefined";
@@ -252,8 +300,51 @@ export async function updateTransactionBulkAction(
return String(value.getTime()); return String(value.getTime());
}; };
const ensureTargetInvoicesAreOpen = async (
records: Array<{ period: string }>,
) => {
if (
existing.paymentMethod !== "Cartão de crédito" ||
!targetCardId ||
(!hasPurchaseDateUpdate &&
!hasPeriodUpdate &&
data.cardId === undefined)
) {
return null;
}
const movedPeriods = new Set<string>();
for (const record of records) {
const targetPeriodForRecord =
buildPeriodForRecord(record) ?? record.period;
const cardChanged = targetCardId !== existing.cardId;
const periodChanged = targetPeriodForRecord !== record.period;
if (cardChanged || periodChanged) {
movedPeriods.add(targetPeriodForRecord);
}
}
if (movedPeriods.size === 0) {
return null;
}
const paidPeriods = await getPaidInvoicePeriods(user.id, targetCardId, [
...movedPeriods,
]);
if (paidPeriods.length === 0) {
return null;
}
return `As faturas dos meses ${formatPaidInvoicePeriods(
paidPeriods,
)} já estão pagas. Desfaça o pagamento antes de mover este lançamento.`;
};
const applyUpdates = async ( const applyUpdates = async (
records: Array<{ id: string; purchaseDate: Date | null }>, records: Array<{ id: string; purchaseDate: Date | null; period: string }>,
) => { ) => {
if (records.length === 0) { if (records.length === 0) {
return; return;
@@ -269,10 +360,20 @@ export async function updateTransactionBulkAction(
for (const record of records) { for (const record of records) {
const dueDateForRecord = buildDueDateForRecord(record.purchaseDate); const dueDateForRecord = buildDueDateForRecord(record.purchaseDate);
const purchaseDateForRecord = buildPurchaseDateForRecord(record);
const periodForRecord = buildPeriodForRecord(record);
const perRecordPayload: Record<string, unknown> = { const perRecordPayload: Record<string, unknown> = {
...baseUpdatePayload, ...baseUpdatePayload,
}; };
if (purchaseDateForRecord !== undefined) {
perRecordPayload.purchaseDate = purchaseDateForRecord;
}
if (periodForRecord !== undefined) {
perRecordPayload.period = periodForRecord;
}
if (dueDateForRecord !== undefined) { if (dueDateForRecord !== undefined) {
perRecordPayload.dueDate = dueDateForRecord; perRecordPayload.dueDate = dueDateForRecord;
} }
@@ -282,6 +383,8 @@ export async function updateTransactionBulkAction(
} }
const groupKey = [ const groupKey = [
serializeDateKey(purchaseDateForRecord),
periodForRecord ?? "undefined",
serializeDateKey(dueDateForRecord), serializeDateKey(dueDateForRecord),
serializeDateKey( serializeDateKey(
hasBoletoPaymentDateUpdate hasBoletoPaymentDateUpdate
@@ -318,12 +421,19 @@ export async function updateTransactionBulkAction(
}; };
if (data.scope === "current") { if (data.scope === "current") {
await applyUpdates([ const currentRecords = [
{ {
id: data.id, id: data.id,
purchaseDate: existing.purchaseDate ?? null, purchaseDate: existing.purchaseDate ?? null,
period: existing.period,
}, },
]); ];
const invoiceError = await ensureTargetInvoicesAreOpen(currentRecords);
if (invoiceError) {
return { success: false, error: invoiceError };
}
await applyUpdates(currentRecords);
revalidate(user.id); revalidate(user.id);
return { success: true, message: "Lançamento atualizado com sucesso." }; return { success: true, message: "Lançamento atualizado com sucesso." };
@@ -338,7 +448,7 @@ export async function updateTransactionBulkAction(
} }
const periodLancamentos = await db.query.transactions.findMany({ const periodLancamentos = await db.query.transactions.findMany({
columns: { id: true, purchaseDate: true }, columns: { id: true, purchaseDate: true, period: true },
where: and( where: and(
eq(transactions.seriesId, existing.seriesId), eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id), eq(transactions.userId, user.id),
@@ -347,10 +457,16 @@ export async function updateTransactionBulkAction(
orderBy: asc(transactions.purchaseDate), orderBy: asc(transactions.purchaseDate),
}); });
const invoiceError = await ensureTargetInvoicesAreOpen(periodLancamentos);
if (invoiceError) {
return { success: false, error: invoiceError };
}
await applyUpdates( await applyUpdates(
periodLancamentos.map((item: (typeof periodLancamentos)[number]) => ({ periodLancamentos.map((item: (typeof periodLancamentos)[number]) => ({
id: item.id, id: item.id,
purchaseDate: item.purchaseDate ?? null, purchaseDate: item.purchaseDate ?? null,
period: item.period,
})), })),
); );
@@ -370,6 +486,7 @@ export async function updateTransactionBulkAction(
columns: { columns: {
id: true, id: true,
purchaseDate: true, purchaseDate: true,
period: true,
}, },
where: and( where: and(
eq(transactions.seriesId, existing.seriesId), eq(transactions.seriesId, existing.seriesId),
@@ -380,10 +497,16 @@ export async function updateTransactionBulkAction(
orderBy: asc(transactions.purchaseDate), orderBy: asc(transactions.purchaseDate),
}); });
const invoiceError = await ensureTargetInvoicesAreOpen(futureLancamentos);
if (invoiceError) {
return { success: false, error: invoiceError };
}
await applyUpdates( await applyUpdates(
futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({ futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({
id: item.id, id: item.id,
purchaseDate: item.purchaseDate ?? null, purchaseDate: item.purchaseDate ?? null,
period: item.period,
})), })),
); );
@@ -399,6 +522,7 @@ export async function updateTransactionBulkAction(
columns: { columns: {
id: true, id: true,
purchaseDate: true, purchaseDate: true,
period: true,
}, },
where: and( where: and(
eq(transactions.seriesId, existing.seriesId), eq(transactions.seriesId, existing.seriesId),
@@ -408,10 +532,16 @@ export async function updateTransactionBulkAction(
orderBy: asc(transactions.purchaseDate), orderBy: asc(transactions.purchaseDate),
}); });
const invoiceError = await ensureTargetInvoicesAreOpen(allLancamentos);
if (invoiceError) {
return { success: false, error: invoiceError };
}
await applyUpdates( await applyUpdates(
allLancamentos.map((item: (typeof allLancamentos)[number]) => ({ allLancamentos.map((item: (typeof allLancamentos)[number]) => ({
id: item.id, id: item.id,
purchaseDate: item.purchaseDate ?? null, purchaseDate: item.purchaseDate ?? null,
period: item.period,
})), })),
); );

View File

@@ -4,6 +4,7 @@ import {
cards, cards,
categories, categories,
financialAccounts, financialAccounts,
invoices,
payers, payers,
type transactions, type transactions,
} from "@/db/schema"; } from "@/db/schema";
@@ -20,9 +21,10 @@ import {
} from "@/shared/lib/accounts/constants"; } from "@/shared/lib/accounts/constants";
import { revalidateForEntity } from "@/shared/lib/actions/helpers"; import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common"; import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date"; import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
import { addMonthsToPeriod } from "@/shared/utils/period"; import { addMonthsToPeriod, MONTH_NAMES } from "@/shared/utils/period";
// ============================================================================ // ============================================================================
// Authorization Validation Functions // Authorization Validation Functions
@@ -662,6 +664,43 @@ export const buildLancamentoRecords = ({
return records; return records;
}; };
export const formatPaidInvoicePeriods = (periods: string[]) =>
periods
.map((period) => {
const [year, month] = period.split("-");
const monthName = MONTH_NAMES[Number(month) - 1] ?? month;
return `${monthName}/${year}`;
})
.join(", ");
export async function getPaidInvoicePeriods(
userId: string,
cardId: string,
periods: string[],
) {
if (periods.length === 0) {
return [];
}
const rows = await db.query.invoices.findMany({
columns: { period: true },
where: and(
eq(invoices.userId, userId),
eq(invoices.cardId, cardId),
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
inArray(invoices.period, periods),
),
});
return [
...new Set(
rows
.map((row) => row.period)
.filter((period): period is string => Boolean(period)),
),
];
}
export const deleteBulkSchema = z.object({ export const deleteBulkSchema = z.object({
id: uuidSchema("Lançamento"), id: uuidSchema("Lançamento"),
scope: z.enum(["current", "period", "future", "all"], { scope: z.enum(["current", "period", "future", "all"], {
@@ -676,6 +715,20 @@ export const updateBulkSchema = z.object({
scope: z.enum(["current", "period", "future", "all"], { scope: z.enum(["current", "period", "future", "all"], {
message: "Escopo de ação inválido.", message: "Escopo de ação inválido.",
}), }),
purchaseDate: z
.string()
.trim()
.refine((value) => !value || isValidDateInput(value), {
message: "Data da transação inválida.",
})
.optional(),
period: z
.string()
.trim()
.regex(/^(\d{4})-(\d{2})$/, {
message: "Selecione um período válido.",
})
.optional(),
name: z name: z
.string({ message: "Informe o estabelecimento." }) .string({ message: "Informe o estabelecimento." })
.trim() .trim()

View File

@@ -1,18 +1,16 @@
"use server"; "use server";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { import {
attachments, attachments,
financialAccounts, financialAccounts,
invoices,
transactionAttachments, transactionAttachments,
transactions, transactions,
} from "@/db/schema"; } from "@/db/schema";
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";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import { import {
buildEntriesByPayer, buildEntriesByPayer,
sendPayerAutoEmails, sendPayerAutoEmails,
@@ -23,7 +21,6 @@ import {
getBusinessTodayDate, getBusinessTodayDate,
parseLocalDateString, parseLocalDateString,
} from "@/shared/utils/date"; } from "@/shared/utils/date";
import { MONTH_NAMES } from "@/shared/utils/period";
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments"; import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
import { import {
buildLancamentoRecords, buildLancamentoRecords,
@@ -33,6 +30,8 @@ import {
createSchema, createSchema,
type DeleteInput, type DeleteInput,
deleteSchema, deleteSchema,
formatPaidInvoicePeriods,
getPaidInvoicePeriods,
isInitialBalanceLancamento, isInitialBalanceLancamento,
resolvePeriod, resolvePeriod,
resolveUserLabel, resolveUserLabel,
@@ -118,27 +117,18 @@ export async function createTransactionAction(
), ),
]; ];
const paidInvoices = await db.query.invoices.findMany({ const paidPeriods = await getPaidInvoicePeriods(
columns: { period: true }, user.id,
where: and( data.cardId,
eq(invoices.userId, user.id), uniquePeriods,
eq(invoices.cardId, data.cardId), );
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
inArray(invoices.period, uniquePeriods),
),
});
if (paidInvoices.length > 0) { if (paidPeriods.length > 0) {
const labels = paidInvoices
.map((inv) => {
const [year, month] = (inv.period ?? "").split("-");
const monthName = MONTH_NAMES[Number(month) - 1] ?? month;
return `${monthName}/${year}`;
})
.join(", ");
return { return {
success: false, success: false,
error: `As faturas dos meses ${labels} já estão pagas. Desfaça o pagamento antes de adicionar este lançamento.`, error: `As faturas dos meses ${formatPaidInvoicePeriods(
paidPeriods,
)} já estão pagas. Desfaça o pagamento antes de adicionar este lançamento.`,
} as ActionResult<{ ids: string[] }>; } as ActionResult<{ ids: string[] }>;
} }
} }
@@ -204,10 +194,12 @@ export async function updateTransactionAction(
columns: { columns: {
id: true, id: true,
note: true, note: true,
period: true,
transactionType: true, transactionType: true,
condition: true, condition: true,
paymentMethod: true, paymentMethod: true,
accountId: true, accountId: true,
cardId: true,
categoryId: true, categoryId: true,
}, },
where: and( where: and(
@@ -225,10 +217,12 @@ export async function updateTransactionAction(
| { | {
id: string; id: string;
note: string | null; note: string | null;
period: string;
transactionType: string; transactionType: string;
condition: string; condition: string;
paymentMethod: string; paymentMethod: string;
accountId: string | null; accountId: string | null;
cardId: string | null;
categoryId: string | null; categoryId: string | null;
category: { name: string } | null; category: { name: string } | null;
} }
@@ -264,6 +258,25 @@ export async function updateTransactionAction(
? parseLocalDateString(data.boletoPaymentDate) ? parseLocalDateString(data.boletoPaymentDate)
: getBusinessTodayDate() : getBusinessTodayDate()
: null; : null;
const targetCardId = data.cardId ?? existing.cardId;
const movedInvoice =
data.paymentMethod === "Cartão de crédito" &&
targetCardId &&
(targetCardId !== existing.cardId || period !== existing.period);
if (movedInvoice) {
const paidPeriods = await getPaidInvoicePeriods(user.id, targetCardId, [
period,
]);
if (paidPeriods.length > 0) {
return {
success: false,
error: `As faturas dos meses ${formatPaidInvoicePeriods(
paidPeriods,
)} já estão pagas. Desfaça o pagamento antes de mover este lançamento.`,
};
}
}
await db await db
.update(transactions) .update(transactions)

View File

@@ -10,14 +10,16 @@ import {
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
interface AttachmentFilePickerProps { interface AttachmentFilePickerProps {
file: File | null; files: File[];
onChange: (file: File | null) => void; onAdd: (file: File) => void;
onRemove: (file: File) => void;
maxSizeMb?: number; maxSizeMb?: number;
} }
export function AttachmentFilePicker({ export function AttachmentFilePicker({
file, files,
onChange, onAdd,
onRemove,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB, maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
}: AttachmentFilePickerProps) { }: AttachmentFilePickerProps) {
const maxFileSizeBytes = maxSizeMb * 1024 * 1024; const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
@@ -45,12 +47,12 @@ export function AttachmentFilePicker({
return; return;
} }
onChange(selected); onAdd(selected);
} }
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs">Anexo</p> <p className="text-xs font-medium">Anexos</p>
<input <input
ref={inputRef} ref={inputRef}
type="file" type="file"
@@ -59,37 +61,44 @@ export function AttachmentFilePicker({
onChange={handleFileChange} onChange={handleFileChange}
/> />
{file ? ( {files.length > 0 && (
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm"> <div className="space-y-1.5">
<RiAttachment2 className="size-4 shrink-0 text-muted-foreground" /> {files.map((file) => (
<span className="min-w-0 flex-1 truncate" title={file.name}> <div
{file.name} key={`${file.name}-${file.size}-${file.lastModified}`}
</span> className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm"
<Button >
type="button" <RiAttachment2 className="size-4 shrink-0 text-muted-foreground" />
variant="ghost" <span className="min-w-0 flex-1 truncate" title={file.name}>
size="icon" {file.name}
className="size-6 shrink-0" </span>
onClick={() => onChange(null)} <Button
> type="button"
<RiCloseLine className="size-4" /> variant="ghost"
</Button> size="icon"
className="size-6 shrink-0"
onClick={() => onRemove(file)}
>
<RiCloseLine className="size-4" />
</Button>
</div>
))}
</div> </div>
) : (
<button
type="button"
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
onClick={() => inputRef.current?.click()}
>
<span className="flex items-center gap-2">
<RiAttachment2 className="size-4" />
Adicionar anexo
</span>
<span className="text-xs">
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
</span>
</button>
)} )}
<button
type="button"
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
onClick={() => inputRef.current?.click()}
>
<span className="flex items-center gap-2">
<RiAttachment2 className="size-4" />
Adicionar anexo
</span>
<span className="text-xs">
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
</span>
</button>
</div> </div>
); );
} }

View File

@@ -67,7 +67,7 @@ export function CategorySection({
> >
<Label htmlFor="categoria">Categoria</Label> <Label htmlFor="categoria">Categoria</Label>
<Select <Select
value={formState.categoryId} value={formState.categoryId ?? ""}
onValueChange={(value) => onFieldChange("categoryId", value)} onValueChange={(value) => onFieldChange("categoryId", value)}
> >
<SelectTrigger id="categoria" className="w-full"> <SelectTrigger id="categoria" className="w-full">

View File

@@ -1,5 +1,7 @@
"use client"; "use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { RiSliceFill } from "@remixicon/react";
import { CurrencyInput } from "@/shared/components/ui/currency-input"; import { CurrencyInput } from "@/shared/components/ui/currency-input";
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
import { import {
@@ -9,6 +11,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/shared/components/ui/select"; } from "@/shared/components/ui/select";
import { cn } from "@/shared/utils/ui";
import { PayerSelectContent } from "../../select-items"; import { PayerSelectContent } from "../../select-items";
import type { PayerSectionProps } from "./transaction-dialog-types"; import type { PayerSectionProps } from "./transaction-dialog-types";
@@ -34,75 +37,59 @@ export function PayerSection({
}; };
return ( return (
<div className="flex w-full flex-col gap-2 md:flex-row"> <div className="space-y-3">
<div className="w-full space-y-1"> <div
<Label htmlFor="payer">Pagador</Label> className={cn(
<div className="flex gap-2"> "flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors",
<Select formState.isSplit
value={formState.payerId} ? "border-primary/20 bg-primary/5"
onValueChange={(value) => onFieldChange("payerId", value)} : "border-border bg-transparent",
> )}
<SelectTrigger >
id="payer" <div className="flex items-center gap-2">
className={formState.isSplit ? "min-w-0 flex-1" : "w-full"} <div>
> <p className="text-sm text-foreground">Dividir lançamento</p>
<SelectValue placeholder="Selecione"> <p className="text-xs text-muted-foreground">
{formState.payerId && Atribuir parte do valor a outro pagador.
(() => { </p>
const selectedOption = payerOptions.find( </div>
(opt) => opt.value === formState.payerId,
);
return selectedOption ? (
<PayerSelectContent
label={selectedOption.label}
avatarUrl={selectedOption.avatarUrl}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{payerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PayerSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
{formState.isSplit && (
<CurrencyInput
value={formState.primarySplitAmount}
onValueChange={handlePrimaryAmountChange}
placeholder="R$ 0,00"
className="h-9 w-[45%] text-sm"
/>
)}
</div> </div>
<CheckboxPrimitive.Root
checked={formState.isSplit}
onCheckedChange={(checked) =>
onFieldChange("isSplit", Boolean(checked))
}
aria-label="Dividir lançamento"
className={cn(
"peer size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
formState.isSplit
? "border-primary bg-primary text-primary-foreground"
: "border-input dark:bg-input/30",
)}
>
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
<RiSliceFill className="size-3" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
</div> </div>
{formState.isSplit ? ( <div className="flex w-full flex-col gap-2 md:flex-row">
<div className="w-full space-y-1 mb-1"> <div className="w-full space-y-1">
<Label htmlFor="secondaryPayer">Dividir com</Label> <Label htmlFor="payer">Pagador</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Select <Select
value={formState.secondaryPayerId} value={formState.payerId ?? ""}
onValueChange={(value) => onValueChange={(value) => onFieldChange("payerId", value)}
onFieldChange("secondaryPayerId", value)
}
> >
<SelectTrigger <SelectTrigger
id="secondaryPayer" id="payer"
disabled={secondaryPayerOptions.length === 0} className={formState.isSplit ? "min-w-0 flex-1" : "w-full"}
className="w-[55%]"
> >
<SelectValue placeholder="Selecione"> <SelectValue placeholder="Selecione">
{formState.secondaryPayerId && {formState.payerId &&
(() => { (() => {
const selectedOption = secondaryPayerOptions.find( const selectedOption = payerOptions.find(
(opt) => opt.value === formState.secondaryPayerId, (opt) => opt.value === formState.payerId,
); );
return selectedOption ? ( return selectedOption ? (
<PayerSelectContent <PayerSelectContent
@@ -114,7 +101,7 @@ export function PayerSection({
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{secondaryPayerOptions.map((option) => ( {payerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
<PayerSelectContent <PayerSelectContent
label={option.label} label={option.label}
@@ -124,15 +111,68 @@ export function PayerSection({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<CurrencyInput {formState.isSplit && (
value={formState.secondarySplitAmount} <CurrencyInput
onValueChange={handleSecondaryAmountChange} value={formState.primarySplitAmount}
placeholder="R$ 0,00" onValueChange={handlePrimaryAmountChange}
className="h-9 w-[45%] text-sm" placeholder="R$ 0,00"
/> className="h-9 w-[45%] text-sm"
/>
)}
</div> </div>
</div> </div>
) : null}
{formState.isSplit ? (
<div className="w-full space-y-1 mb-1">
<Label htmlFor="secondaryPayer">Dividir com</Label>
<div className="flex gap-2">
<Select
value={formState.secondaryPayerId ?? ""}
onValueChange={(value) =>
onFieldChange("secondaryPayerId", value)
}
>
<SelectTrigger
id="secondaryPayer"
disabled={secondaryPayerOptions.length === 0}
className="w-[55%]"
>
<SelectValue placeholder="Selecione">
{formState.secondaryPayerId &&
(() => {
const selectedOption = secondaryPayerOptions.find(
(opt) => opt.value === formState.secondaryPayerId,
);
return selectedOption ? (
<PayerSelectContent
label={selectedOption.label}
avatarUrl={selectedOption.avatarUrl}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{secondaryPayerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PayerSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
<CurrencyInput
value={formState.secondarySplitAmount}
onValueChange={handleSecondaryAmountChange}
placeholder="R$ 0,00"
className="h-9 w-[45%] text-sm"
/>
</div>
</div>
) : null}
</div>
</div> </div>
); );
} }

View File

@@ -1,7 +1,12 @@
"use client"; "use client";
import {
RiCheckboxBlankCircleLine,
RiCheckboxCircleFill,
} from "@remixicon/react";
import { useState } from "react"; import { useState } from "react";
import { PAYMENT_METHODS } from "@/features/transactions/constants"; import { PAYMENT_METHODS } from "@/features/transactions/constants";
import { Button } from "@/shared/components/ui/button";
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
import { MonthPicker } from "@/shared/components/ui/month-picker"; import { MonthPicker } from "@/shared/components/ui/month-picker";
import { import {
@@ -71,6 +76,7 @@ export function PaymentMethodSection({
isUpdateMode, isUpdateMode,
disablePaymentMethod, disablePaymentMethod,
disableCardSelect, disableCardSelect,
showSettledToggle,
}: PaymentMethodSectionProps) { }: PaymentMethodSectionProps) {
const isCartaoSelected = formState.paymentMethod === "Cartão de crédito"; const isCartaoSelected = formState.paymentMethod === "Cartão de crédito";
const showContaSelect = [ const showContaSelect = [
@@ -92,154 +98,200 @@ export function PaymentMethodSection({
const hasSecondaryColumn = isCartaoSelected || showContaSelect; const hasSecondaryColumn = isCartaoSelected || showContaSelect;
return ( return (
<div className="flex w-full flex-col gap-2 md:flex-row"> <div className="space-y-3">
{!isUpdateMode ? ( <div className="flex w-full flex-col gap-2 md:flex-row">
<div {!isUpdateMode ? (
className={cn( <div
"w-full space-y-1", className={cn(
hasSecondaryColumn ? "md:w-1/2" : "md:w-full", "w-full space-y-1",
)} hasSecondaryColumn ? "md:w-1/2" : "md:w-full",
> )}
<Label htmlFor="paymentMethod">Forma de pagamento</Label>
<Select
value={formState.paymentMethod}
onValueChange={(value) => onFieldChange("paymentMethod", value)}
disabled={disablePaymentMethod}
> >
<SelectTrigger <Label htmlFor="paymentMethod">Forma de pagamento</Label>
id="paymentMethod" <Select
className="w-full" value={formState.paymentMethod}
onValueChange={(value) => onFieldChange("paymentMethod", value)}
disabled={disablePaymentMethod} disabled={disablePaymentMethod}
> >
<SelectValue placeholder="Selecione" className="w-full"> <SelectTrigger
{formState.paymentMethod && ( id="paymentMethod"
<PaymentMethodSelectContent label={formState.paymentMethod} /> className="w-full"
)} disabled={disablePaymentMethod}
</SelectValue> >
</SelectTrigger> <SelectValue placeholder="Selecione" className="w-full">
<SelectContent> {formState.paymentMethod && (
{PAYMENT_METHODS.map((method) => ( <PaymentMethodSelectContent
<SelectItem key={method} value={method}> label={formState.paymentMethod}
<PaymentMethodSelectContent label={method} /> />
</SelectItem> )}
))} </SelectValue>
</SelectContent> </SelectTrigger>
</Select> <SelectContent>
</div> {PAYMENT_METHODS.map((method) => (
) : null} <SelectItem key={method} value={method}>
<PaymentMethodSelectContent label={method} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
{isCartaoSelected ? ( {isCartaoSelected ? (
<div <div
className={cn( className={cn(
"w-full space-y-1", "w-full space-y-1",
!isUpdateMode ? "md:w-1/2" : "md:w-full", !isUpdateMode ? "md:w-1/2" : "md:w-full",
)} )}
>
<Label htmlFor="cartao">Cartão</Label>
<Select
value={formState.cardId}
onValueChange={(value) => onFieldChange("cardId", value)}
disabled={disableCardSelect}
> >
<SelectTrigger <Label htmlFor="cartao">Cartão</Label>
id="cartao" <Select
className="w-full" value={formState.cardId ?? ""}
onValueChange={(value) => onFieldChange("cardId", value)}
disabled={disableCardSelect} disabled={disableCardSelect}
> >
<SelectValue placeholder="Selecione"> <SelectTrigger
{formState.cardId && id="cartao"
(() => { className="w-full"
const selectedOption = cardOptions.find( disabled={disableCardSelect}
(opt) => opt.value === formState.cardId, >
); <SelectValue placeholder="Selecione">
return selectedOption ? ( {formState.cardId &&
(() => {
const selectedOption = cardOptions.find(
(opt) => opt.value === formState.cardId,
);
return selectedOption ? (
<AccountCardSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={true}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{cardOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhum cartão cadastrado
</p>
</div>
) : (
cardOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AccountCardSelectContent <AccountCardSelectContent
label={selectedOption.label} label={option.label}
logo={selectedOption.logo} logo={option.logo}
isCartao={true} isCartao={true}
/> />
) : null; </SelectItem>
})()} ))
</SelectValue> )}
</SelectTrigger> </SelectContent>
<SelectContent> </Select>
{cardOptions.length === 0 ? ( {formState.cardId ? (
<div className="px-2 py-6 text-center"> <InlinePeriodPicker
<p className="text-sm text-muted-foreground"> period={formState.period}
Nenhum cartão cadastrado onPeriodChange={(value) => onFieldChange("period", value)}
</p> />
</div> ) : null}
) : ( </div>
cardOptions.map((option) => ( ) : null}
<SelectItem key={option.value} value={option.value}>
<AccountCardSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))
)}
</SelectContent>
</Select>
{formState.cardId ? (
<InlinePeriodPicker
period={formState.period}
onPeriodChange={(value) => onFieldChange("period", value)}
/>
) : null}
</div>
) : null}
{!isCartaoSelected && showContaSelect ? ( {!isCartaoSelected && showContaSelect ? (
<div <div
className={cn( className={cn(
"w-full space-y-1", "w-full space-y-1",
!isUpdateMode ? "md:w-1/2" : "md:w-full", !isUpdateMode ? "md:w-1/2" : "md:w-full",
)} )}
>
<Label htmlFor="conta">Conta</Label>
<Select
value={formState.accountId}
onValueChange={(value) => onFieldChange("accountId", value)}
> >
<SelectTrigger id="conta" className="w-full"> <Label htmlFor="conta">Conta</Label>
<SelectValue placeholder="Selecione"> <Select
{formState.accountId && value={formState.accountId ?? ""}
(() => { onValueChange={(value) => onFieldChange("accountId", value)}
const selectedOption = filteredContaOptions.find( >
(opt) => opt.value === formState.accountId, <SelectTrigger id="conta" className="w-full">
); <SelectValue placeholder="Selecione">
return selectedOption ? ( {formState.accountId &&
(() => {
const selectedOption = filteredContaOptions.find(
(opt) => opt.value === formState.accountId,
);
return selectedOption ? (
<AccountCardSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{filteredContaOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma conta cadastrada
</p>
</div>
) : (
filteredContaOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AccountCardSelectContent <AccountCardSelectContent
label={selectedOption.label} label={option.label}
logo={selectedOption.logo} logo={option.logo}
isCartao={false} isCartao={false}
/> />
) : null; </SelectItem>
})()} ))
</SelectValue> )}
</SelectTrigger> </SelectContent>
<SelectContent> </Select>
{filteredContaOptions.length === 0 ? ( </div>
<div className="px-2 py-6 text-center"> ) : null}
<p className="text-sm text-muted-foreground"> </div>
Nenhuma conta cadastrada
</p> {showSettledToggle ? (
</div> <div
) : ( className={cn(
filteredContaOptions.map((option) => ( "flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors",
<SelectItem key={option.value} value={option.value}> formState.isSettled
<AccountCardSelectContent ? "border-success/20 bg-success/5"
label={option.label} : "border-border bg-transparent",
logo={option.logo} )}
isCartao={false} >
/> <div>
</SelectItem> <p className="text-sm text-foreground text-left">
)) Marcar como pago
)} </p>
</SelectContent> <p className="text-xs text-muted-foreground text-left">
</Select> Indica que o valor foi pago.
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onFieldChange("isSettled", !formState.isSettled)}
aria-label={
formState.isSettled ? "Desfazer pagamento" : "Marcar como pago"
}
aria-pressed={Boolean(formState.isSettled)}
className={cn(
"transition-colors",
formState.isSettled
? "bg-success/10 text-success hover:bg-success/20 hover:text-success"
: "text-muted-foreground hover:text-foreground",
)}
>
{formState.isSettled ? (
<RiCheckboxCircleFill className="size-4" />
) : (
<RiCheckboxBlankCircleLine className="size-4" />
)}
</Button>
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -1,58 +0,0 @@
"use client";
import { Checkbox } from "@/shared/components/ui/checkbox";
import { cn } from "@/shared/utils/ui";
import type { SplitAndSettlementSectionProps } from "./transaction-dialog-types";
export function SplitAndSettlementSection({
formState,
onFieldChange,
showSettledToggle,
}: SplitAndSettlementSectionProps) {
return (
<div className="flex w-full flex-col gap-2 md:flex-row">
<div
className={cn(
"space-y-1",
showSettledToggle ? "md:w-1/2 md:pr-2" : "md:w-full",
)}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-foreground">Dividir lançamento</p>
<p className="text-xs text-muted-foreground">
Atribuir parte do valor a outro pagador.
</p>
</div>
<Checkbox
checked={formState.isSplit}
onCheckedChange={(checked) =>
onFieldChange("isSplit", Boolean(checked))
}
aria-label="Dividir lançamento"
/>
</div>
</div>
{showSettledToggle ? (
<div className="space-y-1 md:w-1/2 md:pr-2">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-foreground">Marcar como pago</p>
<p className="text-xs text-muted-foreground">
Indica que o valor foi pago.
</p>
</div>
<Checkbox
checked={Boolean(formState.isSettled)}
onCheckedChange={(checked) =>
onFieldChange("isSettled", Boolean(checked))
}
aria-label="Marcar como concluído"
/>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -34,6 +34,8 @@ export interface TransactionDialogProps {
maxSizeMb?: number; maxSizeMb?: number;
onBulkEditRequest?: (data: { onBulkEditRequest?: (data: {
id: string; id: string;
purchaseDate: string;
period: string;
name: string; name: string;
categoryId: string | undefined; categoryId: string | undefined;
note: string; note: string;
@@ -71,10 +73,6 @@ export interface CategorySectionProps extends BaseFieldSectionProps {
hideTransactionType?: boolean; hideTransactionType?: boolean;
} }
export interface SplitAndSettlementSectionProps extends BaseFieldSectionProps {
showSettledToggle: boolean;
}
export interface PayerSectionProps extends BaseFieldSectionProps { export interface PayerSectionProps extends BaseFieldSectionProps {
payerOptions: SelectOption[]; payerOptions: SelectOption[];
secondaryPayerOptions: SelectOption[]; secondaryPayerOptions: SelectOption[];
@@ -87,6 +85,7 @@ export interface PaymentMethodSectionProps extends BaseFieldSectionProps {
isUpdateMode: boolean; isUpdateMode: boolean;
disablePaymentMethod: boolean; disablePaymentMethod: boolean;
disableCardSelect: boolean; disableCardSelect: boolean;
showSettledToggle: boolean;
} }
export interface BoletoFieldsSectionProps extends BaseFieldSectionProps { export interface BoletoFieldsSectionProps extends BaseFieldSectionProps {

View File

@@ -46,7 +46,6 @@ import { ConditionSection } from "./condition-section";
import { NoteSection } from "./note-section"; import { NoteSection } from "./note-section";
import { PayerSection } from "./payer-section"; import { PayerSection } from "./payer-section";
import { PaymentMethodSection } from "./payment-method-section"; import { PaymentMethodSection } from "./payment-method-section";
import { SplitAndSettlementSection } from "./split-settlement-section";
import type { import type {
FormState, FormState,
TransactionDialogProps, TransactionDialogProps,
@@ -99,7 +98,7 @@ export function TransactionDialog({
); );
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null); const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]); const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]); const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
@@ -139,7 +138,7 @@ export function TransactionDialog({
setFormState(initial); setFormState(initial);
setErrorMessage(null); setErrorMessage(null);
setPendingFile(null); setPendingFiles([]);
setPendingDetachIds([]); setPendingDetachIds([]);
setPendingUploadFiles([]); setPendingUploadFiles([]);
} }
@@ -330,27 +329,29 @@ export function TransactionDialog({
const result = await createTransactionAction(payload); const result = await createTransactionAction(payload);
if (result.success) { if (result.success) {
if (pendingFile && result.data?.ids?.length) { if (pendingFiles.length > 0 && result.data?.ids?.length) {
const firstId = result.data.ids[0]; const firstId = result.data.ids[0];
const isNewSeries = const isNewSeries =
formState.condition === "Parcelado" || formState.condition === "Parcelado" ||
formState.condition === "Recorrente"; formState.condition === "Recorrente";
const presign = await getPresignedUploadUrlAction({ for (const file of pendingFiles) {
fileName: pendingFile.name, const presign = await getPresignedUploadUrlAction({
mimeType: pendingFile.type, fileName: file.name,
fileSize: pendingFile.size, mimeType: file.type,
transactionId: firstId, fileSize: file.size,
}); transactionId: firstId,
if (presign.success) {
await fetch(presign.presignedUrl, {
method: "PUT",
body: pendingFile,
headers: { "Content-Type": pendingFile.type },
});
await confirmAttachmentUploadAction({
uploadToken: presign.uploadToken,
scope: isNewSeries ? "all" : "current",
}); });
if (presign.success) {
await fetch(presign.presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
await confirmAttachmentUploadAction({
uploadToken: presign.uploadToken,
scope: isNewSeries ? "all" : "current",
});
}
} }
} }
toast.success(result.message); toast.success(result.message);
@@ -371,6 +372,8 @@ export function TransactionDialog({
// o upload após o escopo ser escolhido (sem upload antecipado ao S3) // o upload após o escopo ser escolhido (sem upload antecipado ao S3)
onBulkEditRequest({ onBulkEditRequest({
id: transaction?.id ?? "", id: transaction?.id ?? "",
purchaseDate: formState.purchaseDate,
period: formState.period,
name: formState.name.trim(), name: formState.name.trim(),
categoryId: formState.categoryId, categoryId: formState.categoryId,
note: formState.note.trim() || "", note: formState.note.trim() || "",
@@ -493,30 +496,30 @@ export function TransactionDialog({
onSubmit={handleSubmit} onSubmit={handleSubmit}
noValidate noValidate
> >
<div className="min-w-0 space-y-3 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1"> <div className="min-w-0 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
<BasicFieldsSection {/* Detalhes */}
formState={formState} <div className="space-y-3">
onFieldChange={handleFieldChange} <BasicFieldsSection
estabelecimentos={estabelecimentos} formState={formState}
/> onFieldChange={handleFieldChange}
estabelecimentos={estabelecimentos}
/>
<CategorySection <CategorySection
formState={formState} formState={formState}
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
categoryOptions={categoryOptions} categoryOptions={categoryOptions}
categoryGroups={categoryGroups} categoryGroups={categoryGroups}
isUpdateMode={isUpdateMode} isUpdateMode={isUpdateMode}
hideTransactionType={ hideTransactionType={
Boolean(isNewWithType) && !forceShowTransactionType Boolean(isNewWithType) && !forceShowTransactionType
} }
/> />
</div>
<SplitAndSettlementSection <div className="border-t border-border/40 my-3" />
formState={formState}
onFieldChange={handleFieldChange}
showSettledToggle={showSettledToggle}
/>
{/* Pagador */}
<PayerSection <PayerSection
formState={formState} formState={formState}
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
@@ -525,56 +528,66 @@ export function TransactionDialog({
totalAmount={totalAmount} totalAmount={totalAmount}
/> />
<PaymentMethodSection <div className="border-t border-border/40 my-3" />
formState={formState}
onFieldChange={handleFieldChange}
accountOptions={accountOptions}
cardOptions={cardOptions}
isUpdateMode={isUpdateMode}
disablePaymentMethod={disablePaymentMethod}
disableCardSelect={disableCardSelect}
/>
{showDueDate ? ( {/* Pagamento */}
<BoletoFieldsSection <div className="space-y-3">
<PaymentMethodSection
formState={formState} formState={formState}
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
showPaymentDate={showPaymentDate} accountOptions={accountOptions}
cardOptions={cardOptions}
isUpdateMode={isUpdateMode}
disablePaymentMethod={disablePaymentMethod}
disableCardSelect={disableCardSelect}
showSettledToggle={showSettledToggle}
/> />
) : null}
{isUpdateMode ? ( {showDueDate ? (
<> <BoletoFieldsSection
<NoteSection
formState={formState} formState={formState}
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
showPaymentDate={showPaymentDate}
/> />
<div className="space-y-2"> ) : null}
<Label className="text-xs font-medium leading-none"> </div>
Anexos
</Label> {/* Extras */}
<AttachmentSection {isUpdateMode ? (
transactionId={transaction?.id ?? ""} <>
maxSizeMb={maxSizeMb} <div className="border-t border-border/40 my-3" />
pendingDetachIds={pendingDetachIds} <div className="space-y-3">
onPendingDetach={(id) => <NoteSection
setPendingDetachIds((prev) => [...prev, id]) formState={formState}
} onFieldChange={handleFieldChange}
onUndoPendingDetach={(id) =>
setPendingDetachIds((prev) =>
prev.filter((x) => x !== id),
)
}
pendingUploadFiles={pendingUploadFiles}
onPendingUpload={(file) =>
setPendingUploadFiles((prev) => [...prev, file])
}
onCancelPendingUpload={(file) =>
setPendingUploadFiles((prev) =>
prev.filter((f) => f !== file),
)
}
/> />
<div className="space-y-2">
<Label className="text-xs font-medium leading-none">
Anexos
</Label>
<AttachmentSection
transactionId={transaction?.id ?? ""}
maxSizeMb={maxSizeMb}
pendingDetachIds={pendingDetachIds}
onPendingDetach={(id) =>
setPendingDetachIds((prev) => [...prev, id])
}
onUndoPendingDetach={(id) =>
setPendingDetachIds((prev) =>
prev.filter((x) => x !== id),
)
}
pendingUploadFiles={pendingUploadFiles}
onPendingUpload={(file) =>
setPendingUploadFiles((prev) => [...prev, file])
}
onCancelPendingUpload={(file) =>
setPendingUploadFiles((prev) =>
prev.filter((f) => f !== file),
)
}
/>
</div>
</div> </div>
</> </>
) : ( ) : (
@@ -598,8 +611,11 @@ export function TransactionDialog({
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
/> />
<AttachmentFilePicker <AttachmentFilePicker
file={pendingFile} files={pendingFiles}
onChange={setPendingFile} onAdd={(file) => setPendingFiles((prev) => [...prev, file])}
onRemove={(file) =>
setPendingFiles((prev) => prev.filter((f) => f !== file))
}
maxSizeMb={maxSizeMb} maxSizeMb={maxSizeMb}
/> />
</CollapsibleContent> </CollapsibleContent>

View File

@@ -127,6 +127,8 @@ export function TransactionsPage({
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [pendingEditData, setPendingEditData] = useState<{ const [pendingEditData, setPendingEditData] = useState<{
id: string; id: string;
purchaseDate: string;
period: string;
name: string; name: string;
categoryId: string | undefined; categoryId: string | undefined;
note: string; note: string;
@@ -245,6 +247,8 @@ export function TransactionsPage({
const handleBulkEditRequest = (data: { const handleBulkEditRequest = (data: {
id: string; id: string;
purchaseDate: string;
period: string;
name: string; name: string;
categoryId: string | undefined; categoryId: string | undefined;
note: string; note: string;
@@ -278,6 +282,8 @@ export function TransactionsPage({
const result = await updateTransactionBulkAction({ const result = await updateTransactionBulkAction({
id: pendingEditData.id, id: pendingEditData.id,
scope, scope,
purchaseDate: pendingEditData.purchaseDate,
period: pendingEditData.period,
name: pendingEditData.name, name: pendingEditData.name,
categoryId: pendingEditData.categoryId, categoryId: pendingEditData.categoryId,
note: pendingEditData.note, note: pendingEditData.note,

View File

@@ -60,11 +60,6 @@ export function deriveCreditCardPeriod(
return period; return period;
} }
/**
* Split type for dividing transactions between payers
*/
export type SplitType = "equal" | "60-40" | "70-30" | "80-20" | "custom";
/** /**
* Form state type for lancamento dialog * Form state type for lancamento dialog
*/ */
@@ -79,7 +74,6 @@ export type TransactionFormState = {
payerId: string | undefined; payerId: string | undefined;
secondaryPayerId: string | undefined; secondaryPayerId: string | undefined;
isSplit: boolean; isSplit: boolean;
splitType: SplitType;
primarySplitAmount: string; primarySplitAmount: string;
secondarySplitAmount: string; secondarySplitAmount: string;
accountId: string | undefined; accountId: string | undefined;
@@ -117,7 +111,7 @@ export function buildTransactionInitialState(
): TransactionFormState { ): TransactionFormState {
const purchaseDate = transaction?.purchaseDate const purchaseDate = transaction?.purchaseDate
? transaction.purchaseDate.slice(0, 10) ? transaction.purchaseDate.slice(0, 10)
: (overrides?.defaultPurchaseDate ?? ""); : (overrides?.defaultPurchaseDate ?? getTodayDateString());
const paymentMethod = const paymentMethod =
transaction?.paymentMethod ?? transaction?.paymentMethod ??
@@ -176,7 +170,7 @@ export function buildTransactionInitialState(
payerId: fallbackPayerId ?? undefined, payerId: fallbackPayerId ?? undefined,
secondaryPayerId: undefined, secondaryPayerId: undefined,
isSplit: false, isSplit: false,
splitType: "equal",
primarySplitAmount: "", primarySplitAmount: "",
secondarySplitAmount: "", secondarySplitAmount: "",
accountId: accountId:
@@ -210,39 +204,6 @@ export function buildTransactionInitialState(
}; };
} }
/**
* Split presets with their percentages
*/
const SPLIT_PRESETS: Record<SplitType, { primary: number; secondary: number }> =
{
equal: { primary: 50, secondary: 50 },
"60-40": { primary: 60, secondary: 40 },
"70-30": { primary: 70, secondary: 30 },
"80-20": { primary: 80, secondary: 20 },
custom: { primary: 50, secondary: 50 },
};
/**
* Calculates split amounts based on total and split type
*/
export function calculateSplitAmounts(
totalAmount: number,
splitType: SplitType,
): { primary: string; secondary: string } {
if (totalAmount <= 0) {
return { primary: "", secondary: "" };
}
const preset = SPLIT_PRESETS[splitType];
const primaryAmount = (totalAmount * preset.primary) / 100;
const secondaryAmount = totalAmount - primaryAmount;
return {
primary: primaryAmount.toFixed(2),
secondary: secondaryAmount.toFixed(2),
};
}
/** /**
* Applies field dependencies when form state changes * Applies field dependencies when form state changes
* This function encapsulates the business logic for field interdependencies * This function encapsulates the business logic for field interdependencies
@@ -348,7 +309,6 @@ export function applyFieldDependencies(
// When split is disabled, clear secondary pagador and split fields // When split is disabled, clear secondary pagador and split fields
if (key === "isSplit" && value === false) { if (key === "isSplit" && value === false) {
updates.secondaryPayerId = undefined; updates.secondaryPayerId = undefined;
updates.splitType = "equal";
updates.primarySplitAmount = ""; updates.primarySplitAmount = "";
updates.secondarySplitAmount = ""; updates.secondarySplitAmount = "";
} }
@@ -367,12 +327,9 @@ export function applyFieldDependencies(
if (key === "amount" && typeof value === "string" && currentState.isSplit) { if (key === "amount" && typeof value === "string" && currentState.isSplit) {
const totalAmount = Number.parseFloat(value) || 0; const totalAmount = Number.parseFloat(value) || 0;
if (totalAmount > 0) { if (totalAmount > 0) {
const splitAmounts = calculateSplitAmounts( const half = (totalAmount / 2).toFixed(2);
totalAmount, updates.primarySplitAmount = half;
currentState.splitType, updates.secondarySplitAmount = half;
);
updates.primarySplitAmount = splitAmounts.primary;
updates.secondarySplitAmount = splitAmounts.secondary;
} else { } else {
updates.primarySplitAmount = ""; updates.primarySplitAmount = "";
updates.secondarySplitAmount = ""; updates.secondarySplitAmount = "";