fix(buyer-tasks): generate PO succeeds + supplier stripe + PO badge

generatePoForGroup was creating purchase_orders records without
`po_number`, which is required by the PB schema. PB rejected the
create with 500 ("Failed to create record.") and the operator never
got a PO. Fix:

- New src/lib/server/po-number.ts — server-side generatePONumber()
  that mirrors the client version in pb-helpers.ts (PO-YYYY-MM-NNN
  format) but uses getAdminPocketBase(). Per the data-access rules,
  don't extend pb-helpers.ts; server business logic lives under
  lib/server/.
- generatePoForGroup now generates the PO number, includes
  total_amount (sum of costPrice * quantity) and order_item
  (back-link to the items) in the create payload — mirrors the
  canonical /orders/[id]/process PO-creation flow so the PB admin
  row shows related lines without an extra getList call.
- Test updated to assert the new payload (po_number regex,
  total_amount computed, order_item back-link).

UI while here:
- BuyerTaskGroupCard now renders a deterministic supplier colour
  stripe on the left edge. New src/lib/supplier-color.ts hashes the
  supplierId into a small Tailwind 600 palette (18 colours) so the
  same supplier always shows the same colour, and the supplier name
  gets tinted with the same colour for quick scanning.
- PO number is now shown prominently at the top of the card (in a
  warm-toned badge that matches the AI Insights theme) when one
  exists — po_draft and po_sent tabs. Needs PO tab omits it.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-30 15:00:18 +02:00
parent 20b57ce2bb
commit 172d562f06
7 changed files with 227 additions and 13 deletions

View file

@ -62,4 +62,23 @@ describe("BuyerTaskGroupCard", () => {
fireEvent.click(screen.getByTestId("buyer-task-items-toggle"));
expect(screen.getByText("HSS drill")).toBeInTheDocument();
});
it("shows the PO number prominently at the top when one exists", () => {
render(
<BuyerTaskGroupCard
group={{ ...baseGroup, step: "po_draft", poNumber: "PO-2607-001" }}
onAction={vi.fn()}
/>,
);
expect(
screen.getByTestId(`buyer-task-po-number-${baseGroup.groupId}`),
).toHaveTextContent("PO-2607-001");
});
it("omits the PO number badge on the needs_po tab (no PO yet)", () => {
render(<BuyerTaskGroupCard group={baseGroup} onAction={vi.fn()} />);
expect(
screen.queryByTestId(`buyer-task-po-number-${baseGroup.groupId}`),
).toBeNull();
});
});

View file

@ -1,9 +1,12 @@
"use client";
import { FileText } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { BuyerTaskItemsList, type BuyerTaskItem } from "./buyer-task-items-list";
import type { BuyerTaskGroup } from "@/lib/server/buyer-tasks-service";
import { supplierColor } from "@/lib/supplier-color";
import { withAlpha } from "@/lib/item-status-ui";
const ACTION_LABEL: Record<BuyerTaskGroup["step"], string> = {
needs_po: "Generate PO",
@ -26,20 +29,68 @@ export function BuyerTaskGroupCard({
onAction: (group: BuyerTaskGroup) => void;
disabled?: boolean;
}) {
// Deterministic supplier stripe colour. Same supplier → same colour
// across cards and reloads.
const stripe = supplierColor(group.supplierId);
return (
<Card data-testid={`buyer-task-group-${group.groupId}`}>
<CardContent className="p-0">
<Card
data-testid={`buyer-task-group-${group.groupId}`}
className="relative overflow-hidden"
>
{/* Supplier colour stripe — sits along the left edge so the
operator can spot which supplier this card is for at a
glance when many cards are stacked. Pinned full height via
absolute positioning so it lines up with the Card outline. */}
<div
aria-hidden="true"
className="absolute inset-y-0 left-0 w-1.5"
style={{ backgroundColor: stripe }}
/>
{/* Subtle supplier tint along the stripe — soft enough that the
card content still feels neutral. */}
<div
aria-hidden="true"
className="absolute inset-y-0 left-1.5 w-6 pointer-events-none"
style={{ background: `linear-gradient(to right, ${withAlpha(stripe, 10)}, transparent)` }}
/>
<CardContent className="p-0 pl-3">
<div className="flex items-start gap-3 p-3">
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="font-mono text-sm font-medium">{group.orderNumber}</span>
<span className="text-sm text-muted-foreground">·</span>
<span className="text-sm font-medium">{group.supplierName}</span>
<div className="min-w-0 flex-1 space-y-1.5">
{/* 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). */}
<div className="flex items-baseline gap-2 flex-wrap">
{group.poNumber && (
<span
className="inline-flex items-center gap-1 rounded-md border border-warn/30 bg-warn/10 px-2 py-0.5 font-mono text-xs font-bold text-warn-foreground ring-1 ring-inset ring-warn/15"
data-testid={`buyer-task-po-number-${group.groupId}`}
>
<FileText className="h-3 w-3" />
{group.poNumber}
</span>
)}
<span className="font-mono text-sm font-medium text-muted-foreground">
{group.orderNumber}
</span>
<span
className="text-sm font-semibold"
// Tint the supplier name with the same colour as the
// stripe so the supplier is recognisable even when the
// card content overlaps with neighbours.
style={{ color: stripe }}
data-testid={`buyer-task-supplier-name-${group.groupId}`}
>
{group.supplierName}
</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
<div className="text-xs text-muted-foreground">
<span>{group.customerName}</span><span aria-hidden="true"> · </span><span>{group.companyName}</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
<div className="text-xs text-muted-foreground">
{group.itemCount} item{group.itemCount === 1 ? "" : "s"} · Cost R{group.totalCost.toFixed(2)}
</div>
</div>
@ -57,4 +108,4 @@ export function BuyerTaskGroupCard({
</CardContent>
</Card>
);
}
}

View file

@ -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<string, unknown>;
// 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",

View file

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

View file

@ -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<string> {
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")}`;
}

View file

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

47
src/lib/supplier-color.ts Normal file
View file

@ -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]!;
}