From 3248e3aa039151584de745a95dc382e5a24277aa Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 24 Oct 2026 21:33:50 +0200 Subject: [PATCH] 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 --- src/app/globals.css | 10 + src/components/price-check-marker.tsx | 398 ++++++++++++++++++++++++++ 2 files changed, 408 insertions(+) create mode 100644 src/components/price-check-marker.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 5989db4..e18da4e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 { diff --git a/src/components/price-check-marker.tsx b/src/components/price-check-marker.tsx new file mode 100644 index 0000000..8ae46bf --- /dev/null +++ b/src/components/price-check-marker.tsx @@ -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; + 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 ( +
+ + {title} + + {children} +
+ ); +} + +function Line({ + label, + value, +}: { + label: string; + value: React.ReactNode; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +export function PriceCheckMarker({ + itemId, + collection, + cost_price, + selling_price, + supplier, + intel, + className, +}: Props) { + const [open, setOpen] = useState(false); + const [data, setData] = useState(() => asIntel(intel)); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(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 ( + + + + + {hasFlags ? ( + + ) : null} + + + {check.summary} + + + + +
+
+
+ + + Price check + +
+ + {matchKindLabel(check.matchKind)} + +
+ + {error ? ( +
+ {error} +
+ ) : null} + + {hasFlags ? ( +
+ + Worth a look + +
    + {check.flags.map((f) => ( +
  • {f.message}
  • + ))} +
+
+ ) : null} + + {loading ? ( +
+ + Loading price check… +
+ ) : null} + + {!loading && data ? ( + <> + {data.stock_list[0] ? ( +
+
+ + {data.stock_list[0].qty} on hand ·{" "} + {data.stock_list[0].status.replace("_", " ")} + + } + /> + + + {data.stock_list[0].supplier_name ? ( + + ) : null} +
+
+ ) : null} + + {data.price_list.length > 0 ? ( +
+
+ {data.price_list.slice(0, 3).map((p) => ( +
+ + {p.description || p.sku} + {p.supplier ? ( + · {p.supplier} + ) : null} + + + cost {formatMoney(p.cost_price)} · sell {formatMoney(p.selling_price)} + +
+ ))} +
+
+ ) : null} + + {data.supplier_history.length > 0 ? ( +
+
+ {data.supplier_history.map((s) => ( +
+ + {s.supplier_name || s.supplier_id} + + {" "} + · {s.days_ago}d ago · ×{s.times_bought} + + + {formatMoney(s.last_cost)} +
+ ))} +
+
+ ) : null} + + {data.recent_quote.length > 0 ? ( +
+
+ {data.recent_quote.slice(0, 2).map((q) => ( +
+ + {q.quote_number} + · {q.days_ago}d ago + + + {formatMoney(q.selling_price)} + +
+ ))} +
+
+ ) : null} + + {data.recent_order.length > 0 ? ( +
+
+ {data.recent_order.slice(0, 2).map((o) => ( +
+ + {o.order_number} + · {o.days_ago}d ago + + + {formatMoney(o.selling_price)} + +
+ ))} +
+
+ ) : null} + + ) : null} + + {!loading && !data ? ( +
+ No price data yet. Hit Refresh to compute it. +
+ ) : null} + +
+ + {data?.generated_at ? `computed ${data.generated_at.slice(0, 10)}` : ""} + + +
+
+
+
+ ); +}