feat(ui): blue price-check badge with its popover

The blue badge that sits under the purple AI marker: a tag icon plus the
one-line summary, with an orange dot on the icon when the price check
found something worth a look.

The popover shows POS stock (on hand, status, cost, sell, supplier), the
top three price-list matches, what we last paid each supplier, what we
quoted and ordered before, and how the match was made ("POS item the AI
picked" / "Matched by SKU" / "Similar items"). Refresh re-computes the
intel through the existing /api/v1/items/{collection}/{id}/intel/refresh
route and reads the line back afterwards.

Also closes the colour-token gap: globals.css mapped --color-info to
var(--info), but --info / --info-foreground were never defined, so
text-info rendered nothing. They now have a light and a dark fallback
next to the --ai tokens; themes that define their own --info still win.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 21:33:50 +02:00
parent 13220c3c43
commit 3248e3aa03
2 changed files with 408 additions and 0 deletions

View file

@ -65,9 +65,19 @@
--ai-foreground: oklch(0.98 0 0);
}
/* Price-check blue. The themes that define --info override these; the
* fallbacks keep `text-info` / `bg-info/10` from rendering nothing on a
* theme that has no --info of its own. */
:root {
--info: oklch(0.58 0.13 220);
--info-foreground: oklch(0.98 0 0);
}
.dark {
--ai: oklch(0.72 0.17 300);
--ai-foreground: oklch(0.15 0 0);
--info: oklch(0.70 0.13 220);
--info-foreground: oklch(0.15 0 0);
}
@layer base {

View file

@ -0,0 +1,398 @@
"use client";
/**
* price-check-marker.tsx
*
* The blue "price check" badge that sits under the purple AI marker on
* the quote and order Process pages.
*
* It answers one question at a glance — "is this price sane?" — and
* one click deeper — "why do you say that?".
*
* - The badge itself: a blue tag icon plus the one-line summary
* ("POS R 0,45 · 120 in stock · last bought R 0,42 Fowkes (12 days
* ago) · 2 other suppliers"). An orange dot on the icon means
* something is worth a look (see `buildPriceCheck`'s flags).
* - The popover: POS stock, the top price-list matches, what we last
* paid each supplier, what we quoted and ordered before, how the
* match was made, and a Refresh button.
*
* The intel comes from the line's own `ai_item_insights` column. The AI
* assistant fills it the moment it prices a line, so the badge usually
* has something to say straight away; the Refresh button re-computes it
* (POST /api/v1/items/{collection}/{id}/intel/refresh) and re-reads
* the line afterwards.
*/
import { useCallback, useState } from "react";
import { Loader2, RefreshCw, Tags } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { buildPriceCheck, formatMoney, matchKindLabel } from "@/lib/price-check";
import type { ItemIntel } from "@/lib/types";
import { cn } from "@/lib/utils";
type Collection = "quote_items" | "order_items";
interface Props {
/** PB id of the quote_item / order_item. */
itemId: string;
collection: Collection;
/** The line's own prices — the price check compares them with the
* intel (are we above what we paid last time? is another supplier
* cheaper?). */
cost_price: number;
selling_price: number;
/** PB supplier id on the line. */
supplier: string;
/** The line's stored `ai_item_insights` blob. */
intel?: unknown;
className?: string;
}
/** The stored blob is `unknown` on the item types — only accept the
* shape the popover can actually render. */
function asIntel(raw: unknown): ItemIntel | null {
if (!raw || typeof raw !== "object") return null;
const i = raw as Partial<ItemIntel>;
if (!Array.isArray(i.stock_list) || !Array.isArray(i.supplier_history)) return null;
return {
summary: typeof i.summary === "string" ? i.summary : "",
generated_at: typeof i.generated_at === "string" ? i.generated_at : "",
stock_list: i.stock_list,
supplier_history: i.supplier_history,
price_list: Array.isArray(i.price_list) ? i.price_list : [],
recent_quote: Array.isArray(i.recent_quote) ? i.recent_quote : [],
recent_order: Array.isArray(i.recent_order) ? i.recent_order : [],
match_kind: i.match_kind,
item: i.item,
};
}
function intelPath(collection: Collection, itemId: string): string {
return collection === "quote_items"
? `/api/v1/quote_items/${itemId}/intel`
: `/api/v1/order-items/${itemId}/intel`;
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{title}
</span>
{children}
</div>
);
}
function Line({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
return (
<div className="flex items-baseline justify-between gap-3 text-[11px]">
<span className="min-w-0 truncate text-muted-foreground">{label}</span>
<span className="shrink-0 text-right font-medium tabular-nums">{value}</span>
</div>
);
}
export function PriceCheckMarker({
itemId,
collection,
cost_price,
selling_price,
supplier,
intel,
className,
}: Props) {
const [open, setOpen] = useState(false);
const [data, setData] = useState<ItemIntel | null>(() => asIntel(intel));
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const check = buildPriceCheck(data, {
cost_price,
selling_price,
supplier,
});
const hasFlags = check.flags.length > 0;
/** Read the stored intel back. */
const load = useCallback(async () => {
const res = await fetch(intelPath(collection, itemId));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = (await res.json()) as { intel?: unknown };
setData(asIntel(body.intel));
}, [collection, itemId]);
const handleOpen = useCallback(
async (next: boolean) => {
setOpen(next);
if (!next || data) return;
setLoading(true);
setError(null);
try {
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load price check");
} finally {
setLoading(false);
}
},
[data, load],
);
/** Re-compute the intel, then read the line back. */
const handleRefresh = useCallback(async () => {
setRefreshing(true);
setError(null);
try {
const res = await fetch(`/api/v1/items/${collection}/${itemId}/intel/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Refresh failed");
} finally {
setRefreshing(false);
}
}, [collection, itemId, load]);
return (
<Popover open={open} onOpenChange={handleOpen}>
<PopoverTrigger
aria-label={
hasFlags
? `Price check — ${check.flags.length} thing(s) to look at`
: "Price check"
}
className={cn(
"inline-flex max-w-full items-center gap-1 rounded-md px-1 py-0.5 text-left outline-none",
"transition-colors hover:bg-info/10 focus-visible:ring-[3px] focus-visible:ring-ring/40",
className,
)}
>
<span className="relative inline-flex size-4 shrink-0 items-center justify-center rounded-full border border-info/30 bg-info/10 text-info">
<Tags className="size-2.5" aria-hidden />
{hasFlags ? (
<span
data-price-check-flag-dot
aria-hidden
className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-warn ring-1 ring-background"
/>
) : null}
</span>
<span className="min-w-0 truncate text-[11px] leading-tight text-info">
{check.summary}
</span>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[min(420px,calc(100vw-1rem))] max-h-[80vh] overflow-y-auto p-3"
>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
<Tags className="size-3.5 shrink-0 text-info" aria-hidden />
<span className="truncate text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Price check
</span>
</div>
<span className="shrink-0 rounded-full border border-info/30 bg-info/10 px-2 py-0.5 text-[10px] font-medium text-info">
{matchKindLabel(check.matchKind)}
</span>
</div>
{error ? (
<div className="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-[11px] text-destructive">
{error}
</div>
) : null}
{hasFlags ? (
<div className="flex flex-col gap-1 rounded-md border border-warn/30 bg-warn/10 px-2 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wide text-warn">
Worth a look
</span>
<ul className="list-disc pl-4 text-[11px] text-warn">
{check.flags.map((f) => (
<li key={f.key}>{f.message}</li>
))}
</ul>
</div>
) : null}
{loading ? (
<div className="flex items-center gap-2 py-2 text-[11px] text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Loading price check…
</div>
) : null}
{!loading && data ? (
<>
{data.stock_list[0] ? (
<Section title="POS stock">
<div className="flex flex-col gap-0.5 rounded-md border border-border/60 bg-card px-2 py-1.5">
<Line
label={data.stock_list[0].name || "Top match"}
value={
<span
className={cn(
"text-[11px] font-medium",
data.stock_list[0].status === "in_stock"
? "text-emerald-700 dark:text-emerald-400"
: data.stock_list[0].status === "low"
? "text-amber-700 dark:text-amber-400"
: "text-muted-foreground",
)}
>
{data.stock_list[0].qty} on hand ·{" "}
{data.stock_list[0].status.replace("_", " ")}
</span>
}
/>
<Line label="POS cost" value={formatMoney(data.stock_list[0].cost_price)} />
<Line label="POS sell" value={formatMoney(data.stock_list[0].selling_price)} />
{data.stock_list[0].supplier_name ? (
<Line label="Supplier" value={data.stock_list[0].supplier_name} />
) : null}
</div>
</Section>
) : null}
{data.price_list.length > 0 ? (
<Section title="Price list">
<div className="flex flex-col gap-0.5">
{data.price_list.slice(0, 3).map((p) => (
<div
key={`${p.sku}-${p.updated}`}
className="flex items-baseline justify-between gap-3 text-[11px]"
>
<span className="min-w-0 truncate">
{p.description || p.sku}
{p.supplier ? (
<span className="text-muted-foreground"> · {p.supplier}</span>
) : null}
</span>
<span className="shrink-0 tabular-nums">
cost {formatMoney(p.cost_price)} · sell {formatMoney(p.selling_price)}
</span>
</div>
))}
</div>
</Section>
) : null}
{data.supplier_history.length > 0 ? (
<Section title="Last bought per supplier">
<div className="flex flex-col gap-0.5">
{data.supplier_history.map((s) => (
<div
key={s.supplier_id || s.supplier_name}
className="flex items-baseline justify-between gap-3 text-[11px]"
>
<span className="min-w-0 truncate">
{s.supplier_name || s.supplier_id}
<span className="text-muted-foreground">
{" "}
· {s.days_ago}d ago · ×{s.times_bought}
</span>
</span>
<span className="shrink-0 tabular-nums">{formatMoney(s.last_cost)}</span>
</div>
))}
</div>
</Section>
) : null}
{data.recent_quote.length > 0 ? (
<Section title="Quoted before">
<div className="flex flex-col gap-0.5">
{data.recent_quote.slice(0, 2).map((q) => (
<div
key={q.quote_item_id}
className="flex items-baseline justify-between gap-3 text-[11px]"
>
<span className="min-w-0 truncate">
{q.quote_number}
<span className="text-muted-foreground"> · {q.days_ago}d ago</span>
</span>
<span className="shrink-0 tabular-nums">
{formatMoney(q.selling_price)}
</span>
</div>
))}
</div>
</Section>
) : null}
{data.recent_order.length > 0 ? (
<Section title="Ordered before">
<div className="flex flex-col gap-0.5">
{data.recent_order.slice(0, 2).map((o) => (
<div
key={o.order_item_id}
className="flex items-baseline justify-between gap-3 text-[11px]"
>
<span className="min-w-0 truncate">
{o.order_number}
<span className="text-muted-foreground"> · {o.days_ago}d ago</span>
</span>
<span className="shrink-0 tabular-nums">
{formatMoney(o.selling_price)}
</span>
</div>
))}
</div>
</Section>
) : null}
</>
) : null}
{!loading && !data ? (
<div className="rounded-lg border border-dashed p-3 text-center text-[11px] text-muted-foreground">
No price data yet. Hit Refresh to compute it.
</div>
) : null}
<div className="flex items-center justify-between gap-2 border-t border-dashed pt-2">
<span className="text-[10px] text-muted-foreground/70">
{data?.generated_at ? `computed ${data.generated_at.slice(0, 10)}` : ""}
</span>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 gap-1 px-2 text-[11px]"
disabled={refreshing}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void handleRefresh();
}}
>
{refreshing ? (
<Loader2 className="size-3 animate-spin" aria-hidden />
) : (
<RefreshCw className="size-3" aria-hidden />
)}
Refresh
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}