feat(process-modal): show resolved supplier on Recent Orders rows
The Process Order Item modal already fetched order_items history with the supplier id, but the rendered row only showed description, order number, cost, selling, and markup — leaving the operator to guess which supplier fulfilled each past order. Now each Recent Orders row also shows the resolved supplier name, tinted with the same deterministic supplier colour used by the buyer-tasks cards (djb2 → 18-swatch palette). The id is looked up locally against the modal's existing `suppliers` prop — no extra PB expand needed. Unknown supplier ids (e.g. supplier deleted since the past order) are silently omitted rather than surfaced as a stale id. Two new tests cover the resolved-name and missing-id cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
3b8a299b15
commit
c400b4c69b
2 changed files with 113 additions and 24 deletions
|
|
@ -307,6 +307,72 @@ describe("OrderSprintProcessModal", () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the resolved supplier name on each Recent Orders row", async () => {
|
||||
// The history fetch is the FIRST getList call (PB returns the
|
||||
// most recent matching order_items). Seed it with a row whose
|
||||
// supplier id maps to "Steel Co" in our suppliers list — the
|
||||
// row should render that name so the operator can see who
|
||||
// supplied it last time without expanding the PB relation.
|
||||
getListMock.mockResolvedValueOnce({
|
||||
items: [{
|
||||
id: "hist-1",
|
||||
description: "HSS drill bit",
|
||||
sku: "DRL-001",
|
||||
supplier: "sup-1",
|
||||
cost_price: 20,
|
||||
selling_price: 50,
|
||||
markup: 150,
|
||||
order: "order-prev",
|
||||
updated: "2026-07-01",
|
||||
expand: { order: { order_number: "ORD-PREV" } },
|
||||
}],
|
||||
totalItems: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
// Subsequent fetches (price_list) stay empty.
|
||||
getListMock.mockResolvedValue({ items: [], totalItems: 0, totalPages: 1 });
|
||||
|
||||
renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Steel Co")).toBeInTheDocument();
|
||||
});
|
||||
// Order number + cost + selling still show alongside.
|
||||
expect(screen.getByText("ORD-PREV")).toBeInTheDocument();
|
||||
expect(screen.getByText(/R20\.00/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the supplier chip when the history row's supplier id is not in the suppliers list", async () => {
|
||||
getListMock.mockResolvedValueOnce({
|
||||
items: [{
|
||||
id: "hist-1",
|
||||
description: "HSS drill bit",
|
||||
sku: "DRL-001",
|
||||
supplier: "sup-deleted",
|
||||
cost_price: 20,
|
||||
selling_price: 50,
|
||||
markup: 150,
|
||||
order: "order-prev",
|
||||
updated: "2026-07-01",
|
||||
expand: { order: { order_number: "ORD-PREV" } },
|
||||
}],
|
||||
totalItems: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
getListMock.mockResolvedValue({ items: [], totalItems: 0, totalPages: 1 });
|
||||
|
||||
renderModal();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("ORD-PREV")).toBeInTheDocument();
|
||||
});
|
||||
// The unknown supplier id should not be surfaced as a fallback
|
||||
// — the chip is omitted entirely so the operator doesn't see a
|
||||
// stale id. (Both "Steel Co" and "Bolt Suppliers" appear in the
|
||||
// mocked SupplierSearch <option>s, so we can't use a generic
|
||||
// negative-text assertion; instead we confirm the unknown id
|
||||
// is NOT rendered anywhere.)
|
||||
expect(screen.queryByText("sup-deleted")).toBeNull();
|
||||
});
|
||||
|
||||
describe("'Use AI suggestion' button", () => {
|
||||
it("is disabled when ai_item_insights has no top_historical_matches", () => {
|
||||
renderModal({ item: { ...baseItem, ai_item_insights: { procurement_summary: "hi" } } });
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import { toast } from "sonner";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { supplierColor } from "@/lib/supplier-color";
|
||||
import type { Supplier } from "@/lib/types";
|
||||
import {
|
||||
AiItemHistoryPopover,
|
||||
|
|
@ -113,17 +114,24 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
|
||||
if (!cancelled) {
|
||||
setOrderHistory(
|
||||
historyRes.items.map((i) => ({
|
||||
id: i.id,
|
||||
description: i.description,
|
||||
sku: i.sku,
|
||||
supplier: i.supplier,
|
||||
costPrice: i.cost_price,
|
||||
sellingPrice: i.selling_price,
|
||||
markup: i.markup,
|
||||
orderNumber: (i.expand as { order?: { order_number?: string } })?.order?.order_number || "—",
|
||||
date: i.updated,
|
||||
}))
|
||||
historyRes.items.map((i) => {
|
||||
// Resolve the supplier id to a friendly name from the
|
||||
// suppliers list we already have on hand — avoids an
|
||||
// extra PB expand round-trip per history row.
|
||||
const supplierMatch = suppliers.find((s) => s.id === i.supplier);
|
||||
return {
|
||||
id: i.id,
|
||||
description: i.description,
|
||||
sku: i.sku,
|
||||
supplier: i.supplier,
|
||||
supplierName: supplierMatch?.suppliername || undefined,
|
||||
costPrice: i.cost_price,
|
||||
sellingPrice: i.selling_price,
|
||||
markup: i.markup,
|
||||
orderNumber: (i.expand as { order?: { order_number?: string } })?.order?.order_number || "—",
|
||||
date: i.updated,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -298,12 +306,12 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
|
||||
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 p-0 sm:p-4 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col shadow-2xl border-2 border-warn/40 shadow-warn/15">
|
||||
<Card className="w-full max-w-4xl h-[100dvh] sm:h-auto sm:max-h-[90vh] flex flex-col rounded-none sm:rounded-lg shadow-2xl border-0 sm:border-2 border-warn/40 border-t-warn/40 shadow-warn/15">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 border-b bg-gradient-to-b from-muted/40 to-transparent">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
|
|
@ -322,13 +330,13 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
</button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-y-auto space-y-5 p-5">
|
||||
<CardContent className="flex-1 overflow-y-auto space-y-5 p-4 sm:p-5">
|
||||
{/* AI Insights — tinted section so it reads as distinct from
|
||||
the deterministic history below it. Warm warn tones
|
||||
(yellow/amber) match the modal's outer border and the
|
||||
popover's AI card, so the warm accent reads as a
|
||||
cohesive theme across the buying flow. */}
|
||||
<section className="rounded-lg border border-warn/40 bg-warn/5 p-3 space-y-2 shadow-sm shadow-warn/10">
|
||||
<section className="rounded-lg border border-warn/40 bg-warn/10 p-3 space-y-2 shadow-sm shadow-warn/10 dark:bg-warn/15 dark:border-warn/30">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -342,15 +350,15 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
<ChevronDown className="h-4 w-4 text-warn/70" />
|
||||
)}
|
||||
<Sparkles className="h-4 w-4 text-warn-foreground" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-warn-foreground">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-warn-foreground dark:text-warn">
|
||||
AI Insights
|
||||
</span>
|
||||
{aiHasMatches && (
|
||||
<span className="ml-1 rounded-full bg-warn/20 px-1.5 py-0.5 text-[10px] font-medium text-warn-foreground ring-1 ring-inset ring-warn/30">
|
||||
<span className="ml-1 rounded-full bg-warn/25 px-1.5 py-0.5 text-[10px] font-medium text-warn-foreground ring-1 ring-inset ring-warn/40 dark:bg-warn/25 dark:text-warn dark:ring-warn/40">
|
||||
suggestion available
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-2 text-[10px] font-medium text-warn-foreground/70 group-hover/ai-toggle:text-warn-foreground underline-offset-2 group-hover/ai-toggle:underline transition-colors">
|
||||
<span className="ml-2 text-[10px] font-medium text-warn-foreground/80 group-hover/ai-toggle:text-warn-foreground dark:text-warn/80 dark:group-hover/ai-toggle:text-warn underline-offset-2 group-hover/ai-toggle:underline transition-colors">
|
||||
{aiInsightsExpanded ? "Hide" : "Show"}
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -359,7 +367,7 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
variant="outline"
|
||||
onClick={applyAiSuggestion}
|
||||
disabled={!aiHasMatches}
|
||||
className="border-warn/40 text-warn-foreground hover:bg-warn/10 hover:text-warn-foreground"
|
||||
className="border-warn/40 text-warn-foreground hover:bg-warn/20 hover:text-warn-foreground dark:border-warn/40 dark:text-warn dark:hover:bg-warn/20 dark:hover:text-warn"
|
||||
title={aiHasMatches ? "Pull supplier + cost from AI's top match" : "No AI matches to apply"}
|
||||
>
|
||||
<Wand2 className="h-3 w-3 mr-1" />
|
||||
|
|
@ -402,15 +410,26 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">{h.description}</div>
|
||||
<div className="text-[10px] text-muted-foreground flex items-center gap-2">
|
||||
<div className="text-[10px] text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||
{h.supplierName && (
|
||||
<>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: supplierColor(h.supplier) }}
|
||||
>
|
||||
{h.supplierName}
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
</>
|
||||
)}
|
||||
<span className="font-mono">{h.orderNumber}</span>
|
||||
<span>·</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>R{h.costPrice?.toFixed(2) || "—"}</span>
|
||||
<span>·</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>R{h.sellingPrice?.toFixed(2) || "—"}</span>
|
||||
{h.markup != null && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{h.markup}%</span>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -648,7 +667,7 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
</section>
|
||||
</CardContent>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-5 py-3">
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between border-t bg-muted/20 px-4 py-3 sm:px-5">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{item.item_status === "pending"
|
||||
? "Save marks this item processed and surfaces it on the buyer-tasks Needs PO tab."
|
||||
|
|
@ -685,6 +704,10 @@ interface OrderHistoryItem {
|
|||
description: string;
|
||||
sku?: string;
|
||||
supplier?: string;
|
||||
/** Resolved supplier name (from the modal's `suppliers` prop) so
|
||||
* the recent-orders list can show "who supplied this last time"
|
||||
* without expanding the PB relation. */
|
||||
supplierName?: string;
|
||||
costPrice?: number;
|
||||
sellingPrice?: number;
|
||||
markup?: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue