A container line prints a generic code ("MPLANT05") that the plant register
does not carry, so it had no asset to book to and sat unbooked forever. Each
site now records the container it hires — the code the invoice uses and the
plant item it books to — and a line arriving on an invoice for that site is
allocated to it automatically on import.
The lookup is scoped to the invoice's OWN site, not globally: two sites can
each hire an "MPLANT05" unit, and each line must land on the unit actually at
its own site. Covered by unit tests rather than by deleting a live invoice
and re-importing it.
A code with no asset is refused on save — it would auto-allocate to nothing —
and both or neither is required.
Assigned and booked: Autumns Gate "MPLANT05" -> SC02, The Hills "MPLANT05" ->
SC03, and The Hills' stuck line is now booked and earning. Container lines on
future invoices for those two sites will be booked without anyone touching them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1026 lines
No EOL
44 KiB
TypeScript
1026 lines
No EOL
44 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo, useEffect } from "react";
|
|
import { Search, ArrowUpDown, AlertTriangle, Pencil, Check, Power, Trash2, Loader2, X } 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;
|
|
/** Sites id this machine is parked at, or "" when unassigned. */
|
|
site: string;
|
|
};
|
|
|
|
type Site = {
|
|
id: string;
|
|
name: string;
|
|
code: string;
|
|
is_active: boolean;
|
|
};
|
|
|
|
type SortKey = "type" | "code" | "name" | "site";
|
|
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,
|
|
sites = [],
|
|
}: {
|
|
assets: Asset[];
|
|
sites?: Site[];
|
|
}) {
|
|
const [assets, setAssets] = useState<Asset[]>(initialAssets);
|
|
const [query, setQuery] = useState("");
|
|
const [typeFilter, setTypeFilter] = useState<string>("All");
|
|
const [siteFilter, setSiteFilter] = 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>>({});
|
|
// Site assignment, staged the same way: `{ assetId: siteId }` for rows whose
|
|
// selection differs from what's stored. Site is a closed set of 10 named
|
|
// yards, so a <select> is the right control rather than a text input.
|
|
const [siteDrafts, setSiteDrafts] = useState<Record<string, string>>({});
|
|
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);
|
|
// Guards the exit path: leaving edit mode with unsaved changes asks once,
|
|
// because a single mis-tap should not silently throw away an edit.
|
|
const [confirmDiscard, setConfirmDiscard] = useState(false);
|
|
|
|
const draftKey = (id: string, field: EditableField) => `${id}:${field}`;
|
|
const hasUnsaved =
|
|
dirty.size > 0 ||
|
|
Object.keys(activeDrafts).length > 0 ||
|
|
Object.keys(siteDrafts).length > 0;
|
|
const changeCount =
|
|
dirty.size + Object.keys(activeDrafts).length + Object.keys(siteDrafts).length;
|
|
|
|
const siteName = (id: string) => sites.find((s) => s.id === id)?.name ?? "";
|
|
const effectiveSite = (a: Asset) => (a.id in siteDrafts ? siteDrafts[a.id] : a.site);
|
|
const siteIsDirty = (a: Asset) => a.id in siteDrafts && siteDrafts[a.id] !== a.site;
|
|
|
|
const setDraft = (id: string, field: EditableField, value: string) => {
|
|
setConfirmDiscard(false);
|
|
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;
|
|
});
|
|
};
|
|
|
|
// Stage a site change. Selecting the stored value again clears the draft, so
|
|
// Save never sends a no-op.
|
|
const setSiteDraft = (id: string, siteId: string) => {
|
|
setConfirmDiscard(false);
|
|
const original = assets.find((a) => a.id === id)?.site ?? "";
|
|
setSiteDrafts((d) => {
|
|
const next = { ...d };
|
|
if (siteId === original) delete next[id];
|
|
else next[id] = siteId;
|
|
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) => {
|
|
setConfirmDiscard(false);
|
|
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;
|
|
});
|
|
setSiteDrafts((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;
|
|
|
|
// Codes are the join key across the whole app — `monthly_income`,
|
|
// `cost_lines` and `scripts/seed.mjs` all resolve machines by code. Two
|
|
// machines sharing one silently breaks those links, so block the save and
|
|
// name the clash rather than letting PocketBase accept it.
|
|
const codeErrors: string[] = [];
|
|
const seen = new Map<string, string>(); // lowercased code -> machine name
|
|
const proposed = assets.map((a) => ({
|
|
name: a.name,
|
|
code: draftKey(a.id, "code") in drafts ? drafts[draftKey(a.id, "code")].trim() : a.code.trim(),
|
|
}));
|
|
for (const p of proposed) {
|
|
if (!p.code) continue;
|
|
const key = p.code.toLowerCase();
|
|
const prev = seen.get(key);
|
|
if (prev) codeErrors.push(`"${p.code}" is used by both ${prev} and ${p.name}`);
|
|
else seen.set(key, p.name);
|
|
}
|
|
if (codeErrors.length) {
|
|
setSaveError(`Duplicate machine code — ${codeErrors.join("; ")}`);
|
|
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; site?: string }
|
|
>();
|
|
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);
|
|
}
|
|
for (const [id, site] of Object.entries(siteDrafts)) {
|
|
if (site === (assets.find((a) => a.id === id)?.site ?? "")) continue;
|
|
const patch = byAsset.get(id) ?? {};
|
|
patch.site = site;
|
|
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({});
|
|
setSiteDrafts({});
|
|
setConfirmDiscard(false);
|
|
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({});
|
|
setSiteDrafts({});
|
|
setSaveError(null);
|
|
setConfirmDiscard(false);
|
|
setEditing(false);
|
|
};
|
|
|
|
// Leaving edit mode: clean exits immediately, dirty asks for confirmation
|
|
// rather than dropping the edits on the floor.
|
|
const exitEdit = () => {
|
|
if (!hasUnsaved) {
|
|
discardAll();
|
|
return;
|
|
}
|
|
setConfirmDiscard(true);
|
|
};
|
|
|
|
// 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);
|
|
if (siteFilter === "__none") list = list.filter((a) => !effectiveSite(a));
|
|
else if (siteFilter !== "All") {
|
|
list = list.filter((a) => effectiveSite(a) === siteFilter || siteIsDirty(a));
|
|
}
|
|
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) ||
|
|
siteName(effectiveSite(a)).toLowerCase().includes(q)
|
|
);
|
|
}
|
|
list = [...list].sort((a, b) => {
|
|
const av = sortKey === "site" ? siteName(effectiveSite(a)) : (a as any)[sortKey] ?? "";
|
|
const bv = sortKey === "site" ? siteName(effectiveSite(b)) : (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, siteFilter, statusFilter, sortKey, sortDir, activeDrafts, siteDrafts]);
|
|
|
|
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); setConfirmDiscard(false); }}
|
|
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">
|
|
{/* Edit mode always offers a way out. Previously Discard was only
|
|
rendered when something had changed and Save was disabled
|
|
until then, so entering Edit and changing nothing left the
|
|
table stuck with no control to leave. */}
|
|
{confirmDiscard ? (
|
|
<>
|
|
<span className="hidden sm:inline text-xs text-gray-600 dark:text-gray-300 font-medium">
|
|
Discard {changeCount} change{changeCount === 1 ? "" : "s"}?
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmDiscard(false)}
|
|
className="inline-flex items-center justify-center 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"
|
|
>
|
|
Keep editing
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={discardAll}
|
|
className="inline-flex items-center gap-2 min-h-[44px] min-w-[44px] px-3 rounded-lg text-sm font-semibold bg-[#D1495B] text-white hover:bg-[#B23F4E] transition"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
<span>Discard</span>
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={exitEdit}
|
|
disabled={savingAll}
|
|
aria-label="Cancel editing"
|
|
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"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
<span className="hidden sm:inline">{hasUnsaved ? "Cancel" : "Done"}</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>
|
|
|
|
{/* Site filter — 10 named yards plus "unassigned", so a select at every
|
|
width rather than another pill row. */}
|
|
{sites.length > 0 && (
|
|
<div className="mb-4">
|
|
<label className="sr-only" htmlFor="site-filter">Filter by site</label>
|
|
<select
|
|
id="site-filter"
|
|
value={siteFilter}
|
|
onChange={(e) => setSiteFilter(e.target.value)}
|
|
className="w-full sm:w-64 min-h-[44px] px-3 text-sm rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5"
|
|
>
|
|
<option value="All">All sites ({assets.length})</option>
|
|
<option value="__none">
|
|
Unassigned ({assets.filter((a) => !a.site).length})
|
|
</option>
|
|
{sites.map((s) => {
|
|
const n = assets.filter((a) => a.site === s.id).length;
|
|
return (
|
|
<option key={s.id} value={s.id}>
|
|
{s.name} ({n})
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
</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")}
|
|
{headerCell("site", "Site")}
|
|
<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) ||
|
|
siteIsDirty(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={32}
|
|
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>
|
|
{/* Site — a closed set of named yards, so a select, not a text box */}
|
|
<td className="py-2.5 px-3">
|
|
{editing ? (
|
|
<>
|
|
<label className="sr-only" htmlFor={`site-${a.id}`}>
|
|
Site for {a.name}
|
|
</label>
|
|
<select
|
|
id={`site-${a.id}`}
|
|
value={effectiveSite(a)}
|
|
onChange={(e) => setSiteDraft(a.id, e.target.value)}
|
|
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",
|
|
siteIsDirty(a)
|
|
? "border-[#EDAE49] text-[#EDAE49]"
|
|
: "border-transparent text-gray-600 dark:text-gray-400 focus:border-[#30638E]"
|
|
)}
|
|
>
|
|
<option value="">— Unassigned —</option>
|
|
{sites.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</>
|
|
) : effectiveSite(a) ? (
|
|
<span className="inline-flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-[#00798C] shrink-0" />
|
|
{siteName(effectiveSite(a)) || "Unknown site"}
|
|
</span>
|
|
) : (
|
|
<span className="text-gray-400 dark:text-gray-500">—</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) => effectiveSite(a)).length} assigned ·{" "}
|
|
{filtered.filter((a) => !effectiveSite(a)).length} unassigned
|
|
</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&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>
|
|
);
|
|
} |