Add Suppliers list; book purchase orders and petty-cash slips against it

- /expenses/suppliers: terms (cash / 30 / 60 days), PO + petty cash totals per supplier,
  period filters, add/edit/delete, due dates
- POs and slips resolve to a supplier by code then folded name; new PO suppliers start
  with terms unset, new slip suppliers as cash
- Full-user guard on cash-float and job-card write routes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
David 2026-10-22 15:43:57 +02:00
parent 1ec52961ee
commit cfd54385b3
23 changed files with 639 additions and 7 deletions

View file

@ -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.

View file

@ -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 <SlipCapture hasOpening={hasOpening(entries)} balance={cashBalance(entries)} costCodes={assets.map(a => String(a.code)).filter(Boolean)}/>;
return <SlipCapture hasOpening={hasOpening(entries)} balance={cashBalance(entries)} suppliers={suppliers.map(s => String(s.name))} costCodes={assets.map(a => String(a.code)).filter(Boolean)}/>;
}

View file

@ -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 (
<div className="p-4 sm:p-8 max-w-[1400px]">
<CashFloatCard rows={rows} costCodes={assets.map((a) => String(a.code)).filter(Boolean)} />
<CashFloatCard rows={rows} suppliers={suppliers.map((s) => String(s.name))} costCodes={assets.map((a) => String(a.code)).filter(Boolean)} />
</div>
);
}

View file

@ -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<string, SupplierTxn[]>();
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 (
<div className="p-4 sm:p-8 max-w-[1400px]">
<div className="mb-6 flex items-center gap-3">
<div className="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-[#30638E]/15 to-[#00798C]/15 grid place-items-center text-[#30638E]"><Store className="w-5 h-5" /></div>
<div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Suppliers</h1>
<p className="text-gray-500 dark:text-gray-400 text-sm">Who we buy from, how we pay them, and what we spend with each</p>
</div>
</div>
<SuppliersView rows={rows} />
</div>
);
}

View file

@ -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" },

View file

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

View file

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

View file

@ -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);

View file

@ -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);

View file

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

View file

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

View file

@ -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({

View file

@ -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<string, string> = {};
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 });
}
}

View file

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

View file

@ -23,7 +23,7 @@ const KIND: Record<string, string> = { 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<LogPreset>("month");
const [from, setFrom] = useState("");
@ -201,7 +201,8 @@ export function CashFloatCard({ rows, costCodes = [] }: { rows: CashRow[]; costC
<h3 className="text-base font-semibold text-[#003D5B] dark:text-white">Add an entry by hand</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">For a slip that was not photographed. It comes off the card balance like any other slip.</p>
<label htmlFor="m-supplier" className="block text-sm font-medium mt-4">Supplier</label>
<input id="m-supplier" autoFocus value={mSupplier} onChange={(e) => setMSupplier(e.target.value)} maxLength={200} className={`${field} w-full mt-1`} />
<input id="m-supplier" autoFocus list="cf-suppliers" value={mSupplier} onChange={(e) => setMSupplier(e.target.value)} maxLength={200} className={`${field} w-full mt-1`} />
<datalist id="cf-suppliers">{suppliers.map((s) => <option key={s} value={s} />)}</datalist>
<label htmlFor="m-amount" className="block text-sm font-medium mt-3">Amount (R)</label>
<input id="m-amount" type="text" inputMode="decimal" value={mAmount} onChange={(e) => setMAmount(e.target.value)} className={`${field} w-full mt-1 text-lg tabular-nums`} placeholder="0.00" />
<label htmlFor="m-date" className="block text-sm font-medium mt-3">Date and time of the slip</label>

View file

@ -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<File | null>(null);
const [preview, setPreview] = useState('');
@ -87,7 +87,8 @@ export function SlipCapture({ hasOpening, balance, costCodes }: { hasOpening: bo
</div>
<img src={preview} alt="Photo of the slip" className="mt-3 max-h-48 w-full rounded-xl object-contain bg-bg border border-border"/>
<label htmlFor="supplier" className="block text-sm font-medium mt-4">Supplier</label>
<input id="supplier" className="plant-clock-input mt-1" value={supplier} onChange={e => setSupplier(e.target.value)} maxLength={200} autoComplete="off" placeholder="e.g. Builders Warehouse"/>
<input id="supplier" className="plant-clock-input mt-1" list="slip-suppliers" value={supplier} onChange={e => setSupplier(e.target.value)} maxLength={200} autoComplete="off" placeholder="e.g. Builders Warehouse"/>
<datalist id="slip-suppliers">{suppliers.map(s => <option key={s} value={s}/>)}</datalist>
<label htmlFor="cost-code" className="block text-sm font-medium mt-3">Cost code <span className="font-normal text-text-muted">(optional)</span></label>
<input id="cost-code" className="plant-clock-input mt-1" list="slip-cost-codes" value={costCode} onChange={e => setCostCode(e.target.value)} maxLength={100} autoComplete="off" autoCapitalize="characters" placeholder="e.g. TLB04"/>
<datalist id="slip-cost-codes">{costCodes.map(c => <option key={c} value={c}/>)}</datalist>

View file

@ -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<LogPreset>("all");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [termsFilter, setTermsFilter] = useState("");
const [query, setQuery] = useState("");
const [open, setOpen] = useState<string | null>(null);
const [dialog, setDialog] = useState<null | "add" | SupplierRow>(null);
const [deleting, setDeleting] = useState<SupplierRow | null>(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 (
<div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<div className={`${card} p-4`}><p className="text-xs text-gray-500">Suppliers</p><p className="text-2xl font-semibold mt-1">{rows.length}</p>
<p className="text-xs text-gray-500 mt-1">{rows.filter((r) => isAccount(r.terms)).length} on account · {rows.filter((r) => r.terms === "cash").length} cash</p></div>
<div className={`${card} p-4`}><p className="text-xs text-gray-500">Purchase orders</p><p className="text-xl font-semibold tabular-nums mt-1">{rand(grandPo)}</p></div>
<div className={`${card} p-4`}><p className="text-xs text-gray-500">Petty cash</p><p className="text-xl font-semibold tabular-nums mt-1">{rand(grandCash)}</p></div>
<div className={`${card} p-4`}><p className="text-xs text-gray-500">Total spent</p><p className="text-xl font-semibold tabular-nums mt-1 text-[#30638E]">{rand(grandPo + grandCash)}</p></div>
</div>
{needTerms > 0 && (
<p className="mb-4 rounded-xl border border-[#EDAE49]/40 bg-[#EDAE49]/[0.08] p-3 text-xs text-gray-700 dark:text-gray-300">
<strong>{needTerms} supplier{needTerms !== 1 && "s"}</strong> came in from purchase orders and still need payment terms (cash, 30 or 60 days). Filter by “Terms not set” and tap Edit.
</p>
)}
<div className={`${card} p-4 sm:p-5`}>
<div className="flex flex-col lg:flex-row lg:items-center gap-3 mb-3">
<div className="hidden lg:flex gap-1.5" role="group" aria-label="Period">
{PRESETS.map((p) => (
<button key={p.id} onClick={() => setPreset(p.id)} aria-pressed={preset === p.id}
className={`min-h-[44px] px-4 rounded-lg text-xs font-medium border ${preset === p.id ? "bg-[#30638E] text-white border-[#30638E]" : "border-gray-200 dark:border-border text-gray-600 dark:text-gray-300"}`}>{p.label}</button>
))}
</div>
<select className={`${field} lg:hidden`} value={preset} onChange={(e) => setPreset(e.target.value as LogPreset)} aria-label="Period">
{PRESETS.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
</select>
{preset === "range" && (
<div className="flex items-center gap-2">
<input type="date" className={field} value={from} max={to || undefined} onChange={(e) => setFrom(e.target.value)} aria-label="From" />
<span className="text-xs text-gray-500">to</span>
<input type="date" className={field} value={to} min={from || undefined} onChange={(e) => setTo(e.target.value)} aria-label="To" />
</div>
)}
<div className="flex flex-wrap gap-2 lg:ml-auto">
<input className={`${field} w-44`} placeholder="Search supplier…" value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search suppliers" />
<select className={field} value={termsFilter} onChange={(e) => setTermsFilter(e.target.value)} aria-label="Payment terms">
{TERMS_FILTERS.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
<button onClick={() => begin("add")} className="min-h-[44px] px-4 rounded-lg bg-[#00798C] text-white text-xs font-medium inline-flex items-center gap-1.5"><Plus className="w-4 h-4" />Add supplier</button>
</div>
</div>
{shown.length === 0 ? (
<p className="text-xs text-gray-500 py-4">{rows.length === 0 ? "No suppliers yet." : "No suppliers match these filters."}</p>
) : (
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<table className="w-full text-sm min-w-[920px]">
<thead>
<tr className="text-left text-xs text-gray-500 dark:text-gray-400">
<th className="sticky left-0 bg-white dark:bg-surface py-2 pr-3 font-medium">Supplier</th>
<th className="py-2 pr-3 font-medium">Terms</th>
<th className="py-2 pr-3 font-medium text-right">Purchase orders</th>
<th className="py-2 pr-3 font-medium text-right">Petty cash</th>
<th className="py-2 pr-3 font-medium text-right">Total spent</th>
<th className="py-2 pr-3 font-medium">Last purchase</th>
<th className="py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{shown.map(({ r, t, poCount, poTotal, cashCount, cashTotal, total, last }) => (
<Fragment key={r.id}>
<tr className="border-t border-gray-100 dark:border-border align-top">
<td className="sticky left-0 bg-white dark:bg-surface py-2 pr-3">
<button onClick={() => setOpen(open === r.id ? null : r.id)} aria-expanded={open === r.id} className="min-h-[44px] flex items-center gap-1.5 text-left font-medium">
{open === r.id ? <ChevronDown className="w-4 h-4 shrink-0" /> : <ChevronRight className="w-4 h-4 shrink-0" />}
<span>{r.name}{r.code && <span className="block text-xs font-mono font-normal text-gray-500">{r.code}</span>}</span>
</button>
</td>
<td className="py-2 pr-3 whitespace-nowrap">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${!r.terms ? "bg-[#EDAE49]/20 text-[#8a5d00]" : r.terms === "cash" ? "bg-[#00798C]/10 text-[#00798C]" : "bg-[#30638E]/10 text-[#30638E]"}`}>
{r.terms === "cash" ? "Cash" : isAccount(r.terms) ? `Account · ${r.terms} days` : "Not set"}</span>
</td>
<td className="py-2 pr-3 text-right tabular-nums">{poCount ? <>{rand(poTotal)}<span className="block text-xs text-gray-500">{poCount} order{poCount !== 1 && "s"}</span></> : "—"}</td>
<td className="py-2 pr-3 text-right tabular-nums">{cashCount ? <>{rand(cashTotal)}<span className="block text-xs text-gray-500">{cashCount} slip{cashCount !== 1 && "s"}</span></> : "—"}</td>
<td className="py-2 pr-3 text-right tabular-nums font-semibold">{total ? rand(total) : "—"}</td>
<td className="py-2 pr-3 whitespace-nowrap">{last ? dmy(last) : "—"}</td>
<td className="py-2">
<div className="flex items-center gap-1 whitespace-nowrap">
<button onClick={() => begin(r)} className="min-h-[44px] px-3 rounded-lg border border-gray-200 dark:border-border text-xs font-medium">Edit</button>
<button onClick={() => { setError(""); setDeleting(r); }} className="min-h-[44px] px-3 rounded-lg text-xs text-[#D1495B] hover:bg-[#D1495B]/10">Delete</button>
</div>
</td>
</tr>
{open === r.id && (
<tr className="bg-gray-50/60 dark:bg-white/[0.03]">
<td colSpan={7} className="px-4 py-3">
{r.notes && <p className="text-xs text-gray-600 dark:text-gray-300 mb-2">{r.notes}</p>}
{t.length === 0 ? <p className="text-xs text-gray-500">Nothing booked against this supplier in this period.</p> : (
<table className="w-full text-xs">
<thead><tr className="text-left text-gray-500"><th className="py-1 pr-3 font-medium">Date</th><th className="py-1 pr-3 font-medium">Type</th><th className="py-1 pr-3 font-medium">Reference</th><th className="py-1 pr-3 font-medium text-right">Amount</th><th className="py-1 font-medium">Due</th></tr></thead>
<tbody>{t.map((x) => (
<tr key={x.id} className="border-t border-gray-100 dark:border-border">
<td className="py-1.5 pr-3 whitespace-nowrap">{dmy(x.at)}</td>
<td className="py-1.5 pr-3">{x.kind === "po" ? "Purchase order" : "Petty cash"}</td>
<td className="py-1.5 pr-3 font-mono">{x.ref || "—"}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{rand(x.amount)}</td>
<td className="py-1.5 whitespace-nowrap">{x.kind === "po" && isAccount(r.terms) ? dmy(dueDate(x.at, r.terms)) : x.kind === "cash" ? "Paid" : "—"}</td>
</tr>))}</tbody>
</table>
)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">Purchase orders are VAT-inclusive totals. Due dates are the PO date plus the supplier&apos;s terms; payments are not tracked here yet.</p>
</div>
{dialog && (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/50 p-4" onClick={() => !busy && setDialog(null)}>
<form onClick={(e) => 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">
<h3 className="text-base font-semibold text-[#003D5B] dark:text-white">{dialog === "add" ? "Add a supplier" : "Edit supplier"}</h3>
<label htmlFor="s-name" className="block text-sm font-medium mt-4">Name</label>
<input id="s-name" autoFocus value={name} onChange={(e) => setName(e.target.value)} maxLength={200} className={`${field} w-full mt-1`} />
<label htmlFor="s-code" className="block text-sm font-medium mt-3">Supplier code <span className="font-normal text-gray-500">(as on purchase orders, optional)</span></label>
<input id="s-code" value={code} onChange={(e) => setCode(e.target.value)} maxLength={40} className={`${field} w-full mt-1 font-mono`} placeholder="e.g. 09YTS001" />
<p className="text-sm font-medium mt-3">How do we pay them?</p>
<div className="grid grid-cols-3 gap-2 mt-1" role="radiogroup" aria-label="Payment terms">
{TERMS.map((t) => (
<button key={t.id} type="button" role="radio" aria-checked={terms === t.id} onClick={() => setTerms(t.id)}
className={`min-h-[48px] rounded-lg text-xs font-medium border ${terms === t.id ? "bg-[#30638E] text-white border-[#30638E]" : "border-gray-200 dark:border-border"}`}>{t.id === "cash" ? "Cash" : `Account · ${t.label}`}</button>
))}
</div>
{!terms && <p className="text-xs text-gray-500 mt-1">Not set yet — choose one when you know.</p>}
<label htmlFor="s-notes" className="block text-sm font-medium mt-3">Notes <span className="font-normal text-gray-500">(optional)</span></label>
<textarea id="s-notes" value={notes} onChange={(e) => setNotes(e.target.value)} maxLength={1000} className="mt-1 w-full min-h-[70px] rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface p-2 text-sm" />
{error && <p role="alert" className="text-xs text-[#D1495B] mt-3">{error}</p>}
<div className="flex justify-end gap-2 mt-5">
<button type="button" disabled={busy} onClick={() => setDialog(null)} className="min-h-[44px] px-4 rounded-lg text-sm border border-gray-200 dark:border-border">Cancel</button>
<button type="submit" disabled={busy || !name.trim()} className="min-h-[44px] px-5 rounded-lg text-sm font-medium text-white bg-[#30638E] disabled:opacity-60">{busy ? "Saving…" : "Save"}</button>
</div>
</form>
</div>
)}
{deleting && (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/50 p-4" onClick={() => !busy && setDeleting(null)}>
<div onClick={(e) => e.stopPropagation()} role="alertdialog" aria-modal="true" className="w-full max-w-sm rounded-2xl bg-white dark:bg-surface p-5 shadow-xl">
<h3 className="text-base font-semibold text-[#003D5B] dark:text-white">Delete {deleting.name}?</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">Only a supplier with no purchase orders or slips booked against it can be deleted.</p>
{error && <p role="alert" className="text-xs text-[#D1495B] mt-3">{error}</p>}
<div className="flex justify-end gap-2 mt-5">
<button disabled={busy} onClick={() => setDeleting(null)} className="min-h-[44px] px-4 rounded-lg text-sm border border-gray-200 dark:border-border">Cancel</button>
<button disabled={busy} onClick={() => send(`/api/suppliers/${deleting.id}`, "DELETE")} className="min-h-[44px] px-5 rounded-lg text-sm font-medium text-white bg-[#D1495B] disabled:opacity-60">{busy ? "Deleting…" : "Delete"}</button>
</div>
</div>
</div>
)}
</div>
);
}

14
lib/require-full.ts Normal file
View file

@ -0,0 +1,14 @@
import "server-only";
import { getSession } from "./auth";
/**
* Belt and braces for main-app write routes: the proxy already keeps Office, Mechanic and
* Clock-in users out of every non-clock path, but a destructive route should not depend on that
* alone. Returns a Response to send back when the caller is not a Full user, otherwise null.
*/
export async function refuseUnlessFull(): Promise<Response | null> {
const session = await getSession();
if (!session) return Response.json({ ok: false, error: "Please sign in." }, { status: 401 });
if (session.access !== "full") return Response.json({ ok: false, error: "Only full users can do this." }, { status: 403 });
return null;
}

37
lib/supplier-server.ts Normal file
View file

@ -0,0 +1,37 @@
import "server-only";
import type PocketBase from "pocketbase";
import { supplierKey, type Terms } from "./supplier";
/**
* The supplier a purchase order or slip belongs to — found, or created.
*
* Match order: the supplier's CODE from the PO ("09YTS001") when there is one, then the folded
* NAME. A new supplier takes `terms` (a till slip is "cash"; a PO supplier nobody has classified
* yet is left "" so the list shows it as needing terms). An existing supplier's terms are never
* overwritten here — only a person changes those.
*/
export async function resolveSupplier(
pb: PocketBase,
input: { name: string; code?: string; terms?: Terms; source: string },
): Promise<string> {
const name = (input.name || "").trim();
const key = supplierKey(name);
if (!name || !key) return "";
const code = (input.code || "").trim().toUpperCase();
const all = await pb.collection("suppliers").getFullList({ fields: "id,key,code" });
const hit = (code && all.find((s) => (s.code || "").toUpperCase() === code)) || all.find((s) => s.key === key);
if (hit) {
// Learn a code we did not have, without disturbing anything a person set.
if (code && !(hit.code || "").trim()) await pb.collection("suppliers").update(hit.id, { code }).catch(() => {});
return hit.id;
}
try {
const created = await pb.collection("suppliers").create({ name, key, code, terms: input.terms ?? "", source: input.source });
return created.id;
} catch {
// Lost a race to the unique key: someone created it a moment ago.
const again = (await pb.collection("suppliers").getFullList({ fields: "id,key" })).find((s) => s.key === key);
return again?.id ?? "";
}
}

21
lib/supplier.test.ts Normal file
View file

@ -0,0 +1,21 @@
import test from "node:test";
import assert from "node:assert/strict";
import { dueDate, supplierKey } from "./supplier";
test("the same supplier typed two ways folds to one key", () => {
assert.equal(supplierKey("Lube Force Traders cc"), supplierKey("LUBE FORCE TRADERS"));
assert.equal(supplierKey("PERCA SERVICES (PTY) LTD"), supplierKey("Perca Services"));
assert.equal(supplierKey("Hydra - Lab CC"), supplierKey("hydra lab"));
});
test("different suppliers stay different", () => {
assert.notEqual(supplierKey("Your Tyre Shop"), supplierKey("Tyre Shop"));
assert.notEqual(supplierKey("Jose Electrical"), supplierKey("Jose Plumbing"));
});
test("account purchases fall due PO date + terms; cash and unknown never do", () => {
assert.equal(dueDate("2026-10-07", "30"), "2026-11-06");
assert.equal(dueDate("2026-10-07", "60"), "2026-12-06");
assert.equal(dueDate("2026-12-20", "30"), "2027-01-19");
assert.equal(dueDate("2026-10-07", "cash"), "");
assert.equal(dueDate("2026-10-07", ""), "");
assert.equal(dueDate("nonsense", "30"), "");
});

38
lib/supplier.ts Normal file
View file

@ -0,0 +1,38 @@
/** Supplier list rules: folding names into one key, payment terms and due dates. Pure — client-safe. */
export type Terms = "" | "cash" | "30" | "60";
export const TERMS: { id: Terms; label: string }[] = [
{ id: "cash", label: "Cash" },
{ id: "30", label: "30 days" },
{ id: "60", label: "60 days" },
];
export const termsLabel = (t: string) => TERMS.find((x) => x.id === t)?.label ?? "Not set";
export const parseTerms = (v: unknown): Terms => (v === "cash" || v === "30" || v === "60" ? v : "");
export const isAccount = (t: string) => t === "30" || t === "60";
// Company-form words that change between a PO and a till slip but never name a different supplier.
const NOISE = new Set(["PTY", "LTD", "LIMITED", "CC", "INC", "THE", "AND"]);
/**
* "Lube Force Traders cc" → "LUBEFORCETRADERS". Upper-case, punctuation gone, company-form words
* dropped. This is the same name typed two ways — it is NOT a fuzzy match: "Your Tyre Shop" and
* "Tyre Shop" stay different suppliers.
*/
export function supplierKey(name: string): string {
return (name || "")
.toUpperCase()
.replace(/&/g, " AND ")
.replace(/[^A-Z0-9]+/g, " ")
.split(" ")
.filter((w) => w && !NOISE.has(w))
.join("");
}
/** The day an account purchase falls due: PO date + terms. "" when there are no account terms or no valid date. */
export function dueDate(poDate: string, terms: string): string {
if (!isAccount(terms)) return "";
const d = new Date(`${(poDate || "").slice(0, 10)}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return "";
d.setUTCDate(d.getUTCDate() + Number(terms));
return d.toISOString().slice(0, 10);
}

View file

@ -0,0 +1,39 @@
// Book every existing purchase order and cash-float slip against a supplier (idempotent).
// New suppliers from POs start with terms unset; ones from slips start as "cash".
// Usage: node scripts/backfill-suppliers.mjs
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { supplierKey } from "../lib/supplier.ts";
for (const l of readFileSync(new URL("../.env.local", import.meta.url), "utf8").split("\n")) {
const i = l.indexOf("="); if (i > 0 && !l.trim().startsWith("#")) process.env[l.slice(0, i).trim()] = l.slice(i + 1).trim();
}
const pb = new PocketBase(process.env.NEXT_PUBLIC_PB_URL);
pb.autoCancellation(false);
await pb.collection("_superusers").authWithPassword(process.env.PB_ADMIN, process.env.PB_PASS);
const list = await pb.collection("suppliers").getFullList();
const byKey = new Map(list.map((s) => [s.key, s]));
const byCode = new Map(list.filter((s) => s.code).map((s) => [s.code.toUpperCase(), s]));
async function ensure(name, code, terms, source) {
const key = supplierKey(name);
if (!key) return null;
let s = (code && byCode.get(code.toUpperCase())) || byKey.get(key);
if (!s) {
s = await pb.collection("suppliers").create({ name: name.trim(), key, code: (code || "").toUpperCase(), terms, source });
byKey.set(key, s); if (code) byCode.set(code.toUpperCase(), s);
console.log(` + ${name.trim()} (${source}${terms ? `, ${terms}` : ", terms not set"})`);
}
return s;
}
let linked = 0;
for (const p of await pb.collection("purchases").getFullList()) {
if (p.supplier_ref) continue;
const s = await ensure(p.supplier || "", p.supplier_code || "", "", "Purchase order");
if (s) { await pb.collection("purchases").update(p.id, { supplier_ref: s.id }); linked++; }
}
for (const e of await pb.collection("cash_float_entries").getFullList({ filter: 'kind = "expense"' })) {
if (e.supplier_ref || !e.supplier) continue;
const s = await ensure(e.supplier, "", "cash", "Cash float");
if (s) { await pb.collection("cash_float_entries").update(e.id, { supplier_ref: s.id }); linked++; }
}
console.log(`✓ ${linked} record(s) booked against a supplier; ${byKey.size} supplier(s) in the list`);

View file

@ -0,0 +1,52 @@
// Create the `suppliers` collection and link purchase orders + cash-float slips to it (idempotent).
//
// One supplier list for the whole business. Every purchase order and every petty-cash slip is
// booked against a supplier, so "how much did we spend at X" is one query.
//
// terms "cash" paid on the spot (petty cash)
// "30" account, payable in 30 days
// "60" account, payable in 60 days
// "" not set yet — a supplier that appeared on a PO before anyone classified it
//
// `key` is the supplier's name folded (case, punctuation, PTY/LTD/CC removed) and is UNIQUE, so
// "Lube Force Traders cc" and "LUBE FORCE TRADERS" cannot become two suppliers.
//
// Usage: node scripts/setup-suppliers-collection.mjs
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
for (const l of readFileSync(new URL("../.env.local", import.meta.url), "utf8").split("\n")) {
const i = l.indexOf("="); if (i > 0 && !l.trim().startsWith("#")) process.env[l.slice(0, i).trim()] = l.slice(i + 1).trim();
}
const pb = new PocketBase(process.env.NEXT_PUBLIC_PB_URL);
pb.autoCancellation(false);
await pb.collection("_superusers").authWithPassword(process.env.PB_ADMIN, process.env.PB_PASS);
const ref = await pb.collections.getOne("monthly_income");
const rules = {
listRule: ref.listRule ?? "", viewRule: ref.viewRule ?? "",
createRule: ref.createRule ?? "", updateRule: ref.updateRule ?? "", deleteRule: ref.deleteRule ?? "",
};
const fields = [
{ name: "name", type: "text", required: true },
{ name: "key", type: "text", required: true },
{ name: "code", type: "text" },
{ name: "terms", type: "text" },
{ name: "notes", type: "text" },
{ name: "source", type: "text" },
];
const indexes = ["CREATE UNIQUE INDEX idx_suppliers_key ON suppliers (key)"];
let suppliers = (await pb.collections.getFullList({ filter: 'name = "suppliers"', perPage: 1 }))[0];
if (!suppliers) {
suppliers = await pb.send("/api/collections", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "suppliers", type: "base", fields, indexes, ...rules }) });
console.log(` ✓ created suppliers (${suppliers.id})`);
} else console.log(" · suppliers already present");
for (const target of ["purchases", "cash_float_entries"]) {
const c = await pb.collections.getOne(target).catch(() => null);
if (!c) { console.log(` ! ${target} missing — skipped`); continue; }
if (c.fields.some((f) => f.name === "supplier_ref")) { console.log(` · ${target}.supplier_ref already present`); continue; }
await pb.send(`/api/collections/${target}`, { method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: [...c.fields, { name: "supplier_ref", type: "relation", collectionId: suppliers.id, minSelect: 0, maxSelect: 1, cascadeDelete: false }] }) });
console.log(` ✓ added ${target}.supplier_ref`);
}