feat(lançamentos): escopo "period" na ação em lote e correção do fluxo de anexos em séries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-03-30 18:46:33 +00:00
parent 59b4dea071
commit f418987f47
11 changed files with 477 additions and 141 deletions

View File

@@ -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<ActionResult> {
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<ActionResult> {
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 }>,

View File

@@ -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),
});

View File

@@ -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<typeof deleteBulkSchema>;
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

View File

@@ -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 (
<>
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm">
<div
className={`flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm transition-opacity ${isPendingDelete ? "opacity-50 border-dashed" : ""}`}
>
<AttachmentIcon mimeType={mimeType} />
{canPreview ? (
{isPendingDelete ? (
<div className="flex-1 min-w-0">
<p className="truncate font-medium line-through">{fileName}</p>
<p className="text-xs text-muted-foreground">
Será removido ao salvar
</p>
</div>
) : canPreview ? (
<button
type="button"
className="min-w-0 flex-1 text-left"
@@ -184,29 +204,42 @@ export function AttachmentItem({
</p>
</div>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
asChild
>
<a href={url} target="_blank" rel="noreferrer" download={fileName}>
<RiDownloadLine className="size-4" />
</a>
</Button>
{!readonly && (
{!isPendingDelete && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0 text-destructive hover:text-destructive"
onClick={() => setConfirmOpen(true)}
disabled={isPending}
className="size-7 shrink-0"
asChild
>
<RiDeleteBinLine className="size-4" />
<a href={url} target="_blank" rel="noreferrer" download={fileName}>
<RiDownloadLine className="size-4" />
</a>
</Button>
)}
{!readonly &&
(isPendingDelete ? (
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 text-xs h-7 px-2"
onClick={() => onUndoPendingDelete?.(attachmentId)}
>
Desfazer
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0 text-destructive hover:text-destructive"
onClick={() => setConfirmOpen(true)}
disabled={isPending}
>
<RiDeleteBinLine className="size-4" />
</Button>
))}
</div>
{canPreview && (

View File

@@ -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<AttachmentRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -45,42 +59,70 @@ export function AttachmentSection({
load();
}, [load]);
if (isLoading) {
return <p className="text-xs text-muted-foreground">Carregando...</p>;
}
const hasPendingUploads = (pendingUploadFiles?.length ?? 0) > 0;
return (
<div className="min-w-0 space-y-2 overflow-hidden">
{isLoading ? (
<p className="text-xs text-muted-foreground">Carregando...</p>
) : (
<>
{items.length > 0 ? (
<div className="min-w-0 space-y-1.5">
{items.map((item) => (
<AttachmentItem
key={item.attachmentId}
attachmentId={item.attachmentId}
transactionId={transactionId}
fileName={item.fileName}
fileSize={item.fileSize}
mimeType={item.mimeType}
url={item.url}
onDeleted={load}
readonly={readonly}
/>
))}
</div>
) : (
readonly && (
<p className="text-xs text-muted-foreground">Nenhum anexo.</p>
)
)}
{items.length === 0 && !hasPendingUploads && readonly && (
<p className="text-xs text-muted-foreground">Nenhum anexo.</p>
)}
{!readonly && (
<AttachmentUpload
{(items.length > 0 || hasPendingUploads) && (
<div className="min-w-0 space-y-1.5">
{items.map((item) => (
<AttachmentItem
key={item.attachmentId}
attachmentId={item.attachmentId}
transactionId={transactionId}
seriesId={seriesId}
onUploaded={load}
fileName={item.fileName}
fileSize={item.fileSize}
mimeType={item.mimeType}
url={item.url}
onDeleted={load}
readonly={readonly}
isPendingDelete={pendingDetachIds?.includes(item.attachmentId)}
onPendingDelete={onPendingDetach}
onUndoPendingDelete={onUndoPendingDetach}
/>
)}
</>
))}
{pendingUploadFiles?.map((file) => (
<div
key={`${file.name}-${file.size}`}
className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border border-dashed px-3 py-2 text-sm opacity-60"
>
<RiFileAddLine className="size-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="truncate font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
Será adicionado ao salvar
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 text-xs h-7 px-2"
onClick={() => onCancelPendingUpload?.(file)}
>
Cancelar
</Button>
</div>
))}
</div>
)}
{!readonly && (
<AttachmentUpload
transactionId={transactionId}
onUploaded={load}
onPendingUpload={onPendingUpload}
maxSizeMb={maxSizeMb}
/>
)}
</div>
);

View File

@@ -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<HTMLInputElement>(null);
const [isPending, startTransition] = useTransition();
const [applyToSeries, setApplyToSeries] = useState(false);
const [pendingFile, setPendingFile] = useState<File | null>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<div className="min-w-0 space-y-2 rounded-md border border-dashed p-3 text-sm">
<div className="min-w-0 overflow-hidden">
<p className="truncate font-medium" title={pendingFile.name}>
{pendingFile.name}
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="apply-series"
checked={applyToSeries}
onCheckedChange={(v) => setApplyToSeries(Boolean(v))}
/>
<Label htmlFor="apply-series" className="cursor-pointer text-xs">
Aplicar a todas as parcelas da série
</Label>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
onClick={handleConfirmPending}
disabled={isPending}
>
{isPending ? "Enviando..." : "Confirmar"}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleCancelPending}
disabled={isPending}
>
Cancelar
</Button>
</div>
</div>
);
}
return (
<>
<input
@@ -172,7 +114,9 @@ export function AttachmentUpload({
{isPending ? "Enviando..." : "Adicionar anexo"}
</span>
{!isPending && (
<span className="text-xs">PDF, JPEG, PNG ou WebP · máx. 50 MB</span>
<span className="text-xs">
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
</span>
)}
</button>
</>

View File

@@ -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({
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="period" id="period" className="mt-0.5" />
<div className="flex-1">
<Label
htmlFor="period"
className="text-sm cursor-pointer font-medium"
>
Todos os pagadores deste período
</Label>
<p className="text-xs text-muted-foreground">
Aplica a todos os lançamentos deste mesmo mês na série
</p>
{scope === "period" && actionType === "edit" && (
<div className="mt-1.5 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300">
<RiErrorWarningLine className="mt-0.5 size-3.5 shrink-0" />
<p className="text-xs">
Atenção: os valores individuais de cada pagador serão
substituídos pelos valores deste lançamento.
</p>
</div>
)}
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="future" id="future" className="mt-0.5" />
<div className="flex-1">

View File

@@ -223,7 +223,6 @@ export function TransactionDetailsDialog({
<div className="min-w-0">
<AttachmentSection
transactionId={transaction.id}
seriesId={transaction.seriesId}
readonly
onLoaded={setAttachmentCount}
/>

View File

@@ -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;
}

View File

@@ -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<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
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({
</Label>
<AttachmentSection
transactionId={transaction?.id ?? ""}
seriesId={transaction?.seriesId ?? null}
maxSizeMb={maxSizeMb}
pendingDetachIds={
transaction?.seriesId ? pendingDetachIds : undefined
}
onPendingDetach={
transaction?.seriesId
? (id) => 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
}
/>
</div>
</>
@@ -548,6 +614,7 @@ export function TransactionDialog({
<AttachmentFilePicker
file={pendingFile}
onChange={setPendingFile}
maxSizeMb={maxSizeMb}
/>
</CollapsibleContent>
</Collapsible>

View File

@@ -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}
/>
<TransactionDialog
@@ -480,6 +523,7 @@ export function TransactionsPage({
transaction={transactionToImport ?? undefined}
defaultPeriod={selectedPeriod}
isImporting={true}
maxSizeMb={attachmentMaxSizeMb}
/>
<BulkImportDialog
@@ -507,6 +551,7 @@ export function TransactionsPage({
transaction={selectedTransaction ?? undefined}
defaultPeriod={selectedPeriod}
onBulkEditRequest={handleBulkEditRequest}
maxSizeMb={attachmentMaxSizeMb}
/>
<TransactionDetailsDialog