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,208 @@
"use client";
import { RiFileLine, RiFilePdf2Line, RiImageLine } from "@remixicon/react";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import type { AttachmentForPeriod } from "@/features/attachments/queries";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils";
import { formatCurrency } from "@/shared/utils/currency";
import { formatDate } from "@/shared/utils/date";
import { formatBytes } from "@/shared/utils/number";
interface PdfCanvasProps {
url: string;
}
function PdfCanvas({ url }: PdfCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [locked, setLocked] = useState(false);
useEffect(() => {
let cancelled = false;
setLocked(false);
async function render() {
const pdfjsLib = await import("pdfjs-dist");
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;
try {
pdf = await pdfjsLib.getDocument(url).promise;
} catch (err) {
if ((err as { name?: string }).name === "PasswordException") {
if (!cancelled) setLocked(true);
}
return;
}
const page = await pdf.getPage(1);
const canvas = canvasRef.current;
if (!canvas || cancelled) return;
const containerWidth = canvas.parentElement?.offsetWidth ?? 200;
const viewport = page.getViewport({ scale: 1 });
const scale = containerWidth / viewport.width;
const scaled = page.getViewport({ scale });
canvas.width = scaled.width;
canvas.height = scaled.height;
const ctx = canvas.getContext("2d");
if (!ctx) return;
await page.render({ canvasContext: ctx, canvas, viewport: scaled })
.promise;
}
render().catch(() => {});
return () => {
cancelled = true;
};
}, [url]);
if (locked) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/50">
<RiFilePdf2Line className="size-12 text-muted-foreground/40" />
<span className="text-xs font-medium text-muted-foreground/60">
PDF Protegido
</span>
</div>
);
}
return (
<canvas
ref={canvasRef}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
);
}
interface AttachmentGridItemProps {
attachment: AttachmentForPeriod;
url?: string;
onClick: () => void;
onDetails: () => void;
isLoadingDetails?: boolean;
}
export function AttachmentGridItem({
attachment,
url,
onClick,
onDetails,
isLoadingDetails = false,
}: AttachmentGridItemProps) {
const isPdf = attachment.mimeType === "application/pdf";
const isImage = attachment.mimeType.startsWith("image/");
const amount = Number.parseFloat(attachment.transactionAmount);
return (
<div className="group flex flex-col overflow-hidden rounded-lg border bg-card transition-all duration-200 hover:border-primary">
{/* Thumbnail */}
<button
type="button"
onClick={onClick}
className="relative aspect-4/3 w-full border-b overflow-hidden bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset cursor-pointer"
>
{/* Conteúdo do thumbnail */}
{isImage && url && (
<Image
src={url}
alt={attachment.fileName}
fill
unoptimized
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
)}
{isImage && !url && (
<div className="h-full w-full animate-pulse bg-muted-foreground/10" />
)}
{isPdf && url && <PdfCanvas url={url} />}
{isPdf && !url && (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-red-50 dark:bg-red-950/20">
<RiFilePdf2Line className="size-14 text-red-400/60" />
</div>
)}
{!isImage && !isPdf && (
<div className="flex h-full w-full items-center justify-center bg-muted">
<RiFileLine className="size-14 text-muted-foreground/40" />
</div>
)}
{/* Overlay no hover */}
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors duration-200 group-hover:bg-black/10" />
</button>
{/* Informações */}
<div className="flex flex-1 flex-col gap-3 px-4 py-3">
{/* Nome do arquivo + tipo */}
<div className="flex items-center gap-1 min-w-0">
<div className="shrink-0 gap-0.5 text-xs opacity-60">
{isPdf && <RiFilePdf2Line className="size-4 text-red-500" />}
{isImage && <RiImageLine className="size-4 text-blue-500" />}
{!isPdf && !isImage && <RiFileLine className="size-4" />}
</div>
<Tooltip>
<TooltipTrigger asChild>
<p className="truncate text-sm font-medium leading-tight text-foreground">
{attachment.fileName}
</p>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{attachment.fileName}
</TooltipContent>
</Tooltip>
</div>
{/* Data */}
<span className="text-xs text-muted-foreground">
{formatDate(attachment.purchaseDate)}
</span>
{/* Transação e Valor */}
<div className="flex items-start justify-between gap-2">
<Tooltip>
<TooltipTrigger asChild>
<p className="truncate text-sm text-muted-foreground">
{attachment.transactionName}
</p>
</TooltipTrigger>
<TooltipContent side="top">
{attachment.transactionName}
</TooltipContent>
</Tooltip>
<span
className={cn(
"shrink-0 text-sm font-medium tracking-tighter tabular-nums",
)}
>
{formatCurrency(amount)}
</span>
</div>
{/* Footer: Tamanho + Botão Detalhes */}
<div className="mt-auto flex items-center justify-between border-t pt-3">
<span className="text-xs font-medium text-muted-foreground/70">
{formatBytes(attachment.fileSize)}
</span>
<button
type="button"
onClick={onDetails}
disabled={isLoadingDetails}
className="text-xs font-medium text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
>
{isLoadingDetails ? "Carregando..." : "Detalhes"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,201 @@
"use client";
import {
RiArrowLeftSLine,
RiArrowRightSLine,
RiCloseLine,
RiDownloadLine,
RiExternalLinkLine,
} from "@remixicon/react";
import { useEffect, useState } from "react";
import type { AttachmentForPeriod } from "@/features/attachments/queries";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
interface AttachmentPreviewProps {
attachments: AttachmentForPeriod[];
selectedIndex: number;
onClose: () => void;
}
export function AttachmentPreview({
attachments,
selectedIndex,
onClose,
}: AttachmentPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(selectedIndex);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const open = selectedIndex >= 0;
useEffect(() => {
if (selectedIndex >= 0) setCurrentIndex(selectedIndex);
}, [selectedIndex]);
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "ArrowLeft") setCurrentIndex((i) => Math.max(0, i - 1));
if (e.key === "ArrowRight")
setCurrentIndex((i) => Math.min(attachments.length - 1, i + 1));
}
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [open, attachments.length]);
const attachment = attachments[currentIndex];
const attachmentId = attachment?.attachmentId;
// Busca URL fresca a cada troca de anexo
useEffect(() => {
if (!attachmentId) return;
setPreviewUrl(null);
fetch(`/api/attachments/${attachmentId}/presign`)
.then((r) => r.json())
.then((data: { url: string }) => setPreviewUrl(data.url))
.catch(() => {});
}, [attachmentId]);
if (!attachment) return null;
const isPdf = attachment.mimeType === "application/pdf";
const isImage = attachment.mimeType.startsWith("image/");
const hasPrev = currentIndex > 0;
const hasNext = currentIndex < attachments.length - 1;
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<DialogContent
showCloseButton={false}
className="flex h-[92vh] w-[min(96vw,1400px)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:p-0"
>
<DialogHeader className="flex-row items-start justify-between gap-3 border-b px-4 py-3 sm:px-5">
<div className="min-w-0 space-y-0.5">
<DialogTitle
className="truncate text-sm font-medium"
title={attachment.transactionName}
>
{attachment.transactionName}
</DialogTitle>
<p
className="truncate text-xs text-muted-foreground"
title={attachment.fileName}
>
{attachment.fileName}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{attachments.length > 1 && (
<>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!hasPrev}
onClick={() => setCurrentIndex((i) => i - 1)}
title="Anterior (←)"
>
<RiArrowLeftSLine className="size-4" />
</Button>
<span className="select-none text-xs text-muted-foreground tabular-nums">
{currentIndex + 1} / {attachments.length}
</span>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!hasNext}
onClick={() => setCurrentIndex((i) => i + 1)}
title="Próximo (→)"
>
<RiArrowRightSLine className="size-4" />
</Button>
</>
)}
<Button
type="button"
variant="ghost"
size="icon"
disabled={!previewUrl}
asChild={!!previewUrl}
>
{previewUrl ? (
<a
href={previewUrl}
target="_blank"
rel="noreferrer"
download={attachment.fileName}
>
<RiDownloadLine className="size-4" />
</a>
) : (
<RiDownloadLine className="size-4" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!previewUrl}
asChild={!!previewUrl}
>
{previewUrl ? (
<a href={previewUrl} target="_blank" rel="noreferrer">
<RiExternalLinkLine className="size-4" />
</a>
) : (
<RiExternalLinkLine className="size-4" />
)}
</Button>
<DialogClose asChild>
<Button type="button" variant="ghost" size="icon">
<RiCloseLine className="size-4" />
</Button>
</DialogClose>
</div>
</DialogHeader>
<div className="min-h-0 min-w-0 flex-1">
{!previewUrl && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
</div>
)}
{isPdf && previewUrl && (
<iframe
key={attachment.attachmentId}
src={previewUrl}
className="h-full w-full border-0 bg-background"
title={attachment.fileName}
/>
)}
{isImage && previewUrl && (
<div className="flex h-full w-full items-center justify-center bg-black/85 p-4 sm:p-6">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
key={attachment.attachmentId}
src={previewUrl}
alt={attachment.fileName}
className="max-h-full max-w-full rounded-md object-contain"
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,275 @@
"use client";
import {
RiAttachmentLine,
RiFilePdf2Line,
RiImageLine,
} from "@remixicon/react";
import { useRouter } from "next/navigation";
import type React from "react";
import { useState, useTransition } from "react";
import { AttachmentGridItem } from "@/features/attachments/components/attachment-grid-item";
import { AttachmentPreview } from "@/features/attachments/components/attachment-preview";
import { useAttachmentUrl } from "@/features/attachments/hooks/use-attachment-url";
import type { AttachmentForPeriod } from "@/features/attachments/queries";
import { fetchTransactionByIdAction } from "@/features/transactions/actions/fetch-by-id";
import type { TransactionDialogOptions } from "@/features/transactions/actions/fetch-dialog-options";
import { fetchTransactionDialogOptionsAction } from "@/features/transactions/actions/fetch-dialog-options";
import { TransactionDetailsDialog } from "@/features/transactions/components/dialogs/transaction-details-dialog";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import type { TransactionItem } from "@/features/transactions/components/types";
import { EmptyState } from "@/shared/components/empty-state";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import PageDescription from "@/shared/components/page-description";
import { Card, CardContent } from "@/shared/components/ui/card";
import { cn } from "@/shared/utils/ui";
type FilterType = "all" | "images" | "pdfs";
function AttachmentGridItemWithUrl({
attachment,
onClick,
onDetails,
isLoadingDetails,
}: {
attachment: AttachmentForPeriod;
onClick: () => void;
onDetails: () => void;
isLoadingDetails: boolean;
}) {
const { url, containerRef } = useAttachmentUrl(attachment.attachmentId);
return (
<div ref={containerRef}>
<AttachmentGridItem
attachment={attachment}
url={url ?? undefined}
onClick={onClick}
onDetails={onDetails}
isLoadingDetails={isLoadingDetails}
/>
</div>
);
}
const FILTERS: {
value: FilterType;
label: string;
icon: React.ReactNode;
}[] = [
{
value: "all",
label: "Todos",
icon: <RiAttachmentLine className="size-3.5" />,
},
{
value: "images",
label: "Imagens",
icon: <RiImageLine className="size-3.5 text-blue-500" />,
},
{
value: "pdfs",
label: "PDFs",
icon: <RiFilePdf2Line className="size-3.5 text-red-500" />,
},
];
interface AttachmentsPageProps {
attachments: AttachmentForPeriod[];
}
export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
const router = useRouter();
const [filter, setFilter] = useState<FilterType>("all");
const [selectedIndex, setSelectedIndex] = useState(-1);
const [transactionDetails, setTransactionDetails] =
useState<TransactionItem | null>(null);
const [loadingTransactionId, setLoadingTransactionId] = useState<
string | null
>(null);
const [isPending, startTransition] = useTransition();
// Edit dialog state
const [editOpen, setEditOpen] = useState(false);
const [transactionToEdit, setTransactionToEdit] =
useState<TransactionItem | null>(null);
const [dialogOptions, setDialogOptions] =
useState<TransactionDialogOptions | null>(null);
const filteredAttachments = attachments.filter((a) => {
if (filter === "images") return a.mimeType.startsWith("image/");
if (filter === "pdfs") return a.mimeType === "application/pdf";
return true;
});
const imageCount = attachments.filter((a) =>
a.mimeType.startsWith("image/"),
).length;
const pdfCount = attachments.filter(
(a) => a.mimeType === "application/pdf",
).length;
const counts: Record<FilterType, number> = {
all: attachments.length,
images: imageCount,
pdfs: pdfCount,
};
function handleSelect(attachment: AttachmentForPeriod) {
const idx = filteredAttachments.findIndex(
(a) =>
a.attachmentId === attachment.attachmentId &&
a.transactionId === attachment.transactionId,
);
setSelectedIndex(idx);
}
function handleDetails(transactionId: string) {
setLoadingTransactionId(transactionId);
startTransition(async () => {
const transaction = await fetchTransactionByIdAction(transactionId);
setLoadingTransactionId(null);
if (transaction) setTransactionDetails(transaction);
});
}
function handleEdit(transaction: TransactionItem) {
setTransactionToEdit(transaction);
startTransition(async () => {
const options = await fetchTransactionDialogOptionsAction();
setDialogOptions(options);
setEditOpen(true);
});
}
return (
<div className="w-full space-y-6">
<PageDescription
icon={<RiAttachmentLine className="size-5" />}
title="Anexos"
subtitle="Comprovantes e documentos dos seus lançamentos no mês."
/>
<MonthNavigation />
<Card>
<CardContent>
{attachments.length === 0 ? (
<div className="flex w-full items-center justify-center py-12">
<EmptyState
media={<RiAttachmentLine className="size-6 text-primary" />}
title="Nenhum anexo neste mês"
description="Adicione comprovantes nos seus lançamentos para vê-los aqui."
/>
</div>
) : (
<div className="space-y-4">
{/* Header: filtros + contagem */}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{filteredAttachments.length}{" "}
{filteredAttachments.length === 1 ? "anexo" : "anexos"}
{filter !== "all" &&
` · ${FILTERS.find((f) => f.value === filter)?.label.toLowerCase()}`}
</p>
<div className="flex items-center gap-1 rounded-lg border p-1">
{FILTERS.map(({ value, label, icon }) => (
<button
key={value}
type="button"
onClick={() => {
setFilter(value);
setSelectedIndex(-1);
}}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
filter === value
? "bg-primary text-primary-foreground [&_svg]:opacity-100"
: "text-muted-foreground hover:text-foreground",
)}
>
<span className={cn(filter !== value && "opacity-60")}>
{icon}
</span>
{label}{" "}
<span
className={cn(
"tabular-nums",
filter === value ? "opacity-80" : "opacity-60",
)}
>
({counts[value]})
</span>
</button>
))}
</div>
</div>
{filteredAttachments.length === 0 ? (
<div className="flex w-full items-center justify-center py-12">
<EmptyState
media={<RiAttachmentLine className="size-6 text-primary" />}
title="Nenhum anexo encontrado"
description="Não há anexos do tipo selecionado neste mês."
/>
</div>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{filteredAttachments.map((attachment) => (
<AttachmentGridItemWithUrl
key={`${attachment.attachmentId}-${attachment.transactionId}`}
attachment={attachment}
onClick={() => handleSelect(attachment)}
onDetails={() => handleDetails(attachment.transactionId)}
isLoadingDetails={
isPending &&
loadingTransactionId === attachment.transactionId
}
/>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
<AttachmentPreview
attachments={filteredAttachments}
selectedIndex={selectedIndex}
onClose={() => setSelectedIndex(-1)}
/>
<TransactionDetailsDialog
open={!!transactionDetails}
onOpenChange={(open) => {
if (!open) setTransactionDetails(null);
}}
transaction={transactionDetails}
onEdit={handleEdit}
/>
{dialogOptions && transactionToEdit && (
<TransactionDialog
mode="update"
open={editOpen}
onOpenChange={(open) => {
setEditOpen(open);
if (!open) {
setTransactionToEdit(null);
setDialogOptions(null);
router.refresh();
}
}}
transaction={transactionToEdit}
payerOptions={dialogOptions.payerOptions}
splitPayerOptions={dialogOptions.splitPayerOptions}
defaultPayerId={dialogOptions.defaultPayerId}
accountOptions={dialogOptions.accountOptions}
cardOptions={dialogOptions.cardOptions}
categoryOptions={dialogOptions.categoryOptions}
estabelecimentos={dialogOptions.estabelecimentos}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { useEffect, useRef, useState } from "react";
export function useAttachmentUrl(attachmentId: string) {
const [url, setUrl] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setUrl(null);
const el = containerRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (!entries[0].isIntersecting) return;
observer.disconnect();
fetch(`/api/attachments/${attachmentId}/presign`)
.then((r) => r.json())
.then((data: { url: string }) => setUrl(data.url))
.catch(() => {});
},
{ rootMargin: "150px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [attachmentId]);
return { url, containerRef };
}

View File

@@ -0,0 +1,70 @@
import { and, desc, eq } from "drizzle-orm";
import { cacheLife, cacheTag } from "next/cache";
import {
attachments,
categories,
transactionAttachments,
transactions,
} from "@/db/schema";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
export type AttachmentForPeriod = {
attachmentId: string;
fileName: string;
fileSize: number;
mimeType: string;
transactionId: string;
transactionName: string;
transactionAmount: string;
transactionPeriod: string;
purchaseDate: Date;
categoryName: string | null;
categoryIcon: string | null;
};
export async function fetchAttachmentsForPeriod(
userId: string,
period: string,
): Promise<AttachmentForPeriod[]> {
"use cache";
cacheTag(`dashboard-${userId}`);
cacheLife({ revalidate: 3 });
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) return [];
return db
.select({
attachmentId: attachments.id,
fileName: attachments.fileName,
fileSize: attachments.fileSize,
mimeType: attachments.mimeType,
transactionId: transactions.id,
transactionName: transactions.name,
transactionAmount: transactions.amount,
transactionPeriod: transactions.period,
purchaseDate: transactions.purchaseDate,
categoryName: categories.name,
categoryIcon: categories.icon,
})
.from(transactionAttachments)
.innerJoin(
attachments,
and(
eq(transactionAttachments.attachmentId, attachments.id),
eq(attachments.userId, userId),
),
)
.innerJoin(
transactions,
and(
eq(transactionAttachments.transactionId, transactions.id),
eq(transactions.userId, userId),
eq(transactions.payerId, adminPayerId),
eq(transactions.period, period),
),
)
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.orderBy(desc(transactions.purchaseDate), desc(attachments.id));
}

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>