Files
website-secure/app/suppliers/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

1557 lines
66 KiB
TypeScript

"use client";
import React, { useState } from "react";
import useSWR from "swr";
import {
Card,
CardContent,
} from "@/components/ui/card";
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import {
Plus,
Search,
Pencil,
Trash2,
Building2,
FileDown,
ChevronDown,
ChevronRight,
ShoppingCart,
Eye,
History,
Package,
Upload,
} from "lucide-react";
import { useTableSort } from "@/hooks/use-table-sort";
import { SortableTableHead } from "@/components/sortable-table-head";
import { SupplierDialog } from "@/components/supplier-dialog";
import { toast } from "sonner";
import { formatCurrency } from "@/lib/format";
import type { Supplier } from "@/lib/types";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
type PoItem = {
id: number;
part_number: string;
component_name: string;
supplier_part_number: string | null;
quantity: number;
unit_price: number;
received_quantity: number;
};
type SupplierPo = {
id: number;
po_number: string;
status: string;
item_count: number;
total_value: number;
total_received: number;
total_ordered: number;
created_at: string;
items?: PoItem[];
supplier_name?: string;
supplier_code?: string;
supplier_email?: string;
supplier_phone?: string;
supplier_address?: string;
supplier_contact?: string;
notes?: string;
};
const STATUS_LABELS: Record<string, string> = {
draft: "Draft",
ordered: "Ordered",
partially_received: "Partial",
received: "Received",
cancelled: "Cancelled",
};
const STATUS_VARIANTS: Record<string, "outline" | "secondary" | "default" | "destructive"> = {
draft: "outline",
ordered: "secondary",
partially_received: "default",
received: "default",
cancelled: "destructive",
};
const STATUS_ORDER = ["draft", "ordered", "partially_received", "received"];
function getAllowedNextStatuses(currentStatus: string): string[] {
if (currentStatus === "cancelled") return [];
const currentIdx = STATUS_ORDER.indexOf(currentStatus);
if (currentIdx < 0) return STATUS_ORDER;
const next = STATUS_ORDER.slice(currentIdx + 1);
// Allow cancellation only from "ordered" status
if (currentStatus === "ordered") {
next.push("cancelled");
}
return next;
}
export default function SuppliersPage() {
const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Supplier | null>(null);
const [expandedSupplier, setExpandedSupplier] = useState<number | null>(null);
const [activePos, setActivePos] = useState<SupplierPo[]>([]);
const [historyPos, setHistoryPos] = useState<SupplierPo[]>([]);
const [loadingPos, setLoadingPos] = useState(false);
const [poTab, setPoTab] = useState<string>("active");
const [viewPo, setViewPo] = useState<SupplierPo | null>(null);
const [editingReceivedQty, setEditingReceivedQty] = useState<Record<number, number>>({});
const [savingReceived, setSavingReceived] = useState(false);
const [deletePoTarget, setDeletePoTarget] = useState<SupplierPo | null>(null);
const [shippingCost, setShippingCost] = useState<number>(0);
// Draft PO editing state
type DraftItem = { component_id: number; part_number: string; component_name: string; supplier_part_number: string; quantity: number; unit_price: number };
const [draftItems, setDraftItems] = useState<DraftItem[]>([]);
const [savingDraft, setSavingDraft] = useState(false);
const [addItemOpen, setAddItemOpen] = useState(false);
const [addItemComponents, setAddItemComponents] = useState<Record<string, unknown>[]>([]);
const [addItemSearch, setAddItemSearch] = useState("");
const [loadingAddItems, setLoadingAddItems] = useState(false);
const [uploadingCsv, setUploadingCsv] = useState(false);
// Manual PO creation state
const [createPoOpen, setCreatePoOpen] = useState(false);
const [createPoSupplier, setCreatePoSupplier] = useState<Supplier | null>(null);
const [supplierComponents, setSupplierComponents] = useState<Record<string, unknown>[]>([]);
const [loadingComponents, setLoadingComponents] = useState(false);
const [poLineItems, setPoLineItems] = useState<Record<number, number>>({}); // component_id -> quantity
const [poLinePrices, setPoLinePrices] = useState<Record<number, number>>({}); // component_id -> unit_price override
const [creatingPo, setCreatingPo] = useState(false);
const [componentSearch, setComponentSearch] = useState("");
const { data: rawSuppliers = [], mutate } = useSWR<Supplier[]>(
`/api/suppliers?search=${encodeURIComponent(search)}`,
fetcher
);
const suppliers = Array.isArray(rawSuppliers) ? rawSuppliers : [];
const { sorted: sortedSuppliers, sort, toggleSort } = useTableSort(suppliers);
function openEdit(supplier: Supplier) {
setEditingSupplier(supplier);
setDialogOpen(true);
}
function openCreate() {
setEditingSupplier(null);
setDialogOpen(true);
}
async function handleDelete() {
if (!deleteTarget) return;
try {
const res = await fetch(`/api/suppliers/${deleteTarget.id}`, { method: "DELETE" });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to delete");
}
toast.success("Supplier deleted");
mutate();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to delete supplier");
} finally {
setDeleteTarget(null);
}
}
async function fetchPosForSupplier(supplierId: number) {
setLoadingPos(true);
try {
const [activeRes, historyRes] = await Promise.all([
fetch(`/api/supplier-pos?supplier_id=${supplierId}&status=active`),
fetch(`/api/supplier-pos?supplier_id=${supplierId}&status=received`),
]);
const activeData = await activeRes.json();
const historyData = await historyRes.json();
setActivePos(Array.isArray(activeData) ? activeData : []);
setHistoryPos(Array.isArray(historyData) ? historyData : []);
} catch {
setActivePos([]);
setHistoryPos([]);
} finally {
setLoadingPos(false);
}
}
async function toggleExpandSupplier(supplierId: number) {
if (expandedSupplier === supplierId) {
setExpandedSupplier(null);
return;
}
setExpandedSupplier(supplierId);
setPoTab("active");
await fetchPosForSupplier(supplierId);
}
async function handleViewPo(id: number) {
try {
const res = await fetch(`/api/supplier-pos/${id}`);
const data: SupplierPo = await res.json();
setViewPo(data);
// Init received qty editing state
const qtyMap: Record<number, number> = {};
(data.items || []).forEach((item) => {
qtyMap[item.id] = Number(item.received_quantity) || 0;
});
setEditingReceivedQty(qtyMap);
setShippingCost(Number(data.shipping_cost) || 0);
// Init draft editing state
if (data.status === "draft") {
setDraftItems((data.items || []).map((item) => ({
component_id: Number((item as Record<string, unknown>).component_id) || 0,
part_number: item.part_number,
component_name: item.component_name,
supplier_part_number: item.supplier_part_number || "",
quantity: Number(item.quantity),
unit_price: Number(item.unit_price),
})));
}
setAddItemOpen(false);
} catch {
toast.error("Failed to load PO details");
}
}
async function handleDownloadPoCsv(id: number) {
try {
const res = await fetch(`/api/supplier-pos/${id}`);
const data = await res.json();
const items = (data.items || []) as PoItem[];
const headers = ["Part Number", "Component", "Supplier Ref", "Quantity", "Unit Price", "Total"];
const rows = items.map((item) => [
item.part_number,
item.component_name,
item.supplier_part_number || "",
Number(item.quantity),
Number(item.unit_price).toFixed(2),
(Number(item.quantity) * Number(item.unit_price)).toFixed(2),
]);
const total = items.reduce((s, i) => s + Number(i.quantity) * Number(i.unit_price), 0);
rows.push(["", "", "", "", "Total", total.toFixed(2)]);
const csvContent = [headers, ...rows]
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","))
.join("\n");
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${data.po_number}.csv`;
a.click();
URL.revokeObjectURL(url);
} catch {
toast.error("Failed to generate CSV");
}
}
async function handleDownloadPoPdf(id: number) {
try {
const res = await fetch(`/api/supplier-pos/${id}`);
const data = await res.json();
const { generateSupplierPoPdf } = await import("@/lib/generate-supplier-po-pdf");
const doc = generateSupplierPoPdf({
po_number: data.po_number,
date: data.created_at,
status: data.status,
supplier_name: data.supplier_name,
supplier_code: data.supplier_code,
supplier_email: data.supplier_email,
supplier_phone: data.supplier_phone,
supplier_address: data.supplier_address,
supplier_contact: data.supplier_contact,
supplier_country: data.supplier_country,
supplier_currency: data.supplier_currency,
shipping_cost: Number(data.shipping_cost) || 0,
notes: data.notes,
items: (data.items || []).map((item: PoItem) => ({
part_number: item.part_number,
component_name: item.component_name,
supplier_part_number: item.supplier_part_number,
quantity: Number(item.quantity),
unit_price: Number(item.unit_price),
})),
});
doc.save(`${data.po_number}.pdf`);
} catch {
toast.error("Failed to generate PO PDF");
}
}
async function handlePoStatusChange(poId: number, newStatus: string) {
try {
const res = await fetch(`/api/supplier-pos/${poId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || "Failed to update");
}
await res.json();
toast.success(`PO status updated to ${STATUS_LABELS[newStatus] || newStatus}`);
// Refresh the expanded supplier's PO lists
if (expandedSupplier) {
await fetchPosForSupplier(expandedSupplier);
}
// Close detail dialog on cancellation since PO moves to history
if (newStatus === "cancelled") {
setViewPo(null);
return;
}
// Only open the detail dialog for partially_received so user can enter received quantities
if (newStatus === "partially_received") {
const freshRes = await fetch(`/api/supplier-pos/${poId}`);
const freshPo: SupplierPo = await freshRes.json();
setViewPo(freshPo);
const qtyMap: Record<number, number> = {};
(freshPo.items || []).forEach((item) => {
qtyMap[item.id] = Number(item.received_quantity) || 0;
});
setEditingReceivedQty(qtyMap);
} else if (viewPo && viewPo.id === poId) {
// If the dialog is already open, refresh it
const freshRes = await fetch(`/api/supplier-pos/${poId}`);
const freshPo: SupplierPo = await freshRes.json();
setViewPo(freshPo);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to update PO status");
}
}
async function handleSaveReceivedQuantities() {
if (!viewPo) return;
setSavingReceived(true);
try {
const receivedItems = Object.entries(editingReceivedQty).map(([id, qty]) => ({
id: Number(id),
received_quantity: qty,
}));
// Determine new status based on received quantities
const items = viewPo.items || [];
const allFullyReceived = items.every((item) => {
const receivedQty = editingReceivedQty[item.id] ?? 0;
return receivedQty >= Number(item.quantity);
});
const someReceived = items.some((item) => {
const receivedQty = editingReceivedQty[item.id] ?? 0;
return receivedQty > 0;
});
let newStatus: string | undefined = undefined;
if (allFullyReceived && viewPo.status !== "received") {
newStatus = "received";
} else if (someReceived && viewPo.status !== "partially_received" && viewPo.status !== "received") {
newStatus = "partially_received";
}
const res = await fetch(`/api/supplier-pos/${viewPo.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...(newStatus ? { status: newStatus } : {}),
received_items: receivedItems,
}),
});
if (!res.ok) throw new Error("Failed to save");
toast.success("Received quantities saved");
// Re-fetch full PO to get updated items
const freshRes = await fetch(`/api/supplier-pos/${viewPo.id}`);
const freshPo: SupplierPo = await freshRes.json();
setViewPo(freshPo);
const qtyMap: Record<number, number> = {};
(freshPo.items || []).forEach((item) => {
qtyMap[item.id] = Number(item.received_quantity) || 0;
});
setEditingReceivedQty(qtyMap);
if (expandedSupplier) {
await fetchPosForSupplier(expandedSupplier);
}
} catch {
toast.error("Failed to save received quantities");
} finally {
setSavingReceived(false);
}
}
async function handleDeletePo() {
if (!deletePoTarget) return;
try {
const res = await fetch(`/api/supplier-pos/${deletePoTarget.id}`, { method: "DELETE" });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to delete");
}
toast.success(`PO ${deletePoTarget.po_number} deleted`);
if (expandedSupplier) {
await fetchPosForSupplier(expandedSupplier);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to delete PO");
} finally {
setDeletePoTarget(null);
}
}
async function handleSaveDraftItems() {
if (!viewPo || viewPo.status !== "draft") return;
if (draftItems.length === 0) {
toast.error("A PO must have at least one item");
return;
}
setSavingDraft(true);
try {
const res = await fetch(`/api/supplier-pos/${viewPo.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draft_items: draftItems, shipping_cost: shippingCost }),
});
if (!res.ok) throw new Error("Failed to save");
toast.success("PO items updated");
const freshRes = await fetch(`/api/supplier-pos/${viewPo.id}`);
const freshPo: SupplierPo = await freshRes.json();
setViewPo(freshPo);
setDraftItems((freshPo.items || []).map((item) => ({
component_id: Number((item as Record<string, unknown>).component_id) || 0,
part_number: item.part_number,
component_name: item.component_name,
supplier_part_number: item.supplier_part_number || "",
quantity: Number(item.quantity),
unit_price: Number(item.unit_price),
})));
if (expandedSupplier) await fetchPosForSupplier(expandedSupplier);
} catch {
toast.error("Failed to save PO items");
} finally {
setSavingDraft(false);
}
}
async function loadAddItemComponents() {
if (!viewPo) return;
setAddItemOpen(true);
setAddItemSearch("");
setLoadingAddItems(true);
try {
const res = await fetch(`/api/supplier-pos/${viewPo.id}`);
const poData = await res.json();
const supplierName = poData.supplier_name || "";
const compRes = await fetch(`/api/components?supplier_name=${encodeURIComponent(supplierName)}`);
const data = await compRes.json();
setAddItemComponents(Array.isArray(data) ? data : []);
} catch {
setAddItemComponents([]);
} finally {
setLoadingAddItems(false);
}
}
// Handle LCSC CSV upload for draft POs
async function handleCsvUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file || !viewPo || viewPo.status !== "draft") return;
setUploadingCsv(true);
try {
const text = await file.text();
const lines = text.split("\n").map(line => line.trim()).filter(Boolean);
if (lines.length < 2) {
toast.error("CSV file is empty or has no data rows");
return;
}
// Parse header to find column indices
const header = lines[0].split(",").map(h => h.trim().toLowerCase());
const lcscPartIdx = header.findIndex(h => h.includes("lcsc part"));
const customerNoIdx = header.findIndex(h => h.includes("customer no"));
const quantityIdx = header.findIndex(h => h.includes("quantity"));
const unitPriceIdx = header.findIndex(h => h.includes("unit price"));
if (lcscPartIdx === -1 || customerNoIdx === -1 || quantityIdx === -1 || unitPriceIdx === -1) {
toast.error("CSV must have columns: LCSC Part Number, Customer NO., Quantity, Unit Price");
return;
}
// Parse data rows
const csvItems: { supplierRef: string; partNumber: string; quantity: number; unitPrice: number }[] = [];
for (let i = 1; i < lines.length; i++) {
// Handle CSV with potential commas in quoted fields
const row = lines[i].match(/(".*?"|[^",\s]+)(?=\s*,|\s*$)/g);
if (!row) continue;
const cleanValue = (val: string) => val?.replace(/^"|"$/g, "").trim() || "";
const supplierRef = cleanValue(row[lcscPartIdx] || "");
const partNumber = cleanValue(row[customerNoIdx] || "");
const quantityStr = cleanValue(row[quantityIdx] || "0");
const unitPriceStr = cleanValue(row[unitPriceIdx] || "0").replace(/[^\d.,]/g, "").replace(",", ".");
if (!supplierRef && !partNumber) continue;
csvItems.push({
supplierRef,
partNumber,
quantity: parseInt(quantityStr, 10) || 0,
unitPrice: parseFloat(unitPriceStr) || 0,
});
}
if (csvItems.length === 0) {
toast.error("No valid items found in CSV");
return;
}
// Update draft items with CSV data
// Customer NO. from CSV is used to match against Part Number in PO
// Only Supplier Ref (LCSC Part Number), Quantity, and Unit Price are updated
let updatedCount = 0;
const updatedDraftItems = draftItems.map(item => {
// Match by Customer NO. (from CSV) against Part Number (in PO)
const csvMatch = csvItems.find(
csv => csv.partNumber.toLowerCase() === item.part_number.toLowerCase()
);
if (csvMatch) {
updatedCount++;
return {
...item,
// Part number stays unchanged - only update supplier ref, quantity, and price
supplier_part_number: csvMatch.supplierRef || item.supplier_part_number,
quantity: csvMatch.quantity || item.quantity,
unit_price: csvMatch.unitPrice || item.unit_price,
};
}
return item;
});
if (updatedCount === 0) {
toast.error("No matching items found between CSV and PO. Check that Part Numbers match.");
return;
}
setDraftItems(updatedDraftItems);
toast.success(`Updated ${updatedCount} item(s) from CSV. Review and save when ready.`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to process CSV");
} finally {
setUploadingCsv(false);
// Reset file input
e.target.value = "";
}
}
function addComponentToDraft(comp: Record<string, unknown>) {
const cid = Number(comp.id);
if (draftItems.some((d) => d.component_id === cid)) {
toast.error("Component already in PO");
return;
}
setDraftItems((prev) => [...prev, {
component_id: cid,
part_number: (comp.part_number as string) || "",
component_name: (comp.name as string) || "",
supplier_part_number: (comp.supplier_part_number as string) || "",
quantity: 1,
unit_price: Number(comp.unit_price) || 0,
}]);
toast.success(`Added ${comp.name}`);
}
async function openCreatePo(supplier: Supplier) {
setCreatePoSupplier(supplier);
setPoLineItems({});
setPoLinePrices({});
setComponentSearch("");
setCreatePoOpen(true);
setLoadingComponents(true);
try {
const res = await fetch(`/api/components?supplier_name=${encodeURIComponent(supplier.name)}`);
const data = await res.json();
setSupplierComponents(Array.isArray(data) ? data : []);
} catch {
setSupplierComponents([]);
toast.error("Failed to load components");
} finally {
setLoadingComponents(false);
}
}
function updatePoLineQty(componentId: number, qty: number) {
setPoLineItems((prev) => {
const next = { ...prev };
if (qty <= 0) {
delete next[componentId];
} else {
next[componentId] = qty;
}
return next;
});
}
async function handleCreateManualPo() {
if (!createPoSupplier || Object.keys(poLineItems).length === 0) return;
setCreatingPo(true);
try {
const items = Object.entries(poLineItems)
.filter(([, qty]) => qty > 0)
.map(([cid, qty]) => {
const comp = supplierComponents.find((c) => Number(c.id) === Number(cid));
return {
component_id: Number(cid),
part_number: comp?.part_number || "",
component_name: comp?.name || "",
supplier_part_number: comp?.supplier_part_number || "",
quantity: qty,
unit_price: poLinePrices[Number(cid)] ?? Number(comp?.unit_price) ?? 0,
};
});
if (items.length === 0) {
toast.error("Add at least one component with a quantity");
return;
}
const res = await fetch("/api/supplier-pos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
orders: [{
supplier_id: createPoSupplier.id,
supplier_name: createPoSupplier.name,
items,
}],
}),
});
if (!res.ok) throw new Error("Failed to create PO");
toast.success("Purchase order created");
setCreatePoOpen(false);
setCreatePoSupplier(null);
setPoLineItems({});
if (expandedSupplier === createPoSupplier.id) {
await fetchPosForSupplier(createPoSupplier.id);
}
} catch {
toast.error("Failed to create purchase order");
} finally {
setCreatingPo(false);
}
}
function renderPoTable(pos: SupplierPo[], isHistory: boolean) {
if (loadingPos) {
return <p className="text-sm text-muted-foreground py-4 text-center">Loading purchase orders...</p>;
}
if (pos.length === 0) {
return (
<p className="text-sm text-muted-foreground py-4 text-center">
{isHistory ? "No received purchase orders yet." : "No active purchase orders for this supplier."}
</p>
);
}
return (
<div className="rounded-md border bg-background">
<Table>
<TableHeader>
<TableRow>
<TableHead>PO Number</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Items</TableHead>
<TableHead className="text-right">Total Value</TableHead>
{!isHistory && <TableHead className="text-right">Received</TableHead>}
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pos.map((po) => (
<TableRow key={po.id}>
<TableCell className="font-mono text-xs font-semibold">
{po.po_number}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
{isHistory ? (
<Badge variant={STATUS_VARIANTS[po.status] || "default"} className="text-xs">
{STATUS_LABELS[po.status] || po.status}
</Badge>
) : (() => {
const nextStatuses = getAllowedNextStatuses(po.status);
return nextStatuses.length === 0 ? (
<Badge variant={STATUS_VARIANTS[po.status] || "outline"} className="text-xs">
{STATUS_LABELS[po.status] || po.status}
</Badge>
) : (
<Select
value={po.status}
onValueChange={(val) => handlePoStatusChange(po.id, val)}
>
<SelectTrigger className="h-7 w-[155px] text-xs">
<SelectValue>{STATUS_LABELS[po.status] || po.status}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={po.status} disabled>
{STATUS_LABELS[po.status] || po.status} (current)
</SelectItem>
{nextStatuses.map((s) => (
<SelectItem key={s} value={s}>
{STATUS_LABELS[s] || s}
</SelectItem>
))}
</SelectContent>
</Select>
);
})()}
</TableCell>
<TableCell className="text-right font-mono">
{String(po.item_count)}
</TableCell>
<TableCell className="text-right font-mono text-sm font-semibold">
{formatCurrency(Number(po.total_value))}
</TableCell>
{!isHistory && (
<TableCell className="text-right text-xs text-muted-foreground">
{Number(po.total_received)}/{Number(po.total_ordered)} units
</TableCell>
)}
<TableCell className="text-xs text-muted-foreground">
{new Date(po.created_at).toLocaleDateString("en-GB", {
day: "2-digit", month: "2-digit", year: "numeric",
})}
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); handleViewPo(po.id); }}
>
<Eye className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Download PDF"
onClick={(e) => { e.stopPropagation(); handleDownloadPoPdf(po.id); }}
>
<FileDown className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Download CSV"
onClick={(e) => { e.stopPropagation(); handleDownloadPoCsv(po.id); }}
>
<FileDown className="h-3.5 w-3.5" />
</Button>
{po.status === "draft" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => { e.stopPropagation(); setDeletePoTarget(po); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
const viewPoItems = (viewPo?.items || []) as PoItem[];
const viewPoIsActive = viewPo?.status !== "received";
return (
<div className="flex flex-col gap-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Suppliers
</h1>
<p className="text-sm text-muted-foreground">
Manage component suppliers and contact information
</p>
</div>
<Button onClick={openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Supplier
</Button>
</div>
<Card>
<CardContent className="flex items-center gap-4 py-5">
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10">
<Building2 className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">Total Suppliers</p>
<p className="text-2xl font-bold">{suppliers.length}</p>
</div>
</CardContent>
</Card>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search suppliers..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<SortableTableHead column="code" label="Code" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="name" label="Supplier Name" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="contact_person" label="Contact Person" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="country" label="Country" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="currency" label="Currency" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="email" label="Email" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="phone" label="Phone" sort={sort} onToggle={toggleSort} />
<SortableTableHead column="address" label="Address" sort={sort} onToggle={toggleSort} />
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedSuppliers.length === 0 && (
<TableRow>
<TableCell colSpan={9} className="py-12 text-center text-muted-foreground">
No suppliers found. Click &quot;Add Supplier&quot; to create one.
</TableCell>
</TableRow>
)}
{sortedSuppliers.map((supplier) => {
const isExpanded = expandedSupplier === supplier.id;
return (
<React.Fragment key={supplier.id}>
<TableRow className="cursor-pointer hover:bg-muted/50" onClick={() => toggleExpandSupplier(supplier.id)}>
<TableCell>
<div className="flex items-center gap-2">
{isExpanded ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
<Badge variant="outline" className="font-mono text-xs">{supplier.code}</Badge>
</div>
</TableCell>
<TableCell className="font-medium">{supplier.name}</TableCell>
<TableCell className="text-muted-foreground">{supplier.contact_person || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{supplier.country || "-"}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs font-mono">{supplier.currency || "EUR"}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{supplier.email || "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{supplier.phone || "-"}</TableCell>
<TableCell className="max-w-[200px] truncate text-sm text-muted-foreground">{supplier.address || "-"}</TableCell>
<TableCell>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(supplier)}>
<Pencil className="h-3.5 w-3.5" />
<span className="sr-only">Edit</span>
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => setDeleteTarget(supplier)}>
<Trash2 className="h-3.5 w-3.5" />
<span className="sr-only">Delete</span>
</Button>
</div>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow>
<TableCell colSpan={9} className="bg-muted/30 p-4">
<Tabs value={poTab} onValueChange={setPoTab}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<ShoppingCart className="h-4 w-4 text-primary" />
<span className="text-sm font-semibold">Purchase Orders</span>
<Button
size="sm"
className="h-7 text-xs"
onClick={(e) => { e.stopPropagation(); openCreatePo(supplier); }}
>
<Plus className="mr-1 h-3 w-3" />
Create PO
</Button>
</div>
<TabsList className="h-8">
<TabsTrigger value="active" className="text-xs gap-1.5 px-3">
<Package className="h-3 w-3" />
Active ({activePos.length})
</TabsTrigger>
<TabsTrigger value="history" className="text-xs gap-1.5 px-3">
<History className="h-3 w-3" />
History ({historyPos.length})
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="active" className="mt-0">
{renderPoTable(activePos, false)}
</TabsContent>
<TabsContent value="history" className="mt-0">
{renderPoTable(historyPos, true)}
</TabsContent>
</Tabs>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<SupplierDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
supplier={editingSupplier}
onSaved={() => mutate()}
/>
{/* PO Detail Dialog */}
<Dialog open={!!viewPo} onOpenChange={(open) => { if (!open) setViewPo(null); }}>
<DialogContent className="max-w-4xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-primary" />
{viewPo?.po_number}
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-4 mb-4 flex-wrap">
<span className="text-sm text-muted-foreground">
Supplier: <span className="font-medium text-foreground">{viewPo?.supplier_name}</span>
</span>
{(() => {
const currentStatus = viewPo?.status || "draft";
const nextStatuses = getAllowedNextStatuses(currentStatus);
return nextStatuses.length === 0 ? (
<Badge variant={STATUS_VARIANTS[currentStatus] || "outline"} className="text-xs">
{STATUS_LABELS[currentStatus] || currentStatus}
</Badge>
) : (
<Select
value={currentStatus}
onValueChange={(val) => viewPo && handlePoStatusChange(viewPo.id, val)}
>
<SelectTrigger className="h-7 w-[155px] text-xs">
<SelectValue>{STATUS_LABELS[currentStatus] || currentStatus}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={currentStatus} disabled>
{STATUS_LABELS[currentStatus] || currentStatus} (current)
</SelectItem>
{nextStatuses.map((s) => (
<SelectItem key={s} value={s}>
{STATUS_LABELS[s] || s}
</SelectItem>
))}
</SelectContent>
</Select>
);
})()}
<span className="text-xs text-muted-foreground">
{viewPo ? new Date(viewPo.created_at).toLocaleDateString("en-GB", { day: "2-digit", month: "2-digit", year: "numeric" }) : ""}
</span>
<div className="flex-1" />
<Button variant="outline" size="sm" onClick={() => viewPo && handleDownloadPoPdf(viewPo.id)}>
<FileDown className="mr-2 h-3.5 w-3.5" />
PDF
</Button>
<Button variant="outline" size="sm" onClick={() => viewPo && handleDownloadPoCsv(viewPo.id)}>
<FileDown className="mr-2 h-3.5 w-3.5" />
CSV
</Button>
</div>
{/* Draft mode: editable items table */}
{viewPo?.status === "draft" ? (
<>
{/* CSV Upload Section for Draft POs */}
<div className="rounded-md border border-dashed border-primary/40 bg-primary/5 p-4 mb-4">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium text-foreground">Import from LCSC CSV</h4>
<p className="text-xs text-muted-foreground mt-1">
Upload CSV from LCSC to update Supplier Ref, Quantity, and Unit Price. PO will be finalized after import.
</p>
</div>
<div>
<input
type="file"
accept=".csv"
id="csv-upload"
className="hidden"
onChange={handleCsvUpload}
disabled={uploadingCsv || draftItems.length === 0}
/>
<Button
variant="outline"
size="sm"
onClick={() => document.getElementById("csv-upload")?.click()}
disabled={uploadingCsv || draftItems.length === 0}
>
<Upload className="mr-2 h-3.5 w-3.5" />
{uploadingCsv ? "Processing..." : "Upload CSV"}
</Button>
</div>
</div>
</div>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Part Number</TableHead>
<TableHead>Component</TableHead>
<TableHead>Supplier Ref</TableHead>
<TableHead className="text-right">Unit Price</TableHead>
<TableHead className="text-right w-[110px]">Qty</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{draftItems.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="py-6 text-center text-sm text-muted-foreground">
No items. Add components below.
</TableCell>
</TableRow>
)}
{draftItems.map((item, idx) => (
<TableRow key={`${item.component_id}-${idx}`}>
<TableCell className="text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="font-mono text-xs font-medium">{item.part_number}</TableCell>
<TableCell className="font-medium text-sm">{item.component_name}</TableCell>
<TableCell>
<Input
type="text"
className="h-7 w-32 text-xs font-mono"
value={item.supplier_part_number}
placeholder="-"
onChange={(e) => {
setDraftItems((prev) => prev.map((d, i) => i === idx ? { ...d, supplier_part_number: e.target.value } : d));
}}
/>
</TableCell>
<TableCell className="text-right">
<Input
type="number"
min={0}
step="0.01"
className="h-7 w-24 text-xs font-mono text-right ml-auto"
value={item.unit_price || ""}
placeholder="0.00"
onChange={(e) => {
const val = Math.max(0, Number(e.target.value) || 0);
setDraftItems((prev) => prev.map((d, i) => i === idx ? { ...d, unit_price: val } : d));
}}
/>
</TableCell>
<TableCell className="text-right">
<Input
type="number"
min={1}
className="h-7 w-24 text-xs font-mono text-right ml-auto"
value={item.quantity}
onChange={(e) => {
const val = Math.max(1, Number(e.target.value) || 1);
setDraftItems((prev) => prev.map((d, i) => i === idx ? { ...d, quantity: val } : d));
}}
/>
</TableCell>
<TableCell className="text-right font-mono font-semibold">
{formatCurrency(item.quantity * item.unit_price)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => setDraftItems((prev) => prev.filter((_, i) => i !== idx))}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Add component to draft */}
{!addItemOpen ? (
<Button variant="outline" size="sm" className="self-start" onClick={loadAddItemComponents}>
<Plus className="mr-1 h-3.5 w-3.5" />
Add Component
</Button>
) : (
<div className="rounded-md border p-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Add Component</span>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setAddItemOpen(false)}>
Close
</Button>
</div>
{loadingAddItems ? (
<p className="text-sm text-muted-foreground text-center py-3">Loading components...</p>
) : (
<>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by part number or name..."
value={addItemSearch}
onChange={(e) => setAddItemSearch(e.target.value)}
className="pl-9 h-8 text-sm"
/>
</div>
<div className="max-h-[200px] overflow-auto rounded-md border">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead>Part Number</TableHead>
<TableHead>Component</TableHead>
<TableHead className="text-right">Unit Price</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{(() => {
const searchLower = addItemSearch.toLowerCase();
const filtered = addItemComponents.filter((c) => {
if (!addItemSearch) return true;
return (
(c.part_number as string || "").toLowerCase().includes(searchLower) ||
(c.name as string || "").toLowerCase().includes(searchLower)
);
});
if (filtered.length === 0) {
return (
<TableRow>
<TableCell colSpan={4} className="py-4 text-center text-xs text-muted-foreground">
{addItemSearch ? "No matching components" : "No components from this supplier"}
</TableCell>
</TableRow>
);
}
return filtered.map((comp) => {
const cid = Number(comp.id);
const alreadyAdded = draftItems.some((d) => d.component_id === cid);
return (
<TableRow key={cid} className={alreadyAdded ? "opacity-50" : ""}>
<TableCell className="font-mono text-xs">{comp.part_number as string}</TableCell>
<TableCell className="text-sm">{comp.name as string}</TableCell>
<TableCell className="text-right font-mono text-xs">{formatCurrency(Number(comp.unit_price))}</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={alreadyAdded}
onClick={() => addComponentToDraft(comp)}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
);
});
})()}
</TableBody>
</Table>
</div>
</>
)}
</div>
)}
<div className="flex items-center gap-3 pt-2">
<Label className="text-sm text-muted-foreground whitespace-nowrap">Shipping Cost</Label>
<Input
type="number"
min={0}
step="0.01"
className="h-8 w-28 text-sm font-mono text-right"
value={shippingCost || ""}
placeholder="0.00"
onChange={(e) => setShippingCost(Number(e.target.value) || 0)}
/>
</div>
<div className="flex items-center justify-between pt-2">
<Button size="sm" onClick={handleSaveDraftItems} disabled={savingDraft || draftItems.length === 0}>
{savingDraft ? "Saving..." : "Save Changes"}
</Button>
<div className="rounded-md border bg-muted/50 px-4 py-2">
<span className="text-sm text-muted-foreground mr-3">Total:</span>
<span className="font-mono font-bold">
{formatCurrency(draftItems.reduce((sum, item) => sum + item.quantity * item.unit_price, 0) + shippingCost)}
</span>
</div>
</div>
</>
) : (
<>
{/* Non-draft mode: read-only items with received qty editing for active POs */}
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Part Number</TableHead>
<TableHead>Component</TableHead>
<TableHead>Supplier Ref</TableHead>
<TableHead className="text-right">Ordered</TableHead>
{viewPoIsActive && <TableHead className="text-right">Received</TableHead>}
{viewPoIsActive && <TableHead className="text-right">Pending</TableHead>}
<TableHead className="text-right">Unit Price</TableHead>
<TableHead className="text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{viewPoItems.map((item, idx) => {
const ordered = Number(item.quantity);
const received = editingReceivedQty[item.id] ?? Number(item.received_quantity) ?? 0;
const pending = Math.max(0, ordered - received);
return (
<TableRow key={item.id}>
<TableCell className="text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="font-mono text-xs font-medium">{item.part_number}</TableCell>
<TableCell className="font-medium text-sm">{item.component_name}</TableCell>
<TableCell className="font-mono text-xs">{item.supplier_part_number || "-"}</TableCell>
<TableCell className="text-right font-mono">{ordered.toLocaleString()}</TableCell>
{viewPoIsActive && (
<TableCell className="text-right">
<Input
type="number"
min={0}
max={ordered}
className="h-7 w-20 text-xs font-mono text-right ml-auto"
value={received}
onChange={(e) => {
const val = Math.min(ordered, Math.max(0, Number(e.target.value) || 0));
setEditingReceivedQty((prev) => ({ ...prev, [item.id]: val }));
}}
/>
</TableCell>
)}
{viewPoIsActive && (
<TableCell className="text-right">
<span className={`font-mono text-xs ${pending > 0 ? "text-destructive font-semibold" : "text-muted-foreground"}`}>
{pending.toLocaleString()}
</span>
</TableCell>
)}
<TableCell className="text-right font-mono">{formatCurrency(Number(item.unit_price))}</TableCell>
<TableCell className="text-right font-mono font-semibold">
{formatCurrency(ordered * Number(item.unit_price))}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{shippingCost > 0 && (
<div className="flex items-center gap-3 pt-1">
<span className="text-sm text-muted-foreground">Shipping Cost:</span>
<span className="font-mono text-sm font-semibold">{formatCurrency(shippingCost)}</span>
</div>
)}
<div className="flex items-center justify-between pt-2">
{viewPoIsActive ? (
<Button size="sm" onClick={handleSaveReceivedQuantities} disabled={savingReceived}>
{savingReceived ? "Saving..." : "Save Received Quantities"}
</Button>
) : <div />}
{viewPoItems.length > 0 && (
<div className="rounded-md border bg-muted/50 px-4 py-2">
<span className="text-sm text-muted-foreground mr-3">Total:</span>
<span className="font-mono font-bold">
{formatCurrency(viewPoItems.reduce((sum, item) => sum + Number(item.quantity) * Number(item.unit_price), 0) + shippingCost)}
</span>
</div>
)}
</div>
</>
)}
</DialogContent>
</Dialog>
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Supplier</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete PO Confirmation */}
<AlertDialog open={!!deletePoTarget} onOpenChange={() => setDeletePoTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Purchase Order</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete PO &quot;{deletePoTarget?.po_number}&quot;?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeletePo}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Manual PO Creation Dialog */}
<Dialog open={createPoOpen} onOpenChange={(open) => { if (!open) { setCreatePoOpen(false); setCreatePoSupplier(null); } }}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-4 w-4 text-primary" />
Create Purchase Order
</DialogTitle>
</DialogHeader>
<div className="mb-2">
<span className="text-sm text-muted-foreground">
Supplier: <span className="font-medium text-foreground">{createPoSupplier?.name}</span>
</span>
</div>
{loadingComponents ? (
<p className="text-sm text-muted-foreground py-8 text-center">Loading components...</p>
) : supplierComponents.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">
No components found for this supplier. Components must have their Supplier field set to &quot;{createPoSupplier?.name}&quot;.
</p>
) : (() => {
const searchLower = componentSearch.toLowerCase();
const selectedComponents = supplierComponents.filter((c) => poLineItems[Number(c.id)] > 0);
const filteredComponents = supplierComponents.filter((comp) => {
if (!componentSearch) return true;
return (
(comp.part_number as string || "").toLowerCase().includes(searchLower) ||
(comp.name as string || "").toLowerCase().includes(searchLower) ||
(comp.supplier_part_number as string || "").toLowerCase().includes(searchLower)
);
});
const selectedCount = selectedComponents.length;
const totalCost = Object.entries(poLineItems).reduce((sum, [cid, qty]) => {
const comp = supplierComponents.find((c) => Number(c.id) === Number(cid));
const price = poLinePrices[Number(cid)] ?? Number(comp?.unit_price) ?? 0;
return sum + qty * price;
}, 0);
return (
<>
{/* Search and summary bar */}
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by part number, name, or supplier ref..."
value={componentSearch}
onChange={(e) => setComponentSearch(e.target.value)}
className="pl-9 h-9 text-sm"
/>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{filteredComponents.length} of {supplierComponents.length} components
</span>
</div>
{/* Selected items summary (always visible above the scrollable area) */}
{selectedCount > 0 && (
<div className="rounded-md border border-primary/20 bg-primary/5 px-3 py-2 flex items-center justify-between">
<span className="text-xs font-medium text-primary">
{selectedCount} component(s) selected
</span>
<span className="font-mono text-sm font-semibold">
{formatCurrency(totalCost)}
</span>
</div>
)}
{/* Scrollable table */}
<div className="overflow-auto rounded-md border max-h-[45vh]">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead>Part Number</TableHead>
<TableHead>Component</TableHead>
<TableHead>Supplier Part No.</TableHead>
<TableHead className="text-right">Unit Price</TableHead>
<TableHead className="text-right">In Stock</TableHead>
<TableHead className="text-right w-[120px]">Qty to Order</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredComponents.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-6 text-center text-sm text-muted-foreground">
No components match &quot;{componentSearch}&quot;
</TableCell>
</TableRow>
)}
{filteredComponents.map((comp) => {
const cid = Number(comp.id);
const qty = poLineItems[cid] || 0;
return (
<TableRow key={cid} className={qty > 0 ? "bg-primary/5" : ""}>
<TableCell className="font-mono text-xs font-medium">
{comp.part_number as string}
</TableCell>
<TableCell className="font-medium text-sm">
{comp.name as string}
</TableCell>
<TableCell className="font-mono text-xs">
{(comp.supplier_part_number as string) || "-"}
</TableCell>
<TableCell className="text-right">
<Input
type="number"
min={0}
step="0.01"
className="h-7 w-24 text-xs font-mono text-right ml-auto"
value={poLinePrices[cid] ?? (Number(comp.unit_price) || "")}
placeholder="0.00"
onChange={(e) => {
const val = Math.max(0, Number(e.target.value) || 0);
setPoLinePrices((prev) => ({ ...prev, [cid]: val }));
}}
/>
</TableCell>
<TableCell className="text-right font-mono text-sm">
{Number(comp.stock_quantity).toLocaleString()}
</TableCell>
<TableCell className="text-right">
<Input
type="number"
min={0}
className="h-7 w-24 text-xs font-mono text-right ml-auto"
value={qty || ""}
placeholder="0"
onChange={(e) => updatePoLineQty(cid, Number(e.target.value) || 0)}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* Footer actions */}
<div className="flex items-center justify-end gap-2 pt-1">
<Button variant="outline" size="sm" onClick={() => setCreatePoOpen(false)}>
Cancel
</Button>
<Button
size="sm"
onClick={handleCreateManualPo}
disabled={creatingPo || selectedCount === 0}
>
{creatingPo ? "Creating..." : "Create PO"}
</Button>
</div>
</>
);
})()}
</DialogContent>
</Dialog>
</div>
);
}