Sites: auto-allocate container lines to the container the site hires

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>
This commit is contained in:
David 2026-10-02 18:32:12 +02:00
parent 802f471047
commit 293276ccce
15 changed files with 675 additions and 34 deletions

View file

@ -5,11 +5,11 @@ import { cascadeImpact, revalidateAssetSurfaces } from "./impact/shared";
export const dynamic = "force-dynamic";
// PATCH /api/assets/:id
// Body: any subset of { code?, name?, type?, is_active?, latitude?, longitude? }
// Drives the inline Edit cells (code / name / type), the active toggle in the
// Plant Register table, and the pin-drop mode on the Plant Map. Only the keys
// actually present in the body are written, so a field edit never clobbers the
// others.
// Body: any subset of { code?, name?, type?, is_active?, site?, latitude?, longitude? }
// Drives the inline Edit controls in the Plant Register table (code / name /
// type / site / active toggle) and the pin-drop mode on the Plant Map. Only the
// keys actually present in the body are written, so a field edit never clobbers
// the others.
//
// `name` is required by the PocketBase schema, so an empty name is rejected
// here rather than failing deep inside the SDK with a 400 from the DB.
@ -27,6 +27,7 @@ export async function PATCH(
name?: unknown;
type?: unknown;
is_active?: unknown;
site?: unknown;
latitude?: unknown;
longitude?: unknown;
} = {};
@ -98,6 +99,34 @@ export async function PATCH(
patch[key] = v;
}
// Site assignment. "" / null clears it; anything else must be a real sites
// row, so a typo can't park a machine at a site that doesn't exist.
if ("site" in body) {
const v = body.site;
if (v === null || v === "") {
patch.site = "";
} else if (typeof v === "string") {
const pb = await serverAuth();
const exists = await pb
.collection("sites")
.getOne(v)
.then((s) => !!s)
.catch(() => false);
if (!exists) {
return Response.json(
{ ok: false, error: "That site does not exist" },
{ status: 400 },
);
}
patch.site = v;
} else {
return Response.json(
{ ok: false, error: "site must be a site id, empty string, or null" },
{ status: 400 },
);
}
}
if (Object.keys(patch).length === 0) {
return Response.json(
{ ok: false, error: "No editable fields supplied" },

View file

@ -1,7 +1,7 @@
import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
import { parseInvoicePdf, ParseError, registerCodeFrom } from "@/lib/invoice-parser";
import { syncSitesFromInvoices } from "@/lib/sites";
import { syncSitesFromInvoices, findSiteContainer } from "@/lib/sites";
export const dynamic = "force-dynamic";
@ -163,13 +163,22 @@ export async function POST(req: Request) {
}
// The invoice is written, so this site is now real. Sites are derived, never typed.
await syncSitesFromInvoices(pb, [...allInvoices, invoiceRec as any]);
const sites = await syncSitesFromInvoices(pb, [...allInvoices, invoiceRec as any]);
// A container line carries a generic code the plant register does not have. If the
// site this invoice belongs to has a container hired under that code, the line is
// booked to it automatically rather than waiting for a human to notice.
const siteContainerFor = (machineCode: string) => {
const site = findSiteContainer(sites, parsed.site, machineCode);
return site?.container_asset || "";
};
const unassigned: string[] = [];
for (let i = 0; i < parsed.lines.length; i++) {
const l = parsed.lines[i];
const registerCode = registerCodeFrom(l.machine_code);
const assetId = byCode.get(registerCode) || "";
// Plant register first, then this site's own container, then nothing.
const assetId = byCode.get(registerCode) || siteContainerFor(l.machine_code) || "";
if (!assetId) unassigned.push(l.machine_code);
try {
await pb.collection("invoice_lines").create({

View file

@ -37,6 +37,27 @@ export async function PATCH(
}
if ("notes" in body) patch.notes = String(body.notes ?? "");
if ("container_code" in body || "container_asset" in body) {
const code = "container_code" in body ? String(body.container_code ?? "").trim() : site.container_code;
const assetId = "container_asset" in body ? String(body.container_asset ?? "").trim() : site.container_asset;
// Both or neither: a code with no asset would auto-allocate to nothing.
if (Boolean(code) !== Boolean(assetId)) {
return Response.json(
{ ok: false, error: "Set both the invoice code and the plant item, or clear both" },
{ status: 400 },
);
}
if (assetId) {
try {
await pb.collection("assets").getOne(assetId);
} catch {
return Response.json({ ok: false, error: "That plant item no longer exists" }, { status: 400 });
}
}
patch.container_code = code;
patch.container_asset = assetId;
}
if ("latitude" in body || "longitude" in body) {
const lat = body.latitude === null || body.latitude === "" ? null : Number(body.latitude);
const lng = body.longitude === null || body.longitude === "" ? null : Number(body.longitude);

View file

@ -115,7 +115,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
</head>
<body className="min-h-screen">
<ThemeProvider>
<header className="sticky top-0 z-30 h-16 bg-white/80 dark:bg-bg/80 backdrop-blur border-b border-gray-200 dark:border-border">
{/* z-[1100], not z-30: Leaflet's control panes are z-index 1000 and
live in the root stacking context, so the old z-30 header lost and
the Plant Map scrolled straight over it. Must stay above 1000, and
below the mobile nav drawer (z-[1200]) in Sidebar.tsx. */}
<header className="sticky top-0 z-[1100] h-16 bg-white/80 dark:bg-bg/80 backdrop-blur border-b border-gray-200 dark:border-border">
<div className="flex items-center gap-3 sm:gap-6 px-4 sm:px-6 h-full">
<Link href="/" className="flex items-center gap-2 shrink-0">
<img

View file

@ -1,4 +1,4 @@
import { getAssets } from "@/lib/data";
import { getAssets, getSites } from "@/lib/data";
import { PlantRegisterTable } from "@/components/PlantRegisterTable";
// Reads PocketBase on every request. Without this Next tries to prerender the
@ -7,7 +7,7 @@ import { PlantRegisterTable } from "@/components/PlantRegisterTable";
export const dynamic = "force-dynamic";
export default async function PlantPage() {
const assets = await getAssets();
const [assets, sites] = await Promise.all([getAssets(), getSites()]);
return (
<div className="p-8 max-w-[1400px]">
<div className="flex items-end justify-between mb-6">
@ -18,7 +18,9 @@ export default async function PlantPage() {
</div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Plant Register</h1>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">{assets.length} items · 1 sortable table</p>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{assets.length} items · {sites.length} sites · 1 sortable table
</p>
</div>
</div>
@ -27,6 +29,7 @@ export default async function PlantPage() {
PocketBase `assets` collection — other pages (Income, Running Costs,
Expenses) read them — they're just not part of the register view. */}
<PlantRegisterTable
sites={sites.map((s) => ({ id: s.id, name: s.name, code: s.code, is_active: s.is_active !== false }))}
assets={assets.map((a) => ({
id: a.id,
code: a.code,
@ -35,6 +38,7 @@ export default async function PlantPage() {
type: (a as any).type || "",
is_provisional: !!(a as any).is_provisional,
is_active: (a as any).is_active !== false,
site: a.site || "",
}))}
/>
</div>

View file

@ -7,6 +7,7 @@ import { ZAR2 } from "@/lib/utils";
import { ScrollFade } from "@/components/ScrollFade";
import { ArrowLeft, MapPin, FileText } from "lucide-react";
import { SiteCoordinateEditor } from "@/components/SiteCoordinateEditor";
import { SiteContainerEditor } from "@/components/SiteContainerEditor";
export const dynamic = "force-dynamic";
@ -146,6 +147,23 @@ export default async function SiteDetailPage({ params }: { params: Promise<{ id:
</div>
</div>
<div className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-gray-100 dark:border-white/5">
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Container</h2>
</div>
<div className="px-5 py-4">
<SiteContainerEditor
siteId={site.id}
siteName={site.name}
code={site.container_code}
assetId={site.container_asset}
assets={assets
.filter((a) => a.is_active !== false)
.map((a) => ({ id: a.id, label: `${a.code || "—"} · ${a.name}` }))}
/>
</div>
</div>
<div className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-gray-100 dark:border-white/5">
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Invoices</h2>

View file

@ -1,7 +1,7 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { Search, ArrowUpDown, AlertTriangle, Pencil, Check, Power, Trash2, Loader2, Undo2 } from "lucide-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`,
@ -16,9 +16,18 @@ type Asset = {
type: string;
is_provisional: boolean;
is_active: boolean;
/** Sites id this machine is parked at, or "" when unassigned. */
site: string;
};
type SortKey = "type" | "code" | "name";
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";
@ -35,10 +44,17 @@ const EDITABLE: { field: EditableField; label: string; placeholder: string; mono
{ field: "type", label: "Type", placeholder: "e.g. Backhoe Loader" },
];
export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[] }) {
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");
@ -56,16 +72,32 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
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;
const changeCount = dirty.size + Object.keys(activeDrafts).length;
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 }));
@ -77,6 +109,19 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
});
};
// 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] ?? "");
@ -91,6 +136,7 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
// 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 };
@ -119,6 +165,11 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
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.
@ -134,11 +185,37 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
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 }>();
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) ?? {};
@ -151,6 +228,12 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
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);
@ -181,6 +264,8 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
setDrafts({});
setDirty(new Set());
setActiveDrafts({});
setSiteDrafts({});
setConfirmDiscard(false);
setEditing(false);
} catch (e: any) {
setAssets(snapshot);
@ -201,10 +286,22 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
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);
@ -300,17 +397,22 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
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)
a.type.toLowerCase().includes(q) ||
siteName(effectiveSite(a)).toLowerCase().includes(q)
);
}
list = [...list].sort((a, b) => {
const av = (a as any)[sortKey] ?? "";
const bv = (b as any)[sortKey] ?? "";
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));
@ -318,7 +420,7 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
});
return list;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assets, query, typeFilter, statusFilter, sortKey, sortDir, activeDrafts]);
}, [assets, query, typeFilter, siteFilter, statusFilter, sortKey, sortDir, activeDrafts, siteDrafts]);
const headerCell = (key: SortKey, label: string, align: "left" | "right" = "left") => (
<th
@ -358,7 +460,7 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
{!editing ? (
<button
type="button"
onClick={() => { setEditing(true); setSaveError(null); }}
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"
>
@ -367,16 +469,41 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
</button>
) : (
<div className="flex items-center gap-2">
{hasUnsaved && (
{/* 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={discardAll}
onClick={exitEdit}
disabled={savingAll}
aria-label="Discard changes"
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"
>
<Undo2 className="w-4 h-4" />
<span className="hidden sm:inline">Discard</span>
<X className="w-4 h-4" />
<span className="hidden sm:inline">{hasUnsaved ? "Cancel" : "Done"}</span>
</button>
)}
<button
@ -502,6 +629,33 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
</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"
@ -513,6 +667,7 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
{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>
@ -524,7 +679,8 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
const shownActive = effectiveActive(a);
const rowDirty =
EDITABLE.some((f) => dirty.has(draftKey(a.id, f.field))) ||
activeIsDirty(a);
activeIsDirty(a) ||
siteIsDirty(a);
return (
<tr
key={a.id}
@ -548,7 +704,7 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
onKeyDown={(e) => onCellKeyDown(e, a)}
aria-label={`Code for ${a.name}`}
placeholder="—"
maxLength={15}
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"))
@ -629,6 +785,41 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
<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
@ -685,7 +876,12 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[]
{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
{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" />}

View file

@ -73,15 +73,20 @@ export function Sidebar({ items }: { items: NavItem[] }) {
<Menu className="w-5 h-5" />
</button>
{/* The drawer must out-rank Leaflet. Leaflet's own `.leaflet-top` /
`.leaflet-bottom` control panes are `z-index: 1000` and its map panes
run 400–700, all in the root stacking context — so the old z-40/z-50
pair lost and the Plant Map painted straight over the open menu.
Keep these values above 1000, and above the header's z-[1100]. */}
{mobileOpen && (
<div
className="lg:hidden fixed inset-0 z-40 bg-black/40"
className="lg:hidden fixed inset-0 z-[1200] bg-black/40"
onClick={() => setMobileOpen(false)}
/>
)}
<aside
className={cn(
"lg:hidden fixed top-0 left-0 bottom-0 z-50 w-72 bg-white dark:bg-surface-muted shadow-2xl transition-transform",
"lg:hidden fixed top-0 left-0 bottom-0 z-[1300] w-72 bg-white dark:bg-surface-muted shadow-2xl transition-transform",
mobileOpen ? "translate-x-0" : "-translate-x-full"
)}
>

View file

@ -0,0 +1,154 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Package, X, Check } from "lucide-react";
// Assign the container this site hires.
//
// `code` is what the invoice prints for it (e.g. "MPLANT05"); `assetId` is the plant
// item it books to. With both set, a container line arriving on an invoice for this
// site is allocated automatically on import instead of landing unbooked.
export function SiteContainerEditor({
siteId,
siteName,
code,
assetId,
assets,
}: {
siteId: string;
siteName: string;
code: string;
assetId: string;
assets: { id: string; label: string }[];
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [c, setC] = useState(code);
const [a, setA] = useState(assetId);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
async function save(clear = false) {
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/sites/${siteId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(clear ? { container_code: "", container_asset: "" } : { container_code: c, container_asset: a }),
});
const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
if (!data.ok) {
setError(data.error || "Could not save");
} else {
setOpen(false);
setSaved(true);
setTimeout(() => setSaved(false), 2500);
router.refresh();
}
} catch {
setError("Could not reach the server");
} finally {
setBusy(false);
}
}
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center gap-2 min-h-[44px] px-3 rounded-lg text-sm font-medium text-[#003D5B] dark:text-white hover:bg-gray-100 dark:hover:bg-white/5 transition"
>
{saved ? (
<Check className="w-4 h-4 text-[#00798C]" />
) : (
<Package className={`w-4 h-4 ${assetId ? "text-[#00798C]" : "text-gray-400"}`} />
)}
{assetId ? (
<span>
{code || "container"} → {assets.find((x) => x.id === assetId)?.label ?? "assigned"}
</span>
) : (
<span className="text-gray-500 dark:text-gray-400">No container assigned</span>
)}
</button>
);
}
return (
<div className="space-y-3">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label htmlFor={`cc-${siteId}`} className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
Invoice code
</label>
<input
id={`cc-${siteId}`}
value={c}
onChange={(e) => setC(e.target.value)}
placeholder="MPLANT05"
className="min-h-[44px] w-full px-3 rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-sm font-mono focus:outline-none focus:border-[#30638E]"
/>
</div>
<div className="flex-1">
<label htmlFor={`ca-${siteId}`} className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
Books to
</label>
<select
id={`ca-${siteId}`}
value={a}
onChange={(e) => setA(e.target.value)}
className="min-h-[44px] w-full px-3 rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-sm focus:outline-none focus:border-[#30638E]"
>
<option value="">— choose a plant item —</option>
{assets.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
</div>
</div>
{error && <p className="text-sm text-[#D1495B]">{error}</p>}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => save(false)}
disabled={busy || !c || !a}
className="inline-flex items-center gap-2 min-h-[44px] px-4 rounded-lg text-sm font-semibold text-white bg-[#00798C] hover:opacity-90 transition disabled:opacity-40"
>
{busy && <Loader2 className="w-4 h-4 animate-spin" />}
Save container
</button>
<button
type="button"
onClick={() => setOpen(false)}
disabled={busy}
className="inline-flex items-center gap-2 min-h-[44px] px-3 rounded-lg text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5 transition"
>
<X className="w-4 h-4" />
Cancel
</button>
{assetId && (
<button
type="button"
onClick={() => save(true)}
disabled={busy}
className="inline-flex items-center min-h-[44px] px-3 text-sm text-[#D1495B] hover:underline disabled:opacity-40"
>
Clear
</button>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Container lines on an invoice for <span className="font-medium">{siteName}</span> matching this code
will be booked to it automatically.
</p>
</div>
);
}

View file

@ -14,6 +14,10 @@ export type Asset = {
purchase_value: number;
is_provisional: boolean;
is_active: boolean;
// Which yard this machine is parked at. Empty until assigned in
// /plant → Edit → Site. cascadeDelete is OFF, so removing a site never
// removes the machines that referenced it.
site: string;
// Plant Map. Null until the on-site scan sheet supplies them. 0/0 counts as
// "not located" — see `hasCoords` in components/PlantMap.
latitude: number | null;
@ -242,6 +246,8 @@ export type Site = {
notes: string;
latitude: number;
longitude: number;
container_asset: string;
container_code: string;
is_active: boolean;
} & RecordModel;

View file

@ -126,4 +126,34 @@ test("the parser never returns an invoice whose totals do not reconcile", { skip
assert.ok(Math.abs(l.qty * l.rate - l.amount_excl) < 0.01, `${pdfName}: ${l.machine_code} qty x rate != amount`);
}
}
});
});
// --- container auto-allocation --------------------------------------------------
// A container line prints a generic code ("MPLANT05") that the plant register does not
// carry, so it has no natural asset. Each site records the container it hires, and the
// code the invoice uses for it. These tests pin the lookup — done here rather than by
// deleting a real invoice and re-importing it.
import { findSiteContainer } from "./sites";
const site = (name: string, code: string, asset: string) =>
({ id: name, name, container_code: code, container_asset: asset }) as any;
test("a container line is allocated to the container the site hires", () => {
const sites = [site("Autumns Gate", "MPLANT05", "a-sc02")];
assert.equal(findSiteContainer(sites, "Autumns Gate", "MPLANT05")?.container_asset, "a-sc02");
});
test("allocation is scoped to the invoice's own site", () => {
// Two sites can each hire an "MPLANT05"; a line must go to its own site's unit.
const sites = [site("Autumns Gate", "MPLANT05", "a-sc02"), site("The Hills", "MPLANT05", "h-sc03")];
assert.equal(findSiteContainer(sites, "The Hills", "MPLANT05")?.container_asset, "h-sc03");
assert.equal(findSiteContainer(sites, "Autumns Gate", "MPLANT05")?.container_asset, "a-sc02");
});
test("no allocation when the site has no matching container", () => {
const sites = [site("Pitsana", "WT02", "")];
assert.equal(findSiteContainer(sites, "Pitsana", "MPLANT05"), null, "code does not match");
assert.equal(findSiteContainer(sites, "Pitsana", "WT02"), null, "no asset assigned");
assert.equal(findSiteContainer(sites, "Elsewhere", "WT02"), null, "different site");
assert.equal(findSiteContainer([], "Pitsana", "WT02"), null, "no sites at all");
});

View file

@ -91,4 +91,30 @@ export function takenSiteIdsFor(lines: BookedLine[], exceptLineId: string): Set<
if (l.booked_site && !l.booked_asset) taken.add(l.booked_site);
}
return taken;
}
// Auto-allocation for a line that matches no plant item.
//
// The invoices print a generic code for hire containers ("MPLANT05 Storage Container")
// that the plant register does not carry. Each site records the container it hires, and
// the code the invoice uses for it. So a container line arriving on an invoice for a site
// that has a matching container is booked to that container rather than left stuck.
//
// The lookup is against THIS invoice's site only — two sites can both hire
// "MPLANT05" units, and each line must go to the unit actually on its own site.
export function findSiteContainer(
sites: Site[],
siteName: string,
machineCode: string,
): Site | null {
const wanted = (machineCode || "").trim().toLowerCase();
if (!wanted || !siteName) return null;
return (
sites.find(
(s) =>
(s.name || "").toLowerCase() === (siteName || "").toLowerCase() &&
(s.container_code || "").trim().toLowerCase() === wanted &&
Boolean(s.container_asset),
) ?? null
);
}

View file

@ -107,6 +107,39 @@ function normalizeName(n) {
}
// === Insert assets ===
// Site assignments are preserved across a re-seed. Before the assets are wiped
// we snapshot the current code→site mapping, because the new rows get fresh
// ids and a name/code lookup is the only stable key across the wipe.
const SITES_COLL = "sites";
let existingSiteByCode = new Map();
let existingSiteByName = new Map();
try {
const siteIdToName = new Map();
for (const s of await pb.collection(SITES_COLL).getFullList()) {
siteIdToName.set(s.id, s.name);
}
for (const a of await pb.collection(ASSETS_COLL).getFullList()) {
const siteName = a.site ? siteIdToName.get(a.site) : null;
if (!siteName) continue;
if (a.code) existingSiteByCode.set(a.code, siteName);
existingSiteByName.set(normalizeName(a.name), siteName);
}
if (existingSiteByCode.size || existingSiteByName.size) {
console.log(` · preserving site assignments for ${existingSiteByCode.size + existingSiteByName.size} asset lookups`);
}
} catch {
// `sites` is optional — a fresh install may not have run the setup script.
}
let siteIdByName = new Map();
try {
for (const s of await pb.collection(SITES_COLL).getFullList()) {
siteIdByName.set(normalizeName(s.name), s.id);
}
} catch {
console.log(" · no `sites` collection — every machine will seed unassigned");
}
console.log(`\n→ Seeding ${assets.length} assets`);
const codeToId = {};
const nameToId = {};
@ -114,6 +147,16 @@ let ok = 0;
const seedErrors = [];
for (const a of assets) {
try {
// `site` in assets.json wins; otherwise fall back to whatever was assigned
// before the wipe. An unrecognised name is a loud failure, not a silent
// unassignment — a dropped site link is exactly the kind of quiet data
// loss this seed is meant to surface.
const wantedSite = a.site || existingSiteByCode.get(a.code) || existingSiteByName.get(normalizeName(a.name)) || null;
let siteId = "";
if (wantedSite) {
siteId = siteIdByName.get(normalizeName(wantedSite)) || "";
if (!siteId) seedErrors.push(`asset "${a.name}": unknown site "${wantedSite}" — add it to the sites collection or clear the assignment`);
}
const rec = await pb.collection(ASSETS_COLL).create({
code: a.code,
name: a.name,
@ -126,6 +169,7 @@ for (const a of assets) {
purchase_value: a.purchase_value || 0,
is_provisional: a.is_provisional || false, // Part C.3
is_active: a.is_active !== false, // default true; only "AEB Plant Versekering" is false
site: siteId,
// Plant Map — falls back to the shared placeholder pin until the
// on-site scan sheet supplies a real coordinate for this machine.
latitude: a.latitude ?? DEFAULT_GEO.latitude,

View file

@ -140,6 +140,11 @@ const sitesId = await ensureCollection("sites", [
{ name: "notes", type: "text" },
{ name: "latitude", type: "number" },
{ name: "longitude", type: "number" },
// The container this site hires. `container_code` is the machine code the invoices
// print for it (e.g. "MPLANT05"), so a container line arriving on an invoice for
// this site is auto-allocated to `container_asset` instead of sitting unbooked.
{ name: "container_asset", type: "relation", collectionId: assetsId, minSelect: 1, maxSelect: 1, cascadeDelete: false },
{ name: "container_code", type: "text" },
{ name: "is_active", type: "bool" },
{ name: "created", type: "date", onCreate: true, presentable: false },
{ name: "updated", type: "date", onUpdate: true, presentable: false },

View file

@ -0,0 +1,90 @@
// Adds the `site` relation on `assets` → `sites`, so every machine can be
// assigned to the yard it is actually parked at.
//
// Idempotent: safe to re-run. Exits non-zero if `sites` is missing, because
// creating the relation without a target collection would leave a dangling
// field rather than an obvious failure.
//
// Usage: node scripts/setup-site-assignment.mjs
import PocketBase from "pocketbase";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PB = process.env.NEXT_PUBLIC_PB_URL;
const EMAIL = process.env.PB_ADMIN;
const PASS = process.env.PB_PASS;
const missing = [];
if (!PB) missing.push("NEXT_PUBLIC_PB_URL");
if (!EMAIL) missing.push("PB_ADMIN");
if (!PASS) missing.push("PB_PASS");
if (missing.length) {
console.error(`✗ Missing required env: ${missing.join(", ")}`);
console.error(" Copy .env.example to .env.local and fill in.");
process.exit(1);
}
const pb = new PocketBase(PB);
pb.autoCancellation(false);
console.log(`→ Connecting to ${PB} as ${EMAIL}`);
await pb.collection("_superusers").authWithPassword(EMAIL, PASS);
const sites = await pb.collections.getOne("sites").catch(() => null);
if (!sites) {
console.error("✗ Collection `sites` does not exist. Create it first, then re-run.");
process.exit(1);
}
const siteCount = (await pb.collection("sites").getList(1, 1)).totalItems;
console.log(` ✓ found \`sites\` with ${siteCount} row(s)`);
const assets = await pb.collections.getOne("assets");
const existing = assets.fields.find((f) => f.name === "site");
if (existing) {
// PocketBase refuses to repoint an existing relation (it throws
// `validation_field_relation_change`). If an earlier run created the field
// against the wrong collection, say so instead of silently leaving it broken
// — every assignment will fail with `validation_missing_rel_records`.
if (existing.collectionId !== sites.id) {
const used = (await pb.collection("assets").getFullList()).filter((a) => a.site).length;
if (used > 0) {
console.error(`✗ assets.site points at the wrong collection and ${used} asset(s) already use it.`);
console.error(" Fix by hand: drop the `site` field in the PocketBase UI, then re-run this script.");
process.exit(1);
}
console.log(" · assets.site points at the wrong collection and is unused — recreating it.");
assets.fields = assets.fields.filter((f) => f.name !== "site");
await pb.collections.update(assets.id, { fields: assets.fields });
} else {
console.log(" ✓ assets.site already exists and targets `sites` — nothing to do.");
process.exit(0);
}
}
const rand = (n) => Math.random().toString(36).slice(2, 2 + n);
assets.fields.push({
name: "site",
type: "relation",
required: false,
presentable: false,
hidden: false,
system: false,
collectionId: sites.id, // self-relation: a site may be scoped to a parent site
minSelect: null,
maxSelect: 1,
maxSize: 0,
// Deleting a site must NEVER delete the machines parked at it. This is the
// opposite of monthly_income.asset, which deliberately cascades.
cascadeDelete: false,
id: "relation" + rand(15),
});
const updated = await pb.collections.update(assets.id, { fields: assets.fields });
const field = updated.fields.find((f) => f.name === "site");
if (!field) {
console.error("✗ assets.site missing after update — PocketBase rejected the change.");
process.exit(1);
}
console.log(` ✓ assets.site added (maxSelect=${field.maxSelect}, cascadeDelete=${field.cascadeDelete})`);
console.log("\nNext: assign machines from /plant → Edit → Site, or run `npm run seed`.");