mega-plant/components/SiteBookingSelect.tsx

141 lines
4.7 KiB
TypeScript
Raw Normal View History

Sites: a real Sites list, with the unbooked lines bookable to a site The container lines (MPLANT04/MPLANT05) have no matching plant item, so they sat unbooked forever with no way to account for them. They can now be booked to a SITE instead. `sites` is a new PocketBase collection derived from the invoices — each distinct invoice site becomes a site, created as invoices are imported. They are never hand-typed, and never deleted: a site can carry notes and hold line bookings, so removing one would orphan that history. /sites lists every site with its revenue ex VAT, its VAT, the total billed, how many invoices and machines it carries, and how many lines are still outstanding. Each row links to a detail page listing the site's invoices and every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a group with Overview and Sites. Booking a line to a site posts NO income — there is no machine to carry it — it only marks the line accounted for, so it stops showing as unbooked. Exactly one of booked_asset / booked_site is ever set, and a line with neither is what surfaces as unbooked everywhere. One container per site, as specified: once a machine-less line has taken a site, that site is withheld from the next one's picker and shown disabled ("already has one") rather than hidden. Free sites sort to the top. The rule is enforced server-side too, so it does not depend on the UI being the only way in — re-posting a taken site is refused with 409. Verified: booking The Hills, then trying again, then taking West Park, and the remaining line sees 8 free and 2 taken. Clearing a booking frees its site. The site picker only appears on lines that need it — no machine, or one already booked to a site — so fully-booked rows keep a single control. Also fixed the Sidebar's group-active check, which derived its prefix from the first child. For a group whose first child is "/" that yields "/" and matched every route, so Dashboard would have been highlighted on every page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:24:06 +00:00
"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 (
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 07:59:05 +00:00
// 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">
Sites: a real Sites list, with the unbooked lines bookable to a site The container lines (MPLANT04/MPLANT05) have no matching plant item, so they sat unbooked forever with no way to account for them. They can now be booked to a SITE instead. `sites` is a new PocketBase collection derived from the invoices — each distinct invoice site becomes a site, created as invoices are imported. They are never hand-typed, and never deleted: a site can carry notes and hold line bookings, so removing one would orphan that history. /sites lists every site with its revenue ex VAT, its VAT, the total billed, how many invoices and machines it carries, and how many lines are still outstanding. Each row links to a detail page listing the site's invoices and every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a group with Overview and Sites. Booking a line to a site posts NO income — there is no machine to carry it — it only marks the line accounted for, so it stops showing as unbooked. Exactly one of booked_asset / booked_site is ever set, and a line with neither is what surfaces as unbooked everywhere. One container per site, as specified: once a machine-less line has taken a site, that site is withheld from the next one's picker and shown disabled ("already has one") rather than hidden. Free sites sort to the top. The rule is enforced server-side too, so it does not depend on the UI being the only way in — re-posting a taken site is refused with 409. Verified: booking The Hills, then trying again, then taking West Park, and the remaining line sees 8 free and 2 taken. Clearing a booking frees its site. The site picker only appears on lines that need it — no machine, or one already booked to a site — so fully-booked rows keep a single control. Also fixed the Sidebar's group-active check, which derived its prefix from the first child. For a group whose first child is "/" that yields "/" and matched every route, so Dashboard would have been highlighted on every page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:24:06 +00:00
<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>
);
}