+ {/* PO number + order number on the top row so they're
+ both scannable. The PO number is the headline — the
+ thing the operator needs to find quickly — so it goes
+ first and gets its own visual weight. Only shown when
+ a PO has been generated (po_draft / po_sent tabs). */}
+
+
{group.customerName} · {group.companyName}
-
+
{group.itemCount} item{group.itemCount === 1 ? "" : "s"} · Cost R{group.totalCost.toFixed(2)}
@@ -57,4 +108,4 @@ export function BuyerTaskGroupCard({
);
-}
+}
\ No newline at end of file
diff --git a/src/lib/server/buyer-tasks-service.test.ts b/src/lib/server/buyer-tasks-service.test.ts
index 534f475..d296ffc 100644
--- a/src/lib/server/buyer-tasks-service.test.ts
+++ b/src/lib/server/buyer-tasks-service.test.ts
@@ -306,12 +306,14 @@ describe("listBuyerTasks", () => {
});
describe("generatePoForGroup", () => {
- it("creates a PO and updates items", async () => {
+ it("creates a PO with a generated po_number, total_amount, and order_item back-link", async () => {
const createPo = vi.fn().mockResolvedValue({ id: "po-new" });
const updateItem = vi.fn().mockResolvedValue({});
const admin = {
collection: vi.fn().mockImplementation((name: string) => {
- if (name === "purchase_orders") return { create: createPo };
+ if (name === "purchase_orders") {
+ return { create: createPo, getList: vi.fn().mockResolvedValue({ items: [] }) };
+ }
if (name === "order_items") return { update: updateItem };
return { getList: vi.fn(), getFullList: vi.fn() };
}),
@@ -321,13 +323,22 @@ describe("generatePoForGroup", () => {
const { generatePoForGroup } = await import("./buyer-tasks-service");
const poId = await generatePoForGroup("order-1", "sup-1", [
{ id: "item-1", costPrice: 25, quantity: 2 },
+ { id: "item-2", costPrice: 10, quantity: 3 },
]);
expect(poId).toBe("po-new");
- expect(createPo).toHaveBeenCalledWith({
+ expect(createPo).toHaveBeenCalledTimes(1);
+ const payload = createPo.mock.calls[0]![0] as Record
;
+ // po_number is required by the PB schema — without it the create
+ // returns 500 ("Failed to create record.").
+ expect(payload.po_number).toMatch(/^PO-\d{4}-\d{2}-\d{3}$/);
+ expect(payload).toMatchObject({
order: "order-1",
supplier: "sup-1",
status: "draft",
+ // 25*2 + 10*3 = 80
+ total_amount: 80,
+ order_item: ["item-1", "item-2"],
});
expect(updateItem).toHaveBeenCalledWith("item-1", {
purchase_order: "po-new",
diff --git a/src/lib/server/buyer-tasks-service.ts b/src/lib/server/buyer-tasks-service.ts
index aab324d..6e6c530 100644
--- a/src/lib/server/buyer-tasks-service.ts
+++ b/src/lib/server/buyer-tasks-service.ts
@@ -1,4 +1,5 @@
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
+import { generatePONumber } from "@/lib/server/po-number";
export type BuyerTaskTab = "needs_po" | "po_draft" | "po_sent";
@@ -133,10 +134,23 @@ export async function generatePoForGroup(
if (items.length === 0) return null;
const admin = await getAdminPocketBase();
+ // `purchase_orders.po_number` is required (PB schema enforces it),
+ // and `total_amount` / `order_item` are optional but useful: they
+ // back-link the items to the PO so the PB admin row shows the
+ // related lines without an extra getList call. Mirrors the
+ // canonical /orders/[id]/process create payload.
+ const poNumber = await generatePONumber();
+ const totalAmount = items.reduce(
+ (sum, it) => sum + it.costPrice * it.quantity,
+ 0,
+ );
const po = await admin.collection("purchase_orders").create({
+ po_number: poNumber,
order: orderId,
supplier: supplierId,
status: "draft",
+ total_amount: totalAmount,
+ order_item: items.map((it) => it.id),
});
await Promise.all(
diff --git a/src/lib/server/po-number.ts b/src/lib/server/po-number.ts
new file mode 100644
index 0000000..07f2848
--- /dev/null
+++ b/src/lib/server/po-number.ts
@@ -0,0 +1,44 @@
+import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
+
+/**
+ * Generate the next sequential PO number for the current month.
+ *
+ * Format: `PO-YYYY-MM-NNN` where NNN is the 3-digit sequence of POs
+ * created this month (sorted by descending po_number, so the highest
+ * sequence is what we increment from).
+ *
+ * Server-side variant of the client `generatePONumber` in
+ * `pb-helpers.ts`. Lives here per the data-access discipline rules
+ * (server-side business logic stays out of pb-helpers.ts).
+ *
+ * Race note: two concurrent calls could read the same highest seq
+ * and both write N+1, producing duplicate PO numbers. The call sites
+ * that need stronger guarantees should wrap this in a transaction or
+ * retry-on-conflict — for the buyer-tasks flow the user sees the
+ * duplicate and can regenerate, so we keep the simple version.
+ */
+export async function generatePONumber(): Promise {
+ const admin = await getAdminPocketBase();
+ const now = new Date();
+ const year = now.getFullYear();
+ const month = String(now.getMonth() + 1).padStart(2, "0");
+ const prefix = `PO-${year}-${month}-`;
+
+ const existing = await admin.collection("purchase_orders").getList<{
+ po_number: string;
+ }>(1, 1, {
+ filter: `po_number ~ "${prefix}"`,
+ sort: "-po_number",
+ });
+
+ let seq = 1;
+ if (existing.items.length > 0) {
+ const lastNum = parseInt(
+ existing.items[0].po_number.split("-").pop() || "0",
+ 10,
+ );
+ seq = lastNum + 1;
+ }
+
+ return `${prefix}${String(seq).padStart(3, "0")}`;
+}
\ No newline at end of file
diff --git a/src/lib/supplier-color.test.ts b/src/lib/supplier-color.test.ts
new file mode 100644
index 0000000..952b954
--- /dev/null
+++ b/src/lib/supplier-color.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from "vitest";
+import { supplierColor } from "./supplier-color";
+
+describe("supplierColor", () => {
+ it("returns a hex string", () => {
+ expect(supplierColor("sup-1")).toMatch(/^#[0-9a-f]{6}$/);
+ });
+
+ it("is deterministic — same id always gives the same colour", () => {
+ expect(supplierColor("sup-1")).toBe(supplierColor("sup-1"));
+ expect(supplierColor("abc123")).toBe(supplierColor("abc123"));
+ });
+
+ it("returns distinct colours for different suppliers", () => {
+ // Sample a handful of ids; at least two of them should differ.
+ // (With 18 palette slots, hash collisions are possible — but a
+ // 6-id sample will almost certainly produce >1 distinct colour.)
+ const ids = ["a", "b", "c", "d", "e", "f"];
+ const distinct = new Set(ids.map(supplierColor));
+ expect(distinct.size).toBeGreaterThan(1);
+ });
+
+ it("returns the fallback colour for empty / null ids", () => {
+ expect(supplierColor("")).toBe(supplierColor(null));
+ expect(supplierColor(null)).toBe(supplierColor(undefined));
+ expect(supplierColor("")).toMatch(/^#[0-9a-f]{6}$/);
+ });
+});
\ No newline at end of file
diff --git a/src/lib/supplier-color.ts b/src/lib/supplier-color.ts
new file mode 100644
index 0000000..feaab51
--- /dev/null
+++ b/src/lib/supplier-color.ts
@@ -0,0 +1,47 @@
+/**
+ * Deterministic supplier color from the supplier id.
+ *
+ * Hashes the supplierId into a small palette of saturated Tailwind
+ * 600 swatches so the same supplier always renders the same stripe
+ * colour, and different suppliers are visually distinguishable at a
+ * glance. Used by buyer-tasks cards today; other supplier-tinted UIs
+ * (e.g. supplier lists) can call this too.
+ *
+ * The hash is a tiny djb2 variant — collision-resistance doesn't
+ * matter, only that the colour is stable for a given id and
+ * reasonably spread across the palette.
+ */
+
+const PALETTE: readonly string[] = [
+ "#dc2626", // red 600
+ "#ea580c", // orange 600
+ "#d97706", // amber 600
+ "#ca8a04", // yellow 600
+ "#65a30d", // lime 600
+ "#16a34a", // green 600
+ "#059669", // emerald 600
+ "#0d9488", // teal 600
+ "#0891b2", // cyan 600
+ "#0284c7", // sky 600
+ "#2563eb", // blue 600
+ "#4f46e5", // indigo 600
+ "#7c3aed", // violet 600
+ "#9333ea", // purple 600
+ "#c026d3", // fuchsia 600
+ "#db2777", // pink 600
+ "#e11d48", // rose 600
+ "#475569", // slate 600
+];
+
+const FALLBACK_COLOR = "#64748b"; // slate-500 — neutral when no id
+
+export function supplierColor(supplierId: string | null | undefined): string {
+ if (!supplierId) return FALLBACK_COLOR;
+ let hash = 5381;
+ for (let i = 0; i < supplierId.length; i++) {
+ // (hash * 33) ^ char — classic djb2
+ hash = ((hash << 5) + hash + supplierId.charCodeAt(i)) | 0;
+ }
+ const index = Math.abs(hash) % PALETTE.length;
+ return PALETTE[index]!;
+}
\ No newline at end of file