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:
parent
d055c7bb5d
commit
f3e0248546
3 changed files with 100 additions and 5 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue