"use client"; import { useState, useEffect, useRef } from "react"; import { Plus, Trash2 } from "lucide-react"; import { formatCurrency } from "@/lib/utils"; import type { LineItemInput } from "@/lib/validations/po"; const UOM_OPTIONS = [ { value: "pc", label: "pc — Piece" }, { value: "set", label: "set — Set" }, { value: "pk", label: "pk — Pack" }, { value: "box", label: "box — Box" }, { value: "pair", label: "pair — Pair" }, { value: "roll", label: "roll — Roll" }, { value: "kg", label: "kg — Kilogram" }, { value: "g", label: "g — Gram" }, { value: "L", label: "L — Litre" }, { value: "mL", label: "mL — Millilitre" }, { value: "m", label: "m — Metre" }, { value: "m2", label: "m² — Sq. Metre" }, { value: "hr", label: "hr — Hour" }, { value: "day", label: "day — Day" }, { value: "lump", label: "lump — Lump Sum" }, { value: "Ltr", label: "Ltr — Litre (alt)" }, ]; type ProductHit = { id: string; code: string; name: string; description: string | null; lastPrice: number | null; vendorPrices: { vendorId: string; vendorName: string; price: number }[]; }; export type AccountOption = { id: string; name: string; code: string }; interface Props { items: LineItemInput[]; onChange?: (items: LineItemInput[]) => void; readOnly?: boolean; originalItems?: LineItemInput[]; /** Label shown in the diff banner when originalItems is provided */ originalItemsLabel?: string; /** When true, show per-row account selector */ multiAccount?: boolean; accounts?: AccountOption[]; /** The PO-level default account id — pre-selected in each row dropdown */ defaultAccountId?: string; } type EditRow = { name: string; description: string; quantity: string; unit: string; size: string; unitPrice: string; gstRate: string; productId?: string; accountId?: string; }; function toEditRow(item: LineItemInput): EditRow { return { name: item.name, description: item.description ?? "", quantity: String(item.quantity), unit: item.unit, size: item.size ?? "", unitPrice: item.unitPrice ? String(item.unitPrice) : "", gstRate: String(item.gstRate ?? 0.18), productId: item.productId, accountId: item.accountId, }; } function toLineItem(row: EditRow): LineItemInput { return { name: row.name, description: row.description || undefined, quantity: parseFloat(row.quantity) || 0, unit: row.unit, size: row.size || undefined, unitPrice: parseFloat(row.unitPrice) || 0, gstRate: row.gstRate !== "" && row.gstRate != null ? parseFloat(row.gstRate) : 0.18, productId: row.productId || undefined, accountId: row.accountId || undefined, }; } function calcTotals(items: LineItemInput[]) { const taxable = items.reduce((s, i) => s + i.quantity * i.unitPrice, 0); const gst = items.reduce((s, i) => s + i.quantity * i.unitPrice * (i.gstRate ?? 0.18), 0); return { taxable, gst, grand: taxable + gst }; } function NameCell({ name, description, productId, onChange, }: { name: string; description: string; productId?: string; onChange: (name: string, description: string, pid?: string, price?: number) => void; }) { const [hits, setHits] = useState([]); const [open, setOpen] = useState(false); const timerRef = useRef | null>(null); const wrapRef = useRef(null); useEffect(() => { function handleClick(e: MouseEvent) { if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { setOpen(false); } } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, []); function handleNameInput(v: string) { onChange(v, description, undefined); if (timerRef.current) clearTimeout(timerRef.current); if (v.length < 2) { setHits([]); setOpen(false); return; } timerRef.current = setTimeout(async () => { try { const res = await fetch(`/api/products/search?q=${encodeURIComponent(v)}`); const data: ProductHit[] = await res.json(); setHits(data); setOpen(data.length > 0); } catch { setHits([]); } }, 250); } function select(hit: ProductHit) { onChange(hit.name, hit.description ?? description, hit.id, hit.lastPrice ?? undefined); setOpen(false); setHits([]); } return (
handleNameInput(e.target.value)} onFocus={() => { if (hits.length > 0) setOpen(true); }} className="w-full rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none" placeholder="Item name *" required /> {productId && ( )} {open && (
    {hits.map((hit) => (
  • ))}
)}
onChange(name, e.target.value, productId)} className="w-full rounded border border-neutral-200 px-2 py-1 text-xs text-neutral-500 focus:border-primary-500 focus:outline-none" placeholder="Description (optional)" />
); } export function LineItemsEditor({ items, onChange, readOnly = false, originalItems, originalItemsLabel, multiAccount = false, accounts = [], defaultAccountId, }: Props) { const [rows, setRows] = useState(() => items.map(toEditRow)); function updateRows(next: EditRow[]) { setRows(next); onChange?.(next.map(toLineItem)); } function update(index: number, field: keyof EditRow, value: string) { updateRows(rows.map((row, i) => (i === index ? { ...row, [field]: value } : row))); } function updateNameCell(index: number, name: string, description: string, productId?: string, price?: number) { updateRows( rows.map((row, i) => { if (i !== index) return row; return { ...row, name, description, productId: productId ?? undefined, unitPrice: price != null ? String(price) : row.unitPrice, }; }) ); } function add() { updateRows([...rows, { name: "", description: "", quantity: "1", unit: "pc", size: "", unitPrice: "", gstRate: "0.18", accountId: defaultAccountId, }]); } function remove(index: number) { updateRows(rows.filter((_, i) => i !== index)); } const accountMap = Object.fromEntries(accounts.map((a) => [a.id, a])); // ── Read-only view ─────────────────────────────────────────────────────────── if (readOnly) { const hasSize = items.some((item) => item.size); const hasDiff = originalItems && originalItems.length > 0; const showAccount = items.some((item) => item.accountId); const { taxable, gst, grand } = calcTotals(items); return (
{hasDiff && (

{originalItemsLabel ?? "Line items were amended by manager. Current values shown; original values shown with strikethrough."}

)} {hasSize && } {showAccount && } {items.map((item, i) => { const orig = originalItems?.[i]; const qtyChanged = orig && Number(orig.quantity) !== item.quantity; const priceChanged = orig && Number(orig.unitPrice) !== item.unitPrice; const nameChanged = orig && orig.name !== item.name; const taxableAmt = item.quantity * item.unitPrice; const gstAmt = taxableAmt * (item.gstRate ?? 0.18); const acct = item.accountId ? accountMap[item.accountId] : null; return ( {hasSize && } {showAccount && ( )} ); })}
Item Qty UnitSizeUnit Price Taxable GST% TotalAccount
{nameChanged && {orig.name}} {item.name} {item.description && ( {item.description} )} {qtyChanged && {Number(orig.quantity)}} {item.quantity} {item.unit}{item.size ?? "—"} {priceChanged && {formatCurrency(Number(orig.unitPrice))}} {formatCurrency(item.unitPrice)} {formatCurrency(taxableAmt)} {Math.round((item.gstRate ?? 0.18) * 100)}% {formatCurrency(taxableAmt + gstAmt)} {acct ? acct.code : "—"}
Taxable subtotal {formatCurrency(taxable)}
GST {formatCurrency(gst)}
Grand Total {formatCurrency(grand)}
); } // ── Edit view ──────────────────────────────────────────────────────────────── const liveItems = rows.map(toLineItem); const { taxable, gst, grand } = calcTotals(liveItems); return (
{multiAccount && } {rows.map((row, i) => { const taxableAmt = (parseFloat(row.quantity) || 0) * (parseFloat(row.unitPrice) || 0); const gstR = parseFloat(row.gstRate) || 0.18; return ( {multiAccount && ( )} ); })}
Item Qty Unit Size Unit Price GST%AccountTotal
updateNameCell(i, name, description, pid, price)} /> update(i, "quantity", e.target.value)} className="w-20 rounded border border-neutral-200 px-2 py-1.5 text-sm text-right focus:border-primary-500 focus:outline-none" /> update(i, "size", e.target.value)} className="w-24 rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none" placeholder="e.g. 10mm" /> update(i, "unitPrice", e.target.value)} placeholder="0.00" className="w-28 rounded border border-neutral-200 px-2 py-1.5 text-sm text-right focus:border-primary-500 focus:outline-none" /> {formatCurrency(taxableAmt * (1 + gstR))}
Taxable subtotal {formatCurrency(taxable)}
GST {formatCurrency(gst)}
Grand Total {formatCurrency(grand)}
); }