- Introduced a new `DeliveryNoteRow` type to track delivery notes alongside items, improving the organization of delivery data. - Updated the `DeliveryListResponse` type to include delivery notes, allowing for better integration with the existing delivery management system. - Enhanced the `groupByCustomerOrder` function to accommodate delivery notes, ensuring they are grouped correctly with their respective orders. - Implemented state management for delivery notes in the `DeliveriesPage` component, including loading and error handling. - Added search functionality for delivery notes, enabling operators to filter by delivery note number, customer PO, and order details. - Updated the UI to display delivery notes alongside items, improving the overall user experience in the deliveries management interface. - Enhanced tests to cover new delivery notes functionality and ensure correct behavior across various scenarios.
521 lines
No EOL
20 KiB
TypeScript
521 lines
No EOL
20 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* item-intel-popover.tsx
|
||
*
|
||
* Renders the Item Intel payload (5 sections, flattened) inside a
|
||
* popover. Replaces the older `ItemHistoryPopover` content. Mirrors
|
||
* the layout of the order-sprint-process-modal "Recent Orders"
|
||
* section:
|
||
* - bold primary line (the product/supplier/quote/order name)
|
||
* - small uppercase SOURCE TAG ([STOCK] / [SUPPLIER] / [PRICE LIST]
|
||
* / [QUOTE] / [ORDER]) to the left of the primary
|
||
* - secondary line (SKU, description) below in muted text
|
||
* - inline pill row with `·` separators, supplier name coloured
|
||
* by supplierColor()
|
||
* - single Apply button right-aligned (where actionable)
|
||
*
|
||
* No section headers, no per-section accent colors, no summary
|
||
* line, no table columns. One flat list of cards.
|
||
*/
|
||
|
||
import {
|
||
Info,
|
||
Loader2,
|
||
CheckCircle2,
|
||
CircleDashed,
|
||
} from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { cn } from "@/lib/utils";
|
||
import { supplierColor } from "@/lib/supplier-color";
|
||
import getPocketBase from "@/lib/pocketbase";
|
||
import type { ItemIntel, ItemIntelVerified } from "@/lib/types";
|
||
|
||
interface Props {
|
||
intel: ItemIntel | null;
|
||
loading: boolean;
|
||
error: string | null;
|
||
/** `"quote_items"` or `"order_items"` — picks the PATCH route for apply. */
|
||
collection: "quote_items" | "order_items";
|
||
/** PB id of the item — used as the PATCH target. The popover
|
||
* prefers `intel.item.id` but falls back to this when the stored
|
||
* intel blob predates the n8n contract (no `item` field). */
|
||
itemId: string;
|
||
/** Called after a successful apply so the parent can refresh. */
|
||
onApplied?: (patch: Record<string, unknown>) => void;
|
||
}
|
||
|
||
export function ItemIntelPopover({
|
||
intel,
|
||
loading,
|
||
error,
|
||
collection,
|
||
itemId,
|
||
onApplied,
|
||
}: Props) {
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
Loading intel…
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-[10px] text-destructive">
|
||
Intel lookup failed: {error}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!intel) {
|
||
return (
|
||
<div className="p-3 text-xs text-muted-foreground">No intel available.</div>
|
||
);
|
||
}
|
||
|
||
// Defensive: the stored `ai_item_insights` might be the OLD n8n-written
|
||
// shape ({summary, last_quoted, last_order, stock, suppliers, ...})
|
||
// on items that pre-date the new service. Or it could be a partially-
|
||
// populated new-shape blob that's missing `item` (the target header)
|
||
// because the row was created via a code path that never set it.
|
||
// Treat either case as "no intel" rather than crashing.
|
||
const isNewShape =
|
||
typeof intel.item === "object" &&
|
||
intel.item !== null &&
|
||
typeof intel.item.id === "string" &&
|
||
Array.isArray(intel.stock_list) &&
|
||
Array.isArray(intel.supplier_history) &&
|
||
Array.isArray(intel.price_list) &&
|
||
Array.isArray(intel.recent_quote) &&
|
||
Array.isArray(intel.recent_order);
|
||
if (!isNewShape) {
|
||
return (
|
||
<div className="p-3 text-xs text-muted-foreground">No intel available (legacy data shape — run a refresh to upgrade).</div>
|
||
);
|
||
}
|
||
|
||
const noData =
|
||
intel.stock_list.length === 0 &&
|
||
intel.supplier_history.length === 0 &&
|
||
intel.price_list.length === 0 &&
|
||
intel.recent_quote.length === 0 &&
|
||
intel.recent_order.length === 0;
|
||
|
||
if (noData) {
|
||
if (intel.verified) {
|
||
return (
|
||
<div className="space-y-2">
|
||
<VerificationBadge verified={intel.verified} />
|
||
<div className="rounded-lg border border-dashed p-3 text-center text-[11px] text-muted-foreground">
|
||
<div className="font-medium text-foreground">No matches</div>
|
||
<div className="mt-1">
|
||
AI verified this item but found no stock, supplier history,
|
||
price list, quotes, or orders matching it.
|
||
</div>
|
||
<div className="mt-2 text-foreground">Pick a supplier, set cost, save.</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="rounded-lg border border-dashed p-3 text-center text-[11px] text-muted-foreground">
|
||
<div className="font-medium text-foreground">No intel yet for this item</div>
|
||
<div className="mt-1">
|
||
No stock, supplier history, price list, quotes, or orders found.
|
||
</div>
|
||
<div className="mt-2 text-foreground">Pick a supplier, set cost, save.</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// `||` (not `??`) on purpose: stored intel blobs from older service
|
||
// versions or n8n writes can have `item.id = ""`. The `??` operator
|
||
// only falls back on null/undefined, so an empty string would
|
||
// produce an invalid apply target. Falling back to any falsy value
|
||
// is safe — the prop is the authoritative id and the stored blob is
|
||
// just a hint about the target identity.
|
||
|
||
/** PATCH the parent quote/order item with the given payload via the
|
||
* user's PB session (so the browser's cookie auth works). The
|
||
* agent-authenticated `/api/v1/quote_items/[id]` route requires
|
||
* the AGENT_API_KEY bearer, which the browser doesn't carry. */
|
||
async function applyPatch(patch: Record<string, unknown>) {
|
||
const summary =
|
||
patch.description
|
||
? String(patch.description)
|
||
: patch.cost_price
|
||
? `cost R ${Number(patch.cost_price).toFixed(2)}`
|
||
: "pricing";
|
||
// Same fix as `resolvedPatchUrl` above — use `||` so empty-string
|
||
// `item.id` (from broken stored intel) falls back to the prop.
|
||
const targetId = intel?.item?.id || itemId;
|
||
console.log("[Apply]", {
|
||
collection,
|
||
targetId,
|
||
itemId,
|
||
intelItemId: intel?.item?.id,
|
||
patch,
|
||
});
|
||
if (!targetId) {
|
||
toast.error(
|
||
"Apply failed: missing item id (intel blob has no `item` and the trigger didn't pass one). Refresh and try again.",
|
||
);
|
||
return;
|
||
}
|
||
try {
|
||
const pb = getPocketBase();
|
||
const record = await pb.collection(collection).update(targetId, patch);
|
||
console.log("[Apply] success", { id: record.id, patch });
|
||
toast.success(`Applied ${summary}`);
|
||
onApplied?.(patch);
|
||
} catch (err) {
|
||
const message =
|
||
err instanceof Error ? err.message : "unknown error";
|
||
console.error("[Apply] failed", { targetId, error: err });
|
||
toast.error(`Apply failed: ${message}`);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{/* header — uses `intel.item` if present, otherwise falls back
|
||
to a placeholder built from the `itemId` we were given. */}
|
||
<div className="pb-2 border-b border-dashed">
|
||
<div className="text-xs font-semibold truncate">
|
||
{intel.item?.description ?? itemId}
|
||
</div>
|
||
<div className="text-[10px] font-mono text-muted-foreground truncate">
|
||
{intel.item
|
||
? `${intel.item.collection} · qty ${intel.item.quantity}${intel.item.sku ? ` · sku ${intel.item.sku}` : " · no sku"}${intel.item.supplier_name ? ` · ${intel.item.supplier_name}` : ""}`
|
||
: collection}
|
||
</div>
|
||
</div>
|
||
|
||
{/* verification status — set by the n8n AI verifier */}
|
||
<VerificationBadge verified={intel.verified} />
|
||
|
||
{/* Flat list — all 5 sections merged into a single stream.
|
||
Each card carries a SOURCE TAG so the operator can see at a
|
||
glance which kind of match it is. Order: stock → supplier
|
||
→ price list → quote → order. */}
|
||
<div className="space-y-1.5">
|
||
{[
|
||
...intel.stock_list.map((s): FlatRow => ({
|
||
key: `stock-${s.stock_item_id}`,
|
||
source: "STOCK",
|
||
sourceTone: "emerald",
|
||
primary: s.name,
|
||
secondary: s.sku,
|
||
supplierId: s.supplier_id,
|
||
supplierText: s.supplier_name || undefined,
|
||
pills: [
|
||
{ text: `qty ${s.qty}`, tone: s.qty > 0 ? "emerald" : "muted" },
|
||
{ text: s.cost_price ? `R ${s.cost_price.toFixed(2)}` : "—", tone: "muted" },
|
||
{ text: s.status.replace("_", " "), tone: statusTone(s.status) },
|
||
],
|
||
applyDisabled: true,
|
||
})),
|
||
...intel.supplier_history.map((s): FlatRow => ({
|
||
key: `supplier-${s.supplier_id}`,
|
||
source: "SUPPLIER",
|
||
sourceTone: "indigo",
|
||
primary: s.supplier_name,
|
||
secondary: s.stock_item_name || s.stock_item_sku,
|
||
supplierId: s.supplier_id,
|
||
// No supplierText — primary is already the name.
|
||
pills: [
|
||
{ text: `${s.days_ago}d ago`, tone: "muted" },
|
||
{ text: `R ${s.last_cost.toFixed(2)}`, tone: "emerald" },
|
||
{ text: `×${s.times_bought}`, tone: "muted" },
|
||
{ text: s.last_purchase_number, tone: "mono" },
|
||
],
|
||
onApply: () => applyPatch({
|
||
// Only include supplier when the source has one — an empty
|
||
// string would clear the parent item's supplier.
|
||
...(s.supplier_id && { supplier: s.supplier_id }),
|
||
cost_price: s.last_cost,
|
||
pricing_source: "stock_purchase",
|
||
pricing_notes: `Auto-applied from supplier ${s.supplier_name} PO ${s.last_purchase_number} for ${s.stock_item_name || s.stock_item_sku} (×${s.times_bought})`,
|
||
}),
|
||
})),
|
||
...intel.price_list.map((p): FlatRow => ({
|
||
key: `price-${p.sku}-${p.updated}`,
|
||
source: "PRICE LIST",
|
||
sourceTone: "amber",
|
||
primary: p.description,
|
||
secondary: p.sku,
|
||
supplierId: null, // price_list has no supplier FK; supplier is a name string
|
||
supplierText: p.supplier,
|
||
pills: [
|
||
{ text: `cost R ${p.cost_price.toFixed(2)}`, tone: "muted" },
|
||
{ text: `sell R ${p.selling_price.toFixed(2)}`, tone: "emerald" },
|
||
],
|
||
onApply: () => applyPatch({
|
||
cost_price: p.cost_price,
|
||
selling_price: p.selling_price,
|
||
pricing_source: "catalogue",
|
||
matched_price_list_id: p.sku,
|
||
pricing_notes: `Auto-applied from price list ${p.sku} (${p.supplier})`,
|
||
}),
|
||
})),
|
||
...intel.recent_quote.map((q): FlatRow => ({
|
||
key: `quote-${q.quote_item_id}`,
|
||
source: "QUOTE",
|
||
sourceTone: "sky",
|
||
primary: q.quote_number,
|
||
secondary: q.description,
|
||
supplierId: q.supplier_id || null,
|
||
supplierText: q.supplier_name || q.customer_name || q.company || "—",
|
||
pills: [
|
||
{ text: `${q.days_ago}d ago`, tone: "muted" },
|
||
{ text: q.quote_status, tone: quoteStatusTone(q.quote_status) },
|
||
{ text: `cost R ${q.cost_price.toFixed(2)}`, tone: "muted" },
|
||
{ text: `sell R ${q.selling_price.toFixed(2)}`, tone: "emerald" },
|
||
],
|
||
// Don't apply unpriced quote rows — applying selling_price=0
|
||
// would silently zero out the parent item's price.
|
||
applyDisabled: q.cost_price === 0 && q.selling_price === 0,
|
||
onApply: () => applyPatch({
|
||
// Only include supplier when the source quote_item has one —
|
||
// an empty string would clear the parent item's supplier.
|
||
...(q.supplier_id && { supplier: q.supplier_id }),
|
||
cost_price: q.cost_price,
|
||
selling_price: q.selling_price,
|
||
pricing_source: "quote_history",
|
||
pricing_notes: `Auto-applied from ${q.quote_number} (${q.days_ago}d ago)`,
|
||
}),
|
||
})),
|
||
...intel.recent_order.map((o): FlatRow => ({
|
||
key: `order-${o.order_item_id}`,
|
||
source: "ORDER",
|
||
sourceTone: "violet",
|
||
primary: o.order_number,
|
||
secondary: o.description,
|
||
supplierId: o.supplier_id || null,
|
||
supplierText: o.supplier_name || undefined,
|
||
pills: [
|
||
{ text: `${o.days_ago}d ago`, tone: "muted" },
|
||
{ text: o.item_status.replace("_", " "), tone: orderStatusTone(o.item_status) },
|
||
{ text: `cost R ${o.cost_price.toFixed(2)}`, tone: "muted" },
|
||
{ text: `sell R ${o.selling_price.toFixed(2)}`, tone: "emerald" },
|
||
],
|
||
onApply: () => applyPatch({
|
||
// Only include supplier when the source order_item has one —
|
||
// an empty string would clear the parent item's supplier.
|
||
...(o.supplier_id && { supplier: o.supplier_id }),
|
||
cost_price: o.cost_price,
|
||
selling_price: o.selling_price,
|
||
pricing_source: "order_history",
|
||
pricing_notes: `Auto-applied from ${o.order_number} (${o.days_ago}d ago)`,
|
||
}),
|
||
})),
|
||
].map((r) => (
|
||
<FlatCard
|
||
key={r.key}
|
||
source={r.source}
|
||
sourceTone={r.sourceTone}
|
||
primary={r.primary}
|
||
secondary={r.secondary}
|
||
supplierId={r.supplierId}
|
||
supplierText={r.supplierText}
|
||
pills={r.pills}
|
||
onApply={r.applyDisabled ? undefined : r.onApply}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
<p className="text-[10px] text-muted-foreground/70 pt-1">
|
||
refreshed {relTime(intel.generated_at)}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||
|
||
type PillTone = "muted" | "emerald" | "amber" | "red" | "indigo" | "sky" | "violet" | "mono";
|
||
|
||
/** One row in the flat intel list. All 5 sections (stock, supplier,
|
||
* price list, quote, order) are flattened into a single array of
|
||
* these; the `source` tag tells the operator which kind of match it
|
||
* is. */
|
||
type FlatRow = {
|
||
key: string;
|
||
source: string;
|
||
sourceTone: "emerald" | "indigo" | "amber" | "sky" | "violet";
|
||
primary: string;
|
||
secondary?: string;
|
||
/** supplierId → coloured via supplierColor() in the pill row. */
|
||
supplierId: string | null;
|
||
/** Static text fallback when no supplierId (price list + quote). */
|
||
supplierText?: string;
|
||
pills: Array<{ text: string; tone: PillTone }>;
|
||
onApply?: () => void;
|
||
applyDisabled?: boolean;
|
||
};
|
||
|
||
function FlatCard({
|
||
source,
|
||
sourceTone,
|
||
primary,
|
||
secondary,
|
||
supplierId,
|
||
supplierText,
|
||
pills,
|
||
onApply,
|
||
}: {
|
||
source: string;
|
||
sourceTone: "emerald" | "indigo" | "amber" | "sky" | "violet";
|
||
primary: string;
|
||
secondary?: string;
|
||
supplierId: string | null;
|
||
supplierText?: string;
|
||
pills: Array<{ text: string; tone: PillTone }>;
|
||
onApply?: () => void;
|
||
}) {
|
||
const sourceClass = {
|
||
emerald: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
|
||
indigo: "bg-indigo-500/15 text-indigo-700 dark:text-indigo-300",
|
||
amber: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
|
||
sky: "bg-sky-500/15 text-sky-700 dark:text-sky-400",
|
||
violet: "bg-violet-500/15 text-violet-700 dark:text-violet-400",
|
||
}[sourceTone];
|
||
return (
|
||
<div className="rounded-md border border-border/60 bg-card px-2.5 py-2 flex items-start gap-2">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-baseline gap-1.5">
|
||
<span
|
||
className={cn(
|
||
"shrink-0 rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider",
|
||
sourceClass,
|
||
)}
|
||
>
|
||
{source}
|
||
</span>
|
||
<span className="text-[12px] font-semibold leading-tight truncate">
|
||
{primary}
|
||
</span>
|
||
</div>
|
||
{secondary && (
|
||
<div className="mt-0.5 text-[11px] text-muted-foreground leading-tight truncate">
|
||
{secondary}
|
||
</div>
|
||
)}
|
||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[10px]">
|
||
{/* Supplier name (resolved from id by the service). Coloured
|
||
by supplierColor(id) for a stable per-supplier stripe.
|
||
The id itself is never shown — operators don't know what
|
||
pn93z9minsdhavt is. */}
|
||
{supplierText && (
|
||
<span
|
||
style={supplierId ? { color: supplierColor(supplierId) } : undefined}
|
||
className="font-medium"
|
||
>
|
||
{supplierText}
|
||
</span>
|
||
)}
|
||
{pills.map((p, i) => (
|
||
<Pill key={i} text={p.text} tone={p.tone} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
{onApply && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
onApply();
|
||
}}
|
||
className="shrink-0 rounded-md bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-700 dark:text-indigo-300 px-2.5 py-1 text-[11px] font-medium transition-colors self-center"
|
||
>
|
||
Apply
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Pill({ text, tone }: { text: string; tone: PillTone }) {
|
||
const cls = (() => {
|
||
switch (tone) {
|
||
case "muted": return "text-muted-foreground";
|
||
case "emerald": return "text-emerald-700 dark:text-emerald-400";
|
||
case "amber": return "text-amber-700 dark:text-amber-400";
|
||
case "red": return "text-red-700 dark:text-red-400";
|
||
case "indigo": return "text-indigo-700 dark:text-indigo-300";
|
||
case "sky": return "text-sky-700 dark:text-sky-300";
|
||
case "violet": return "text-violet-700 dark:text-violet-400";
|
||
case "mono": return "font-mono text-muted-foreground";
|
||
}
|
||
})();
|
||
return <span className={cls}>{text}</span>;
|
||
}
|
||
|
||
function statusTone(status: string): PillTone {
|
||
const s = status.toLowerCase();
|
||
if (s.includes("stock")) return "emerald";
|
||
if (s.includes("low")) return "amber";
|
||
if (s.includes("out") || s.includes("cancel") || s.includes("problem")) return "red";
|
||
return "muted";
|
||
}
|
||
function quoteStatusTone(status: string): PillTone {
|
||
const s = status.toLowerCase();
|
||
if (s.includes("approv")) return "emerald";
|
||
if (s.includes("problem") || s.includes("cancel")) return "red";
|
||
if (s.includes("sent") || s.includes("email")) return "indigo";
|
||
return "muted";
|
||
}
|
||
function orderStatusTone(status: string): PillTone {
|
||
const s = status.toLowerCase();
|
||
if (s.includes("deliver") || s.includes("receiv")) return "emerald";
|
||
if (s.includes("cancel") || s.includes("problem")) return "red";
|
||
if (s.includes("pending") || s.includes("pool")) return "amber";
|
||
return "muted";
|
||
}
|
||
|
||
/** Verification status banner — set by the operator's n8n AI verifier
|
||
* (see `ItemIntelVerified` in src/lib/types.ts). */
|
||
function VerificationBadge({ verified }: { verified?: ItemIntelVerified }) {
|
||
if (!verified) {
|
||
return (
|
||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/80">
|
||
<CircleDashed className="h-3 w-3" />
|
||
<span>Pending AI verification — showing raw fuzzy matches</span>
|
||
</div>
|
||
);
|
||
}
|
||
const removed = verified.removed_count > 0 ? ` · ${verified.removed_count} removed` : "";
|
||
return (
|
||
<div
|
||
className="flex items-center gap-1.5 text-[10px] text-emerald-700 dark:text-emerald-400"
|
||
title={verified.notes || undefined}
|
||
>
|
||
<CheckCircle2 className="h-3 w-3" />
|
||
<span>
|
||
Verified by {verified.by} · {relTime(verified.at)}{removed}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function relTime(iso: string): string {
|
||
const t = new Date(iso).getTime();
|
||
if (Number.isNaN(t)) return iso;
|
||
const diff = Date.now() - t;
|
||
const min = Math.round(diff / 60000);
|
||
if (min < 60) return `${min}m ago`;
|
||
const hr = Math.round(min / 60);
|
||
if (hr < 24) return `${hr}h ago`;
|
||
const d = Math.round(hr / 24);
|
||
if (d < 30) return `${d}d ago`;
|
||
const mo = Math.round(d / 30);
|
||
return `${mo}mo ago`;
|
||
}
|
||
|
||
// Silences the "Info icon import unused" warning while we keep the
|
||
// symbol available for future use.
|
||
void Info; |