diff --git a/src/app/api/cash-floats/log/[id]/photo/route.ts b/src/app/api/cash-floats/log/[id]/photo/route.ts index 397bf9c..05c27d6 100644 --- a/src/app/api/cash-floats/log/[id]/photo/route.ts +++ b/src/app/api/cash-floats/log/[id]/photo/route.ts @@ -2,7 +2,8 @@ import { NextResponse } from "next/server"; import { pbAdmin } from "@/lib/pb"; import { requireApiUser } from "@/lib/auth/guard"; import { cardHeldBy } from "@/lib/cash-float-server"; -import { slipPhotos } from "@/lib/cash-float"; +import { MAX_SLIP_PHOTOS, slipPhotos } from "@/lib/cash-float"; +import { fail, recordId, requireManager, revalidateCashFloats } from "../../../cash-float-api"; import type { CashFloatLogRecord } from "@/lib/pb-types"; export const dynamic = "force-dynamic"; @@ -68,3 +69,46 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: }, }); } + +const ALLOWED_MIMES = ["image/jpeg", "image/png", "image/webp"]; +const MAX_FILE_BYTES = 10 * 1024 * 1024; + +/** + * POST /api/cash-floats/log/[id]/photo — add pages to a slip after the fact. + * + * Multipart `photo` files, appended after the pages already there (at most + * five in all). Management-only; slips only. + */ +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const user = await requireManager(); + if (user instanceof Response) return user; + + const { id } = await params; + if (!recordId(id)) return NextResponse.json({ error: "Invalid entry" }, { status: 400 }); + + let form: FormData; + try { + form = await request.formData(); + } catch { + return NextResponse.json({ error: "Could not read the form" }, { status: 400 }); + } + const photos = form.getAll("photo").filter((photo): photo is File => photo instanceof File && photo.size > 0); + if (!photos.length) return NextResponse.json({ error: "Choose an invoice to attach" }, { status: 400 }); + if (photos.some(photo => photo.size > MAX_FILE_BYTES)) return NextResponse.json({ error: "A file is too big — keep each one under 10 MB" }, { status: 400 }); + if (photos.some(photo => !ALLOWED_MIMES.includes(photo.type))) return NextResponse.json({ error: "The invoice must be a JPG, PNG or WebP image" }, { status: 400 }); + + try { + const pb = await pbAdmin(); + const row = await pb.collection("cash_float_log").getOne(id).catch(() => null); + if (!row) return NextResponse.json({ error: "That entry no longer exists" }, { status: 404 }); + if (row.kind !== "slip") return NextResponse.json({ error: "Only a slip can carry an invoice" }, { status: 400 }); + if (slipPhotos(row.slip_photo).length + photos.length > MAX_SLIP_PHOTOS) { + return NextResponse.json({ error: `A slip can have at most ${MAX_SLIP_PHOTOS} photos` }, { status: 400 }); + } + await pb.collection("cash_float_log").update(id, { "slip_photo+": photos }); + revalidateCashFloats(row.site); + return NextResponse.json({ ok: true }, { status: 201 }); + } catch (error) { + return fail(error); + } +} diff --git a/src/app/dashboard/management/cash-floats/cash-floats-view.tsx b/src/app/dashboard/management/cash-floats/cash-floats-view.tsx index 12b4016..10447bc 100644 --- a/src/app/dashboard/management/cash-floats/cash-floats-view.tsx +++ b/src/app/dashboard/management/cash-floats/cash-floats-view.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { useRouter } from "next/navigation"; -import { List, Plus, ReceiptText, ShieldCheck, Table2, TriangleAlert, WalletCards } from "lucide-react"; +import { ChevronRight, List, Plus, ReceiptText, ShieldCheck, Table2, TriangleAlert, WalletCards } from "lucide-react"; import { Area, AreaChart, ResponsiveContainer } from "recharts"; import { TableActions } from "@/components/table-actions"; import { Button } from "@/components/ui/button"; @@ -233,6 +233,7 @@ function CardRow({ card, onOpen, actions }: { actions: React.ComponentProps["items"]; }) { const gradientId = React.useId(); + const [open, setOpen] = React.useState(false); const color = card.low ? RED : GREEN; const data = card.monthlySpend.map((spent, index) => ({ index, spent: spent / 100 })); return ( @@ -264,11 +265,30 @@ function CardRow({ card, onOpen, actions }: {

Balance

{formatCents(card.balance)}

+ + {open && ( +
+ {card.monthlySpend.map((spent, index) => ( +
+

{monthLabel(card.monthlySpend.length - 1 - index)}

+

{spent ? formatCents(spent) : "—"}

+
+ ))} +
+ )} ); } +/** "Oct 26" for the month `back` months before this one. */ +function monthLabel(back: number): string { + const now = new Date(); + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - back, 1)).toLocaleDateString("en-ZA", { month: "short", year: "2-digit", timeZone: "UTC" }); +} + function Figure({ label, value }: { label: string; value: string }) { return (
diff --git a/src/app/dashboard/management/cash-floats/transactions-sheet.tsx b/src/app/dashboard/management/cash-floats/transactions-sheet.tsx index 7d7bfbb..72da013 100644 --- a/src/app/dashboard/management/cash-floats/transactions-sheet.tsx +++ b/src/app/dashboard/management/cash-floats/transactions-sheet.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { Download, Loader2 } from "lucide-react"; +import { Download, Loader2, Paperclip } from "lucide-react"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { cardLabel, formatCents, toCents, type CashFloatKind } from "@/lib/cash-float"; @@ -48,6 +48,28 @@ export function TransactionsSheet({ card, onClose }: { const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(""); + const [reload, setReload] = React.useState(0); + const [attaching, setAttaching] = React.useState(""); + const attachRow = React.useRef(null); + const fileInput = React.useRef(null); + + async function attach(row: LogRow, files: File[]) { + if (!files.length) return; + setAttaching(row.id); + setError(""); + try { + const form = new FormData(); + for (const file of files) form.append("photo", file, file.name); + const response = await fetch(`/api/cash-floats/log/${row.id}/photo`, { method: "POST", body: form }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error ?? "Could not attach the invoice"); + setReload(count => count + 1); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not attach the invoice"); + } finally { + setAttaching(""); + } + } React.useEffect(() => { let cancelled = false; @@ -62,7 +84,7 @@ export function TransactionsSheet({ card, onClose }: { .catch(() => { if (!cancelled) setError("Could not load the card's movements"); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [card.cardNumber, month, all]); + }, [card.cardNumber, month, all, reload]); // The rows come back newest first, so the running balance is worked out // from the oldest one forwards. @@ -155,13 +177,22 @@ export function TransactionsSheet({ card, onClose }: { ))} - : —} + : row.kind === "slip" + ? + : —} ))}
+ { + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + if (attachRow.current) void attach(attachRow.current, files); + }} /> {loading && }