WFDASH-WEBAPP/src/components/order-sprint-process-modal.tsx
SJCoetz 2de8cf1a56 chore(context): lint + build adjustments
Co-authored-by: Orca <help@stably.ai>
2026-08-07 03:04:28 +02:00

718 lines
No EOL
27 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import {
Building2,
ChevronDown,
ChevronUp,
History,
Info,
Loader2,
Lock,
ShoppingCart,
Truck,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SupplierSearch } from "@/components/ui/supplier-search";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
import { cn } from "@/lib/utils";
import { supplierColor } from "@/lib/supplier-color";
import type { Supplier } from "@/lib/types";
import { MarginApprovalDialog } from "@/components/margin-approval-dialog";
import { OrderItemContextPanel, type ApplyPayload } from "@/components/order-item-context-panel";
const round2 = (n: number) => Math.round(n * 100) / 100;
const MARKUP_APPROVAL_THRESHOLD = 22;
interface Props {
item: OrderSprintItem | null;
suppliers: Supplier[];
open: boolean;
onClose: () => void;
onProcessed: () => void;
}
type OrderSprintItem = {
id: string;
description?: string;
quantity?: number | null;
order_qty?: number | null;
supplier?: string;
cost_price?: number;
selling_price?: number;
markup?: number;
sku?: string;
notes?: string;
category?: string;
po_description?: string;
item_status?: string;
ai_item_insights?: Record<string, unknown> | null;
};
export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProcessed }: Props) {
// Selling comes from the order and is fixed — never edited here. We
// capture it on mount via `item.selling_price` so the operator sees
// the price the customer was quoted.
const [supplierId, setSupplierId] = useState(item?.supplier || "");
const [cost, setCost] = useState(item?.cost_price || 0);
// qty that goes onto the PO. Defaults to order_qty (the customer's
// requested qty from the original quote); the operator can adjust
// for partial fills.
const [qty, setQty] = useState(item?.order_qty ?? item?.quantity ?? 1);
const [poDescription, setPoDescription] = useState(item?.po_description || item?.description || "");
const [notes, setNotes] = useState(item?.notes || "");
const [processing, setProcessing] = useState(false);
const [approvalPromptOpen, setApprovalPromptOpen] = useState(false);
const [approvalError, setApprovalError] = useState<string | null>(null);
const [suppliersExpanded, setSuppliersExpanded] = useState(true);
const [orderHistory, setOrderHistory] = useState<OrderHistoryItem[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [availableSuppliers, setAvailableSuppliers] = useState<AvailableSupplier[]>([]);
const [loadingSuppliers, setLoadingSuppliers] = useState(false);
const selling = item?.selling_price ?? 0;
const liveMarkup =
cost > 0 && selling > 0
? round2(((selling - cost) / cost) * 100)
: 0;
// Load order history and available suppliers when modal opens
useEffect(() => {
if (!open || !item) return;
let cancelled = false;
(async () => {
setLoadingHistory(true);
setLoadingSuppliers(true);
try {
const pb = getPocketBase();
const desc = item.description?.toLowerCase() || "";
const sku = item.sku || "";
const filter = sku ? `sku ~ "${sku}" || description ~ "${desc}"` : `description ~ "${desc}"`;
const historyRes = await pb.collection("order_items").getList(1, 10, {
filter: `${filter} && item_status != "pending" && selling_price > 0`,
sort: "-updated",
fields: "id,description,sku,supplier,cost_price,selling_price,markup,order,updated",
expand: "order",
});
if (!cancelled) {
setOrderHistory(
historyRes.items.map((i) => {
// Resolve the supplier id to a friendly name from the
// suppliers list we already have on hand — avoids an
// extra PB expand round-trip per history row.
const supplierMatch = suppliers.find((s) => s.id === i.supplier);
return {
id: i.id,
description: i.description,
sku: i.sku,
supplier: i.supplier,
supplierName: supplierMatch?.suppliername || undefined,
costPrice: i.cost_price,
sellingPrice: i.selling_price,
markup: i.markup,
orderNumber: (i.expand as { order?: { order_number?: string } })?.order?.order_number || "—",
date: i.updated,
};
})
);
}
} catch {
if (!cancelled) setOrderHistory([]);
} finally {
if (!cancelled) setLoadingHistory(false);
}
// Load available suppliers from price list
try {
const pb = getPocketBase();
const desc = item.description?.toLowerCase() || "";
const priceRes = await pb.collection("price_list").getList(1, 50, {
filter: `description ~ "${desc}" || brand ~ "${desc}"`,
fields: "id,description,supplier,last_cost,brand",
});
if (!cancelled) {
const supplierMap = new Map<string, AvailableSupplier>();
for (const p of priceRes.items) {
const sId = p.supplier as string;
if (!sId || supplierMap.has(sId)) continue;
const supplier = suppliers.find((s) => s.id === sId);
if (supplier) {
supplierMap.set(sId, {
id: sId,
name: supplier.suppliername || "—",
lastCost: p.last_cost as number,
brand: p.brand as string,
timesBought: 1,
});
}
}
setAvailableSuppliers(Array.from(supplierMap.values()));
}
} catch {
if (!cancelled) setAvailableSuppliers([]);
} finally {
if (!cancelled) setLoadingSuppliers(false);
}
})();
return () => { cancelled = true; };
}, [open, item, suppliers]);
// Apply supplier from history
const applySupplier = (s: AvailableSupplier) => {
setSupplierId(s.id);
if (s.lastCost) {
setCost(s.lastCost);
}
toast.success(`Applied supplier: ${s.name}`);
};
// Apply from order history — copy cost + selling as-is (selling is
// already fixed on the order, so we keep the historical selling;
// we only refresh cost if the item doesn't have one yet).
const applyFromHistory = (h: OrderHistoryItem) => {
const supplier = suppliers.find((s) => s.id === h.supplier);
if (supplier) setSupplierId(supplier.id);
if (h.costPrice) setCost(h.costPrice);
toast.success(`Applied from order ${h.orderNumber}`);
};
// Apply a row from the context panel. Sells only the cost (+ supplier
// when the row carries one). Selling stays locked to the order.
const applyContext = (p: ApplyPayload) => {
if (p.cost_price > 0) setCost(p.cost_price);
if (p.supplier_id) setSupplierId(p.supplier_id);
toast.success(`Applied cost from ${p.source_label}: R${p.cost_price.toFixed(2)}`);
};
// Process
const handleProcess = async () => {
if (!item) return;
if (!supplierId) {
toast.error("Pick a supplier before processing");
return;
}
const trimmedPoDescription = poDescription.trim();
const computedMarkup =
cost > 0 && selling > 0
? round2(((selling - cost) / cost) * 100)
: 0;
const storedMarkup = Math.max(0, computedMarkup);
// A below-threshold pending/rejected item must first be saved under
// the blocking status and sent to the manager. A processed item at
// the same price is the signal that n8n already approved it.
if (
cost > 0 &&
selling > 0 &&
computedMarkup < MARKUP_APPROVAL_THRESHOLD &&
item.item_status !== "processed"
) {
setApprovalError(null);
setApprovalPromptOpen(true);
return;
}
setProcessing(true);
try {
const pb = getPocketBase();
// Same readiness gate as /orders/[id]/process saveItem:
// pending → processed only when supplier + cost > 0 +
// po_description are all set. Items already past `processed`
// (stock_booked, po_generated, ready_delivery, etc.) are not
// downgraded. This is what makes the item appear on the
// buyer-tasks Needs PO tab.
const isReadyForBuyer =
supplierId.trim() !== "" &&
cost > 0 &&
trimmedPoDescription !== "";
const currentStatus = item.item_status ?? "pending";
const nextStatus =
(currentStatus === "pending" || currentStatus === "approval_rejected") &&
isReadyForBuyer
? "processed"
: currentStatus;
await pb.collection("order_items").update(item.id, {
supplier: supplierId,
cost_price: round2(cost),
selling_price: round2(selling),
markup: storedMarkup,
markup_percent: storedMarkup,
order_qty: qty,
po_description: trimmedPoDescription,
notes,
item_status: nextStatus,
});
toast.success("Item processed!");
onProcessed();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Processing failed");
} finally {
setProcessing(false);
}
};
const submitMarginApproval = async () => {
if (!item) return;
setProcessing(true);
setApprovalError(null);
try {
const response = await fetch(
`/api/v1/order-items/${item.id}/request-margin-approval`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source: "order_sprint",
draft: {
description: item.description || "",
supplier: supplierId,
cost_price: round2(cost),
selling_price: round2(selling),
order_qty: qty,
po_description: poDescription.trim(),
notes,
},
}),
},
);
const result = (await response.json().catch(() => ({}))) as {
error?: string;
webhookFired?: boolean;
webhookError?: string | null;
};
if (!response.ok) {
throw new Error(result.error || `Approval request failed (${response.status})`);
}
if (result.webhookFired === false) {
toast.warning("Draft saved and blocked, but the approval webhook failed", {
description: result.webhookError || "Check the n8n workflow and retry.",
duration: 12000,
});
} else {
toast.success("Approval requested", {
description: "The item is blocked until a manager approves it.",
});
}
setApprovalPromptOpen(false);
onProcessed();
} catch (err) {
setApprovalError(
err instanceof Error ? err.message : "Could not request approval",
);
} finally {
setProcessing(false);
}
};
// Esc closes
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open || !item) return null;
return (
<>
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 p-0 sm:p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<Card className="w-full max-w-4xl h-[100dvh] sm:h-auto sm:max-h-[90vh] flex flex-col rounded-none sm:rounded-lg shadow-2xl border-0 sm:border-2 border-warn/40 border-t-warn/40 shadow-warn/15">
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 border-b bg-gradient-to-b from-muted/40 to-transparent">
<div className="min-w-0 flex-1">
<CardTitle className="text-sm flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-primary" />
Process Order Item
</CardTitle>
<p className="mt-1 text-sm font-medium">{item.description || "(no description)"}</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<X className="h-4 w-4" />
</button>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto space-y-5 p-4 sm:p-5">
{/* Context — deterministic history surfaced from PB. Replaces the
AI Insights section. Selling stays locked; Apply copies cost. */}
<OrderItemContextPanel
itemId={item.id}
onApply={applyContext}
/>
{/* Order History */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<History className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Recent Orders ({orderHistory.length})
</span>
</div>
{loadingHistory && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
</div>
{orderHistory.length > 0 ? (
<div className="space-y-2">
{orderHistory.slice(0, 5).map((h) => (
<div
key={h.id}
className="flex items-center gap-3 p-2.5 rounded-lg border border-border/60 bg-muted/20 hover:bg-muted/40 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">{h.description}</div>
<div className="text-[10px] text-muted-foreground flex items-center gap-2 flex-wrap">
{h.supplierName && (
<>
<span
className="font-medium"
style={{ color: supplierColor(h.supplier) }}
>
{h.supplierName}
</span>
<span aria-hidden="true">·</span>
</>
)}
<span className="font-mono">{h.orderNumber}</span>
<span aria-hidden="true">·</span>
<span>R{h.costPrice?.toFixed(2) || "—"}</span>
<span aria-hidden="true">·</span>
<span>R{h.sellingPrice?.toFixed(2) || "—"}</span>
{h.markup != null && (
<>
<span aria-hidden="true">·</span>
<span>{h.markup}%</span>
</>
)}
</div>
</div>
<Button
size="xs"
variant="outline"
onClick={() => applyFromHistory(h)}
className="border-border/60"
>
Apply
</Button>
</div>
))}
</div>
) : (
!loadingHistory && (
<p className="text-xs text-muted-foreground italic">
No order history found.
</p>
)
)}
</section>
{/* Available Suppliers */}
<section className="space-y-2">
<button
type="button"
onClick={() => setSuppliersExpanded(v => !v)}
aria-expanded={suppliersExpanded}
className="flex items-center gap-2 w-full text-left"
>
{suppliersExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<Truck className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Available Suppliers ({availableSuppliers.length})
</span>
</button>
{suppliersExpanded && (
<div className="pl-6">
{loadingSuppliers ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Searching…
</div>
) : availableSuppliers.length > 0 ? (
<div className="space-y-2">
{availableSuppliers.map((s) => (
<div
key={s.id}
className="flex items-center gap-3 p-2.5 rounded-lg border border-border/60 hover:bg-muted/30 hover:border-primary/30 transition-colors"
>
<Building2 className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium">{s.name}</div>
{s.lastCost && (
<div className="text-[10px] text-muted-foreground">
Last cost: R{s.lastCost.toFixed(2)}
{s.brand && ` · ${s.brand}`}
</div>
)}
</div>
<Button
size="xs"
variant="outline"
onClick={() => applySupplier(s)}
className="border-border/60"
>
Use
</Button>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground italic">
No suppliers found in price list.
</p>
)}
</div>
)}
</section>
{/* Pricing — qty + cost + selling (read-only) + markup
(derived). Selling is locked because it comes from the
order; markup is derived from selling/cost so the
operator sees the margin they're pricing at. */}
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="space-y-1.5">
<Label htmlFor="order-sprint-qty" className="text-xs">
PO Qty
</Label>
<Input
id="order-sprint-qty"
type="number"
min={1}
value={qty || ""}
onChange={(e) => setQty(Math.max(1, Number(e.target.value) || 1))}
className="h-9 tabular-nums"
placeholder="1"
/>
<p className="text-[10px] text-muted-foreground">
From order: {item.order_qty ?? item.quantity ?? 1}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="order-sprint-cost" className="text-xs">
Cost (ZAR)
</Label>
<Input
id="order-sprint-cost"
type="number"
value={cost || ""}
onChange={(e) => setCost(Number(e.target.value) || 0)}
className="h-9 tabular-nums"
placeholder="0.00"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs flex items-center gap-1">
<Lock className="h-3 w-3 text-muted-foreground" />
Selling (ZAR)
</Label>
<div
className="flex h-9 items-center rounded-md border border-dashed border-border/60 bg-muted/30 px-3 text-sm tabular-nums text-foreground"
aria-label="Selling price (from order, read-only)"
title="Selling comes from the order and is not editable here"
>
{selling > 0 ? `R${selling.toFixed(2)}` : <span className="text-muted-foreground italic">not set</span>}
</div>
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
From order · not editable
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs flex items-center gap-1">
<Lock className="h-3 w-3 text-muted-foreground" />
Markup
</Label>
<div
className="flex h-9 items-center rounded-md border border-dashed border-border/60 bg-muted/30 px-3 text-sm tabular-nums"
aria-label="Markup (derived from cost and selling, read-only)"
title="Derived from selling vs cost"
>
{cost > 0 && selling > 0 ? (
<span
className={cn(
"font-medium",
selling >= cost ? "text-emerald-700 dark:text-emerald-400" : "text-destructive",
)}
>
{liveMarkup.toFixed(1)}%
</span>
) : (
<span className="text-muted-foreground italic">—</span>
)}
</div>
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
Derived · not editable
</p>
</div>
</section>
{/* Supplier */}
<section className="space-y-1.5">
<Label className="text-xs">Supplier</Label>
<SupplierSearch
value={supplierId}
onValueChange={setSupplierId}
suppliers={suppliers}
placeholder="Pick the supplier…"
ariaLabel="Supplier"
/>
</section>
{/* PO description — what the supplier sees on the PO. Defaults
to the order description so the buyer-tasks pending →
processed transition fires for the common no-op case.
Mirrors the canonical /orders/[id]/process gate. The
order's original item description is shown read-only
above the textarea as a reference, so the operator can
see what the customer asked for vs what they're sending
to the supplier. */}
<section className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">PO description</Label>
{poDescription.trim() !== (item.description || "").trim() && (
<button
type="button"
onClick={() => setPoDescription(item.description || "")}
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
↺ reset to order description
</button>
)}
</div>
{item.description && (
<div
className="rounded-md border border-dashed border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground"
aria-label="Order description (read-only)"
>
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Order description
</span>
<div className="mt-0.5 text-foreground/80 italic">
{item.description}
</div>
</div>
)}
<Textarea
value={poDescription}
onChange={(e) => setPoDescription(e.target.value)}
rows={2}
className="text-sm"
placeholder="Description the supplier sees on the PO"
/>
</section>
{/* Notes */}
<section className="space-y-1.5">
<Label className="text-xs">Notes</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="text-sm"
placeholder="Internal notes…"
/>
</section>
</CardContent>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between border-t bg-muted/20 px-4 py-3 sm:px-5">
<p className="text-[11px] text-muted-foreground">
{item.item_status === "pending"
? "Save marks this item processed and surfaces it on the buyer-tasks Needs PO tab."
: `Current status: ${item.item_status ?? "pending"}. Save keeps it there.`}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onClose}
disabled={processing}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleProcess}
disabled={processing}
className="bg-success text-success-foreground hover:bg-success/90 shadow-sm"
>
{processing && <Loader2 className="h-3 w-3 mr-1 animate-spin" />}
<ShoppingCart className="h-3 w-3 mr-1" />
Process Item
</Button>
</div>
</div>
</Card>
</div>
<MarginApprovalDialog
open={approvalPromptOpen}
description={item.description || "Order item"}
costPrice={round2(cost)}
sellingPrice={round2(selling)}
markup={liveMarkup}
threshold={MARKUP_APPROVAL_THRESHOLD}
busy={processing}
error={approvalError}
onOpenChange={(nextOpen) => {
setApprovalPromptOpen(nextOpen);
if (!nextOpen) setApprovalError(null);
}}
onConfirm={() => void submitMarginApproval()}
/>
</>
);
}
interface OrderHistoryItem {
id: string;
description: string;
sku?: string;
supplier?: string;
/** Resolved supplier name (from the modal's `suppliers` prop) so
* the recent-orders list can show "who supplied this last time"
* without expanding the PB relation. */
supplierName?: string;
costPrice?: number;
sellingPrice?: number;
markup?: number;
orderNumber: string;
date: string;
}
interface AvailableSupplier {
id: string;
name: string;
lastCost?: number;
brand?: string;
timesBought?: number;
}