- 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
184 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|