Files
website-secure/app/production/page.tsx
T
pm 9c14a19a87
DevSecOps Enterprise Pipeline / security-gate-and-deploy (push) Failing after 41s
chore: upload parte 1 - logica
2026-06-01 15:35:53 +01:00

1614 lines
68 KiB
TypeScript

"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<ProductEntry[]>([
{ id: nextId(), productId: "", batchSize: "1" },
]);
const [planData, setPlanData] = useState<ProductionData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { data: products = [] } = useSWR<Product[]>("/api/products", fetcher);
const { data: historyRaw, mutate: mutateHistory } = useSWR<Record<string, unknown>[]>("/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<Record<string, unknown> | null>(null);
const [viewPlanItems, setViewPlanItems] = useState<Record<string, unknown>[]>([]);
const [viewTab, setViewTab] = useState<"pick" | "purchase">("pick");
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
const [selectedPoItems, setSelectedPoItems] = useState<Set<number>>(new Set());
const [generatingPo, setGeneratingPo] = useState(false);
const [linkPoIds, setLinkPoIds] = useState<Set<number>>(new Set());
// Fetch active supplier POs for linking
const { data: activePos = [] } = useSWR<Record<string, unknown>[]>(
"/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<string, unknown>)[], prefix: string) {
const externalItems = items.filter((i) => !(i as Record<string, unknown>).is_internal);
const sorted = [...externalItems].sort((a, b) => {
const locA = ((a as Record<string, unknown>).shelf_location as string) || "ZZZ";
const locB = ((b as Record<string, unknown>).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<string, unknown>;
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<string, unknown>[]) {
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<string, { supplier_id: number | null; supplier_name: string; items: Record<string, unknown>[] }>();
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<string, unknown>[] = 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<string, unknown>[] = 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 (
<TooltipProvider>
<div className="flex flex-col gap-6 p-6 lg:p-8">
{/* Header */}
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Production Planning
</h1>
<p className="text-sm text-muted-foreground">
Calculate combined component requirements for one or more production
batches and generate a consolidated purchase list
</p>
</div>
{/* Batch Configuration */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Calculator className="h-4 w-4 text-primary" />
Batch Configuration
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{entries.map((entry, idx) => (
<div key={entry.id} className="flex flex-wrap items-end gap-3">
<div className="flex h-10 w-8 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
{idx + 1}
</div>
<div className="flex flex-col gap-1.5 flex-1 min-w-[220px]">
{idx === 0 && (
<Label className="text-xs text-muted-foreground">
Product
</Label>
)}
<Select
value={entry.productId}
onValueChange={(val) =>
updateEntry(entry.id, "productId", val)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a product..." />
</SelectTrigger>
<SelectContent>
{products.length === 0 && (
<SelectItem value="__none" disabled>
No products available
</SelectItem>
)}
{products.map((product) => {
const alreadySelected =
selectedProductIds.includes(String(product.id)) &&
entry.productId !== String(product.id);
return (
<SelectItem
key={product.id}
value={String(product.id)}
disabled={alreadySelected}
>
<span className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">
{product.sku}
</span>
<span
className={
alreadySelected
? "text-muted-foreground"
: ""
}
>
{product.name}
</span>
{product.status !== "active" && (
<Badge variant="outline" className="text-xs">
{product.status}
</Badge>
)}
{alreadySelected && (
<span className="text-xs text-muted-foreground italic">
(already added)
</span>
)}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5 w-[140px]">
{idx === 0 && (
<Label className="text-xs text-muted-foreground">
Batch Size
</Label>
)}
<Input
type="number"
min="1"
value={entry.batchSize}
onChange={(e) =>
updateEntry(entry.id, "batchSize", e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter") handleCalculate();
}}
placeholder="e.g. 100"
/>
</div>
<Button
variant="ghost"
size="icon"
className="h-10 w-10 text-muted-foreground hover:text-destructive"
onClick={() => removeEntry(entry.id)}
disabled={entries.length <= 1}
aria-label="Remove product entry"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex items-center gap-3 pt-2">
<Button
variant="outline"
size="sm"
onClick={addEntry}
className="gap-1.5"
>
<Plus className="h-3.5 w-3.5" />
Add Product
</Button>
<div className="flex-1" />
<Button
onClick={handleCalculate}
disabled={!canCalculate || isLoading}
className="gap-2"
>
<Factory className="h-4 w-4" />
{isLoading
? "Calculating..."
: `Calculate Requirements${entries.filter((e) => e.productId).length > 1 ? ` (${entries.filter((e) => e.productId).length} products)` : ""}`}
</Button>
</div>
</CardContent>
</Card>
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Factory className="mr-2 h-5 w-5 animate-spin" />
Calculating production requirements...
</div>
)}
{/* Results */}
{planData && !isLoading && (
<>
{/* Per-Product Breakdown */}
{planData.entries.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Package className="h-4 w-4 text-primary" />
Per-Product Breakdown
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>SKU</TableHead>
<TableHead className="text-right">
Batch Size
</TableHead>
<TableHead className="text-right">
BOM Items
</TableHead>
<TableHead className="text-right">
Total Parts
</TableHead>
<TableHead className="text-right">
Batch Cost
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{planData.entries.map((entry) => (
<TableRow key={entry.product.id}>
<TableCell className="font-medium">
{entry.product.name}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{entry.product.sku}
</TableCell>
<TableCell className="text-right font-mono">
{entry.batchSize.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono">
{entry.itemCount}
</TableCell>
<TableCell className="text-right font-mono">
{entry.totalParts.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(entry.totalCost)}
</TableCell>
</TableRow>
))}
<TableRow className="bg-muted/50 font-semibold">
<TableCell colSpan={4} className="text-right">
Combined Totals
</TableCell>
<TableCell className="text-right font-mono">
{planData.summary.totalParts.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(planData.summary.totalCost)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Summary Cards */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Layers className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">
Unique Components
</p>
<p className="text-xl font-bold text-foreground">
{planData.summary.totalComponents}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">
Total Parts Needed
</p>
<p className="text-xl font-bold text-foreground">
{planData.summary.totalParts.toLocaleString()}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Euro className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">
Total Batch Cost
</p>
<p className="text-xl font-bold text-foreground">
{formatCurrency(planData.summary.totalCost)}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<div
className={`flex h-10 w-10 items-center justify-center rounded-lg ${
planData.summary.allInStock
? "bg-accent/10"
: "bg-destructive/10"
}`}
>
{planData.summary.allInStock ? (
<CheckCircle2 className="h-5 w-5 text-accent" />
) : (
<CircleAlert className="h-5 w-5 text-destructive" />
)}
</div>
<div>
<p className="text-sm text-muted-foreground">
Stock Status
</p>
{planData.summary.allInStock ? (
<p className="text-xl font-bold text-accent">
All In Stock
</p>
) : (
<p className="text-xl font-bold text-destructive">
{planData.summary.shortageCount} Shortage
{planData.summary.shortageCount !== 1 ? "s" : ""}
</p>
)}
</div>
</CardContent>
</Card>
</div>
{/* Pick List for Production */}
{planData.items.length > 0 && (
<Card className="border-primary/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardList className="h-4 w-4 text-primary" />
Pick List - Components to Collect from Shelves
<Badge variant="secondary" className="ml-1 text-xs">
{planData.items.length} item{planData.items.length !== 1 ? "s" : ""}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Shelf Location</TableHead>
<TableHead>Part Number</TableHead>
<TableHead>Component</TableHead>
<TableHead>Category</TableHead>
<TableHead>Package</TableHead>
<TableHead className="text-right">Qty to Pick</TableHead>
<TableHead className="text-right">In Stock</TableHead>
<TableHead>Stock Status</TableHead>
{planData.entries.length > 1 && (
<TableHead>Needed By</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{[...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 (
<TableRow
key={item.component_id}
className={hasShortage ? "bg-destructive/5" : ""}
>
<TableCell>
{item.shelf_location ? (
<span className="inline-flex items-center gap-1.5 font-mono text-sm font-semibold">
<MapPin className="h-3.5 w-3.5 text-primary" />
{item.shelf_location}
</span>
) : (
<span className="text-muted-foreground italic text-xs">
No location set
</span>
)}
</TableCell>
<TableCell className="font-mono text-xs font-medium">
{item.part_number}
</TableCell>
<TableCell className="font-medium">
{item.component_name}
</TableCell>
<TableCell>
{item.category_name && (
<Badge variant="secondary" className="text-xs">
{item.category_name}
</Badge>
)}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{item.package_type || "-"}
</TableCell>
<TableCell className="text-right font-mono text-sm font-semibold">
{Number(item.total_needed).toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{Number(item.stock_quantity).toLocaleString()}
</TableCell>
<TableCell>
{hasShortage ? (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="destructive" className="text-xs cursor-help">
<AlertTriangle className="mr-1 h-3 w-3" />
Short {Number(item.shortage).toLocaleString()}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
Can pick {canPick.toLocaleString()} from stock. Need to purchase {Number(item.shortage).toLocaleString()} more.
</p>
</TooltipContent>
</Tooltip>
) : (
<Badge className="bg-accent text-accent-foreground text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
Available
</Badge>
)}
</TableCell>
{planData.entries.length > 1 && (
<TableCell>
<span className="text-xs text-muted-foreground">
{item.sources
.map((s) => `${s.sku}: ${s.subtotal}`)
.join(", ")}
</span>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* Purchase List */}
{planData.purchaseList.length > 0 && (
<Card className="border-destructive/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShoppingCart className="h-4 w-4 text-destructive" />
Purchase List ({planData.purchaseList.length} item
{planData.purchaseList.length !== 1 ? "s" : ""} to acquire)
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Part Number</TableHead>
<TableHead>Component</TableHead>
<TableHead>Manufacturer</TableHead>
<TableHead>Supplier</TableHead>
<TableHead>Supplier Part No.</TableHead>
<TableHead className="text-right">
Unit Price
</TableHead>
<TableHead className="text-right">
Total Needed
</TableHead>
<TableHead className="text-right">
In Stock
</TableHead>
<TableHead className="text-right">
Qty to Purchase
</TableHead>
<TableHead className="text-right">
Purchase Cost
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{planData.purchaseList.map((item) => (
<TableRow key={item.component_id}>
<TableCell className="font-mono text-xs font-medium">
{item.part_number}
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="font-medium">
{item.component_name}
</span>
{planData.entries.length > 1 && (
<span className="text-xs text-muted-foreground">
{item.sources
.map(
(s) =>
`${s.sku}: ${s.subtotal}`
)
.join(", ")}
</span>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{item.manufacturer || "-"}
</TableCell>
<TableCell>
{item.supplier !== "N/A" ? (
<span className="font-medium">
{item.supplier}
</span>
) : (
<span className="text-muted-foreground italic">
Not set
</span>
)}
</TableCell>
<TableCell>
{item.supplier_part_number !== "N/A" ? (
<span className="font-mono text-xs">
{item.supplier_part_number}
</span>
) : (
<span className="text-muted-foreground italic text-xs">
Not set
</span>
)}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatUnitPrice(item.unit_price)}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{item.total_needed.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{item.stock_quantity.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono text-sm font-bold text-destructive">
{item.shortage.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono text-sm font-semibold">
{formatCurrency(item.purchase_cost)}
</TableCell>
</TableRow>
))}
{/* Total row */}
<TableRow className="bg-muted/50 font-semibold">
<TableCell colSpan={8} className="text-right">
Total Purchase Cost
</TableCell>
<TableCell className="text-right font-mono text-destructive">
{planData.purchaseList
.reduce((s, i) => s + i.shortage, 0)
.toLocaleString()}
</TableCell>
<TableCell className="text-right font-mono text-destructive">
{formatCurrency(
planData.purchaseList.reduce(
(s, i) => s + i.purchase_cost,
0
)
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* All in stock message */}
{planData.purchaseList.length === 0 &&
planData.items.length > 0 && (
<Card className="border-accent/30">
<CardContent className="flex items-center gap-4 py-8 justify-center">
<CheckCircle2 className="h-8 w-8 text-accent" />
<div>
<p className="text-lg font-semibold text-foreground">
All components are in stock
</p>
<p className="text-sm text-muted-foreground">
You have enough inventory to produce all planned batches
without additional purchases.
</p>
</div>
</CardContent>
</Card>
)}
{/* Accept Planning */}
{planData.items.length > 0 && (
<Card className="border-primary/30">
<CardContent className="py-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-foreground">
Accept Production Planning
</p>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
<Button
onClick={handleAcceptPlanning}
disabled={accepting || linkPoIds.size === 0}
className="gap-2"
>
<Check className="h-4 w-4" />
{accepting ? "Accepting..." : "Accept Planning"}
</Button>
</div>
{/* Link Supplier POs - Required */}
<div className={`rounded-lg border p-3 ${linkPoIds.size === 0 ? "border-destructive/50 bg-destructive/5" : "bg-muted/30"}`}>
<p className="text-sm font-medium mb-2">
Link Supplier POs <span className="text-destructive">*</span>
</p>
<p className="text-xs text-muted-foreground mb-3">
Select at least one Supplier PO to link with this production plan.
</p>
{activePos.length > 0 ? (
<>
<div className="flex flex-wrap gap-2">
{activePos.map((po) => {
const isSelected = linkPoIds.has(po.id as number);
return (
<button
key={po.id as number}
type="button"
onClick={() => {
setLinkPoIds((prev) => {
const next = new Set(prev);
if (isSelected) {
next.delete(po.id as number);
} else {
next.add(po.id as number);
}
return next;
});
}}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-xs font-mono transition-colors ${
isSelected
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-muted border-border"
}`}
>
{isSelected && <Check className="h-3 w-3" />}
{po.po_number}
<span className="text-[10px] opacity-70">({po.supplier_name})</span>
</button>
);
})}
</div>
{linkPoIds.size > 0 ? (
<p className="text-xs text-primary mt-2">
{linkPoIds.size} PO{linkPoIds.size !== 1 ? "s" : ""} selected
</p>
) : (
<p className="text-xs text-destructive mt-2">
Please select at least one PO to continue
</p>
)}
</>
) : (
<p className="text-xs text-destructive">
No active Supplier POs available. Create a PO from the Supplier POs page first.
</p>
)}
</div>
</CardContent>
</Card>
)}
</>
)}
{/* Planning History */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<History className="h-4 w-4 text-primary" />
Planning History
{history.length > 0 && (
<Badge variant="secondary" className="ml-1 text-xs">
{history.length}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{history.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">
No production plans accepted yet. Calculate requirements and accept a plan to create history.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<SortableTableHead column="name" label="Name" sort={historySort} onToggle={toggleHistorySort} />
<TableHead>Products</TableHead>
<SortableTableHead column="item_count" label="Components" sort={historySort} onToggle={toggleHistorySort} className="text-right" />
<SortableTableHead column="total_quantity" label="Total Qty" sort={historySort} onToggle={toggleHistorySort} className="text-right" />
<SortableTableHead column="shortage_count" label="Shortages" sort={historySort} onToggle={toggleHistorySort} className="text-right" />
<SortableTableHead column="created_at" label="Created" sort={historySort} onToggle={toggleHistorySort} />
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedHistory.map((plan) => {
const pj = plan.products_json;
const prods: Record<string, unknown>[] = Array.isArray(pj)
? pj
: typeof pj === "string"
? (() => { try { return JSON.parse(pj); } catch { return []; } })()
: [];
return (
<TableRow key={plan.id as number}>
<TableCell className="font-medium">
{plan.name as string}
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">
{prods.map((p) => `${p.sku} x${p.batch_size}`).join(", ") || "-"}
</TableCell>
<TableCell className="text-right font-mono">
{String(plan.item_count)}
</TableCell>
<TableCell className="text-right font-mono">
{Number(plan.total_quantity).toLocaleString()}
</TableCell>
<TableCell className="text-right">
{Number(plan.shortage_count) > 0 ? (
<Badge variant="destructive" className="text-xs">
{String(plan.shortage_count)}
</Badge>
) : (
<Badge className="bg-accent text-accent-foreground text-xs">0</Badge>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(plan.created_at as string).toLocaleDateString("en-GB", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleViewPlan(plan.id as number)}
>
<Eye className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(plan.id as number)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Empty state */}
{!planData && !isLoading && history.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Factory className="h-12 w-12 mb-4 opacity-30" />
<p className="text-lg font-medium">
Add products and batch sizes to get started
</p>
<p className="text-sm">
The planner will calculate combined component requirements across
all products and highlight shortages
</p>
</div>
)}
</div>
{/* View Planning Detail Dialog */}
<Dialog open={!!viewPlan} onOpenChange={(open) => !open && setViewPlan(null)}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="h-4 w-4 text-primary" />
{viewPlan?.name as string}
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-4 mb-4">
<span className="text-xs text-muted-foreground">
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" }) : ""}
</span>
<span className="text-xs text-muted-foreground">
{viewPlanItems.length} components
</span>
</div>
{/* Tab switcher */}
<div className="flex items-center gap-2 border-b pb-2 mb-4">
<Button
variant={viewTab === "pick" ? "default" : "ghost"}
size="sm"
onClick={() => setViewTab("pick")}
className="gap-1.5"
>
<ClipboardList className="h-3.5 w-3.5" />
Pick List
</Button>
<Button
variant={viewTab === "purchase" ? "default" : "ghost"}
size="sm"
onClick={() => setViewTab("purchase")}
className="gap-1.5"
>
<ShoppingCart className="h-3.5 w-3.5" />
Purchase List
{viewPlanItems.filter((i) => Number(i.shortage) > 0).length > 0 && (
<Badge variant="destructive" className="ml-1 text-xs">
{viewPlanItems.filter((i) => Number(i.shortage) > 0).length}
</Badge>
)}
</Button>
<div className="flex-1" />
{viewTab === "pick" && (
<Button variant="outline" size="sm" onClick={() => downloadPickListCSV(viewPlanItems, `pick-list-${viewPlan?.id}`)}>
<FileDown className="mr-2 h-3.5 w-3.5" />
Download Pick List CSV
</Button>
)}
{viewTab === "purchase" && viewPlanItems.filter((i) => Number(i.shortage) > 0).length > 0 && (
<Button variant="outline" size="sm" onClick={() => downloadPurchaseListCSV(viewPlanItems)}>
<FileDown className="mr-2 h-3.5 w-3.5" />
Download Purchase List CSV
</Button>
)}
</div>
{/* Pick List Tab */}
{viewTab === "pick" && (
<>
<p className="text-xs text-muted-foreground mb-3">
External components to collect from shelves (internal production components excluded).
</p>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Shelf</TableHead>
<TableHead>Part Number</TableHead>
<TableHead>Component</TableHead>
<TableHead>Category</TableHead>
<TableHead>Package</TableHead>
<TableHead className="text-right">Qty to Pick</TableHead>
<TableHead>Notes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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) => (
<TableRow key={item.id as number}>
<TableCell>
{item.shelf_location ? (
<span className="inline-flex items-center gap-1 font-mono text-xs font-semibold">
<MapPin className="h-3 w-3 text-primary" />
{item.shelf_location as string}
</span>
) : (
<span className="text-xs text-muted-foreground italic">N/A</span>
)}
</TableCell>
<TableCell className="font-mono text-xs font-medium">
{item.part_number as string}
</TableCell>
<TableCell className="font-medium text-sm">
{item.component_name as string}
</TableCell>
<TableCell>
{item.category_name && (
<Badge variant="secondary" className="text-xs">
{item.category_name as string}
</Badge>
)}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{(item.package_type as string) || "-"}
</TableCell>
<TableCell className="text-right font-mono text-sm font-semibold">
{Number(item.quantity_needed).toLocaleString()}
</TableCell>
<TableCell className="text-muted-foreground text-xs">-</TableCell>
</TableRow>
))}
{viewPlanItems.filter((i) => !i.is_internal).length === 0 && (
<TableRow>
<TableCell colSpan={7} className="py-8 text-center text-sm text-muted-foreground">
No external components in this plan.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</>
)}
{/* 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<string, unknown>) => {
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<string, number>();
selectedItems.forEach((i) => {
const s = (i.supplier as string) || "Unknown Supplier";
supplierGroups.set(s, (supplierGroups.get(s) || 0) + 1);
});
return (
<>
<div className="flex items-center justify-between mb-3">
<p className="text-xs text-muted-foreground">
Components with insufficient stock that need to be purchased. Select items to generate Purchase Orders grouped by supplier.
</p>
</div>
{/* Generate PO bar */}
{someSelected && (
<div className="flex items-center gap-3 rounded-md border bg-muted/50 p-3 mb-3">
<span className="text-sm font-medium">
{selectedPoItems.size} item{selectedPoItems.size !== 1 ? "s" : ""} selected
</span>
{supplierGroups.size > 0 && (
<span className="text-xs text-muted-foreground">
({supplierGroups.size} supplier{supplierGroups.size !== 1 ? "s" : ""}: {Array.from(supplierGroups.entries()).map(([name, count]) => `${name} (${count})`).join(", ")})
</span>
)}
<div className="flex-1" />
<Button
size="sm"
onClick={handleGeneratePOs}
disabled={generatingPo}
className="gap-1.5"
>
<FileDown className="h-3.5 w-3.5" />
{generatingPo ? "Generating..." : `Generate ${supplierGroups.size} PO${supplierGroups.size !== 1 ? "s" : ""}`}
</Button>
</div>
)}
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={allSelected}
disabled={selectableItems.length === 0}
onCheckedChange={(checked) => {
if (checked) {
setSelectedPoItems(new Set(selectableItems.map((i) => i.id as number)));
} else {
setSelectedPoItems(new Set());
}
}}
aria-label="Select all items"
/>
</TableHead>
<TableHead>Part Number</TableHead>
<TableHead>Component</TableHead>
<TableHead>Supplier</TableHead>
<TableHead>Supplier Part No.</TableHead>
<TableHead className="text-right">Qty to Purchase</TableHead>
<TableHead>PO Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow key={item.id as number} className={isSelected ? "bg-primary/5" : itemHasActivePo ? "opacity-60" : ""}>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Checkbox
checked={isSelected}
disabled={itemHasActivePo}
onCheckedChange={(checked) => {
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}`}
/>
</span>
</TooltipTrigger>
{itemHasActivePo && (
<TooltipContent>
<p className="text-xs">PO already exists ({poNumber}). Cancel it first to generate a new one.</p>
</TooltipContent>
)}
</Tooltip>
</TableCell>
<TableCell className="font-mono text-xs font-medium">
{item.part_number as string}
</TableCell>
<TableCell className="font-medium text-sm">
{item.component_name as string}
</TableCell>
<TableCell className="text-sm">
{(item.supplier as string) || <span className="text-muted-foreground italic">N/A</span>}
</TableCell>
<TableCell className="font-mono text-xs">
{(item.supplier_part_number as string) || "-"}
</TableCell>
<TableCell className="text-right font-mono text-sm font-bold text-destructive">
{Number(item.shortage).toLocaleString()}
</TableCell>
<TableCell>
{poNumber ? (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={
poStatus === "cancelled" ? "destructive" :
poStatus === "received" ? "default" :
poStatus === "partially_received" ? "default" :
poStatus === "ordered" ? "secondary" : "outline"
}
className="text-xs cursor-help"
>
{poStatus === "cancelled" ? "Cancelled" :
poStatus === "partially_received" ? "Partial" :
poStatus === "received" ? "Received" :
poStatus === "ordered" ? "Ordered" : "Draft"}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p className="font-mono text-xs">{poNumber}</p>
</TooltipContent>
</Tooltip>
) : (
<span className="text-xs text-muted-foreground italic">No PO</span>
)}
</TableCell>
</TableRow>
);
})}
{shortageItems.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="py-8 text-center text-sm text-muted-foreground">
All components were in stock for this plan.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</>
);
})()}
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete Planning Record</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Are you sure you want to delete this planning record? This action cannot be undone. Note: stock levels will not be restored.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeletePlan}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TooltipProvider>
);
}