From f418987f47f13265f9bdc8fffd9cb5c38467a12a Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Mon, 30 Mar 2026 18:46:33 +0000 Subject: [PATCH] =?UTF-8?q?feat(lan=C3=A7amentos):=20escopo=20"period"=20n?= =?UTF-8?q?a=20a=C3=A7=C3=A3o=20em=20lote=20e=20corre=C3=A7=C3=A3o=20do=20?= =?UTF-8?q?fluxo=20de=20anexos=20em=20s=C3=A9ries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../transactions/actions/attachments.ts | 131 +++++++++++++++++- .../transactions/actions/bulk-actions.ts | 62 ++++++++- src/features/transactions/actions/core.ts | 4 +- .../attachments/attachment-item.tsx | 69 ++++++--- .../attachments/attachment-section.tsx | 108 ++++++++++----- .../attachments/attachment-upload.tsx | 86 ++---------- .../components/dialogs/bulk-action-dialog.tsx | 27 +++- .../dialogs/transaction-details-dialog.tsx | 1 - .../transaction-dialog-types.ts | 4 + .../transaction-dialog/transaction-dialog.tsx | 81 ++++++++++- .../components/page/transactions-page.tsx | 45 ++++++ 11 files changed, 477 insertions(+), 141 deletions(-) diff --git a/src/features/transactions/actions/attachments.ts b/src/features/transactions/actions/attachments.ts index 2096318..513b9af 100644 --- a/src/features/transactions/actions/attachments.ts +++ b/src/features/transactions/actions/attachments.ts @@ -33,7 +33,7 @@ const presignSchema = z.object({ const confirmSchema = z.object({ uploadToken: z.string().min(1), - applyToSeries: z.boolean().default(false), + scope: z.enum(["current", "period", "future", "all"]).default("current"), }); const detachSchema = z.object({ @@ -183,7 +183,7 @@ export async function getPresignedUploadUrlAction(input: { export async function confirmAttachmentUploadAction(input: { uploadToken: string; - applyToSeries?: boolean; + scope?: "current" | "period" | "future" | "all"; }): Promise { try { const user = await getUser(); @@ -195,7 +195,11 @@ export async function confirmAttachmentUploadAction(input: { } const [transaction] = await db - .select({ id: transactions.id, seriesId: transactions.seriesId }) + .select({ + id: transactions.id, + seriesId: transactions.seriesId, + period: transactions.period, + }) .from(transactions) .where( and( @@ -253,9 +257,9 @@ export async function confirmAttachmentUploadAction(input: { let transactionIds: string[] = [uploadPayload.transactionId]; - if (data.applyToSeries && transaction.seriesId) { + if (data.scope !== "current" && transaction.seriesId) { const seriesRows = await db - .select({ id: transactions.id }) + .select({ id: transactions.id, period: transactions.period }) .from(transactions) .where( and( @@ -263,7 +267,18 @@ export async function confirmAttachmentUploadAction(input: { eq(transactions.userId, user.id), ), ); - transactionIds = seriesRows.map((t) => t.id); + + if (data.scope === "period") { + transactionIds = seriesRows + .filter((r) => r.period === transaction.period) + .map((r) => r.id); + } else if (data.scope === "future") { + transactionIds = seriesRows + .filter((r) => (r.period ?? "") >= (transaction.period ?? "")) + .map((r) => r.id); + } else { + transactionIds = seriesRows.map((r) => r.id); + } } await db.insert(transactionAttachments).values( @@ -407,6 +422,110 @@ export async function fetchTransactionAttachmentsAction( ); } +const detachBulkSchema = z.object({ + attachmentId: z.string().uuid(), + transactionId: z.string().uuid(), + scope: z.enum(["current", "period", "future", "all"]), +}); + +export async function detachAttachmentBulkAction(input: { + attachmentId: string; + transactionId: string; + scope: "current" | "period" | "future" | "all"; +}): Promise { + try { + const user = await getUser(); + const data = detachBulkSchema.parse(input); + + const [baseTransaction] = await db + .select({ + id: transactions.id, + seriesId: transactions.seriesId, + period: transactions.period, + }) + .from(transactions) + .where( + and( + eq(transactions.id, data.transactionId), + eq(transactions.userId, user.id), + ), + ); + + if (!baseTransaction) { + return { success: false, error: "Lançamento não encontrado." }; + } + + const [attachment] = await db + .select({ id: attachments.id, fileKey: attachments.fileKey }) + .from(attachments) + .where( + and( + eq(attachments.id, data.attachmentId), + eq(attachments.userId, user.id), + ), + ); + + if (!attachment) { + return { success: false, error: "Anexo não encontrado." }; + } + + let targetTransactionIds: string[]; + + if (data.scope === "current" || !baseTransaction.seriesId) { + targetTransactionIds = [data.transactionId]; + } else { + const seriesRows = await db + .select({ id: transactions.id, period: transactions.period }) + .from(transactions) + .where( + and( + eq(transactions.seriesId, baseTransaction.seriesId), + eq(transactions.userId, user.id), + ), + ); + + if (data.scope === "period") { + targetTransactionIds = seriesRows + .filter((r) => r.period === baseTransaction.period) + .map((r) => r.id); + } else if (data.scope === "future") { + targetTransactionIds = seriesRows + .filter((r) => (r.period ?? "") >= (baseTransaction.period ?? "")) + .map((r) => r.id); + } else { + targetTransactionIds = seriesRows.map((r) => r.id); + } + } + + if (targetTransactionIds.length > 0) { + await db + .delete(transactionAttachments) + .where( + and( + inArray(transactionAttachments.transactionId, targetTransactionIds), + eq(transactionAttachments.attachmentId, data.attachmentId), + ), + ); + } + + const [remaining] = await db + .select({ total: count() }) + .from(transactionAttachments) + .where(eq(transactionAttachments.attachmentId, data.attachmentId)); + + if (!remaining || remaining.total === 0) { + await deleteS3Object(attachment.fileKey); + await db.delete(attachments).where(eq(attachments.id, data.attachmentId)); + } + + revalidateForEntity("transactions", user.id); + + return { success: true, message: "Anexo removido com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + /** Limpa anexos órfãos do S3 após deletar transações. Chame APÓS o delete. */ export async function cleanupAttachmentsAfterTransactionDelete( attachmentData: Array<{ id: string; fileKey: string }>, diff --git a/src/features/transactions/actions/bulk-actions.ts b/src/features/transactions/actions/bulk-actions.ts index 75cb916..c3cc2d2 100644 --- a/src/features/transactions/actions/bulk-actions.ts +++ b/src/features/transactions/actions/bulk-actions.ts @@ -1,6 +1,6 @@ "use server"; -import { and, asc, eq, inArray, sql } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"; import { transactions } from "@/db/schema"; import { PAYMENT_METHODS, @@ -80,6 +80,24 @@ export async function deleteTransactionBulkAction( return { success: true, message: "Lançamento removido com sucesso." }; } + if (data.scope === "period") { + await db + .delete(transactions) + .where( + and( + eq(transactions.seriesId, existing.seriesId), + eq(transactions.userId, user.id), + eq(transactions.period, existing.period ?? ""), + ), + ); + + revalidate(user.id); + return { + success: true, + message: "Todos os lançamentos do período foram removidos.", + }; + } + if (data.scope === "future") { await db .delete(transactions) @@ -147,6 +165,7 @@ export async function updateTransactionBulkAction( condition: true, transactionType: true, purchaseDate: true, + payerId: true, }, where: and( eq(transactions.id, data.id), @@ -169,7 +188,8 @@ export async function updateTransactionBulkAction( name: data.name, categoryId: data.categoryId ?? null, note: data.note ?? null, - payerId: data.payerId ?? null, + // "period" atualiza todos os pagadores do mês — preserva o payerId de cada linha + ...(data.scope !== "period" && { payerId: data.payerId ?? null }), accountId: data.accountId ?? null, cardId: data.cardId ?? null, ...(data.isSettled !== undefined && { isSettled: data.isSettled }), @@ -309,6 +329,42 @@ export async function updateTransactionBulkAction( return { success: true, message: "Lançamento atualizado com sucesso." }; } + if (data.scope === "period") { + if (!existing.period) { + return { + success: false, + error: "Período do lançamento não encontrado.", + }; + } + + const periodLancamentos = await db.query.transactions.findMany({ + columns: { id: true, purchaseDate: true }, + where: and( + eq(transactions.seriesId, existing.seriesId), + eq(transactions.userId, user.id), + eq(transactions.period, existing.period), + ), + orderBy: asc(transactions.purchaseDate), + }); + + await applyUpdates( + periodLancamentos.map((item: (typeof periodLancamentos)[number]) => ({ + id: item.id, + purchaseDate: item.purchaseDate ?? null, + })), + ); + + revalidate(user.id); + return { + success: true, + message: "Todos os lançamentos do período foram atualizados.", + }; + } + + const payerIdFilter = existing.payerId + ? eq(transactions.payerId, existing.payerId) + : isNull(transactions.payerId); + if (data.scope === "future") { const futureLancamentos = await db.query.transactions.findMany({ columns: { @@ -319,6 +375,7 @@ export async function updateTransactionBulkAction( eq(transactions.seriesId, existing.seriesId), eq(transactions.userId, user.id), sql`${transactions.period} >= ${existing.period}`, + payerIdFilter, ), orderBy: asc(transactions.purchaseDate), }); @@ -346,6 +403,7 @@ export async function updateTransactionBulkAction( where: and( eq(transactions.seriesId, existing.seriesId), eq(transactions.userId, user.id), + payerIdFilter, ), orderBy: asc(transactions.purchaseDate), }); diff --git a/src/features/transactions/actions/core.ts b/src/features/transactions/actions/core.ts index d144fc1..34a408b 100644 --- a/src/features/transactions/actions/core.ts +++ b/src/features/transactions/actions/core.ts @@ -664,7 +664,7 @@ export const buildLancamentoRecords = ({ export const deleteBulkSchema = z.object({ id: uuidSchema("Lançamento"), - scope: z.enum(["current", "future", "all"], { + scope: z.enum(["current", "period", "future", "all"], { message: "Escopo de ação inválido.", }), }); @@ -673,7 +673,7 @@ export type DeleteBulkInput = z.infer; export const updateBulkSchema = z.object({ id: uuidSchema("Lançamento"), - scope: z.enum(["current", "future", "all"], { + scope: z.enum(["current", "period", "future", "all"], { message: "Escopo de ação inválido.", }), name: z diff --git a/src/features/transactions/components/attachments/attachment-item.tsx b/src/features/transactions/components/attachments/attachment-item.tsx index b714dce..8698579 100644 --- a/src/features/transactions/components/attachments/attachment-item.tsx +++ b/src/features/transactions/components/attachments/attachment-item.tsx @@ -125,6 +125,9 @@ interface AttachmentItemProps { url: string; onDeleted: () => void; readonly?: boolean; + isPendingDelete?: boolean; + onPendingDelete?: (attachmentId: string) => void; + onUndoPendingDelete?: (attachmentId: string) => void; } export function AttachmentItem({ @@ -136,6 +139,9 @@ export function AttachmentItem({ url, onDeleted, readonly = false, + isPendingDelete = false, + onPendingDelete, + onUndoPendingDelete, }: AttachmentItemProps) { const [isPending, startTransition] = useTransition(); const [previewOpen, setPreviewOpen] = useState(false); @@ -145,6 +151,11 @@ export function AttachmentItem({ mimeType === "application/pdf" || mimeType.startsWith("image/"); function handleDelete() { + if (onPendingDelete) { + onPendingDelete(attachmentId); + setConfirmOpen(false); + return; + } startTransition(async () => { const result = await detachTransactionAttachmentAction({ attachmentId, @@ -162,9 +173,18 @@ export function AttachmentItem({ return ( <> -
+
- {canPreview ? ( + {isPendingDelete ? ( +
+

{fileName}

+

+ Será removido ao salvar +

+
+ ) : canPreview ? (
)} - - {!readonly && ( + {!isPendingDelete && ( )} + {!readonly && + (isPendingDelete ? ( + + ) : ( + + ))}
{canPreview && ( diff --git a/src/features/transactions/components/attachments/attachment-section.tsx b/src/features/transactions/components/attachments/attachment-section.tsx index dfe1b1e..b19f7e1 100644 --- a/src/features/transactions/components/attachments/attachment-section.tsx +++ b/src/features/transactions/components/attachments/attachment-section.tsx @@ -1,7 +1,9 @@ "use client"; +import { RiFileAddLine } from "@remixicon/react"; import { useCallback, useEffect, useState } from "react"; import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments"; +import { Button } from "@/shared/components/ui/button"; import { AttachmentItem } from "./attachment-item"; import { AttachmentUpload } from "./attachment-upload"; @@ -16,16 +18,28 @@ type AttachmentRow = { interface AttachmentSectionProps { transactionId: string; - seriesId: string | null; readonly?: boolean; onLoaded?: (count: number) => void; + pendingDetachIds?: string[]; + onPendingDetach?: (attachmentId: string) => void; + onUndoPendingDetach?: (attachmentId: string) => void; + pendingUploadFiles?: File[]; + onPendingUpload?: (file: File) => void; + onCancelPendingUpload?: (file: File) => void; + maxSizeMb?: number; } export function AttachmentSection({ transactionId, - seriesId, readonly = false, onLoaded, + pendingDetachIds, + onPendingDetach, + onUndoPendingDetach, + pendingUploadFiles, + onPendingUpload, + onCancelPendingUpload, + maxSizeMb, }: AttachmentSectionProps) { const [items, setItems] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -45,42 +59,70 @@ export function AttachmentSection({ load(); }, [load]); + if (isLoading) { + return

Carregando...

; + } + + const hasPendingUploads = (pendingUploadFiles?.length ?? 0) > 0; + return (
- {isLoading ? ( -

Carregando...

- ) : ( - <> - {items.length > 0 ? ( -
- {items.map((item) => ( - - ))} -
- ) : ( - readonly && ( -

Nenhum anexo.

- ) - )} + {items.length === 0 && !hasPendingUploads && readonly && ( +

Nenhum anexo.

+ )} - {!readonly && ( - 0 || hasPendingUploads) && ( +
+ {items.map((item) => ( + - )} - + ))} + + {pendingUploadFiles?.map((file) => ( +
+ +
+

{file.name}

+

+ Será adicionado ao salvar +

+
+ +
+ ))} +
+ )} + + {!readonly && ( + )}
); diff --git a/src/features/transactions/components/attachments/attachment-upload.tsx b/src/features/transactions/components/attachments/attachment-upload.tsx index 9e1ccf8..abd41d1 100644 --- a/src/features/transactions/components/attachments/attachment-upload.tsx +++ b/src/features/transactions/components/attachments/attachment-upload.tsx @@ -1,7 +1,7 @@ "use client"; import { RiAttachment2 } from "@remixicon/react"; -import { useRef, useState, useTransition } from "react"; +import { useRef, useTransition } from "react"; import { toast } from "sonner"; import { confirmAttachmentUploadAction, @@ -9,27 +9,25 @@ import { } from "@/features/transactions/actions/attachments"; import { ALLOWED_MIME_TYPES, - MAX_FILE_SIZE, + DEFAULT_MAX_FILE_SIZE_MB, } from "@/features/transactions/attachments-config"; -import { Button } from "@/shared/components/ui/button"; -import { Checkbox } from "@/shared/components/ui/checkbox"; -import { Label } from "@/shared/components/ui/label"; interface AttachmentUploadProps { transactionId: string; - seriesId: string | null; onUploaded: () => void; + onPendingUpload?: (file: File) => void; + maxSizeMb?: number; } export function AttachmentUpload({ transactionId, - seriesId, onUploaded, + onPendingUpload, + maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB, }: AttachmentUploadProps) { + const maxFileSizeBytes = maxSizeMb * 1024 * 1024; const inputRef = useRef(null); const [isPending, startTransition] = useTransition(); - const [applyToSeries, setApplyToSeries] = useState(false); - const [pendingFile, setPendingFile] = useState(null); function handleFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0]; @@ -49,19 +47,16 @@ export function AttachmentUpload({ return; } - if (file.size > MAX_FILE_SIZE) { - toast.error("O arquivo deve ter no máximo 50MB."); + if (file.size > maxFileSizeBytes) { + toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`); return; } - if (seriesId) { - setPendingFile(file); - } else { - uploadFile(file, false); + if (onPendingUpload) { + onPendingUpload(file); + return; } - } - function uploadFile(file: File, toSeries: boolean) { startTransition(async () => { const presignResult = await getPresignedUploadUrlAction({ fileName: file.name, @@ -88,13 +83,10 @@ export function AttachmentUpload({ const confirmResult = await confirmAttachmentUploadAction({ uploadToken: presignResult.uploadToken, - applyToSeries: toSeries, }); if (confirmResult.success) { toast.success(confirmResult.message); - setPendingFile(null); - setApplyToSeries(false); onUploaded(); } else { toast.error(confirmResult.error); @@ -102,56 +94,6 @@ export function AttachmentUpload({ }); } - function handleConfirmPending() { - if (pendingFile) uploadFile(pendingFile, applyToSeries); - } - - function handleCancelPending() { - setPendingFile(null); - setApplyToSeries(false); - } - - if (pendingFile) { - return ( -
-
-

- {pendingFile.name} -

-
-
- setApplyToSeries(Boolean(v))} - /> - -
-
- - -
-
- ); - } - return ( <> {!isPending && ( - PDF, JPEG, PNG ou WebP · máx. 50 MB + + PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB + )} diff --git a/src/features/transactions/components/dialogs/bulk-action-dialog.tsx b/src/features/transactions/components/dialogs/bulk-action-dialog.tsx index f3ebb4d..0e5fd8c 100644 --- a/src/features/transactions/components/dialogs/bulk-action-dialog.tsx +++ b/src/features/transactions/components/dialogs/bulk-action-dialog.tsx @@ -1,5 +1,6 @@ "use client"; +import { RiErrorWarningLine } from "@remixicon/react"; import { useState } from "react"; import { Button } from "@/shared/components/ui/button"; import { @@ -13,7 +14,7 @@ import { import { Label } from "@/shared/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"; -export type BulkActionScope = "current" | "future" | "all"; +export type BulkActionScope = "current" | "period" | "future" | "all"; type BulkActionDialogProps = { open: boolean; @@ -108,6 +109,30 @@ export function BulkActionDialog({ +
+ +
+ +

+ Aplica a todos os lançamentos deste mesmo mês na série +

+ {scope === "period" && actionType === "edit" && ( +
+ +

+ Atenção: os valores individuais de cada pagador serão + substituídos pelos valores deste lançamento. +

+
+ )} +
+
+
diff --git a/src/features/transactions/components/dialogs/transaction-details-dialog.tsx b/src/features/transactions/components/dialogs/transaction-details-dialog.tsx index 32ccf10..fe3bf21 100644 --- a/src/features/transactions/components/dialogs/transaction-details-dialog.tsx +++ b/src/features/transactions/components/dialogs/transaction-details-dialog.tsx @@ -223,7 +223,6 @@ export function TransactionDetailsDialog({
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 d3cea2c..51c9e36 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 @@ -30,6 +30,8 @@ export interface TransactionDialogProps { forceShowTransactionType?: boolean; /** Called after successful create/update. Receives the action result. */ onSuccess?: () => void; + /** Max attachment file size in MB for this user */ + maxSizeMb?: number; onBulkEditRequest?: (data: { id: string; name: string; @@ -42,6 +44,8 @@ export interface TransactionDialogProps { dueDate: string | null; boletoPaymentDate: string | null; isSettled: boolean | null; + pendingDetachIds: string[]; + pendingUploadFiles: File[]; }) => void; } 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 5ef8127..681ab3d 100644 --- a/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx +++ b/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx @@ -8,6 +8,7 @@ import { } from "@/features/transactions/actions"; import { confirmAttachmentUploadAction, + detachTransactionAttachmentAction, getPresignedUploadUrlAction, } from "@/features/transactions/actions/attachments"; import { @@ -72,10 +73,11 @@ export function TransactionDialog({ defaultAmount, lockCardSelection, lockPaymentMethod, - isImporting = false, + isImporting, defaultTransactionType, - forceShowTransactionType = false, + forceShowTransactionType, onSuccess, + maxSizeMb, onBulkEditRequest, }: TransactionDialogProps) { const [dialogOpen, setDialogOpen] = useControlledState( @@ -98,6 +100,8 @@ export function TransactionDialog({ const [isPending, startTransition] = useTransition(); const [errorMessage, setErrorMessage] = useState(null); const [pendingFile, setPendingFile] = useState(null); + const [pendingDetachIds, setPendingDetachIds] = useState([]); + const [pendingUploadFiles, setPendingUploadFiles] = useState([]); useEffect(() => { if (dialogOpen) { @@ -136,6 +140,8 @@ export function TransactionDialog({ setFormState(initial); setErrorMessage(null); setPendingFile(null); + setPendingDetachIds([]); + setPendingUploadFiles([]); } }, [ dialogOpen, @@ -342,7 +348,7 @@ export function TransactionDialog({ }); await confirmAttachmentUploadAction({ uploadToken: presign.uploadToken, - applyToSeries: isNewSeries, + scope: isNewSeries ? "all" : "current", }); } } @@ -357,11 +363,11 @@ export function TransactionDialog({ return; } - // Update mode const hasSeriesId = Boolean(transaction?.seriesId); if (hasSeriesId && onBulkEditRequest) { - // Para lançamentos em série, abre o diálogo de bulk action + // Para lançamentos em série, passa os arquivos para a página confirmar + // o upload após o escopo ser escolhido (sem upload antecipado ao S3) onBulkEditRequest({ id: transaction?.id ?? "", name: formState.name.trim(), @@ -383,11 +389,13 @@ export function TransactionDialog({ formState.paymentMethod === "Cartão de crédito" ? null : Boolean(formState.isSettled), + pendingDetachIds, + pendingUploadFiles, }); return; } - // Atualização normal para lançamentos únicos ou todos os campos + // Atualização normal para lançamentos únicos const updatePayload: UpdateTransactionInput = { id: transaction?.id ?? "", ...payload, @@ -396,6 +404,31 @@ export function TransactionDialog({ const result = await updateTransactionAction(updatePayload); if (result.success) { + for (const attachmentId of pendingDetachIds) { + await detachTransactionAttachmentAction({ + attachmentId, + transactionId: transaction?.id ?? "", + }); + } + for (const file of pendingUploadFiles) { + const presign = await getPresignedUploadUrlAction({ + fileName: file.name, + mimeType: file.type, + fileSize: file.size, + transactionId: transaction?.id ?? "", + }); + if (presign.success) { + await fetch(presign.presignedUrl, { + method: "PUT", + body: file, + headers: { "Content-Type": file.type }, + }); + await confirmAttachmentUploadAction({ + uploadToken: presign.uploadToken, + scope: "current", + }); + } + } toast.success(result.message); onSuccess?.(); setDialogOpen(false); @@ -521,7 +554,40 @@ export function TransactionDialog({ setPendingDetachIds((prev) => [...prev, id]) + : undefined + } + onUndoPendingDetach={ + transaction?.seriesId + ? (id) => + setPendingDetachIds((prev) => + prev.filter((x) => x !== id), + ) + : undefined + } + pendingUploadFiles={ + transaction?.seriesId ? pendingUploadFiles : undefined + } + onPendingUpload={ + transaction?.seriesId + ? (file) => + setPendingUploadFiles((prev) => [...prev, file]) + : undefined + } + onCancelPendingUpload={ + transaction?.seriesId + ? (file) => + setPendingUploadFiles((prev) => + prev.filter((f) => f !== file), + ) + : undefined + } />
@@ -548,6 +614,7 @@ export function TransactionDialog({ diff --git a/src/features/transactions/components/page/transactions-page.tsx b/src/features/transactions/components/page/transactions-page.tsx index 812c1de..bfdc0be 100644 --- a/src/features/transactions/components/page/transactions-page.tsx +++ b/src/features/transactions/components/page/transactions-page.tsx @@ -10,6 +10,11 @@ import { toggleTransactionSettlementAction, updateTransactionBulkAction, } from "@/features/transactions/actions"; +import { + confirmAttachmentUploadAction, + detachAttachmentBulkAction, + getPresignedUploadUrlAction, +} from "@/features/transactions/actions/attachments"; import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog"; import type { TransactionsExportContext, @@ -59,6 +64,7 @@ interface TransactionsPageProps { lockPaymentMethod?: boolean; pagination?: TransactionsPaginationState; exportContext?: TransactionsExportContext; + attachmentMaxSizeMb?: number; // Opções específicas para o dialog de importação (quando visualizando dados de outro usuário) importPayerOptions?: SelectOption[]; importSplitPayerOptions?: SelectOption[]; @@ -91,6 +97,7 @@ export function TransactionsPage({ lockPaymentMethod, pagination, exportContext, + attachmentMaxSizeMb, importPayerOptions, importSplitPayerOptions, importDefaultPayerId, @@ -130,6 +137,8 @@ export function TransactionsPage({ dueDate: string | null; boletoPaymentDate: string | null; isSettled: boolean | null; + pendingDetachIds: string[]; + pendingUploadFiles: File[]; transaction: TransactionItem; } | null>(null); const [pendingDeleteData, setPendingDeleteData] = @@ -246,6 +255,8 @@ export function TransactionsPage({ dueDate: string | null; boletoPaymentDate: string | null; isSettled: boolean | null; + pendingDetachIds: string[]; + pendingUploadFiles: File[]; }) => { if (!selectedTransaction) { return; @@ -284,6 +295,36 @@ export function TransactionsPage({ throw new Error(result.error); } + // Propaga remoções de anexo pendentes com o mesmo escopo + for (const attachmentId of pendingEditData.pendingDetachIds) { + await detachAttachmentBulkAction({ + attachmentId, + transactionId: pendingEditData.id, + scope, + }); + } + + // Faz upload dos arquivos pendentes e confirma com o escopo escolhido + for (const file of pendingEditData.pendingUploadFiles) { + const presign = await getPresignedUploadUrlAction({ + fileName: file.name, + mimeType: file.type, + fileSize: file.size, + transactionId: pendingEditData.id, + }); + if (presign.success) { + await fetch(presign.presignedUrl, { + method: "PUT", + body: file, + headers: { "Content-Type": file.type }, + }); + await confirmAttachmentUploadAction({ + uploadToken: presign.uploadToken, + scope, + }); + } + } + toast.success(result.message); setBulkEditOpen(false); setPendingEditData(null); @@ -438,6 +479,7 @@ export function TransactionsPage({ lockCardSelection={lockCardSelection} lockPaymentMethod={lockPaymentMethod} defaultTransactionType={transactionTypeForCreate ?? undefined} + maxSizeMb={attachmentMaxSizeMb} /> ) : null} @@ -459,6 +501,7 @@ export function TransactionsPage({ estabelecimentos={estabelecimentos} transaction={transactionToCopy ?? undefined} defaultPeriod={selectedPeriod} + maxSizeMb={attachmentMaxSizeMb} />