refactor(item-history): trigger fetches new endpoint instead of inline read
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
7bc7208f8a
commit
cafc4c7962
1 changed files with 69 additions and 50 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue