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:
parent
20b57ce2bb
commit
172d562f06
7 changed files with 227 additions and 13 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
44
src/lib/server/po-number.ts
Normal file
44
src/lib/server/po-number.ts
Normal 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")}`;
|
||||
}
|
||||
28
src/lib/supplier-color.test.ts
Normal file
28
src/lib/supplier-color.test.ts
Normal 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
47
src/lib/supplier-color.ts
Normal 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]!;
|
||||
}
|
||||
Loading…
Reference in a new issue