diff --git a/src/features/transactions/actions/bulk-actions.ts b/src/features/transactions/actions/bulk-actions.ts index c3cc2d2..6d04a79 100644 --- a/src/features/transactions/actions/bulk-actions.ts +++ b/src/features/transactions/actions/bulk-actions.ts @@ -16,6 +16,7 @@ import { } from "@/shared/lib/payers/notifications"; import type { ActionResult } from "@/shared/lib/types/actions"; import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date"; +import { addMonthsToPeriod, parsePeriod } from "@/shared/utils/period"; import { centsToDecimalString, type DeleteBulkInput, @@ -26,6 +27,8 @@ import { fetchOwnedCardIds, fetchOwnedCategoryIds, fetchOwnedPayerIds, + formatPaidInvoicePeriods, + getPaidInvoicePeriods, type MassAddInput, massAddSchema, resolvePeriod, @@ -37,6 +40,12 @@ import { validateAllOwnership, } 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( input: DeleteBulkInput, ): Promise { @@ -164,8 +173,10 @@ export async function updateTransactionBulkAction( period: true, condition: true, transactionType: true, + paymentMethod: true, purchaseDate: true, payerId: true, + cardId: true, }, where: and( eq(transactions.id, data.id), @@ -204,6 +215,8 @@ export async function updateTransactionBulkAction( const hasDueDateUpdate = data.dueDate !== undefined; const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined; + const hasPurchaseDateUpdate = data.purchaseDate !== undefined; + const hasPeriodUpdate = data.period !== undefined; const baseDueDate = hasDueDateUpdate && data.dueDate @@ -218,8 +231,13 @@ export async function updateTransactionBulkAction( : hasBoletoPaymentDateUpdate ? null : undefined; - - const basePurchaseDate = existing.purchaseDate ?? null; + const referencePurchaseDate = 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) => { if (!hasDueDateUpdate) { @@ -230,18 +248,48 @@ export async function updateTransactionBulkAction( return null; } - if (!basePurchaseDate || !recordPurchaseDate) { + if (!referencePurchaseDate || !recordPurchaseDate) { return baseDueDate; } const monthDiff = - (recordPurchaseDate.getFullYear() - basePurchaseDate.getFullYear()) * + (recordPurchaseDate.getFullYear() - + referencePurchaseDate.getFullYear()) * 12 + - (recordPurchaseDate.getMonth() - basePurchaseDate.getMonth()); + (recordPurchaseDate.getMonth() - referencePurchaseDate.getMonth()); 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) => { if (value === undefined) { return "undefined"; @@ -252,8 +300,51 @@ export async function updateTransactionBulkAction( 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(); + + 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 ( - records: Array<{ id: string; purchaseDate: Date | null }>, + records: Array<{ id: string; purchaseDate: Date | null; period: string }>, ) => { if (records.length === 0) { return; @@ -269,10 +360,20 @@ export async function updateTransactionBulkAction( for (const record of records) { const dueDateForRecord = buildDueDateForRecord(record.purchaseDate); + const purchaseDateForRecord = buildPurchaseDateForRecord(record); + const periodForRecord = buildPeriodForRecord(record); const perRecordPayload: Record = { ...baseUpdatePayload, }; + if (purchaseDateForRecord !== undefined) { + perRecordPayload.purchaseDate = purchaseDateForRecord; + } + + if (periodForRecord !== undefined) { + perRecordPayload.period = periodForRecord; + } + if (dueDateForRecord !== undefined) { perRecordPayload.dueDate = dueDateForRecord; } @@ -282,6 +383,8 @@ export async function updateTransactionBulkAction( } const groupKey = [ + serializeDateKey(purchaseDateForRecord), + periodForRecord ?? "undefined", serializeDateKey(dueDateForRecord), serializeDateKey( hasBoletoPaymentDateUpdate @@ -318,12 +421,19 @@ export async function updateTransactionBulkAction( }; if (data.scope === "current") { - await applyUpdates([ + const currentRecords = [ { id: data.id, 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); return { success: true, message: "Lançamento atualizado com sucesso." }; @@ -338,7 +448,7 @@ export async function updateTransactionBulkAction( } const periodLancamentos = await db.query.transactions.findMany({ - columns: { id: true, purchaseDate: true }, + columns: { id: true, purchaseDate: true, period: true }, where: and( eq(transactions.seriesId, existing.seriesId), eq(transactions.userId, user.id), @@ -347,10 +457,16 @@ export async function updateTransactionBulkAction( orderBy: asc(transactions.purchaseDate), }); + const invoiceError = await ensureTargetInvoicesAreOpen(periodLancamentos); + if (invoiceError) { + return { success: false, error: invoiceError }; + } + await applyUpdates( periodLancamentos.map((item: (typeof periodLancamentos)[number]) => ({ id: item.id, purchaseDate: item.purchaseDate ?? null, + period: item.period, })), ); @@ -370,6 +486,7 @@ export async function updateTransactionBulkAction( columns: { id: true, purchaseDate: true, + period: true, }, where: and( eq(transactions.seriesId, existing.seriesId), @@ -380,10 +497,16 @@ export async function updateTransactionBulkAction( orderBy: asc(transactions.purchaseDate), }); + const invoiceError = await ensureTargetInvoicesAreOpen(futureLancamentos); + if (invoiceError) { + return { success: false, error: invoiceError }; + } + await applyUpdates( futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({ id: item.id, purchaseDate: item.purchaseDate ?? null, + period: item.period, })), ); @@ -399,6 +522,7 @@ export async function updateTransactionBulkAction( columns: { id: true, purchaseDate: true, + period: true, }, where: and( eq(transactions.seriesId, existing.seriesId), @@ -408,10 +532,16 @@ export async function updateTransactionBulkAction( orderBy: asc(transactions.purchaseDate), }); + const invoiceError = await ensureTargetInvoicesAreOpen(allLancamentos); + if (invoiceError) { + return { success: false, error: invoiceError }; + } + await applyUpdates( allLancamentos.map((item: (typeof allLancamentos)[number]) => ({ id: item.id, purchaseDate: item.purchaseDate ?? null, + period: item.period, })), ); diff --git a/src/features/transactions/actions/core.ts b/src/features/transactions/actions/core.ts index 34a408b..089d4fc 100644 --- a/src/features/transactions/actions/core.ts +++ b/src/features/transactions/actions/core.ts @@ -4,6 +4,7 @@ import { cards, categories, financialAccounts, + invoices, payers, type transactions, } from "@/db/schema"; @@ -20,9 +21,10 @@ import { } from "@/shared/lib/accounts/constants"; import { revalidateForEntity } from "@/shared/lib/actions/helpers"; import { db } from "@/shared/lib/db"; +import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices"; import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common"; import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date"; -import { addMonthsToPeriod } from "@/shared/utils/period"; +import { addMonthsToPeriod, MONTH_NAMES } from "@/shared/utils/period"; // ============================================================================ // Authorization Validation Functions @@ -662,6 +664,43 @@ export const buildLancamentoRecords = ({ 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({ id: uuidSchema("Lançamento"), scope: z.enum(["current", "period", "future", "all"], { @@ -676,6 +715,20 @@ export const updateBulkSchema = z.object({ scope: z.enum(["current", "period", "future", "all"], { 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 .string({ message: "Informe o estabelecimento." }) .trim() diff --git a/src/features/transactions/actions/single-actions.ts b/src/features/transactions/actions/single-actions.ts index 74aa663..67ca014 100644 --- a/src/features/transactions/actions/single-actions.ts +++ b/src/features/transactions/actions/single-actions.ts @@ -1,18 +1,16 @@ "use server"; import { randomUUID } from "node:crypto"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { attachments, financialAccounts, - invoices, transactionAttachments, transactions, } from "@/db/schema"; import { handleActionError } from "@/shared/lib/actions/helpers"; import { getUser } from "@/shared/lib/auth/server"; import { db } from "@/shared/lib/db"; -import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices"; import { buildEntriesByPayer, sendPayerAutoEmails, @@ -23,7 +21,6 @@ import { getBusinessTodayDate, parseLocalDateString, } from "@/shared/utils/date"; -import { MONTH_NAMES } from "@/shared/utils/period"; import { cleanupAttachmentsAfterTransactionDelete } from "./attachments"; import { buildLancamentoRecords, @@ -33,6 +30,8 @@ import { createSchema, type DeleteInput, deleteSchema, + formatPaidInvoicePeriods, + getPaidInvoicePeriods, isInitialBalanceLancamento, resolvePeriod, resolveUserLabel, @@ -118,27 +117,18 @@ export async function createTransactionAction( ), ]; - const paidInvoices = await db.query.invoices.findMany({ - columns: { period: true }, - where: and( - eq(invoices.userId, user.id), - eq(invoices.cardId, data.cardId), - eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID), - inArray(invoices.period, uniquePeriods), - ), - }); + const paidPeriods = await getPaidInvoicePeriods( + user.id, + data.cardId, + uniquePeriods, + ); - if (paidInvoices.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(", "); + if (paidPeriods.length > 0) { return { 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[] }>; } } @@ -204,10 +194,12 @@ export async function updateTransactionAction( columns: { id: true, note: true, + period: true, transactionType: true, condition: true, paymentMethod: true, accountId: true, + cardId: true, categoryId: true, }, where: and( @@ -225,10 +217,12 @@ export async function updateTransactionAction( | { id: string; note: string | null; + period: string; transactionType: string; condition: string; paymentMethod: string; accountId: string | null; + cardId: string | null; categoryId: string | null; category: { name: string } | null; } @@ -264,6 +258,25 @@ export async function updateTransactionAction( ? parseLocalDateString(data.boletoPaymentDate) : getBusinessTodayDate() : 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 .update(transactions) diff --git a/src/features/transactions/components/attachments/attachment-file-picker.tsx b/src/features/transactions/components/attachments/attachment-file-picker.tsx index aa7bbf2..6485a21 100644 --- a/src/features/transactions/components/attachments/attachment-file-picker.tsx +++ b/src/features/transactions/components/attachments/attachment-file-picker.tsx @@ -10,14 +10,16 @@ import { import { Button } from "@/shared/components/ui/button"; interface AttachmentFilePickerProps { - file: File | null; - onChange: (file: File | null) => void; + files: File[]; + onAdd: (file: File) => void; + onRemove: (file: File) => void; maxSizeMb?: number; } export function AttachmentFilePicker({ - file, - onChange, + files, + onAdd, + onRemove, maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB, }: AttachmentFilePickerProps) { const maxFileSizeBytes = maxSizeMb * 1024 * 1024; @@ -45,12 +47,12 @@ export function AttachmentFilePicker({ return; } - onChange(selected); + onAdd(selected); } return (
-

Anexo

+

Anexos

- {file ? ( -
- - - {file.name} - - + {files.length > 0 && ( +
+ {files.map((file) => ( +
+ + + {file.name} + + +
+ ))}
- ) : ( - )} + +
); } diff --git a/src/features/transactions/components/dialogs/transaction-dialog/category-section.tsx b/src/features/transactions/components/dialogs/transaction-dialog/category-section.tsx index 3ed16cf..d44a03f 100644 --- a/src/features/transactions/components/dialogs/transaction-dialog/category-section.tsx +++ b/src/features/transactions/components/dialogs/transaction-dialog/category-section.tsx @@ -67,7 +67,7 @@ export function CategorySection({ > onFieldChange("payerId", value)} - > - - - {formState.payerId && - (() => { - const selectedOption = payerOptions.find( - (opt) => opt.value === formState.payerId, - ); - return selectedOption ? ( - - ) : null; - })()} - - - - {payerOptions.map((option) => ( - - - - ))} - - - {formState.isSplit && ( - - )} +
+
+
+
+

Dividir lançamento

+

+ Atribuir parte do valor a outro pagador. +

+
+ + 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", + )} + > + + + +
- {formState.isSplit ? ( -
- +
+
+
- + {formState.isSplit && ( + + )}
- ) : null} + + {formState.isSplit ? ( +
+ +
+ + +
+
+ ) : null} +
); } diff --git a/src/features/transactions/components/dialogs/transaction-dialog/payment-method-section.tsx b/src/features/transactions/components/dialogs/transaction-dialog/payment-method-section.tsx index 465590d..4fda7f6 100644 --- a/src/features/transactions/components/dialogs/transaction-dialog/payment-method-section.tsx +++ b/src/features/transactions/components/dialogs/transaction-dialog/payment-method-section.tsx @@ -1,7 +1,12 @@ "use client"; +import { + RiCheckboxBlankCircleLine, + RiCheckboxCircleFill, +} from "@remixicon/react"; import { useState } from "react"; import { PAYMENT_METHODS } from "@/features/transactions/constants"; +import { Button } from "@/shared/components/ui/button"; import { Label } from "@/shared/components/ui/label"; import { MonthPicker } from "@/shared/components/ui/month-picker"; import { @@ -71,6 +76,7 @@ export function PaymentMethodSection({ isUpdateMode, disablePaymentMethod, disableCardSelect, + showSettledToggle, }: PaymentMethodSectionProps) { const isCartaoSelected = formState.paymentMethod === "Cartão de crédito"; const showContaSelect = [ @@ -92,154 +98,200 @@ export function PaymentMethodSection({ const hasSecondaryColumn = isCartaoSelected || showContaSelect; return ( -
- {!isUpdateMode ? ( -
- - onFieldChange("paymentMethod", value)} disabled={disablePaymentMethod} > - - {formState.paymentMethod && ( - - )} - - - - {PAYMENT_METHODS.map((method) => ( - - - - ))} - - -
- ) : null} + + + {formState.paymentMethod && ( + + )} + + + + {PAYMENT_METHODS.map((method) => ( + + + + ))} + + +
+ ) : null} - {isCartaoSelected ? ( -
- - onFieldChange("cardId", value)} disabled={disableCardSelect} > - - {formState.cardId && - (() => { - const selectedOption = cardOptions.find( - (opt) => opt.value === formState.cardId, - ); - return selectedOption ? ( + + + {formState.cardId && + (() => { + const selectedOption = cardOptions.find( + (opt) => opt.value === formState.cardId, + ); + return selectedOption ? ( + + ) : null; + })()} + + + + {cardOptions.length === 0 ? ( +
+

+ Nenhum cartão cadastrado +

+
+ ) : ( + cardOptions.map((option) => ( + - ) : null; - })()} -
- - - {cardOptions.length === 0 ? ( -
-

- Nenhum cartão cadastrado -

-
- ) : ( - cardOptions.map((option) => ( - - - - )) - )} -
- - {formState.cardId ? ( - onFieldChange("period", value)} - /> - ) : null} -
- ) : null} + + )) + )} + + + {formState.cardId ? ( + onFieldChange("period", value)} + /> + ) : null} +
+ ) : null} - {!isCartaoSelected && showContaSelect ? ( -
- - onFieldChange("accountId", value)} + > + + + {formState.accountId && + (() => { + const selectedOption = filteredContaOptions.find( + (opt) => opt.value === formState.accountId, + ); + return selectedOption ? ( + + ) : null; + })()} + + + + {filteredContaOptions.length === 0 ? ( +
+

+ Nenhuma conta cadastrada +

+
+ ) : ( + filteredContaOptions.map((option) => ( + - ) : null; - })()} - - - - {filteredContaOptions.length === 0 ? ( -
-

- Nenhuma conta cadastrada -

-
- ) : ( - filteredContaOptions.map((option) => ( - - - - )) - )} -
- +
+ )) + )} +
+ +
+ ) : null} +
+ + {showSettledToggle ? ( +
+
+

+ Marcar como pago +

+

+ Indica que o valor já foi pago. +

+
+
) : null} diff --git a/src/features/transactions/components/dialogs/transaction-dialog/split-settlement-section.tsx b/src/features/transactions/components/dialogs/transaction-dialog/split-settlement-section.tsx deleted file mode 100644 index 8930c1a..0000000 --- a/src/features/transactions/components/dialogs/transaction-dialog/split-settlement-section.tsx +++ /dev/null @@ -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 ( -
-
-
-
-

Dividir lançamento

-

- Atribuir parte do valor a outro pagador. -

-
- - onFieldChange("isSplit", Boolean(checked)) - } - aria-label="Dividir lançamento" - /> -
-
- - {showSettledToggle ? ( -
-
-
-

Marcar como pago

-

- Indica que o valor já foi pago. -

-
- - onFieldChange("isSettled", Boolean(checked)) - } - aria-label="Marcar como concluído" - /> -
-
- ) : null} -
- ); -} diff --git a/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog-types.ts b/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog-types.ts index 51c9e36..82397d0 100644 --- a/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog-types.ts +++ b/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog-types.ts @@ -34,6 +34,8 @@ export interface TransactionDialogProps { maxSizeMb?: number; onBulkEditRequest?: (data: { id: string; + purchaseDate: string; + period: string; name: string; categoryId: string | undefined; note: string; @@ -71,10 +73,6 @@ export interface CategorySectionProps extends BaseFieldSectionProps { hideTransactionType?: boolean; } -export interface SplitAndSettlementSectionProps extends BaseFieldSectionProps { - showSettledToggle: boolean; -} - export interface PayerSectionProps extends BaseFieldSectionProps { payerOptions: SelectOption[]; secondaryPayerOptions: SelectOption[]; @@ -87,6 +85,7 @@ export interface PaymentMethodSectionProps extends BaseFieldSectionProps { isUpdateMode: boolean; disablePaymentMethod: boolean; disableCardSelect: boolean; + showSettledToggle: boolean; } export interface BoletoFieldsSectionProps extends BaseFieldSectionProps { diff --git a/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx b/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx index 509a4ef..3cf68fc 100644 --- a/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx +++ b/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx @@ -46,7 +46,6 @@ import { ConditionSection } from "./condition-section"; import { NoteSection } from "./note-section"; import { PayerSection } from "./payer-section"; import { PaymentMethodSection } from "./payment-method-section"; -import { SplitAndSettlementSection } from "./split-settlement-section"; import type { FormState, TransactionDialogProps, @@ -99,7 +98,7 @@ export function TransactionDialog({ ); const [isPending, startTransition] = useTransition(); const [errorMessage, setErrorMessage] = useState(null); - const [pendingFile, setPendingFile] = useState(null); + const [pendingFiles, setPendingFiles] = useState([]); const [pendingDetachIds, setPendingDetachIds] = useState([]); const [pendingUploadFiles, setPendingUploadFiles] = useState([]); @@ -139,7 +138,7 @@ export function TransactionDialog({ setFormState(initial); setErrorMessage(null); - setPendingFile(null); + setPendingFiles([]); setPendingDetachIds([]); setPendingUploadFiles([]); } @@ -330,27 +329,29 @@ export function TransactionDialog({ const result = await createTransactionAction(payload); if (result.success) { - if (pendingFile && result.data?.ids?.length) { + if (pendingFiles.length > 0 && result.data?.ids?.length) { const firstId = result.data.ids[0]; const isNewSeries = formState.condition === "Parcelado" || formState.condition === "Recorrente"; - const presign = await getPresignedUploadUrlAction({ - fileName: pendingFile.name, - mimeType: pendingFile.type, - fileSize: pendingFile.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", + for (const file of pendingFiles) { + const presign = await getPresignedUploadUrlAction({ + fileName: file.name, + mimeType: file.type, + fileSize: file.size, + transactionId: firstId, }); + 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); @@ -371,6 +372,8 @@ export function TransactionDialog({ // o upload após o escopo ser escolhido (sem upload antecipado ao S3) onBulkEditRequest({ id: transaction?.id ?? "", + purchaseDate: formState.purchaseDate, + period: formState.period, name: formState.name.trim(), categoryId: formState.categoryId, note: formState.note.trim() || "", @@ -493,30 +496,30 @@ export function TransactionDialog({ onSubmit={handleSubmit} noValidate > -
- +
+ {/* Detalhes */} +
+ - + +
- +
+ {/* Pagador */} - +
- {showDueDate ? ( - + - ) : null} - {isUpdateMode ? ( - <> - -
- - - 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), - ) - } + ) : null} +
+ + {/* Extras */} + {isUpdateMode ? ( + <> +
+
+ +
+ + + 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), + ) + } + /> +
) : ( @@ -598,8 +611,11 @@ export function TransactionDialog({ onFieldChange={handleFieldChange} /> setPendingFiles((prev) => [...prev, file])} + onRemove={(file) => + setPendingFiles((prev) => prev.filter((f) => f !== file)) + } maxSizeMb={maxSizeMb} /> diff --git a/src/features/transactions/components/page/transactions-page.tsx b/src/features/transactions/components/page/transactions-page.tsx index bfdc0be..469638f 100644 --- a/src/features/transactions/components/page/transactions-page.tsx +++ b/src/features/transactions/components/page/transactions-page.tsx @@ -127,6 +127,8 @@ export function TransactionsPage({ const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const [pendingEditData, setPendingEditData] = useState<{ id: string; + purchaseDate: string; + period: string; name: string; categoryId: string | undefined; note: string; @@ -245,6 +247,8 @@ export function TransactionsPage({ const handleBulkEditRequest = (data: { id: string; + purchaseDate: string; + period: string; name: string; categoryId: string | undefined; note: string; @@ -278,6 +282,8 @@ export function TransactionsPage({ const result = await updateTransactionBulkAction({ id: pendingEditData.id, scope, + purchaseDate: pendingEditData.purchaseDate, + period: pendingEditData.period, name: pendingEditData.name, categoryId: pendingEditData.categoryId, note: pendingEditData.note, diff --git a/src/features/transactions/form-helpers.ts b/src/features/transactions/form-helpers.ts index 6d19e2c..2771faa 100644 --- a/src/features/transactions/form-helpers.ts +++ b/src/features/transactions/form-helpers.ts @@ -60,11 +60,6 @@ export function deriveCreditCardPeriod( 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 */ @@ -79,7 +74,6 @@ export type TransactionFormState = { payerId: string | undefined; secondaryPayerId: string | undefined; isSplit: boolean; - splitType: SplitType; primarySplitAmount: string; secondarySplitAmount: string; accountId: string | undefined; @@ -117,7 +111,7 @@ export function buildTransactionInitialState( ): TransactionFormState { const purchaseDate = transaction?.purchaseDate ? transaction.purchaseDate.slice(0, 10) - : (overrides?.defaultPurchaseDate ?? ""); + : (overrides?.defaultPurchaseDate ?? getTodayDateString()); const paymentMethod = transaction?.paymentMethod ?? @@ -176,7 +170,7 @@ export function buildTransactionInitialState( payerId: fallbackPayerId ?? undefined, secondaryPayerId: undefined, isSplit: false, - splitType: "equal", + primarySplitAmount: "", secondarySplitAmount: "", accountId: @@ -210,39 +204,6 @@ export function buildTransactionInitialState( }; } -/** - * Split presets with their percentages - */ -const SPLIT_PRESETS: Record = - { - 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 * 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 if (key === "isSplit" && value === false) { updates.secondaryPayerId = undefined; - updates.splitType = "equal"; updates.primarySplitAmount = ""; updates.secondarySplitAmount = ""; } @@ -367,12 +327,9 @@ export function applyFieldDependencies( if (key === "amount" && typeof value === "string" && currentState.isSplit) { const totalAmount = Number.parseFloat(value) || 0; if (totalAmount > 0) { - const splitAmounts = calculateSplitAmounts( - totalAmount, - currentState.splitType, - ); - updates.primarySplitAmount = splitAmounts.primary; - updates.secondarySplitAmount = splitAmounts.secondary; + const half = (totalAmount / 2).toFixed(2); + updates.primarySplitAmount = half; + updates.secondarySplitAmount = half; } else { updates.primarySplitAmount = ""; updates.secondarySplitAmount = "";