feat(item-history): tight popover renderer with 4 render states
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
f4d9601881
commit
bce66b135a
2 changed files with 335 additions and 0 deletions
145
src/components/item-history-popover.test.tsx
Normal file
145
src/components/item-history-popover.test.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
import { ItemHistoryPopover } from "@/components/item-history-popover";
|
||||
import type { ItemHistoryResponse } from "@/lib/types";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseGenerated = "2026-07-29T12:00:00Z";
|
||||
|
||||
const fullDeterministic: ItemHistoryResponse["deterministic"] = {
|
||||
generated_at: baseGenerated,
|
||||
match: { sku: "DRL-6MM", match_kind: "sku_exact", match_confidence: 1 },
|
||||
cheapest: { supplier_id: "sup-a", supplier_name: "Fastener Co", last_cost: 42 },
|
||||
suppliers: [
|
||||
{ supplier_id: "sup-a", supplier_name: "Fastener Co", last_cost: 42, last_bought_at: "2026-07-11T10:00:00Z", times_bought: 4, last_order_id: "po1" },
|
||||
{ supplier_id: "sup-b", supplier_name: "Tool Depot", last_cost: 48, last_bought_at: "2026-07-24T10:00:00Z", times_bought: 2, last_order_id: "po2" },
|
||||
],
|
||||
other_quotes: [
|
||||
{ quote_id: "q1", quote_number: "Q-26070112", company: "MegaCorp", customer_name: "John", status: "approved", selling_price: 85, quoted_at: "2026-07-11T10:00:00Z", days_ago: 18 },
|
||||
],
|
||||
last_sold: null,
|
||||
stock: { in_stock: 240, status: "in_stock" },
|
||||
};
|
||||
|
||||
const sampleAi = {
|
||||
summary: "Phone Fastener Co — they had it for R42 last week.",
|
||||
last_quoted_price: 85,
|
||||
};
|
||||
|
||||
describe("ItemHistoryPopover", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-29T12:00:00Z"));
|
||||
});
|
||||
|
||||
it("renders both cards when both deterministic and AI are present", () => {
|
||||
render(
|
||||
<ItemHistoryPopover
|
||||
data={{
|
||||
deterministic: fullDeterministic,
|
||||
ai: sampleAi,
|
||||
ai_updated_at: baseGenerated,
|
||||
generated_at: baseGenerated,
|
||||
}}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/PB-derived/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/AI snapshot/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Fastener Co/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/Phone Fastener Co/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders only the deterministic card when AI is null", () => {
|
||||
render(
|
||||
<ItemHistoryPopover
|
||||
data={{
|
||||
deterministic: fullDeterministic,
|
||||
ai: null,
|
||||
ai_updated_at: null,
|
||||
generated_at: baseGenerated,
|
||||
}}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/PB-derived/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/AI snapshot/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only the AI card when deterministic is null", () => {
|
||||
render(
|
||||
<ItemHistoryPopover
|
||||
data={{
|
||||
deterministic: null,
|
||||
ai: sampleAi,
|
||||
ai_updated_at: baseGenerated,
|
||||
generated_at: baseGenerated,
|
||||
}}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/AI snapshot/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/PB-derived/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the empty state when both are null", () => {
|
||||
render(
|
||||
<ItemHistoryPopover
|
||||
data={{
|
||||
deterministic: null,
|
||||
ai: null,
|
||||
ai_updated_at: null,
|
||||
generated_at: baseGenerated,
|
||||
}}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/No history for this item yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the loading state", () => {
|
||||
render(
|
||||
<ItemHistoryPopover data={null} loading={true} error={null} />
|
||||
);
|
||||
expect(screen.getByText(/Loading/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the error chip on the deterministic side when error is set", () => {
|
||||
render(
|
||||
<ItemHistoryPopover
|
||||
data={{
|
||||
deterministic: null,
|
||||
ai: sampleAi,
|
||||
ai_updated_at: baseGenerated,
|
||||
generated_at: baseGenerated,
|
||||
}}
|
||||
loading={false}
|
||||
error="PB timeout"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/PB timeout/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/AI snapshot/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the fuzzy-match badge when match_kind is description_fuzzy", () => {
|
||||
render(
|
||||
<ItemHistoryPopover
|
||||
data={{
|
||||
deterministic: { ...fullDeterministic, match: { sku: "", match_kind: "description_fuzzy", match_confidence: 0.7 } },
|
||||
ai: null,
|
||||
ai_updated_at: null,
|
||||
generated_at: baseGenerated,
|
||||
}}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/best-guess match/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
190
src/components/item-history-popover.tsx
Normal file
190
src/components/item-history-popover.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"use client";
|
||||
|
||||
import { Info, Loader2, Package, TrendingUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ItemHistoryResponse } from "@/lib/types";
|
||||
|
||||
interface Props {
|
||||
data: ItemHistoryResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function ItemHistoryPopover({ data, loading, error }: Props) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading history…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="p-3 text-xs text-muted-foreground">No history available.</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { deterministic, ai, generated_at } = data;
|
||||
const noData = !deterministic && !ai;
|
||||
|
||||
if (noData && !error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
|
||||
<div className="font-medium">No history for this item yet</div>
|
||||
<div className="mt-1">First time we’re quoting this SKU — no supplier history, no other quotes.</div>
|
||||
<div className="mt-2 text-foreground">Pick a supplier, set cost, save.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showBoth = deterministic && ai;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{error && (
|
||||
<div className="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-[10px] text-destructive">
|
||||
History lookup failed: {error}
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("grid gap-2", showBoth && "grid-cols-2")}>
|
||||
{deterministic && <DeterministicCard data={deterministic} now={generated_at} />}
|
||||
{ai && <AiCard data={ai} updatedAt={data.ai_updated_at} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FreshnessBadge({ label, ts, variant }: { label: string; ts: string | null; variant: "indigo" | "amber" }) {
|
||||
const cls =
|
||||
variant === "indigo"
|
||||
? "bg-indigo-500/10 text-indigo-700 dark:text-indigo-300"
|
||||
: "bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||
return (
|
||||
<span className={cn("inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium", cls)}>
|
||||
{label} {ts ? relTime(ts) : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] font-bold uppercase tracking-wide text-muted-foreground">{title}</div>
|
||||
<div className="text-xs">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeterministicCard({
|
||||
data,
|
||||
now,
|
||||
}: {
|
||||
data: NonNullable<ItemHistoryResponse["deterministic"]>;
|
||||
now: string;
|
||||
}) {
|
||||
const fuzzy = data.match.match_kind === "description_fuzzy";
|
||||
return (
|
||||
<div className="rounded-lg border border-indigo-200 bg-indigo-50/40 p-2.5 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TrendingUp className="h-3.5 w-3.5 text-indigo-700" />
|
||||
<span className="text-xs font-semibold text-indigo-700">PB-derived</span>
|
||||
</div>
|
||||
<FreshnessBadge label="fresh" ts={data.generated_at} variant="indigo" />
|
||||
</div>
|
||||
|
||||
{fuzzy && (
|
||||
<div className="rounded border border-amber-300 bg-amber-50 px-2 py-1 text-[10px] text-amber-900">
|
||||
⚠ best-guess match · {Math.round(data.match.match_confidence * 100)}% confidence
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.cheapest && (
|
||||
<div className="text-xs font-bold text-emerald-700">
|
||||
↓ Cheapest: {data.cheapest.supplier_name} R{data.cheapest.last_cost}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.suppliers.length > 0 && (
|
||||
<Section title={`We buy this from (${data.suppliers.length})`}>
|
||||
<ul className="space-y-0.5">
|
||||
{data.suppliers.map((s) => (
|
||||
<li key={s.supplier_id} className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{s.supplier_name}</span>
|
||||
<span className="tabular-nums text-emerald-700">R{s.last_cost}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{relTime(s.last_bought_at)} (×{s.times_bought})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{data.other_quotes.length > 0 && (
|
||||
<Section title={`Other quotes (${data.other_quotes.length})`}>
|
||||
<ul className="space-y-0.5">
|
||||
{data.other_quotes.map((q) => (
|
||||
<li key={q.quote_id} className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-[11px]">{q.quote_number}</span>
|
||||
<span className="tabular-nums">R{q.selling_price}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{q.status} · {q.days_ago}d</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{data.last_sold && (
|
||||
<Section title="Last sold">
|
||||
R{data.last_sold.selling_price} · {data.last_sold.days_ago}d ago · {data.last_sold.company}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{data.stock && (
|
||||
<Section title="Stock">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Package className="h-3 w-3" />
|
||||
{data.stock.in_stock} units ({data.stock.status.replace("_", " ")})
|
||||
</span>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AiCard({ data, updatedAt }: { data: Record<string, unknown>; updatedAt: string | null }) {
|
||||
const summary = typeof data.summary === "string" ? data.summary : null;
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50/40 p-2.5 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Info className="h-3.5 w-3.5 text-amber-700" />
|
||||
<span className="text-xs font-semibold text-amber-700">AI snapshot</span>
|
||||
</div>
|
||||
<FreshnessBadge label="snapshot" ts={updatedAt} variant="amber" />
|
||||
</div>
|
||||
{summary && <p className="text-xs">{summary}</p>}
|
||||
<details className="text-[10px] text-muted-foreground">
|
||||
<summary className="cursor-pointer">Raw AI data</summary>
|
||||
<pre className="mt-1 max-h-32 overflow-auto rounded bg-muted/40 p-1 text-[10px]">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function relTime(iso: string): string {
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return iso;
|
||||
const diff = Date.now() - t;
|
||||
const min = Math.round(diff / 60000);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
const d = Math.round(hr / 24);
|
||||
if (d < 30) return `${d}d ago`;
|
||||
const mo = Math.round(d / 30);
|
||||
return `${mo}mo ago`;
|
||||
}
|
||||
Loading…
Reference in a new issue