mega-plant/components/PlantRegisterTable.tsx
David dc7024d98a Fix: the mobile table was mislabelled and clipping its own totals
A screenshot at phone width showed an "INVOICE | LINES | TOTAL | STATUS"
header row while the data under LINES was money. The header and its data cell
had drifted apart: the earlier pass hid the Lines <td> but not the matching
<th>, because the loop that added the hide classes only matched text-left
headers and Lines is text-right. So the Total values rendered under a heading
that named a column which no longer existed.

The same screenshot showed the totals clipped — "41,710.50" reading as
"710,50". With only three columns left, the 520px min-width was still forcing
a sideways scroll, and the opaque sticky first column then sat on top of the
left of the money values. The mobile floor is gone, so the three remaining
columns size to their content, and the sticky and status gutters are trimmed
at mobile widths to let them fit 375px.

Below sm the table is now exactly Invoice, Total, Status — and every header
matches a visible cell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 15:34:19 +02:00

830 lines
No EOL
36 KiB
TypeScript

"use client";
import { useState, useMemo, useEffect } from "react";
import { Search, ArrowUpDown, AlertTriangle, Pencil, Check, Power, Trash2, Loader2, Undo2 } from "lucide-react";
import { cn } from "@/lib/utils";
// The register view only carries what it renders. `supplier`,
// `monthly_excl`, `monthly_incl`, and `purchase_value` still live on the
// PocketBase `assets` collection and are used by other pages — they are just
// not columns on this table, so they aren't sent here.
type Asset = {
id: string;
code: string;
name: string;
category: string;
type: string;
is_provisional: boolean;
is_active: boolean;
};
type SortKey = "type" | "code" | "name";
type StatusFilter = "all" | "active" | "inactive";
type EditableField = "code" | "name" | "type";
type Impact = {
asset: { id: string; code: string; name: string };
counts: Record<string, number>;
total: number;
};
// Cells the user can edit inline, and how wide each should render.
const EDITABLE: { field: EditableField; label: string; placeholder: string; mono?: boolean }[] = [
{ field: "code", label: "Code", placeholder: "e.g. TLB08", mono: true },
{ field: "name", label: "Name", placeholder: "Machine name" },
{ field: "type", label: "Type", placeholder: "e.g. Backhoe Loader" },
];
export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[] }) {
const [assets, setAssets] = useState<Asset[]>(initialAssets);
const [query, setQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("All");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [sortKey, setSortKey] = useState<SortKey>("type");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [editing, setEditing] = useState(false);
// Inline cell edits. `drafts` holds unsaved text keyed by `${assetId}:${field}`
// so several cells can be mid-edit at once. `dirty` is the set of keys whose
// draft differs from the stored value — those are what Save commits and what
// Discard reverts.
//
// `activeDrafts` does the same job for the Active/Inactive toggle. It used to
// PATCH on click, which meant toggling a machine left Save disabled and the
// change looked like it hadn't stuck. Now every edit on this screen — text
// and status alike — is a draft committed by one Save.
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [dirty, setDirty] = useState<Set<string>>(new Set());
const [activeDrafts, setActiveDrafts] = useState<Record<string, boolean>>({});
const [saveError, setSaveError] = useState<string | null>(null);
const [savingAll, setSavingAll] = useState(false);
const [savingId, setSavingId] = useState<string | null>(null);
const [errorId, setErrorId] = useState<string | null>(null);
const draftKey = (id: string, field: EditableField) => `${id}:${field}`;
const hasUnsaved = dirty.size > 0 || Object.keys(activeDrafts).length > 0;
const changeCount = dirty.size + Object.keys(activeDrafts).length;
const setDraft = (id: string, field: EditableField, value: string) => {
const k = draftKey(id, field);
const original = String(assets.find((a) => a.id === id)?.[field] ?? "");
setDrafts((d) => ({ ...d, [k]: value }));
setDirty((s) => {
const next = new Set(s);
if (value.trim() === original) next.delete(k);
else next.add(k);
return next;
});
};
const draftValue = (a: Asset, field: EditableField) => {
const k = draftKey(a.id, field);
return k in drafts ? drafts[k] : String(a[field] ?? "");
};
// The status the row should currently *look* like — the pending draft if
// there is one, otherwise the stored value.
const effectiveActive = (a: Asset) =>
a.id in activeDrafts ? activeDrafts[a.id] : a.is_active;
const activeIsDirty = (a: Asset) =>
a.id in activeDrafts && activeDrafts[a.id] !== a.is_active;
// Stage an active/inactive toggle. No request here — Save commits it.
const stageActive = (id: string, next: boolean) => {
const original = assets.find((a) => a.id === id)?.is_active;
setActiveDrafts((d) => {
const nextDrafts = { ...d };
// Toggling back to the stored value clears the pending change entirely.
if (next === original) delete nextDrafts[id];
else nextDrafts[id] = next;
return nextDrafts;
});
};
const clearRowDrafts = (id: string) => {
for (const { field } of EDITABLE) {
setDirty((s) => {
const next = new Set(s);
next.delete(draftKey(id, field));
return next;
});
}
setDrafts((d) => {
const next = { ...d };
for (const { field } of EDITABLE) delete next[draftKey(id, field)];
return next;
});
setActiveDrafts((d) => {
const next = { ...d };
delete next[id];
return next;
});
};
// Enter commits the row, Escape reverts the whole row back to stored values.
const onCellKeyDown = (e: React.KeyboardEvent, a: Asset) => {
if (e.key === "Escape") {
e.preventDefault();
clearRowDrafts(a.id);
} else if (e.key === "Enter") {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
};
const saveAll = async () => {
if (!hasUnsaved) return;
setSavingAll(true);
setSaveError(null);
// Group the pending edits per asset so each one costs a single PATCH.
const byAsset = new Map<string, Partial<Record<EditableField, string>> & { is_active?: boolean }>();
for (const k of dirty) {
const [id, field] = k.split(":") as [string, EditableField];
const patch = byAsset.get(id) ?? {};
patch[field] = (drafts[k] ?? "").trim();
byAsset.set(id, patch);
}
for (const [id, isActive] of Object.entries(activeDrafts)) {
if (isActive === assets.find((a) => a.id === id)?.is_active) continue;
const patch = byAsset.get(id) ?? {};
patch.is_active = isActive;
byAsset.set(id, patch);
}
if (byAsset.size === 0) {
setSavingAll(false);
return;
}
const snapshot = assets;
// Optimistic: apply locally, then roll back on the first failure.
setAssets((curr) =>
curr.map((a) => {
const patch = byAsset.get(a.id);
return patch ? { ...a, ...patch } : a;
}),
);
try {
for (const [id, patch] of byAsset) {
setSavingId(id);
const res = await fetch(`/api/assets/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error || "Update failed");
}
// Success — clear the draft buffer and leave edit mode.
setDrafts({});
setDirty(new Set());
setActiveDrafts({});
setEditing(false);
} catch (e: any) {
setAssets(snapshot);
setSaveError(e?.message || "Save failed");
// Flash the row that was being saved so it's obvious which one failed.
const firstId = byAsset.keys().next().value;
if (firstId) {
setErrorId(firstId as string);
window.setTimeout(() => setErrorId(null), 2000);
}
} finally {
setSavingId(null);
setSavingAll(false);
}
};
const discardAll = () => {
setDrafts({});
setDirty(new Set());
setActiveDrafts({});
setSaveError(null);
setEditing(false);
};
// Delete confirmation state. `impact` is loaded before the dialog opens so
// the row counts are real, not a guess.
const [deleteTarget, setDeleteTarget] = useState<Asset | null>(null);
const [impact, setImpact] = useState<Impact | null>(null);
const [impactLoading, setImpactLoading] = useState(false);
const [confirmText, setConfirmText] = useState("");
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
if (!deleteTarget) return;
let cancelled = false;
setImpactLoading(true);
setImpact(null);
setConfirmText("");
setDeleteError(null);
fetch(`/api/assets/${deleteTarget.id}/impact`)
.then((r) => r.json())
.then((d) => { if (!cancelled && d.ok) setImpact(d); })
.catch(() => { if (!cancelled) setDeleteError("Could not load what this delete affects."); })
.finally(() => { if (!cancelled) setImpactLoading(false); });
return () => { cancelled = true; };
}, [deleteTarget]);
// Lock body scroll while the modal is open, same as the mobile nav drawer.
useEffect(() => {
if (!deleteTarget) return;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, [deleteTarget]);
// Escape closes the dialog unless a delete is already in flight.
useEffect(() => {
if (!deleteTarget) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") closeDialog(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteTarget, deleting]);
const closeDialog = () => {
if (deleting) return;
setDeleteTarget(null);
setImpact(null);
setConfirmText("");
setDeleteError(null);
};
const confirmDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
setDeleteError(null);
try {
const res = await fetch(`/api/assets/${deleteTarget.id}`, { method: "DELETE" });
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error || "Delete failed");
setAssets((curr) => curr.filter((a) => a.id !== deleteTarget.id));
setDeleteTarget(null);
setImpact(null);
setConfirmText("");
// If the deleted row was the only inactive one, fall back to All so the
// user isn't left staring at an empty table.
setStatusFilter((f) => (f === "inactive" ? "all" : f));
} catch (e: any) {
setDeleteError(e?.message || "Delete failed");
} finally {
setDeleting(false);
}
};
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]);
// Counts reflect pending toggles so the All/Active/Inactive numbers move as
// you stage them — otherwise the toggle looks like it did nothing.
const statusCounts = useMemo(() => {
const c = { all: assets.length, active: 0, inactive: 0 };
for (const a of assets) (effectiveActive(a) ? c.active++ : c.inactive++);
return c;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assets, activeDrafts]);
const filtered = useMemo(() => {
let list = assets;
if (statusFilter === "active") {
// Rows with a staged status change stay visible while editing, otherwise
// the row you just toggled disappears and it reads as a failed save.
list = list.filter((a) => effectiveActive(a) || activeIsDirty(a));
} else if (statusFilter === "inactive") {
list = list.filter((a) => !effectiveActive(a) || activeIsDirty(a));
}
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)
);
}
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;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assets, query, typeFilter, statusFilter, sortKey, sortDir, activeDrafts]);
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-surface rounded-2xl border border-gray-100 dark:border-border p-6 shadow-sm">
{/* Header / search / edit button */}
<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="flex items-center gap-2">
<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, type…"
className="h-11 min-h-[44px] pl-9 pr-3 w-full sm:w-56 bg-gray-50 dark:bg-white/5 border border-transparent rounded-lg text-sm placeholder:text-gray-400 focus:bg-white dark:focus:bg-white/10 focus:border-[#30638E] focus:outline-none"
/>
</div>
{!editing ? (
<button
type="button"
onClick={() => { setEditing(true); setSaveError(null); }}
aria-label="Edit table"
className="inline-flex items-center gap-2 min-h-[44px] min-w-[44px] px-4 rounded-lg text-sm font-semibold bg-[#30638E] text-white hover:bg-[#003D5B] transition shadow-sm"
>
<Pencil className="w-4 h-4" />
<span>Edit</span>
</button>
) : (
<div className="flex items-center gap-2">
{hasUnsaved && (
<button
type="button"
onClick={discardAll}
disabled={savingAll}
aria-label="Discard changes"
className="inline-flex items-center gap-2 min-h-[44px] min-w-[44px] px-3 rounded-lg text-sm font-semibold text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5 transition disabled:opacity-40"
>
<Undo2 className="w-4 h-4" />
<span className="hidden sm:inline">Discard</span>
</button>
)}
<button
type="button"
onClick={saveAll}
disabled={savingAll || !hasUnsaved}
aria-label="Save changes"
className={cn(
"inline-flex items-center gap-2 min-h-[44px] min-w-[44px] px-4 rounded-lg text-sm font-semibold transition shadow-sm",
hasUnsaved
? "bg-[#00798C] text-white hover:bg-[#006677]"
: "bg-[#00798C]/40 text-white cursor-not-allowed"
)}
>
{savingAll ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
<span>{savingAll ? "Saving…" : "Save"}</span>
</button>
</div>
)}
</div>
</div>
{/* Edit-mode banner / unsaved changes indicator */}
{editing && (
<div
className={cn(
"mb-3 flex flex-wrap items-center justify-between gap-2 rounded-xl px-3 py-2.5 border text-xs",
saveError
? "bg-[#D1495B]/[0.06] border-[#D1495B]/25 text-[#D1495B]"
: hasUnsaved
? "bg-[#EDAE49]/[0.08] border-[#EDAE49]/25 text-[#EDAE49]"
: "bg-[#30638E]/[0.06] border-[#30638E]/20 text-[#30638E]"
)}
>
<span className="font-medium">
{saveError
? saveError
: hasUnsaved
? `${changeCount} unsaved ${changeCount === 1 ? "change" : "changes"} — edit Code, Name, Type, or the active toggle, then press Save.`
: "Edit mode: Code, Name, Type, and the active toggle are editable. Enter commits a cell, Escape reverts a row."}
</span>
{hasUnsaved && <span className="font-semibold">Unsaved</span>}
</div>
)}
{/* Status filter — segmented toggle group at every breakpoint.
Only 3 options, so unlike the 13-item type filter it never needs to
collapse into a dropdown below lg. */}
<div className="mb-3">
<div
role="group"
aria-label="Filter by status"
className="inline-flex p-1 rounded-xl bg-gray-100 dark:bg-white/5 gap-1 w-full sm:w-auto"
>
{([
{ id: "all", label: "All", count: statusCounts.all },
{ id: "active", label: "Active", count: statusCounts.active },
{ id: "inactive", label: "Inactive", count: statusCounts.inactive },
] as const).map((opt) => {
const on = statusFilter === opt.id;
return (
<button
key={opt.id}
type="button"
onClick={() => setStatusFilter(opt.id)}
aria-pressed={on}
className={cn(
"flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 min-h-[44px] px-3 sm:px-4 rounded-lg text-sm font-medium transition",
on
? "bg-[#003D5B] text-white shadow-sm"
: "text-gray-600 dark:text-gray-400 hover:bg-white dark:hover:bg-white/10"
)}
>
<span>{opt.label}</span>
<span
className={cn(
"tabular-nums text-xs",
on ? "text-white/70" : "text-gray-400 dark:text-text-soft"
)}
>
{opt.count}
</span>
</button>
);
})}
</div>
</div>
{/* Type filter — Round 4 Part B.3: pills on lg+, dropdown below lg */}
<div className="mb-4">
<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={cn(
"min-h-[44px] px-3 rounded-lg text-xs font-medium transition",
typeFilter === t
? "bg-[#30638E] 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>
<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 */}
<div
className="overflow-x-auto rounded-lg border border-gray-100 dark:border-border 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-surface-muted/95 backdrop-blur">
{headerCell("code", "Code")}
{headerCell("name", "Name")}
{headerCell("type", "Type")}
<th className="text-center py-3 px-3 font-medium">Status</th>
{editing && <th className="text-center py-3 px-3 font-medium">Delete</th>}
</tr>
</thead>
<tbody>
{filtered.map((a) => {
const saving = savingId === a.id;
const errored = errorId === a.id;
const shownActive = effectiveActive(a);
const rowDirty =
EDITABLE.some((f) => dirty.has(draftKey(a.id, f.field))) ||
activeIsDirty(a);
return (
<tr
key={a.id}
className={cn(
"border-b border-gray-50 dark:border-border hover:bg-gray-50/40 dark:hover:bg-white/[0.02] transition",
!shownActive && !rowDirty && "opacity-60",
errored && "bg-[#D1495B]/10",
rowDirty && "bg-[#EDAE49]/[0.06]"
)}
>
{/* Code — inline editable */}
<td className="py-2.5 px-3 sticky left-0 z-10 bg-white dark:bg-surface">
{editing ? (
<div className="flex items-center gap-1.5">
{a.is_provisional && (
<AlertTriangle className="shrink-0 w-3 h-3 text-[#EDAE49]" aria-label="provisional code" />
)}
<input
value={draftValue(a, "code")}
onChange={(e) => setDraft(a.id, "code", e.target.value)}
onKeyDown={(e) => onCellKeyDown(e, a)}
aria-label={`Code for ${a.name}`}
placeholder="—"
maxLength={15}
className={cn(
"w-24 min-h-[44px] px-2 rounded-md text-xs font-mono font-semibold border bg-gray-50 dark:bg-white/5 focus:bg-white dark:focus:bg-white/10 focus:outline-none",
dirty.has(draftKey(a.id, "code"))
? "border-[#EDAE49] text-[#EDAE49]"
: "border-transparent focus:border-[#30638E]"
)}
/>
</div>
) : (
<div className="flex items-center">
{a.is_provisional && (
<AlertTriangle className="inline w-3 h-3 text-[#EDAE49] mr-1" aria-label="provisional code" />
)}
<span className={cn(
"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>
</div>
)}
</td>
{/* Name — inline editable */}
<td className="py-2.5 px-3">
{editing ? (
<div className="flex items-center gap-2">
<input
value={draftValue(a, "name")}
onChange={(e) => setDraft(a.id, "name", e.target.value)}
onKeyDown={(e) => onCellKeyDown(e, a)}
aria-label={`Name for ${a.code || a.name}`}
placeholder="Machine name"
maxLength={120}
className={cn(
"w-full sm:w-44 min-h-[44px] px-2 rounded-md text-sm font-medium border bg-gray-50 dark:bg-white/5 focus:bg-white dark:focus:bg-white/10 focus:outline-none",
dirty.has(draftKey(a.id, "name"))
? "border-[#EDAE49] text-[#EDAE49]"
: "border-transparent text-[#003D5B] dark:text-white focus:border-[#30638E]"
)}
/>
{!shownActive && (
<span className="text-xs uppercase tracking-wider text-gray-400 dark:text-text-soft font-semibold shrink-0">inactive</span>
)}
</div>
) : (
<span className="font-medium text-[#003D5B] dark:text-white">
{a.name}
{a.is_provisional && (
<span className="ml-2 text-xs uppercase tracking-wider text-[#EDAE49] font-semibold">provisional</span>
)}
{!shownActive && (
<span className="ml-2 text-xs uppercase tracking-wider text-gray-400 dark:text-text-soft font-semibold">inactive</span>
)}
</span>
)}
</td>
{/* Type — inline editable */}
<td className="py-2.5 px-3">
{editing ? (
<input
value={draftValue(a, "type")}
onChange={(e) => setDraft(a.id, "type", e.target.value)}
onKeyDown={(e) => onCellKeyDown(e, a)}
aria-label={`Type for ${a.name}`}
placeholder="e.g. Backhoe Loader"
maxLength={80}
list="plant-type-options"
className={cn(
"w-full sm:w-40 min-h-[44px] px-2 rounded-md text-sm border bg-gray-50 dark:bg-white/5 focus:bg-white dark:focus:bg-white/10 focus:outline-none",
dirty.has(draftKey(a.id, "type"))
? "border-[#EDAE49] text-[#EDAE49]"
: "border-transparent text-gray-600 dark:text-gray-400 focus:border-[#30638E]"
)}
/>
) : (
<span className="text-gray-600 dark:text-gray-400">{a.type || "—"}</span>
)}
</td>
<td className="py-2.5 px-3 text-center">
{editing ? (
<button
type="button"
role="switch"
aria-checked={shownActive}
aria-label={`${shownActive ? "Deactivate" : "Activate"} ${a.name}`}
disabled={savingAll}
onClick={() => stageActive(a.id, !shownActive)}
className={cn(
"relative inline-flex flex-col items-center justify-center min-h-[44px] min-w-[44px] rounded-full transition",
"active:scale-95 disabled:opacity-40",
activeIsDirty(a) && "ring-2 ring-[#EDAE49] ring-offset-1 dark:ring-offset-surface",
shownActive
? "bg-[#00798C]/15 hover:bg-[#00798C]/25 text-[#00798C]"
: "bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10 text-gray-400"
)}
>
<Power className={cn("w-4 h-4", saving && "animate-pulse")} />
{activeIsDirty(a) && (
<span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full bg-[#EDAE49] border border-white dark:border-surface" />
)}
</button>
) : (
<span
className={cn(
"inline-flex items-center justify-center min-h-[24px] px-2 rounded-full text-xs font-semibold uppercase tracking-wider",
shownActive
? "bg-[#00798C]/10 text-[#00798C]"
: "bg-gray-100 dark:bg-white/5 text-gray-400"
)}
>
{shownActive ? "Active" : "Inactive"}
</span>
)}
</td>
{editing && (
<td className="py-2.5 px-3 text-center">
<button
type="button"
aria-label={`Delete ${a.name}`}
onClick={() => setDeleteTarget(a)}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-full text-[#D1495B] hover:bg-[#D1495B]/10 transition active:scale-95"
>
<Trash2 className="w-4 h-4" />
</button>
</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={2} className="py-3 px-3 text-right font-semibold text-[#003D5B] dark:text-white">
{filtered.length} {filtered.length === 1 ? "item" : "items"} shown
</td>
<td className="py-3 px-3 text-xs text-gray-500 dark:text-gray-400">
{filtered.filter((a) => effectiveActive(a)).length} active · {filtered.filter((a) => !effectiveActive(a)).length} inactive
</td>
<td className="py-3 px-3" />
{editing && <td className="py-3 px-3" />}
</tr>
</tbody>
</table>
</div>
{/* Datalist for the Type cell — offers existing types without locking the
field to a closed set, so a genuinely new type can be typed in. */}
<datalist id="plant-type-options">
{types.filter((t) => t !== "All").map((t) => (
<option key={t} value={t} />
))}
</datalist>
{filtered.length === 0 && (
<div className="mt-6 text-center text-sm text-gray-500 dark:text-gray-400">
No items match "{query}" in {typeFilter} ({statusFilter}).
</div>
)}
{/* Delete confirmation. Shows the exact cascade row counts from
/api/assets/:id/impact rather than a generic warning, and requires
typing DELETE to arm the destructive button. */}
{deleteTarget && (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50 p-4"
onClick={closeDialog}
role="dialog"
aria-modal="true"
aria-labelledby="delete-title"
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full sm:max-w-md bg-white dark:bg-surface rounded-2xl border border-gray-200 dark:border-white/10 shadow-2xl p-5 max-h-[90vh] overflow-y-auto"
>
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-[#D1495B]/10 grid place-items-center shrink-0">
<Trash2 className="w-5 h-5 text-[#D1495B]" />
</div>
<div className="min-w-0">
<h3 id="delete-title" className="text-base font-semibold text-[#003D5B] dark:text-white">
Delete {deleteTarget.code ? `${deleteTarget.code} · ` : ""}{deleteTarget.name}?
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
This cannot be undone from the dashboard.
</p>
</div>
</div>
{/* Cascade impact — real counts from the API */}
<div className="rounded-xl bg-[#D1495B]/[0.06] border border-[#D1495B]/20 p-3 mb-4">
<div className="text-xs font-semibold uppercase tracking-wider text-[#D1495B] mb-2">
Also permanently deletes
</div>
{impactLoading ? (
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Loader2 className="w-4 h-4 animate-spin" /> Counting linked rows…
</div>
) : impact ? (
impact.total === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
No linked financial rows — only the register entry itself.
</p>
) : (
<ul className="space-y-1.5">
{Object.entries(impact.counts).map(([label, n]) => (
<li key={label} className="flex items-center justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400 capitalize">{label}</span>
<span className={cn(
"font-semibold tabular-nums",
n > 0 ? "text-[#D1495B]" : "text-gray-400 dark:text-text-soft"
)}>
{n}
</span>
</li>
))}
<li className="flex items-center justify-between text-sm pt-1.5 mt-1.5 border-t border-[#D1495B]/20">
<span className="font-semibold text-[#003D5B] dark:text-white">Total rows lost</span>
<span className="font-semibold tabular-nums text-[#D1495B]">{impact.total}</span>
</li>
</ul>
)
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">
{deleteError || "Could not determine impact."}
</p>
)}
</div>
{impact && impact.total > 0 && (
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
To keep the P&amp;L history instead, close this and mark the machine{" "}
<span className="font-semibold text-[#003D5B] dark:text-white">Inactive</span> — it disappears from the default view but its income and cost rows stay intact.
</p>
)}
{/* Type-to-confirm */}
<label className="sr-only" htmlFor="confirm-delete">Type DELETE to confirm</label>
<input
id="confirm-delete"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="Type DELETE to confirm"
autoComplete="off"
className="w-full min-h-[44px] px-3 mb-3 text-sm rounded-lg border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5 focus:bg-white dark:focus:bg-white/10 focus:border-[#30638E] focus:outline-none"
/>
{deleteError && impact && (
<p className="text-xs text-[#D1495B] mb-3">{deleteError}</p>
)}
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<button
type="button"
onClick={closeDialog}
disabled={deleting}
className="inline-flex items-center justify-center min-h-[44px] px-4 rounded-lg text-sm font-semibold text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5 transition"
>
Cancel
</button>
<button
type="button"
onClick={confirmDelete}
disabled={deleting || impactLoading || confirmText.trim().toUpperCase() !== "DELETE"}
className={cn(
"inline-flex items-center justify-center gap-2 min-h-[44px] px-4 rounded-lg text-sm font-semibold transition",
"disabled:opacity-40 disabled:cursor-not-allowed",
"bg-[#D1495B] text-white hover:bg-[#B23F4E]"
)}
>
{deleting && <Loader2 className="w-4 h-4 animate-spin" />}
Delete permanently
</button>
</div>
</div>
</div>
)}
</section>
);
}