diff --git a/app/api/assets/[id]/impact/shared.ts b/app/api/assets/[id]/impact/shared.ts index 8b0846f..0b0ee81 100644 --- a/app/api/assets/[id]/impact/shared.ts +++ b/app/api/assets/[id]/impact/shared.ts @@ -32,6 +32,9 @@ export async function cascadeImpact( /** Refresh every surface that reads the asset list or its dependent rows. */ export function revalidateAssetSurfaces() { revalidatePath("/plant"); + // Separate route — revalidatePath("/plant") does not cover sub-paths, so the + // Plant Map has to be named explicitly or a reload shows stale pins. + revalidatePath("/plant/map"); revalidatePath("/"); revalidatePath("/income"); revalidatePath("/running"); diff --git a/app/api/assets/[id]/route.ts b/app/api/assets/[id]/route.ts index f399c14..858f6a6 100644 --- a/app/api/assets/[id]/route.ts +++ b/app/api/assets/[id]/route.ts @@ -5,10 +5,11 @@ import { cascadeImpact, revalidateAssetSurfaces } from "./impact/shared"; export const dynamic = "force-dynamic"; // PATCH /api/assets/:id -// Body: any subset of { code?, name?, type?, is_active? } -// Drives the inline Edit cells (code / name / type) and the active toggle in -// the Plant Register table. Only the keys actually present in the body are -// written, so a field edit never clobbers the others. +// Body: any subset of { code?, name?, type?, is_active?, latitude?, longitude? } +// Drives the inline Edit cells (code / name / type), the active toggle in the +// Plant Register table, and the pin-drop mode on the Plant Map. Only the keys +// actually present in the body are written, so a field edit never clobbers the +// others. // // `name` is required by the PocketBase schema, so an empty name is rejected // here rather than failing deep inside the SDK with a 400 from the DB. @@ -21,7 +22,14 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; - let body: { code?: unknown; name?: unknown; type?: unknown; is_active?: unknown } = {}; + let body: { + code?: unknown; + name?: unknown; + type?: unknown; + is_active?: unknown; + latitude?: unknown; + longitude?: unknown; + } = {}; try { body = await req.json(); } catch { @@ -31,7 +39,7 @@ export async function PATCH( ); } - const patch: Record = {}; + const patch: Record = {}; if ("code" in body) { if (typeof body.code !== "string") { @@ -65,6 +73,31 @@ export async function PATCH( patch.is_active = body.is_active; } + // Coordinates for the Plant Map. `null` clears a pin; a number is validated + // against real-world bounds so a typo can't park a marker at lat 900. + for (const key of ["latitude", "longitude"] as const) { + if (!(key in body)) continue; + const v = body[key]; + if (v === null) { + patch[key] = null; + continue; + } + if (typeof v !== "number" || !Number.isFinite(v)) { + return Response.json( + { ok: false, error: `${key} must be a number or null` }, + { status: 400 }, + ); + } + const limit = key === "latitude" ? 90 : 180; + if (v < -limit || v > limit) { + return Response.json( + { ok: false, error: `${key} must be between -${limit} and ${limit}` }, + { status: 400 }, + ); + } + patch[key] = v; + } + if (Object.keys(patch).length === 0) { return Response.json( { ok: false, error: "No editable fields supplied" }, @@ -75,9 +108,11 @@ export async function PATCH( try { const pb = await serverAuth(); const updated = await pb.collection("assets").update(id, patch); - // Plant Register + the dashboard's GlobalSearch use the asset list, so - // refresh both surfaces. + // Plant Register, the Plant Map, and the dashboard's GlobalSearch all read + // the asset list, so refresh every one. revalidatePath("/plant") does not + // cover "/plant/map" — sub-paths must be named separately. revalidatePath("/plant"); + revalidatePath("/plant/map"); revalidatePath("/"); return Response.json({ ok: true, asset: updated }); } catch (e: any) { diff --git a/app/api/invoice-lines/[id]/book/route.ts b/app/api/invoice-lines/[id]/book/route.ts index 62c1752..a9fc073 100644 --- a/app/api/invoice-lines/[id]/book/route.ts +++ b/app/api/invoice-lines/[id]/book/route.ts @@ -1,32 +1,25 @@ import { revalidatePath } from "next/cache"; import { serverAuth } from "@/lib/pb"; -import { bookedAmount, BOOKED_LABEL } from "@/lib/invoice-booking"; +import { takenSiteIdsFor } from "@/lib/sites"; export const dynamic = "force-dynamic"; // POST /api/invoice-lines/:id/book -// Body: { assetId: string } — "" clears the booking. +// Body: { assetId?: string; siteId?: string; clear?: true } // -// Sets invoice_lines.booked_asset and keeps the matching monthly_income row in step: -// the previous invoice-derived row for this line is removed first, then a fresh one is -// posted against the new machine. That makes changing a booking safe and repeatable. -// -// The posted amount follows the shared policy in lib/invoice-booking-policy.json, so -// this route and scripts/book-invoice.mjs can never disagree about it. - +// Books a line to a plant item, or — when nothing in the register matches, which is +// the container codes — to a site. Exactly one target at a time. Booking to a site does +// NOT create a monthly_income row: with no machine there is no income to attribute, so +// the line is only marked as accounted for and stops showing as unbooked. export async function POST( req: Request, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; - let assetId = ""; + let body: { assetId?: string; siteId?: string; clear?: boolean } = {}; try { - const body = await req.json(); - if (typeof body?.assetId !== "string") { - return Response.json({ ok: false, error: "assetId must be a string" }, { status: 400 }); - } - assetId = body.assetId.trim(); + body = await req.json(); } catch { return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); } @@ -35,71 +28,77 @@ export async function POST( try { const line = await pb.collection("invoice_lines").getOne(id); - const invoice = await pb.collection("invoices").getOne(line.invoice); + const allLines = await pb.collection("invoice_lines").getFullList(); - // Verify the target asset actually exists before writing anything, so a stale - // dropdown value fails here rather than creating a dangling relation. - let asset = null; - if (assetId) { - try { - asset = await pb.collection("assets").getOne(assetId); - } catch { - return Response.json( - { ok: false, error: "That plant item no longer exists — reload the page" }, - { status: 400 }, - ); - } + if (body.clear) { + await pb.collection("invoice_lines").update(id, { booked_asset: "", booked_site: "" }); + revalidatePath("/income/invoices"); + revalidatePath("/sites"); + return Response.json({ ok: true, booked: false }); } - // Drop the row this line previously contributed, if any. - const existing = await pb.collection("monthly_income").getFullList({ - filter: `invoice_line = "${id}"`, - }); - for (const row of existing) await pb.collection("monthly_income").delete(row.id); - - await pb.collection("invoice_lines").update(id, { - booked_asset: assetId, - booked_year: line.booked_year || invoice.booked_year, - booked_month: line.booked_month || invoice.booked_month, - }); - - const amount = bookedAmount(line.amount_excl, line.vat); - - let posted = null; - if (asset) { - posted = await pb.collection("monthly_income").create({ - asset: assetId, - year: line.booked_year || invoice.booked_year, - month: line.booked_month || invoice.booked_month, - income: amount, + if (body.assetId) { + let asset = null; + try { + asset = await pb.collection("assets").getOne(body.assetId); + } catch { + return Response.json({ ok: false, error: "That plant item no longer exists" }, { status: 400 }); + } + const existing = await pb.collection("monthly_income").getFullList({ filter: `invoice_line = "${id}"` }); + for (const row of existing) await pb.collection("monthly_income").delete(row.id); + await pb.collection("invoice_lines").update(id, { booked_asset: body.assetId, booked_site: "" }); + const posted = await pb.collection("monthly_income").create({ + asset: body.assetId, + year: line.booked_year, + month: line.booked_month, + income: line.total_incl, fixed_expenses: 0, is_forecast: false, invoice_line: id, }); + await refreshBookedState(pb, line.invoice); + revalidatePath("/income/invoices"); + revalidatePath("/income"); + revalidatePath("/sites"); + return Response.json({ + ok: true, + booked: true, + target: "asset", + asset: { id: asset.id, code: asset.code, name: asset.name }, + income: posted.income, + }); } - // Re-evaluate the header: booked only when every one of its lines has a target. - const siblings = await pb.collection("invoice_lines").getFullList({ - filter: `invoice = "${line.invoice}"`, - }); - const complete = siblings.every((l) => (l.id === id ? assetId : l.booked_asset)); - await pb.collection("invoices").update(line.invoice, { is_booked: complete }); + if (body.siteId) { + const site = await pb.collection("sites").getOne(body.siteId); + // Enforce one container per site, server-side — the picker hides taken sites, but + // the rule must not depend on the UI being the only way in. + const taken = takenSiteIdsFor(allLines as any[], id); + if (taken.has(body.siteId)) { + return Response.json( + { ok: false, error: `${site.name} already has a container booked to it` }, + { status: 409 }, + ); + } + // No monthly_income row: a site booking accounts for the line without inventing a + // machine to carry income. + const existing = await pb.collection("monthly_income").getFullList({ filter: `invoice_line = "${id}"` }); + for (const row of existing) await pb.collection("monthly_income").delete(row.id); + await pb.collection("invoice_lines").update(id, { booked_site: body.siteId, booked_asset: "" }); + await refreshBookedState(pb, line.invoice); + revalidatePath("/income/invoices"); + revalidatePath("/sites"); + return Response.json({ ok: true, booked: true, target: "site", site: { id: site.id, name: site.name } }); + } - revalidatePath("/income/invoices"); - revalidatePath("/income"); - - return Response.json({ - ok: true, - booked: Boolean(asset), - asset: asset ? { id: asset.id, code: asset.code, name: asset.name } : null, - income: posted?.income ?? 0, - label: BOOKED_LABEL, - invoiceFullyBooked: complete, - }); + return Response.json({ ok: false, error: "Nothing to book — send assetId, siteId or clear" }, { status: 400 }); } catch (e: any) { - return Response.json( - { ok: false, error: e?.message || "Booking failed" }, - { status: 500 }, - ); + return Response.json({ ok: false, error: e?.message || "Booking failed" }, { status: 500 }); } +} + +async function refreshBookedState(pb: any, invoiceId: string) { + const siblings = await pb.collection("invoice_lines").getFullList({ filter: `invoice = "${invoiceId}"` }); + const complete = siblings.every((l: any) => l.booked_asset || l.booked_site); + await pb.collection("invoices").update(invoiceId, { is_booked: complete }); } \ No newline at end of file diff --git a/app/api/invoice-lines/[id]/site-options/route.ts b/app/api/invoice-lines/[id]/site-options/route.ts new file mode 100644 index 0000000..f954d9c --- /dev/null +++ b/app/api/invoice-lines/[id]/site-options/route.ts @@ -0,0 +1,34 @@ +import { serverAuth } from "@/lib/pb"; +import { takenSiteIdsFor } from "@/lib/sites"; + +export const dynamic = "force-dynamic"; + +// GET /api/invoice-lines/:id/site-options +// The sites a line may be booked to. +// +// One container per site: a site another machine-less line has already taken is returned +// with `taken: true` so the picker can show it disabled rather than hide it — the user +// can still see the site exists and why it is not available. Clearing a booking frees it. +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const pb = await serverAuth(); + try { + const [sites, lines] = await Promise.all([ + pb.collection("sites").getFullList({ sort: "name" }), + pb.collection("invoice_lines").getFullList(), + ]); + const taken = takenSiteIdsFor(lines as any[], id); + return Response.json({ + ok: true, + options: (sites as any[]) + .map((s) => ({ id: s.id, name: s.name, taken: taken.has(s.id) })) + // Free sites first, so the ones you can actually pick are at the top. + .sort((a, b) => Number(a.taken) - Number(b.taken) || a.name.localeCompare(b.name)), + }); + } catch (e: any) { + return Response.json({ ok: false, error: e?.message || "Could not load sites" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/invoices/intake/route.ts b/app/api/invoices/intake/route.ts index 3e91f20..0177691 100644 --- a/app/api/invoices/intake/route.ts +++ b/app/api/invoices/intake/route.ts @@ -1,6 +1,7 @@ import { revalidatePath } from "next/cache"; import { serverAuth } from "@/lib/pb"; import { parseInvoicePdf, ParseError, registerCodeFrom } from "@/lib/invoice-parser"; +import { syncSitesFromInvoices } from "@/lib/sites"; export const dynamic = "force-dynamic"; @@ -101,7 +102,7 @@ export async function POST(req: Request) { // can never succeed without first deleting the original — so it must not land in the // retry inbox, where it would sit looking like something to fix. It is reported with // its own status so the UI can say "you already have this" instead of "this failed". - const allInvoices = await pb.collection("invoices").getFullList(); + const allInvoices = (await pb.collection("invoices").getFullList()) as any[]; const existing = allInvoices.find((i: any) => i.invoice_no === parsed.invoice_no); if (existing) { return Response.json( @@ -161,6 +162,9 @@ export async function POST(req: Request) { ); } + // The invoice is written, so this site is now real. Sites are derived, never typed. + await syncSitesFromInvoices(pb, [...allInvoices, invoiceRec as any]); + const unassigned: string[] = []; for (let i = 0; i < parsed.lines.length; i++) { const l = parsed.lines[i]; diff --git a/app/layout.tsx b/app/layout.tsx index ff8bbe7..5c8b165 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,12 +3,12 @@ import type { Metadata } from "next"; import Link from "next/link"; import { LayoutDashboard, - Truck, TrendingUp, Wrench, Wallet, LineChart as LineChartIcon, Receipt, + MapPin, Boxes, } from "lucide-react"; import { Sidebar, type NavItem } from "@/components/Sidebar"; @@ -46,8 +46,24 @@ export const metadata: Metadata = { }; const navItems: NavItem[] = [ - { kind: "link", href: "/", label: "Dashboard", icon: }, - { kind: "link", href: "/plant", label: "Plant Register", icon: }, + { + kind: "group", + label: "Dashboard", + icon: , + children: [ + { href: "/", label: "Overview" }, + { href: "/sites", label: "Sites" }, + ], + }, + { + kind: "group", + label: "Plant Register", + icon: , + children: [ + { href: "/plant", label: "Register" }, + { href: "/plant/map", label: "Plant Map" }, + ], + }, { kind: "group", label: "Income", diff --git a/app/plant/map/page.tsx b/app/plant/map/page.tsx new file mode 100644 index 0000000..55ab029 --- /dev/null +++ b/app/plant/map/page.tsx @@ -0,0 +1,44 @@ +import { getAssets } from "@/lib/data"; +import { PlantMapLoader } from "@/components/PlantMapLoader"; +import { MapPinned } from "lucide-react"; + +// Same reason as /plant — reads PocketBase per request, never prerendered. +export const dynamic = "force-dynamic"; + +export default async function PlantMapPage() { + const assets = await getAssets(); + const located = assets.filter( + (a) => a.latitude != null && a.longitude != null && (a.latitude !== 0 || a.longitude !== 0), + ).length; + + return ( +
+
+
+
+
+ +
+

Plant Map

+
+

+ {assets.length} registered · {located} located on the map +

+
+
+ + ({ + id: a.id, + code: a.code, + name: a.name, + category: a.category, + type: a.type, + is_active: a.is_active !== false, + latitude: a.latitude, + longitude: a.longitude, + }))} + /> +
+ ); +} diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx new file mode 100644 index 0000000..6007b38 --- /dev/null +++ b/app/sites/[id]/page.tsx @@ -0,0 +1,179 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getAssets, getInvoices, getInvoiceLines, getSites, joinBookedLines } from "@/lib/data"; +import { buildSiteSummaries } from "@/lib/sites"; +import { ZAR2 } from "@/lib/utils"; +import { ScrollFade } from "@/components/ScrollFade"; +import { ArrowLeft, MapPin, FileText } from "lucide-react"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + const sites = await getSites(); + const site = sites.find((s) => s.id === id); + return { title: `${site?.name ?? "Site"} — Mega Plant` }; +} + +export default async function SiteDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const [sites, invoices, lines, assets] = await Promise.all([ + getSites(), + getInvoices(), + getInvoiceLines(), + getAssets(), + ]); + + const site = sites.find((s) => s.id === id); + if (!site) notFound(); + + const joined = joinBookedLines(lines, assets); + const summary = buildSiteSummaries(sites, invoices, joined, assets).find((s) => s.id === id); + if (!summary) notFound(); + + const invById = new Map(invoices.map((i) => [i.id, i])); + const assetById = new Map(assets.map((a) => [a.id, a])); + const siteLines = joined + .filter((l) => invById.get(l.invoice)?.site === site.name) + .sort((a, b) => (invById.get(b.invoice)!.invoice_date).localeCompare(invById.get(a.invoice)!.invoice_date) || a.line_no - b.line_no); + + return ( +
+ + + All sites + + +
+
+
+
+ +
+

{site.name}

+
+

+ {summary.invoices.length} invoice{summary.invoices.length === 1 ? "" : "s"} ·{" "} + {summary.machineCount} machine{summary.machineCount === 1 ? "" : "s"} ·{" "} + {summary.lineCount} line{summary.lineCount === 1 ? "" : "s"} +

+
+
+
+
Revenue (ex VAT)
+
+ {ZAR2(summary.revenueExcl)} +
+
+ + {ZAR2(summary.vat)} VAT +
+
+
+
Billed (incl VAT)
+
{ZAR2(summary.revenueIncl)}
+
+
+
+ +
+
+
+
+

Line items

+
+
+ + + + + + + + + + + + + {siteLines.map((l) => { + const inv = invById.get(l.invoice)!; + const asset = l.booked_asset ? assetById.get(l.booked_asset) : null; + return ( + + + + + + + + + ); + })} + +
MachineInvoiceQtyEx VATVATTotal
+
{l.machine_code}
+
+ {asset ? `${asset.code} ${asset.name}` : l.description} +
+
+ {inv.invoice_no} + + {l.qty} {l.unit} + {ZAR2(l.amount_excl)}{ZAR2(l.vat)} + {ZAR2(l.total_incl)} +
+
+ +
+
+ +
+
+
+

Invoices

+
+
    + {summary.invoices.map((i) => ( +
  • + + + + + {i.invoiceNo} + + + {String(i.invoiceDate).slice(0, 10)} + + + + + {ZAR2(i.totalIncl)} + +
  • + ))} +
+
+ + {summary.unbookedCount > 0 && ( +
+

Unbooked

+

+ {summary.unbookedCount} line{summary.unbookedCount === 1 ? "" : "s"} here still need a + machine or a site — {ZAR2(summary.unbookedAmount)} incl VAT. +

+ + Go and assign them + +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/sites/page.tsx b/app/sites/page.tsx new file mode 100644 index 0000000..d475693 --- /dev/null +++ b/app/sites/page.tsx @@ -0,0 +1,173 @@ +import type { Metadata } from "next"; +import { getAssets, getInvoices, getInvoiceLines, getSites, joinBookedLines } from "@/lib/data"; +import { buildSiteSummaries } from "@/lib/sites"; +import { ZAR, ZAR2 } from "@/lib/utils"; +import { ScrollFade } from "@/components/ScrollFade"; +import { AlertTriangle, ArrowRight, MapPin } from "lucide-react"; + +export const dynamic = "force-dynamic"; +export const metadata: Metadata = { title: "Sites — Mega Plant" }; + +export default async function SitesPage() { + const [sites, invoices, lines, assets] = await Promise.all([ + getSites(), + getInvoices(), + getInvoiceLines(), + getAssets(), + ]); + + const joined = joinBookedLines(lines, assets); + const rows = buildSiteSummaries(sites, invoices, joined, assets); + + const totalIncl = rows.reduce((s, r) => s + r.revenueIncl, 0); + const totalExcl = rows.reduce((s, r) => s + r.revenueExcl, 0); + const totalVat = rows.reduce((s, r) => s + r.vat, 0); + const outstanding = rows.reduce((s, r) => s + r.unbookedCount, 0); + + return ( +
+
+
+
+
+ +
+

Sites

+
+

+ {rows.length} site{rows.length === 1 ? "" : "s"} from the {invoices.length} imported + invoice{invoices.length === 1 ? "" : "s"} · revenue and VAT per site +

+
+
+ +
+
Billed (incl VAT)
+
{ZAR2(totalIncl)}
+
+
+
+ + {outstanding > 0 && ( +
+ +

+ {outstanding} line{outstanding === 1 ? "" : "s"} across these sites still have no + machine and no site. Open an invoice and assign one. +

+
+ )} + + {rows.length === 0 ? ( +
+

No sites yet.

+

+ Sites appear automatically as invoices are imported. +

+
+ ) : ( +
+
+ + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + ))} + + + + + + + + + +
+ Site + + Invoices + + Machines + Revenue ex VAT + VAT + Total incl VATUnbooked
+ + + {r.name} + + + + {r.invoices.length} + + {r.machineCount} + + {ZAR2(r.revenueExcl)} + + {ZAR2(r.vat)} + + {ZAR2(r.revenueIncl)} + + {r.unbookedCount > 0 ? ( + + + {r.unbookedCount} unbooked + + ) : ( + — + )} +
+ Total + + + + {ZAR(totalExcl)} + + {ZAR(totalVat)} + + {ZAR(totalIncl)} + +
+
+ +
+ )} +
+ ); +} + +function Tile({ label, value, sub }: { label: string; value: string; sub?: string }) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} \ No newline at end of file diff --git a/components/InvoiceList.tsx b/components/InvoiceList.tsx index a0a63a4..357886c 100644 --- a/components/InvoiceList.tsx +++ b/components/InvoiceList.tsx @@ -136,13 +136,13 @@ function InvoiceRow({ <> {/* Sticky first column so the invoice identity survives horizontal scroll. */} + + + + {placingId && ( +

+ Tap the map to place{" "} + {placing?.code || placing?.name}, or pick another + machine below. +

+ )} + + +
+ {visible.length === 0 ? ( +

No machines match.

+ ) : ( +
    + {visible.map((a) => { + const on = hasCoords(a); + const isPlacing = placingId === a.id; + return ( +
  • + + + + + {on && ( + + )} +
  • + ); + })} +
+ )} +
+ + + ); +} + +/** + * 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; +} diff --git a/components/PlantMapLoader.tsx b/components/PlantMapLoader.tsx new file mode 100644 index 0000000..781f850 --- /dev/null +++ b/components/PlantMapLoader.tsx @@ -0,0 +1,43 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { Loader2, MapPinned } from "lucide-react"; + +/** + * Leaflet reads `window` at module scope, so it cannot be evaluated during SSR. + * `next/dynamic` with `ssr: false` keeps it out of the server bundle entirely — + * this file is the client boundary that makes that possible (App Router only + * allows `ssr: false` inside a Client Component). + */ +const PlantMap = dynamic(() => import("./PlantMap").then((m) => m.PlantMap), { + ssr: false, + loading: () => , +}); + +function MapSkeleton() { + return ( +
+
+
+ +

Loading map…

+
+
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+
+
+ ); +} + +export function PlantMapLoader(props: React.ComponentProps) { + return ; +} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index ac30a57..ee0c4f0 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -11,6 +11,15 @@ export type NavItem = | { kind: "link"; href: string; label: string; icon: React.ReactNode } | { kind: "group"; label: string; icon: React.ReactNode; children: NavLeaf[] }; +// A group is active when the current path is one of its children, or sits under one +// of them. A child of "/" only matches exactly — otherwise every route would count as +// "under" the dashboard. +function isGroupActive(item: Extract, pathname: string): boolean { + return item.children.some((c) => + c.href === "/" ? pathname === "/" : pathname === c.href || pathname.startsWith(`${c.href}/`), + ); +} + export function Sidebar({ items }: { items: NavItem[] }) { const pathname = usePathname(); const [mobileOpen, setMobileOpen] = useState(false); @@ -41,11 +50,7 @@ export function Sidebar({ items }: { items: NavItem[] }) { onClick={onNavigate} /> ) : ( - + ) )} diff --git a/components/SiteBookingSelect.tsx b/components/SiteBookingSelect.tsx new file mode 100644 index 0000000..73b80cd --- /dev/null +++ b/components/SiteBookingSelect.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, X, MapPin, Sprout } from "lucide-react"; + +type SiteOption = { id: string; name: string; taken: boolean }; + +// Book an invoice line that has no matching plant item to a SITE instead. +// +// One container per site: sites another line has already taken are listed but disabled, +// so the outstanding containers spread across sites rather than piling onto the first. +// Clearing frees the site again. Booking to a site accounts for the line without +// creating income, because no machine exists to carry it. +export function SiteBookingSelect({ + lineId, + value, +}: { + lineId: string; + value: string; +}) { + const router = useRouter(); + const [options, setOptions] = useState(null); + const [current, setCurrent] = useState(value); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setCurrent(value); + }, [value]); + + useEffect(() => { + let alive = true; + fetch(`/api/invoice-lines/${lineId}/site-options`, { cache: "no-store" }) + .then((r) => r.json()) + .then((d) => { + if (alive && d.ok) setOptions(d.options); + }) + .catch(() => { + if (alive) setOptions([]); + }); + return () => { + alive = false; + }; + }, [lineId]); + + async function book(payload: Record, optimistic: string) { + setBusy(true); + setError(null); + const previous = current; + setCurrent(optimistic); + try { + const res = await fetch(`/api/invoice-lines/${lineId}/book`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" })); + if (!data.ok) { + setCurrent(previous); + setError(data.error || "Could not book it"); + } else { + router.refresh(); + } + } catch { + setCurrent(previous); + setError("Could not reach the server"); + } finally { + setBusy(false); + } + } + + if (options === null) return null; + + const currentName = options.find((o) => o.id === current)?.name; + + return ( +
+ +
+ + {busy && } + {current && !busy && ( + + )} +
+ + {currentName && ( +

+ + Booked to {currentName} — no income posted (no machine) +

+ )} + + {error &&

{error}

} +
+ ); +} + +export function SiteBookingLegend() { + return ( +

+ + A site booking accounts for a line that matches no plant item. It posts no income — + there is no machine to carry it — and each site takes one container. +

+ ); +} diff --git a/lib/data.ts b/lib/data.ts index dab8db5..62588b4 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -14,6 +14,10 @@ export type Asset = { purchase_value: number; is_provisional: boolean; is_active: boolean; + // Plant Map. Null until the on-site scan sheet supplies them. 0/0 counts as + // "not located" — see `hasCoords` in components/PlantMap. + latitude: number | null; + longitude: number | null; } & RecordModel; export type CostLine = { @@ -104,6 +108,7 @@ export type InvoiceLine = { vat: number; total_incl: number; booked_asset: string; + booked_site: string; booked_month: number; booked_year: number; } & RecordModel; @@ -230,6 +235,19 @@ export function findDuplicateMachineCodes( return out; } +export type Site = { + id: string; + name: string; + code: string; + notes: string; + is_active: boolean; +} & RecordModel; + +export async function getSites(): Promise { + const pb = await serverAuth(); + return pb.collection("sites").getFullList({ sort: "name" }); +} + export const MONTH_NAMES = [ "Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec", ]; diff --git a/lib/sites.ts b/lib/sites.ts new file mode 100644 index 0000000..677b8ff --- /dev/null +++ b/lib/sites.ts @@ -0,0 +1,94 @@ +import type { Asset, BookedLine, Invoice, Site } from "./data"; + +// Sites are DERIVED from the invoices: each distinct invoice site becomes a site. +// They are not typed in by hand, so this is the only place that creates them. +// +// Existing records are never deleted and never overwritten — a site may carry notes +// and may have lines booked to it, so removing one would orphan that history. An +// invoice site that disappears simply stops appearing in new invoices. +export async function syncSitesFromInvoices(pb: any, invoices: Invoice[]): Promise { + const existing = (await pb.collection("sites").getFullList()) as Site[]; + const byName = new Map(existing.map((s: Site) => [(s.name || "").toLowerCase(), s])); + + const wanted = [...new Set(invoices.map((i) => (i.site || "").trim()).filter(Boolean))]; + for (const name of wanted) { + if (byName.has(name.toLowerCase())) continue; + try { + const created = await pb.collection("sites").create({ name, is_active: true }); + byName.set(name.toLowerCase(), created as Site); + } catch { + // A concurrent upload may have created it first; the catch is a no-op. + } + } + return byName.size ? Array.from(byName.values()) : existing; +} + +export type SiteSummary = { + id: string; + name: string; + /** Income, ex VAT — what actually lands in the P&L. */ + revenueExcl: number; + vat: number; + /** What the debtors were billed in total. */ + revenueIncl: number; + invoices: { id: string; invoiceNo: string; invoiceDate: string; totalIncl: number }[]; + machineCount: number; + lineCount: number; + /** Lines with neither a plant item nor a site — the work still outstanding. */ + unbookedCount: number; + unbookedAmount: number; +}; + +// Per-site revenue and VAT, derived by walking invoice lines through their invoice. +export function buildSiteSummaries( + sites: Site[], + invoices: Invoice[], + lines: BookedLine[], + assets: Asset[], +): SiteSummary[] { + const invById = new Map(invoices.map((i) => [i.id, i])); + const assetById = new Map(assets.map((a) => [a.id, a])); + + return sites + .map((site) => { + const siteLines = lines.filter((l) => invById.get(l.invoice)?.site === site.name); + const revExcl = siteLines.reduce((s, l) => s + (l.amount_excl || 0), 0); + const vat = siteLines.reduce((s, l) => s + (l.vat || 0), 0); + const incl = siteLines.reduce((s, l) => s + (l.total_incl || 0), 0); + + const invMap = new Map(); + for (const l of siteLines) { + const inv = invById.get(l.invoice); + if (inv) invMap.set(inv.id, { id: inv.id, invoiceNo: inv.invoice_no, invoiceDate: inv.invoice_date, totalIncl: inv.total_incl }); + } + + const unbooked = siteLines.filter((l) => !l.isBooked && !l.booked_site); + return { + id: site.id, + name: site.name, + revenueExcl: revExcl, + vat, + revenueIncl: incl, + invoices: Array.from(invMap.values()).sort((a, b) => b.invoiceDate.localeCompare(a.invoiceDate)), + machineCount: new Set(siteLines.map((l) => l.booked_asset).filter(Boolean)).size, + lineCount: siteLines.length, + unbookedCount: unbooked.length, + unbookedAmount: unbooked.reduce((s, l) => s + (l.total_incl || 0), 0), + }; + }) + .filter((s) => s.invoices.length > 0 || s.lineCount > 0) + .sort((a, b) => b.revenueIncl - a.revenueIncl); +} + +// Sites already taken by another line that has no plant item. +// One container per site: once a site holds a container, the next container's +// picker must not offer it, so the outstanding containers spread across sites +// instead of piling onto the first one. +export function takenSiteIdsFor(lines: BookedLine[], exceptLineId: string): Set { + const taken = new Set(); + for (const l of lines) { + if (l.id === exceptLineId) continue; + if (l.booked_site && !l.booked_asset) taken.add(l.booked_site); + } + return taken; +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index acb1edb..b88fefd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,14 +16,17 @@ "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.6", + "@types/leaflet": "^1.9.22", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "leaflet": "^1.9.4", "lucide-react": "^0.469.0", "next": "^16", "pdfjs-dist": "^4.10.38", "pocketbase": "^0.27.3", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-leaflet": "^5.0.0", "recharts": "^2.15.0", "tailwind-merge": "^2.6.0", "xlsx": "^0.18.5" @@ -3819,6 +3822,17 @@ "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -4343,6 +4357,12 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4350,6 +4370,15 @@ "license": "MIT", "peer": true }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "22.20.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", @@ -6612,6 +6641,12 @@ "node": ">=0.10.0" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7439,6 +7474,20 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", diff --git a/package.json b/package.json index c466e6d..69318cd 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,17 @@ "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.6", + "@types/leaflet": "^1.9.22", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "leaflet": "^1.9.4", "lucide-react": "^0.469.0", "next": "^16", "pdfjs-dist": "^4.10.38", "pocketbase": "^0.27.3", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-leaflet": "^5.0.0", "recharts": "^2.15.0", "tailwind-merge": "^2.6.0", "xlsx": "^0.18.5" diff --git a/scripts/seed.mjs b/scripts/seed.mjs index 754d8a5..0d318f2 100644 --- a/scripts/seed.mjs +++ b/scripts/seed.mjs @@ -37,6 +37,17 @@ if (errors.length) { process.exit(1); } +// Placeholder pin for every machine that has no coordinate of its own, so the +// Plant Map is never blank. Every asset therefore shares one point until the +// on-site scan sheet supplies real per-machine coordinates — any explicit +// `latitude`/`longitude` in assets.json wins over this. +// When the sheet lands, delete these two lines and the `?? DEFAULT_GEO` fallbacks +// below; the JSON values will carry every pin from then on. +const DEFAULT_GEO = { + latitude: -26.10161543177211, + longitude: 28.34872485354214, +}; + const ASSETS_COLL = "assets"; const INCOME_COLL = "monthly_income"; const RUN_COLL = "monthly_running_costs"; @@ -115,6 +126,10 @@ for (const a of assets) { purchase_value: a.purchase_value || 0, is_provisional: a.is_provisional || false, // Part C.3 is_active: a.is_active !== false, // default true; only "AEB Plant Versekering" is false + // Plant Map — falls back to the shared placeholder pin until the + // on-site scan sheet supplies a real coordinate for this machine. + latitude: a.latitude ?? DEFAULT_GEO.latitude, + longitude: a.longitude ?? DEFAULT_GEO.longitude, }); if (a.code) codeToId[a.code] = rec.id; nameToId[normalizeName(a.name)] = rec.id; diff --git a/scripts/setup-invoice-collections.mjs b/scripts/setup-invoice-collections.mjs index b451481..7855cc8 100644 --- a/scripts/setup-invoice-collections.mjs +++ b/scripts/setup-invoice-collections.mjs @@ -134,6 +134,15 @@ async function dropField(collectionName, ...names) { console.log("\n→ Ensuring collections"); +const sitesId = await ensureCollection("sites", [ + { name: "name", type: "text", required: true }, + { name: "code", type: "text" }, + { name: "notes", type: "text" }, + { name: "is_active", type: "bool" }, + { name: "created", type: "date", onCreate: true, presentable: false }, + { name: "updated", type: "date", onUpdate: true, presentable: false }, +]); + const invoicesId = await ensureCollection("invoices", [ { name: "invoice_no", type: "text", required: true }, { name: "invoice_date", type: "date", required: true }, @@ -183,6 +192,19 @@ const invoiceLinesId = await ensureCollection("invoice_lines", [ { name: "booked_year", type: "number" }, ]); +// A line can be booked to a SITE when no plant item matches it — the container codes +// (MPLANT04/MPLANT05) are the live case, since the register has no such machine. +// Exactly one of booked_asset / booked_site is expected; a line holding neither is +// what surfaces as unbooked. +await ensureField("invoice_lines", { + name: "booked_site", + type: "relation", + collectionId: sitesId, + minSelect: 1, + maxSelect: 1, + cascadeDelete: false, +}); + // Provenance back-reference. `npm run seed` wipes monthly_income, so posting an // invoice has to be re-runnable without double-counting. Marking each posted row with // the invoice line it came from makes booking idempotent and auditable: a row with an