mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51:46 +00:00
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:
File diff suppressed because it is too large
Load Diff
@@ -1,181 +1,181 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1762993507299,
|
||||
"tag": "0000_flashy_manta",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1765199006435,
|
||||
"tag": "0001_young_mister_fear",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1765200545692,
|
||||
"tag": "0002_slimy_flatman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1767102605526,
|
||||
"tag": "0003_green_korg",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1767104066872,
|
||||
"tag": "0004_acoustic_mach_iv",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1767106121811,
|
||||
"tag": "0005_adorable_bruce_banner",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1767107487318,
|
||||
"tag": "0006_youthful_mister_fear",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1767118780033,
|
||||
"tag": "0007_sturdy_kate_bishop",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1767125796314,
|
||||
"tag": "0008_fat_stick",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1768925100873,
|
||||
"tag": "0009_add_dashboard_widgets",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1769369834242,
|
||||
"tag": "0010_lame_psynapse",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1769447087678,
|
||||
"tag": "0011_remove_unused_inbox_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1769533200000,
|
||||
"tag": "0012_rename_tables_to_portuguese",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1769523352777,
|
||||
"tag": "0013_fancy_rick_jones",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1769619226903,
|
||||
"tag": "0014_yielding_jack_flag",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1770332054481,
|
||||
"tag": "0015_concerned_kat_farrell",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1771166328908,
|
||||
"tag": "0016_complete_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1772400510326,
|
||||
"tag": "0017_previous_warstar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1773020417482,
|
||||
"tag": "0018_rainy_epoch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1773699152928,
|
||||
"tag": "0019_ordinary_wild_pack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1773841892114,
|
||||
"tag": "0020_add-budget-invoice-unique-constraints",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1774033320053,
|
||||
"tag": "0021_careful_malcolm_colcord",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1748000000000,
|
||||
"tag": "0022_import-category-mappings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1774529878374,
|
||||
"tag": "0023_sturdy_wolfpack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1774891206703,
|
||||
"tag": "0024_petite_lucky_pierre",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1762993507299,
|
||||
"tag": "0000_flashy_manta",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1765199006435,
|
||||
"tag": "0001_young_mister_fear",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1765200545692,
|
||||
"tag": "0002_slimy_flatman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1767102605526,
|
||||
"tag": "0003_green_korg",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1767104066872,
|
||||
"tag": "0004_acoustic_mach_iv",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1767106121811,
|
||||
"tag": "0005_adorable_bruce_banner",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1767107487318,
|
||||
"tag": "0006_youthful_mister_fear",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1767118780033,
|
||||
"tag": "0007_sturdy_kate_bishop",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1767125796314,
|
||||
"tag": "0008_fat_stick",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1768925100873,
|
||||
"tag": "0009_add_dashboard_widgets",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1769369834242,
|
||||
"tag": "0010_lame_psynapse",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1769447087678,
|
||||
"tag": "0011_remove_unused_inbox_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1769533200000,
|
||||
"tag": "0012_rename_tables_to_portuguese",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1769523352777,
|
||||
"tag": "0013_fancy_rick_jones",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1769619226903,
|
||||
"tag": "0014_yielding_jack_flag",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1770332054481,
|
||||
"tag": "0015_concerned_kat_farrell",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1771166328908,
|
||||
"tag": "0016_complete_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1772400510326,
|
||||
"tag": "0017_previous_warstar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1773020417482,
|
||||
"tag": "0018_rainy_epoch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1773699152928,
|
||||
"tag": "0019_ordinary_wild_pack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1773841892114,
|
||||
"tag": "0020_add-budget-invoice-unique-constraints",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1774033320053,
|
||||
"tag": "0021_careful_malcolm_colcord",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1748000000000,
|
||||
"tag": "0022_import-category-mappings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1774529878374,
|
||||
"tag": "0023_sturdy_wolfpack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1774891206703,
|
||||
"tag": "0024_petite_lucky_pierre",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
38
src/app/(dashboard)/attachments/loading.tsx
Normal file
38
src/app/(dashboard)/attachments/loading.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
|
||||
export default function AnexosLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<div className="w-full space-y-6">
|
||||
{/* Header */}
|
||||
<Skeleton className="h-10 w-40 rounded-md bg-foreground/10" />
|
||||
|
||||
{/* Month navigation */}
|
||||
<Skeleton className="h-10 w-64 rounded-md bg-foreground/10" />
|
||||
|
||||
{/* Count */}
|
||||
<Skeleton className="h-4 w-20 rounded-md bg-foreground/10" />
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col overflow-hidden rounded-lg border"
|
||||
>
|
||||
<Skeleton className="aspect-square w-full bg-foreground/10" />
|
||||
<div className="space-y-1.5 p-2.5">
|
||||
<Skeleton className="h-3 w-3/4 rounded bg-foreground/10" />
|
||||
<Skeleton className="h-3 w-full rounded bg-foreground/10" />
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-3 w-16 rounded bg-foreground/10" />
|
||||
<Skeleton className="h-3 w-12 rounded bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
36
src/app/(dashboard)/attachments/page.tsx
Normal file
36
src/app/(dashboard)/attachments/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { connection } from "next/server";
|
||||
import { AttachmentsPage } from "@/features/attachments/components/attachments-page";
|
||||
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import { parsePeriodParam } from "@/shared/utils/period";
|
||||
|
||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
const getSingleParam = (
|
||||
params: Record<string, string | string[] | undefined> | undefined,
|
||||
key: string,
|
||||
) => {
|
||||
const value = params?.[key];
|
||||
if (!value) return null;
|
||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const { period } = parsePeriodParam(periodoParam);
|
||||
|
||||
const attachments = await fetchAttachmentsForPeriod(userId, period);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<AttachmentsPage attachments={attachments} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
27
src/app/api/attachments/[attachmentId]/presign/route.ts
Normal file
27
src/app/api/attachments/[attachmentId]/presign/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { attachments } from "@/db/schema";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ attachmentId: string }> },
|
||||
) {
|
||||
const [userId, { attachmentId }] = await Promise.all([getUserId(), params]);
|
||||
|
||||
const [row] = await db
|
||||
.select({ fileKey: attachments.fileKey })
|
||||
.from(attachments)
|
||||
.where(
|
||||
and(eq(attachments.id, attachmentId), eq(attachments.userId, userId)),
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const url = await createPresignedGetUrl(row.fileKey);
|
||||
return NextResponse.json({ url });
|
||||
}
|
||||
208
src/features/attachments/components/attachment-grid-item.tsx
Normal file
208
src/features/attachments/components/attachment-grid-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
src/features/attachments/components/attachment-preview.tsx
Normal file
201
src/features/attachments/components/attachment-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
275
src/features/attachments/components/attachments-page.tsx
Normal file
275
src/features/attachments/components/attachments-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/features/attachments/hooks/use-attachment-url.ts
Normal file
31
src/features/attachments/hooks/use-attachment-url.ts
Normal 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 };
|
||||
}
|
||||
70
src/features/attachments/queries.ts
Normal file
70
src/features/attachments/queries.ts
Normal 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));
|
||||
}
|
||||
23
src/features/transactions/actions/fetch-by-id.ts
Normal file
23
src/features/transactions/actions/fetch-by-id.ts
Normal 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;
|
||||
}
|
||||
55
src/features/transactions/actions/fetch-dialog-options.ts
Normal file
55
src/features/transactions/actions/fetch-dialog-options.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
RiArrowLeftRightLine,
|
||||
RiAtLine,
|
||||
RiAttachmentLine,
|
||||
RiBankCard2Line,
|
||||
RiBankLine,
|
||||
RiBarChart2Line,
|
||||
@@ -110,6 +111,14 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
icon: <RiTodoLine className="size-4" />,
|
||||
iconClass: "text-primary",
|
||||
},
|
||||
{
|
||||
href: "/attachments",
|
||||
label: "anexos",
|
||||
description: "Comprovantes e documentos",
|
||||
icon: <RiAttachmentLine className="size-4" />,
|
||||
iconClass: "text-primary",
|
||||
preservePeriod: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,8 +32,9 @@ export const revalidateConfig = {
|
||||
payers: ["/payers"],
|
||||
notes: ["/notes", "/notes/archived", "/dashboard"],
|
||||
notifications: ["/dashboard"],
|
||||
transactions: ["/transactions", "/accounts"],
|
||||
transactions: ["/transactions", "/accounts", "/attachments"],
|
||||
inbox: ["/inbox", "/transactions", "/dashboard"],
|
||||
attachments: ["/attachments"],
|
||||
} as const;
|
||||
|
||||
/** Entities whose mutations should invalidate the dashboard cache */
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/**
|
||||
* Utility functions for safe number conversions
|
||||
* Utility functions for safe number conversions and formatting
|
||||
*/
|
||||
|
||||
export 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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely converts unknown value to number
|
||||
* @param value - Value to convert
|
||||
|
||||
Reference in New Issue
Block a user