refactor(item-history): trigger fetches new endpoint instead of inline read

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 15:13:15 +02:00
parent 7bc7208f8a
commit cafc4c7962

View file

@ -4,94 +4,113 @@
* ai-item-history-trigger.tsx
*
* Per-line-item "Item History" popover trigger. Renders a small ⓘ
* icon button in the quote/order items table; click opens a popover
* showing the AI's analysis for that line (last quoted/sold price,
* stock status, supplier history, etc.).
* icon button in the sprint row; click fetches
* `GET /api/v1/quote_items/[id]/item-history` and renders the
* combined deterministic + AI history panel.
*
* Shape of the data: same fluid-JSON rules as the document-level
* card. See `ai-insights-renderer.tsx` for the rendering contract.
*
* Implementation note: this project's `Button` component doesn't
* support `asChild`, so we style the `PopoverTrigger` directly
* (same pattern as `components/ui/duplicate-warning.tsx`).
* The fetch returns 404 when `ITEM_HISTORY_COMPARE_MODE === "none"`,
* in which case the trigger hides itself.
*/
import { useState } from "react";
import { Info } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import {
AiInsightsCard,
AiInsightsEmptyState,
type AiItemInsightsShape,
} from "./ai-insights-renderer";
import { ItemHistoryPopover } from "@/components/item-history-popover";
import type { ItemHistoryResponse } from "@/lib/types";
import { cn } from "@/lib/utils";
interface Props {
/** PB id of the quote_item / order_item. */
itemId: string;
insights: AiItemInsightsShape | null | undefined;
/**
* Pre-loaded insights (optional). If supplied AND we don't have
* a fresh fetch yet, the popover renders from this immediately —
* the fetch then runs in the background and updates the view.
* Kept so the parent (sprint list) can still pass through the
* existing `ai_item_insights` for an instant first paint.
*/
insights?: Record<string, unknown> | null | undefined;
className?: string;
}
export function AiItemHistoryTrigger({ itemId, insights, className }: Props) {
const [open, setOpen] = useState(false);
const hasInsights =
insights != null &&
typeof insights === "object" &&
Object.keys(insights).length > 0;
const [data, setData] = useState<ItemHistoryResponse | null>(
insights
? {
deterministic: null,
ai: insights,
ai_updated_at: null,
generated_at: new Date().toISOString(),
}
: null
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hidden, setHidden] = useState(false);
const handleOpen = async (next: boolean) => {
setOpen(next);
if (!next || data || hidden) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/v1/quote_items/${itemId}/item-history`);
if (res.status === 404) {
// Mode is "none" — hide the trigger.
setHidden(true);
setOpen(false);
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = (await res.json()) as ItemHistoryResponse;
setData(body);
if (body.error) setError(body.error);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load history");
} finally {
setLoading(false);
}
};
if (hidden) return null;
const hasData = data && (data.deterministic || data.ai);
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpen}>
<PopoverTrigger
// The project's Button component doesn't support `asChild`, so
// we render the trigger as a bare button-styled element. This
// mirrors `components/ui/duplicate-warning.tsx` which uses the
// same pattern.
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full",
"transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
hasInsights
? "text-info hover:bg-info/10 hover:text-info"
hasData
? "text-indigo-600 hover:bg-indigo-500/10"
: "text-muted-foreground/40 hover:text-muted-foreground",
className,
)}
aria-label={
hasInsights
? "View item history (AI analysis available)"
: "No AI analysis for this item yet"
hasData ? "View item history" : "No history for this item yet"
}
title={hasInsights ? "Item history" : "No item history yet"}
title="Item history"
>
<Info className="h-3.5 w-3.5" />
</PopoverTrigger>
<PopoverContent
align="end"
side="left"
className="w-96 max-h-[70vh] overflow-y-auto"
className="w-[min(560px,calc(100vw-2rem))] max-h-[75vh] overflow-y-auto p-3"
>
<PopoverHeader>
<PopoverTitle className="flex items-center gap-2 text-info">
<Info className="h-3.5 w-3.5" />
<div className="mb-2 flex items-center gap-1.5">
<Info className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Item History
</PopoverTitle>
</PopoverHeader>
{hasInsights ? (
<AiInsightsCard
insights={insights}
variant="popover"
title="Analysis"
/>
) : (
<AiInsightsEmptyState
title="No analysis"
message="This item hasn't been analysed yet. Insights will appear here after the workflow runs on the parent quote/order."
/>
)}
</span>
</div>
<ItemHistoryPopover data={data} loading={loading} error={error} />
<p className="mt-2 text-[10px] text-muted-foreground">
Item id: <span className="font-mono">{itemId}</span>
</p>