diff --git a/AGENTS.md b/AGENTS.md index fe739ea..36d9b4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ If a route fails any of the above, it is not done. - `node scripts/setup-operators-collections.mjs` → create the `operators` collection (idempotent). - `node scripts/setup-job-cards-collection.mjs` → create the `job_cards` collection (idempotent). - `node scripts/setup-cash-float-collection.mjs` → create the `cash_float_entries` collection (idempotent). +- `node scripts/setup-suppliers-collection.mjs` → create `suppliers` and add `supplier_ref` to `purchases` / `cash_float_entries` (idempotent); then `node --import tsx scripts/backfill-suppliers.mjs` books existing rows. - `node scripts/setup-mechanics-collections.mjs` → create the `mechanics` collection (idempotent). - `node scripts/setup-purchase-collections.mjs` → create the `purchases` / `purchase_lines` collections (idempotent). - `node scripts/setup-oil-collections.mjs` → create the `oil_items` / `oil_stock_movements` collections (idempotent). @@ -942,3 +943,18 @@ photographed in the Plant Clock app's **Slips** tab (`/clock/slips`) by an **Off - **Access levels** are now Full / Clock-in / Mechanic / **Office**. What each may reach inside `/clock` and `/api/clock` is ONE table in `lib/clock-access.ts`, used by `proxy.ts` and the bottom nav. Office sees only Slips, and ONLY Office sees Slips (not full or clock-in users). + + +## Suppliers — one list, every purchase booked against it + +`/expenses/suppliers` is the single supplier list. Every purchase order (`purchases.supplier_ref`) and every +petty-cash slip (`cash_float_entries.supplier_ref`) is booked against a supplier, so spend per supplier is one sum. + +- **Terms** per supplier: `cash`, `30` or `60` (account days), or `""` = not set yet. A supplier that first + appears on an uploaded PO is created with terms **unset** and flagged on the page; one that first appears on a + till slip is created as `cash`. An existing supplier's terms are never changed by a PO or slip — only a person does that. +- **Matching** (`lib/supplier-server.ts` `resolveSupplier`): the PO's supplier CODE first (`09YTS001`), then the + folded NAME (`supplierKey` drops case, punctuation and PTY/LTD/CC). `suppliers.key` is UNIQUE. Not fuzzy: + "Your Tyre Shop" and "Tyre Shop" are different suppliers. +- Due date = PO date + terms (`dueDate`). **Payments are not tracked**, so nothing is ever called "overdue". +- A supplier with anything booked against it cannot be deleted. Editing writes need a Full user. diff --git a/app/(clock)/clock/slips/page.tsx b/app/(clock)/clock/slips/page.tsx index 3dd6a1f..4dbc850 100644 --- a/app/(clock)/clock/slips/page.tsx +++ b/app/(clock)/clock/slips/page.tsx @@ -11,9 +11,10 @@ export default async function SlipsPage() { const session = await requireClockUser(); if (session.access !== 'office') redirect(clockHome(session.access)); const pb = await clockPB(); - const [entries, assets] = await Promise.all([ + const [entries, assets, suppliers] = await Promise.all([ pb.collection('cash_float_entries').getFullList({ fields: 'kind,amount' }), pb.collection('assets').getFullList({ sort: 'code', fields: 'code', filter: 'is_active = true' }), + pb.collection('suppliers').getFullList({ sort: 'name', fields: 'name' }), ]); - return String(a.code)).filter(Boolean)}/>; + return String(s.name))} costCodes={assets.map(a => String(a.code)).filter(Boolean)}/>; } diff --git a/app/(dashboard)/expenses/petty-cash/page.tsx b/app/(dashboard)/expenses/petty-cash/page.tsx index 78f42ea..de593a8 100644 --- a/app/(dashboard)/expenses/petty-cash/page.tsx +++ b/app/(dashboard)/expenses/petty-cash/page.tsx @@ -22,11 +22,12 @@ export default async function PettyCash() { by: String(e.recorded_by || ""), })).sort((a, b) => b.at.localeCompare(a.at)); + const suppliers = await pb.collection("suppliers").getFullList({ sort: "name", fields: "name" }); const assets = await pb.collection("assets").getFullList({ sort: "code", fields: "code", filter: "is_active = true" }); return (
- String(a.code)).filter(Boolean)} /> + String(s.name))} costCodes={assets.map((a) => String(a.code)).filter(Boolean)} />
); } diff --git a/app/(dashboard)/expenses/suppliers/page.tsx b/app/(dashboard)/expenses/suppliers/page.tsx new file mode 100644 index 0000000..4818962 --- /dev/null +++ b/app/(dashboard)/expenses/suppliers/page.tsx @@ -0,0 +1,42 @@ +import { Store } from "lucide-react"; +import { serverAuth } from "@/lib/pb"; +import { SuppliersView, type SupplierRow, type SupplierTxn } from "@/components/SuppliersView"; + +export const dynamic = "force-dynamic"; + +/** + * One supplier list for the whole business. Every purchase order and every petty-cash slip is + * booked against a supplier, so this shows what was spent where, and whether the supplier is a + * cash supplier or an account (30 / 60 days). + */ +export default async function SuppliersPage() { + const pb = await serverAuth(); + const [suppliers, purchases, slips] = await Promise.all([ + pb.collection("suppliers").getFullList({ sort: "name" }), + pb.collection("purchases").getFullList({ fields: "id,po_no,po_date,total,supplier_ref" }), + pb.collection("cash_float_entries").getFullList({ filter: 'kind = "expense"', fields: "id,amount,occurred_on,created,comment,supplier_ref" }), + ]); + const txns = new Map(); + const add = (id: string, t: SupplierTxn) => { if (id) txns.set(id, [...(txns.get(id) ?? []), t]); }; + for (const p of purchases) add(p.supplier_ref, { id: p.id, kind: "po", at: String(p.po_date || ""), ref: String(p.po_no || ""), amount: Number(p.total || 0) }); + for (const s of slips) add(s.supplier_ref, { id: s.id, kind: "cash", at: String(s.occurred_on || s.created || ""), ref: String(s.comment || ""), amount: Number(s.amount || 0) }); + + const rows: SupplierRow[] = suppliers.map((s) => ({ + id: s.id, name: String(s.name), code: String(s.code || ""), terms: String(s.terms || ""), + notes: String(s.notes || ""), source: String(s.source || ""), + txns: (txns.get(s.id) ?? []).sort((a, b) => b.at.localeCompare(a.at)), + })); + + return ( +
+
+
+
+

Suppliers

+

Who we buy from, how we pay them, and what we spend with each

+
+
+ +
+ ); +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 7630981..f7b7da5 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -78,6 +78,7 @@ const navItems: NavItem[] = [ { href: "/expenses", label: "Overview" }, // Vehicle Costs and Finance (Bastion) are hidden: Running Costs (Insurance, Financing) already // covers them. The pages still exist at /expenses/vehicles and /expenses/finance. + { href: "/expenses/suppliers", label: "Suppliers" }, { href: "/expenses/personnel", label: "Personnel" }, { href: "/expenses/subscriptions", label: "Subscriptions" }, { href: "/expenses/rent", label: "Rent & Utilities" }, diff --git a/app/api/cash-float/entries/[id]/route.ts b/app/api/cash-float/entries/[id]/route.ts index 873ba4d..ea321d8 100644 --- a/app/api/cash-float/entries/[id]/route.ts +++ b/app/api/cash-float/entries/[id]/route.ts @@ -1,11 +1,14 @@ import { revalidatePath } from "next/cache"; import { serverAuth } from "@/lib/pb"; +import { refuseUnlessFull } from "@/lib/require-full"; export const dynamic = "force-dynamic"; // DELETE /api/cash-float/entries/:id — remove a slip or top-up entered by mistake (main app only). // The opening balance cannot be deleted, only corrected, or the card would lose its starting point. export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const refused = await refuseUnlessFull(); + if (refused) return refused; const { id } = await params; try { const pb = await serverAuth(); diff --git a/app/api/cash-float/entries/route.ts b/app/api/cash-float/entries/route.ts index d38828a..17a2c6a 100644 --- a/app/api/cash-float/entries/route.ts +++ b/app/api/cash-float/entries/route.ts @@ -1,8 +1,10 @@ import { revalidatePath } from "next/cache"; import { serverAuth } from "@/lib/pb"; +import { refuseUnlessFull } from "@/lib/require-full"; import { getSession } from "@/lib/auth"; import { hasOpening, parseAmount } from "@/lib/cash-float"; import { assetIdForCostCode } from "@/lib/cash-float-server"; +import { resolveSupplier } from "@/lib/supplier-server"; export const dynamic = "force-dynamic"; @@ -10,6 +12,8 @@ export const dynamic = "force-dynamic"; // A slip entered by hand in the main app. Same rules as the Slips tab in the clock app, except the // photo is optional (a slip that was lost or never photographed) and the date can be set. export async function POST(req: Request) { + const refused = await refuseUnlessFull(); + if (refused) return refused; let form: FormData; try { form = await req.formData(); } catch { return Response.json({ ok: false, error: "Send the entry details." }, { status: 400 }); } @@ -43,10 +47,13 @@ export async function POST(req: Request) { data.set("supplier", supplier); data.set("cost_code", costCode); data.set("comment", comment); const assetId = await assetIdForCostCode(pb, costCode); if (assetId) data.set("asset", assetId); + const supplierId = await resolveSupplier(pb, { name: supplier, terms: "cash", source: "Cash float" }); + if (supplierId) data.set("supplier_ref", supplierId); data.set("recorded_by", `${String(session?.user.name || session?.user.email || "")} (manual)`.trim()); if (hasPhoto) data.set("slip_photo", photo as File, (photo as File).name || "slip.jpg"); await pb.collection("cash_float_entries").create(data); revalidatePath("/expenses/petty-cash"); + revalidatePath("/expenses/suppliers"); return Response.json({ ok: true }); } catch (e: any) { return Response.json({ ok: false, error: e?.message || "Could not save" }, { status: 500 }); diff --git a/app/api/cash-float/opening/route.ts b/app/api/cash-float/opening/route.ts index 51f151e..345af6a 100644 --- a/app/api/cash-float/opening/route.ts +++ b/app/api/cash-float/opening/route.ts @@ -1,5 +1,6 @@ import { revalidatePath } from "next/cache"; import { serverAuth } from "@/lib/pb"; +import { refuseUnlessFull } from "@/lib/require-full"; import { parseAmount } from "@/lib/cash-float"; export const dynamic = "force-dynamic"; @@ -7,6 +8,8 @@ export const dynamic = "force-dynamic"; // PUT /api/cash-float/opening { amount } — correct the opening balance (main app only). // Rewrites the one "opening" entry; slips and top-ups are untouched, so the balance moves by the difference. export async function PUT(req: Request) { + const refused = await refuseUnlessFull(); + if (refused) return refused; let body: { amount?: unknown } = {}; try { body = await req.json(); } catch { return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); } const amount = parseAmount(body.amount, true); diff --git a/app/api/cash-float/topup/route.ts b/app/api/cash-float/topup/route.ts index 200367a..3892be7 100644 --- a/app/api/cash-float/topup/route.ts +++ b/app/api/cash-float/topup/route.ts @@ -1,5 +1,6 @@ import { revalidatePath } from "next/cache"; import { serverAuth } from "@/lib/pb"; +import { refuseUnlessFull } from "@/lib/require-full"; import { getSession } from "@/lib/auth"; import { parseAmount } from "@/lib/cash-float"; @@ -7,6 +8,8 @@ export const dynamic = "force-dynamic"; // POST /api/cash-float/topup { amount, comment? } — money loaded onto the card (main app only). export async function POST(req: Request) { + const refused = await refuseUnlessFull(); + if (refused) return refused; let body: { amount?: unknown; comment?: unknown } = {}; try { body = await req.json(); } catch { return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); } const amount = parseAmount(body.amount); diff --git a/app/api/clock/slip/route.ts b/app/api/clock/slip/route.ts index 934a0e0..f83d9de 100644 --- a/app/api/clock/slip/route.ts +++ b/app/api/clock/slip/route.ts @@ -3,6 +3,7 @@ import { clockAccess, clockFailure, clockJson } from '@/lib/clock-api'; import { clockPB } from '@/lib/clock-store'; import { cashBalance, hasOpening, parseAmount } from '@/lib/cash-float'; import { assetIdForCostCode } from '@/lib/cash-float-server'; +import { resolveSupplier } from '@/lib/supplier-server'; // POST /api/clock/slip (multipart: slip_photo, supplier, amount, cost_code?, comment?) // A photographed slip becomes one "expense" on the cash-float card and takes its amount off the balance. @@ -37,11 +38,15 @@ export async function POST(request: Request) { data.set('kind', 'expense'); data.set('amount', String(amount)); data.set('occurred_on', new Date().toISOString()); data.set('supplier', supplier); data.set('cost_code', costCode); data.set('comment', comment); if (assetId) data.set('asset', assetId); + // A petty-cash supplier joins the one supplier list (as a cash supplier if it is new). + const supplierId = await resolveSupplier(pb, { name: supplier, terms: 'cash', source: 'Cash float' }); + if (supplierId) data.set('supplier_ref', supplierId); data.set('recorded_by', String(user.user.name || user.user.email || user.user.id)); data.set('slip_photo', photo, photo.name || 'slip.jpg'); const entry = await pb.collection('cash_float_entries').create(data); revalidatePath('/expenses/petty-cash'); + revalidatePath('/expenses/suppliers'); return clockJson({ id: entry.id, amount, balance: cashBalance([...entries, { kind: 'expense', amount }]) }); } catch (e) { return clockFailure(e); } } diff --git a/app/api/job-cards/[id]/route.ts b/app/api/job-cards/[id]/route.ts index fc3f9a8..5e94e20 100644 --- a/app/api/job-cards/[id]/route.ts +++ b/app/api/job-cards/[id]/route.ts @@ -1,5 +1,6 @@ import { revalidatePath } from "next/cache"; import { serverAuth } from "@/lib/pb"; +import { refuseUnlessFull } from "@/lib/require-full"; import { isJobType, MAX_NOTE, minutesBetween } from "@/lib/job-cards"; export const dynamic = "force-dynamic"; @@ -11,6 +12,8 @@ export const dynamic = "force-dynamic"; // Remove a job card (a test scan, a duplicate). Oil booked onto it STAYS booked out of stock — // only the link to the card is cleared, so nothing silently comes back into stock. export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const refused = await refuseUnlessFull(); + if (refused) return refused; const { id } = await params; let body: { job_type?: unknown; note?: unknown; odometer?: unknown; no_meter?: unknown; ended_at?: unknown }; try { body = await req.json(); } catch { return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); } @@ -52,6 +55,8 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st } export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const refused = await refuseUnlessFull(); + if (refused) return refused; const { id } = await params; try { const pb = await serverAuth(); diff --git a/app/api/purchases/intake/route.ts b/app/api/purchases/intake/route.ts index 6cab647..54b21b2 100644 --- a/app/api/purchases/intake/route.ts +++ b/app/api/purchases/intake/route.ts @@ -1,3 +1,4 @@ +import { resolveSupplier } from "@/lib/supplier-server"; import { revalidatePath } from "next/cache"; import { serverAuth } from "@/lib/pb"; import { parsePurchasePdf, PurchaseParseError, normaliseCostCode } from "@/lib/purchase-parser"; @@ -83,8 +84,11 @@ export async function POST(req: Request) { } const oilByStore = new Map(oilItems.map((i) => [normaliseCostCode(i.store_cost_code || ""), i])); + // Book the order against its supplier (created, with terms unset, if it is new to the list). + const supplierId = await resolveSupplier(pb, { name: po.supplier, code: po.supplier_code, source: "Purchase order" }); const purchase = await pb.collection("purchases").create({ po_no: po.po_no, + ...(supplierId ? { supplier_ref: supplierId } : {}), supplier: po.supplier, supplier_code: po.supplier_code, po_date: po.po_date, @@ -158,6 +162,7 @@ export async function POST(req: Request) { revalidatePath("/running/purchases"); revalidatePath("/running/oils"); + revalidatePath("/expenses/suppliers"); revalidatePath("/running"); return Response.json({ diff --git a/app/api/suppliers/[id]/route.ts b/app/api/suppliers/[id]/route.ts new file mode 100644 index 0000000..2471204 --- /dev/null +++ b/app/api/suppliers/[id]/route.ts @@ -0,0 +1,58 @@ +import { revalidatePath } from "next/cache"; +import { serverAuth } from "@/lib/pb"; +import { refuseUnlessFull } from "@/lib/require-full"; +import { parseTerms, supplierKey } from "@/lib/supplier"; + +export const dynamic = "force-dynamic"; + +// PATCH /api/suppliers/:id { name?, code?, terms?, notes? } +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const refused = await refuseUnlessFull(); + if (refused) return refused; + const { id } = await params; + let body: { name?: unknown; code?: unknown; terms?: unknown; notes?: unknown } = {}; + try { body = await req.json(); } catch { return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); } + const patch: Record = {}; + try { + const pb = await serverAuth(); + if ("name" in body) { + const name = String(body.name ?? "").trim(); + const key = supplierKey(name); + if (!name || !key) return Response.json({ ok: false, error: "The name cannot be empty." }, { status: 400 }); + const clash = (await pb.collection("suppliers").getFullList({ fields: "id,name,key" })).find((s) => s.key === key && s.id !== id); + if (clash) return Response.json({ ok: false, error: `"${clash.name}" is already on the list.` }, { status: 409 }); + patch.name = name; patch.key = key; + } + if ("code" in body) patch.code = String(body.code ?? "").trim().toUpperCase().slice(0, 40); + if ("terms" in body) patch.terms = parseTerms(body.terms); + if ("notes" in body) patch.notes = String(body.notes ?? "").trim().slice(0, 1000); + if (!Object.keys(patch).length) return Response.json({ ok: false, error: "Nothing to change." }, { status: 400 }); + await pb.collection("suppliers").update(id, patch); + revalidatePath("/expenses/suppliers"); + return Response.json({ ok: true }); + } catch (e: any) { + return Response.json({ ok: false, error: e?.message || "Could not save" }, { status: 500 }); + } +} + +// DELETE /api/suppliers/:id — only a supplier with nothing booked against it. +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const refused = await refuseUnlessFull(); + if (refused) return refused; + const { id } = await params; + try { + const pb = await serverAuth(); + const [pos, slips] = await Promise.all([ + pb.collection("purchases").getList(1, 1, { filter: pb.filter("supplier_ref = {:id}", { id }) }), + pb.collection("cash_float_entries").getList(1, 1, { filter: pb.filter("supplier_ref = {:id}", { id }) }), + ]); + if (pos.totalItems || slips.totalItems) { + return Response.json({ ok: false, error: `This supplier has ${pos.totalItems} purchase order(s) and ${slips.totalItems} slip(s) booked against it, so it cannot be deleted.` }, { status: 409 }); + } + await pb.collection("suppliers").delete(id); + revalidatePath("/expenses/suppliers"); + return Response.json({ ok: true }); + } catch (e: any) { + return Response.json({ ok: false, error: e?.message || "Could not delete" }, { status: 500 }); + } +} diff --git a/app/api/suppliers/route.ts b/app/api/suppliers/route.ts new file mode 100644 index 0000000..b9e43dc --- /dev/null +++ b/app/api/suppliers/route.ts @@ -0,0 +1,31 @@ +import { revalidatePath } from "next/cache"; +import { serverAuth } from "@/lib/pb"; +import { refuseUnlessFull } from "@/lib/require-full"; +import { parseTerms, supplierKey } from "@/lib/supplier"; + +export const dynamic = "force-dynamic"; + +// POST /api/suppliers { name, code?, terms, notes? } — add a supplier to the list by hand. +export async function POST(req: Request) { + const refused = await refuseUnlessFull(); + if (refused) return refused; + let body: { name?: unknown; code?: unknown; terms?: unknown; notes?: unknown } = {}; + try { body = await req.json(); } catch { return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); } + const name = String(body.name ?? "").trim(); + const key = supplierKey(name); + if (!name || !key) return Response.json({ ok: false, error: "Enter the supplier's name." }, { status: 400 }); + if (name.length > 200) return Response.json({ ok: false, error: "That name is too long." }, { status: 400 }); + try { + const pb = await serverAuth(); + const clash = (await pb.collection("suppliers").getFullList({ fields: "id,name,key" })).find((s) => s.key === key); + if (clash) return Response.json({ ok: false, error: `"${clash.name}" is already on the list.` }, { status: 409 }); + await pb.collection("suppliers").create({ + name, key, code: String(body.code ?? "").trim().toUpperCase().slice(0, 40), terms: parseTerms(body.terms), + notes: String(body.notes ?? "").trim().slice(0, 1000), source: "Added in app", + }); + revalidatePath("/expenses/suppliers"); + return Response.json({ ok: true }); + } catch (e: any) { + return Response.json({ ok: false, error: e?.message || "Could not save" }, { status: 500 }); + } +} diff --git a/components/CashFloatCard.tsx b/components/CashFloatCard.tsx index 920d04e..c61b0a6 100644 --- a/components/CashFloatCard.tsx +++ b/components/CashFloatCard.tsx @@ -23,7 +23,7 @@ const KIND: Record = { opening: "Opening balance", topup: "Top-u * The cash-float card: opening balance, then every slip (photographed in the Plant Clock app) * comes off it. Shown as a ledger so any rand can be traced to a slip. */ -export function CashFloatCard({ rows, costCodes = [] }: { rows: CashRow[]; costCodes?: string[] }) { +export function CashFloatCard({ rows, costCodes = [], suppliers = [] }: { rows: CashRow[]; costCodes?: string[]; suppliers?: string[] }) { const router = useRouter(); const [preset, setPreset] = useState("month"); const [from, setFrom] = useState(""); @@ -201,7 +201,8 @@ export function CashFloatCard({ rows, costCodes = [] }: { rows: CashRow[]; costC

Add an entry by hand

For a slip that was not photographed. It comes off the card balance like any other slip.

- setMSupplier(e.target.value)} maxLength={200} className={`${field} w-full mt-1`} /> + setMSupplier(e.target.value)} maxLength={200} className={`${field} w-full mt-1`} /> + {suppliers.map((s) => setMAmount(e.target.value)} className={`${field} w-full mt-1 text-lg tabular-nums`} placeholder="0.00" /> diff --git a/components/SlipCapture.tsx b/components/SlipCapture.tsx index ad62dfe..1b3b70a 100644 --- a/components/SlipCapture.tsx +++ b/components/SlipCapture.tsx @@ -9,7 +9,7 @@ import { rand } from '@/lib/cash-float'; * Photograph a cash-card slip, fill in what it was for, and it comes off the card's balance. * The first time ever, the card's opening balance is asked for. */ -export function SlipCapture({ hasOpening, balance, costCodes }: { hasOpening: boolean; balance: number; costCodes: string[] }) { +export function SlipCapture({ hasOpening, balance, costCodes, suppliers }: { hasOpening: boolean; balance: number; costCodes: string[]; suppliers: string[] }) { const router = useRouter(); const [photo, setPhoto] = useState(null); const [preview, setPreview] = useState(''); @@ -87,7 +87,8 @@ export function SlipCapture({ hasOpening, balance, costCodes }: { hasOpening: bo Photo of the slip - setSupplier(e.target.value)} maxLength={200} autoComplete="off" placeholder="e.g. Builders Warehouse"/> + setSupplier(e.target.value)} maxLength={200} autoComplete="off" placeholder="e.g. Builders Warehouse"/> + {suppliers.map(s => setCostCode(e.target.value)} maxLength={100} autoComplete="off" autoCapitalize="characters" placeholder="e.g. TLB04"/> {costCodes.map(c => diff --git a/components/SuppliersView.tsx b/components/SuppliersView.tsx new file mode 100644 index 0000000..ed4bafa --- /dev/null +++ b/components/SuppliersView.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { Fragment, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ChevronDown, ChevronRight, Plus } from "lucide-react"; +import { dueDate, isAccount, TERMS, termsLabel } from "@/lib/supplier"; +import { rand } from "@/lib/cash-float"; +import { inBounds, presetBounds, saDay, type LogPreset } from "@/lib/oil-log"; + +export type SupplierTxn = { id: string; kind: "po" | "cash"; at: string; ref: string; amount: number }; +export type SupplierRow = { id: string; name: string; code: string; terms: string; notes: string; source: string; txns: SupplierTxn[] }; + +const PRESETS: { id: LogPreset; label: string }[] = [ + { id: "all", label: "Everything" }, { id: "month", label: "This month" }, { id: "week", label: "This week" }, + { id: "today", label: "Today" }, { id: "range", label: "Date range" }, +]; +const dmy = (s: string) => { + const d = new Date(s.replace(" ", "T")); + return s && !Number.isNaN(d.getTime()) ? d.toLocaleDateString("en-ZA", { timeZone: "Africa/Johannesburg", day: "numeric", month: "short", year: "numeric" }) : "—"; +}; +const TERMS_FILTERS = [ + { id: "", label: "All suppliers" }, { id: "account", label: "Account (30 / 60 days)" }, { id: "30", label: "30 days" }, + { id: "60", label: "60 days" }, { id: "cash", label: "Cash" }, { id: "unset", label: "Terms not set" }, +]; + +export function SuppliersView({ rows }: { rows: SupplierRow[] }) { + const router = useRouter(); + const [preset, setPreset] = useState("all"); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [termsFilter, setTermsFilter] = useState(""); + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(null); + const [dialog, setDialog] = useState(null); + const [deleting, setDeleting] = useState(null); + const [name, setName] = useState(""); + const [code, setCode] = useState(""); + const [terms, setTerms] = useState(""); + const [notes, setNotes] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const computed = useMemo(() => { + const bounds = presetBounds(preset, saDay(new Date().toISOString()), from, to); + return rows.map((r) => { + const t = r.txns.filter((x) => inBounds(saDay(x.at), bounds)); + const po = t.filter((x) => x.kind === "po"), cash = t.filter((x) => x.kind === "cash"); + const poTotal = po.reduce((s, x) => s + x.amount, 0), cashTotal = cash.reduce((s, x) => s + x.amount, 0); + return { r, t, poCount: po.length, poTotal, cashCount: cash.length, cashTotal, total: poTotal + cashTotal, last: t[0]?.at ?? "" }; + }); + }, [rows, preset, from, to]); + + const shown = useMemo(() => { + const q = query.trim().toLowerCase(); + return computed + .filter(({ r }) => { + if (q && !`${r.name} ${r.code}`.toLowerCase().includes(q)) return false; + if (termsFilter === "account") return isAccount(r.terms); + if (termsFilter === "unset") return !r.terms; + return !termsFilter || r.terms === termsFilter; + }) + .sort((a, b) => b.total - a.total || a.r.name.localeCompare(b.r.name)); + }, [computed, query, termsFilter]); + + const grandPo = shown.reduce((s, x) => s + x.poTotal, 0); + const grandCash = shown.reduce((s, x) => s + x.cashTotal, 0); + const needTerms = rows.filter((r) => !r.terms).length; + + function begin(target: "add" | SupplierRow) { + setDialog(target); setError(""); + if (target === "add") { setName(""); setCode(""); setTerms(""); setNotes(""); } + else { setName(target.name); setCode(target.code); setTerms(target.terms); setNotes(target.notes); } + } + async function send(url: string, method: string, body?: object) { + setBusy(true); setError(""); + try { + const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined }); + const j = await res.json().catch(() => ({})); + if (!res.ok || !j.ok) throw new Error(j.error || "Could not save"); + setDialog(null); setDeleting(null); router.refresh(); + } catch (e: any) { setError(e.message); } finally { setBusy(false); } + } + function submit(e: React.FormEvent) { + e.preventDefault(); + const body = { name, code, terms, notes }; + if (dialog === "add") send("/api/suppliers", "POST", body); + else if (dialog) send(`/api/suppliers/${dialog.id}`, "PATCH", body); + } + + const field = "min-h-[44px] rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface px-3 text-sm"; + const card = "bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-2xl shadow-sm"; + + return ( +
+
+

Suppliers

{rows.length}

+

{rows.filter((r) => isAccount(r.terms)).length} on account · {rows.filter((r) => r.terms === "cash").length} cash

+

Purchase orders

{rand(grandPo)}

+

Petty cash

{rand(grandCash)}

+

Total spent

{rand(grandPo + grandCash)}

+
+ + {needTerms > 0 && ( +

+ {needTerms} supplier{needTerms !== 1 && "s"} came in from purchase orders and still need payment terms (cash, 30 or 60 days). Filter by “Terms not set” and tap Edit. +

+ )} + +
+
+
+ {PRESETS.map((p) => ( + + ))} +
+ + {preset === "range" && ( +
+ setFrom(e.target.value)} aria-label="From" /> + to + setTo(e.target.value)} aria-label="To" /> +
+ )} +
+ setQuery(e.target.value)} aria-label="Search suppliers" /> + + +
+
+ + {shown.length === 0 ? ( +

{rows.length === 0 ? "No suppliers yet." : "No suppliers match these filters."}

+ ) : ( +
+ + + + + + + + + + + + + + {shown.map(({ r, t, poCount, poTotal, cashCount, cashTotal, total, last }) => ( + + + + + + + + + + + {open === r.id && ( + + + + )} + + ))} + +
SupplierTermsPurchase ordersPetty cashTotal spentLast purchaseActions
+ + + + {r.terms === "cash" ? "Cash" : isAccount(r.terms) ? `Account · ${r.terms} days` : "Not set"} + {poCount ? <>{rand(poTotal)}{poCount} order{poCount !== 1 && "s"} : "—"}{cashCount ? <>{rand(cashTotal)}{cashCount} slip{cashCount !== 1 && "s"} : "—"}{total ? rand(total) : "—"}{last ? dmy(last) : "—"} +
+ + +
+
+ {r.notes &&

{r.notes}

} + {t.length === 0 ?

Nothing booked against this supplier in this period.

: ( + + + {t.map((x) => ( + + + + + + + ))} +
DateTypeReferenceAmountDue
{dmy(x.at)}{x.kind === "po" ? "Purchase order" : "Petty cash"}{x.ref || "—"}{rand(x.amount)}{x.kind === "po" && isAccount(r.terms) ? dmy(dueDate(x.at, r.terms)) : x.kind === "cash" ? "Paid" : "—"}
+ )} +
+
+ )} +

Purchase orders are VAT-inclusive totals. Due dates are the PO date plus the supplier's terms; payments are not tracked here yet.

+
+ + {dialog && ( +
!busy && setDialog(null)}> +
e.stopPropagation()} onSubmit={submit} role="dialog" aria-modal="true" className="w-full max-w-md max-h-[92svh] overflow-y-auto rounded-2xl bg-white dark:bg-surface p-5 shadow-xl"> +

{dialog === "add" ? "Add a supplier" : "Edit supplier"}

+ + setName(e.target.value)} maxLength={200} className={`${field} w-full mt-1`} /> + + setCode(e.target.value)} maxLength={40} className={`${field} w-full mt-1 font-mono`} placeholder="e.g. 09YTS001" /> +

How do we pay them?

+
+ {TERMS.map((t) => ( + + ))} +
+ {!terms &&

Not set yet — choose one when you know.

} + +