Files
openmonetis/src/features/transactions/components/attachments/attachment-item.tsx
Felipe Coutinho 0ab3298cef 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>
2026-04-01 14:13:54 +00:00

285 lines
7.1 KiB
TypeScript

"use client";
import {
RiDeleteBinLine,
RiDownloadLine,
RiExternalLinkLine,
RiFileImageLine,
RiFilePdf2Line,
} from "@remixicon/react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { detachTransactionAttachmentAction } from "@/features/transactions/actions/attachments";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function AttachmentIcon({ mimeType }: { mimeType: string }) {
if (mimeType === "application/pdf")
return <RiFilePdf2Line className="size-4 text-red-500 shrink-0" />;
if (mimeType.startsWith("image/"))
return <RiFileImageLine className="size-4 text-blue-500 shrink-0" />;
}
function AttachmentPreview({
open,
onOpenChange,
fileName,
mimeType,
url,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
mimeType: string;
url: string;
}) {
const isPdf = mimeType === "application/pdf";
const isImage = mimeType.startsWith("image/");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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-center justify-between gap-3 border-b px-4 py-3 sm:px-5">
<div className="min-w-0">
<DialogTitle
className="truncate text-sm font-medium"
title={fileName}
>
{fileName}
</DialogTitle>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="icon" asChild>
<a
href={url}
target="_blank"
rel="noreferrer"
download={fileName}
>
<RiDownloadLine className="size-4" />
</a>
</Button>
<Button type="button" variant="ghost" size="icon" asChild>
<a href={url} target="_blank" rel="noreferrer">
<RiExternalLinkLine className="size-4" />
</a>
</Button>
<DialogClose asChild>
<Button type="button" variant="ghost" size="sm">
Fechar
</Button>
</DialogClose>
</div>
</DialogHeader>
<div className="min-h-0 min-w-0 flex-1">
{isPdf && (
<iframe
src={url}
className="h-full w-full border-0 bg-background"
title={fileName}
/>
)}
{isImage && (
<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
src={url}
alt={fileName}
className="max-h-full max-w-full rounded-md object-contain"
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
interface AttachmentItemProps {
attachmentId: string;
transactionId: string;
fileName: string;
fileSize: number;
mimeType: string;
url: string;
onDeleted: () => void;
readonly?: boolean;
isPendingDelete?: boolean;
onPendingDelete?: (attachmentId: string) => void;
onUndoPendingDelete?: (attachmentId: string) => void;
}
export function AttachmentItem({
attachmentId,
transactionId,
fileName,
fileSize,
mimeType,
url,
onDeleted,
readonly = false,
isPendingDelete = false,
onPendingDelete,
onUndoPendingDelete,
}: AttachmentItemProps) {
const [isPending, startTransition] = useTransition();
const [previewOpen, setPreviewOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const canPreview =
mimeType === "application/pdf" || mimeType.startsWith("image/");
function handleDelete() {
if (onPendingDelete) {
onPendingDelete(attachmentId);
setConfirmOpen(false);
return;
}
startTransition(async () => {
const result = await detachTransactionAttachmentAction({
attachmentId,
transactionId,
});
if (result.success) {
toast.success(result.message);
onDeleted();
} else {
toast.error(result.error);
}
});
setConfirmOpen(false);
}
return (
<>
<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} />
{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"
onClick={() => setPreviewOpen(true)}
title={fileName}
>
<p className="truncate font-medium hover:underline">{fileName}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(fileSize)}
</p>
</button>
) : (
<div className="flex-1 min-w-0">
<p className="truncate font-medium">{fileName}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(fileSize)}
</p>
</div>
)}
{!isPendingDelete && (
<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="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 && (
<AttachmentPreview
open={previewOpen}
onOpenChange={setPreviewOpen}
fileName={fileName}
mimeType={mimeType}
url={url}
/>
)}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Remover anexo</DialogTitle>
<DialogDescription>
Tem certeza que deseja remover{" "}
<span className="break-all font-medium text-foreground">
{fileName}
</span>
?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" disabled={isPending}>
Cancelar
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isPending}
>
{isPending ? "Removendo..." : "Remover"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}