diff --git a/app/api/assets/[id]/route.ts b/app/api/assets/[id]/route.ts index 858f6a6..f26d453 100644 --- a/app/api/assets/[id]/route.ts +++ b/app/api/assets/[id]/route.ts @@ -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" }, diff --git a/app/api/invoices/intake/route.ts b/app/api/invoices/intake/route.ts index 0177691..b7a0a3d 100644 --- a/app/api/invoices/intake/route.ts +++ b/app/api/invoices/intake/route.ts @@ -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({ diff --git a/app/api/sites/[id]/route.ts b/app/api/sites/[id]/route.ts index 5e53680..c997c21 100644 --- a/app/api/sites/[id]/route.ts +++ b/app/api/sites/[id]/route.ts @@ -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); diff --git a/app/layout.tsx b/app/layout.tsx index 5c8b165..43c8ca6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -115,7 +115,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo -
+ {/* 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. */} +
@@ -18,7 +18,9 @@ export default async function PlantPage() {

Plant Register

-

{assets.length} items · 1 sortable table

+

+ {assets.length} items · {sites.length} sites · 1 sortable table +

@@ -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. */} ({ 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 || "", }))} /> diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index c17666a..951c3c9 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -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: +
+
+

Container

+
+
+ a.is_active !== false) + .map((a) => ({ id: a.id, label: `${a.code || "—"} · ${a.name}` }))} + /> +
+
+

Invoices

diff --git a/components/PlantRegisterTable.tsx b/components/PlantRegisterTable.tsx index 3eccc7a..68c8b05 100644 --- a/components/PlantRegisterTable.tsx +++ b/components/PlantRegisterTable.tsx @@ -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(initialAssets); const [query, setQuery] = useState(""); const [typeFilter, setTypeFilter] = useState("All"); + const [siteFilter, setSiteFilter] = useState("All"); const [statusFilter, setStatusFilter] = useState("all"); const [sortKey, setSortKey] = useState("type"); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); @@ -56,16 +72,32 @@ export function PlantRegisterTable({ assets: initialAssets }: { assets: Asset[] const [drafts, setDrafts] = useState>({}); const [dirty, setDirty] = useState>(new Set()); const [activeDrafts, setActiveDrafts] = useState>({}); + // 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 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" + > + + + {sites.map((s) => { + const n = assets.filter((a) => a.site === s.id).length; + return ( + + ); + })} + +
+ )} + {/* Table */}
Status {editing && Delete} @@ -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 ( 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[] {a.type || "—"} )} + {/* Site — a closed set of named yards, so a select, not a text box */} + + {editing ? ( + <> + + + + ) : effectiveSite(a) ? ( + + + {siteName(effectiveSite(a)) || "Unknown site"} + + ) : ( + — + )} + {editing ? ( + {/* 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 && (
setMobileOpen(false)} /> )}