Part A — Round 3 regressions: - A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure - A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum) - A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart) - A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00) - A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack' Part B — Mobile standard: - B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses - B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char - B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running - B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container - B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters - B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile - B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance AGENTS.md created with the standing mobile checklist rule.
205 lines
9.2 KiB
TypeScript
205 lines
9.2 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-11 min-h-[44px] pl-9 pr-3 w-full sm: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 — Round 4 Part B.3: pills on lg+, dropdown below lg to avoid 13-row wrap */}
|
|
<div className="mb-4">
|
|
{/* Desktop: pills */}
|
|
<div className="hidden lg:flex flex-wrap gap-1.5">
|
|
{types.map((t) => {
|
|
const count = t === "All" ? assets.length : assets.filter((a) => a.type === t).length;
|
|
return (
|
|
<button
|
|
key={t}
|
|
onClick={() => setTypeFilter(t)}
|
|
className={`min-h-[44px] px-3 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>
|
|
{/* Mobile: dropdown */}
|
|
<div className="lg:hidden">
|
|
<label className="sr-only" htmlFor="type-filter">Filter by type</label>
|
|
<select
|
|
id="type-filter"
|
|
value={typeFilter}
|
|
onChange={(e) => setTypeFilter(e.target.value)}
|
|
className="w-full min-h-[44px] px-3 text-sm rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5"
|
|
>
|
|
{types.map((t) => {
|
|
const count = t === "All" ? assets.length : assets.filter((a) => a.type === t).length;
|
|
return <option key={t} value={t}>{t} ({count})</option>;
|
|
})}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table — Round 4 Part B.4: sticky first column + touch scroll + right-edge fade */}
|
|
<div
|
|
className="overflow-x-auto rounded-lg border border-gray-100 dark:border-white/5 relative"
|
|
style={{ WebkitOverflowScrolling: "touch" }}
|
|
>
|
|
<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 sticky left-0 z-10 bg-white dark:bg-[#131a21]">
|
|
{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>
|
|
);
|
|
}
|