Merge feat/order-item-context-block: replace AI Insights with deterministic context block
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
commit
30cc4fc5d2
12 changed files with 1089 additions and 199 deletions
|
|
@ -63,7 +63,7 @@ import { ItemStatusPill } from "@/components/item-status-pill";
|
|||
import { QuoteSearch } from "@/components/ui/quote-search";
|
||||
import { SendPoModal } from "@/components/purchase-orders/send-po-modal";
|
||||
import { AiInsightsPanel } from "@/components/ai-insights-panel";
|
||||
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
|
||||
import { OrderItemContextPopoverTrigger } from "@/components/order-item-context-popover-trigger";
|
||||
import type { Order, OrderItem, PurchaseOrder, Quote, ItemStatus } from "@/lib/types";
|
||||
|
||||
/**
|
||||
|
|
@ -497,16 +497,13 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||
<TableCell className="font-medium">
|
||||
<div className="whitespace-pre-wrap break-words max-w-[420px] flex items-start gap-1.5"
|
||||
// Stop the row's click handler from firing
|
||||
// when the operator pops the AI history
|
||||
// when the operator pops the context panel
|
||||
// open — otherwise the row pushes to
|
||||
// /orders/{id}/process on every ⓘ click.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AiItemHistoryTrigger
|
||||
<OrderItemContextPopoverTrigger
|
||||
itemId={item.id}
|
||||
insights={
|
||||
(item.ai_item_insights as Record<string, unknown> | null | undefined) ?? null
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>{item.description}</span>
|
||||
|
|
|
|||
51
src/app/api/v1/order-items/[id]/context/route.test.ts
Normal file
51
src/app/api/v1/order-items/[id]/context/route.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const getOrderItemContext = vi.fn();
|
||||
vi.mock("@/lib/server/order-item-context-service", () => ({
|
||||
getOrderItemContext: (...args: unknown[]) => getOrderItemContext(...args),
|
||||
}));
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
function get(id: string) {
|
||||
return new NextRequest(`http://localhost/api/v1/order-items/${id}/context`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
describe("GET /api/v1/order-items/[id]/context", () => {
|
||||
beforeEach(() => {
|
||||
getOrderItemContext.mockReset();
|
||||
});
|
||||
|
||||
it("400s on missing id", async () => {
|
||||
const res = await GET(get(""), { params: Promise.resolve({ id: "" }) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("200s with the service result", async () => {
|
||||
getOrderItemContext.mockResolvedValue({
|
||||
generated_at: "2026-08-07T10:00:00Z",
|
||||
match: { sku: "DRL-6MM", match_kind: "sku_exact", match_confidence: 1 },
|
||||
last_quote: null,
|
||||
last_order: null,
|
||||
last_invoiced: null,
|
||||
stock: null,
|
||||
suppliers: [],
|
||||
cheapest: null,
|
||||
});
|
||||
const res = await GET(get("abc"), { params: Promise.resolve({ id: "abc" }) });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.match.sku).toBe("DRL-6MM");
|
||||
});
|
||||
|
||||
it("500s when the service throws", async () => {
|
||||
getOrderItemContext.mockRejectedValue(new Error("PB exploded"));
|
||||
const res = await GET(get("abc"), { params: Promise.resolve({ id: "abc" }) });
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("PB exploded");
|
||||
});
|
||||
});
|
||||
35
src/app/api/v1/order-items/[id]/context/route.ts
Normal file
35
src/app/api/v1/order-items/[id]/context/route.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getOrderItemContext } from "@/lib/server/order-item-context-service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/order-items/[id]/context
|
||||
*
|
||||
* Deterministic SKU-based context for an order_item: last quote,
|
||||
* last order, last invoice checked in, stock state, supplier
|
||||
* history, cheapest. Used by the sprint process modal (L1 inline)
|
||||
* and the orders detail page (L2 popover).
|
||||
*
|
||||
* No Bearer required — matches the existing item-history route's
|
||||
* read-only SKU aggregates pattern. Auth happens upstream at the
|
||||
* page level.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
if (!id || typeof id !== "string") {
|
||||
return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
return NextResponse.json(await getOrderItemContext(id));
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "Lookup failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
107
src/components/order-item-context-panel.test.tsx
Normal file
107
src/components/order-item-context-panel.test.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { OrderItemContextPanel } from "@/components/order-item-context-panel";
|
||||
import type { OrderItemContextResponse } from "@/lib/server/order-item-context-service";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const fullContext: OrderItemContextResponse = {
|
||||
generated_at: "2026-08-07T10:00:00Z",
|
||||
match: { sku: "DRL-6MM", match_kind: "sku_exact", match_confidence: 1 },
|
||||
last_quote: {
|
||||
quote_item_id: "qi1", quote_id: "q1", quote_number: "Q-26080701",
|
||||
customer_name: "Acme", company: "Acme Ltd", status: "approved",
|
||||
cost_price: 100, selling_price: 150, markup_percent: 50, days_ago: 5,
|
||||
},
|
||||
last_order: {
|
||||
order_item_id: "oi1", order_id: "o1", order_number: "ORD-26080702",
|
||||
company: "Acme Ltd", cost_price: 110, selling_price: 150,
|
||||
markup_percent: 36.4, item_status: "delivered", updated_at: "2026-08-01T00:00:00Z", days_ago: 6,
|
||||
},
|
||||
last_invoiced: {
|
||||
order_item_id: "oi2", order_id: "o2", order_number: "ORD-26080703",
|
||||
supplier_invoice_number: "INV-99", customer_invoice_number: "C-7",
|
||||
invoiced_date: "2026-08-02T00:00:00Z", cost_price: 105, quantity: 10, qty_invoiced: 10, days_ago: 5,
|
||||
},
|
||||
stock: {
|
||||
stock_item_id: "s1", name: "Drill 6mm", sku: "DRL-6MM",
|
||||
current_qty: 240, min_qty: 20, selling_price: 140, markup_percent: 40, status: "in_stock",
|
||||
},
|
||||
suppliers: [],
|
||||
cheapest: null,
|
||||
};
|
||||
|
||||
describe("OrderItemContextPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders all 4 rows when context is fully populated", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => fullContext,
|
||||
} as Response);
|
||||
|
||||
render(<OrderItemContextPanel itemId="x" onApply={() => {}} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Last quote/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Last order/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Last invoice/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Stock/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the dashed empty-state card when context is all-null", async () => {
|
||||
const empty: OrderItemContextResponse = {
|
||||
...fullContext,
|
||||
last_quote: null, last_order: null, last_invoiced: null, stock: null, suppliers: [],
|
||||
};
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => empty,
|
||||
} as Response);
|
||||
|
||||
render(<OrderItemContextPanel itemId="x" onApply={() => {}} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No context for this item yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("fires onApply with the row's cost and source when Apply is clicked", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => fullContext,
|
||||
} as Response);
|
||||
const onApply = vi.fn();
|
||||
|
||||
render(<OrderItemContextPanel itemId="x" onApply={onApply} />);
|
||||
await waitFor(() => screen.getByText(/Last quote/));
|
||||
// Walk up two levels: span (Last quote) → label div → row div (which contains the Apply button).
|
||||
const quoteRow = screen.getByText(/Last quote/).parentElement!.parentElement!;
|
||||
const applyBtn = quoteRow.querySelector("button")!;
|
||||
fireEvent.click(applyBtn);
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith(expect.objectContaining({
|
||||
source: "last_quote",
|
||||
cost_price: 100,
|
||||
source_label: "Q-26080701",
|
||||
}));
|
||||
});
|
||||
|
||||
it("marks the invoice row's selling cell as dimmed", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => fullContext,
|
||||
} as Response);
|
||||
|
||||
render(<OrderItemContextPanel itemId="x" onApply={() => {}} />);
|
||||
await waitFor(() => screen.getByText(/Last invoice/));
|
||||
// The dimmed class is applied to the selling cell on the invoice row.
|
||||
const dimmed = document.querySelector(".text-muted-foreground\\/50");
|
||||
expect(dimmed).not.toBeNull();
|
||||
});
|
||||
});
|
||||
232
src/components/order-item-context-panel.tsx
Normal file
232
src/components/order-item-context-panel.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
History,
|
||||
Loader2,
|
||||
Package,
|
||||
Receipt,
|
||||
ShoppingBag,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { OrderItemContextResponse } from "@/lib/server/order-item-context-service";
|
||||
|
||||
export interface ApplyPayload {
|
||||
source: "last_quote" | "last_order" | "last_invoiced" | "stock";
|
||||
cost_price: number;
|
||||
supplier_id?: string;
|
||||
source_label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
itemId: string;
|
||||
defaultOpen?: boolean;
|
||||
onApply: (payload: ApplyPayload) => void;
|
||||
}
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
function relTime(daysAgo: number): string {
|
||||
if (daysAgo < 1) return "today";
|
||||
if (daysAgo === 1) return "1d";
|
||||
if (daysAgo < 30) return `${daysAgo}d`;
|
||||
const mo = Math.round(daysAgo / 30);
|
||||
return `${mo}mo`;
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
badge: string;
|
||||
cost: number;
|
||||
selling: number | null;
|
||||
markup: number | null;
|
||||
sellingDimmed?: boolean;
|
||||
applyDisabled?: boolean;
|
||||
onApply?: () => void;
|
||||
}
|
||||
|
||||
function Row({ label, icon, badge, cost, selling, markup, sellingDimmed, applyDisabled, onApply }: RowProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border/60 bg-muted/20 hover:bg-muted/40 transition-colors px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium shrink-0">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0 font-mono">{badge}</span>
|
||||
<div className="flex-1 grid grid-cols-3 gap-2 text-xs tabular-nums">
|
||||
<span><span className="text-[10px] text-muted-foreground mr-1">cost</span>R {cost.toFixed(2)}</span>
|
||||
<span className={cn(sellingDimmed && "text-muted-foreground/50")}>
|
||||
<span className="text-[10px] text-muted-foreground mr-1">selling</span>
|
||||
{selling != null ? `R ${selling.toFixed(2)}` : "—"}
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-[10px] text-muted-foreground mr-1">markup</span>
|
||||
{markup != null ? `${markup.toFixed(1)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApply}
|
||||
disabled={applyDisabled || !onApply}
|
||||
className="shrink-0 rounded-md border border-border/60 px-2 py-1 text-[11px] font-medium hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrderItemContextPanel({ itemId, defaultOpen = true, onApply }: Props) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [data, setData] = useState<OrderItemContextResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchOnce = useCallback(async () => {
|
||||
if (data || loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/order-items/${itemId}/context`);
|
||||
if (res.status === 404) { setData(null); return; }
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = (await res.json()) as OrderItemContextResponse;
|
||||
setData(body);
|
||||
if (body.error) setError(body.error);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Lookup failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [itemId, data, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- data fetch on dep change
|
||||
if (open) void fetchOnce();
|
||||
}, [open, fetchOnce]);
|
||||
|
||||
const noData = !data || (!data.last_quote && !data.last_order && !data.last_invoiced && !data.stock && data.suppliers.length === 0);
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-indigo-500/30 bg-gradient-to-b from-indigo-50/40 to-transparent p-3 space-y-2 shadow-sm shadow-indigo-500/5 dark:from-indigo-950/20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-2 text-left w-full"
|
||||
>
|
||||
{open ? <ChevronUp className="h-4 w-4 text-indigo-700 dark:text-indigo-300" /> : <ChevronDown className="h-4 w-4 text-indigo-700 dark:text-indigo-300" />}
|
||||
<History className="h-4 w-4 text-indigo-700 dark:text-indigo-300" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-indigo-700 dark:text-indigo-300">
|
||||
Context
|
||||
</span>
|
||||
{data && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">
|
||||
{[
|
||||
data.last_quote && "quote",
|
||||
data.last_order && "order",
|
||||
data.last_invoiced && "invoice",
|
||||
data.stock && "stock",
|
||||
].filter(Boolean).join(" · ") || "no matches"}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-2 text-[10px] font-medium text-muted-foreground hover:text-foreground underline-offset-2 hover:underline">
|
||||
{open ? "Hide" : "Show"}
|
||||
</span>
|
||||
{loading && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground ml-auto" />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-2 pl-6">
|
||||
{error && (
|
||||
<div className="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-[10px] text-destructive">
|
||||
Context lookup failed: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.match.match_kind === "description_fuzzy" && (
|
||||
<div className="rounded-md border border-warn/30 bg-warn/10 px-2 py-1 text-[10px] text-warn-foreground ring-1 ring-inset ring-warn/15">
|
||||
⚠ best-guess match · {Math.round(data.match.match_confidence * 100)}% confidence
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.last_quote && (
|
||||
<Row
|
||||
label="Last quote"
|
||||
icon={<ShoppingBag className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
badge={`${data.last_quote.quote_number} · ${relTime(data.last_quote.days_ago)}`}
|
||||
cost={data.last_quote.cost_price}
|
||||
selling={data.last_quote.selling_price}
|
||||
markup={data.last_quote.markup_percent}
|
||||
onApply={() => onApply({
|
||||
source: "last_quote",
|
||||
cost_price: round2(data.last_quote!.cost_price),
|
||||
source_label: data.last_quote!.quote_number,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data?.last_order && (
|
||||
<Row
|
||||
label="Last order"
|
||||
icon={<History className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
badge={`${data.last_order.order_number} · ${relTime(data.last_order.days_ago)}`}
|
||||
cost={data.last_order.cost_price}
|
||||
selling={data.last_order.selling_price}
|
||||
markup={data.last_order.markup_percent}
|
||||
onApply={() => onApply({
|
||||
source: "last_order",
|
||||
cost_price: round2(data.last_order!.cost_price),
|
||||
source_label: data.last_order!.order_number,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data?.last_invoiced && (
|
||||
<Row
|
||||
label="Last invoice"
|
||||
icon={<Receipt className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
badge={`${data.last_invoiced.supplier_invoice_number} · ${relTime(data.last_invoiced.days_ago)}`}
|
||||
cost={data.last_invoiced.cost_price}
|
||||
selling={null}
|
||||
markup={null}
|
||||
sellingDimmed
|
||||
onApply={() => onApply({
|
||||
source: "last_invoiced",
|
||||
cost_price: round2(data.last_invoiced!.cost_price),
|
||||
source_label: data.last_invoiced!.supplier_invoice_number,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data?.stock && (
|
||||
<Row
|
||||
label="Stock"
|
||||
icon={<Package className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
badge={`${data.stock.current_qty} units (${data.stock.status.replace("_", " ")})`}
|
||||
cost={data.stock.selling_price ?? 0}
|
||||
selling={data.stock.selling_price ?? null}
|
||||
markup={data.stock.markup_percent ?? null}
|
||||
applyDisabled={data.stock.selling_price == null}
|
||||
onApply={data.stock.selling_price != null ? () => onApply({
|
||||
source: "stock",
|
||||
cost_price: round2(data.stock!.selling_price!),
|
||||
source_label: data.stock!.name,
|
||||
}) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{noData && !loading && !error && (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
|
||||
<div className="font-medium">No context for this item yet</div>
|
||||
<div className="mt-1">First time we’re seeing this SKU — pick a supplier, set cost, save.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
36
src/components/order-item-context-popover-trigger.test.tsx
Normal file
36
src/components/order-item-context-popover-trigger.test.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { cleanup, render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { OrderItemContextPopoverTrigger } from "@/components/order-item-context-popover-trigger";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("OrderItemContextPopoverTrigger", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the ⓘ button by default", () => {
|
||||
render(<OrderItemContextPopoverTrigger itemId="x" />);
|
||||
expect(screen.getByRole("button", { name: /View item context/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fetches on first open, not on render", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
generated_at: "2026-08-07T10:00:00Z",
|
||||
match: { sku: "X", match_kind: "sku_exact", match_confidence: 1 },
|
||||
last_quote: null, last_order: null, last_invoiced: null, stock: null,
|
||||
suppliers: [], cheapest: null,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<OrderItemContextPopoverTrigger itemId="abc" />);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /View item context/ }));
|
||||
await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1));
|
||||
expect(fetchSpy.mock.calls[0][0]).toContain("/api/v1/order-items/abc/context");
|
||||
});
|
||||
});
|
||||
58
src/components/order-item-context-popover-trigger.tsx
Normal file
58
src/components/order-item-context-popover-trigger.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Info } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { OrderItemContextPanel } from "@/components/order-item-context-panel";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
itemId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⓘ popover trigger that renders the deterministic order-item
|
||||
* context inside the orders detail page items table. Fetches lazily
|
||||
* on first open.
|
||||
*/
|
||||
export function OrderItemContextPopoverTrigger({ itemId, className }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
"text-indigo-600 hover:bg-indigo-500/10",
|
||||
className,
|
||||
)}
|
||||
aria-label="View item context"
|
||||
title="Item context"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="right"
|
||||
className="w-[420px] p-0"
|
||||
>
|
||||
<OrderItemContextPanel
|
||||
itemId={itemId}
|
||||
defaultOpen
|
||||
onApply={() => {
|
||||
// Orders page is read-only at the row level — apply
|
||||
// requires the user to open the process modal. Close
|
||||
// the popover and let the row click target the modal.
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -108,12 +108,28 @@ describe("OrderSprintProcessModal", () => {
|
|||
// Order history + price list fetches resolve to empty lists so
|
||||
// the modal mounts without throwing. We don't assert on them.
|
||||
getListMock.mockResolvedValue({ items: [], totalItems: 0, totalPages: 1 });
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ webhookFired: true }), {
|
||||
// The context panel + margin-approval endpoint both go through
|
||||
// the global fetch. Route by URL so each gets a valid body —
|
||||
// returning the approval payload for the context call would
|
||||
// crash the panel's noData computation.
|
||||
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/context")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
generated_at: "2026-08-07T00:00:00Z",
|
||||
match: { sku: "", match_kind: "none", match_confidence: 0 },
|
||||
last_quote: null, last_order: null, last_invoiced: null, stock: null,
|
||||
suppliers: [], cheapest: null,
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ webhookFired: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
|
|
@ -170,13 +186,16 @@ describe("OrderSprintProcessModal", () => {
|
|||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(onProcessed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe(
|
||||
const approvalCall = fetchMock.mock.calls.find(
|
||||
(c) => typeof c[0] === "string" && c[0].includes("/request-margin-approval"),
|
||||
);
|
||||
expect(approvalCall).toBeDefined();
|
||||
expect(approvalCall![0]).toBe(
|
||||
"/api/v1/order-items/item-1/request-margin-approval",
|
||||
);
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1].body)).toMatchObject({
|
||||
expect(JSON.parse(approvalCall![1].body)).toMatchObject({
|
||||
source: "order_sprint",
|
||||
draft: {
|
||||
cost_price: 45,
|
||||
|
|
@ -420,75 +439,4 @@ describe("OrderSprintProcessModal", () => {
|
|||
// 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" } } });
|
||||
const btn = screen.getByRole("button", { name: /use ai suggestion/i });
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("applies supplier (resolved by name) and cost from the AI's top match", async () => {
|
||||
renderModal({
|
||||
item: {
|
||||
...baseItem,
|
||||
ai_item_insights: {
|
||||
procurement_summary: "x".repeat(60),
|
||||
top_historical_matches: [
|
||||
{
|
||||
cost_price: 42,
|
||||
matched_description: "INGCO LASER DISTANCE METER 0.05-85M",
|
||||
matched_item_id: "abc",
|
||||
selling_price: 50,
|
||||
supplier_id: "sup-1", // resolves to "Steel Co"
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /use ai suggestion/i }),
|
||||
);
|
||||
|
||||
// Supplier dropdown now reads "Steel Co" (sup-1's name).
|
||||
expect((screen.getByLabelText("Supplier") as HTMLSelectElement).value).toBe(
|
||||
"sup-1",
|
||||
);
|
||||
// Cost populated from AI's cost_price.
|
||||
expect(
|
||||
(screen.getByLabelText(/cost \(zar\)/i) as HTMLInputElement).value,
|
||||
).toBe("42");
|
||||
});
|
||||
|
||||
it("warns when the AI's supplier_id doesn't exist in the suppliers list", async () => {
|
||||
renderModal({
|
||||
item: {
|
||||
...baseItem,
|
||||
ai_item_insights: {
|
||||
procurement_summary: "x".repeat(60),
|
||||
top_historical_matches: [
|
||||
{
|
||||
cost_price: 42,
|
||||
supplier_id: "ghost-supplier",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /use ai suggestion/i }),
|
||||
);
|
||||
|
||||
// Cost still populated, supplier stays empty, the toast warns
|
||||
// about the bad ID rather than silently dropping it.
|
||||
expect(
|
||||
(screen.getByLabelText(/cost \(zar\)/i) as HTMLInputElement).value,
|
||||
).toBe("42");
|
||||
expect((screen.getByLabelText("Supplier") as HTMLSelectElement).value).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
|
|
@ -10,9 +10,7 @@ import {
|
|||
Loader2,
|
||||
Lock,
|
||||
ShoppingCart,
|
||||
Sparkles,
|
||||
Truck,
|
||||
Wand2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -27,10 +25,7 @@ import { cn } from "@/lib/utils";
|
|||
import { supplierColor } from "@/lib/supplier-color";
|
||||
import type { Supplier } from "@/lib/types";
|
||||
import { MarginApprovalDialog } from "@/components/margin-approval-dialog";
|
||||
import {
|
||||
AiItemHistoryPopover,
|
||||
AiInsightsEmptyState,
|
||||
} from "./ai-insights-renderer";
|
||||
import { OrderItemContextPanel, type ApplyPayload } from "@/components/order-item-context-panel";
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
const MARKUP_APPROVAL_THRESHOLD = 22;
|
||||
|
|
@ -60,14 +55,6 @@ type OrderSprintItem = {
|
|||
ai_item_insights?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
interface AiTopMatch {
|
||||
cost_price?: number;
|
||||
matched_description?: string;
|
||||
matched_item_id?: string;
|
||||
selling_price?: number;
|
||||
supplier_id?: string;
|
||||
}
|
||||
|
||||
export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProcessed }: Props) {
|
||||
// Selling comes from the order and is fixed — never edited here. We
|
||||
// capture it on mount via `item.selling_price` so the operator sees
|
||||
|
|
@ -84,13 +71,11 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
const [approvalPromptOpen, setApprovalPromptOpen] = useState(false);
|
||||
const [approvalError, setApprovalError] = useState<string | null>(null);
|
||||
const [suppliersExpanded, setSuppliersExpanded] = useState(true);
|
||||
const [aiInsightsExpanded, setAiInsightsExpanded] = useState(true);
|
||||
const [orderHistory, setOrderHistory] = useState<OrderHistoryItem[]>([]);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const [availableSuppliers, setAvailableSuppliers] = useState<AvailableSupplier[]>([]);
|
||||
const [loadingSuppliers, setLoadingSuppliers] = useState(false);
|
||||
|
||||
const aiInsights = useMemo(() => item?.ai_item_insights ?? null, [item]);
|
||||
const selling = item?.selling_price ?? 0;
|
||||
const liveMarkup =
|
||||
cost > 0 && selling > 0
|
||||
|
|
@ -204,43 +189,13 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
toast.success(`Applied from order ${h.orderNumber}`);
|
||||
};
|
||||
|
||||
// "Use AI suggestion" — read the top match from ai_item_insights and
|
||||
// set supplier (resolving the ID to the actual supplier name) + cost.
|
||||
const applyAiSuggestion = useCallback(() => {
|
||||
if (!aiInsights) {
|
||||
toast.error("No AI insights available for this item");
|
||||
return;
|
||||
}
|
||||
const matches = aiInsights.top_historical_matches;
|
||||
if (!Array.isArray(matches) || matches.length === 0) {
|
||||
toast.error("AI didn't return any supplier matches");
|
||||
return;
|
||||
}
|
||||
const top = matches[0] as AiTopMatch;
|
||||
const aiSupplierId = top.supplier_id;
|
||||
const aiCost = top.cost_price;
|
||||
|
||||
let resolvedName: string | null = null;
|
||||
if (aiSupplierId) {
|
||||
const supplier = suppliers.find((s) => s.id === aiSupplierId);
|
||||
if (supplier) {
|
||||
setSupplierId(supplier.id);
|
||||
resolvedName = supplier.suppliername || supplier.id;
|
||||
} else {
|
||||
// AI referenced a supplier ID that no longer exists — surface
|
||||
// the bad ID so the operator can see it instead of silently
|
||||
// dropping the suggestion.
|
||||
toast.warning(`AI suggested supplier ${aiSupplierId} but it's not in your suppliers list`);
|
||||
}
|
||||
}
|
||||
if (typeof aiCost === "number" && aiCost > 0) setCost(aiCost);
|
||||
|
||||
if (resolvedName || aiCost) {
|
||||
toast.success(
|
||||
`AI suggestion applied${resolvedName ? `: ${resolvedName}` : ""}${aiCost ? ` @ R${aiCost.toFixed(2)}` : ""}`,
|
||||
);
|
||||
}
|
||||
}, [aiInsights, suppliers]);
|
||||
// Apply a row from the context panel. Sells only the cost (+ supplier
|
||||
// when the row carries one). Selling stays locked to the order.
|
||||
const applyContext = (p: ApplyPayload) => {
|
||||
if (p.cost_price > 0) setCost(p.cost_price);
|
||||
if (p.supplier_id) setSupplierId(p.supplier_id);
|
||||
toast.success(`Applied cost from ${p.source_label}: R${p.cost_price.toFixed(2)}`);
|
||||
};
|
||||
|
||||
// Process
|
||||
const handleProcess = async () => {
|
||||
|
|
@ -375,10 +330,6 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
|
||||
if (!open || !item) return null;
|
||||
|
||||
const aiHasMatches =
|
||||
Array.isArray(aiInsights?.top_historical_matches) &&
|
||||
(aiInsights.top_historical_matches as unknown[]).length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -407,62 +358,12 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
</CardHeader>
|
||||
|
||||
<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/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"
|
||||
onClick={() => setAiInsightsExpanded(v => !v)}
|
||||
aria-expanded={aiInsightsExpanded}
|
||||
className="flex items-center gap-2 text-left group/ai-toggle"
|
||||
>
|
||||
{aiInsightsExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-warn/70" />
|
||||
) : (
|
||||
<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 dark:text-warn">
|
||||
AI Insights
|
||||
</span>
|
||||
{aiHasMatches && (
|
||||
<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/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>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={applyAiSuggestion}
|
||||
disabled={!aiHasMatches}
|
||||
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" />
|
||||
Use AI suggestion
|
||||
</Button>
|
||||
</div>
|
||||
{aiInsightsExpanded && (
|
||||
<div className="pl-6">
|
||||
{aiInsights ? (
|
||||
<AiItemHistoryPopover insights={aiInsights} />
|
||||
) : (
|
||||
<AiInsightsEmptyState
|
||||
title="No AI analysis"
|
||||
message="Process without AI suggestions."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{/* Context — deterministic history surfaced from PB. Replaces the
|
||||
AI Insights section. Selling stays locked; Apply copies cost. */}
|
||||
<OrderItemContextPanel
|
||||
itemId={item.id}
|
||||
onApply={applyContext}
|
||||
/>
|
||||
|
||||
{/* Order History */}
|
||||
<section className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -318,7 +318,7 @@ function emptyHistory(generated_at: string, match: ItemHistoryMatch): ItemHistor
|
|||
};
|
||||
}
|
||||
|
||||
function aggregateSuppliers(
|
||||
export function aggregateSuppliers(
|
||||
rows: Array<{ id: string; supplier: string; cost_price: number; updated: string }>,
|
||||
nameById: Map<string, string>
|
||||
): ItemHistorySupplier[] {
|
||||
|
|
|
|||
148
src/lib/server/order-item-context-service.test.ts
Normal file
148
src/lib/server/order-item-context-service.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { getOrderItemContext } from "@/lib/server/order-item-context-service";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
|
||||
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
||||
getAdminPocketBase: vi.fn(),
|
||||
}));
|
||||
|
||||
interface CollectionMock {
|
||||
getOne: ReturnType<typeof vi.fn>;
|
||||
getList: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makePb(collections: Record<string, CollectionMock>) {
|
||||
return {
|
||||
collection: (name: string) => {
|
||||
const c = collections[name];
|
||||
if (!c) throw new Error(`unexpected collection: ${name}`);
|
||||
return c;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ORD = "order_items";
|
||||
const Q = "quote_items";
|
||||
const STK = "stock_items";
|
||||
const SUP = "suppliers";
|
||||
|
||||
describe("getOrderItemContext", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns all-null context when the target has no SKU and no description", async () => {
|
||||
const pb = makePb({
|
||||
[ORD]: { getOne: vi.fn().mockResolvedValue({ id: "x", sku: "", description: "" }), getList: vi.fn() },
|
||||
});
|
||||
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited<ReturnType<typeof getAdminPocketBase>>);
|
||||
|
||||
const ctx = await getOrderItemContext("x");
|
||||
expect(ctx.last_quote).toBeNull();
|
||||
expect(ctx.last_order).toBeNull();
|
||||
expect(ctx.last_invoiced).toBeNull();
|
||||
expect(ctx.stock).toBeNull();
|
||||
expect(ctx.suppliers).toEqual([]);
|
||||
expect(ctx.cheapest).toBeNull();
|
||||
expect(ctx.match).toEqual({ sku: "", match_kind: "none", match_confidence: 0 });
|
||||
});
|
||||
|
||||
it("populates last_quote from the SKU-based quote_items join", async () => {
|
||||
const target = { id: "x", sku: "DRL-6MM", description: "Drill 6mm", quote_item: "qi0" };
|
||||
const quoteList = {
|
||||
items: [{
|
||||
id: "qi1",
|
||||
quote: "q1",
|
||||
cost_price: 100,
|
||||
selling_price: 150,
|
||||
created: "2026-08-01T10:00:00Z",
|
||||
expand: { quote: { quote_number: "Q-26080101", status: "approved", customer_name: "Acme", company: "Acme Ltd" } },
|
||||
}],
|
||||
};
|
||||
const emptyList = { items: [] };
|
||||
const supplierList = { items: [{ id: "sup1", suppliername: "Fastener Co" }] };
|
||||
|
||||
const pb = makePb({
|
||||
[ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn().mockResolvedValue(emptyList) },
|
||||
[Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(quoteList) },
|
||||
[STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) },
|
||||
[SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) },
|
||||
});
|
||||
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited<ReturnType<typeof getAdminPocketBase>>);
|
||||
|
||||
const ctx = await getOrderItemContext("x");
|
||||
expect(ctx.match).toEqual({ sku: "DRL-6MM", match_kind: "sku_exact", match_confidence: 1 });
|
||||
expect(ctx.last_quote?.quote_number).toBe("Q-26080101");
|
||||
expect(ctx.last_quote?.cost_price).toBe(100);
|
||||
expect(ctx.last_quote?.selling_price).toBe(150);
|
||||
expect(ctx.last_quote?.markup_percent).toBe(50);
|
||||
});
|
||||
|
||||
it("marks stock status as 'low' when current_qty <= min_qty and > 0", async () => {
|
||||
const target = { id: "x", sku: "DRL-6MM", description: "Drill", quote_item: "" };
|
||||
const emptyList = { items: [] };
|
||||
const supplierList = { items: [] };
|
||||
const stockList = {
|
||||
items: [{ id: "s1", name: "Drill 6mm", sku: "DRL-6MM", current_qty: 5, min_qty: 10, selling_price: 140, markup: 40 }],
|
||||
};
|
||||
const pb = makePb({
|
||||
[ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn().mockResolvedValue(emptyList) },
|
||||
[Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) },
|
||||
[STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(stockList) },
|
||||
[SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) },
|
||||
});
|
||||
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited<ReturnType<typeof getAdminPocketBase>>);
|
||||
|
||||
const ctx = await getOrderItemContext("x");
|
||||
expect(ctx.stock?.status).toBe("low");
|
||||
});
|
||||
|
||||
it("marks stock status as 'out' when current_qty <= 0", async () => {
|
||||
const target = { id: "x", sku: "DRL-6MM", description: "Drill", quote_item: "" };
|
||||
const emptyList = { items: [] };
|
||||
const supplierList = { items: [] };
|
||||
const stockList = {
|
||||
items: [{ id: "s1", name: "Drill 6mm", sku: "DRL-6MM", current_qty: 0, min_qty: 5, selling_price: 140, markup: 40 }],
|
||||
};
|
||||
const pb = makePb({
|
||||
[ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn().mockResolvedValue(emptyList) },
|
||||
[Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) },
|
||||
[STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(stockList) },
|
||||
[SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) },
|
||||
});
|
||||
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited<ReturnType<typeof getAdminPocketBase>>);
|
||||
|
||||
const ctx = await getOrderItemContext("x");
|
||||
expect(ctx.stock?.status).toBe("out");
|
||||
});
|
||||
|
||||
it("populates last_invoiced when an invoiced row exists", async () => {
|
||||
const target = { id: "x", sku: "DRL-6MM", description: "Drill", quote_item: "" };
|
||||
const emptyList = { items: [] };
|
||||
const supplierList = { items: [] };
|
||||
const invoicedList = {
|
||||
items: [{
|
||||
id: "oi2",
|
||||
order: "o1",
|
||||
supplier_invoice_number: "INV-99",
|
||||
customer_invoice_number: "C-INV-7",
|
||||
invoiced_date: "2026-08-02T10:00:00Z",
|
||||
cost_price: 105,
|
||||
quantity: 10,
|
||||
qty_invoiced: 10,
|
||||
expand: { order: { order_number: "ORD-26080201" } },
|
||||
}],
|
||||
};
|
||||
const pb = makePb({
|
||||
[ORD]: { getOne: vi.fn().mockResolvedValue(target), getList: vi.fn((_n: string, _p: unknown, opts: { sort?: string }) => opts?.sort === "-invoiced_date" ? invoicedList : emptyList) },
|
||||
[Q]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) },
|
||||
[STK]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList) },
|
||||
[SUP]: { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(supplierList) },
|
||||
});
|
||||
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as unknown as Awaited<ReturnType<typeof getAdminPocketBase>>);
|
||||
|
||||
const ctx = await getOrderItemContext("x");
|
||||
expect(ctx.last_invoiced?.supplier_invoice_number).toBe("INV-99");
|
||||
expect(ctx.last_invoiced?.cost_price).toBe(105);
|
||||
});
|
||||
});
|
||||
377
src/lib/server/order-item-context-service.ts
Normal file
377
src/lib/server/order-item-context-service.ts
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
/**
|
||||
* order-item-context-service — deterministic SKU-based context for
|
||||
* the order-sprint process modal + orders detail page.
|
||||
*
|
||||
* Mirrors the shape of `item-history-service.ts` but is anchored on
|
||||
* `order_items.id` instead of `quote_items.id`. The four rows
|
||||
* surfaced (last quote, last order, last invoiced, stock) plus a
|
||||
* supplier/cheapest aggregate give the operator the historical
|
||||
* record before they pick a supplier and cost.
|
||||
*
|
||||
* Pure helpers (`findBestDescriptionMatch`, `aggregateSuppliers`,
|
||||
* `computeCheapest`, `daysAgo`, `isValidPrice`) are imported from
|
||||
* `item-history-service.ts` — DO NOT duplicate them.
|
||||
*/
|
||||
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
import {
|
||||
findBestDescriptionMatch,
|
||||
aggregateSuppliers,
|
||||
computeCheapest,
|
||||
daysAgo,
|
||||
isValidPrice,
|
||||
} from "@/lib/server/item-history-service";
|
||||
import type {
|
||||
ItemHistoryMatch,
|
||||
ItemHistorySupplier,
|
||||
ItemHistoryCheapest,
|
||||
} from "@/lib/types";
|
||||
|
||||
export interface OrderItemLastQuote {
|
||||
quote_item_id: string;
|
||||
quote_id: string;
|
||||
quote_number: string;
|
||||
customer_name: string;
|
||||
company: string;
|
||||
status: string;
|
||||
cost_price: number;
|
||||
selling_price: number;
|
||||
markup_percent: number;
|
||||
days_ago: number;
|
||||
}
|
||||
|
||||
export interface OrderItemLastOrder {
|
||||
order_item_id: string;
|
||||
order_id: string;
|
||||
order_number: string;
|
||||
company: string;
|
||||
cost_price: number;
|
||||
selling_price: number;
|
||||
markup_percent: number;
|
||||
item_status: string;
|
||||
updated_at: string;
|
||||
days_ago: number;
|
||||
}
|
||||
|
||||
export interface OrderItemLastInvoiced {
|
||||
order_item_id: string;
|
||||
order_id: string;
|
||||
order_number: string;
|
||||
supplier_invoice_number: string;
|
||||
customer_invoice_number: string;
|
||||
invoiced_date: string;
|
||||
cost_price: number;
|
||||
quantity: number;
|
||||
qty_invoiced: number;
|
||||
days_ago: number;
|
||||
}
|
||||
|
||||
export interface OrderItemStock {
|
||||
stock_item_id: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
current_qty: number;
|
||||
min_qty: number | null;
|
||||
selling_price: number | null;
|
||||
markup_percent: number | null;
|
||||
status: "in_stock" | "low" | "out";
|
||||
}
|
||||
|
||||
export interface OrderItemContext {
|
||||
generated_at: string;
|
||||
match: ItemHistoryMatch;
|
||||
last_quote: OrderItemLastQuote | null;
|
||||
last_order: OrderItemLastOrder | null;
|
||||
last_invoiced: OrderItemLastInvoiced | null;
|
||||
stock: OrderItemStock | null;
|
||||
suppliers: ItemHistorySupplier[];
|
||||
cheapest: ItemHistoryCheapest | null;
|
||||
}
|
||||
|
||||
export interface OrderItemContextResponse extends OrderItemContext {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function getOrderItemContext(
|
||||
orderItemId: string,
|
||||
): Promise<OrderItemContextResponse> {
|
||||
const pb = await getAdminPocketBase();
|
||||
const generated_at = new Date().toISOString();
|
||||
const response: OrderItemContextResponse = {
|
||||
generated_at,
|
||||
match: { sku: "", match_kind: "none", match_confidence: 0 },
|
||||
last_quote: null,
|
||||
last_order: null,
|
||||
last_invoiced: null,
|
||||
stock: null,
|
||||
suppliers: [],
|
||||
cheapest: null,
|
||||
};
|
||||
|
||||
// 1. Load the target row. SKU + description drive matching.
|
||||
const target = await pb.collection("order_items").getOne<{
|
||||
id: string;
|
||||
sku?: string;
|
||||
description?: string;
|
||||
quote_item?: string;
|
||||
}>(orderItemId, { fields: "id,sku,description,quote_item" });
|
||||
response.match.sku = target.sku || "";
|
||||
|
||||
// Bail early when there's nothing to match on.
|
||||
if (!target.sku && !target.description) return response;
|
||||
|
||||
// 2. Description-fuzzy candidates — only when SKU is empty.
|
||||
const fuzzyCandidates = !target.sku && target.description
|
||||
? pb.collection("order_items").getList<{
|
||||
id: string;
|
||||
sku: string;
|
||||
description: string;
|
||||
}>(1, 500, {
|
||||
filter: `id != "${orderItemId}" && description != "" && sku != ""`,
|
||||
sort: "-created",
|
||||
fields: "id,sku,description",
|
||||
})
|
||||
: Promise.resolve({
|
||||
items: [] as Array<{ id: string; sku: string; description: string }>,
|
||||
});
|
||||
|
||||
const fuzzyResolved = !target.sku && target.description
|
||||
? (await fuzzyCandidates).items
|
||||
: [];
|
||||
const fm = !target.sku && target.description
|
||||
? findBestDescriptionMatch(
|
||||
{ id: orderItemId, sku: "", description: target.description },
|
||||
fuzzyResolved,
|
||||
0.6,
|
||||
)
|
||||
: null;
|
||||
|
||||
// 3. Resolve effective SKU + match metadata.
|
||||
const effectiveSku = target.sku || fm?.item.sku || "";
|
||||
if (target.sku) {
|
||||
response.match.match_kind = "sku_exact";
|
||||
response.match.match_confidence = 1;
|
||||
} else if (effectiveSku && fm) {
|
||||
response.match.match_kind = "description_fuzzy";
|
||||
response.match.match_confidence = fm.confidence;
|
||||
response.match.sku = effectiveSku;
|
||||
}
|
||||
|
||||
const sku = effectiveSku;
|
||||
const skuFilter = sku ? `sku = "${sku.replace(/"/g, '\\"')}"` : "";
|
||||
|
||||
// 4. Fan out 5 PB reads in parallel.
|
||||
const [quoteRows, orderRows, invoicedRows, stockRows, suppliersRows] = await Promise.all([
|
||||
sku
|
||||
? pb.collection("quote_items").getList<{
|
||||
id: string;
|
||||
quote: string;
|
||||
cost_price: number;
|
||||
selling_price: number;
|
||||
created: string;
|
||||
expand?: {
|
||||
quote?: {
|
||||
quote_number: string;
|
||||
status: string;
|
||||
customer_name?: string;
|
||||
company?: string;
|
||||
};
|
||||
};
|
||||
}>(1, 5, {
|
||||
filter: `${skuFilter} && id != "${target.quote_item ?? ""}"`,
|
||||
sort: "-created",
|
||||
expand: "quote",
|
||||
})
|
||||
: Promise.resolve({
|
||||
items: [] as Array<{
|
||||
id: string; quote: string; cost_price: number;
|
||||
selling_price: number; created: string;
|
||||
expand?: {
|
||||
quote?: {
|
||||
quote_number: string;
|
||||
status: string;
|
||||
customer_name?: string;
|
||||
company?: string;
|
||||
};
|
||||
};
|
||||
}>,
|
||||
}),
|
||||
|
||||
sku
|
||||
? pb.collection("order_items").getList<{
|
||||
id: string;
|
||||
order: string;
|
||||
supplier: string;
|
||||
cost_price: number;
|
||||
selling_price: number;
|
||||
markup_percent?: number;
|
||||
item_status: string;
|
||||
updated: string;
|
||||
expand?: { order?: { order_number: string; company?: string } };
|
||||
}>(1, 5, {
|
||||
filter: `${skuFilter} && id != "${orderItemId}"`,
|
||||
sort: "-updated",
|
||||
expand: "order",
|
||||
})
|
||||
: Promise.resolve({
|
||||
items: [] as Array<{
|
||||
id: string; order: string; supplier: string;
|
||||
cost_price: number; selling_price: number;
|
||||
markup_percent?: number;
|
||||
item_status: string; updated: string;
|
||||
expand?: { order?: { order_number: string; company?: string } };
|
||||
}>,
|
||||
}),
|
||||
|
||||
sku
|
||||
? pb.collection("order_items").getList<{
|
||||
id: string;
|
||||
order: string;
|
||||
supplier_invoice_number: string;
|
||||
customer_invoice_number: string;
|
||||
invoiced_date: string;
|
||||
cost_price: number;
|
||||
quantity: number;
|
||||
qty_invoiced: number;
|
||||
expand?: { order?: { order_number: string } };
|
||||
}>(1, 1, {
|
||||
filter: `${skuFilter} && invoiced_date != null && invoiced_date != "" && supplier_invoice_number != ""`,
|
||||
sort: "-invoiced_date",
|
||||
expand: "order",
|
||||
})
|
||||
: Promise.resolve({
|
||||
items: [] as Array<{
|
||||
id: string; order: string;
|
||||
supplier_invoice_number: string;
|
||||
customer_invoice_number: string;
|
||||
invoiced_date: string; cost_price: number;
|
||||
quantity: number; qty_invoiced: number;
|
||||
expand?: { order?: { order_number: string } };
|
||||
}>,
|
||||
}),
|
||||
|
||||
sku
|
||||
? pb.collection("stock_items").getList<{
|
||||
id: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
current_qty: number;
|
||||
min_qty: number | null;
|
||||
selling_price: number | null;
|
||||
markup: number | null;
|
||||
}>(1, 5, { filter: skuFilter })
|
||||
: Promise.resolve({
|
||||
items: [] as Array<{
|
||||
id: string; name: string; sku: string;
|
||||
current_qty: number; min_qty: number | null;
|
||||
selling_price: number | null; markup: number | null;
|
||||
}>,
|
||||
}),
|
||||
|
||||
pb.collection("suppliers").getList<{ id: string; suppliername: string }>(1, 200),
|
||||
]);
|
||||
|
||||
const supplierNameById = new Map(
|
||||
suppliersRows.items.map((s) => [s.id, s.suppliername]),
|
||||
);
|
||||
|
||||
// 5. Last quote — most recent with a valid cost or selling.
|
||||
const q = quoteRows.items.find(
|
||||
(r) => isValidPrice(r.cost_price) || isValidPrice(r.selling_price),
|
||||
);
|
||||
if (q) {
|
||||
const cost = q.cost_price || 0;
|
||||
const sell = q.selling_price || 0;
|
||||
response.last_quote = {
|
||||
quote_item_id: q.id,
|
||||
quote_id: q.quote,
|
||||
quote_number: q.expand?.quote?.quote_number || "—",
|
||||
customer_name: q.expand?.quote?.customer_name || "—",
|
||||
company: q.expand?.quote?.company || "—",
|
||||
status: q.expand?.quote?.status || "",
|
||||
cost_price: cost,
|
||||
selling_price: sell,
|
||||
markup_percent:
|
||||
cost > 0 && sell > 0
|
||||
? Math.round(((sell - cost) / cost) * 10000) / 100
|
||||
: 0,
|
||||
days_ago: daysAgo(q.created),
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Last order — most recent other row with valid pricing.
|
||||
const o = orderRows.items.find(
|
||||
(r) => isValidPrice(r.cost_price) || isValidPrice(r.selling_price),
|
||||
);
|
||||
if (o) {
|
||||
const cost = o.cost_price || 0;
|
||||
const sell = o.selling_price || 0;
|
||||
response.last_order = {
|
||||
order_item_id: o.id,
|
||||
order_id: o.order,
|
||||
order_number: o.expand?.order?.order_number || "—",
|
||||
company: o.expand?.order?.company || "—",
|
||||
cost_price: cost,
|
||||
selling_price: sell,
|
||||
markup_percent:
|
||||
o.markup_percent ??
|
||||
(cost > 0 && sell > 0
|
||||
? Math.round(((sell - cost) / cost) * 10000) / 100
|
||||
: 0),
|
||||
item_status: o.item_status,
|
||||
updated_at: o.updated,
|
||||
days_ago: daysAgo(o.updated),
|
||||
};
|
||||
}
|
||||
|
||||
// 7. Last invoiced.
|
||||
const i = invoicedRows.items[0];
|
||||
if (i) {
|
||||
response.last_invoiced = {
|
||||
order_item_id: i.id,
|
||||
order_id: i.order,
|
||||
order_number: i.expand?.order?.order_number || "—",
|
||||
supplier_invoice_number: i.supplier_invoice_number,
|
||||
customer_invoice_number: i.customer_invoice_number,
|
||||
invoiced_date: i.invoiced_date,
|
||||
cost_price: i.cost_price,
|
||||
quantity: i.quantity,
|
||||
qty_invoiced: i.qty_invoiced,
|
||||
days_ago: daysAgo(i.invoiced_date),
|
||||
};
|
||||
}
|
||||
|
||||
// 8. Stock — derive status from qty thresholds.
|
||||
const s = stockRows.items[0];
|
||||
if (s) {
|
||||
let status: OrderItemStock["status"];
|
||||
if (s.current_qty <= 0) status = "out";
|
||||
else if (s.min_qty != null && s.current_qty <= s.min_qty) status = "low";
|
||||
else status = "in_stock";
|
||||
response.stock = {
|
||||
stock_item_id: s.id,
|
||||
name: s.name,
|
||||
sku: s.sku,
|
||||
current_qty: s.current_qty,
|
||||
min_qty: s.min_qty,
|
||||
selling_price: s.selling_price,
|
||||
markup_percent: s.markup,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
// 9. Suppliers list — derived from order_rows; filter to priced.
|
||||
response.suppliers = aggregateSuppliers(
|
||||
orderRows.items.map((r) => ({
|
||||
id: r.id,
|
||||
supplier: r.supplier,
|
||||
cost_price: r.cost_price,
|
||||
updated: r.updated,
|
||||
})),
|
||||
supplierNameById,
|
||||
).filter((s) => s.last_cost > 0);
|
||||
|
||||
response.cheapest = computeCheapest(response.suppliers);
|
||||
|
||||
return response;
|
||||
}
|
||||
Loading…
Reference in a new issue