mega-plant/components/SiteBookingSelect.tsx
David 8423878b4f Invoices: fix the tablet layout and make booking an unbooked line obvious
On /income/invoices at tablet width the expanded invoice detail was unreadable
and the booking control was effectively unreachable.

- The line-item table is rendered inside a <td> of the invoice list's own
  scrollable table, so its 1200px width stretched the OUTER table to 1234px and
  the whole list scrolled sideways whenever a row was open. The detail is now
  measured with a ResizeObserver and given that pixel width, so it can no longer
  influence the outer table.
- Both tables' first columns are sticky left-0, and two nested stickies at the
  same offset stack on top of one another: the invoice number, the site and the
  period printed over each other. The detail wrapper is now sticky left-0 z-20,
  above the outer cell's z-10, so the inner table scrolls in its own box.
- The unbooked row tint was bg-[#D1495B]/[0.04] — 96% transparent — so every
  column a sticky cell covered bled through it on scroll. The tint is now the
  solid #FDF3F4 (dark #292934), repeated exactly on the sticky cells.
- The KPI row was flex-wrap, which sizes tiles by CONTENT, so the wide "booked
  to income" tile pushed the third one onto a second row below ~644px. It is a
  grid-cols-3 now, with the figures sized to the column.
- The machine picker and the site dropdown in the Booked to cell were flex
  children and compressed into each other by ~12px instead of sitting side by
  side. Both are shrink-0 now.
- Booking a line always saved on selection with no Save button, which read as
  "nothing happened". Unmatched rows now say so.

The booking API was already correct: a machine booking clears the site and posts
income, a site booking posts none. Verified at 768x1024 and 375x812.
2026-10-03 09:59:05 +02:00

140 lines
4.7 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 (
// shrink-0: this sits beside the machine picker in a flex row and used to be
// compressed under it, leaving the two controls overlapping mid-glyph.
<div className="min-w-[200px] shrink-0">
<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>
);
}