mega-plant/components/SiteContainerEditor.tsx
David 293276ccce 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>
2026-10-02 18:32:12 +02:00

154 lines
No EOL
5.3 KiB
TypeScript

"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>
);
}