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() {