Files
openmonetis/src/features/attachments/components/attachments-page.tsx
Felipe Coutinho e8cc673e52 style(ui): padronizar tipografia — font-medium para font-semibold
Padronização de peso tipográfico em títulos, rótulos de seção,
nomes de entidades e valores monetários em toda a interface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:51:34 +00:00

266 lines
8.2 KiB
TypeScript

"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 { 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">
<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>
);
}