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:
parent
13220c3c43
commit
3248e3aa03
2 changed files with 408 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
398
src/components/price-check-marker.tsx
Normal file
398
src/components/price-check-marker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue