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>
154 lines
No EOL
5.3 KiB
TypeScript
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>
|
|
);
|
|
} |