1614 lines
68 KiB
TypeScript
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>
|
|
);
|
|
}
|