feat(anexos): página de galeria de comprovantes e documentos

Adiciona rota `/attachments` com visualização de todos os anexos do
usuário em grade, visualização inline de imagem e PDF, navegação entre
arquivos do mesmo lançamento e download direto.

Inclui também:
- API REST em `/api/attachments` para servir os arquivos
- Actions `fetch-by-id` e `fetch-dialog-options` em transactions
- Item "Anexos" adicionado à navbar
- `formatBytes` extraído para `src/shared/utils/number.ts`
- Migrations de banco atualizadas
- Fix: uploads e remoções de anexo agora funcionam para todos os
  lançamentos, não apenas os pertencentes a séries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-04-01 14:13:54 +00:00
parent cad41680eb
commit 0ab3298cef
19 changed files with 3898 additions and 3095 deletions

View File

@@ -0,0 +1,23 @@
"use server";
import { eq } from "drizzle-orm";
import { transactions } from "@/db/schema";
import { mapTransactionsData } from "@/features/transactions/page-helpers";
import { fetchTransactionsWithRelations } from "@/features/transactions/queries";
import { getUser } from "@/shared/lib/auth/server";
import type { TransactionItem } from "../components/types";
export async function fetchTransactionByIdAction(
transactionId: string,
): Promise<TransactionItem | null> {
const user = await getUser();
const rows = await fetchTransactionsWithRelations({
filters: [
eq(transactions.id, transactionId),
eq(transactions.userId, user.id),
],
excludeInitialBalanceFromIncome: false,
});
const mapped = mapTransactionsData(rows);
return mapped[0] ?? null;
}

View File

@@ -0,0 +1,55 @@
"use server";
import {
buildOptionSets,
buildSluggedFilters,
} from "@/features/transactions/page-helpers";
import {
fetchRecentEstablishments,
fetchTransactionFilterSources,
} from "@/features/transactions/queries";
import { getUserId } from "@/shared/lib/auth/server";
import type { SelectOption } from "../components/types";
export type TransactionDialogOptions = {
payerOptions: SelectOption[];
splitPayerOptions: SelectOption[];
defaultPayerId: string | null;
accountOptions: SelectOption[];
cardOptions: SelectOption[];
categoryOptions: SelectOption[];
estabelecimentos: string[];
};
export async function fetchTransactionDialogOptionsAction(): Promise<TransactionDialogOptions> {
const userId = await getUserId();
const [filterSources, estabelecimentos] = await Promise.all([
fetchTransactionFilterSources(userId),
fetchRecentEstablishments(userId),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const {
payerOptions,
splitPayerOptions,
defaultPayerId,
accountOptions,
cardOptions,
categoryOptions,
} = buildOptionSets({
...sluggedFilters,
payerRows: filterSources.payerRows,
});
return {
payerOptions,
splitPayerOptions,
defaultPayerId,
accountOptions,
cardOptions,
categoryOptions,
estabelecimentos,
};
}

View File

@@ -85,7 +85,7 @@ export function AttachmentFilePicker({
<RiAttachment2 className="size-4" />
Adicionar anexo
</span>
<span className="text-[11px]">
<span className="text-xs">
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
</span>
</button>

View File

@@ -5,8 +5,7 @@ import {
RiDownloadLine,
RiExternalLinkLine,
RiFileImageLine,
RiFileLine,
RiFilePdfLine,
RiFilePdf2Line,
} from "@remixicon/react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
@@ -30,10 +29,9 @@ function formatBytes(bytes: number): string {
function AttachmentIcon({ mimeType }: { mimeType: string }) {
if (mimeType === "application/pdf")
return <RiFilePdfLine className="size-4 text-red-500 shrink-0" />;
return <RiFilePdf2Line className="size-4 text-red-500 shrink-0" />;
if (mimeType.startsWith("image/"))
return <RiFileImageLine className="size-4 text-blue-500 shrink-0" />;
return <RiFileLine className="size-4 text-muted-foreground shrink-0" />;
}
function AttachmentPreview({

View File

@@ -41,6 +41,7 @@ export function TransactionDetailsDialog({
}: TransactionDetailsDialogProps) {
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: transaction?.id é trigger intencional para reset do contador
useEffect(() => {
setAttachmentCount(null);
}, [transaction?.id]);
@@ -87,7 +88,7 @@ export function TransactionDetailsDialog({
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Resumo
</p>
<p className="mt-1 text-2xl font-semibold">
<p className="mt-1 text-2xl font-medium">
{currencyFormatter.format(valorTotal)}
</p>
</div>
@@ -235,14 +236,14 @@ export function TransactionDetailsDialog({
<Separator />
<DialogFooter>
{onEdit && !transaction.readonly && (
<Button variant="outline" onClick={handleEdit}>
Editar
</Button>
)}
<DialogClose asChild>
<Button type="button">Fechar</Button>
<Button type="button" variant="outline">
Fechar
</Button>
</DialogClose>
{onEdit && !transaction.readonly && (
<Button onClick={handleEdit}>Editar</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -156,6 +156,7 @@ export function TransactionDialog({
defaultTransactionType,
isImporting,
cardOptions,
mode,
]);
const primaryPayerId = formState.payerId;
@@ -555,38 +556,23 @@ export function TransactionDialog({
<AttachmentSection
transactionId={transaction?.id ?? ""}
maxSizeMb={maxSizeMb}
pendingDetachIds={
transaction?.seriesId ? pendingDetachIds : undefined
pendingDetachIds={pendingDetachIds}
onPendingDetach={(id) =>
setPendingDetachIds((prev) => [...prev, id])
}
onPendingDetach={
transaction?.seriesId
? (id) => setPendingDetachIds((prev) => [...prev, id])
: undefined
onUndoPendingDetach={(id) =>
setPendingDetachIds((prev) =>
prev.filter((x) => x !== id),
)
}
onUndoPendingDetach={
transaction?.seriesId
? (id) =>
setPendingDetachIds((prev) =>
prev.filter((x) => x !== id),
)
: undefined
pendingUploadFiles={pendingUploadFiles}
onPendingUpload={(file) =>
setPendingUploadFiles((prev) => [...prev, file])
}
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
onCancelPendingUpload={(file) =>
setPendingUploadFiles((prev) =>
prev.filter((f) => f !== file),
)
}
/>
</div>