1557 lines
66 KiB
TypeScript
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 "Add Supplier" 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 "{deleteTarget?.name}"?
|
|
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 "{deletePoTarget?.po_number}"?
|
|
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 "{createPoSupplier?.name}".
|
|
</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 "{componentSearch}"
|
|
</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>
|
|
);
|
|
}
|