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";
|
|
|
|
|
|
|
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
|
|
|
|
// Leaflet's and marker-cluster's stylesheets are imported globally in
|
|
|
|
|
|
// app/globals.css — see the comment there for why they must not live here.
|
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
|
|
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
|
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 MarkerClusterGroup from "react-leaflet-cluster";
|
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 L from "leaflet";
|
|
|
|
|
|
import { MapPinned, Loader2, Search, X, MapPin, Trash2 } from "lucide-react";
|
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-10-06 02:02:08 +00:00
|
|
|
|
import { HEAD_OFFICE, hasCoords, resolveAssetLocation, type MapAsset, type MapSite } from "@/lib/plant-map";
|
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-06 02:02:08 +00:00
|
|
|
|
// Detailed street tiles in both themes. Esri's gray canvas returns placeholder
|
|
|
|
|
|
// images above zoom 16 here, even though its metadata lists higher levels.
|
|
|
|
|
|
const STREET_TILES = {
|
|
|
|
|
|
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
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
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const escapeHtml = (s: string) =>
|
|
|
|
|
|
s.replace(/[&<>"']/g, (c) =>
|
|
|
|
|
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Leaflet's stock markers point at image files that Webpack rewrites, which
|
|
|
|
|
|
* breaks under Next's bundler. A divIcon with inline markup sidesteps asset
|
|
|
|
|
|
* resolution entirely and lets the pin carry the machine code and brand colour.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function badgeIcon(label: string, active: boolean) {
|
|
|
|
|
|
const fill = active ? "#00798C" : "#9CA3AF";
|
|
|
|
|
|
const ring = active ? "rgba(0,121,140,0.28)" : "rgba(156,163,175,0.25)";
|
|
|
|
|
|
return L.divIcon({
|
|
|
|
|
|
className: "plant-map-marker",
|
2026-10-06 02:02:08 +00:00
|
|
|
|
html: `<span style="display:flex;width:44px;height:44px;align-items:center;justify-content:center"><span style="
|
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
|
|
|
|
display:flex;align-items:center;justify-content:center;
|
|
|
|
|
|
width:34px;height:34px;border-radius:9999px;
|
|
|
|
|
|
background:${fill};color:#fff;font-weight:700;font-size:12px;
|
|
|
|
|
|
letter-spacing:.02em;border:2.5px solid #fff;
|
|
|
|
|
|
box-shadow:0 0 0 6px ${ring},0 2px 6px rgba(0,0,0,.3);
|
2026-10-06 02:02:08 +00:00
|
|
|
|
">${escapeHtml(label)}</span></span>`,
|
|
|
|
|
|
iconSize: [44, 44],
|
|
|
|
|
|
iconAnchor: [22, 22],
|
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
|
|
|
|
popupAnchor: [0, -20],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
/**
|
|
|
|
|
|
* Cluster bubble showing how many machines share a point. Sizing steps up with
|
|
|
|
|
|
* the count so a glance reads magnitude, and the colour walks the brand's own
|
|
|
|
|
|
* ramp — teal for a handful, indigo navy for a full yard.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `fontSize` and `iconSize` are interpolated from the plugin's 0–10
|
|
|
|
|
|
* `childCount`, which is how markercluster reports cluster density.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function clusterIcon(cluster: L.MarkerCluster) {
|
|
|
|
|
|
const count = cluster.getChildCount();
|
|
|
|
|
|
const density = Math.min(cluster.getChildCount() / 10, 1);
|
|
|
|
|
|
|
|
|
|
|
|
const size = Math.round(44 + density * 16); // 44px floor keeps it tappable on a phone
|
|
|
|
|
|
const fill = count < 10 ? "#00798C" : count < 50 ? "#30638E" : "#003D5B";
|
|
|
|
|
|
const fontSize = count > 999 ? 13 : count > 99 ? 14 : 15;
|
|
|
|
|
|
|
|
|
|
|
|
return L.divIcon({
|
|
|
|
|
|
className: "plant-map-cluster",
|
|
|
|
|
|
html: `<span style="
|
|
|
|
|
|
display:flex;align-items:center;justify-content:center;
|
|
|
|
|
|
width:${size}px;height:${size}px;border-radius:9999px;
|
|
|
|
|
|
background:${fill};color:#fff;font-weight:700;font-size:${fontSize}px;
|
|
|
|
|
|
font-variant-numeric:tabular-nums;
|
|
|
|
|
|
border:3px solid rgba(255,255,255,.92);
|
|
|
|
|
|
box-shadow:0 0 0 7px ${fill}33,0 3px 10px rgba(0,0,0,.35);
|
|
|
|
|
|
">${count}</span>`,
|
|
|
|
|
|
iconSize: [size, size],
|
|
|
|
|
|
// Anchor on the centre: a cluster represents a point, not a pin tip.
|
|
|
|
|
|
iconAnchor: [size / 2, size / 2],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 02:02:08 +00:00
|
|
|
|
function waypointIcon(office = false) {
|
|
|
|
|
|
return L.divIcon({
|
|
|
|
|
|
className: "plant-map-waypoint",
|
|
|
|
|
|
html: `<span style="display:flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:12px;background:${office ? "#30638E" : "#EDAE49"};color:${office ? "#fff" : "#003D5B"};border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.35)"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span>`,
|
|
|
|
|
|
iconSize: [44, 44],
|
|
|
|
|
|
// Keep the waypoint above machines clustered at the same coordinates.
|
|
|
|
|
|
iconAnchor: [22, 68],
|
|
|
|
|
|
popupAnchor: [0, -68],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function MapController({ focus, points, fitKey }: {
|
|
|
|
|
|
focus: { lat: number; lng: number; zoom?: number } | null;
|
|
|
|
|
|
points: [number, number][];
|
|
|
|
|
|
fitKey: number;
|
|
|
|
|
|
}) {
|
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
|
|
|
|
const map = useMap();
|
2026-10-06 02:02:08 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
map.fitBounds(L.latLngBounds(points), { padding: [50, 70], maxZoom: 14 });
|
|
|
|
|
|
}, [map, points, fitKey]);
|
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
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!focus) return;
|
2026-10-06 02:02:08 +00:00
|
|
|
|
map.flyTo([focus.lat, focus.lng], focus.zoom ?? Math.max(map.getZoom(), 15), { duration: 0.8 });
|
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
|
|
|
|
}, [focus, map]);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 02:02:08 +00:00
|
|
|
|
export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite[] }) {
|
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
|
|
|
|
const [assetsState, setAssets] = useState<MapAsset[]>(assets);
|
|
|
|
|
|
const [query, setQuery] = useState("");
|
|
|
|
|
|
const [showInactive, setShowInactive] = useState(true);
|
|
|
|
|
|
const [focus, setFocus] = useState<{ lat: number; lng: number; zoom?: number } | null>(null);
|
2026-10-06 02:02:08 +00:00
|
|
|
|
const [fitKey, setFitKey] = useState(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
|
|
|
|
const [placingId, setPlacingId] = useState<string | null>(null);
|
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
const [notice, setNotice] = useState<string | null>(null);
|
|
|
|
|
|
|
2026-10-06 02:02:08 +00:00
|
|
|
|
useEffect(() => setAssets(assets), [assets]);
|
|
|
|
|
|
const mappedAssets = useMemo(() => assetsState.map(a => ({ ...a, ...resolveAssetLocation(a, sites) })), [assetsState, sites]);
|
|
|
|
|
|
const located = useMemo(() => mappedAssets.filter(hasCoords), [mappedAssets]);
|
|
|
|
|
|
const awaiting = useMemo(() => mappedAssets.filter(a => !hasCoords(a)), [mappedAssets]);
|
|
|
|
|
|
const pinnedSites = useMemo(() => sites.filter(hasCoords), [sites]);
|
|
|
|
|
|
const points = useMemo<[number, number][]>(() => [
|
|
|
|
|
|
[HEAD_OFFICE.latitude, HEAD_OFFICE.longitude],
|
|
|
|
|
|
...pinnedSites.map(s => [s.latitude, s.longitude] as [number, number]),
|
|
|
|
|
|
...located.map(a => [a.latitude!, a.longitude!] as [number, number]),
|
|
|
|
|
|
], [pinnedSites, located]);
|
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
|
|
|
|
|
|
|
|
|
|
const visible = useMemo(() => {
|
|
|
|
|
|
const q = query.trim().toLowerCase();
|
2026-10-06 02:02:08 +00:00
|
|
|
|
return mappedAssets
|
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
|
|
|
|
.filter((a) => (showInactive ? true : a.is_active))
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
(a) =>
|
|
|
|
|
|
!q ||
|
|
|
|
|
|
a.code.toLowerCase().includes(q) ||
|
|
|
|
|
|
a.name.toLowerCase().includes(q) ||
|
2026-10-06 02:02:08 +00:00
|
|
|
|
a.type.toLowerCase().includes(q) ||
|
|
|
|
|
|
a.siteName?.toLowerCase().includes(q),
|
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-06 02:02:08 +00:00
|
|
|
|
}, [mappedAssets, query, showInactive]);
|
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
|
|
|
|
|
|
|
|
|
|
const saveCoords = useCallback(async (id: string, lat: number, lng: number) => {
|
|
|
|
|
|
setSaving(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/api/assets/${id}`, {
|
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ latitude: lat, longitude: lng }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (!res.ok || !data.ok) throw new Error(data.error || "Could not save location");
|
|
|
|
|
|
setAssets((curr) =>
|
|
|
|
|
|
curr.map((a) => (a.id === id ? { ...a, latitude: lat, longitude: lng } : a)),
|
|
|
|
|
|
);
|
2026-10-06 02:02:08 +00:00
|
|
|
|
setPlacingId(null);
|
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
|
|
|
|
setNotice("Location saved");
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
setNotice(e?.message || "Could not save location");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const clearCoords = useCallback(async (id: string) => {
|
|
|
|
|
|
setSaving(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/api/assets/${id}`, {
|
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ latitude: null, longitude: null }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (!res.ok || !data.ok) throw new Error(data.error || "Could not clear location");
|
|
|
|
|
|
setAssets((curr) =>
|
|
|
|
|
|
curr.map((a) => (a.id === id ? { ...a, latitude: null, longitude: null } : a)),
|
|
|
|
|
|
);
|
|
|
|
|
|
setNotice("Location cleared");
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
setNotice(e?.message || "Could not clear location");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!notice) return;
|
|
|
|
|
|
const t = window.setTimeout(() => setNotice(null), 2500);
|
|
|
|
|
|
return () => window.clearTimeout(t);
|
|
|
|
|
|
}, [notice]);
|
|
|
|
|
|
|
|
|
|
|
|
const placing = placingId ? assetsState.find((a) => a.id === placingId) : null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-10-06 02:02:08 +00:00
|
|
|
|
<div className="grid min-w-0 lg:grid-cols-[minmax(0,1fr)_340px] gap-4 items-start">
|
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
|
|
|
|
{/* ── Map ─────────────────────────────────────────────────────── */}
|
2026-10-06 02:02:08 +00:00
|
|
|
|
<div className="relative min-w-0 rounded-2xl overflow-hidden border border-gray-200 dark:border-white/5 shadow-sm">
|
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
|
|
|
|
<MapContainer
|
2026-10-06 02:02:08 +00:00
|
|
|
|
center={[HEAD_OFFICE.latitude, HEAD_OFFICE.longitude]}
|
|
|
|
|
|
zoom={10}
|
|
|
|
|
|
maxZoom={19}
|
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
|
|
|
|
scrollWheelZoom
|
|
|
|
|
|
className={cn("w-full", "h-[52vh] min-h-[320px]", "lg:h-[calc(100vh-15rem)] lg:min-h-[560px]")}
|
|
|
|
|
|
>
|
2026-10-06 02:02:08 +00:00
|
|
|
|
<TileLayer url={STREET_TILES.url} attribution={STREET_TILES.attribution} maxNativeZoom={19} maxZoom={19} />
|
|
|
|
|
|
<MapController focus={focus} points={points} fitKey={fitKey} />
|
|
|
|
|
|
{placingId && <MapClickCapture onPick={(lat, lng) => saveCoords(placingId, lat, lng)} disabled={saving} />}
|
|
|
|
|
|
<Marker position={[HEAD_OFFICE.latitude, HEAD_OFFICE.longitude]} icon={waypointIcon(true)} title="Head office" zIndexOffset={1000}>
|
|
|
|
|
|
<Popup><strong>Head office</strong><p>Default location for unassigned machines.</p></Popup>
|
|
|
|
|
|
</Marker>
|
|
|
|
|
|
{pinnedSites.map(site => (
|
|
|
|
|
|
<Marker key={site.id} position={[site.latitude, site.longitude]} icon={waypointIcon()} title={site.name} zIndexOffset={1000}>
|
|
|
|
|
|
<Popup>
|
|
|
|
|
|
<strong>{site.name}</strong>
|
|
|
|
|
|
<p>{assetsState.filter(a => a.site === site.id).length} machines assigned · Site location</p>
|
|
|
|
|
|
<a href={`/sites/${site.id}`}>View site details</a>
|
|
|
|
|
|
</Popup>
|
|
|
|
|
|
</Marker>
|
|
|
|
|
|
))}
|
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
|
|
|
|
|
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
|
|
|
|
{/* Clustering: machines sharing a point collapse into one numbered
|
|
|
|
|
|
bubble. Clicking a bubble zooms to the machines inside it, and
|
|
|
|
|
|
markers that genuinely overlap at max zoom spiderfy apart so every
|
|
|
|
|
|
machine stays individually reachable. */}
|
|
|
|
|
|
<MarkerClusterGroup
|
|
|
|
|
|
iconCreateFunction={clusterIcon}
|
|
|
|
|
|
chunkedLoading
|
|
|
|
|
|
showCoverageOnHover={false}
|
|
|
|
|
|
zoomToBoundsOnClick
|
|
|
|
|
|
spiderfyOnMaxZoom
|
|
|
|
|
|
maxClusterRadius={70}
|
|
|
|
|
|
removeOutsideVisibleBounds
|
|
|
|
|
|
>
|
2026-10-06 02:02:08 +00:00
|
|
|
|
{visible.filter(hasCoords).map((a) => (
|
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
|
|
|
|
<Marker
|
|
|
|
|
|
key={a.id}
|
|
|
|
|
|
position={[a.latitude as number, a.longitude as number]}
|
|
|
|
|
|
icon={badgeIcon(a.code || "?", a.is_active)}
|
2026-10-06 02:02:08 +00:00
|
|
|
|
title={`${a.code || a.name} · ${a.siteName || (a.source === "head-office" ? "Head office" : "Manual pin")}`}
|
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
|
|
|
|
>
|
|
|
|
|
|
<Popup>
|
|
|
|
|
|
<div style={{ minWidth: "180px" }}>
|
2026-10-06 02:02:08 +00:00
|
|
|
|
<div style={{ fontWeight: 700, marginBottom: 2 }}>{a.name}</div>
|
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 style={{ color: "#6b7280", fontSize: 12, marginBottom: 6 }}>
|
2026-10-06 02:02:08 +00:00
|
|
|
|
{a.code || "no code"}
|
|
|
|
|
|
{a.type ? ` · ${a.type}` : ""}
|
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>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: "inline-block",
|
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
|
padding: "2px 8px",
|
|
|
|
|
|
borderRadius: 999,
|
|
|
|
|
|
background: a.is_active ? "rgba(0,121,140,.12)" : "rgba(156,163,175,.18)",
|
|
|
|
|
|
color: a.is_active ? "#00798C" : "#6b7280",
|
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{a.is_active ? "Active" : "Inactive"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ color: "#6b7280", fontSize: 12, marginTop: 6 }}>
|
2026-10-06 02:02:08 +00:00
|
|
|
|
{a.siteName ? `${a.siteName} · Site location` : a.source === "head-office" ? "Head office · Default location" : "Manual location"}
|
|
|
|
|
|
<br />{a.latitude?.toFixed(5)}, {a.longitude?.toFixed(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
|
|
|
|
</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>
|
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
|
|
|
|
</Popup>
|
|
|
|
|
|
</Marker>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</MarkerClusterGroup>
|
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
|
|
|
|
</MapContainer>
|
|
|
|
|
|
|
2026-10-06 02:02:08 +00:00
|
|
|
|
<button type="button" onClick={() => { setFocus(null); setFitKey(k => k + 1); }}
|
|
|
|
|
|
className="absolute top-3 right-3 z-[500] min-h-[44px] px-3 rounded-lg bg-white dark:bg-surface text-sm font-medium shadow text-[#003D5B] dark:text-white">
|
|
|
|
|
|
Fit all
|
|
|
|
|
|
</button>
|
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
|
|
|
|
{/* Legend */}
|
2026-10-06 02:02:08 +00:00
|
|
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 bg-white dark:bg-surface border-t border-gray-200 dark:border-white/10 px-3 py-2 text-xs">
|
|
|
|
|
|
<span className="flex items-center gap-2"><span className="w-3 h-3 rounded-sm bg-[#EDAE49]" /><span className="text-gray-600 dark:text-gray-300">Site</span></span>
|
|
|
|
|
|
<span className="flex items-center gap-2"><span className="w-3 h-3 rounded-sm bg-[#30638E]" /><span className="text-gray-600 dark:text-gray-300">Head office</span></span>
|
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 className="flex items-center gap-2">
|
|
|
|
|
|
<span className="w-3 h-3 rounded-full bg-[#00798C] border-2 border-white shadow" />
|
|
|
|
|
|
<span className="text-gray-600 dark:text-gray-300">Active</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="flex items-center gap-2">
|
|
|
|
|
|
<span className="w-3 h-3 rounded-full bg-gray-400 border-2 border-white shadow" />
|
|
|
|
|
|
<span className="text-gray-600 dark:text-gray-300">Inactive</span>
|
|
|
|
|
|
</span>
|
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
|
|
|
|
{located.length > 1 && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span className="flex items-center gap-2">
|
|
|
|
|
|
<span className="w-3 h-3 rounded-full bg-[#30638E] border-2 border-white shadow" />
|
|
|
|
|
|
<span className="text-gray-600 dark:text-gray-300">
|
|
|
|
|
|
Cluster — tap to zoom
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
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>
|
|
|
|
|
|
|
|
|
|
|
|
{notice && (
|
|
|
|
|
|
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[700] px-3 py-2 rounded-xl bg-[#003D5B] text-white text-xs font-medium shadow-lg">
|
|
|
|
|
|
{notice}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* ── Side panel ──────────────────────────────────────────────── */}
|
2026-10-06 02:02:08 +00:00
|
|
|
|
<aside className="min-w-0 rounded-2xl border border-gray-200 dark:border-white/5 bg-white dark:bg-surface shadow-sm overflow-hidden">
|
|
|
|
|
|
<div className="p-4 border-b border-gray-100 dark:border-white/5">
|
|
|
|
|
|
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white mb-2">Sites & head office</h2>
|
|
|
|
|
|
<button type="button" onClick={() => setFocus({ lat: HEAD_OFFICE.latitude, lng: HEAD_OFFICE.longitude })}
|
|
|
|
|
|
className="flex items-center gap-2 w-full min-h-[44px] text-left text-sm text-[#003D5B] dark:text-white">
|
|
|
|
|
|
<MapPinned className="w-4 h-4 shrink-0 text-[#30638E]" />Head office
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{pinnedSites.map(site => (
|
|
|
|
|
|
<button key={site.id} type="button"
|
|
|
|
|
|
onClick={() => setFocus({ lat: site.latitude, lng: site.longitude })}
|
|
|
|
|
|
className="flex items-center gap-2 w-full min-h-[44px] text-left text-sm text-[#003D5B] dark:text-white">
|
|
|
|
|
|
<MapPin className="w-4 h-4 shrink-0 text-[#EDAE49]" />
|
|
|
|
|
|
<span className="min-w-0 flex-1 truncate">{site.name}</span>
|
|
|
|
|
|
<span className="text-xs shrink-0 text-gray-500 dark:text-gray-400">{assetsState.filter(a => a.site === site.id).length} assigned</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{sites.length > pinnedSites.length && (
|
|
|
|
|
|
<a href="/sites" className="flex items-center min-h-[44px] text-xs text-[#30638E] dark:text-gray-300 underline underline-offset-4">
|
|
|
|
|
|
{sites.length - pinnedSites.length} sites still need coordinates
|
|
|
|
|
|
</a>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">Assigned machines follow their site's pin. These locations are based on assignments, not live GPS.</p>
|
|
|
|
|
|
</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 className="p-4 border-b border-gray-100 dark:border-white/5">
|
|
|
|
|
|
<div className="flex items-center justify-between mb-3">
|
|
|
|
|
|
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Machines</h2>
|
|
|
|
|
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
|
{located.length} located · {awaiting.length} awaiting
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="relative mb-3">
|
|
|
|
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={query}
|
|
|
|
|
|
onChange={(e) => setQuery(e.target.value)}
|
2026-10-06 02:02:08 +00:00
|
|
|
|
placeholder="Filter code, name, type, site…"
|
2026-10-03 07:20:24 +00:00
|
|
|
|
className="w-full min-h-[44px] pl-9 pr-3 rounded-lg text-sm bg-gray-50 dark:bg-white/5 border border-transparent focus:bg-white dark:focus:bg-white/10 focus:border-[#30638E] focus:outline-none"
|
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 className="flex items-center gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowInactive((v) => !v)}
|
|
|
|
|
|
aria-pressed={showInactive}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex-1 inline-flex items-center justify-center gap-1.5 min-h-[44px] px-3 rounded-lg text-xs font-medium transition",
|
|
|
|
|
|
showInactive
|
|
|
|
|
|
? "bg-[#003D5B] text-white"
|
|
|
|
|
|
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{showInactive ? "All machines" : "Active only"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => {
|
2026-10-06 02:02:08 +00:00
|
|
|
|
setPlacingId((cur) => (cur ? null : (mappedAssets.find(a => !a.site)?.id ?? null)));
|
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
|
|
|
|
setNotice(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"inline-flex items-center justify-center gap-1.5 min-h-[44px] px-3 rounded-lg text-xs font-semibold transition",
|
|
|
|
|
|
placingId
|
|
|
|
|
|
? "bg-[#EDAE49] text-[#003D5B] hover:bg-[#E5A340]"
|
|
|
|
|
|
: "bg-[#30638E] text-white hover:bg-[#003D5B]",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <MapPin className="w-4 h-4" />}
|
|
|
|
|
|
{placingId ? "Placing…" : "Set location"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{placingId && (
|
|
|
|
|
|
<p className="mt-2.5 text-xs text-[#EDAE49] font-medium leading-relaxed">
|
|
|
|
|
|
Tap the map to place{" "}
|
|
|
|
|
|
<span className="font-bold">{placing?.code || placing?.name}</span>, or pick another
|
|
|
|
|
|
machine below.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="max-h-[52vh] lg:max-h-[calc(100vh-25rem)] overflow-y-auto">
|
|
|
|
|
|
{visible.length === 0 ? (
|
|
|
|
|
|
<p className="p-4 text-sm text-gray-500 dark:text-gray-400">No machines match.</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ul className="divide-y divide-gray-100 dark:divide-white/5">
|
|
|
|
|
|
{visible.map((a) => {
|
|
|
|
|
|
const on = hasCoords(a);
|
|
|
|
|
|
const isPlacing = placingId === a.id;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<li
|
|
|
|
|
|
key={a.id}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex items-center gap-2 p-2 transition",
|
|
|
|
|
|
isPlacing ? "bg-[#EDAE49]/10" : "hover:bg-gray-50 dark:hover:bg-white/[0.02]",
|
|
|
|
|
|
!a.is_active && !isPlacing && "opacity-60",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
disabled={!on}
|
|
|
|
|
|
onClick={() => setFocus({ lat: a.latitude as number, lng: a.longitude as number })}
|
|
|
|
|
|
className="flex-1 min-w-0 min-h-[44px] text-left disabled:cursor-default"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="flex items-center gap-2">
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"font-mono text-xs px-1.5 py-0.5 rounded font-semibold shrink-0",
|
|
|
|
|
|
a.is_active
|
|
|
|
|
|
? "bg-[#30638E]/10 text-[#30638E]"
|
|
|
|
|
|
: "bg-gray-100 dark:bg-white/5 text-gray-500",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{a.code || "—"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="truncate text-sm text-[#003D5B] dark:text-white">
|
|
|
|
|
|
{a.name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="block mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
|
{on ? (
|
|
|
|
|
|
<span className="text-[#00798C] font-medium">
|
2026-10-06 02:02:08 +00:00
|
|
|
|
{a.siteName || (a.source === "head-office" ? "Head office · Default" : "Manual pin")}
|
|
|
|
|
|
<span className="block font-normal text-gray-500 dark:text-gray-400">{a.latitude?.toFixed(4)}, {a.longitude?.toFixed(4)}</span>
|
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>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="text-gray-400 dark:text-gray-500">
|
2026-10-06 02:02:08 +00:00
|
|
|
|
{a.source === "missing-site" ? `${a.siteName} · Site coordinates needed` : "No coordinates yet"}
|
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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setPlacingId(isPlacing ? null : a.id)}
|
|
|
|
|
|
aria-label={`Place ${a.name} on the map`}
|
2026-10-06 02:02:08 +00:00
|
|
|
|
disabled={Boolean(a.site) || saving}
|
|
|
|
|
|
title={a.site ? "Edit the assigned site coordinates from Sites" : undefined}
|
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={cn(
|
2026-10-06 02:02:08 +00:00
|
|
|
|
"inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg transition shrink-0 disabled:opacity-40",
|
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
|
|
|
|
isPlacing
|
|
|
|
|
|
? "bg-[#EDAE49] text-[#003D5B]"
|
|
|
|
|
|
: "text-[#30638E] hover:bg-[#30638E]/10",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isPlacing ? <X className="w-4 h-4" /> : <MapPin className="w-4 h-4" />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
2026-10-06 02:02:08 +00:00
|
|
|
|
{on && !a.site && (
|
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
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => clearCoords(a.id)}
|
|
|
|
|
|
disabled={saving}
|
|
|
|
|
|
aria-label={`Clear location for ${a.name}`}
|
|
|
|
|
|
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg text-[#D1495B] hover:bg-[#D1495B]/10 transition shrink-0 disabled:opacity-40"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="w-4 h-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Leaflet's own click handler is managed by react-leaflet's internals, so a
|
|
|
|
|
|
* tap while "placing" needs its own listener on the container. Registered
|
|
|
|
|
|
* imperatively and removed on unmount.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function MapClickCapture({
|
|
|
|
|
|
onPick,
|
|
|
|
|
|
disabled,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
onPick: (lat: number, lng: number) => void;
|
|
|
|
|
|
disabled: boolean;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const mapRef = useMap();
|
|
|
|
|
|
const handlerRef = useRef(onPick);
|
|
|
|
|
|
handlerRef.current = onPick;
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (disabled) return;
|
|
|
|
|
|
const map = mapRef;
|
|
|
|
|
|
const onClick = (e: L.LeafletMouseEvent) => handlerRef.current(e.latlng.lat, e.latlng.lng);
|
|
|
|
|
|
map.on("click", onClick);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
map.off("click", onClick);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [mapRef, disabled]);
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|