parseFloat('0') is falsy in JS so `|| 0.18` silently discarded the user's
explicit 0% selection. Replaced with an explicit empty-string guard.
Adds e2e spec gst-rate.spec.ts covering all five GST rates (0/5/12/18/28%).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
480 lines
21 KiB
TypeScript
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: 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<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>
|
|
);
|
|
}
|