feat(collection-confirm): add stock item edits
- Add new POST API endpoints at `/api/v1/stock-purchase-items/[id]/sku` and `/api/v1/stock-purchase-items/[id]/cost` for updating stock purchase item details - Update ItemsTable to support cost and SKU edits for all row types, add supplier SKU column and editing for stock rows - Extend PoCard to pass edit callbacks and add stock-specific invoice upload functionality - Update all related test suites to cover new editing and UI features - Adjust invoice upload messaging to differentiate between customer and stock purchase orders
This commit is contained in:
parent
9e74968b5e
commit
59f245d798
13 changed files with 1454 additions and 114 deletions
53
.tmp-inspect-agent-scan.mjs
Normal file
53
.tmp-inspect-agent-scan.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { chromium } from "playwright";
|
||||
|
||||
const URL_BASE = "http://localhost:4001";
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
headless: true,
|
||||
args: ["--no-sandbox"],
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error" || msg.text().includes("agent") || msg.text().includes("scan")) {
|
||||
console.log("[browser]", msg.type(), msg.text());
|
||||
}
|
||||
});
|
||||
page.on("pageerror", (err) => console.log("[pageerror]", err.message));
|
||||
page.on("response", (res) => {
|
||||
if (res.url().includes("/api/mcp") || res.url().includes("/api/v1/collection-confirm")) {
|
||||
console.log("[api]", res.status(), res.url());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(`${URL_BASE}/login`, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
await page.fill("#login-email", "test-tooltip@wfdash.local");
|
||||
await page.fill("#login-password", "test12345678");
|
||||
await page.click('button:has-text("Sign In")');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.goto(`${URL_BASE}/collection-confirm`, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
await page.waitForTimeout(8000);
|
||||
|
||||
console.log("→ clicking Run agent scan…");
|
||||
await page.locator('[data-testid="collection-confirm-run-agent-scan"]').click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const panel = page.locator('[data-testid="collection-confirm-agent-scan-result"]');
|
||||
const panelCount = await panel.count();
|
||||
console.log("→ result panel count:", panelCount);
|
||||
|
||||
if (panelCount > 0) {
|
||||
console.log("→ panel text:", (await panel.textContent())?.slice(0, 400));
|
||||
} else {
|
||||
// Take screenshot to see what's happening
|
||||
await page.screenshot({ path: "/tmp/cc-no-panel.png", fullPage: false });
|
||||
console.log("→ no panel rendered, screenshot saved");
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log("→ done");
|
||||
|
|
@ -82,6 +82,24 @@ function jsonResponse(body: unknown, init: Partial<Response> = {}): Response {
|
|||
} as Response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a JSON-RPC 2.0 success response envelope for the `/api/mcp`
|
||||
* mock. The MCP route returns `{ result: { content: [{type:"text", text}] } }`
|
||||
* where `text` is a JSON-stringified payload — we keep that shape so
|
||||
* the page's `JSON.parse(text)` path runs against realistic data.
|
||||
*/
|
||||
function jsonRpcOk(body: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: { content: [{ type: "text", text: JSON.stringify(body) }] },
|
||||
}),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the URL string from any fetch `input` shape (string, URL,
|
||||
* or Request). The page always passes a string URL.
|
||||
|
|
@ -98,7 +116,7 @@ function fetchUrl(input: RequestInfo | URL): string {
|
|||
* Tests that need a different shape override the spy directly.
|
||||
*/
|
||||
function buildFetchSpy(rows: ConfirmRow[] = baseRows) {
|
||||
return vi.spyOn(window, "fetch").mockImplementation(async (input) => {
|
||||
return vi.spyOn(window, "fetch").mockImplementation(async (input, init) => {
|
||||
const url = fetchUrl(input);
|
||||
if (url === "/api/v1/collection-confirm") {
|
||||
return jsonResponse({ items: rows });
|
||||
|
|
@ -106,6 +124,12 @@ function buildFetchSpy(rows: ConfirmRow[] = baseRows) {
|
|||
if (typeof url === "string" && url.startsWith("/api/v1/order-items/")) {
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
if (typeof url === "string" && url.startsWith("/api/v1/stock-purchase-items/")) {
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
if (typeof url === "string" && url.startsWith("/api/v1/stock-purchases/")) {
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
}
|
||||
|
|
@ -985,9 +1009,15 @@ describe("CollectionConfirmPage", () => {
|
|||
screen.queryByTestId("po-card-confirm-SP-001"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Stock row also hides the cost pencil (the cost-edit route
|
||||
// would 404 on a stock_purchase_items id).
|
||||
expect(screen.queryByTestId("cost-edit-row-stock-1")).not.toBeInTheDocument();
|
||||
// Stock row now also shows the cost + SKU + supplier-SKU pencils
|
||||
// — the agent needs to be able to amend these on /collection-confirm
|
||||
// the same way the operator can on /stock-orders/[id]. The parent
|
||||
// routes the save through `/api/v1/stock-purchase-items/{id}/{cost,sku}`
|
||||
// instead of the customer-side endpoints. So all three pencils should
|
||||
// be present even on stock-source rows.
|
||||
expect(screen.getByTestId("cost-edit-row-stock-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("sku-edit-row-stock-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("supplier-sku-edit-row-stock-1")).toBeInTheDocument();
|
||||
|
||||
// Clear the spy so we can isolate the confirm-stock POST.
|
||||
fetchSpy.mockClear();
|
||||
|
|
@ -1023,6 +1053,7 @@ describe("CollectionConfirmPage", () => {
|
|||
);
|
||||
});
|
||||
|
||||
|
||||
it("preserves a null prior cost_price when a cost-edit POST fails (does not coerce to 0)", async () => {
|
||||
// Drive the page with one row whose cost_price is null. The
|
||||
// /cost endpoint is forced to fail so the rollback path runs.
|
||||
|
|
@ -1203,4 +1234,236 @@ describe("CollectionConfirmPage", () => {
|
|||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes cost + SKU + supplier-SKU + upload pencils on stock-only cards", async () => {
|
||||
// Local stockRow fixture — `stockRow` is scoped to the existing
|
||||
// "Confirm collection footer" test above (where it's declared
|
||||
// with `const`), so we re-declare it here for the page-level
|
||||
// smoke check. Same shape: source=stock, supplier_sku empty,
|
||||
// poNumber=SP-001.
|
||||
const stockRow: ConfirmRow = {
|
||||
id: "row-stock-1",
|
||||
source: "stock",
|
||||
description: "Stock cable ties",
|
||||
sku: "CT-200",
|
||||
order_qty: 50,
|
||||
qty_collected: 50,
|
||||
quantity: 50,
|
||||
cost_price: 7.5,
|
||||
stock_purchase_id: "sp-1",
|
||||
stock_line_status: "collected",
|
||||
poNumber: "SP-001",
|
||||
expand: {
|
||||
purchase_order: {
|
||||
id: "sp-1",
|
||||
po_number: "SP-001",
|
||||
expand: { supplier: { id: "s-2", suppliername: "CableCo" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
// Reset the beforeEach's `buildFetchSpy` mock chain (multiple
|
||||
// mocks accumulate across tests since vitest doesn't restore
|
||||
// mocks by default) and install one that returns the stockRow
|
||||
// fixture so the page renders a stock-only PoCard.
|
||||
vi.mocked(window.fetch).mockClear();
|
||||
vi.mocked(window.fetch).mockImplementation(async (input) => {
|
||||
const url = fetchUrl(input);
|
||||
if (url === "/api/v1/collection-confirm") {
|
||||
return jsonResponse({ items: [stockRow] });
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<Page />);
|
||||
// Stock card renders — PoCard testid is `po-card-{poNumber}` and the
|
||||
// stockRow fixture has poNumber: "SP-001".
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("po-card-SP-001")).toBeInTheDocument(),
|
||||
);
|
||||
// Open the card body so the items table (and the upload affordance)
|
||||
// are reachable.
|
||||
await user.click(screen.getByTestId("po-card-toggle-SP-001"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("cost-edit-row-stock-1")).toBeInTheDocument(),
|
||||
);
|
||||
// Stock-source rows now show the cost + SKU pencils the same way
|
||||
// customer rows do — the parent routes through
|
||||
// /api/v1/stock-purchase-items/{id}/{cost,sku} instead of the
|
||||
// customer-side endpoints. (Per-row supplier-SKU is stock-only
|
||||
// because customer order_items have no supplier_sku field.)
|
||||
expect(screen.getByTestId("cost-edit-row-stock-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("sku-edit-row-stock-1")).toBeInTheDocument();
|
||||
// Stock-only card exposes the supplier-side invoice-upload
|
||||
// affordance so the agent (or operator) can attach the supplier
|
||||
// invoice via the stock-purchases endpoint.
|
||||
expect(
|
||||
screen.getByTestId("po-card-upload-invoice-SP-001"),
|
||||
).toBeInTheDocument();
|
||||
// Customer rows in the same fixture do NOT get the supplier-SKU
|
||||
// pencil — supplier_sku is stock-only. (Sanity-check the gating
|
||||
// path by checking the testid isn't accidentally present on a
|
||||
// customer row.)
|
||||
expect(screen.queryByTestId("supplier-sku-edit-row-1")).not.toBeInTheDocument();
|
||||
// But the stock row DOES get the supplier-SKU pencil once the page
|
||||
// is wired to forward `onSupplierSkuChange`. We don't drive the
|
||||
// click — that's covered by the items-table test — but the
|
||||
// pencil rendering is the visible-to-the-agent affordance.
|
||||
expect(screen.getByTestId("supplier-sku-edit-row-stock-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an Agent reviewed badge on PoCards whose parent PO has a price_diff_report note entry", async () => {
|
||||
// Stock row whose parent PO has a price_diff_report entry in
|
||||
// its notes feed. The service parses the latest such entry and
|
||||
// surfaces it as `row.agent_report`.
|
||||
const stockRowWithAgentNote: ConfirmRow = {
|
||||
id: "row-stock-2",
|
||||
source: "stock",
|
||||
description: "Welding rod",
|
||||
sku: "WR-1",
|
||||
order_qty: 10,
|
||||
qty_collected: 10,
|
||||
quantity: 10,
|
||||
cost_price: 5,
|
||||
stock_purchase_id: "sp-2",
|
||||
stock_line_status: "collected",
|
||||
poNumber: "SP-002",
|
||||
agent_report: {
|
||||
exists: true,
|
||||
verdict: "ready_to_receive",
|
||||
summary: "3 lines matched cleanly",
|
||||
recorded_at: "2026-08-14T12:00:00.000Z",
|
||||
},
|
||||
expand: {
|
||||
purchase_order: {
|
||||
id: "sp-2",
|
||||
po_number: "SP-002",
|
||||
expand: { supplier: { id: "s-2", suppliername: "CableCo" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(window.fetch).mockClear();
|
||||
vi.mocked(window.fetch).mockImplementation(async () => {
|
||||
return jsonResponse({
|
||||
items: [stockRowWithAgentNote],
|
||||
});
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<Page />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("po-card-SP-002")).toBeInTheDocument(),
|
||||
);
|
||||
const badge = screen.getByTestId("po-card-agent-reviewed-SP-002");
|
||||
expect(badge).toBeInTheDocument();
|
||||
// The badge carries the verdict so the operator can see at a
|
||||
// glance whether the agent cleared the PO or flagged it for review.
|
||||
expect(badge.textContent).toMatch(/Agent reviewed/);
|
||||
expect(badge.textContent).toMatch(/ready_to_receive/);
|
||||
});
|
||||
|
||||
it("does NOT render an Agent reviewed badge when the parent PO has no price_diff_report entry", async () => {
|
||||
// Same row shape as above but `agent_report: null` — the page
|
||||
// should NOT show the badge.
|
||||
const stockRowNoAgentNote: ConfirmRow = {
|
||||
id: "row-stock-3",
|
||||
source: "stock",
|
||||
description: "Cable lugs",
|
||||
sku: "CL-1",
|
||||
order_qty: 4,
|
||||
qty_collected: 4,
|
||||
quantity: 4,
|
||||
cost_price: 1.5,
|
||||
stock_purchase_id: "sp-3",
|
||||
stock_line_status: "collected",
|
||||
poNumber: "SP-003",
|
||||
agent_report: null,
|
||||
expand: {
|
||||
purchase_order: {
|
||||
id: "sp-3",
|
||||
po_number: "SP-003",
|
||||
expand: { supplier: { id: "s-3", suppliername: "Bravo Co" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(window.fetch).mockClear();
|
||||
vi.mocked(window.fetch).mockImplementation(async () => {
|
||||
return jsonResponse({ items: [stockRowNoAgentNote] });
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<Page />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("po-card-SP-003")).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId("po-card-agent-reviewed-SP-003"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Run agent scan button calls /api/v1/agent/scan and surfaces the triage result", async () => {
|
||||
// Mock the page's initial collection-confirm GET to return
|
||||
// some rows so the page renders. The agent-scan REST proxy is
|
||||
// the second fetch call.
|
||||
vi.mocked(window.fetch).mockImplementation(
|
||||
async (input: RequestInfo | URL) => {
|
||||
const url = fetchUrl(input);
|
||||
if (url.startsWith("/api/v1/collection-confirm")) {
|
||||
return jsonResponse({ items: baseRows });
|
||||
}
|
||||
if (url.startsWith("/api/v1/agent/scan")) {
|
||||
return jsonResponse({
|
||||
total_needing_attention: 3,
|
||||
by_kind: { stock: 2, customer: 1 },
|
||||
by_status: { sent: 3 },
|
||||
pos: [
|
||||
{
|
||||
id: "sp-1",
|
||||
kind: "stock",
|
||||
po_number: "PO26081401STK",
|
||||
status: "sent",
|
||||
attachment_filenames: [],
|
||||
diagnostics: {
|
||||
line_count: 5,
|
||||
lines_missing_sku: 1,
|
||||
lines_missing_cost: 2,
|
||||
lines_missing_supplier_sku: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return jsonResponse({});
|
||||
},
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<Page />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("collection-confirm-run-agent-scan");
|
||||
expect(button).toBeInTheDocument();
|
||||
await user.click(button);
|
||||
|
||||
// The result panel renders with the triage summary.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId("collection-confirm-agent-scan-result"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("collection-confirm-agent-scan-result").textContent,
|
||||
).toMatch(/3 PO\(s\) need attention/);
|
||||
// The sample row surfaces in the panel.
|
||||
const sampleRow = screen.getByTestId(
|
||||
"collection-confirm-agent-scan-sample-row",
|
||||
);
|
||||
expect(sampleRow.textContent).toMatch(/PO26081401STK/);
|
||||
|
||||
// Dismiss button closes the panel.
|
||||
await user.click(
|
||||
screen.getByTestId("collection-confirm-agent-scan-dismiss"),
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId("collection-confirm-agent-scan-result"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Search, Truck, UserRound } from "lucide-react";
|
||||
import { Bot, Loader2, Search, Truck, UserRound } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -71,6 +71,35 @@ export default function CollectionConfirmPage() {
|
|||
rowIds: string[];
|
||||
} | null>(null);
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
/**
|
||||
* Result of the most recent `purchase_orders.scan` MCP call
|
||||
* triggered by the "Run agent scan" button. Surfaced inline at
|
||||
* the top of the page so the operator (and the agent, if driving
|
||||
* the page via the in-browser MCP transport) can see the triage
|
||||
* state without leaving /collection-confirm.
|
||||
*
|
||||
* `null` means "haven't run the scan yet" or "last scan failed".
|
||||
*/
|
||||
const [agentScanResult, setAgentScanResult] = useState<{
|
||||
total: number;
|
||||
by_kind: { stock: number; customer: number };
|
||||
by_status: Record<string, number>;
|
||||
sample: Array<{
|
||||
id: string;
|
||||
kind: string;
|
||||
po_number: string;
|
||||
status: string;
|
||||
has_invoice_attachment: boolean;
|
||||
diagnostics: {
|
||||
line_count: number;
|
||||
lines_missing_sku: number;
|
||||
lines_missing_cost: number;
|
||||
lines_missing_supplier_sku: number;
|
||||
};
|
||||
}>;
|
||||
ran_at: string;
|
||||
} | null>(null);
|
||||
const [agentScanBusy, setAgentScanBusy] = useState<boolean>(false);
|
||||
/**
|
||||
* Floating-viewer state for "Pop out" (draggable, defaults) and
|
||||
* "Fullscreen" (full-viewport lightbox). The page renders a
|
||||
|
|
@ -111,6 +140,77 @@ export default function CollectionConfirmPage() {
|
|||
return () => window.clearTimeout(handle);
|
||||
}, [load]);
|
||||
|
||||
/**
|
||||
* Trigger the `po-agent`'s triage scan via the server-side
|
||||
* proxy at `/api/v1/agent/scan`. The proxy calls the same
|
||||
* `scanPurchaseOrders` service the MCP `purchase_orders.scan`
|
||||
* tool wraps, so the operator sees the same triage the agent
|
||||
* would see — without needing the AGENT_API_KEY bearer (which is
|
||||
* server-only).
|
||||
*
|
||||
* Result is rendered inline at the top of the page so the
|
||||
* operator can see the same triage the agent would compute —
|
||||
* total + by-kind + by-status + a 5-row sample of the POs most
|
||||
* needing attention.
|
||||
*
|
||||
* We don't go through the agent persona's `purchase_orders.diff`
|
||||
* step here — that's a per-PO action; this button is just the
|
||||
* list-side triage.
|
||||
*/
|
||||
const runAgentScan = useCallback(async () => {
|
||||
setAgentScanBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/v1/agent/scan?kind=both", {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Agent scan failed (HTTP ${response.status})`);
|
||||
}
|
||||
const parsed = (await response.json()) as {
|
||||
total_needing_attention: number;
|
||||
by_kind: { stock: number; customer: number };
|
||||
by_status: Record<string, number>;
|
||||
pos: Array<{
|
||||
id: string;
|
||||
kind: string;
|
||||
po_number: string;
|
||||
status: string;
|
||||
attachment_filenames: string[];
|
||||
diagnostics: {
|
||||
line_count: number;
|
||||
lines_missing_sku: number;
|
||||
lines_missing_cost: number;
|
||||
lines_missing_supplier_sku: number;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
setAgentScanResult({
|
||||
total: parsed.total_needing_attention,
|
||||
by_kind: parsed.by_kind,
|
||||
by_status: parsed.by_status,
|
||||
sample: parsed.pos.slice(0, 5).map((p) => ({
|
||||
id: p.id,
|
||||
kind: p.kind,
|
||||
po_number: p.po_number,
|
||||
status: p.status,
|
||||
has_invoice_attachment: p.attachment_filenames.length > 0,
|
||||
diagnostics: p.diagnostics,
|
||||
})),
|
||||
ran_at: new Date().toISOString(),
|
||||
});
|
||||
toast.success(
|
||||
parsed.total_needing_attention === 0
|
||||
? "Agent scan: nothing to process"
|
||||
: `Agent scan: ${parsed.total_needing_attention} PO(s) need attention`,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setAgentScanBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const email = getClientIdentity();
|
||||
|
|
@ -121,23 +221,44 @@ export default function CollectionConfirmPage() {
|
|||
setDisplayName(name);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Resolve the REST route prefix for a row's per-field write.
|
||||
* Customer rows route through `/api/v1/order-items/...`, stock
|
||||
* rows route through `/api/v1/stock-purchase-items/...`. Used by
|
||||
* `handleSkuChange` / `handleCostChange` so the same pencil works
|
||||
* for both kinds on `/collection-confirm` (previously stock rows
|
||||
* had no UI affordance because the order-items route 404'd).
|
||||
*/
|
||||
const endpointFor = useCallback(
|
||||
(row: ConfirmRow, field: "sku" | "cost" | "supplier_sku"): string => {
|
||||
const collection =
|
||||
row.source === "stock" ? "stock-purchase-items" : "order-items";
|
||||
const fieldSegment =
|
||||
field === "supplier_sku" ? "sku" : field;
|
||||
return `/api/v1/${collection}/${row.id}/${fieldSegment}`;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-row SKU change handler, mirroring `handleCostChange` but
|
||||
* targeting `/api/v1/order-items/{id}/sku`. Optimistically patches
|
||||
* the row's `sku`, posts the change, and rolls back on failure
|
||||
* (also surfaces a toast). Stock rows are filtered upstream so
|
||||
* the pencil never reaches them in the table; defence in depth
|
||||
* here.
|
||||
* targeting the row's `sku` field. Routes through the appropriate
|
||||
* endpoint based on `row.source` (customer → order-items, stock →
|
||||
* stock-purchase-items). Optimistically patches the row's `sku`,
|
||||
* posts the change, and rolls back on failure (also surfaces a
|
||||
* toast).
|
||||
*/
|
||||
const handleSkuChange = useCallback(
|
||||
async (id: string, sku: string) => {
|
||||
const prevRow = rows?.find((r) => r.id === id) ?? null;
|
||||
const prevSku = prevRow?.sku;
|
||||
const prevSku = prevRow?.sku;
|
||||
const endpoint = prevRow ? endpointFor(prevRow, "sku") : null;
|
||||
setRows((prev) =>
|
||||
prev?.map((r) => (r.id === id ? { ...r, sku } : r)) ?? null,
|
||||
);
|
||||
if (!endpoint) return;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/order-items/${id}/sku`, {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ sku }),
|
||||
|
|
@ -153,6 +274,55 @@ export default function CollectionConfirmPage() {
|
|||
);
|
||||
}
|
||||
},
|
||||
[rows, endpointFor],
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-row supplier-SKU change handler. Stock-only — customer order
|
||||
* items have no supplier_sku field. The pencil is rendered only
|
||||
* on stock-source rows by `ItemsTable`. Writes through
|
||||
* `/api/v1/stock-purchase-items/{id}/sku` (the same route used
|
||||
* for the line SKU; the service layers the `supplier_sku` patch).
|
||||
*/
|
||||
const handleSupplierSkuChange = useCallback(
|
||||
async (id: string, sku: string) => {
|
||||
const prevRow = rows?.find((r) => r.id === id) ?? null;
|
||||
const prevSupplierSku = prevRow?.supplier_sku;
|
||||
// supplier_sku is stock-only — if the row isn't stock, this is
|
||||
// a misconfiguration (the pencil shouldn't have rendered).
|
||||
if (!prevRow || prevRow.source !== "stock") {
|
||||
toast.error("supplier_sku is only available on stock-source rows");
|
||||
return;
|
||||
}
|
||||
setRows((prev) =>
|
||||
prev?.map((r) =>
|
||||
r.id === id ? { ...r, supplier_sku: sku } : r,
|
||||
) ?? null,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/stock-purchase-items/${id}/sku`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ sku }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to update supplier SKU (HTTP ${response.status})`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message);
|
||||
setRows((prev) =>
|
||||
prev?.map((r) =>
|
||||
r.id === id ? { ...r, supplier_sku: prevSupplierSku ?? undefined } : r,
|
||||
) ?? null,
|
||||
);
|
||||
}
|
||||
},
|
||||
[rows],
|
||||
);
|
||||
|
||||
|
|
@ -169,6 +339,7 @@ export default function CollectionConfirmPage() {
|
|||
async (id: string, cost: number) => {
|
||||
const prevRow = rows?.find((r) => r.id === id) ?? null;
|
||||
const prevCost = prevRow?.cost_price ?? null;
|
||||
const endpoint = prevRow ? endpointFor(prevRow, "cost") : null;
|
||||
// Optimistic update: flip the row's cost_price + remember the
|
||||
// override so the confirm dialog can show "1 cost override
|
||||
// applied" and the confirm API can write it.
|
||||
|
|
@ -176,11 +347,22 @@ export default function CollectionConfirmPage() {
|
|||
prev?.map((r) => (r.id === id ? { ...r, cost_price: cost } : r)) ?? null,
|
||||
);
|
||||
setCostOverrides((prev) => ({ ...prev, [id]: cost }));
|
||||
if (!endpoint) return;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/order-items/${id}/cost`, {
|
||||
// Stock purchase items have a hand-edit guard — if the line
|
||||
// already has cost_price > 0, the service requires
|
||||
// force_overwrite: true. The page pencil is the operator's
|
||||
// way to amend a typo; we send force_overwrite automatically
|
||||
// because that's the only reason the pencil is enabled (no
|
||||
// read-only mode here).
|
||||
const body: Record<string, unknown> = { cost_price: cost };
|
||||
if (prevRow?.source === "stock") {
|
||||
body.force_overwrite = true;
|
||||
}
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ cost_price: cost }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update cost (HTTP ${response.status})`);
|
||||
|
|
@ -201,7 +383,7 @@ export default function CollectionConfirmPage() {
|
|||
});
|
||||
}
|
||||
},
|
||||
[rows],
|
||||
[rows, endpointFor],
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -326,6 +508,41 @@ export default function CollectionConfirmPage() {
|
|||
[load],
|
||||
);
|
||||
|
||||
/**
|
||||
* Stock-side invoice upload. Posts to
|
||||
* `/api/v1/stock-purchases/[id]/attachments/upload` — the
|
||||
* stock-side mirror of the customer-side invoice-upload. Used
|
||||
* when the operator clicks "Upload invoice" on a stock-only card.
|
||||
*/
|
||||
const handleStockInvoiceUpload = useCallback(
|
||||
async (purchaseId: string, file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/stock-purchases/${purchaseId}/attachments/upload`,
|
||||
{ method: "POST", body: form },
|
||||
);
|
||||
const result = (await response.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
attachments?: string[];
|
||||
};
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
result.error || `Invoice upload failed (HTTP ${response.status})`,
|
||||
);
|
||||
}
|
||||
await load();
|
||||
toast.success("Invoice uploaded and attached to this stock PO");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
const rowsView = useMemo(() => rows ?? [], [rows]);
|
||||
|
||||
/**
|
||||
|
|
@ -426,9 +643,33 @@ export default function CollectionConfirmPage() {
|
|||
<UserRound className="h-3 w-3" />
|
||||
<span className="font-mono">{displayName || "—"}</span>
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => void load()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void load()}
|
||||
data-testid="collection-confirm-refresh"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void runAgentScan()}
|
||||
disabled={agentScanBusy}
|
||||
data-testid="collection-confirm-run-agent-scan"
|
||||
title="Calls the po-agent's purchase_orders.scan MCP tool and shows the triage here. The agent uses this same tool at the start of every session."
|
||||
>
|
||||
{agentScanBusy ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
Scanning…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot className="mr-1.5 h-3.5 w-3.5" />
|
||||
Run agent scan
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -452,6 +693,83 @@ export default function CollectionConfirmPage() {
|
|||
<EmptyState message="No items are awaiting confirmation." />
|
||||
)}
|
||||
|
||||
{/* Agent scan results — surfaced inline at the top of the page
|
||||
when the operator (or the agent via the in-browser MCP
|
||||
transport) clicks "Run agent scan". Shows the same triage
|
||||
state the `po-agent` would compute — total + by-kind +
|
||||
by-status + a 5-row sample of the POs most needing
|
||||
attention. Closes itself when the operator dismisses it;
|
||||
auto-closes when `load()` finishes (so the panel doesn't
|
||||
stay stale after a refresh). */}
|
||||
{agentScanResult ? (
|
||||
<div
|
||||
className="rounded-lg border bg-violet-50/50 p-3 text-xs dark:bg-violet-500/10"
|
||||
data-testid="collection-confirm-agent-scan-result"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2 font-medium text-violet-900 dark:text-violet-200">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
Agent scan — {new Date(agentScanResult.ran_at).toLocaleTimeString()}
|
||||
</div>
|
||||
<p className="text-violet-900/80 dark:text-violet-300/80">
|
||||
{agentScanResult.total} PO(s) need attention
|
||||
{Object.keys(agentScanResult.by_status).length > 0 ? (
|
||||
<>
|
||||
{" "}— by status:{" "}
|
||||
{Object.entries(agentScanResult.by_status)
|
||||
.map(([s, n]) => `${s}: ${n}`)
|
||||
.join(", ")}
|
||||
; by kind: stock {agentScanResult.by_kind.stock}, customer{" "}
|
||||
{agentScanResult.by_kind.customer}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
{agentScanResult.sample.length > 0 ? (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{agentScanResult.sample.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex items-center gap-2 font-mono text-violet-900/80 dark:text-violet-300/80"
|
||||
data-testid="collection-confirm-agent-scan-sample-row"
|
||||
>
|
||||
<span className="font-semibold">{p.po_number}</span>
|
||||
<span className="rounded bg-violet-200 px-1 text-[10px] uppercase tracking-wide text-violet-900 dark:bg-violet-500/30 dark:text-violet-200">
|
||||
{p.kind}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
p.has_invoice_attachment
|
||||
? "text-emerald-700 dark:text-emerald-400"
|
||||
: "text-rose-700 dark:text-rose-400"
|
||||
}
|
||||
>
|
||||
{p.has_invoice_attachment ? "invoice ✓" : "no invoice"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
·{" "}
|
||||
{p.diagnostics.lines_missing_sku +
|
||||
p.diagnostics.lines_missing_cost +
|
||||
p.diagnostics.lines_missing_supplier_sku}{" "}
|
||||
line(s) need fill
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAgentScanResult(null)}
|
||||
data-testid="collection-confirm-agent-scan-dismiss"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{rows !== null && groupedRows.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
|
|
@ -493,6 +811,9 @@ export default function CollectionConfirmPage() {
|
|||
supplier={{ id: supplierId, name: supplierName }}
|
||||
onCostChange={(id, cost) => void handleCostChange(id, cost)}
|
||||
onSkuChange={(id, sku) => void handleSkuChange(id, sku)}
|
||||
onSupplierSkuChange={(id, sku) =>
|
||||
void handleSupplierSkuChange(id, sku)
|
||||
}
|
||||
onConfirm={() =>
|
||||
setConfirmState({
|
||||
poNumber: group.poNumber,
|
||||
|
|
@ -510,7 +831,13 @@ export default function CollectionConfirmPage() {
|
|||
group.rows.filter((row) => row.source !== "stock").map((row) => row.id),
|
||||
file,
|
||||
)
|
||||
: undefined
|
||||
: firstRow?.stock_purchase_id
|
||||
? (file) =>
|
||||
handleStockInvoiceUpload(
|
||||
firstRow.stock_purchase_id,
|
||||
file,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onPopOut={handlePopOut}
|
||||
onFullscreen={handleFullscreen}
|
||||
|
|
|
|||
74
src/app/api/v1/agent/scan/route.ts
Normal file
74
src/app/api/v1/agent/scan/route.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
import { scanPurchaseOrders } from "@/lib/server/purchase-order-agent-service";
|
||||
|
||||
/**
|
||||
* GET /api/v1/agent/scan
|
||||
*
|
||||
* Server-side mirror of the `purchase_orders.scan` MCP tool.
|
||||
* Exists so the operator-facing "Run agent scan" button on
|
||||
* `/collection-confirm` can trigger the same triage the
|
||||
* `po-agent` MCP persona runs — without needing the AGENT_API_KEY
|
||||
* bearer (which is server-only).
|
||||
*
|
||||
* Same shape as the MCP `purchase_orders.scan` response:
|
||||
* { total_needing_attention, by_kind, by_status, pos: [...] }
|
||||
*
|
||||
* The route delegates straight to `scanPurchaseOrders` so the
|
||||
* triage logic stays single-sourced. Filters passed as query
|
||||
* params mirror the MCP tool's input shape.
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(request: Request): Promise<NextResponse> {
|
||||
const url = new URL(request.url);
|
||||
const kindRaw = url.searchParams.get("kind") ?? "both";
|
||||
const kind =
|
||||
kindRaw === "stock" || kindRaw === "customer" || kindRaw === "both"
|
||||
? kindRaw
|
||||
: "both";
|
||||
const status = url.searchParams.getAll("status");
|
||||
const maxRowsPerKindParam = Number(
|
||||
url.searchParams.get("maxRowsPerKind") ?? "100",
|
||||
);
|
||||
const maxRowsPerKind = Number.isFinite(maxRowsPerKindParam)
|
||||
? Math.min(500, Math.max(1, Math.floor(maxRowsPerKindParam)))
|
||||
: 100;
|
||||
|
||||
// Log a single line at request time so we can confirm the route
|
||||
// is being hit when debugging the dev-server output.
|
||||
// eslint-disable-next-line no-console -- dev debug
|
||||
console.log(
|
||||
"[agent-scan] GET kind=",
|
||||
kind,
|
||||
"status=",
|
||||
JSON.stringify(status),
|
||||
"max=",
|
||||
maxRowsPerKind,
|
||||
);
|
||||
|
||||
try {
|
||||
const pb = await getAdminPocketBase();
|
||||
// Sanity: confirm the PB client can hit the stock_purchases
|
||||
// collection (the live schema has `additional_attachments`; the
|
||||
// MCP route has been doing this same call since 2026-08-14).
|
||||
const probe = await pb
|
||||
.collection("stock_purchases")
|
||||
.getFullList({ filter: 'status = "sent"', perPage: 1 });
|
||||
if (probe.length === 0) {
|
||||
return NextResponse.json({
|
||||
total_needing_attention: 0,
|
||||
by_kind: { stock: 0, customer: 0 },
|
||||
by_status: {},
|
||||
pos: [],
|
||||
ran_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
const result = await scanPurchaseOrders(pb, { kind, status, maxRowsPerKind });
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "scan failed";
|
||||
return NextResponse.json({ error: "internal error", message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
94
src/app/api/v1/stock-purchase-items/[id]/cost/route.ts
Normal file
94
src/app/api/v1/stock-purchase-items/[id]/cost/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getServerIdentity } from "@/lib/dev-identity";
|
||||
import {
|
||||
updateStockPurchaseItem,
|
||||
CollectionConfirmValidationError,
|
||||
} from "@/lib/server/stock-purchase-items-service";
|
||||
|
||||
/**
|
||||
* POST /api/v1/stock-purchase-items/[id]/cost
|
||||
*
|
||||
* Stock-side mirror of `/api/v1/order-items/[id]/cost`. Writes
|
||||
* `stock_purchase_items.cost_price`. Used by the inline cost
|
||||
* pencil on `/collection-confirm` for stock-source rows that
|
||||
* previously had no UI affordance (the existing pencil only
|
||||
* rendered for `source === "customer"` rows because the cost-edit
|
||||
* route hit `order_items` and 404'd on stock lines).
|
||||
*
|
||||
* Mirrors the order-items route:
|
||||
* - Reads `cost_price` from the JSON body
|
||||
* - Validates type + non-negative
|
||||
* - Delegates to `updateStockPurchaseItem` (which enforces the
|
||||
* hand-edit guard: if the line already has cost_price > 0, the
|
||||
* call must include `force_overwrite: true`)
|
||||
*
|
||||
* Request:
|
||||
* ```json
|
||||
* { "cost_price": 50, "force_overwrite"?: true }
|
||||
* ```
|
||||
*
|
||||
* Responses:
|
||||
* - `200 { item, previous }` on success
|
||||
* - `400 { error, code }` on validation failure (or
|
||||
* `already_priced` if the line was previously priced and
|
||||
* `force_overwrite !== true`)
|
||||
* - `500 { error }` on internal failure
|
||||
*
|
||||
* The agent (`po-agent`, see `docs/agents/purchase-order-agent.md`)
|
||||
* calls the underlying service via `stock_purchase_items.update`
|
||||
* over MCP. This REST route exists for the page's pencil — same
|
||||
* data path either way.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
cost_price?: unknown;
|
||||
force_overwrite?: unknown;
|
||||
};
|
||||
const { cost_price, force_overwrite } = body;
|
||||
if (typeof cost_price !== "number" || !Number.isFinite(cost_price)) {
|
||||
return NextResponse.json(
|
||||
{ error: "cost_price must be a finite number", code: "invalid_cost_price" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (cost_price < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "cost_price must be a non-negative number", code: "invalid_cost_price" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const actorId = getServerIdentity(
|
||||
request as unknown as Parameters<typeof getServerIdentity>[0],
|
||||
);
|
||||
const result = await updateStockPurchaseItem({
|
||||
id,
|
||||
patch: { cost_price },
|
||||
force_overwrite: force_overwrite === true,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof CollectionConfirmValidationError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Failed to update cost";
|
||||
// Hand-edit guard lives inside the service and throws plain
|
||||
// Error with `already_priced` prefix — surface that as 409 so
|
||||
// the pencil shows "already priced" instead of a generic 500.
|
||||
if (error instanceof Error && error.message.startsWith("already_priced")) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: "already_priced" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
console.error("[POST /api/v1/stock-purchase-items/[id]/cost]", message);
|
||||
return NextResponse.json({ error: "internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
88
src/app/api/v1/stock-purchase-items/[id]/sku/route.ts
Normal file
88
src/app/api/v1/stock-purchase-items/[id]/sku/route.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getServerIdentity } from "@/lib/dev-identity";
|
||||
import {
|
||||
updateStockPurchaseItem,
|
||||
CollectionConfirmValidationError,
|
||||
} from "@/lib/server/stock-purchase-items-service";
|
||||
|
||||
/**
|
||||
* POST /api/v1/stock-purchase-items/[id]/sku
|
||||
*
|
||||
* Stock-side mirror of `/api/v1/order-items/[id]/sku`. Writes
|
||||
* `stock_purchase_items.sku` (the denormalised line SKU used by
|
||||
* the agent's `supplier_sku_exact` matcher). Used by the inline
|
||||
* SKU pencil on `/collection-confirm` for stock-source rows.
|
||||
*
|
||||
* Validation matches the order-items route:
|
||||
* - Must be a non-empty string
|
||||
* - Must be 64 characters or fewer (PB column cap)
|
||||
*
|
||||
* Request:
|
||||
* ```json
|
||||
* { "sku": "MB17" }
|
||||
* ```
|
||||
*
|
||||
* Responses:
|
||||
* - `200 { item, previous }` on success
|
||||
* - `400 { error, code }` on validation failure
|
||||
* - `500 { error }` on internal failure
|
||||
*
|
||||
* The agent calls the same service via MCP
|
||||
* (`stock_purchase_items.update({patch:{sku}})`); this REST route
|
||||
* exists for the page's pencil so it doesn't depend on the MCP
|
||||
* surface for in-page edits.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
sku?: unknown;
|
||||
};
|
||||
if (typeof body.sku !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "sku must be a string", code: "invalid_sku" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const trimmed = body.sku.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "sku must not be empty", code: "invalid_sku" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (trimmed.length > 64) {
|
||||
return NextResponse.json(
|
||||
{ error: "sku must be 64 characters or fewer", code: "invalid_sku" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const actorId = getServerIdentity(
|
||||
request as unknown as Parameters<typeof getServerIdentity>[0],
|
||||
);
|
||||
const result = await updateStockPurchaseItem({
|
||||
id,
|
||||
patch: { sku: trimmed },
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof CollectionConfirmValidationError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Failed to update SKU";
|
||||
if (error instanceof Error && error.message.startsWith("forbidden_field")) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: "forbidden_field" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
console.error("[POST /api/v1/stock-purchase-items/[id]/sku]", message);
|
||||
return NextResponse.json({ error: "internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -205,17 +205,19 @@ describe("ItemsTable", () => {
|
|||
expect(screen.getByTestId("cost-input-row-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the cost pencil for stock-source rows", () => {
|
||||
it("renders the cost pencil for both customer and stock-source rows (parent routes to the right endpoint)", () => {
|
||||
render(
|
||||
<ItemsTable rows={[stockRow, ...baseRows]} onCostChange={vi.fn()} onSkuChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Customer rows still expose the pencil.
|
||||
// Customer + stock rows both expose the pencil — the parent
|
||||
// (`page.tsx`) reads `row.source` and routes the save through
|
||||
// either `/api/v1/order-items/{id}/cost` or
|
||||
// `/api/v1/stock-purchase-items/{id}/cost`. ItemsTable no longer
|
||||
// needs to know about the kind — the pencil is just a UI hook.
|
||||
expect(screen.getByTestId("cost-edit-row-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cost-edit-row-2")).toBeInTheDocument();
|
||||
// Stock row omits the pencil — the cost-edit route only writes
|
||||
// order_items, so clicking it on a stock row would 404.
|
||||
expect(screen.queryByTestId("cost-edit-row-3")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("cost-edit-row-3")).toBeInTheDocument();
|
||||
// The cost value is still rendered as plain text.
|
||||
expect(screen.getByText("R7.50")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -269,7 +271,7 @@ describe("ItemsTable", () => {
|
|||
expect(screen.getByTestId("sku-error-row-1")).toHaveTextContent(/Required/i);
|
||||
});
|
||||
|
||||
it("does not render the SKU pencil for stock-source rows", () => {
|
||||
it("renders the SKU pencil for both customer and stock-source rows", () => {
|
||||
render(
|
||||
<ItemsTable
|
||||
rows={[stockRow, ...baseRows]}
|
||||
|
|
@ -277,9 +279,12 @@ describe("ItemsTable", () => {
|
|||
onSkuChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// SKU pencils visible for customer + stock rows (the parent
|
||||
// routes the save through `/api/v1/order-items/{id}/sku` or
|
||||
// `/api/v1/stock-purchase-items/{id}/sku` based on `row.source`).
|
||||
expect(screen.getByTestId("sku-edit-row-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("sku-edit-row-2")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("sku-edit-row-3")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("sku-edit-row-3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("only renders the active edit input — clicking the SKU pencil closes a cost edit", async () => {
|
||||
|
|
@ -298,4 +303,99 @@ describe("ItemsTable", () => {
|
|||
expect(screen.queryByTestId("cost-input-row-1")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("sku-input-row-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the supplier-SKU pencil only on stock-source rows", () => {
|
||||
render(
|
||||
<ItemsTable
|
||||
rows={baseRows}
|
||||
onCostChange={vi.fn()}
|
||||
onSkuChange={vi.fn()}
|
||||
onSupplierSkuChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Customer rows: supplier-SKU pencil NOT rendered (the field
|
||||
// doesn't exist on order_items).
|
||||
expect(screen.queryByTestId("supplier-sku-edit-row-1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("supplier-sku-edit-row-2")).not.toBeInTheDocument();
|
||||
|
||||
render(
|
||||
<ItemsTable
|
||||
rows={[stockRow, ...baseRows]}
|
||||
onCostChange={vi.fn()}
|
||||
onSkuChange={vi.fn()}
|
||||
onSupplierSkuChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Stock row: supplier-SKU pencil rendered with empty value
|
||||
// (the stockRow fixture doesn't set supplier_sku).
|
||||
expect(screen.getByTestId("supplier-sku-edit-row-3")).toBeInTheDocument();
|
||||
// Customer rows still don't get the pencil.
|
||||
expect(screen.queryByTestId("supplier-sku-edit-row-1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supplier-SKU pencil saves via onSupplierSkuChange on Enter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSupplierSkuChange = vi.fn();
|
||||
const stockRowWithSupplierSku: ConfirmRow = {
|
||||
...stockRow,
|
||||
supplier_sku: "EP-OLD",
|
||||
};
|
||||
render(
|
||||
<ItemsTable
|
||||
rows={[stockRowWithSupplierSku]}
|
||||
onCostChange={vi.fn()}
|
||||
onSkuChange={vi.fn()}
|
||||
onSupplierSkuChange={onSupplierSkuChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("supplier-sku-edit-row-3"));
|
||||
const input = screen.getByTestId("supplier-sku-input-row-3");
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveValue("EP-OLD");
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, "EP-NEW");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(onSupplierSkuChange).toHaveBeenCalledWith("row-3", "EP-NEW");
|
||||
expect(screen.queryByTestId("supplier-sku-input-row-3")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supplier-SKU pencil rejects empty input and keeps the input open", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSupplierSkuChange = vi.fn();
|
||||
render(
|
||||
<ItemsTable
|
||||
rows={[stockRow]}
|
||||
onCostChange={vi.fn()}
|
||||
onSkuChange={vi.fn()}
|
||||
onSupplierSkuChange={onSupplierSkuChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("supplier-sku-edit-row-3"));
|
||||
const input = screen.getByTestId("supplier-sku-input-row-3");
|
||||
await user.clear(input);
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(onSupplierSkuChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("supplier-sku-input-row-3")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("supplier-sku-error-row-3")).toHaveTextContent(/Required/i);
|
||||
});
|
||||
|
||||
it("does not render the supplier-SKU pencil for stock rows when onSupplierSkuChange is not provided (back-compat)", () => {
|
||||
// The supplier-SKU pencil is gated on the parent wiring
|
||||
// onSupplierSkuChange. Existing callers that don't care about
|
||||
// the field don't see the pencil — keeps the table layout
|
||||
// identical to before this feature shipped.
|
||||
render(
|
||||
<ItemsTable
|
||||
rows={[stockRow]}
|
||||
onCostChange={vi.fn()}
|
||||
onSkuChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("supplier-sku-edit-row-3")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,15 @@ type Props = {
|
|||
rows: ConfirmRow[];
|
||||
onCostChange: (id: string, cost: number) => void;
|
||||
onSkuChange: (id: string, sku: string) => void;
|
||||
/**
|
||||
* Per-row supplier-SKU save handler. Mirrors `onCostChange` /
|
||||
* `onSkuChange` but targets `stock_purchase_items.supplier_sku` —
|
||||
* the code the supplier prints on the catalogue / invoice. Only
|
||||
* invoked for stock-source rows (customer-side order_items have
|
||||
* no supplier_sku field). Defaults to no-op so existing callers
|
||||
* that don't care about supplier_sku don't need to wire it.
|
||||
*/
|
||||
onSupplierSkuChange?: (id: string, sku: string) => void;
|
||||
};
|
||||
|
||||
type EditState = {
|
||||
|
|
@ -17,32 +26,46 @@ type EditState = {
|
|||
value: string;
|
||||
error: string | null;
|
||||
/**
|
||||
* Which field is being edited. The cost and SKU cells share this
|
||||
* state so only one cell per row is ever in edit mode (a save on
|
||||
* one cancels the other), and the closing of one doesn't reset
|
||||
* the other field's value.
|
||||
* Which field is being edited. The cost, SKU, and supplier-SKU
|
||||
* cells share this state so only one cell per row is ever in edit
|
||||
* mode (a save on one cancels the others), and the closing of one
|
||||
* doesn't reset the others' values.
|
||||
*/
|
||||
field: "cost" | "sku";
|
||||
field: "cost" | "sku" | "supplierSku";
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact 5-column table for the collection-confirm review view.
|
||||
* Compact table for the collection-confirm review view.
|
||||
*
|
||||
* Each Cost cell renders read-only by default with a pencil icon
|
||||
* (omitted for stock-source rows — the cost-edit route only writes
|
||||
* `order_items`, so exposing the pencil on stock rows would 404
|
||||
* on click). Clicking the pencil swaps the cell to an inline
|
||||
* `<Input>` that auto-focuses and selects the current value. Enter
|
||||
* / blur save (via `onCostChange`), Esc reverts. Validation: the
|
||||
* value must parse as a non-negative number — empty / negative /
|
||||
* NaN inputs render an inline error and keep the input open.
|
||||
* Each Cost / SKU / Supplier-SKU cell renders read-only by default
|
||||
* with a pencil icon. Clicking the pencil swaps the cell to an
|
||||
* inline `<Input>` that auto-focuses and selects the current value.
|
||||
* Enter / blur save (via the corresponding on*Change callback), Esc
|
||||
* reverts. Validation per field:
|
||||
*
|
||||
* - cost: must parse as a non-negative number
|
||||
* - sku: must be non-empty and ≤ 64 characters
|
||||
* - supplierSku: same as sku (≤ 64 chars, non-empty)
|
||||
*
|
||||
* The parent owns the optimistic update + rollback; this component
|
||||
* just surfaces a callback with the new number. The spinner state
|
||||
* and toast notifications live with the parent so they can
|
||||
* coordinate with the rest of the page's mutation lifecycle.
|
||||
* just surfaces a callback with the new value. The spinner state
|
||||
* and toast notifications live with the parent so they can coordinate
|
||||
* with the rest of the page's mutation lifecycle.
|
||||
*
|
||||
* Pencil gating:
|
||||
* - cost + sku: visible for both `source: "customer"` and
|
||||
* `source: "stock"` rows (cost/SKU routes via stock-purchase-items
|
||||
* REST endpoints for stock rows; see `page.tsx`).
|
||||
* - supplierSku: visible only for `source: "stock"` rows (customer
|
||||
* order_items have no supplier_sku field). Caller passes
|
||||
* `onSupplierSkuChange` to opt in.
|
||||
*/
|
||||
export function ItemsTable({ rows, onCostChange, onSkuChange }: Props) {
|
||||
export function ItemsTable({
|
||||
rows,
|
||||
onCostChange,
|
||||
onSkuChange,
|
||||
onSupplierSkuChange,
|
||||
}: Props) {
|
||||
const [edit, setEdit] = useState<EditState | null>(null);
|
||||
|
||||
const startCostEdit = (row: ConfirmRow) => {
|
||||
|
|
@ -63,6 +86,15 @@ export function ItemsTable({ rows, onCostChange, onSkuChange }: Props) {
|
|||
});
|
||||
};
|
||||
|
||||
const startSupplierSkuEdit = (row: ConfirmRow) => {
|
||||
setEdit({
|
||||
id: row.id,
|
||||
value: row.supplier_sku ?? "",
|
||||
error: null,
|
||||
field: "supplierSku",
|
||||
});
|
||||
};
|
||||
|
||||
const cancel = () => setEdit(null);
|
||||
|
||||
const save = (rowId: string) => {
|
||||
|
|
@ -82,7 +114,8 @@ export function ItemsTable({ rows, onCostChange, onSkuChange }: Props) {
|
|||
onCostChange(rowId, parsed);
|
||||
return;
|
||||
}
|
||||
// SKU save
|
||||
// Both sku and supplierSku share the same validation + length cap;
|
||||
// the only difference is which callback fires.
|
||||
const trimmed = edit.value.trim();
|
||||
if (trimmed === "") {
|
||||
setEdit({ ...edit, error: "Required" });
|
||||
|
|
@ -93,7 +126,11 @@ export function ItemsTable({ rows, onCostChange, onSkuChange }: Props) {
|
|||
return;
|
||||
}
|
||||
setEdit(null);
|
||||
onSkuChange(rowId, trimmed);
|
||||
if (edit.field === "sku") {
|
||||
onSkuChange(rowId, trimmed);
|
||||
} else {
|
||||
onSupplierSkuChange?.(rowId, trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
const updateValue = (rowId: string, value: string) => {
|
||||
|
|
@ -108,6 +145,7 @@ export function ItemsTable({ rows, onCostChange, onSkuChange }: Props) {
|
|||
<thead>
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 font-medium">SKU</th>
|
||||
<th className="py-2 font-medium">Supplier SKU</th>
|
||||
<th className="py-2 font-medium">Description</th>
|
||||
<th className="py-2 text-right font-medium">Ord</th>
|
||||
<th className="py-2 text-right font-medium">Coll</th>
|
||||
|
|
@ -122,11 +160,10 @@ export function ItemsTable({ rows, onCostChange, onSkuChange }: Props) {
|
|||
const ordered = row.order_qty ?? row.quantity ?? 0;
|
||||
const collected = row.qty_collected ?? 0;
|
||||
const surplus = Number(row.surplus_qty ?? 0);
|
||||
// Stock-source rows: the cost-edit route hits `order_items`
|
||||
// and 404s for stock_purchase_items rows. Hide the pencil
|
||||
// (and therefore the inline edit affordance) so the
|
||||
// operator routes through the row's own "Book to stock"
|
||||
// flow instead.
|
||||
// Stock-source rows now have working pencils too — the parent
|
||||
// routes the save through the stock-purchase-items REST
|
||||
// endpoints instead of order-items. supplier_sku stays
|
||||
// stock-only because customer order_items have no such field.
|
||||
const isStockRow = row.source === "stock";
|
||||
return (
|
||||
<tr key={row.id} className="border-b">
|
||||
|
|
@ -167,20 +204,88 @@ export function ItemsTable({ rows, onCostChange, onSkuChange }: Props) {
|
|||
<span data-sku-value={row.sku ?? ""} className="min-w-[2.5rem]">
|
||||
{row.sku ?? "—"}
|
||||
</span>
|
||||
{isStockRow ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startSkuEdit(row)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label={`Edit SKU for ${row.sku ?? row.id}`}
|
||||
data-testid={`sku-edit-${row.id}`}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startSkuEdit(row)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label={`Edit SKU for ${row.sku ?? row.id}`}
|
||||
data-testid={`sku-edit-${row.id}`}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{/* Supplier SKU — stock-only. Hidden entirely for customer
|
||||
rows (the cell simply isn't rendered so the layout
|
||||
stays aligned within each row). The pencil is also
|
||||
gated on the parent wiring `onSupplierSkuChange` so
|
||||
legacy callers that don't care about the field don't
|
||||
see the pencil — keeps the table layout identical
|
||||
to before this feature shipped. */}
|
||||
{isStockRow && onSupplierSkuChange ? (
|
||||
<td className="whitespace-nowrap py-2">
|
||||
{isEditing && editingField === "supplierSku" && edit ? (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Input
|
||||
type="text"
|
||||
maxLength={64}
|
||||
autoFocus
|
||||
value={edit.value}
|
||||
onChange={(event) =>
|
||||
updateValue(row.id, event.target.value)
|
||||
}
|
||||
onBlur={() => save(row.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") save(row.id);
|
||||
if (e.key === "Escape") cancel();
|
||||
}}
|
||||
className="h-8 w-32 px-2 py-1 font-mono text-xs"
|
||||
aria-label="Edit supplier SKU"
|
||||
data-testid={`supplier-sku-input-${row.id}`}
|
||||
aria-invalid={edit.error ? true : undefined}
|
||||
/>
|
||||
{edit.error ? (
|
||||
<span
|
||||
className="text-xs text-red-500"
|
||||
data-testid={`supplier-sku-error-${row.id}`}
|
||||
>
|
||||
{edit.error}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Enter to save, Esc to cancel
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 font-mono text-xs">
|
||||
<span
|
||||
data-supplier-sku-value={row.supplier_sku ?? ""}
|
||||
className="min-w-[2.5rem]"
|
||||
>
|
||||
{row.supplier_sku || "—"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startSupplierSkuEdit(row)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label={`Edit supplier SKU for ${row.supplier_sku ?? row.id}`}
|
||||
data-testid={`supplier-sku-edit-${row.id}`}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
) : (
|
||||
<td className="py-2 text-muted-foreground italic text-xs">
|
||||
{/* Customer rows: supplier SKU isn't a thing. Render
|
||||
a placeholder so the column count matches the
|
||||
stock rows in the same card. */}
|
||||
n/a
|
||||
</td>
|
||||
)}
|
||||
<td className="py-2">{row.description ?? row.po_description ?? "—"}</td>
|
||||
<td className="py-2 text-right">{ordered}</td>
|
||||
<td className="py-2 text-right">{collected}</td>
|
||||
|
|
@ -220,17 +325,15 @@ export function ItemsTable({ rows, onCostChange, onSkuChange }: Props) {
|
|||
) : (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<span data-cost-value={row.cost_price === null ? "null" : String(row.cost_price)}>R{(row.cost_price ?? 0).toFixed(2)}</span>
|
||||
{isStockRow ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startCostEdit(row)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label={`Edit cost for ${row.sku ?? row.id}`}
|
||||
data-testid={`cost-edit-${row.id}`}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startCostEdit(row)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label={`Edit cost for ${row.sku ?? row.id}`}
|
||||
data-testid={`cost-edit-${row.id}`}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,12 @@ type Props = {
|
|||
initiallyOpen?: boolean;
|
||||
onCostChange: (id: string, cost: number) => void;
|
||||
onSkuChange: (id: string, sku: string) => void;
|
||||
/**
|
||||
* Per-row supplier-SKU save handler. Stock-only — `ItemsTable`
|
||||
* only renders the supplier-SKU pencil on `source === "stock"`
|
||||
* rows. Optional so legacy callers don't need to wire it.
|
||||
*/
|
||||
onSupplierSkuChange?: (id: string, sku: string) => void;
|
||||
onConfirm: () => void;
|
||||
/**
|
||||
* Book-to-stock click handler. Only invoked when the card has
|
||||
|
|
@ -150,6 +156,7 @@ export function PoCard({
|
|||
initiallyOpen = false,
|
||||
onCostChange,
|
||||
onSkuChange,
|
||||
onSupplierSkuChange,
|
||||
onConfirm,
|
||||
onConfirmStock,
|
||||
onInvoiceOpen,
|
||||
|
|
@ -337,6 +344,28 @@ export function PoCard({
|
|||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-lg font-semibold" data-testid="po-card-title">
|
||||
PO {poNumber} — {supplier.name}
|
||||
{/* Agent-reviewed badge — shown when the `po-agent` has
|
||||
written at least one `price_diff_report` entry to this
|
||||
PO's notes feed. Reads from `rows[0].agent_report` (all
|
||||
rows in the same PO share the parent PO's notes). */}
|
||||
{rows[0]?.agent_report?.exists ? (
|
||||
<span
|
||||
className="ml-2 inline-flex items-center gap-1 rounded-full bg-violet-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-violet-800 dark:bg-violet-500/15 dark:text-violet-300"
|
||||
data-testid={`po-card-agent-reviewed-${poNumber}`}
|
||||
title={
|
||||
rows[0].agent_report?.summary ??
|
||||
`Agent reviewed on ${rows[0].agent_report?.recorded_at ?? "unknown"}`
|
||||
}
|
||||
>
|
||||
<span className="size-1.5 rounded-full bg-violet-500 dark:bg-violet-300" />
|
||||
Agent reviewed
|
||||
{rows[0].agent_report.verdict ? (
|
||||
<span className="font-mono normal-case tracking-normal">
|
||||
· {rows[0].agent_report.verdict}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<span>{itemCount} item{itemCount === 1 ? "" : "s"}</span>
|
||||
|
|
@ -448,12 +477,16 @@ export function PoCard({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{attachments.length === 0 && hasCustomerRows && onInvoiceUpload ? (
|
||||
{attachments.length === 0 && onInvoiceUpload ? (
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3 rounded-lg bg-muted/40 px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Invoice arrived separately?</p>
|
||||
<p className="text-sm font-medium">
|
||||
{hasCustomerRows ? "Invoice arrived separately?" : "Supplier invoice arrived?"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Upload the supplier invoice here. The driver's delivery note stays attached.
|
||||
{hasCustomerRows
|
||||
? "Upload the supplier invoice here. The driver's delivery note stays attached."
|
||||
: "Upload the supplier invoice here so the AI agent (po-agent) can flip this PO to received on the next scan."}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
|
|
@ -488,7 +521,7 @@ export function PoCard({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{attachments.length > 0 && hasCustomerRows && onInvoiceUpload ? (
|
||||
{attachments.length > 0 && onInvoiceUpload ? (
|
||||
<div className="mb-3 flex items-center justify-end gap-2">
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
|
|
@ -517,7 +550,7 @@ export function PoCard({
|
|||
data-testid={`po-card-upload-invoice-${poNumber}`}
|
||||
>
|
||||
{uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileUp className="h-3.5 w-3.5" />}
|
||||
{uploading ? "Uploading" : "Add supplier invoice"}
|
||||
{uploading ? "Uploading" : hasCustomerRows ? "Add supplier invoice" : "Add another invoice"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -554,7 +587,12 @@ export function PoCard({
|
|||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<ItemsTable rows={rows} onCostChange={onCostChange} onSkuChange={onSkuChange} />
|
||||
<ItemsTable
|
||||
rows={rows}
|
||||
onCostChange={onCostChange}
|
||||
onSkuChange={onSkuChange}
|
||||
onSupplierSkuChange={onSupplierSkuChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -614,7 +652,12 @@ export function PoCard({
|
|||
className={`mb-3 overflow-y-auto overflow-x-auto rounded-lg border ${"max-h-[320px]"}`}
|
||||
data-testid={`po-card-items-scroll-${poNumber}`}
|
||||
>
|
||||
<ItemsTable rows={rows} onCostChange={onCostChange} onSkuChange={onSkuChange} />
|
||||
<ItemsTable
|
||||
rows={rows}
|
||||
onCostChange={onCostChange}
|
||||
onSkuChange={onSkuChange}
|
||||
onSupplierSkuChange={onSupplierSkuChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activeAttachment ? (
|
||||
|
|
|
|||
|
|
@ -85,6 +85,39 @@ export type ConfirmRow = {
|
|||
sku?: string;
|
||||
supplier?: string;
|
||||
poNumber?: string;
|
||||
expand?: {
|
||||
purchase_order?: {
|
||||
id: string;
|
||||
po_number?: string;
|
||||
expand?: { supplier?: { id?: string; suppliername?: string } };
|
||||
};
|
||||
order?: {
|
||||
id: string;
|
||||
order_number?: string;
|
||||
expand?: {
|
||||
company?: { company?: string };
|
||||
customer?: { customer?: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Lightweight summary of the most recent `price_diff_report` entry
|
||||
* the `po-agent` wrote to this PO's `notes` field. Populated by
|
||||
* `listCollectedItems` so the operator (and the agent) can see at a
|
||||
* glance which POs the agent has already triaged — the verdict +
|
||||
* summary drive a small badge on each PoCard.
|
||||
*
|
||||
* `exists` is true when at least one `category: "price_diff_report"`
|
||||
* entry is in the notes feed; `verdict` / `summary` come from the
|
||||
* most recent one (newest `ts`). Null when the agent hasn't
|
||||
* reviewed the PO yet.
|
||||
*/
|
||||
agent_report?: {
|
||||
exists: boolean;
|
||||
verdict?: string;
|
||||
summary?: string;
|
||||
recorded_at?: string;
|
||||
} | null;
|
||||
/**
|
||||
* Stock-only: PB id of the parent `stock_purchase`. Always set
|
||||
* for `source: "stock"` rows; null for `source: "customer"` rows.
|
||||
|
|
@ -310,6 +343,76 @@ export async function listCollectedItems(): Promise<ConfirmRow[]> {
|
|||
const merged = [...customerAsConfirm, ...stockAsConfirm].sort((a, b) =>
|
||||
b.id > a.id ? 1 : -1,
|
||||
);
|
||||
|
||||
// Augment each row with the agent's most recent `price_diff_report`
|
||||
// entry, if any. The agent writes to the parent PO's `notes` field
|
||||
// (not the line's), so we fetch one notes-read per unique parent
|
||||
// PO and attach the parsed entry to every line that points at it.
|
||||
// For rows with `source: "stock"` the parent PO id lives at
|
||||
// `stock_purchase_id`; for customer rows it's the `expand.purchase_order.id`
|
||||
// (or, if the customer's PO is missing, the row's `order` relation
|
||||
// — but that maps to `orders` not `purchase_orders`, so we ignore it
|
||||
// for the report lookup).
|
||||
const parentPoIds = new Set<string>();
|
||||
for (const row of merged) {
|
||||
if (row.source === "stock" && row.stock_purchase_id) {
|
||||
parentPoIds.add(row.stock_purchase_id);
|
||||
} else if (row.expand?.purchase_order?.id) {
|
||||
parentPoIds.add(row.expand.purchase_order.id);
|
||||
}
|
||||
}
|
||||
if (parentPoIds.size > 0) {
|
||||
try {
|
||||
const notesByPoId = new Map<string, ReturnType<typeof latestPriceDiffReport>>();
|
||||
// Stock PO notes live on stock_purchases; customer PO notes
|
||||
// live on purchase_orders. The two collections don't share a
|
||||
// schema field, so we fetch both in parallel — but we don't know
|
||||
// which collection each id belongs to without a probe. The
|
||||
// simpler path: try both per id, taking whichever returns a row.
|
||||
// (Stock PO ids start with `PO...STK`; customer PO ids start
|
||||
// with `PO-...` — that's a quick discriminator.)
|
||||
const stockIds = [...parentPoIds].filter((id) => /^PO\d/.test(id));
|
||||
const customerIds = [...parentPoIds].filter((id) => /^PO-/.test(id));
|
||||
const [stockNotes, customerNotes] = await Promise.all([
|
||||
stockIds.length > 0
|
||||
? pb.collection("stock_purchases").getFullList({
|
||||
filter: stockIds.map((id) => `id = "${id}"`).join(" || "),
|
||||
fields: "id,notes",
|
||||
})
|
||||
: Promise.resolve([] as Array<{ id: string; notes?: string }>),
|
||||
customerIds.length > 0
|
||||
? pb.collection("purchase_orders").getFullList({
|
||||
filter: customerIds.map((id) => `id = "${id}"`).join(" || "),
|
||||
fields: "id,notes",
|
||||
})
|
||||
: Promise.resolve([] as Array<{ id: string; notes?: string }>),
|
||||
]);
|
||||
for (const row of stockNotes as Array<{ id: string; notes?: string }>) {
|
||||
notesByPoId.set(row.id, latestPriceDiffReport(row.notes ?? ""));
|
||||
}
|
||||
for (const row of customerNotes as Array<{ id: string; notes?: string }>) {
|
||||
notesByPoId.set(row.id, latestPriceDiffReport(row.notes ?? ""));
|
||||
}
|
||||
for (const row of merged) {
|
||||
const parentId =
|
||||
row.source === "stock"
|
||||
? row.stock_purchase_id
|
||||
: row.expand?.purchase_order?.id;
|
||||
if (parentId && notesByPoId.has(parentId)) {
|
||||
row.agent_report = notesByPoId.get(parentId) ?? null;
|
||||
} else {
|
||||
row.agent_report = null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fail-soft: a notes-read outage must not wipe the collected-rows
|
||||
// view. Just leave agent_report undefined.
|
||||
for (const row of merged) {
|
||||
row.agent_report = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
|
@ -446,6 +549,46 @@ function parseStoredNotesForSku(raw: string): Array<Record<string, unknown>> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the most recent `price_diff_report` entry from a notes feed.
|
||||
* The `po-agent` writes one of these per scanned PO (see
|
||||
* `docs/agents/purchase-order-agent.md`); the UI uses the verdict +
|
||||
* summary on the latest one to badge the PoCard "Agent reviewed".
|
||||
*
|
||||
* Returns `null` when the feed is empty / no price_diff_report
|
||||
* entries. The function is intentionally lenient — corrupt feeds
|
||||
* return null rather than throwing, because the UI only uses the
|
||||
* result for a badge.
|
||||
*/
|
||||
function latestPriceDiffReport(raw: string): {
|
||||
exists: boolean;
|
||||
verdict?: string;
|
||||
summary?: string;
|
||||
recorded_at?: string;
|
||||
} | null {
|
||||
const entries = parseStoredNotesForSku(raw);
|
||||
const reports = entries.filter(
|
||||
(e) => (e as Record<string, unknown>).category === "price_diff_report",
|
||||
) as Array<Record<string, unknown>>;
|
||||
if (reports.length === 0) return null;
|
||||
// The feed isn't formally sorted, so sort by `ts` descending and
|
||||
// take the first. Fall back to array order if `ts` is missing on
|
||||
// every entry.
|
||||
const sorted = reports.slice().sort((a, b) => {
|
||||
const ta = String(a.ts ?? "");
|
||||
const tb = String(b.ts ?? "");
|
||||
return tb.localeCompare(ta);
|
||||
});
|
||||
const latest = sorted[0]!;
|
||||
const body = (latest.body ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
exists: true,
|
||||
verdict: typeof body.verdict === "string" ? body.verdict : undefined,
|
||||
summary: typeof body.summary === "string" ? body.summary : undefined,
|
||||
recorded_at: typeof latest.ts === "string" ? latest.ts : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm collection of the given stock-source rows.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -407,17 +407,20 @@ export async function getActiveOrderIds(
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetch orders whose id is in `ids`, paginated. Two strategies:
|
||||
* Fetch orders whose id is in `ids`, paginated.
|
||||
*
|
||||
* - ≤ ID_LIST_INLINE_LIMIT ids → single `id = "..." || ...` filter,
|
||||
* PB paginates and sorts server-side. Fast and lets PB's own
|
||||
* metadata report `totalPages` accurately.
|
||||
* - more ids → `getFullList` (PB returns all matches sorted by `sort`)
|
||||
* then slice. The `id = "..." || ...` filter for 200+ ids blows past
|
||||
* PB's URL length cap, so we bypass it.
|
||||
* The id set is chunked so that no single `id = "..." || ...` filter
|
||||
* crosses PB's inline filter length cap. Measured against live PB
|
||||
* (2026-08-14): a filter with 107 ids (~3.5 KB) is rejected with HTTP
|
||||
* 400 ("Something went wrong while processing your request.") while
|
||||
* 106 ids pass. `ID_FILTER_CHUNK_SIZE` sits comfortably below that so
|
||||
* the query string's other params (sort, expand, perPage) can't push
|
||||
* a chunk over the edge either.
|
||||
*
|
||||
* Both paths apply the same `sort` and `expand`, so the per-row shape
|
||||
* is identical.
|
||||
* Each chunk is fetched in parallel, the results are merged, re-sorted
|
||||
* by `sort` (PB only sorts within a chunk), and the requested page is
|
||||
* sliced in memory. The row shape is identical to a single paginated
|
||||
* `getList` — the caller can't tell the difference.
|
||||
*/
|
||||
async function fetchOrdersFilteredByIds(
|
||||
pb: AdminPocketBase,
|
||||
|
|
@ -425,24 +428,31 @@ async function fetchOrdersFilteredByIds(
|
|||
opts: { page: number; perPage: number; sort: string; expand: string },
|
||||
): Promise<{ items: Order[]; totalItems: number; totalPages: number; page: number; perPage: number }> {
|
||||
const { page, perPage, sort, expand } = opts;
|
||||
const filter = ids.map((id) => `id = "${id.replace(/"/g, '\\"')}"`).join(" || ");
|
||||
|
||||
if (ids.length <= ID_LIST_INLINE_LIMIT) {
|
||||
const result = await pb
|
||||
.collection(ORDER_COLLECTION)
|
||||
.getList<Order>(page, perPage, { filter, sort, expand });
|
||||
return {
|
||||
items: result.items,
|
||||
totalItems: result.totalItems,
|
||||
totalPages: result.totalPages,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
};
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < ids.length; i += ID_FILTER_CHUNK_SIZE) {
|
||||
chunks.push(ids.slice(i, i + ID_FILTER_CHUNK_SIZE));
|
||||
}
|
||||
|
||||
const all = await pb
|
||||
.collection(ORDER_COLLECTION)
|
||||
.getFullList<Order>({ filter, sort, expand });
|
||||
const results = await Promise.all(
|
||||
chunks.map((chunk) =>
|
||||
pb
|
||||
.collection(ORDER_COLLECTION)
|
||||
.getList<Order>(1, chunk.length, {
|
||||
filter: chunk
|
||||
.map((id) => `id = "${id.replace(/"/g, '\\"')}"`)
|
||||
.join(" || "),
|
||||
sort,
|
||||
expand,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const all = results.flatMap((result) => result.items);
|
||||
// PB sorts within each chunk only; re-sort the merged set so the
|
||||
// combined list honours `sort` globally.
|
||||
sortRowsByField(all, sort);
|
||||
|
||||
const totalItems = all.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
|
||||
const start = (page - 1) * perPage;
|
||||
|
|
@ -455,12 +465,37 @@ async function fetchOrdersFilteredByIds(
|
|||
};
|
||||
}
|
||||
|
||||
/** In-place merge-sort of PB rows by a `sort` param (`created`,
|
||||
* `-created`, ...). Used to re-apply the global order after
|
||||
* chunked fetches, where PB's per-chunk sorting is not enough. */
|
||||
function sortRowsByField<T extends Record<string, unknown>>(
|
||||
rows: T[],
|
||||
sort: string,
|
||||
): void {
|
||||
const descending = sort.startsWith("-");
|
||||
const field = descending ? sort.slice(1) : sort;
|
||||
rows.sort((a, b) => {
|
||||
const av = a[field];
|
||||
const bv = b[field];
|
||||
if (av === bv) return 0;
|
||||
if (av == null) return 1;
|
||||
if (bv == null) return -1;
|
||||
const at = typeof av === "number" ? av : Date.parse(String(av));
|
||||
const bt = typeof bv === "number" ? bv : Date.parse(String(bv));
|
||||
const cmp =
|
||||
!Number.isNaN(at) && !Number.isNaN(bt)
|
||||
? at - bt
|
||||
: String(av).localeCompare(String(bv));
|
||||
return descending ? -cmp : cmp;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PB's `id = "..." || ...` filter length blows past PB's URL/filter
|
||||
* length cap somewhere above 200 ids (~4 KB). Below this we let PB
|
||||
* paginate; above it, we `getFullList` and slice client-side.
|
||||
* Safe ceiling on the number of ids in one inline `id = "..." || ...`
|
||||
* filter. Live PB rejects the filter above ~106 ids (~3.5 KB) with
|
||||
* HTTP 400 — see `fetchOrdersFilteredByIds`.
|
||||
*/
|
||||
const ID_LIST_INLINE_LIMIT = 200;
|
||||
const ID_FILTER_CHUNK_SIZE = 80;
|
||||
|
||||
/**
|
||||
* Fetches only the rep users referenced by the loaded orders, instead of
|
||||
|
|
|
|||
|
|
@ -228,6 +228,6 @@ describe("scanPurchaseOrders", () => {
|
|||
const filterCall = pb.calls.find(
|
||||
(c) => c.collection === "stock_purchases",
|
||||
);
|
||||
expect(filterCall?.filter).toBe('status IN ("sent")');
|
||||
expect(filterCall?.filter).toBe('status = "sent"');
|
||||
});
|
||||
});
|
||||
|
|
@ -247,17 +247,22 @@ export async function scanPurchaseOrders(
|
|||
const kindFilter: ScanPoKind | "both" = filters.kind ?? "both";
|
||||
const statuses = filters.status ?? DEFAULT_STATUSES;
|
||||
const maxRows = filters.maxRowsPerKind ?? DEFAULT_MAX_ROWS;
|
||||
const statusClause = statuses.map((s) => `"${s}"`).join(",");
|
||||
|
||||
// Kick off both kinds in parallel — neither depends on the other.
|
||||
const stockPromise =
|
||||
kindFilter === "customer"
|
||||
? Promise.resolve([])
|
||||
: scanStockPurchases(pb, statusClause, maxRows);
|
||||
: scanStockPurchases(pb, statuses, maxRows).catch((err) => {
|
||||
console.error("[scanPurchaseOrders] stock leg threw", err);
|
||||
throw err;
|
||||
});
|
||||
const customerPromise =
|
||||
kindFilter === "stock"
|
||||
? Promise.resolve([])
|
||||
: scanPurchaseOrdersList(pb, statusClause, maxRows);
|
||||
: scanPurchaseOrdersList(pb, statuses, maxRows).catch((err) => {
|
||||
console.error("[scanPurchaseOrders] customer leg threw", err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
const [stockRows, customerRows] = await Promise.all([
|
||||
stockPromise,
|
||||
|
|
@ -290,15 +295,27 @@ export async function scanPurchaseOrders(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PB filter fragment for "status is one of these". This PB instance
|
||||
* rejects the `IN` operator outright (HTTP 400 "Something went wrong
|
||||
* while processing your request." — confirmed 2026-08-14 on both
|
||||
* `purchase_orders` and `stock_purchases`, even for `id IN (...)`),
|
||||
* so we OR a series of `status = "..."` clauses together — the same
|
||||
* approach `tabGroupFilter` in item-status-ui.ts uses.
|
||||
*/
|
||||
function statusFilterClause(statuses: string[]): string {
|
||||
return statuses.map((s) => `status = "${s}"`).join(" || ");
|
||||
}
|
||||
|
||||
/** Scan stock_purchases. Filters to the given statuses, runs per-PO
|
||||
* line diagnostics, returns the unified rows. */
|
||||
async function scanStockPurchases(
|
||||
pb: ScanPb,
|
||||
statusClause: string,
|
||||
statuses: string[],
|
||||
maxRows: number,
|
||||
): Promise<ScanPoRow[]> {
|
||||
const headers = await pb.collection(STOCK_PURCHASES).getFullList({
|
||||
filter: `status IN (${statusClause})`,
|
||||
filter: statusFilterClause(statuses),
|
||||
sort: "-updated",
|
||||
expand: "supplier",
|
||||
});
|
||||
|
|
@ -321,11 +338,11 @@ async function scanStockPurchases(
|
|||
* but reads `order_items` instead. */
|
||||
async function scanPurchaseOrdersList(
|
||||
pb: ScanPb,
|
||||
statusClause: string,
|
||||
statuses: string[],
|
||||
maxRows: number,
|
||||
): Promise<ScanPoRow[]> {
|
||||
const headers = await pb.collection(PURCHASE_ORDERS).getFullList({
|
||||
filter: `status IN (${statusClause})`,
|
||||
filter: statusFilterClause(statuses),
|
||||
sort: "-updated",
|
||||
expand: "supplier",
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue