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;
|
} 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,
|
* Extract the URL string from any fetch `input` shape (string, URL,
|
||||||
* or Request). The page always passes a 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.
|
* Tests that need a different shape override the spy directly.
|
||||||
*/
|
*/
|
||||||
function buildFetchSpy(rows: ConfirmRow[] = baseRows) {
|
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);
|
const url = fetchUrl(input);
|
||||||
if (url === "/api/v1/collection-confirm") {
|
if (url === "/api/v1/collection-confirm") {
|
||||||
return jsonResponse({ items: rows });
|
return jsonResponse({ items: rows });
|
||||||
|
|
@ -106,6 +124,12 @@ function buildFetchSpy(rows: ConfirmRow[] = baseRows) {
|
||||||
if (typeof url === "string" && url.startsWith("/api/v1/order-items/")) {
|
if (typeof url === "string" && url.startsWith("/api/v1/order-items/")) {
|
||||||
return jsonResponse({ ok: true });
|
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({});
|
return jsonResponse({});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -985,9 +1009,15 @@ describe("CollectionConfirmPage", () => {
|
||||||
screen.queryByTestId("po-card-confirm-SP-001"),
|
screen.queryByTestId("po-card-confirm-SP-001"),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
// Stock row also hides the cost pencil (the cost-edit route
|
// Stock row now also shows the cost + SKU + supplier-SKU pencils
|
||||||
// would 404 on a stock_purchase_items id).
|
// — the agent needs to be able to amend these on /collection-confirm
|
||||||
expect(screen.queryByTestId("cost-edit-row-stock-1")).not.toBeInTheDocument();
|
// 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.
|
// Clear the spy so we can isolate the confirm-stock POST.
|
||||||
fetchSpy.mockClear();
|
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 () => {
|
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
|
// Drive the page with one row whose cost_price is null. The
|
||||||
// /cost endpoint is forced to fail so the rollback path runs.
|
// /cost endpoint is forced to fail so the rollback path runs.
|
||||||
|
|
@ -1203,4 +1234,236 @@ describe("CollectionConfirmPage", () => {
|
||||||
false,
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
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 { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -71,6 +71,35 @@ export default function CollectionConfirmPage() {
|
||||||
rowIds: string[];
|
rowIds: string[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [busy, setBusy] = useState<boolean>(false);
|
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
|
* Floating-viewer state for "Pop out" (draggable, defaults) and
|
||||||
* "Fullscreen" (full-viewport lightbox). The page renders a
|
* "Fullscreen" (full-viewport lightbox). The page renders a
|
||||||
|
|
@ -111,6 +140,77 @@ export default function CollectionConfirmPage() {
|
||||||
return () => window.clearTimeout(handle);
|
return () => window.clearTimeout(handle);
|
||||||
}, [load]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const email = getClientIdentity();
|
const email = getClientIdentity();
|
||||||
|
|
@ -121,23 +221,44 @@ export default function CollectionConfirmPage() {
|
||||||
setDisplayName(name);
|
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
|
* Per-row SKU change handler, mirroring `handleCostChange` but
|
||||||
* targeting `/api/v1/order-items/{id}/sku`. Optimistically patches
|
* targeting the row's `sku` field. Routes through the appropriate
|
||||||
* the row's `sku`, posts the change, and rolls back on failure
|
* endpoint based on `row.source` (customer → order-items, stock →
|
||||||
* (also surfaces a toast). Stock rows are filtered upstream so
|
* stock-purchase-items). Optimistically patches the row's `sku`,
|
||||||
* the pencil never reaches them in the table; defence in depth
|
* posts the change, and rolls back on failure (also surfaces a
|
||||||
* here.
|
* toast).
|
||||||
*/
|
*/
|
||||||
const handleSkuChange = useCallback(
|
const handleSkuChange = useCallback(
|
||||||
async (id: string, sku: string) => {
|
async (id: string, sku: string) => {
|
||||||
const prevRow = rows?.find((r) => r.id === id) ?? null;
|
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) =>
|
setRows((prev) =>
|
||||||
prev?.map((r) => (r.id === id ? { ...r, sku } : r)) ?? null,
|
prev?.map((r) => (r.id === id ? { ...r, sku } : r)) ?? null,
|
||||||
);
|
);
|
||||||
|
if (!endpoint) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/order-items/${id}/sku`, {
|
const response = await fetch(endpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ sku }),
|
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],
|
[rows],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -169,6 +339,7 @@ export default function CollectionConfirmPage() {
|
||||||
async (id: string, cost: number) => {
|
async (id: string, cost: number) => {
|
||||||
const prevRow = rows?.find((r) => r.id === id) ?? null;
|
const prevRow = rows?.find((r) => r.id === id) ?? null;
|
||||||
const prevCost = prevRow?.cost_price ?? null;
|
const prevCost = prevRow?.cost_price ?? null;
|
||||||
|
const endpoint = prevRow ? endpointFor(prevRow, "cost") : null;
|
||||||
// Optimistic update: flip the row's cost_price + remember the
|
// Optimistic update: flip the row's cost_price + remember the
|
||||||
// override so the confirm dialog can show "1 cost override
|
// override so the confirm dialog can show "1 cost override
|
||||||
// applied" and the confirm API can write it.
|
// 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,
|
prev?.map((r) => (r.id === id ? { ...r, cost_price: cost } : r)) ?? null,
|
||||||
);
|
);
|
||||||
setCostOverrides((prev) => ({ ...prev, [id]: cost }));
|
setCostOverrides((prev) => ({ ...prev, [id]: cost }));
|
||||||
|
if (!endpoint) return;
|
||||||
try {
|
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",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ cost_price: cost }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to update cost (HTTP ${response.status})`);
|
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],
|
[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]);
|
const rowsView = useMemo(() => rows ?? [], [rows]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -426,9 +643,33 @@ export default function CollectionConfirmPage() {
|
||||||
<UserRound className="h-3 w-3" />
|
<UserRound className="h-3 w-3" />
|
||||||
<span className="font-mono">{displayName || "—"}</span>
|
<span className="font-mono">{displayName || "—"}</span>
|
||||||
</span>
|
</span>
|
||||||
<Button variant="outline" size="sm" onClick={() => void load()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void load()}
|
||||||
|
data-testid="collection-confirm-refresh"
|
||||||
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -452,6 +693,83 @@ export default function CollectionConfirmPage() {
|
||||||
<EmptyState message="No items are awaiting confirmation." />
|
<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 && (
|
{rows !== null && groupedRows.length > 0 && (
|
||||||
<div className="space-y-4">
|
<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">
|
<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 }}
|
supplier={{ id: supplierId, name: supplierName }}
|
||||||
onCostChange={(id, cost) => void handleCostChange(id, cost)}
|
onCostChange={(id, cost) => void handleCostChange(id, cost)}
|
||||||
onSkuChange={(id, sku) => void handleSkuChange(id, sku)}
|
onSkuChange={(id, sku) => void handleSkuChange(id, sku)}
|
||||||
|
onSupplierSkuChange={(id, sku) =>
|
||||||
|
void handleSupplierSkuChange(id, sku)
|
||||||
|
}
|
||||||
onConfirm={() =>
|
onConfirm={() =>
|
||||||
setConfirmState({
|
setConfirmState({
|
||||||
poNumber: group.poNumber,
|
poNumber: group.poNumber,
|
||||||
|
|
@ -510,7 +831,13 @@ export default function CollectionConfirmPage() {
|
||||||
group.rows.filter((row) => row.source !== "stock").map((row) => row.id),
|
group.rows.filter((row) => row.source !== "stock").map((row) => row.id),
|
||||||
file,
|
file,
|
||||||
)
|
)
|
||||||
: undefined
|
: firstRow?.stock_purchase_id
|
||||||
|
? (file) =>
|
||||||
|
handleStockInvoiceUpload(
|
||||||
|
firstRow.stock_purchase_id,
|
||||||
|
file,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
onPopOut={handlePopOut}
|
onPopOut={handlePopOut}
|
||||||
onFullscreen={handleFullscreen}
|
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();
|
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(
|
render(
|
||||||
<ItemsTable rows={[stockRow, ...baseRows]} onCostChange={vi.fn()} onSkuChange={vi.fn()} />,
|
<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-1")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("cost-edit-row-2")).toBeInTheDocument();
|
expect(screen.getByTestId("cost-edit-row-2")).toBeInTheDocument();
|
||||||
// Stock row omits the pencil — the cost-edit route only writes
|
expect(screen.getByTestId("cost-edit-row-3")).toBeInTheDocument();
|
||||||
// order_items, so clicking it on a stock row would 404.
|
|
||||||
expect(screen.queryByTestId("cost-edit-row-3")).not.toBeInTheDocument();
|
|
||||||
// The cost value is still rendered as plain text.
|
// The cost value is still rendered as plain text.
|
||||||
expect(screen.getByText("R7.50")).toBeInTheDocument();
|
expect(screen.getByText("R7.50")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
@ -269,7 +271,7 @@ describe("ItemsTable", () => {
|
||||||
expect(screen.getByTestId("sku-error-row-1")).toHaveTextContent(/Required/i);
|
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(
|
render(
|
||||||
<ItemsTable
|
<ItemsTable
|
||||||
rows={[stockRow, ...baseRows]}
|
rows={[stockRow, ...baseRows]}
|
||||||
|
|
@ -277,9 +279,12 @@ describe("ItemsTable", () => {
|
||||||
onSkuChange={vi.fn()}
|
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-1")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("sku-edit-row-2")).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 () => {
|
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.queryByTestId("cost-input-row-1")).not.toBeInTheDocument();
|
||||||
expect(screen.getByTestId("sku-input-row-1")).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[];
|
rows: ConfirmRow[];
|
||||||
onCostChange: (id: string, cost: number) => void;
|
onCostChange: (id: string, cost: number) => void;
|
||||||
onSkuChange: (id: string, sku: string) => 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 = {
|
type EditState = {
|
||||||
|
|
@ -17,32 +26,46 @@ type EditState = {
|
||||||
value: string;
|
value: string;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
/**
|
/**
|
||||||
* Which field is being edited. The cost and SKU cells share this
|
* Which field is being edited. The cost, SKU, and supplier-SKU
|
||||||
* state so only one cell per row is ever in edit mode (a save on
|
* cells share this state so only one cell per row is ever in edit
|
||||||
* one cancels the other), and the closing of one doesn't reset
|
* mode (a save on one cancels the others), and the closing of one
|
||||||
* the other field's value.
|
* 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
|
* Each Cost / SKU / Supplier-SKU cell renders read-only by default
|
||||||
* (omitted for stock-source rows — the cost-edit route only writes
|
* with a pencil icon. Clicking the pencil swaps the cell to an
|
||||||
* `order_items`, so exposing the pencil on stock rows would 404
|
* inline `<Input>` that auto-focuses and selects the current value.
|
||||||
* on click). Clicking the pencil swaps the cell to an inline
|
* Enter / blur save (via the corresponding on*Change callback), Esc
|
||||||
* `<Input>` that auto-focuses and selects the current value. Enter
|
* reverts. Validation per field:
|
||||||
* / blur save (via `onCostChange`), Esc reverts. Validation: the
|
*
|
||||||
* value must parse as a non-negative number — empty / negative /
|
* - cost: must parse as a non-negative number
|
||||||
* NaN inputs render an inline error and keep the input open.
|
* - 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
|
* The parent owns the optimistic update + rollback; this component
|
||||||
* just surfaces a callback with the new number. The spinner state
|
* just surfaces a callback with the new value. The spinner state
|
||||||
* and toast notifications live with the parent so they can
|
* and toast notifications live with the parent so they can coordinate
|
||||||
* coordinate with the rest of the page's mutation lifecycle.
|
* 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 [edit, setEdit] = useState<EditState | null>(null);
|
||||||
|
|
||||||
const startCostEdit = (row: ConfirmRow) => {
|
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 cancel = () => setEdit(null);
|
||||||
|
|
||||||
const save = (rowId: string) => {
|
const save = (rowId: string) => {
|
||||||
|
|
@ -82,7 +114,8 @@ export function ItemsTable({ rows, onCostChange, onSkuChange }: Props) {
|
||||||
onCostChange(rowId, parsed);
|
onCostChange(rowId, parsed);
|
||||||
return;
|
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();
|
const trimmed = edit.value.trim();
|
||||||
if (trimmed === "") {
|
if (trimmed === "") {
|
||||||
setEdit({ ...edit, error: "Required" });
|
setEdit({ ...edit, error: "Required" });
|
||||||
|
|
@ -93,7 +126,11 @@ export function ItemsTable({ rows, onCostChange, onSkuChange }: Props) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEdit(null);
|
setEdit(null);
|
||||||
onSkuChange(rowId, trimmed);
|
if (edit.field === "sku") {
|
||||||
|
onSkuChange(rowId, trimmed);
|
||||||
|
} else {
|
||||||
|
onSupplierSkuChange?.(rowId, trimmed);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateValue = (rowId: string, value: string) => {
|
const updateValue = (rowId: string, value: string) => {
|
||||||
|
|
@ -108,6 +145,7 @@ export function ItemsTable({ rows, onCostChange, onSkuChange }: Props) {
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b text-left">
|
<tr className="border-b text-left">
|
||||||
<th className="py-2 font-medium">SKU</th>
|
<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 font-medium">Description</th>
|
||||||
<th className="py-2 text-right font-medium">Ord</th>
|
<th className="py-2 text-right font-medium">Ord</th>
|
||||||
<th className="py-2 text-right font-medium">Coll</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 ordered = row.order_qty ?? row.quantity ?? 0;
|
||||||
const collected = row.qty_collected ?? 0;
|
const collected = row.qty_collected ?? 0;
|
||||||
const surplus = Number(row.surplus_qty ?? 0);
|
const surplus = Number(row.surplus_qty ?? 0);
|
||||||
// Stock-source rows: the cost-edit route hits `order_items`
|
// Stock-source rows now have working pencils too — the parent
|
||||||
// and 404s for stock_purchase_items rows. Hide the pencil
|
// routes the save through the stock-purchase-items REST
|
||||||
// (and therefore the inline edit affordance) so the
|
// endpoints instead of order-items. supplier_sku stays
|
||||||
// operator routes through the row's own "Book to stock"
|
// stock-only because customer order_items have no such field.
|
||||||
// flow instead.
|
|
||||||
const isStockRow = row.source === "stock";
|
const isStockRow = row.source === "stock";
|
||||||
return (
|
return (
|
||||||
<tr key={row.id} className="border-b">
|
<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]">
|
<span data-sku-value={row.sku ?? ""} className="min-w-[2.5rem]">
|
||||||
{row.sku ?? "—"}
|
{row.sku ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
{isStockRow ? null : (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => startSkuEdit(row)}
|
||||||
onClick={() => startSkuEdit(row)}
|
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
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}`}
|
||||||
aria-label={`Edit SKU for ${row.sku ?? row.id}`}
|
data-testid={`sku-edit-${row.id}`}
|
||||||
data-testid={`sku-edit-${row.id}`}
|
>
|
||||||
>
|
<Pencil className="h-3 w-3" />
|
||||||
<Pencil className="h-3 w-3" />
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">{row.description ?? row.po_description ?? "—"}</td>
|
||||||
<td className="py-2 text-right">{ordered}</td>
|
<td className="py-2 text-right">{ordered}</td>
|
||||||
<td className="py-2 text-right">{collected}</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">
|
<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>
|
<span data-cost-value={row.cost_price === null ? "null" : String(row.cost_price)}>R{(row.cost_price ?? 0).toFixed(2)}</span>
|
||||||
{isStockRow ? null : (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => startCostEdit(row)}
|
||||||
onClick={() => startCostEdit(row)}
|
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
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}`}
|
||||||
aria-label={`Edit cost for ${row.sku ?? row.id}`}
|
data-testid={`cost-edit-${row.id}`}
|
||||||
data-testid={`cost-edit-${row.id}`}
|
>
|
||||||
>
|
<Pencil className="h-3 w-3" />
|
||||||
<Pencil className="h-3 w-3" />
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,12 @@ type Props = {
|
||||||
initiallyOpen?: boolean;
|
initiallyOpen?: boolean;
|
||||||
onCostChange: (id: string, cost: number) => void;
|
onCostChange: (id: string, cost: number) => void;
|
||||||
onSkuChange: (id: string, sku: string) => 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;
|
onConfirm: () => void;
|
||||||
/**
|
/**
|
||||||
* Book-to-stock click handler. Only invoked when the card has
|
* Book-to-stock click handler. Only invoked when the card has
|
||||||
|
|
@ -150,6 +156,7 @@ export function PoCard({
|
||||||
initiallyOpen = false,
|
initiallyOpen = false,
|
||||||
onCostChange,
|
onCostChange,
|
||||||
onSkuChange,
|
onSkuChange,
|
||||||
|
onSupplierSkuChange,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onConfirmStock,
|
onConfirmStock,
|
||||||
onInvoiceOpen,
|
onInvoiceOpen,
|
||||||
|
|
@ -337,6 +344,28 @@ export function PoCard({
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="text-lg font-semibold" data-testid="po-card-title">
|
<h2 className="text-lg font-semibold" data-testid="po-card-title">
|
||||||
PO {poNumber} — {supplier.name}
|
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>
|
</h2>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
<span>{itemCount} item{itemCount === 1 ? "" : "s"}</span>
|
<span>{itemCount} item{itemCount === 1 ? "" : "s"}</span>
|
||||||
|
|
@ -448,12 +477,16 @@ export function PoCard({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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="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">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
|
@ -488,7 +521,7 @@ export function PoCard({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{attachments.length > 0 && hasCustomerRows && onInvoiceUpload ? (
|
{attachments.length > 0 && onInvoiceUpload ? (
|
||||||
<div className="mb-3 flex items-center justify-end gap-2">
|
<div className="mb-3 flex items-center justify-end gap-2">
|
||||||
<input
|
<input
|
||||||
ref={uploadInputRef}
|
ref={uploadInputRef}
|
||||||
|
|
@ -517,7 +550,7 @@ export function PoCard({
|
||||||
data-testid={`po-card-upload-invoice-${poNumber}`}
|
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 ? <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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -554,7 +587,12 @@ export function PoCard({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<ItemsTable rows={rows} onCostChange={onCostChange} onSkuChange={onSkuChange} />
|
<ItemsTable
|
||||||
|
rows={rows}
|
||||||
|
onCostChange={onCostChange}
|
||||||
|
onSkuChange={onSkuChange}
|
||||||
|
onSupplierSkuChange={onSupplierSkuChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -614,7 +652,12 @@ export function PoCard({
|
||||||
className={`mb-3 overflow-y-auto overflow-x-auto rounded-lg border ${"max-h-[320px]"}`}
|
className={`mb-3 overflow-y-auto overflow-x-auto rounded-lg border ${"max-h-[320px]"}`}
|
||||||
data-testid={`po-card-items-scroll-${poNumber}`}
|
data-testid={`po-card-items-scroll-${poNumber}`}
|
||||||
>
|
>
|
||||||
<ItemsTable rows={rows} onCostChange={onCostChange} onSkuChange={onSkuChange} />
|
<ItemsTable
|
||||||
|
rows={rows}
|
||||||
|
onCostChange={onCostChange}
|
||||||
|
onSkuChange={onSkuChange}
|
||||||
|
onSupplierSkuChange={onSupplierSkuChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeAttachment ? (
|
{activeAttachment ? (
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,39 @@ export type ConfirmRow = {
|
||||||
sku?: string;
|
sku?: string;
|
||||||
supplier?: string;
|
supplier?: string;
|
||||||
poNumber?: 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
|
* Stock-only: PB id of the parent `stock_purchase`. Always set
|
||||||
* for `source: "stock"` rows; null for `source: "customer"` rows.
|
* 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) =>
|
const merged = [...customerAsConfirm, ...stockAsConfirm].sort((a, b) =>
|
||||||
b.id > a.id ? 1 : -1,
|
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;
|
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.
|
* 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,
|
* The id set is chunked so that no single `id = "..." || ...` filter
|
||||||
* PB paginates and sorts server-side. Fast and lets PB's own
|
* crosses PB's inline filter length cap. Measured against live PB
|
||||||
* metadata report `totalPages` accurately.
|
* (2026-08-14): a filter with 107 ids (~3.5 KB) is rejected with HTTP
|
||||||
* - more ids → `getFullList` (PB returns all matches sorted by `sort`)
|
* 400 ("Something went wrong while processing your request.") while
|
||||||
* then slice. The `id = "..." || ...` filter for 200+ ids blows past
|
* 106 ids pass. `ID_FILTER_CHUNK_SIZE` sits comfortably below that so
|
||||||
* PB's URL length cap, so we bypass it.
|
* 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
|
* Each chunk is fetched in parallel, the results are merged, re-sorted
|
||||||
* is identical.
|
* 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(
|
async function fetchOrdersFilteredByIds(
|
||||||
pb: AdminPocketBase,
|
pb: AdminPocketBase,
|
||||||
|
|
@ -425,24 +428,31 @@ async function fetchOrdersFilteredByIds(
|
||||||
opts: { page: number; perPage: number; sort: string; expand: string },
|
opts: { page: number; perPage: number; sort: string; expand: string },
|
||||||
): Promise<{ items: Order[]; totalItems: number; totalPages: number; page: number; perPage: number }> {
|
): Promise<{ items: Order[]; totalItems: number; totalPages: number; page: number; perPage: number }> {
|
||||||
const { page, perPage, sort, expand } = opts;
|
const { page, perPage, sort, expand } = opts;
|
||||||
const filter = ids.map((id) => `id = "${id.replace(/"/g, '\\"')}"`).join(" || ");
|
|
||||||
|
|
||||||
if (ids.length <= ID_LIST_INLINE_LIMIT) {
|
const chunks: string[][] = [];
|
||||||
const result = await pb
|
for (let i = 0; i < ids.length; i += ID_FILTER_CHUNK_SIZE) {
|
||||||
.collection(ORDER_COLLECTION)
|
chunks.push(ids.slice(i, i + ID_FILTER_CHUNK_SIZE));
|
||||||
.getList<Order>(page, perPage, { filter, sort, expand });
|
|
||||||
return {
|
|
||||||
items: result.items,
|
|
||||||
totalItems: result.totalItems,
|
|
||||||
totalPages: result.totalPages,
|
|
||||||
page: result.page,
|
|
||||||
perPage: result.perPage,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const all = await pb
|
const results = await Promise.all(
|
||||||
.collection(ORDER_COLLECTION)
|
chunks.map((chunk) =>
|
||||||
.getFullList<Order>({ filter, sort, expand });
|
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 totalItems = all.length;
|
||||||
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
|
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
|
||||||
const start = (page - 1) * 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
|
* Safe ceiling on the number of ids in one inline `id = "..." || ...`
|
||||||
* length cap somewhere above 200 ids (~4 KB). Below this we let PB
|
* filter. Live PB rejects the filter above ~106 ids (~3.5 KB) with
|
||||||
* paginate; above it, we `getFullList` and slice client-side.
|
* 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
|
* Fetches only the rep users referenced by the loaded orders, instead of
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,6 @@ describe("scanPurchaseOrders", () => {
|
||||||
const filterCall = pb.calls.find(
|
const filterCall = pb.calls.find(
|
||||||
(c) => c.collection === "stock_purchases",
|
(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 kindFilter: ScanPoKind | "both" = filters.kind ?? "both";
|
||||||
const statuses = filters.status ?? DEFAULT_STATUSES;
|
const statuses = filters.status ?? DEFAULT_STATUSES;
|
||||||
const maxRows = filters.maxRowsPerKind ?? DEFAULT_MAX_ROWS;
|
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.
|
// Kick off both kinds in parallel — neither depends on the other.
|
||||||
const stockPromise =
|
const stockPromise =
|
||||||
kindFilter === "customer"
|
kindFilter === "customer"
|
||||||
? Promise.resolve([])
|
? Promise.resolve([])
|
||||||
: scanStockPurchases(pb, statusClause, maxRows);
|
: scanStockPurchases(pb, statuses, maxRows).catch((err) => {
|
||||||
|
console.error("[scanPurchaseOrders] stock leg threw", err);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
const customerPromise =
|
const customerPromise =
|
||||||
kindFilter === "stock"
|
kindFilter === "stock"
|
||||||
? Promise.resolve([])
|
? 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([
|
const [stockRows, customerRows] = await Promise.all([
|
||||||
stockPromise,
|
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
|
/** Scan stock_purchases. Filters to the given statuses, runs per-PO
|
||||||
* line diagnostics, returns the unified rows. */
|
* line diagnostics, returns the unified rows. */
|
||||||
async function scanStockPurchases(
|
async function scanStockPurchases(
|
||||||
pb: ScanPb,
|
pb: ScanPb,
|
||||||
statusClause: string,
|
statuses: string[],
|
||||||
maxRows: number,
|
maxRows: number,
|
||||||
): Promise<ScanPoRow[]> {
|
): Promise<ScanPoRow[]> {
|
||||||
const headers = await pb.collection(STOCK_PURCHASES).getFullList({
|
const headers = await pb.collection(STOCK_PURCHASES).getFullList({
|
||||||
filter: `status IN (${statusClause})`,
|
filter: statusFilterClause(statuses),
|
||||||
sort: "-updated",
|
sort: "-updated",
|
||||||
expand: "supplier",
|
expand: "supplier",
|
||||||
});
|
});
|
||||||
|
|
@ -321,11 +338,11 @@ async function scanStockPurchases(
|
||||||
* but reads `order_items` instead. */
|
* but reads `order_items` instead. */
|
||||||
async function scanPurchaseOrdersList(
|
async function scanPurchaseOrdersList(
|
||||||
pb: ScanPb,
|
pb: ScanPb,
|
||||||
statusClause: string,
|
statuses: string[],
|
||||||
maxRows: number,
|
maxRows: number,
|
||||||
): Promise<ScanPoRow[]> {
|
): Promise<ScanPoRow[]> {
|
||||||
const headers = await pb.collection(PURCHASE_ORDERS).getFullList({
|
const headers = await pb.collection(PURCHASE_ORDERS).getFullList({
|
||||||
filter: `status IN (${statusClause})`,
|
filter: statusFilterClause(statuses),
|
||||||
sort: "-updated",
|
sort: "-updated",
|
||||||
expand: "supplier",
|
expand: "supplier",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue