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>
This commit is contained in:
parent
dc7024d98a
commit
14ec899ca9
21 changed files with 1426 additions and 92 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<string, string | boolean> = {};
|
||||
const patch: Record<string, string | boolean | number | null> = {};
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
34
app/api/invoice-lines/[id]/site-options/route.ts
Normal file
34
app/api/invoice-lines/[id]/site-options/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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: <LayoutDashboard className="w-4 h-4" /> },
|
||||
{ kind: "link", href: "/plant", label: "Plant Register", icon: <Boxes className="w-4 h-4" /> },
|
||||
{
|
||||
kind: "group",
|
||||
label: "Dashboard",
|
||||
icon: <LayoutDashboard className="w-4 h-4" />,
|
||||
children: [
|
||||
{ href: "/", label: "Overview" },
|
||||
{ href: "/sites", label: "Sites" },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "group",
|
||||
label: "Plant Register",
|
||||
icon: <Boxes className="w-4 h-4" />,
|
||||
children: [
|
||||
{ href: "/plant", label: "Register" },
|
||||
{ href: "/plant/map", label: "Plant Map" },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "group",
|
||||
label: "Income",
|
||||
|
|
|
|||
44
app/plant/map/page.tsx
Normal file
44
app/plant/map/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="p-8 max-w-[1400px]">
|
||||
<div className="flex items-end justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#30638E]/15 to-[#003D5B]/15 grid place-items-center text-[#30638E]">
|
||||
<MapPinned className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Plant Map</h1>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{assets.length} registered · {located} located on the map
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PlantMapLoader
|
||||
assets={assets.map((a) => ({
|
||||
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,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
app/sites/[id]/page.tsx
Normal file
179
app/sites/[id]/page.tsx
Normal file
|
|
@ -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<Metadata> {
|
||||
const { id } = await params;
|
||||
const sites = await getSites();
|
||||
const site = sites.find((s) => s.id === id);
|
||||
return { title: `${site?.name ?? "Site"} — Mega Plant` };
|
||||
}
|
||||
|
||||
export default async function SiteDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const [sites, invoices, lines, assets] = await Promise.all([
|
||||
getSites(),
|
||||
getInvoices(),
|
||||
getInvoiceLines(),
|
||||
getAssets(),
|
||||
]);
|
||||
|
||||
const site = sites.find((s) => s.id === id);
|
||||
if (!site) notFound();
|
||||
|
||||
const joined = joinBookedLines(lines, assets);
|
||||
const summary = buildSiteSummaries(sites, invoices, joined, assets).find((s) => s.id === id);
|
||||
if (!summary) notFound();
|
||||
|
||||
const invById = new Map(invoices.map((i) => [i.id, i]));
|
||||
const assetById = new Map(assets.map((a) => [a.id, a]));
|
||||
const siteLines = joined
|
||||
.filter((l) => invById.get(l.invoice)?.site === site.name)
|
||||
.sort((a, b) => (invById.get(b.invoice)!.invoice_date).localeCompare(invById.get(a.invoice)!.invoice_date) || a.line_no - b.line_no);
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-8 max-w-[1400px]">
|
||||
<Link
|
||||
href="/sites"
|
||||
className="inline-flex items-center gap-1.5 min-h-[44px] text-sm text-gray-500 dark:text-gray-400 hover:text-[#00798C] transition"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
All sites
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#EDAE49]/20 to-[#00798C]/15 grid place-items-center text-[#00798C]">
|
||||
<MapPin className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">{site.name}</h1>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{summary.invoices.length} invoice{summary.invoices.length === 1 ? "" : "s"} ·{" "}
|
||||
{summary.machineCount} machine{summary.machineCount === 1 ? "" : "s"} ·{" "}
|
||||
{summary.lineCount} line{summary.lineCount === 1 ? "" : "s"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-stretch gap-3">
|
||||
<div className="px-5 py-3 rounded-2xl bg-white dark:bg-[#131a21] border border-gray-100 dark:border-white/5">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Revenue (ex VAT)</div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
||||
{ZAR2(summary.revenueExcl)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
|
||||
+ {ZAR2(summary.vat)} VAT
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-[#00798C] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-teal-500/20">
|
||||
<div className="text-xs text-white/80">Billed (incl VAT)</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">{ZAR2(summary.revenueIncl)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-5">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="relative bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-100 dark:border-white/5">
|
||||
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Line items</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
|
||||
<table className="w-full text-sm sm:min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/[0.02]">
|
||||
<th className="text-left py-3 px-4 font-medium sticky left-0 z-10 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur">Machine</th>
|
||||
<th className="text-left py-3 px-3 font-medium hidden sm:table-cell">Invoice</th>
|
||||
<th className="text-right py-3 px-3 font-medium whitespace-nowrap hidden sm:table-cell">Qty</th>
|
||||
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Ex VAT</th>
|
||||
<th className="text-right py-3 px-3 font-medium whitespace-nowrap hidden sm:table-cell">VAT</th>
|
||||
<th className="text-right py-3 px-4 font-medium whitespace-nowrap">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{siteLines.map((l) => {
|
||||
const inv = invById.get(l.invoice)!;
|
||||
const asset = l.booked_asset ? assetById.get(l.booked_asset) : null;
|
||||
return (
|
||||
<tr key={l.id} className="border-b border-gray-50 dark:border-white/5">
|
||||
<td className="py-3 px-4 sticky left-0 z-10 bg-white dark:bg-[#131a21]">
|
||||
<div className="font-mono text-xs font-semibold text-[#00798C]">{l.machine_code}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{asset ? `${asset.code} ${asset.name}` : l.description}
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden sm:table-cell py-3 px-3 font-mono text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{inv.invoice_no}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell py-3 px-3 text-right tabular-nums text-gray-600 dark:text-gray-300 whitespace-nowrap">
|
||||
{l.qty} {l.unit}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-right tabular-nums text-gray-600 dark:text-gray-300 whitespace-nowrap">{ZAR2(l.amount_excl)}</td>
|
||||
<td className="hidden sm:table-cell py-3 px-3 text-right tabular-nums text-gray-500 dark:text-gray-400 whitespace-nowrap">{ZAR2(l.vat)}</td>
|
||||
<td className="py-3 px-4 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
|
||||
{ZAR2(l.total_incl)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ScrollFade />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-100 dark:border-white/5">
|
||||
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Invoices</h2>
|
||||
</div>
|
||||
<ul className="divide-y divide-gray-100 dark:border-white/5">
|
||||
{summary.invoices.map((i) => (
|
||||
<li key={i.id} className="px-5 py-3 flex items-center justify-between gap-3">
|
||||
<span className="inline-flex items-center gap-2 min-w-0">
|
||||
<FileText className="w-4 h-4 text-[#00798C] shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block font-mono text-xs font-semibold text-[#003D5B] dark:text-white">
|
||||
{i.invoiceNo}
|
||||
</span>
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
||||
{String(i.invoiceDate).slice(0, 10)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-sm tabular-nums text-[#003D5B] dark:text-white whitespace-nowrap">
|
||||
{ZAR2(i.totalIncl)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{summary.unbookedCount > 0 && (
|
||||
<div className="px-5 py-4 rounded-2xl bg-[#D1495B]/5 border border-[#D1495B]/15">
|
||||
<h2 className="text-sm font-semibold text-[#D1495B] mb-1">Unbooked</h2>
|
||||
<p className="text-sm text-[#D1495B]">
|
||||
{summary.unbookedCount} line{summary.unbookedCount === 1 ? "" : "s"} here still need a
|
||||
machine or a site — {ZAR2(summary.unbookedAmount)} incl VAT.
|
||||
</p>
|
||||
<Link
|
||||
href="/income/invoices"
|
||||
className="inline-flex items-center min-h-[44px] text-sm font-medium text-[#D1495B] hover:underline mt-1"
|
||||
>
|
||||
Go and assign them
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
app/sites/page.tsx
Normal file
173
app/sites/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="p-4 sm:p-8 max-w-[1400px]">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#EDAE49]/20 to-[#00798C]/15 grid place-items-center text-[#00798C]">
|
||||
<MapPin className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Sites</h1>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{rows.length} site{rows.length === 1 ? "" : "s"} from the {invoices.length} imported
|
||||
invoice{invoices.length === 1 ? "" : "s"} · revenue and VAT per site
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-stretch gap-3">
|
||||
<Tile label="Revenue (ex VAT)" value={ZAR2(totalExcl)} sub={`+ ${ZAR2(totalVat)} VAT`} />
|
||||
<div className="bg-gradient-to-br from-[#00798C] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-teal-500/20">
|
||||
<div className="text-xs text-white/80">Billed (incl VAT)</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">{ZAR2(totalIncl)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{outstanding > 0 && (
|
||||
<div className="flex items-start gap-3 px-4 py-3 mb-5 rounded-xl bg-[#D1495B]/5 border border-[#D1495B]/15">
|
||||
<AlertTriangle className="w-4 h-4 text-[#D1495B] shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-[#D1495B]">
|
||||
{outstanding} line{outstanding === 1 ? "" : "s"} across these sites still have no
|
||||
machine and no site. Open an invoice and assign one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="bg-white dark:bg-[#131a21] border border-gray-100 dark:border-white/5 rounded-2xl p-10 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">No sites yet.</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 mt-1">
|
||||
Sites appear automatically as invoices are imported.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
|
||||
<table className="w-full text-sm sm:min-w-[820px]">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/[0.02]">
|
||||
<th className="text-left py-3 px-4 font-medium sticky left-0 z-10 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur">
|
||||
Site
|
||||
</th>
|
||||
<th className="text-right py-3 px-3 font-medium whitespace-nowrap hidden sm:table-cell">
|
||||
Invoices
|
||||
</th>
|
||||
<th className="text-right py-3 px-3 font-medium whitespace-nowrap hidden sm:table-cell">
|
||||
Machines
|
||||
</th>
|
||||
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Revenue ex VAT</th>
|
||||
<th className="text-right py-3 px-3 font-medium whitespace-nowrap hidden sm:table-cell">
|
||||
VAT
|
||||
</th>
|
||||
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Total incl VAT</th>
|
||||
<th className="text-left py-3 px-4 font-medium whitespace-nowrap">Unbooked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={`border-b border-gray-50 dark:border-white/5 ${
|
||||
r.unbookedCount ? "bg-[#D1495B]/[0.04]" : "hover:bg-gray-50/60 dark:hover:bg-white/[0.02]"
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 px-4 sticky left-0 z-10 bg-white dark:bg-[#131a21]">
|
||||
<a
|
||||
href={`/sites/${r.id}`}
|
||||
className="group inline-flex items-center gap-2 min-h-[44px] font-medium text-[#003D5B] dark:text-white hover:text-[#00798C] transition"
|
||||
>
|
||||
<MapPin className="w-3.5 h-3.5 text-[#EDAE49] shrink-0" />
|
||||
<span className="whitespace-nowrap">{r.name}</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 opacity-0 group-hover:opacity-100 transition shrink-0" />
|
||||
</a>
|
||||
</td>
|
||||
<td className="hidden sm:table-cell py-3 px-3 text-right tabular-nums text-gray-600 dark:text-gray-300">
|
||||
{r.invoices.length}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell py-3 px-3 text-right tabular-nums text-gray-600 dark:text-gray-300">
|
||||
{r.machineCount}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-right tabular-nums font-medium text-[#003D5B] dark:text-white whitespace-nowrap">
|
||||
{ZAR2(r.revenueExcl)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell py-3 px-3 text-right tabular-nums text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{ZAR2(r.vat)}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
|
||||
{ZAR2(r.revenueIncl)}
|
||||
</td>
|
||||
<td className="py-3 px-4 whitespace-nowrap">
|
||||
{r.unbookedCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-[#D1495B]/10 text-[#D1495B] text-xs font-semibold">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||
{r.unbookedCount} unbooked
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-200 dark:border-white/15 bg-gray-50/80 dark:bg-white/[0.03]">
|
||||
<td className="py-3 px-4 sticky left-0 z-10 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur text-sm font-semibold text-[#003D5B] dark:text-white">
|
||||
Total
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" />
|
||||
<td className="hidden sm:table-cell" />
|
||||
<td className="py-3 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
|
||||
{ZAR(totalExcl)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell py-3 px-3 text-right tabular-nums whitespace-nowrap text-[#003D5B] dark:text-white">
|
||||
{ZAR(totalVat)}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
|
||||
{ZAR(totalIncl)}
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<ScrollFade />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<div className="px-5 py-3 rounded-2xl bg-white dark:bg-[#131a21] border border-gray-100 dark:border-white/5">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{label}</div>
|
||||
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">{value}</div>
|
||||
{sub && <div className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -136,13 +136,13 @@ function InvoiceRow({
|
|||
<>
|
||||
<tr
|
||||
className={`border-b border-gray-50 dark:border-border transition ${
|
||||
hasProblem ? "bg-[#D1495B]/[0.04]" : "hover:bg-gray-50/60 dark:hover:bg-white/[0.02]"
|
||||
hasProblem ? "bg-[#D1495B]/[0.04] dark:bg-[#292934]" : "hover:bg-gray-50/60 dark:hover:bg-white/[0.02]"
|
||||
}`}
|
||||
>
|
||||
{/* Sticky first column so the invoice identity survives horizontal scroll. */}
|
||||
<td
|
||||
className={`py-2 px-4 sticky left-0 z-10 ${
|
||||
hasProblem ? "bg-[#D1495B]/[0.04] dark:bg-[#1a1319]" : "bg-white dark:bg-surface"
|
||||
hasProblem ? "bg-[#D1495B]/[0.04] dark:bg-[#292934]" : "bg-white dark:bg-surface"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ZAR2 } from "@/lib/utils";
|
|||
import { AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||
import { BookingSelect } from "@/components/BookingSelect";
|
||||
import { ScrollFade } from "@/components/ScrollFade";
|
||||
import { SiteBookingSelect, SiteBookingLegend } from "@/components/SiteBookingSelect";
|
||||
import { InvoiceUploader, AttachedDocument } from "@/components/InvoiceUploader";
|
||||
|
||||
export function InvoiceSummary({
|
||||
|
|
@ -127,6 +128,10 @@ export function InvoiceSummary({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-5 py-3 border-b border-gray-100 dark:border-white/5">
|
||||
<SiteBookingLegend />
|
||||
</div>
|
||||
|
||||
<InvoiceTable lines={lines} assets={assets} />
|
||||
|
||||
<div className="p-5 border-t border-gray-100 dark:border-border bg-gray-50/50 dark:bg-white/[0.04]">
|
||||
|
|
@ -259,7 +264,7 @@ export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: A
|
|||
className={`py-3 px-5 sticky left-0 z-10 ${
|
||||
l.isBooked
|
||||
? "bg-white dark:bg-surface"
|
||||
: "bg-[#D1495B]/[0.04] dark:bg-[#1a1319]"
|
||||
: "bg-[#D1495B]/[0.04] dark:bg-[#292934]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 whitespace-nowrap">
|
||||
|
|
@ -276,8 +281,15 @@ export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: A
|
|||
<td className="py-3 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
|
||||
{ZAR2(l.total_incl)}
|
||||
</td>
|
||||
<td className="py-3 px-3 w-[220px]">
|
||||
<BookingSelect lineId={l.id} value={l.booked_asset || ""} assets={options} />
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:items-start">
|
||||
<div className="min-w-[180px]">
|
||||
<BookingSelect lineId={l.id} value={l.booked_asset || ""} assets={options} />
|
||||
</div>
|
||||
{(!l.isBooked || l.booked_site) && (
|
||||
<SiteBookingSelect lineId={l.id} value={l.booked_site || ""} />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
448
components/PlantMap.tsx
Normal file
448
components/PlantMap.tsx
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
"use client";
|
||||
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import { MapPinned, Loader2, Search, X, MapPin, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MapAsset = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
type: string;
|
||||
is_active: boolean;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
};
|
||||
|
||||
/** A machine counts as located only with a real, non-null, non-(0,0) pair. */
|
||||
export function hasCoords(a: MapAsset): boolean {
|
||||
if (a.latitude == null || a.longitude == null) return false;
|
||||
if (a.latitude === 0 && a.longitude === 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Johannesburg / Ekurhuleni — the client operates around Halfway Gardens,
|
||||
// Cosmo City and West Park. Swap this when the fleet spreads nationally.
|
||||
const DEFAULT_CENTER: [number, number] = [-26.05, 28.05];
|
||||
const DEFAULT_ZOOM = 10;
|
||||
|
||||
// OSM's standard tile servers have a usage policy that forbids heavy/commercial
|
||||
// traffic. This is fine while a handful of staff use the dashboard, but move to
|
||||
// a proper tile provider before the site count grows.
|
||||
const TILES = {
|
||||
light: {
|
||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
},
|
||||
dark: {
|
||||
url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
},
|
||||
} as const;
|
||||
|
||||
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",
|
||||
html: `<span style="
|
||||
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);
|
||||
">${escapeHtml(label)}</span>`,
|
||||
iconSize: [34, 34],
|
||||
iconAnchor: [17, 17],
|
||||
popupAnchor: [0, -20],
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-centres the map when the caller asks (fit-all, or a list selection). */
|
||||
function MapController({ focus }: { focus: { lat: number; lng: number; zoom?: number } | null }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (!focus) return;
|
||||
map.flyTo([focus.lat, focus.lng], focus.zoom ?? Math.max(map.getZoom(), 15), {
|
||||
duration: 0.8,
|
||||
});
|
||||
}, [focus, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PlantMap({ assets }: { assets: MapAsset[] }) {
|
||||
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);
|
||||
const [dark, setDark] = useState(false);
|
||||
const [placingId, setPlacingId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
// Read the theme class the ThemeProvider puts on <html>. Leaflet needs the
|
||||
// right tile set, and this is cheaper than threading context through here.
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const read = () => setDark(document.documentElement.classList.contains("dark"));
|
||||
read();
|
||||
const obs = new MutationObserver(read);
|
||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
mq.addEventListener("change", read);
|
||||
return () => {
|
||||
obs.disconnect();
|
||||
mq.removeEventListener("change", read);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const located = useMemo(() => assetsState.filter(hasCoords), [assetsState]);
|
||||
const awaiting = useMemo(() => assetsState.filter((a) => !hasCoords(a)), [assetsState]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return assetsState
|
||||
.filter((a) => (showInactive ? true : a.is_active))
|
||||
.filter(
|
||||
(a) =>
|
||||
!q ||
|
||||
a.code.toLowerCase().includes(q) ||
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
a.type.toLowerCase().includes(q),
|
||||
);
|
||||
}, [assetsState, query, showInactive]);
|
||||
|
||||
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)),
|
||||
);
|
||||
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 tiles = dark ? TILES.dark : TILES.light;
|
||||
const placing = placingId ? assetsState.find((a) => a.id === placingId) : null;
|
||||
|
||||
return (
|
||||
<div className="grid lg:grid-cols-[minmax(0,1fr)_340px] gap-4 items-start">
|
||||
{/* ── Map ─────────────────────────────────────────────────────── */}
|
||||
<div className="relative rounded-2xl overflow-hidden border border-gray-200 dark:border-white/5 shadow-sm">
|
||||
<MapContainer
|
||||
center={DEFAULT_CENTER}
|
||||
zoom={DEFAULT_ZOOM}
|
||||
scrollWheelZoom
|
||||
className={cn("w-full", "h-[52vh] min-h-[320px]", "lg:h-[calc(100vh-15rem)] lg:min-h-[560px]")}
|
||||
>
|
||||
<TileLayer url={tiles.url} attribution={tiles.attribution} />
|
||||
<MapController focus={focus} />
|
||||
|
||||
{located.map((a) => (
|
||||
<Marker
|
||||
key={a.id}
|
||||
position={[a.latitude as number, a.longitude as number]}
|
||||
icon={badgeIcon(a.code || "?", a.is_active)}
|
||||
>
|
||||
<Popup>
|
||||
<div style={{ minWidth: "180px" }}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 2 }}>{escapeHtml(a.name)}</div>
|
||||
<div style={{ color: "#6b7280", fontSize: 12, marginBottom: 6 }}>
|
||||
{escapeHtml(a.code || "no code")}
|
||||
{a.type ? ` · ${escapeHtml(a.type)}` : ""}
|
||||
</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 }}>
|
||||
{a.latitude?.toFixed(5)}, {a.longitude?.toFixed(5)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
|
||||
{/* Placing mode — capture the map click outside React's synthetic tree */}
|
||||
{placingId && (
|
||||
<MapClickCapture
|
||||
onPick={(lat, lng) => saveCoords(placingId, lat, lng)}
|
||||
disabled={saving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="absolute left-3 bottom-3 z-[500] flex flex-col gap-1.5 rounded-xl bg-white/95 dark:bg-[#1e2732]/95 backdrop-blur border border-gray-200 dark:border-white/10 px-2.5 py-2 text-xs">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Nothing located yet */}
|
||||
{located.length === 0 && !placingId && (
|
||||
<div className="absolute inset-0 z-[600] grid place-items-center p-4 bg-white/80 dark:bg-[#0f151b]/80 backdrop-blur-sm">
|
||||
<div className="text-center max-w-sm">
|
||||
<div className="w-12 h-12 mx-auto rounded-2xl bg-[#30638E]/10 grid place-items-center mb-3">
|
||||
<MapPinned className="w-6 h-6 text-[#30638E]" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-[#003D5B] dark:text-white mb-1">
|
||||
No coordinates yet
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
All {awaiting.length} machines are waiting on the on-site scan sheet. Until it
|
||||
lands, drop a pin manually below to start building the map.
|
||||
</p>
|
||||
</div>
|
||||
</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 ──────────────────────────────────────────────── */}
|
||||
<aside className="rounded-2xl border border-gray-200 dark:border-white/5 bg-white dark:bg-[#131a21] shadow-sm overflow-hidden">
|
||||
<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)}
|
||||
placeholder="Filter code, name, type…"
|
||||
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-[#0f1419] focus:border-[#30638E] focus:outline-none"
|
||||
/>
|
||||
</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={() => {
|
||||
setPlacingId((cur) => (cur ? null : (awaiting[0]?.id ?? located[0]?.id ?? null)));
|
||||
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">
|
||||
{a.latitude?.toFixed(4)}, {a.longitude?.toFixed(4)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
No coordinates yet
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlacingId(isPlacing ? null : a.id)}
|
||||
aria-label={`Place ${a.name} on the map`}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg transition shrink-0",
|
||||
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>
|
||||
|
||||
{on && (
|
||||
<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;
|
||||
}
|
||||
43
components/PlantMapLoader.tsx
Normal file
43
components/PlantMapLoader.tsx
Normal file
|
|
@ -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: () => <MapSkeleton />,
|
||||
});
|
||||
|
||||
function MapSkeleton() {
|
||||
return (
|
||||
<div className="grid lg:grid-cols-[minmax(0,1fr)_340px] gap-4 items-start">
|
||||
<div className="relative rounded-2xl overflow-hidden border border-gray-200 dark:border-white/5 shadow-sm bg-gray-100 dark:bg-[#1e2732] h-[52vh] min-h-[320px] lg:h-[calc(100vh-15rem)] lg:min-h-[560px] grid place-items-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-6 h-6 mx-auto text-[#30638E] animate-spin" />
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">Loading map…</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 dark:border-white/5 bg-white dark:bg-[#131a21] shadow-sm overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-100 dark:border-white/5">
|
||||
<div className="h-4 w-20 rounded bg-gray-200 dark:bg-white/10 animate-pulse" />
|
||||
<div className="mt-3 h-11 rounded-lg bg-gray-100 dark:bg-white/5 animate-pulse" />
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-9 rounded bg-gray-100 dark:bg-white/5 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlantMapLoader(props: React.ComponentProps<typeof import("./PlantMap").PlantMap>) {
|
||||
return <PlantMap {...props} />;
|
||||
}
|
||||
|
|
@ -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<NavItem, { kind: "group" }>, 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}
|
||||
/>
|
||||
) : (
|
||||
<Group
|
||||
key={i}
|
||||
item={item}
|
||||
active={pathname.startsWith(item.children[0]?.href.split("/").slice(0, 3).join("/") ?? "")}
|
||||
/>
|
||||
<Group key={i} item={item} active={isGroupActive(item, pathname)} />
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
|
|
|
|||
138
components/SiteBookingSelect.tsx
Normal file
138
components/SiteBookingSelect.tsx
Normal file
|
|
@ -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<SiteOption[] | null>(null);
|
||||
const [current, setCurrent] = useState(value);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrent(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetch(`/api/invoice-lines/${lineId}/site-options`, { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (alive && d.ok) setOptions(d.options);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setOptions([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [lineId]);
|
||||
|
||||
async function book(payload: Record<string, unknown>, optimistic: string) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const previous = current;
|
||||
setCurrent(optimistic);
|
||||
try {
|
||||
const res = await fetch(`/api/invoice-lines/${lineId}/book`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
|
||||
if (!data.ok) {
|
||||
setCurrent(previous);
|
||||
setError(data.error || "Could not book it");
|
||||
} else {
|
||||
router.refresh();
|
||||
}
|
||||
} catch {
|
||||
setCurrent(previous);
|
||||
setError("Could not reach the server");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (options === null) return null;
|
||||
|
||||
const currentName = options.find((o) => o.id === current)?.name;
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px]">
|
||||
<label className="sr-only" htmlFor={`site-${lineId}`}>
|
||||
Book this line to a site
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
id={`site-${lineId}`}
|
||||
value={current}
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
book(v ? { siteId: v } : { clear: true }, v);
|
||||
}}
|
||||
className={`min-h-[44px] w-full rounded-lg border px-2 text-sm transition focus:outline-none focus:border-[#30638E] disabled:opacity-60 ${
|
||||
current
|
||||
? "border-[#EDAE49]/40 bg-[#EDAE49]/10 text-[#8a6420] dark:text-[#EDAE49]"
|
||||
: "border-gray-200 dark:border-white/10 bg-white dark:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<option value="">— Book to a site —</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.id} value={o.id} disabled={o.taken && o.id !== current}>
|
||||
{o.name}
|
||||
{o.taken && o.id !== current ? " — already has one" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{busy && <Loader2 className="w-4 h-4 animate-spin text-[#00798C] shrink-0" aria-label="Saving" />}
|
||||
{current && !busy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => book({ clear: true }, "")}
|
||||
aria-label="Remove the site booking"
|
||||
className="grid place-items-center w-11 h-11 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5 transition shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentName && (
|
||||
<p className="mt-1 flex items-center gap-1.5 text-xs text-[#8a6420] dark:text-[#EDAE49]">
|
||||
<MapPin className="w-3 h-3 shrink-0" />
|
||||
Booked to {currentName} — no income posted (no machine)
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-1 text-xs text-[#D1495B]">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteBookingLegend() {
|
||||
return (
|
||||
<p className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<Sprout className="w-3.5 h-3.5 shrink-0" />
|
||||
A site booking accounts for a line that matches no plant item. It posts no income —
|
||||
there is no machine to carry it — and each site takes one container.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
18
lib/data.ts
18
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<Site[]> {
|
||||
const pb = await serverAuth();
|
||||
return pb.collection("sites").getFullList<Site>({ sort: "name" });
|
||||
}
|
||||
|
||||
export const MONTH_NAMES = [
|
||||
"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec",
|
||||
];
|
||||
|
|
|
|||
94
lib/sites.ts
Normal file
94
lib/sites.ts
Normal file
|
|
@ -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<Site[]> {
|
||||
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<string, { id: string; invoiceNo: string; invoiceDate: string; totalIncl: number }>();
|
||||
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<string> {
|
||||
const taken = new Set<string>();
|
||||
for (const l of lines) {
|
||||
if (l.id === exceptLineId) continue;
|
||||
if (l.booked_site && !l.booked_asset) taken.add(l.booked_site);
|
||||
}
|
||||
return taken;
|
||||
}
|
||||
49
package-lock.json
generated
49
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue