diff --git a/src/components/order-item-context-panel.tsx b/src/components/order-item-context-panel.tsx new file mode 100644 index 0000000..c143440 --- /dev/null +++ b/src/components/order-item-context-panel.tsx @@ -0,0 +1,231 @@ +"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 ( +
+
+ {icon} + {label} +
+ {badge} +
+ costR {cost.toFixed(2)} + + selling + {selling != null ? `R ${selling.toFixed(2)}` : "—"} + + + markup + {markup != null ? `${markup.toFixed(1)}%` : "—"} + +
+ +
+ ); +} + +export function OrderItemContextPanel({ itemId, defaultOpen = true, onApply }: Props) { + const [open, setOpen] = useState(defaultOpen); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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(() => { + 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 ( +
+ + + {open && ( +
+ {error && ( +
+ Context lookup failed: {error} +
+ )} + + {data?.match.match_kind === "description_fuzzy" && ( +
+ ⚠ best-guess match · {Math.round(data.match.match_confidence * 100)}% confidence +
+ )} + + {data?.last_quote && ( + } + 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 && ( + } + 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 && ( + } + 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 && ( + } + 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 && ( +
+
No context for this item yet
+
First time we’re seeing this SKU — pick a supplier, set cost, save.
+
+ )} +
+ )} +
+ ); +}