feat(context): OrderItemContextPanel (inline expandable, 4 rows)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
5291fd2fd1
commit
efd2ad6540
1 changed files with 231 additions and 0 deletions
231
src/components/order-item-context-panel.tsx
Normal file
231
src/components/order-item-context-panel.tsx
Normal 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’re seeing this SKU — pick a supplier, set cost, save.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue