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:
parent
802f471047
commit
293276ccce
15 changed files with 675 additions and 34 deletions
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
154
components/SiteContainerEditor.tsx
Normal file
154
components/SiteContainerEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
26
lib/sites.ts
26
lib/sites.ts
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
90
scripts/setup-site-assignment.mjs
Normal file
90
scripts/setup-site-assignment.mjs
Normal 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`.");
|
||||
Loading…
Reference in a new issue