feat(context): OrderItemContextPanel (inline expandable, 4 rows)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-07 02:53:21 +02:00
parent 5291fd2fd1
commit efd2ad6540

View file

@ -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 (
<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(() => {
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&rsquo;re seeing this SKU — pick a supplier, set cost, save.</div>
</div>
)}
</div>
)}
</section>
);
}