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:
parent
1ec52961ee
commit
cfd54385b3
23 changed files with 639 additions and 7 deletions
16
AGENTS.md
16
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.
|
||||
|
|
|
|||
|
|
@ -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)}/>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
42
app/(dashboard)/expenses/suppliers/page.tsx
Normal file
42
app/(dashboard)/expenses/suppliers/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
58
app/api/suppliers/[id]/route.ts
Normal file
58
app/api/suppliers/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
31
app/api/suppliers/route.ts
Normal file
31
app/api/suppliers/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
248
components/SuppliersView.tsx
Normal file
248
components/SuppliersView.tsx
Normal 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'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
14
lib/require-full.ts
Normal 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
37
lib/supplier-server.ts
Normal 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
21
lib/supplier.test.ts
Normal 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
38
lib/supplier.ts
Normal 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);
|
||||
}
|
||||
39
scripts/backfill-suppliers.mjs
Normal file
39
scripts/backfill-suppliers.mjs
Normal 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`);
|
||||
52
scripts/setup-suppliers-collection.mjs
Normal file
52
scripts/setup-suppliers-collection.mjs
Normal 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`);
|
||||
}
|
||||
Loading…
Reference in a new issue