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.
140 lines
4.7 KiB
TypeScript
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>
|
|
);
|
|
}
|