139 lines
4.5 KiB
TypeScript
139 lines
4.5 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useEffect, useState } from "react";
|
||
|
|
import { useRouter } from "next/navigation";
|
||
|
|
import { Loader2, X, MapPin, Sprout } from "lucide-react";
|
||
|
|
|
||
|
|
type SiteOption = { id: string; name: string; taken: boolean };
|
||
|
|
|
||
|
|
// Book an invoice line that has no matching plant item to a SITE instead.
|
||
|
|
//
|
||
|
|
// One container per site: sites another line has already taken are listed but disabled,
|
||
|
|
// so the outstanding containers spread across sites rather than piling onto the first.
|
||
|
|
// Clearing frees the site again. Booking to a site accounts for the line without
|
||
|
|
// creating income, because no machine exists to carry it.
|
||
|
|
export function SiteBookingSelect({
|
||
|
|
lineId,
|
||
|
|
value,
|
||
|
|
}: {
|
||
|
|
lineId: string;
|
||
|
|
value: string;
|
||
|
|
}) {
|
||
|
|
const router = useRouter();
|
||
|
|
const [options, setOptions] = useState<SiteOption[] | null>(null);
|
||
|
|
const [current, setCurrent] = useState(value);
|
||
|
|
const [busy, setBusy] = useState(false);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
setCurrent(value);
|
||
|
|
}, [value]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
let alive = true;
|
||
|
|
fetch(`/api/invoice-lines/${lineId}/site-options`, { cache: "no-store" })
|
||
|
|
.then((r) => r.json())
|
||
|
|
.then((d) => {
|
||
|
|
if (alive && d.ok) setOptions(d.options);
|
||
|
|
})
|
||
|
|
.catch(() => {
|
||
|
|
if (alive) setOptions([]);
|
||
|
|
});
|
||
|
|
return () => {
|
||
|
|
alive = false;
|
||
|
|
};
|
||
|
|
}, [lineId]);
|
||
|
|
|
||
|
|
async function book(payload: Record<string, unknown>, optimistic: string) {
|
||
|
|
setBusy(true);
|
||
|
|
setError(null);
|
||
|
|
const previous = current;
|
||
|
|
setCurrent(optimistic);
|
||
|
|
try {
|
||
|
|
const res = await fetch(`/api/invoice-lines/${lineId}/book`, {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify(payload),
|
||
|
|
});
|
||
|
|
const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
|
||
|
|
if (!data.ok) {
|
||
|
|
setCurrent(previous);
|
||
|
|
setError(data.error || "Could not book it");
|
||
|
|
} else {
|
||
|
|
router.refresh();
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
setCurrent(previous);
|
||
|
|
setError("Could not reach the server");
|
||
|
|
} finally {
|
||
|
|
setBusy(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (options === null) return null;
|
||
|
|
|
||
|
|
const currentName = options.find((o) => o.id === current)?.name;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="min-w-[200px]">
|
||
|
|
<label className="sr-only" htmlFor={`site-${lineId}`}>
|
||
|
|
Book this line to a site
|
||
|
|
</label>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<select
|
||
|
|
id={`site-${lineId}`}
|
||
|
|
value={current}
|
||
|
|
disabled={busy}
|
||
|
|
onChange={(e) => {
|
||
|
|
const v = e.target.value;
|
||
|
|
book(v ? { siteId: v } : { clear: true }, v);
|
||
|
|
}}
|
||
|
|
className={`min-h-[44px] w-full rounded-lg border px-2 text-sm transition focus:outline-none focus:border-[#30638E] disabled:opacity-60 ${
|
||
|
|
current
|
||
|
|
? "border-[#EDAE49]/40 bg-[#EDAE49]/10 text-[#8a6420] dark:text-[#EDAE49]"
|
||
|
|
: "border-gray-200 dark:border-white/10 bg-white dark:bg-white/5"
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
<option value="">— Book to a site —</option>
|
||
|
|
{options.map((o) => (
|
||
|
|
<option key={o.id} value={o.id} disabled={o.taken && o.id !== current}>
|
||
|
|
{o.name}
|
||
|
|
{o.taken && o.id !== current ? " — already has one" : ""}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
{busy && <Loader2 className="w-4 h-4 animate-spin text-[#00798C] shrink-0" aria-label="Saving" />}
|
||
|
|
{current && !busy && (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => book({ clear: true }, "")}
|
||
|
|
aria-label="Remove the site booking"
|
||
|
|
className="grid place-items-center w-11 h-11 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5 transition shrink-0"
|
||
|
|
>
|
||
|
|
<X className="w-4 h-4" />
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{currentName && (
|
||
|
|
<p className="mt-1 flex items-center gap-1.5 text-xs text-[#8a6420] dark:text-[#EDAE49]">
|
||
|
|
<MapPin className="w-3 h-3 shrink-0" />
|
||
|
|
Booked to {currentName} — no income posted (no machine)
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{error && <p className="mt-1 text-xs text-[#D1495B]">{error}</p>}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function SiteBookingLegend() {
|
||
|
|
return (
|
||
|
|
<p className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||
|
|
<Sprout className="w-3.5 h-3.5 shrink-0" />
|
||
|
|
A site booking accounts for a line that matches no plant item. It posts no income —
|
||
|
|
there is no machine to carry it — and each site takes one container.
|
||
|
|
</p>
|
||
|
|
);
|
||
|
|
}
|