pelagia-portal/App/components/po/po-line-items-editor.tsx
2026-05-18 23:18:58 +05:30

480 lines
21 KiB
TypeScript

"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: 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<ProductHit[]>([]);
const [open, setOpen] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wrapRef = useRef<HTMLDivElement>(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 (
<div ref={wrapRef} className="relative w-full space-y-1">
<div className="relative">
<input
value={name}
onChange={(e) => 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 && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-primary-500 pointer-events-none select-none">
</span>
)}
{open && (
<ul className="absolute z-50 left-0 right-0 top-full mt-0.5 max-h-52 overflow-y-auto rounded-lg border border-neutral-200 bg-white shadow-lg text-sm">
{hits.map((hit) => (
<li key={hit.id}>
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); select(hit); }}
className="w-full text-left px-3 py-2 hover:bg-primary-50 flex items-start gap-2"
>
<span className="font-mono text-xs text-neutral-400 shrink-0 mt-0.5 w-24 truncate">{hit.code}</span>
<span className="flex-1 min-w-0">
<span className="font-medium text-neutral-900">{hit.name}</span>
{hit.description && (
<span className="block text-xs text-neutral-500 truncate">{hit.description}</span>
)}
{hit.vendorPrices.length > 0 && (
<span className="block text-xs text-neutral-400 truncate mt-0.5">
{hit.vendorPrices.map(vp => `${vp.vendorName}: ${formatCurrency(vp.price)}`).join(" · ")}
</span>
)}
</span>
{hit.lastPrice != null && hit.vendorPrices.length === 0 && (
<span className="shrink-0 text-xs text-neutral-500">{formatCurrency(hit.lastPrice)}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
<input
value={description}
onChange={(e) => 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)"
/>
</div>
);
}
export function LineItemsEditor({
items,
onChange,
readOnly = false,
originalItems,
originalItemsLabel,
multiAccount = false,
accounts = [],
defaultAccountId,
}: Props) {
const [rows, setRows] = useState<EditRow[]>(() => 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 (
<div className="overflow-x-auto">
{hasDiff && (
<p className="mb-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-1.5">
{originalItemsLabel ?? "Line items were amended by manager. Current values shown; original values shown with strikethrough."}
</p>
)}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200">
<th className="pb-2 text-left font-medium text-neutral-600 w-full">Item</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Qty</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-3 whitespace-nowrap">Unit</th>
{hasSize && <th className="pb-2 text-left font-medium text-neutral-600 pl-3 whitespace-nowrap">Size</th>}
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Unit Price</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Taxable</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">GST%</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Total</th>
{showAccount && <th className="pb-2 text-left font-medium text-neutral-600 pl-4 whitespace-nowrap">Account</th>}
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{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 (
<tr key={i}>
<td className="py-2 pr-4">
{nameChanged && <span className="block text-neutral-400 line-through text-xs">{orig.name}</span>}
<span className={nameChanged ? "text-amber-700 font-medium" : "text-neutral-900"}>{item.name}</span>
{item.description && (
<span className="block text-xs text-neutral-500 mt-0.5">{item.description}</span>
)}
</td>
<td className="py-2 pl-4 text-right">
{qtyChanged && <span className="block text-neutral-400 line-through text-xs">{Number(orig.quantity)}</span>}
<span className={qtyChanged ? "text-amber-700 font-medium" : ""}>{item.quantity}</span>
</td>
<td className="py-2 pl-3 text-neutral-500">{item.unit}</td>
{hasSize && <td className="py-2 pl-3 text-neutral-500">{item.size ?? "—"}</td>}
<td className="py-2 pl-4 text-right">
{priceChanged && <span className="block text-neutral-400 line-through text-xs">{formatCurrency(Number(orig.unitPrice))}</span>}
<span className={priceChanged ? "text-amber-700 font-medium" : ""}>{formatCurrency(item.unitPrice)}</span>
</td>
<td className="py-2 pl-4 text-right">{formatCurrency(taxableAmt)}</td>
<td className="py-2 pl-4 text-right text-neutral-500">{Math.round((item.gstRate ?? 0.18) * 100)}%</td>
<td className="py-2 pl-4 text-right">{formatCurrency(taxableAmt + gstAmt)}</td>
{showAccount && (
<td className="py-2 pl-4 text-xs text-neutral-500 font-mono">
{acct ? acct.code : "—"}
</td>
)}
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-neutral-100">
<td colSpan={hasSize ? (showAccount ? 6 : 5) : (showAccount ? 5 : 4)} className="pt-3 text-right text-sm text-neutral-500">Taxable subtotal</td>
<td className="pt-3 pl-4 text-right text-sm text-neutral-700" colSpan={3}>{formatCurrency(taxable)}</td>
</tr>
<tr>
<td colSpan={hasSize ? (showAccount ? 6 : 5) : (showAccount ? 5 : 4)} className="py-0.5 text-right text-sm text-neutral-500">GST</td>
<td className="py-0.5 pl-4 text-right text-sm text-neutral-700" colSpan={3}>{formatCurrency(gst)}</td>
</tr>
<tr className="border-t border-neutral-200">
<td colSpan={hasSize ? (showAccount ? 6 : 5) : (showAccount ? 5 : 4)} className="pt-2 text-right text-sm font-semibold text-neutral-900">Grand Total</td>
<td className="pt-2 pl-4 text-right text-sm font-semibold text-neutral-900" colSpan={3}>{formatCurrency(grand)}</td>
</tr>
</tfoot>
</table>
</div>
);
}
// ── Edit view ────────────────────────────────────────────────────────────────
const liveItems = rows.map(toLineItem);
const { taxable, gst, grand } = calcTotals(liveItems);
return (
<div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200">
<th className="pb-2 text-left font-medium text-neutral-600 w-full">Item</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Qty</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-3 whitespace-nowrap">Unit</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-3 whitespace-nowrap">Size</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Unit Price</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">GST%</th>
{multiAccount && <th className="pb-2 text-left font-medium text-neutral-600 pl-4 whitespace-nowrap">Account</th>}
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Total</th>
<th className="pb-2 pl-4 w-8" />
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{rows.map((row, i) => {
const taxableAmt = (parseFloat(row.quantity) || 0) * (parseFloat(row.unitPrice) || 0);
const gstR = parseFloat(row.gstRate) || 0.18;
return (
<tr key={i}>
<td className="py-2 pr-4">
<NameCell
name={row.name}
description={row.description}
productId={row.productId}
onChange={(name, description, pid, price) => updateNameCell(i, name, description, pid, price)}
/>
</td>
<td className="py-2 pl-4">
<input
type="number" min="0" step="any"
value={row.quantity}
onChange={(e) => 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"
/>
</td>
<td className="py-2 pl-3">
<select
value={row.unit}
onChange={(e) => update(i, "unit", e.target.value)}
className="w-32 rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none"
>
{UOM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</td>
<td className="py-2 pl-3">
<input
value={row.size}
onChange={(e) => 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"
/>
</td>
<td className="py-2 pl-4">
<input
type="number" min="0" step="any"
value={row.unitPrice}
onChange={(e) => 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"
/>
</td>
<td className="py-2 pl-4">
<select
value={row.gstRate}
onChange={(e) => update(i, "gstRate", e.target.value)}
className="w-20 rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none"
>
<option value="0">0%</option>
<option value="0.05">5%</option>
<option value="0.12">12%</option>
<option value="0.18">18%</option>
<option value="0.28">28%</option>
</select>
</td>
{multiAccount && (
<td className="py-2 pl-4">
<select
value={row.accountId ?? defaultAccountId ?? ""}
onChange={(e) => update(i, "accountId", e.target.value)}
className="w-36 rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none"
>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.name} ({a.code})</option>
))}
</select>
</td>
)}
<td className="py-2 pl-4 text-right text-sm">
{formatCurrency(taxableAmt * (1 + gstR))}
</td>
<td className="py-2 pl-4">
<button
type="button"
aria-label="Remove line item"
onClick={() => remove(i)}
disabled={rows.length === 1}
className="text-neutral-400 hover:text-danger disabled:opacity-30 transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-neutral-100">
<td colSpan={multiAccount ? 7 : 6} className="pt-3 text-right text-sm text-neutral-500">Taxable subtotal</td>
<td className="pt-3 pl-4 text-right text-sm text-neutral-700" colSpan={2}>{formatCurrency(taxable)}</td>
</tr>
<tr>
<td colSpan={multiAccount ? 7 : 6} className="py-0.5 text-right text-sm text-neutral-500">GST</td>
<td className="py-0.5 pl-4 text-right text-sm text-neutral-700" colSpan={2}>{formatCurrency(gst)}</td>
</tr>
<tr className="border-t border-neutral-200">
<td colSpan={multiAccount ? 7 : 6} className="pt-2 text-right text-sm font-semibold text-neutral-900">Grand Total</td>
<td className="pt-2 pl-4 text-right text-sm font-semibold text-neutral-900" colSpan={2}>{formatCurrency(grand)}</td>
</tr>
</tfoot>
</table>
</div>
<button
type="button"
onClick={add}
className="mt-3 flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 font-medium"
>
<Plus className="h-4 w-4" />
Add line item
</button>
</div>
);
}