"use client"; import { useState, useCallback } from "react"; import useSWR from "swr"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { Factory, Package, AlertTriangle, CheckCircle2, ShoppingCart, Download, Calculator, Layers, Euro, CircleAlert, Plus, X, Info, ClipboardList, MapPin, Check, Eye, Trash2, FileDown, History, } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Checkbox } from "@/components/ui/checkbox"; import { useTableSort } from "@/hooks/use-table-sort"; import { SortableTableHead } from "@/components/sortable-table-head"; import { toast } from "sonner"; import { formatCurrency, formatUnitPrice } from "@/lib/format"; import type { Product } from "@/lib/types"; const fetcher = (url: string) => fetch(url).then((r) => r.json()); interface ProductEntry { id: string; productId: string; batchSize: string; } interface SourceInfo { product_name: string; sku: string; qty_per_unit: number; batch_size: number; subtotal: number; } interface AggregatedItem { component_id: number; part_number: string; component_name: string; manufacturer: string | null; supplier: string | null; supplier_part_number: string | null; shelf_location: string | null; unit_price: number; stock_quantity: number; package_type: string | null; category_name: string | null; is_internal: boolean; total_needed: number; shortage: number; line_cost: number; sources: SourceInfo[]; } interface PurchaseItem { component_id: number; part_number: string; component_name: string; manufacturer: string | null; supplier: string; supplier_part_number: string; shelf_location: string; unit_price: number; stock_quantity: number; total_needed: number; shortage: number; purchase_cost: number; category_name: string | null; sources: SourceInfo[]; } interface ProductSummaryEntry { product: Product; batchSize: number; totalCost: number; totalParts: number; itemCount: number; } interface ProductionData { entries: ProductSummaryEntry[]; summary: { totalProducts: number; totalComponents: number; totalParts: number; totalCost: number; shortageCount: number; allInStock: boolean; }; items: AggregatedItem[]; purchaseList: PurchaseItem[]; } let entryCounter = 0; function nextId() { entryCounter++; return `entry-${entryCounter}`; } export default function ProductionPlanningPage() { const [entries, setEntries] = useState([ { id: nextId(), productId: "", batchSize: "1" }, ]); const [planData, setPlanData] = useState(null); const [isLoading, setIsLoading] = useState(false); const { data: products = [] } = useSWR("/api/products", fetcher); const { data: historyRaw, mutate: mutateHistory } = useSWR[]>("/api/planning-history", fetcher); const history = Array.isArray(historyRaw) ? historyRaw : []; const { sorted: sortedHistory, sort: historySort, toggleSort: toggleHistorySort } = useTableSort(history); const [accepting, setAccepting] = useState(false); const [viewPlan, setViewPlan] = useState | null>(null); const [viewPlanItems, setViewPlanItems] = useState[]>([]); const [viewTab, setViewTab] = useState<"pick" | "purchase">("pick"); const [deleteTarget, setDeleteTarget] = useState(null); const [selectedPoItems, setSelectedPoItems] = useState>(new Set()); const [generatingPo, setGeneratingPo] = useState(false); const [linkPoIds, setLinkPoIds] = useState>(new Set()); // Fetch active supplier POs for linking const { data: activePos = [] } = useSWR[]>( "/api/supplier-pos?status=active", fetcher ); const handleAcceptPlanning = useCallback(async () => { if (!planData?.items?.length) return; setAccepting(true); try { const res = await fetch("/api/production/accept", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: `Production Plan ${new Date().toLocaleDateString("en-GB", { day: "2-digit", month: "2-digit", year: "numeric" })}`, items: planData.items.map((item) => ({ component_id: item.component_id, part_number: item.part_number, component_name: item.component_name, category_name: item.category_name, package_type: item.package_type, shelf_location: item.shelf_location, unit_price: item.unit_price, stock_quantity: item.stock_quantity, total_needed: item.total_needed, shortage: item.shortage, is_internal: item.is_internal, manufacturer: item.manufacturer, supplier: item.supplier, supplier_part_number: item.supplier_part_number, sources: item.sources, })), products: planData.entries.map((e) => ({ product_name: e.product.name, sku: e.product.sku, batch_size: e.batchSize, })), summary: planData.summary, link_po_ids: Array.from(linkPoIds), }), }); if (!res.ok) { const err = await res.json(); toast.error(err.error || "Failed to accept planning"); return; } toast.success("Planning accepted! History saved and stock updated."); mutateHistory(); // Auto-download pick list CSV (external components only) downloadPickListCSV(planData.items, "pick-list"); setPlanData(null); setEntries([{ id: nextId(), productId: "", batchSize: "1" }]); setLinkPoIds(new Set()); } catch { toast.error("Failed to accept planning"); } finally { setAccepting(false); } }, [planData, mutateHistory, linkPoIds]); function downloadPickListCSV(items: (AggregatedItem | Record)[], prefix: string) { const externalItems = items.filter((i) => !(i as Record).is_internal); const sorted = [...externalItems].sort((a, b) => { const locA = ((a as Record).shelf_location as string) || "ZZZ"; const locB = ((b as Record).shelf_location as string) || "ZZZ"; return locA.localeCompare(locB); }); const headers = ["Shelf Location", "Part Number", "Component Name", "Category", "Package", "Qty to Pick", "Notes"]; const rows = sorted.map((item) => { const r = item as Record; return [ (r.shelf_location as string) || "N/A", r.part_number as string, `"${r.component_name}"`, (r.category_name as string) || "", (r.package_type as string) || "", r.total_needed ?? r.quantity_needed, "", ]; }); const csv = [headers, ...rows].map((row) => row.join(",")).join("\n"); triggerDownload(csv, `${prefix}-${new Date().toISOString().slice(0, 10)}.csv`); } function downloadPurchaseListCSV(items: Record[]) { const shortageItems = items.filter((i) => Number(i.shortage) > 0); const headers = [ "Part Number", "Component", "Supplier", "Supplier Part No.", "Qty to Purchase", ]; const rows = shortageItems.map((item) => [ item.part_number, `"${item.component_name}"`, (item.supplier as string) || "N/A", (item.supplier_part_number as string) || "N/A", item.shortage, ]); const csv = [headers, ...rows].map((row) => row.join(",")).join("\n"); triggerDownload(csv, `purchase-list-${viewPlan?.id || "export"}.csv`); } function triggerDownload(csv: string, filename: string) { const blob = new Blob([csv], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } async function handleViewPlan(id: number) { try { const res = await fetch(`/api/planning-history/${id}`); const data = await res.json(); setViewPlan(data); setViewPlanItems(data.items || []); setViewTab("pick"); setSelectedPoItems(new Set()); } catch { toast.error("Failed to load planning detail"); } } async function handleDeletePlan() { if (!deleteTarget) return; try { await fetch(`/api/planning-history/${deleteTarget}`, { method: "DELETE" }); toast.success("Planning record deleted"); mutateHistory(); setDeleteTarget(null); } catch { toast.error("Failed to delete planning record"); } } async function handleGeneratePOs() { if (selectedPoItems.size === 0) return; const shortageItems = viewPlanItems.filter( (i) => Number(i.shortage) > 0 && selectedPoItems.has(i.id as number) ).filter((i) => { // Only allow items without an active (non-cancelled) PO const poNumber = i.po_number as string | null; const poStatus = i.po_status as string | null; return !poNumber || poStatus === "cancelled"; }); if (shortageItems.length === 0) { toast.error("All selected items already have active POs."); return; } // Group by supplier const bySupplier = new Map[] }>(); for (const item of shortageItems) { const supplierName = (item.supplier as string) || "Unknown Supplier"; // We need to look up supplier_id from the suppliers list if (!bySupplier.has(supplierName)) { bySupplier.set(supplierName, { supplier_id: null, supplier_name: supplierName, items: [] }); } bySupplier.get(supplierName)!.items.push(item); } // Fetch suppliers to get IDs try { setGeneratingPo(true); const suppRes = await fetch("/api/suppliers"); const suppliers: Record[] = await suppRes.json(); const orders = []; for (const [name, group] of bySupplier.entries()) { const match = suppliers.find( (s) => (s.name as string)?.toLowerCase() === name.toLowerCase() ); if (!match) { toast.error(`Supplier "${name}" not found in the system. Please create it first.`); setGeneratingPo(false); return; } orders.push({ supplier_id: match.id, supplier_name: name, items: group.items.map((i) => ({ component_id: i.component_id, part_number: i.part_number, component_name: i.component_name, supplier_part_number: i.supplier_part_number, quantity: Number(i.shortage), unit_price: Number(i.unit_price), })), }); } const res = await fetch("/api/supplier-pos", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ orders, planning_id: viewPlan?.id, }), }); if (!res.ok) { const err = await res.json(); toast.error(err.error || "Failed to generate POs"); return; } const created: Record[] = await res.json(); toast.success( `${created.length} Purchase Order${created.length !== 1 ? "s" : ""} created: ${created.map((po) => po.po_number).join(", ")}` ); setSelectedPoItems(new Set()); } catch { toast.error("Failed to generate purchase orders"); } finally { setGeneratingPo(false); } } const addEntry = useCallback(() => { setEntries((prev) => [ ...prev, { id: nextId(), productId: "", batchSize: "1" }, ]); }, []); const removeEntry = useCallback((id: string) => { setEntries((prev) => { if (prev.length <= 1) return prev; return prev.filter((e) => e.id !== id); }); }, []); const updateEntry = useCallback( (id: string, field: "productId" | "batchSize", value: string) => { setEntries((prev) => prev.map((e) => (e.id === id ? { ...e, [field]: value } : e)) ); setPlanData(null); }, [] ); const canCalculate = entries.some( (e) => e.productId && Number(e.batchSize) >= 1 ); const handleCalculate = useCallback(async () => { const validEntries = entries .filter((e) => e.productId && Number(e.batchSize) >= 1) .map((e) => ({ productId: Number(e.productId), batchSize: Number(e.batchSize), })); if (validEntries.length === 0) return; setIsLoading(true); try { const res = await fetch("/api/production", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ entries: validEntries }), }); const data = await res.json(); setPlanData(data); } finally { setIsLoading(false); } }, [entries]); function handleExportCSV() { if (!planData?.purchaseList?.length) return; const headers = [ "Part Number", "Component", "Supplier", "Supplier Part No.", "Qty to Purchase", ]; const rows = planData.purchaseList.map((item) => [ item.part_number, `"${item.component_name}"`, item.supplier, item.supplier_part_number, item.shortage, ]); const csv = [headers, ...rows].map((row) => row.join(",")).join("\n"); triggerDownload(csv, `purchase-list-${new Date().toISOString().slice(0, 10)}.csv`); } function handleExportPickList() { if (!planData?.items?.length) return; const headers = [ "Shelf Location", "Part Number", "Component Name", "Category", "Package", "Qty to Pick", "Needed For", ]; const sorted = [...planData.items].sort((a, b) => { const locA = a.shelf_location || "ZZZ"; const locB = b.shelf_location || "ZZZ"; return locA.localeCompare(locB); }); const rows = sorted.map((item) => [ item.shelf_location || "N/A", item.part_number, `"${item.component_name}"`, item.category_name || "", item.package_type || "", item.total_needed, `"${item.sources.map((s) => `${s.sku} x${s.subtotal}`).join(", ")}"`, ]); const csv = [headers, ...rows].map((row) => row.join(",")).join("\n"); const blob = new Blob([csv], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `pick-list-${new Date().toISOString().slice(0, 10)}.csv`; a.click(); URL.revokeObjectURL(url); } const selectedProductIds = entries.map((e) => e.productId).filter(Boolean); return (
{/* Header */}

Production Planning

Calculate combined component requirements for one or more production batches and generate a consolidated purchase list

{/* Batch Configuration */} Batch Configuration {entries.map((entry, idx) => (
{idx + 1}
{idx === 0 && ( )}
{idx === 0 && ( )} updateEntry(entry.id, "batchSize", e.target.value) } onKeyDown={(e) => { if (e.key === "Enter") handleCalculate(); }} placeholder="e.g. 100" />
))}
{/* Loading */} {isLoading && (
Calculating production requirements...
)} {/* Results */} {planData && !isLoading && ( <> {/* Per-Product Breakdown */} {planData.entries.length > 1 && ( Per-Product Breakdown Product SKU Batch Size BOM Items Total Parts Batch Cost {planData.entries.map((entry) => ( {entry.product.name} {entry.product.sku} {entry.batchSize.toLocaleString()} {entry.itemCount} {entry.totalParts.toLocaleString()} {formatCurrency(entry.totalCost)} ))} Combined Totals {planData.summary.totalParts.toLocaleString()} {formatCurrency(planData.summary.totalCost)}
)} {/* Summary Cards */}

Unique Components

{planData.summary.totalComponents}

Total Parts Needed

{planData.summary.totalParts.toLocaleString()}

Total Batch Cost

{formatCurrency(planData.summary.totalCost)}

{planData.summary.allInStock ? ( ) : ( )}

Stock Status

{planData.summary.allInStock ? (

All In Stock

) : (

{planData.summary.shortageCount} Shortage {planData.summary.shortageCount !== 1 ? "s" : ""}

)}
{/* Pick List for Production */} {planData.items.length > 0 && ( Pick List - Components to Collect from Shelves {planData.items.length} item{planData.items.length !== 1 ? "s" : ""}
Shelf Location Part Number Component Category Package Qty to Pick In Stock Stock Status {planData.entries.length > 1 && ( Needed By )} {[...planData.items] .sort((a, b) => { const locA = a.shelf_location || "ZZZ"; const locB = b.shelf_location || "ZZZ"; return locA.localeCompare(locB); }) .map((item) => { const hasShortage = Number(item.shortage) > 0; const canPick = Math.min(Number(item.total_needed), Number(item.stock_quantity)); return ( {item.shelf_location ? ( {item.shelf_location} ) : ( No location set )} {item.part_number} {item.component_name} {item.category_name && ( {item.category_name} )} {item.package_type || "-"} {Number(item.total_needed).toLocaleString()} {Number(item.stock_quantity).toLocaleString()} {hasShortage ? ( Short {Number(item.shortage).toLocaleString()}

Can pick {canPick.toLocaleString()} from stock. Need to purchase {Number(item.shortage).toLocaleString()} more.

) : ( Available )}
{planData.entries.length > 1 && ( {item.sources .map((s) => `${s.sku}: ${s.subtotal}`) .join(", ")} )}
); })}
)} {/* Purchase List */} {planData.purchaseList.length > 0 && ( Purchase List ({planData.purchaseList.length} item {planData.purchaseList.length !== 1 ? "s" : ""} to acquire)
Part Number Component Manufacturer Supplier Supplier Part No. Unit Price Total Needed In Stock Qty to Purchase Purchase Cost {planData.purchaseList.map((item) => ( {item.part_number}
{item.component_name} {planData.entries.length > 1 && ( {item.sources .map( (s) => `${s.sku}: ${s.subtotal}` ) .join(", ")} )}
{item.manufacturer || "-"} {item.supplier !== "N/A" ? ( {item.supplier} ) : ( Not set )} {item.supplier_part_number !== "N/A" ? ( {item.supplier_part_number} ) : ( Not set )} {formatUnitPrice(item.unit_price)} {item.total_needed.toLocaleString()} {item.stock_quantity.toLocaleString()} {item.shortage.toLocaleString()} {formatCurrency(item.purchase_cost)}
))} {/* Total row */} Total Purchase Cost {planData.purchaseList .reduce((s, i) => s + i.shortage, 0) .toLocaleString()} {formatCurrency( planData.purchaseList.reduce( (s, i) => s + i.purchase_cost, 0 ) )}
)} {/* All in stock message */} {planData.purchaseList.length === 0 && planData.items.length > 0 && (

All components are in stock

You have enough inventory to produce all planned batches without additional purchases.

)} {/* Accept Planning */} {planData.items.length > 0 && (

Accept Production Planning

This will save the full production plan to history (pick list + purchase list), download the pick list CSV, and decrement component stock for all BOM items.

{/* Link Supplier POs - Required */}

Link Supplier POs *

Select at least one Supplier PO to link with this production plan.

{activePos.length > 0 ? ( <>
{activePos.map((po) => { const isSelected = linkPoIds.has(po.id as number); return ( ); })}
{linkPoIds.size > 0 ? (

{linkPoIds.size} PO{linkPoIds.size !== 1 ? "s" : ""} selected

) : (

Please select at least one PO to continue

)} ) : (

No active Supplier POs available. Create a PO from the Supplier POs page first.

)}
)} )} {/* Planning History */} Planning History {history.length > 0 && ( {history.length} )} {history.length === 0 ? (
No production plans accepted yet. Calculate requirements and accept a plan to create history.
) : ( Products Actions {sortedHistory.map((plan) => { const pj = plan.products_json; const prods: Record[] = Array.isArray(pj) ? pj : typeof pj === "string" ? (() => { try { return JSON.parse(pj); } catch { return []; } })() : []; return ( {plan.name as string} {prods.map((p) => `${p.sku} x${p.batch_size}`).join(", ") || "-"} {String(plan.item_count)} {Number(plan.total_quantity).toLocaleString()} {Number(plan.shortage_count) > 0 ? ( {String(plan.shortage_count)} ) : ( 0 )} {new Date(plan.created_at as string).toLocaleDateString("en-GB", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", })}
); })}
)}
{/* Empty state */} {!planData && !isLoading && history.length === 0 && (

Add products and batch sizes to get started

The planner will calculate combined component requirements across all products and highlight shortages

)}
{/* View Planning Detail Dialog */} !open && setViewPlan(null)}> {viewPlan?.name as string}
Created: {viewPlan ? new Date(viewPlan.created_at as string).toLocaleDateString("en-GB", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }) : ""} {viewPlanItems.length} components
{/* Tab switcher */}
{viewTab === "pick" && ( )} {viewTab === "purchase" && viewPlanItems.filter((i) => Number(i.shortage) > 0).length > 0 && ( )}
{/* Pick List Tab */} {viewTab === "pick" && ( <>

External components to collect from shelves (internal production components excluded).

Shelf Part Number Component Category Package Qty to Pick Notes {viewPlanItems .filter((i) => !i.is_internal) .sort((a, b) => { const locA = (a.shelf_location as string) || "ZZZ"; const locB = (b.shelf_location as string) || "ZZZ"; return locA.localeCompare(locB); }) .map((item) => ( {item.shelf_location ? ( {item.shelf_location as string} ) : ( N/A )} {item.part_number as string} {item.component_name as string} {item.category_name && ( {item.category_name as string} )} {(item.package_type as string) || "-"} {Number(item.quantity_needed).toLocaleString()} - ))} {viewPlanItems.filter((i) => !i.is_internal).length === 0 && ( No external components in this plan. )}
)} {/* Purchase List Tab */} {viewTab === "purchase" && (() => { const shortageItems = viewPlanItems.filter((i) => Number(i.shortage) > 0); // Items with an active (non-cancelled) PO cannot have a new PO generated const hasActivePo = (item: Record) => { const poNumber = item.po_number as string | null; const poStatus = item.po_status as string | null; return !!poNumber && poStatus !== "cancelled"; }; const selectableItems = shortageItems.filter((i) => !hasActivePo(i)); const allSelected = selectableItems.length > 0 && selectableItems.every((i) => selectedPoItems.has(i.id as number)); const someSelected = selectableItems.some((i) => selectedPoItems.has(i.id as number)); // Group selected items by supplier for summary (only selectable items) const selectedItems = selectableItems.filter((i) => selectedPoItems.has(i.id as number)); const supplierGroups = new Map(); selectedItems.forEach((i) => { const s = (i.supplier as string) || "Unknown Supplier"; supplierGroups.set(s, (supplierGroups.get(s) || 0) + 1); }); return ( <>

Components with insufficient stock that need to be purchased. Select items to generate Purchase Orders grouped by supplier.

{/* Generate PO bar */} {someSelected && (
{selectedPoItems.size} item{selectedPoItems.size !== 1 ? "s" : ""} selected {supplierGroups.size > 0 && ( ({supplierGroups.size} supplier{supplierGroups.size !== 1 ? "s" : ""}: {Array.from(supplierGroups.entries()).map(([name, count]) => `${name} (${count})`).join(", ")}) )}
)}
{ if (checked) { setSelectedPoItems(new Set(selectableItems.map((i) => i.id as number))); } else { setSelectedPoItems(new Set()); } }} aria-label="Select all items" /> Part Number Component Supplier Supplier Part No. Qty to Purchase PO Status {shortageItems.map((item) => { const itemHasActivePo = hasActivePo(item); const isSelected = selectedPoItems.has(item.id as number); const poNumber = item.po_number as string | null; const poStatus = item.po_status as string | null; return ( { setSelectedPoItems((prev) => { const next = new Set(prev); if (checked) { next.add(item.id as number); } else { next.delete(item.id as number); } return next; }); }} aria-label={`Select ${item.component_name}`} /> {itemHasActivePo && (

PO already exists ({poNumber}). Cancel it first to generate a new one.

)}
{item.part_number as string} {item.component_name as string} {(item.supplier as string) || N/A} {(item.supplier_part_number as string) || "-"} {Number(item.shortage).toLocaleString()} {poNumber ? ( {poStatus === "cancelled" ? "Cancelled" : poStatus === "partially_received" ? "Partial" : poStatus === "received" ? "Received" : poStatus === "ordered" ? "Ordered" : "Draft"}

{poNumber}

) : ( No PO )}
); })} {shortageItems.length === 0 && ( All components were in stock for this plan. )}
); })()}
{/* Delete Confirmation Dialog */} !open && setDeleteTarget(null)}> Delete Planning Record

Are you sure you want to delete this planning record? This action cannot be undone. Note: stock levels will not be restored.

); }