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
|
|
|
import type { Metadata } from "next";
|
|
|
|
|
import Link from "next/link";
|
|
|
|
|
import { notFound } from "next/navigation";
|
2026-10-06 02:02:08 +00:00
|
|
|
import { getAssets, getInvoices, getInvoiceLines, getSites, joinBookedLines, MONTH_NAMES } from "@/lib/server-data";
|
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
|
|
|
import { buildSiteSummaries } from "@/lib/sites";
|
Split the invoice register into Internal and External Invoices
Renames the existing /income/invoices page to "Internal Invoices" and adds a
sibling /income/external-invoices page, splitting ONE `invoices` collection by
the debtor printed on the invoice: a Mega group company is internal, anything
else is external.
Never two collections. An invoice must not be able to exist on neither page, and
the halves must stay reconcilable against /income. Both routes render through the
single components/InvoiceRegisterPage.tsx so they cannot drift apart.
lib/invoice-party-policy.json holds the group entity list and lib/invoice-party.ts
does the matching. Matching is on debtor name OR debtor_no, never name alone:
all three external invoices (PHI 178/186/192) carry an empty debtor name and only
a third-party number, so a name-only match would misfile every one of them. An
invoice naming neither is never internal - it lands on External and is reported
as unclassified rather than silently filed.
The split is 16 internal / 3 external across the 19 invoices in the register.
Also fixes three things the split would otherwise break:
- Booking a line, importing a PDF and retrying an intake can all land on either
side depending on the debtor, so the five write paths only revalidated
/income/invoices and would have left the other page stale. They now call
revalidateInvoicePages() from lib/invoice-revalidate.ts. Route paths live in
lib/invoice-routes.ts, which imports no next/* so a client component may import
it; the revalidate helper imports next/cache and is server-only.
- A site's "Go and assign them" link hard-coded the internal path, so a site whose
outstanding lines sit on external invoices would dead-end. SiteSummary now
carries the outstanding lines split by party.
- verify-auth-routes.mjs now covers the new route.
The two routes are siblings by design: isGroupActive matches a child by path
prefix, so a nested /income/invoices/external would highlight both leaves at once.
Verified: typecheck clean, 41 tests pass (7 new), production build green,
anonymous access to both routes 307s to /login, and the mobile checklist passes at
375x812 and 768x1024 - no horizontal page scroll, no tap target under 44px, no
text under 12px. The nested line-item table still scrolls inside its own container
without stretching the outer table.
Note: lib/rows.ts, lib/server-data.ts and the running/insurance feature are
someone else's in-flight work and were deliberately left uncommitted here.
2026-10-08 09:49:48 +00:00
|
|
|
import { INTERNAL_INVOICES_PATH, EXTERNAL_INVOICES_PATH } from "@/lib/invoice-routes";
|
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
|
|
|
import { ZAR2 } from "@/lib/utils";
|
|
|
|
|
import { ScrollFade } from "@/components/ScrollFade";
|
|
|
|
|
import { ArrowLeft, MapPin, FileText } from "lucide-react";
|
2026-10-02 17:09:16 +00:00
|
|
|
import { Sparkline } from "@/components/Sparkline";
|
Sites: give every site coordinates
`assets` already carried latitude/longitude, but `sites` had nowhere to put a
pin — so there was no way to locate a site. Both fields added, matching the
plant collection's shape so the same map component can plot either.
Coordinates are entered as two plain number fields with an "open in maps" link
to verify the point, rather than a drag-to-pin map: the in-app Leaflet map is
still being worked on, and a coordinate is usually copied off some other map
anyway. Clearing removes the pin rather than leaving half a coordinate, which
would plot in the Gulf of Guinea and read as "located".
Bounds are checked server-side, not just in the form: a point outside South Africa
is refused with the numbers echoed back, because a swapped pair or a dropped
digit is far likelier than a machine abroad. 0,0 is rejected by the same rule.
The sites list marks each row as pinned or "no pin", with a banner counting the
gaps, so an unlocated site is visible without opening it.
While checking this, found that all 44 assets carry the SAME coordinate
(-26.1016, 28.3487) — the Plant Map currently stacks every marker on one pin
while reporting "44 located on the map". Left alone, but worth knowing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:33:43 +00:00
|
|
|
import { SiteCoordinateEditor } from "@/components/SiteCoordinateEditor";
|
2026-10-02 16:32:12 +00:00
|
|
|
import { SiteContainerEditor } from "@/components/SiteContainerEditor";
|
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
|
|
|
|
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
|
|
|
|
|
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
|
|
|
|
const { id } = await params;
|
|
|
|
|
const sites = await getSites();
|
|
|
|
|
const site = sites.find((s) => s.id === id);
|
|
|
|
|
return { title: `${site?.name ?? "Site"} — Mega Plant` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default async function SiteDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
|
|
|
|
const { id } = await params;
|
|
|
|
|
const [sites, invoices, lines, assets] = await Promise.all([
|
|
|
|
|
getSites(),
|
|
|
|
|
getInvoices(),
|
|
|
|
|
getInvoiceLines(),
|
|
|
|
|
getAssets(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const site = sites.find((s) => s.id === id);
|
|
|
|
|
if (!site) notFound();
|
|
|
|
|
|
|
|
|
|
const joined = joinBookedLines(lines, assets);
|
|
|
|
|
const summary = buildSiteSummaries(sites, invoices, joined, assets).find((s) => s.id === id);
|
|
|
|
|
if (!summary) notFound();
|
|
|
|
|
|
|
|
|
|
const invById = new Map(invoices.map((i) => [i.id, i]));
|
|
|
|
|
const assetById = new Map(assets.map((a) => [a.id, a]));
|
|
|
|
|
const siteLines = joined
|
|
|
|
|
.filter((l) => invById.get(l.invoice)?.site === site.name)
|
|
|
|
|
.sort((a, b) => (invById.get(b.invoice)!.invoice_date).localeCompare(invById.get(a.invoice)!.invoice_date) || a.line_no - b.line_no);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="p-4 sm:p-8 max-w-[1400px]">
|
|
|
|
|
<Link
|
|
|
|
|
href="/sites"
|
|
|
|
|
className="inline-flex items-center gap-1.5 min-h-[44px] text-sm text-gray-500 dark:text-gray-400 hover:text-[#00798C] transition"
|
|
|
|
|
>
|
|
|
|
|
<ArrowLeft className="w-4 h-4" />
|
|
|
|
|
All sites
|
|
|
|
|
</Link>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center gap-3 mb-1">
|
|
|
|
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#EDAE49]/20 to-[#00798C]/15 grid place-items-center text-[#00798C]">
|
|
|
|
|
<MapPin className="w-5 h-5" />
|
|
|
|
|
</div>
|
|
|
|
|
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">{site.name}</h1>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
|
|
|
|
{summary.invoices.length} invoice{summary.invoices.length === 1 ? "" : "s"} ·{" "}
|
|
|
|
|
{summary.machineCount} machine{summary.machineCount === 1 ? "" : "s"} ·{" "}
|
|
|
|
|
{summary.lineCount} line{summary.lineCount === 1 ? "" : "s"}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-wrap items-stretch gap-3">
|
2026-10-03 07:20:20 +00:00
|
|
|
<div className="px-5 py-3 rounded-2xl bg-white dark:bg-surface border border-gray-100 dark:border-white/5">
|
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
|
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">Revenue (ex VAT)</div>
|
|
|
|
|
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
|
|
|
|
{ZAR2(summary.revenueExcl)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
|
|
|
|
|
+ {ZAR2(summary.vat)} VAT
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="bg-gradient-to-br from-[#00798C] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-teal-500/20">
|
|
|
|
|
<div className="text-xs text-white/80">Billed (incl VAT)</div>
|
|
|
|
|
<div className="text-2xl font-semibold tabular-nums">{ZAR2(summary.revenueIncl)}</div>
|
|
|
|
|
</div>
|
2026-10-02 17:09:16 +00:00
|
|
|
{/* The same monthly series the site list plots, so the detail page and the
|
|
|
|
|
list can never disagree about what a month was worth. */}
|
2026-10-03 07:20:20 +00:00
|
|
|
<div className="px-5 py-3 rounded-2xl bg-white dark:bg-surface border border-gray-100 dark:border-white/5">
|
2026-10-02 17:09:16 +00:00
|
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
Billed per month · {summary.year}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-1">
|
|
|
|
|
<Sparkline data={summary.months} color="#00798C" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
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
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid lg:grid-cols-3 gap-5">
|
|
|
|
|
<div className="lg:col-span-2">
|
2026-10-03 07:20:20 +00:00
|
|
|
<div className="relative bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
|
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
|
|
|
<div className="px-5 py-4 border-b border-gray-100 dark:border-white/5">
|
|
|
|
|
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Line items</h2>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
|
|
|
|
|
<table className="w-full text-sm sm:min-w-[640px]">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/[0.02]">
|
|
|
|
|
<th className="text-left py-3 px-4 font-medium sticky left-0 z-10 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur">Machine</th>
|
|
|
|
|
<th className="text-left py-3 px-3 font-medium hidden sm:table-cell">Invoice</th>
|
|
|
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap hidden sm:table-cell">Qty</th>
|
|
|
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Ex VAT</th>
|
|
|
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap hidden sm:table-cell">VAT</th>
|
|
|
|
|
<th className="text-right py-3 px-4 font-medium whitespace-nowrap">Total</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{siteLines.map((l) => {
|
|
|
|
|
const inv = invById.get(l.invoice)!;
|
|
|
|
|
const asset = l.booked_asset ? assetById.get(l.booked_asset) : null;
|
|
|
|
|
return (
|
|
|
|
|
<tr key={l.id} className="border-b border-gray-50 dark:border-white/5">
|
2026-10-03 07:20:20 +00:00
|
|
|
<td className="py-3 px-4 sticky left-0 z-10 bg-white dark:bg-surface">
|
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
|
|
|
<div className="font-mono text-xs font-semibold text-[#00798C]">{l.machine_code}</div>
|
|
|
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
{asset ? `${asset.code} ${asset.name}` : l.description}
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="hidden sm:table-cell py-3 px-3 font-mono text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
|
|
|
|
{inv.invoice_no}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="hidden sm:table-cell py-3 px-3 text-right tabular-nums text-gray-600 dark:text-gray-300 whitespace-nowrap">
|
|
|
|
|
{l.qty} {l.unit}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="py-3 px-3 text-right tabular-nums text-gray-600 dark:text-gray-300 whitespace-nowrap">{ZAR2(l.amount_excl)}</td>
|
|
|
|
|
<td className="hidden sm:table-cell py-3 px-3 text-right tabular-nums text-gray-500 dark:text-gray-400 whitespace-nowrap">{ZAR2(l.vat)}</td>
|
|
|
|
|
<td className="py-3 px-4 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
|
|
|
|
|
{ZAR2(l.total_incl)}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<ScrollFade />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-5">
|
2026-10-03 07:20:20 +00:00
|
|
|
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
|
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
|
|
|
<div className="px-5 py-4 border-b border-gray-100 dark:border-white/5">
|
Sites: give every site coordinates
`assets` already carried latitude/longitude, but `sites` had nowhere to put a
pin — so there was no way to locate a site. Both fields added, matching the
plant collection's shape so the same map component can plot either.
Coordinates are entered as two plain number fields with an "open in maps" link
to verify the point, rather than a drag-to-pin map: the in-app Leaflet map is
still being worked on, and a coordinate is usually copied off some other map
anyway. Clearing removes the pin rather than leaving half a coordinate, which
would plot in the Gulf of Guinea and read as "located".
Bounds are checked server-side, not just in the form: a point outside South Africa
is refused with the numbers echoed back, because a swapped pair or a dropped
digit is far likelier than a machine abroad. 0,0 is rejected by the same rule.
The sites list marks each row as pinned or "no pin", with a banner counting the
gaps, so an unlocated site is visible without opening it.
While checking this, found that all 44 assets carry the SAME coordinate
(-26.1016, 28.3487) — the Plant Map currently stacks every marker on one pin
while reporting "44 located on the map". Left alone, but worth knowing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:33:43 +00:00
|
|
|
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Location</h2>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="px-5 py-4">
|
|
|
|
|
<SiteCoordinateEditor
|
|
|
|
|
siteId={site.id}
|
|
|
|
|
name={site.name}
|
|
|
|
|
latitude={site.latitude}
|
|
|
|
|
longitude={site.longitude}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-10-03 07:20:20 +00:00
|
|
|
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
|
Sites: give every site coordinates
`assets` already carried latitude/longitude, but `sites` had nowhere to put a
pin — so there was no way to locate a site. Both fields added, matching the
plant collection's shape so the same map component can plot either.
Coordinates are entered as two plain number fields with an "open in maps" link
to verify the point, rather than a drag-to-pin map: the in-app Leaflet map is
still being worked on, and a coordinate is usually copied off some other map
anyway. Clearing removes the pin rather than leaving half a coordinate, which
would plot in the Gulf of Guinea and read as "located".
Bounds are checked server-side, not just in the form: a point outside South Africa
is refused with the numbers echoed back, because a swapped pair or a dropped
digit is far likelier than a machine abroad. 0,0 is rejected by the same rule.
The sites list marks each row as pinned or "no pin", with a banner counting the
gaps, so an unlocated site is visible without opening it.
While checking this, found that all 44 assets carry the SAME coordinate
(-26.1016, 28.3487) — the Plant Map currently stacks every marker on one pin
while reporting "44 located on the map". Left alone, but worth knowing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:33:43 +00:00
|
|
|
<div className="px-5 py-4 border-b border-gray-100 dark:border-white/5">
|
2026-10-02 16:32:12 +00:00
|
|
|
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Container</h2>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="px-5 py-4">
|
|
|
|
|
<SiteContainerEditor
|
|
|
|
|
siteId={site.id}
|
|
|
|
|
siteName={site.name}
|
|
|
|
|
code={site.container_code}
|
|
|
|
|
assetId={site.container_asset}
|
|
|
|
|
assets={assets
|
|
|
|
|
.filter((a) => a.is_active !== false)
|
|
|
|
|
.map((a) => ({ id: a.id, label: `${a.code || "—"} · ${a.name}` }))}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-10-03 07:20:20 +00:00
|
|
|
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
|
2026-10-02 16:32:12 +00:00
|
|
|
<div className="px-5 py-4 border-b border-gray-100 dark:border-white/5">
|
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
|
|
|
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Invoices</h2>
|
|
|
|
|
</div>
|
2026-10-02 17:09:16 +00:00
|
|
|
{/* Grouped by billing month rather than one flat list, so the page reads the
|
|
|
|
|
same way as the list: a month, then the invoices that made it up. */}
|
|
|
|
|
<div className="divide-y divide-gray-100 dark:border-white/5">
|
|
|
|
|
{summary.monthly.map((m) => (
|
|
|
|
|
<div key={m.key} className="px-5 py-3">
|
|
|
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
|
|
|
<span className="text-sm font-semibold text-[#003D5B] dark:text-white">
|
|
|
|
|
{MONTH_NAMES[m.month - 1]} {m.year}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white whitespace-nowrap">
|
|
|
|
|
{ZAR2(m.totalIncl)}
|
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
|
|
|
</span>
|
2026-10-02 17:09:16 +00:00
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-gray-400 dark:text-text-soft tabular-nums">
|
|
|
|
|
ex {ZAR2(m.totalExcl)} + {ZAR2(m.vat)} VAT · {m.invoices.length} invoice
|
|
|
|
|
{m.invoices.length === 1 ? "" : "s"}
|
|
|
|
|
</div>
|
|
|
|
|
<ul className="mt-2 space-y-1">
|
|
|
|
|
{m.invoices.map((inv) => (
|
|
|
|
|
<li key={inv.id} className="flex items-center justify-between gap-2">
|
|
|
|
|
<span className="inline-flex items-center gap-2 min-w-0">
|
|
|
|
|
<FileText className="w-4 h-4 text-[#00798C] shrink-0" />
|
|
|
|
|
<span className="min-w-0">
|
|
|
|
|
<span className="block font-mono text-xs font-semibold text-[#003D5B] dark:text-white">
|
|
|
|
|
{inv.invoiceNo}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
{fmtDate(inv.invoiceDate)} · {fmtRange(inv.periodFrom, inv.periodTo)}
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
{inv.hasPdf ? (
|
|
|
|
|
<a
|
|
|
|
|
href={`/api/invoices/${inv.id}/document`}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noreferrer"
|
2026-10-06 02:02:08 +00:00
|
|
|
className="inline-flex items-center min-h-[44px] min-w-[44px] px-2 -mr-2 rounded-lg text-xs font-medium text-[#00798C] hover:bg-[#00798C]/10 transition shrink-0"
|
2026-10-02 17:09:16 +00:00
|
|
|
>
|
|
|
|
|
PDF
|
|
|
|
|
</a>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-xs tabular-nums text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
|
|
|
|
{ZAR2(inv.totalIncl)}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
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
|
|
|
))}
|
2026-10-02 17:09:16 +00:00
|
|
|
</div>
|
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
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{summary.unbookedCount > 0 && (
|
|
|
|
|
<div className="px-5 py-4 rounded-2xl bg-[#D1495B]/5 border border-[#D1495B]/15">
|
|
|
|
|
<h2 className="text-sm font-semibold text-[#D1495B] mb-1">Unbooked</h2>
|
|
|
|
|
<p className="text-sm text-[#D1495B]">
|
|
|
|
|
{summary.unbookedCount} line{summary.unbookedCount === 1 ? "" : "s"} here still need a
|
|
|
|
|
machine or a site — {ZAR2(summary.unbookedAmount)} incl VAT.
|
|
|
|
|
</p>
|
|
|
|
|
<Link
|
Split the invoice register into Internal and External Invoices
Renames the existing /income/invoices page to "Internal Invoices" and adds a
sibling /income/external-invoices page, splitting ONE `invoices` collection by
the debtor printed on the invoice: a Mega group company is internal, anything
else is external.
Never two collections. An invoice must not be able to exist on neither page, and
the halves must stay reconcilable against /income. Both routes render through the
single components/InvoiceRegisterPage.tsx so they cannot drift apart.
lib/invoice-party-policy.json holds the group entity list and lib/invoice-party.ts
does the matching. Matching is on debtor name OR debtor_no, never name alone:
all three external invoices (PHI 178/186/192) carry an empty debtor name and only
a third-party number, so a name-only match would misfile every one of them. An
invoice naming neither is never internal - it lands on External and is reported
as unclassified rather than silently filed.
The split is 16 internal / 3 external across the 19 invoices in the register.
Also fixes three things the split would otherwise break:
- Booking a line, importing a PDF and retrying an intake can all land on either
side depending on the debtor, so the five write paths only revalidated
/income/invoices and would have left the other page stale. They now call
revalidateInvoicePages() from lib/invoice-revalidate.ts. Route paths live in
lib/invoice-routes.ts, which imports no next/* so a client component may import
it; the revalidate helper imports next/cache and is server-only.
- A site's "Go and assign them" link hard-coded the internal path, so a site whose
outstanding lines sit on external invoices would dead-end. SiteSummary now
carries the outstanding lines split by party.
- verify-auth-routes.mjs now covers the new route.
The two routes are siblings by design: isGroupActive matches a child by path
prefix, so a nested /income/invoices/external would highlight both leaves at once.
Verified: typecheck clean, 41 tests pass (7 new), production build green,
anonymous access to both routes 307s to /login, and the mobile checklist passes at
375x812 and 768x1024 - no horizontal page scroll, no tap target under 44px, no
text under 12px. The nested line-item table still scrolls inside its own container
without stretching the outer table.
Note: lib/rows.ts, lib/server-data.ts and the running/insurance feature are
someone else's in-flight work and were deliberately left uncommitted here.
2026-10-08 09:49:48 +00:00
|
|
|
href={
|
|
|
|
|
// Send the reader to the half of the register that actually holds these
|
|
|
|
|
// lines. Internal wins a tie because it is where every seeded invoice
|
|
|
|
|
// lives today, so this preserves the existing destination.
|
|
|
|
|
summary.unbookedExternalCount > summary.unbookedInternalCount
|
|
|
|
|
? EXTERNAL_INVOICES_PATH
|
|
|
|
|
: INTERNAL_INVOICES_PATH
|
|
|
|
|
}
|
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
|
|
|
className="inline-flex items-center min-h-[44px] text-sm font-medium text-[#D1495B] hover:underline mt-1"
|
|
|
|
|
>
|
|
|
|
|
Go and assign them
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2026-10-02 17:09:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PocketBase dates arrive as "2026-09-18 00:00:00.000Z". The date part is parsed as
|
|
|
|
|
// UTC so a label can't slip a day backwards in a timezone behind the server.
|
|
|
|
|
function fmtDate(d: string): string {
|
|
|
|
|
if (!d) return "—";
|
|
|
|
|
const dt = new Date(d.length <= 10 ? `${d}T00:00:00Z` : d);
|
|
|
|
|
if (Number.isNaN(dt.getTime())) return String(d).slice(0, 10);
|
|
|
|
|
return `${dt.getUTCDate()} ${MONTH_NAMES[dt.getUTCMonth()]}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fmtRange(from: string, to: string): string {
|
|
|
|
|
if (!from || !to) return "—";
|
|
|
|
|
return `${fmtDate(from)} → ${fmtDate(to)}`;
|
|
|
|
|
}
|