Add slip invoice uploads and spend details

Add a manager-only endpoint for appending JPG, PNG, or WebP invoice photos to slip logs with file and photo-count limits. Expose invoice attachment controls in transaction sheets and add expandable monthly spend summaries to cash-float cards.
This commit is contained in:
DEls 2026-11-03 13:10:53 +02:00
parent d055c7bb5d
commit f3e0248546
3 changed files with 100 additions and 5 deletions

View file

@ -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<CashFloatLogRecord>(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);
}
}

View file

@ -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<typeof TableActions>["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 }: {
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Balance</p>
<p className={`text-sm font-semibold tabular-nums ${card.low ? "text-destructive" : ""}`}>{formatCents(card.balance)}</p>
</div>
<button type="button" aria-expanded={open} aria-label={`Spend per month for card ${card.cardNumber}`} onClick={() => setOpen(o => !o)} className="grid h-9 w-9 shrink-0 place-items-center rounded-md hover:bg-muted">
<ChevronRight className={`h-4 w-4 text-muted-foreground transition-transform ${open ? "rotate-90" : ""}`} />
</button>
<TableActions label="Actions" ariaLabel={`Actions for card ${card.cardNumber}`} items={actions} />
{open && (
<div className="grid w-full grid-cols-3 gap-2 border-t border-border pt-3 text-xs sm:grid-cols-4 md:grid-cols-6 2xl:grid-cols-12">
{card.monthlySpend.map((spent, index) => (
<div key={index} className="min-w-0 text-center">
<p className="mb-1 text-[11px] text-muted-foreground">{monthLabel(card.monthlySpend.length - 1 - index)}</p>
<p className={`truncate rounded py-1.5 font-medium tabular-nums ${spent ? "bg-muted" : "text-muted-foreground/60"}`} title={formatCents(spent)}>{spent ? formatCents(spent) : "—"}</p>
</div>
))}
</div>
)}
</div>
);
}
/** "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 (
<div className="hidden w-28 shrink-0 text-right md:block">

View file

@ -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<LogRow[]>([]);
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<LogRow | null>(null);
const fileInput = React.useRef<HTMLInputElement>(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 }: {
</a>
))}
</span>
: <span className="text-muted-foreground">—</span>}
: row.kind === "slip"
? <Button variant="outline" className="min-h-8 px-2 text-xs" disabled={attaching === row.id} aria-label={`Attach invoice to the ${row.supplier_name || "slip"} on ${(row.occurred_on ?? "").slice(0, 10)}`} onClick={() => { attachRow.current = row; fileInput.current?.click(); }}>
{attaching === row.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <><Paperclip className="mr-1 h-3.5 w-3.5" />Attach</>}
</Button>
: <span className="text-muted-foreground">—</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
<input ref={fileInput} type="file" multiple accept="image/jpeg,image/png,image/webp" className="hidden" aria-label="Choose invoice files" onChange={event => {
const files = Array.from(event.target.files ?? []);
event.target.value = "";
if (attachRow.current) void attach(attachRow.current, files);
}} />
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
</DialogContent>
</Dialog>