mega-plant/components/PlantRegisterTable.tsx
Mega Plant b70fc8c5c7 Round 2 (Parts B-D): asset register restructure, type field, sequential codes, flat sortable table, cost_lines, removed synthetic code, fail loudly
- B.1: removed running_cost from PB monthly_income schema
- B.2: 40 of 40 machines now have income (was 32/40)
- B.3: removed syntheticCode() + auto-create branch; deleted 2 ghost Z* assets; seed fails loudly on unmatched
- C.1: collapsed 13 cost-component rows into 5 machines + new cost_lines collection; Finance/Insurance/Tracking categories removed from assets
- C.2: type field on assets derived from code prefix
- C.3: sequential codes SC01-07, OC01-02, PC01-02; fixed SITE CONTANER spelling
- C.4: flat sortable plant register table with type filter pills
- C.5: wired filter input on plant register
- D: verified term 2 (insurance) reconciles for all 5 financed machines
2026-09-12 10:24:26 +02:00

184 lines
8.1 KiB
TypeScript

"use client";
import { useState, useMemo } from "react";
import { Search, ArrowUpDown, AlertTriangle } from "lucide-react";
import { ZAR } from "@/lib/utils";
type Asset = {
id: string;
code: string;
name: string;
category: string;
type: string;
supplier: string;
monthly_excl: number;
monthly_incl: number;
purchase_value: number;
is_provisional: boolean;
};
type SortKey = "type" | "code" | "name" | "monthly_incl" | "monthly_excl" | "purchase_value" | "supplier";
export function PlantRegisterTable({ assets }: { assets: Asset[] }) {
const [query, setQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("All");
const [sortKey, setSortKey] = useState<SortKey>("type");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const types = useMemo(() => {
const set = new Set<string>();
for (const a of assets) if (a.type) set.add(a.type);
return ["All", ...Array.from(set).sort()];
}, [assets]);
const filtered = useMemo(() => {
let list = assets;
if (typeFilter !== "All") list = list.filter((a) => a.type === typeFilter);
const q = query.trim().toLowerCase();
if (q) {
list = list.filter((a) =>
a.code.toLowerCase().includes(q) ||
a.name.toLowerCase().includes(q) ||
a.type.toLowerCase().includes(q) ||
(a.supplier || "").toLowerCase().includes(q)
);
}
list = [...list].sort((a, b) => {
const av = (a as any)[sortKey] ?? "";
const bv = (b as any)[sortKey] ?? "";
let cmp = 0;
if (typeof av === "number" && typeof bv === "number") cmp = av - bv;
else cmp = String(av).localeCompare(String(bv));
return sortDir === "asc" ? cmp : -cmp;
});
return list;
}, [assets, query, typeFilter, sortKey, sortDir]);
const totals = useMemo(() => {
const t = { monthly_excl: 0, monthly_incl: 0, purchase_value: 0 };
for (const a of filtered) {
t.monthly_excl += a.monthly_excl || 0;
t.monthly_incl += a.monthly_incl || 0;
t.purchase_value += a.purchase_value || 0;
}
return t;
}, [filtered]);
const headerCell = (key: SortKey, label: string, align: "left" | "right" = "left") => (
<th
onClick={() => {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
}}
className={`text-${align} py-3 px-3 font-medium cursor-pointer select-none hover:text-[#003D5B] dark:hover:text-white transition`}
>
<span className={`inline-flex items-center gap-1 ${align === "right" ? "justify-end" : ""}`}>
{label}
<ArrowUpDown className={`w-3 h-3 ${sortKey === key ? "text-[#30638E]" : "text-gray-300"}`} />
</span>
</th>
);
return (
<section className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 p-6 shadow-sm">
{/* Header / search / type pills */}
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Plant Register</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{filtered.length} of {assets.length} items · sorted by {sortKey} {sortDir}
</p>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter code, name, supplier…"
className="h-9 pl-9 pr-3 w-64 bg-gray-50 dark:bg-white/5 border border-transparent rounded-lg text-sm placeholder:text-gray-400 focus:bg-white dark:focus:bg-[#0f1419] focus:border-[#30638E] focus:outline-none"
/>
</div>
</div>
{/* Type filter pills */}
<div className="flex flex-wrap gap-1.5 mb-4">
{types.map((t) => {
const count = t === "All" ? assets.length : assets.filter((a) => a.type === t).length;
return (
<button
key={t}
onClick={() => setTypeFilter(t)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${
typeFilter === t
? "bg-[#003D5B] text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
{t} <span className="ml-1 text-[10px] opacity-60">({count})</span>
</button>
);
})}
</div>
{/* Table */}
<div className="overflow-x-auto rounded-lg border border-gray-100 dark:border-white/5">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10">
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-200 dark:border-white/10 bg-gray-50/95 dark:bg-[#1a222b]/95 backdrop-blur">
{headerCell("code", "Code")}
{headerCell("name", "Name")}
{headerCell("type", "Type")}
{headerCell("supplier", "Supplier")}
{headerCell("monthly_excl", "Monthly excl", "right")}
{headerCell("monthly_incl", "Monthly incl", "right")}
{headerCell("purchase_value", "Purchase value", "right")}
</tr>
</thead>
<tbody>
{filtered.map((a) => (
<tr key={a.id} className="border-b border-gray-50 dark:border-white/5 hover:bg-gray-50/40 dark:hover:bg-white/[0.02]">
<td className="py-2.5 px-3">
{a.is_provisional && (
<AlertTriangle className="inline w-3 h-3 text-[#EDAE49] mr-1" aria-label="provisional code" />
)}
<span className={`font-mono text-xs px-2 py-1 rounded-md font-semibold ${
a.is_provisional
? "bg-[#EDAE49]/10 text-[#EDAE49]"
: "bg-[#30638E]/10 text-[#30638E]"
}`}>
{a.code || "—"}
</span>
</td>
<td className="py-2.5 px-3 font-medium text-[#003D5B] dark:text-white whitespace-nowrap">
{a.name}
{a.is_provisional && (
<span className="ml-2 text-[10px] uppercase tracking-wider text-[#EDAE49] font-semibold">provisional</span>
)}
</td>
<td className="py-2.5 px-3 text-gray-600 dark:text-gray-400 whitespace-nowrap">{a.type || "—"}</td>
<td className="py-2.5 px-3 text-gray-500 dark:text-gray-400">{a.supplier || "—"}</td>
<td className="py-2.5 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{a.monthly_excl ? ZAR(a.monthly_excl) : "—"}</td>
<td className="py-2.5 px-3 text-right tabular-nums text-[#003D5B] dark:text-white font-medium">{a.monthly_incl ? ZAR(a.monthly_incl) : "—"}</td>
<td className="py-2.5 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{a.purchase_value ? ZAR(a.purchase_value) : "—"}</td>
</tr>
))}
<tr className="bg-gradient-to-r from-[#003D5B]/5 dark:from-white/[0.03] to-transparent border-t-2 border-[#003D5B]/20">
<td colSpan={4} className="py-3 px-3 text-right font-semibold text-[#003D5B] dark:text-white">
Totals ({filtered.length} items)
</td>
<td className="py-3 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white">{ZAR(totals.monthly_excl)}</td>
<td className="py-3 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white">{ZAR(totals.monthly_incl)}</td>
<td className="py-3 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white">{ZAR(totals.purchase_value)}</td>
</tr>
</tbody>
</table>
</div>
{filtered.length === 0 && (
<div className="mt-6 text-center text-sm text-gray-500 dark:text-gray-400">
No items match "{query}" in {typeFilter}.
</div>
)}
</section>
);
}