feat(lancamentos): adicionar suporte a anexos com upload para storage S3

Permite vincular arquivos (PDF, imagens) a lançamentos via upload direto
para storage compatível com S3, usando token assinado por arquivo e
validação de propriedade na leitura e remoção.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-03-28 15:13:05 +00:00
parent 32da4f906e
commit f82043127a
14 changed files with 1392 additions and 141 deletions

View File

@@ -1,5 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import {
currencyFormatter,
formatCondition,
@@ -21,6 +22,7 @@ import {
import { Separator } from "@/shared/components/ui/separator";
import { parseLocalDateString } from "@/shared/utils/date";
import { getPaymentMethodIcon } from "@/shared/utils/icons";
import { AttachmentSection } from "../attachments/attachment-section";
import { InstallmentTimeline } from "../shared/installment-timeline";
import type { TransactionItem } from "../types";
@@ -37,6 +39,12 @@ export function TransactionDetailsDialog({
transaction,
onEdit,
}: TransactionDetailsDialogProps) {
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
useEffect(() => {
setAttachmentCount(null);
}, [transaction?.id]);
if (!transaction) return null;
const isInstallment =
@@ -63,7 +71,7 @@ export function TransactionDetailsDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogContent className="min-w-0 overflow-x-hidden sm:max-w-xl">
<DialogHeader>
<DialogTitle>{transaction.name}</DialogTitle>
<DialogDescription>
@@ -71,57 +79,18 @@ export function TransactionDetailsDialog({
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto text-sm">
<div className="grid gap-3">
<ul className="grid gap-3">
<DetailRow
label="Período"
value={formatPeriod(transaction.period)}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
Forma de Pagamento
</span>
<span className="flex items-center gap-1.5">
{getPaymentMethodIcon(transaction.paymentMethod)}
<span>{transaction.paymentMethod}</span>
</span>
</li>
<DetailRow
label={transaction.cartaoName ? "Cartão" : "Conta"}
value={transaction.cartaoName ?? transaction.contaName ?? "—"}
/>
<DetailRow
label="Categoria"
value={transaction.categoriaName ?? "—"}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Tipo de Transação</span>
<TransactionTypeBadge
kind={
transaction.categoriaName === "Saldo inicial"
? "Saldo inicial"
: transaction.transactionType
}
/>
</li>
<DetailRow
label="Condição"
value={formatCondition(transaction.condition)}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Responsável</span>
<span>{transaction.pagadorName}</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Status</span>
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
<div className="min-w-0 space-y-4">
<section className="rounded-lg border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Resumo
</p>
<p className="mt-1 text-2xl font-semibold">
{currencyFormatter.format(valorTotal)}
</p>
</div>
<Badge
variant="secondary"
className={
@@ -132,75 +101,140 @@ export function TransactionDetailsDialog({
>
{transaction.isSettled ? "Pago" : "Pendente"}
</Badge>
</li>
{isBoleto && transaction.dueDate && (
<DetailRow
label="Vencimento"
value={formatDate(transaction.dueDate)}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<TransactionTypeBadge
kind={
transaction.categoriaName === "Saldo inicial"
? "Saldo inicial"
: transaction.transactionType
}
/>
<span>{formatCondition(transaction.condition)}</span>
</div>
</section>
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Detalhes
</h3>
<ul className="min-w-0 grid gap-2 rounded-lg border p-3">
<DetailRow
label="Período"
value={formatPeriod(transaction.period)}
/>
)}
{transaction.isDivided && (
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Divisão</span>
<Badge variant="outline">Dividido</Badge>
<span className="text-muted-foreground">
Forma de Pagamento
</span>
<span className="flex items-center gap-1.5">
{getPaymentMethodIcon(transaction.paymentMethod)}
<span>{transaction.paymentMethod}</span>
</span>
</li>
)}
{transaction.note && (
<li className="flex flex-col gap-1">
<span className="text-muted-foreground">Notas</span>
<span className="text-foreground">{transaction.note}</span>
<DetailRow
label={transaction.cartaoName ? "Cartão" : "Conta"}
value={transaction.cartaoName ?? transaction.contaName ?? "—"}
/>
<DetailRow
label="Categoria"
value={transaction.categoriaName ?? "—"}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Responsável</span>
<span>{transaction.pagadorName}</span>
</li>
)}
</ul>
<ul className="mb-2 grid gap-3">
{isInstallment && (
<li className="mt-4">
<InstallmentTimeline
purchaseDate={parseLocalDateString(
transaction.purchaseDate,
)}
currentInstallment={parcelaAtual}
totalInstallments={totalParcelas}
period={transaction.period}
{isBoleto && transaction.dueDate && (
<DetailRow
label="Vencimento"
value={formatDate(transaction.dueDate)}
/>
</li>
)}
)}
<DetailRow
label={isInstallment ? "Valor da Parcela" : "Valor"}
value={currencyFormatter.format(valorParcela)}
/>
{transaction.isDivided && (
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Divisão</span>
<Badge variant="outline">Dividido</Badge>
</li>
)}
</ul>
</section>
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Valores
</h3>
<ul className="min-w-0 grid gap-2 rounded-lg border p-3">
{isInstallment && (
<li className="mb-1">
<InstallmentTimeline
purchaseDate={parseLocalDateString(
transaction.purchaseDate,
)}
currentInstallment={parcelaAtual}
totalInstallments={totalParcelas}
period={transaction.period}
/>
</li>
)}
{isInstallment && (
<DetailRow
label="Valor Restante"
value={currencyFormatter.format(valorRestante)}
label={isInstallment ? "Valor da Parcela" : "Valor"}
value={currencyFormatter.format(valorParcela)}
/>
)}
{transaction.recurrenceCount && (
<DetailRow
label="Quantidade de Recorrências"
value={`${transaction.recurrenceCount} meses`}
/>
)}
{isInstallment && (
<DetailRow
label="Valor Restante"
value={currencyFormatter.format(valorRestante)}
/>
)}
{!isInstallment && <Separator className="my-2" />}
{transaction.recurrenceCount && (
<DetailRow
label="Quantidade de Recorrências"
value={`${transaction.recurrenceCount} meses`}
/>
)}
</ul>
</section>
<li className="flex items-center justify-between font-semibold">
<span className="text-muted-foreground">Total da Compra</span>
<span className="text-lg">
{currencyFormatter.format(valorTotal)}
</span>
</li>
</ul>
{transaction.note ? (
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Notas
</h3>
<div className="rounded-lg border p-3 text-foreground">
{transaction.note}
</div>
</section>
) : null}
{attachmentCount !== 0 && (
<section className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Anexos
</h3>
<div className="min-w-0">
<AttachmentSection
transactionId={transaction.id}
seriesId={transaction.seriesId}
readonly
onLoaded={setAttachmentCount}
/>
</div>
</section>
)}
</div>
</div>
<Separator />
<DialogFooter>
{onEdit && !transaction.readonly && (
<Button variant="outline" onClick={handleEdit}>
@@ -223,9 +257,9 @@ interface DetailRowProps {
function DetailRow({ label, value }: DetailRowProps) {
return (
<li className="flex items-center justify-between">
<li className="min-w-0 flex items-center justify-between gap-3">
<span className="text-muted-foreground">{label}</span>
<span>{value}</span>
<span className="min-w-0 truncate">{value}</span>
</li>
);
}