Invoices: keep the PDF on the invoice, and search the invoice's own words
Two things this question turned up. The upload DID store the PDF, but on the intake record, never on the invoice — so "Open invoice PDF" 404'd for every invoice imported through the app while working for the older ones. The file now lives on invoices.source_pdf and the intake record keeps only the provenance (who sent it, what was parsed), rather than a second copy of the file. The document endpoint also falls back to the intake record, which makes the seven already-stored documents reachable again without a migration. All 10 now open. The machine combobox searched only the register's own words, so an invoice line reading "CB22 2.2 TON Roller Vibration" could never be matched to the asset filed as "CAT 2,4TON RV" — the words simply were not in the list. The line's own invoice code and description are now searchable too, so the two sides of the same machine can actually be found from either direction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5b9425873b
commit
876619c3ac
9 changed files with 611 additions and 146 deletions
24
AGENTS.md
24
AGENTS.md
|
|
@ -71,6 +71,30 @@ where a multi-site machine should be read. Only an **unbooked** line is a real p
|
|||
The register has no `MPLANT04` / `MPLANT05` codes, so those map to `OC01` / `SC01` as the
|
||||
consistent target — chosen, not derived. Confirm with the client if either should differ.
|
||||
|
||||
## Site monthly chart — which month an invoice lands in
|
||||
|
||||
`/sites` plots one sparkline per site (billed per month) and a site's row expands to the
|
||||
invoices behind each month. `buildSiteSummaries` (`lib/sites.ts`) produces both.
|
||||
|
||||
**The month is always the invoice's `booked_month`, never `invoice_date` and never
|
||||
`period_from`.** `seed.mjs` sets `booked_month`/`booked_year` from the month of
|
||||
`period_to`, which is the same rule that posts the income into `monthly_income`. So a
|
||||
site's September total on `/sites` is the same rand as the September column on `/income`
|
||||
— the two screens cannot disagree. PHI 166 (period 21 Aug → 18 Sep) therefore books to
|
||||
**September**. Re-deriving the month any other way is the one way to break this.
|
||||
|
||||
Other rules worth not re-learning:
|
||||
|
||||
- A month total is the **sum of that site's lines**, not the invoice header total. One
|
||||
invoice carries many lines to a site (PHI 166 has five), so an invoice is listed once
|
||||
per month with its lines accumulated — never one card per line.
|
||||
- `months[]` is 12 slots of `null` for "no invoice", never `0`. A site whose billing
|
||||
starts in September is not eight months of zero.
|
||||
- The series covers ONE year — `latestInvoiceYear(invoices)`, the max `booked_year`.
|
||||
Months from other years are dropped, not folded in, and the panel says so.
|
||||
- `SiteSummary.pinned` exists because the table is a client component and cannot look
|
||||
the site up in the `sites` collection itself.
|
||||
|
||||
## Money on invoices — VAT is NOT revenue
|
||||
|
||||
On `PHI 166` the totals block reads `SUB TOTAL 161,367.50 / Escalation 0.00 / PLUS VAT
|
||||
|
|
|
|||
|
|
@ -20,15 +20,28 @@ export async function GET(
|
|||
try {
|
||||
const pb = await serverAuth();
|
||||
const invoice = await pb.collection("invoices").getOne(id);
|
||||
const token = invoice.source_pdf;
|
||||
|
||||
// Older imports stored the PDF on the intake record rather than the invoice, so
|
||||
// fall back to that before giving up — otherwise those documents are unreachable.
|
||||
let token = invoice.source_pdf;
|
||||
let holder: any = invoice;
|
||||
if (!token) {
|
||||
const intake = (await pb.collection("invoice_intake").getFullList({ filter: `invoice = "${id}"`, perPage: 1 }))[0];
|
||||
if (intake?.source_pdf) {
|
||||
token = intake.source_pdf;
|
||||
holder = intake;
|
||||
}
|
||||
}
|
||||
if (!token) {
|
||||
return Response.json({ ok: false, error: "No document attached" }, { status: 404 });
|
||||
}
|
||||
|
||||
// A bare `/api/files/<filename>` 404s for protected files even with an admin JWT —
|
||||
// PocketBase resolves those by the full /collectionId/recordId/filename path. Let the
|
||||
// SDK build the URL so the lookup matches whatever the field actually holds.
|
||||
const upstream = pb.files.getURL(invoice, token);
|
||||
// SDK build the URL so the lookup matches whatever the field actually holds. The
|
||||
// holder matters: a file on an intake record lives under THAT record's path, not
|
||||
// under the invoice's.
|
||||
const upstream = pb.files.getURL(holder, token);
|
||||
|
||||
const res = await fetch(upstream, {
|
||||
headers: pb.authStore.token ? { Authorization: pb.authStore.token } : undefined,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
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 { getAssets, getInvoices, getInvoiceLines, getSites, joinBookedLines, MONTH_NAMES } 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";
|
||||
import { Sparkline } from "@/components/Sparkline";
|
||||
import { SiteCoordinateEditor } from "@/components/SiteCoordinateEditor";
|
||||
import { SiteContainerEditor } from "@/components/SiteContainerEditor";
|
||||
|
||||
|
|
@ -78,6 +79,16 @@ export default async function SiteDetailPage({ params }: { params: Promise<{ id:
|
|||
<div className="text-xs text-white/80">Billed (incl VAT)</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">{ZAR2(summary.revenueIncl)}</div>
|
||||
</div>
|
||||
{/* The same monthly series the site list plots, so the detail page and the
|
||||
list can never disagree about what a month was worth. */}
|
||||
<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">
|
||||
Billed per month · {summary.year}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<Sparkline data={summary.months} color="#00798C" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -168,26 +179,57 @@ export default async function SiteDetailPage({ params }: { params: Promise<{ id:
|
|||
<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>
|
||||
{/* Grouped by billing month rather than one flat list, so the page reads the
|
||||
same way as the list: a month, then the invoices that made it up. */}
|
||||
<div className="divide-y divide-gray-100 dark:border-white/5">
|
||||
{summary.monthly.map((m) => (
|
||||
<div key={m.key} className="px-5 py-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-sm font-semibold text-[#003D5B] dark:text-white">
|
||||
{MONTH_NAMES[m.month - 1]} {m.year}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-sm tabular-nums text-[#003D5B] dark:text-white whitespace-nowrap">
|
||||
{ZAR2(i.totalIncl)}
|
||||
</span>
|
||||
</li>
|
||||
<span className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white whitespace-nowrap">
|
||||
{ZAR2(m.totalIncl)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-text-soft tabular-nums">
|
||||
ex {ZAR2(m.totalExcl)} + {ZAR2(m.vat)} VAT · {m.invoices.length} invoice
|
||||
{m.invoices.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{m.invoices.map((inv) => (
|
||||
<li key={inv.id} className="flex items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-2 min-w-0">
|
||||
<FileText className="w-4 h-4 text-[#00798C] shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block font-mono text-xs font-semibold text-[#003D5B] dark:text-white">
|
||||
{inv.invoiceNo}
|
||||
</span>
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
||||
{fmtDate(inv.invoiceDate)} · {fmtRange(inv.periodFrom, inv.periodTo)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{inv.hasPdf ? (
|
||||
<a
|
||||
href={`/api/invoices/${inv.id}/document`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center min-h-[44px] px-2 -mr-2 rounded-lg text-xs font-medium text-[#00798C] hover:bg-[#00798C]/10 transition shrink-0"
|
||||
>
|
||||
PDF
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xs tabular-nums text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{ZAR2(inv.totalIncl)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary.unbookedCount > 0 && (
|
||||
|
|
@ -209,4 +251,18 @@ export default async function SiteDetailPage({ params }: { params: Promise<{ id:
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PocketBase dates arrive as "2026-09-18 00:00:00.000Z". The date part is parsed as
|
||||
// UTC so a label can't slip a day backwards in a timezone behind the server.
|
||||
function fmtDate(d: string): string {
|
||||
if (!d) return "—";
|
||||
const dt = new Date(d.length <= 10 ? `${d}T00:00:00Z` : d);
|
||||
if (Number.isNaN(dt.getTime())) return String(d).slice(0, 10);
|
||||
return `${dt.getUTCDate()} ${MONTH_NAMES[dt.getUTCMonth()]}`;
|
||||
}
|
||||
|
||||
function fmtRange(from: string, to: string): string {
|
||||
if (!from || !to) return "—";
|
||||
return `${fmtDate(from)} → ${fmtDate(to)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
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, MapPinned } from "lucide-react";
|
||||
import { buildSiteSummaries, latestInvoiceYear } from "@/lib/sites";
|
||||
import { ZAR2 } from "@/lib/utils";
|
||||
import { SitesView } from "@/components/SitesView";
|
||||
import { AlertTriangle, MapPin, MapPinned } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata: Metadata = { title: "Sites — Mega Plant" };
|
||||
|
|
@ -18,12 +18,13 @@ export default async function SitesPage() {
|
|||
|
||||
const joined = joinBookedLines(lines, assets);
|
||||
const rows = buildSiteSummaries(sites, invoices, joined, assets);
|
||||
const year = latestInvoiceYear(invoices);
|
||||
|
||||
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);
|
||||
const pinned = rows.filter((r) => sites.find((x) => x.id === r.id)?.latitude).length;
|
||||
const pinned = rows.filter((r) => r.pinned).length;
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-8 max-w-[1400px]">
|
||||
|
|
@ -78,114 +79,7 @@ export default async function SitesPage() {
|
|||
</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-3 sm: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="hidden sm:table-cell 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-2 sm: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-3 sm: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>
|
||||
{sites.find((x) => x.id === r.id)?.latitude ? (
|
||||
<MapPinned className="w-3 h-3 text-[#00798C] shrink-0" aria-label="Has coordinates" />
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">no pin</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="hidden sm:table-cell 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-2 sm:px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
|
||||
<div>{ZAR2(r.revenueIncl)}</div>
|
||||
{/* The ex-VAT/VAT split stays visible on mobile, but as two short
|
||||
stacked lines — "161 367,50 + 24 205,13" on one line is wider
|
||||
than the total it annotates, which pushed the row back over the
|
||||
375px viewport. */}
|
||||
<div className="sm:hidden text-xs font-normal leading-tight text-gray-500 dark:text-gray-400">
|
||||
<div>ex {ZAR2(r.revenueExcl)}</div>
|
||||
<div>VAT {ZAR2(r.vat)}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-2 sm:px-4 whitespace-nowrap">
|
||||
{r.unbookedCount > 0 ? (
|
||||
<span
|
||||
title={`${r.unbookedCount} line(s) still unbooked`}
|
||||
className="inline-flex items-center gap-1.5 px-2 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}
|
||||
<span className="hidden sm:inline">unbooked</span>
|
||||
</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>
|
||||
<SitesView rows={rows} year={year} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -284,7 +284,13 @@ export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: A
|
|||
<td className="py-3 px-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:items-start">
|
||||
<div className="min-w-[180px]">
|
||||
<MachineCombobox lineId={l.id} value={l.booked_asset || ""} machines={machines} />
|
||||
<MachineCombobox
|
||||
lineId={l.id}
|
||||
value={l.booked_asset || ""}
|
||||
machines={machines}
|
||||
lineCode={l.machine_code}
|
||||
lineDescription={l.description}
|
||||
/>
|
||||
</div>
|
||||
{(!l.isBooked || l.booked_site) && (
|
||||
<SiteBookingSelect lineId={l.id} value={l.booked_site || ""} />
|
||||
|
|
|
|||
|
|
@ -20,10 +20,17 @@ export function MachineCombobox({
|
|||
lineId,
|
||||
value,
|
||||
machines,
|
||||
lineCode,
|
||||
lineDescription,
|
||||
}: {
|
||||
lineId: string;
|
||||
value: string;
|
||||
machines: Machine[];
|
||||
// The invoice's own words for this line. The register rarely uses them: an invoice
|
||||
// says "CB22 2.2 TON Roller Vibration" where the asset is filed as "CAT 2,4TON RV",
|
||||
// so searching on the register alone would never connect the two.
|
||||
lineCode?: string;
|
||||
lineDescription?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
|
|
@ -63,8 +70,15 @@ export function MachineCombobox({
|
|||
// Flattened, filtered list — the flat order is what the keyboard walks.
|
||||
const groups = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const hit = (m: Machine) =>
|
||||
!q || m.code.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || m.category.toLowerCase().includes(q);
|
||||
// Anything the invoice calls this line is searchable too, so "skidsteer" or
|
||||
// "CB22" finds the machine even though neither word is in the register's name.
|
||||
const extra = [lineCode, lineDescription].filter(Boolean).join(" ").toLowerCase();
|
||||
const hit = (m: Machine) => {
|
||||
if (!q) return true;
|
||||
if (m.code.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || m.category.toLowerCase().includes(q))
|
||||
return true;
|
||||
return extra.length > 0 && extra.includes(q);
|
||||
};
|
||||
const matched = machines.filter(hit);
|
||||
const byCategory = new Map<string, Machine[]>();
|
||||
for (const m of matched) {
|
||||
|
|
@ -82,7 +96,7 @@ export function MachineCombobox({
|
|||
.flat()
|
||||
.sort((a, b) => a.code.localeCompare(b.code)),
|
||||
};
|
||||
}, [machines, query]);
|
||||
}, [machines, query, lineCode, lineDescription]);
|
||||
|
||||
const menuWidth = 300;
|
||||
// ~16px count strip + a "Not booked" row (if any) + 12px per row, capped.
|
||||
|
|
@ -228,7 +242,7 @@ export function MachineCombobox({
|
|||
>
|
||||
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-white/5">
|
||||
{groups.flat.length} of {machines.length} machines
|
||||
{!selected && " · type a code or name to narrow it"}
|
||||
{!selected && " · search a code, name or the invoice description"}
|
||||
</div>
|
||||
|
||||
{value && (
|
||||
|
|
|
|||
337
components/SitesView.tsx
Normal file
337
components/SitesView.tsx
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
"use client";
|
||||
|
||||
import { Fragment, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, ChevronRight, FileText, MapPin, MapPinned } from "lucide-react";
|
||||
import { Sparkline } from "@/components/Sparkline";
|
||||
import { ScrollFade } from "@/components/ScrollFade";
|
||||
import { MONTH_NAMES } from "@/lib/data";
|
||||
import type { SiteMonth, SiteSummary } from "@/lib/sites";
|
||||
import { ZAR, ZAR2 } from "@/lib/utils";
|
||||
|
||||
// Site · Billed · year/sparkline · Invoices · Machines · ex VAT · VAT · incl VAT ·
|
||||
// Unbooked · expand. Must equal the widest row, or the expanded panel lands a column
|
||||
// short and the whole table shifts.
|
||||
const COLS = 10;
|
||||
|
||||
// The per-site equivalent of IncomeView's machine cards: a sparkline of what each
|
||||
// month was billed, and on click the invoices behind each month.
|
||||
//
|
||||
// The month a figure sits under is the invoice's `booked_month`, the same field the
|
||||
// seed posts into monthly_income — so a site's Sept total here is the same rand as the
|
||||
// Sept column on /income. Deriving the month any other way would make the two screens
|
||||
// quietly disagree, which is worse than having no site chart at all.
|
||||
export function SitesView({
|
||||
rows,
|
||||
year,
|
||||
color = "#00798C",
|
||||
}: {
|
||||
rows: SiteSummary[];
|
||||
year: number;
|
||||
color?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<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-[900px]">
|
||||
<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-3 sm:px-4 font-medium sticky left-0 z-10 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur">
|
||||
Site
|
||||
</th>
|
||||
<th className="hidden sm:table-cell text-right py-3 px-3 font-medium whitespace-nowrap">
|
||||
Billed
|
||||
</th>
|
||||
<th className="text-left py-3 px-2 font-medium whitespace-nowrap" title={`Monthly billing, ${year}`}>
|
||||
{year}
|
||||
</th>
|
||||
<th className="hidden sm:table-cell text-right py-3 px-3 font-medium whitespace-nowrap">
|
||||
Invoices
|
||||
</th>
|
||||
<th className="hidden sm:table-cell text-right py-3 px-3 font-medium whitespace-nowrap">
|
||||
Machines
|
||||
</th>
|
||||
<th className="hidden sm:table-cell text-right py-3 px-3 font-medium whitespace-nowrap">
|
||||
Revenue ex VAT
|
||||
</th>
|
||||
<th className="hidden sm:table-cell text-right py-3 px-3 font-medium whitespace-nowrap">
|
||||
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-2 font-medium whitespace-nowrap">Unbooked</th>
|
||||
<th className="w-11" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const isOpen = expanded === r.id;
|
||||
const billed = r.monthly.filter((m) => m.year === year).length;
|
||||
return (
|
||||
<Fragment key={r.id}>
|
||||
<tr
|
||||
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-3 sm:px-4 sticky left-0 z-10 bg-white dark:bg-[#131a21]">
|
||||
<Link
|
||||
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>
|
||||
{r.pinned ? (
|
||||
<MapPinned className="w-3 h-3 text-[#00798C] shrink-0" aria-label="Has coordinates" />
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">no pin</span>
|
||||
)}
|
||||
</Link>
|
||||
{/* The sparkline's own column is hidden on mobile, so it rides under
|
||||
the name here instead — same pattern as IncomeView's second row.
|
||||
Two separate elements rather than one responsive width, so the
|
||||
chart never reflows on resize. */}
|
||||
<div className="sm:hidden pb-1">
|
||||
<Sparkline data={r.months} color={color} width={72} height={28} />
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="hidden sm:table-cell py-3 px-3 text-right tabular-nums text-gray-600 dark:text-gray-300 whitespace-nowrap"
|
||||
title={`${billed} of 12 months billed in ${year}`}
|
||||
>
|
||||
{billed} of 12
|
||||
<span className="block text-xs text-gray-400 dark:text-text-soft">months</span>
|
||||
</td>
|
||||
<td className="hidden sm:table-cell py-3 px-2">
|
||||
<Sparkline data={r.months} color={color} />
|
||||
</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="hidden sm:table-cell 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-2 sm:px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
|
||||
<div>{ZAR2(r.revenueIncl)}</div>
|
||||
{/* The ex-VAT/VAT split stays visible on mobile, but as two short
|
||||
stacked lines — "161 367,50 + 24 205,13" on one line is wider
|
||||
than the total it annotates, which pushed the row back over the
|
||||
375px viewport. */}
|
||||
<div className="sm:hidden text-xs font-normal leading-tight text-gray-500 dark:text-gray-400">
|
||||
<div>ex {ZAR2(r.revenueExcl)}</div>
|
||||
<div>VAT {ZAR2(r.vat)}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-2 sm:px-4 whitespace-nowrap">
|
||||
{r.unbookedCount > 0 ? (
|
||||
<span
|
||||
title={`${r.unbookedCount} line(s) still unbooked`}
|
||||
className="inline-flex items-center gap-1.5 px-2 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}
|
||||
<span className="hidden sm:inline">unbooked</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1 pr-2">
|
||||
<button
|
||||
onClick={() => setExpanded(isOpen ? null : r.id)}
|
||||
aria-expanded={isOpen}
|
||||
aria-label={`${isOpen ? "Hide" : "Show"} monthly invoices for ${r.name}`}
|
||||
className="grid place-items-center w-11 min-h-[44px] rounded-lg text-gray-400 hover:text-[#00798C] hover:bg-[#00798C]/10 transition"
|
||||
>
|
||||
<ChevronRight className={`w-4 h-4 transition-transform ${isOpen ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen && (
|
||||
<tr key={`${r.id}-detail`} className="border-b border-gray-100 dark:border-white/5">
|
||||
<td colSpan={COLS} className="px-3 sm:px-4 pb-4 pt-1 bg-gray-50/60 dark:bg-white/[0.03]">
|
||||
<MonthPanel site={r} year={year} color={color} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</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="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(rows.reduce((s, r) => s + r.revenueExcl, 0))}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell py-3 px-3 text-right tabular-nums whitespace-nowrap text-[#003D5B] dark:text-white">
|
||||
{ZAR(rows.reduce((s, r) => s + r.vat, 0))}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
|
||||
{ZAR(rows.reduce((s, r) => s + r.revenueIncl, 0))}
|
||||
</td>
|
||||
<td />
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<ScrollFade />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthPanel({ site, year, color }: { site: SiteSummary; year: number; color: string }) {
|
||||
const inYear = site.monthly.filter((m) => m.year === year);
|
||||
const billed = inYear.length;
|
||||
const other = site.monthly.filter((m) => m.year !== year);
|
||||
|
||||
return (
|
||||
<div className="pt-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||
<h3 className="text-sm font-semibold text-[#003D5B] dark:text-white">
|
||||
{site.name} — invoices per month
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
|
||||
{billed} of 12 months billed in {year}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* With one month of history a bare card looks like a bug, so say so plainly
|
||||
rather than leaving the reader to guess. */}
|
||||
{billed <= 1 && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{billed === 0
|
||||
? `No invoice for this site in ${year}.`
|
||||
: `One month of billing so far. The trend fills in as each month's invoice lands.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{inYear.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{inYear.map((m) => (
|
||||
<MonthCard key={m.key} month={m} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 dark:text-text-soft">Nothing billed in {year}.</p>
|
||||
)}
|
||||
|
||||
{other.length > 0 && (
|
||||
<p className="text-xs text-gray-400 dark:text-text-soft mt-3">
|
||||
Also billed in {other.map((m) => m.year).filter((v, i, a) => a.indexOf(v) === i).join(", ")} — not on this
|
||||
chart, which shows {year}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4 pt-3 border-t border-gray-100 dark:border-white/5 text-xs">
|
||||
<Stat label="Revenue ex VAT" value={ZAR(site.revenueExcl)} />
|
||||
<Stat label="VAT" value={ZAR(site.vat)} />
|
||||
<Stat label="Total billed" value={ZAR(site.revenueIncl)} strong />
|
||||
<Stat
|
||||
label="Invoices"
|
||||
value={`${site.invoices.length} · ${site.lineCount} line${site.lineCount === 1 ? "" : "s"}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-text-soft mt-2">
|
||||
<span className="inline-block w-2 h-2 rounded-full align-middle mr-1" style={{ background: color }} />
|
||||
Each point is what this site was billed that month, VAT included — the same rand as the
|
||||
month column on the income screen.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthCard({ month }: { month: SiteMonth }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 dark:border-white/5 bg-white dark:bg-[#131a21] p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[#003D5B] dark:text-white">
|
||||
{MONTH_NAMES[month.month - 1]} {month.year}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{month.invoices.length} invoice{month.invoices.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
|
||||
{ZAR2(month.totalIncl)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 tabular-nums mt-1">
|
||||
ex {ZAR2(month.totalExcl)} + {ZAR2(month.vat)} VAT
|
||||
</div>
|
||||
<ul className="mt-2 pt-2 border-t border-gray-100 dark:border-white/5 space-y-1">
|
||||
{month.invoices.map((inv) => (
|
||||
<li key={inv.id} className="flex items-center justify-between gap-2">
|
||||
<span className="min-w-0">
|
||||
<span className="block font-mono text-xs font-semibold text-[#003D5B] dark:text-white">
|
||||
{inv.invoiceNo}
|
||||
</span>
|
||||
<span className="block text-xs text-gray-400 dark:text-text-soft">
|
||||
{fmtDate(inv.invoiceDate)} · {fmtRange(inv.periodFrom, inv.periodTo)}
|
||||
</span>
|
||||
</span>
|
||||
{inv.hasPdf ? (
|
||||
<a
|
||||
href={`/api/invoices/${inv.id}/document`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 min-h-[44px] px-2 -mr-2 rounded-lg text-xs font-medium text-[#00798C] hover:bg-[#00798C]/10 transition shrink-0"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
PDF
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xs text-gray-300 dark:text-text-soft shrink-0">no PDF</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="uppercase tracking-wider text-xs text-gray-400 dark:text-text-soft">{label}</div>
|
||||
<div
|
||||
className={`tabular-nums ${strong ? "font-semibold" : ""} ${
|
||||
strong ? "text-[#003D5B] dark:text-white" : "text-[#00798C]"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PocketBase dates come back as "2026-09-18 00:00:00.000Z". Parse the date part as UTC
|
||||
// so the label can't slip a day backwards in a timezone behind the server.
|
||||
function fmtDate(d: string): string {
|
||||
if (!d) return "—";
|
||||
const dt = new Date(d.length <= 10 ? `${d}T00:00:00Z` : d);
|
||||
if (Number.isNaN(dt.getTime())) return String(d).slice(0, 10);
|
||||
return `${dt.getUTCDate()} ${MONTH_NAMES[dt.getUTCMonth()]}`;
|
||||
}
|
||||
|
||||
function fmtRange(from: string, to: string): string {
|
||||
if (!from || !to) return "—";
|
||||
return `${fmtDate(from)} → ${fmtDate(to)}`;
|
||||
}
|
||||
|
|
@ -6,9 +6,12 @@ type Props = {
|
|||
data: (number | null)[];
|
||||
color: string;
|
||||
height?: number;
|
||||
// Sites renders a narrower sparkline on mobile so the 375px table row keeps its
|
||||
// budget. Defaults to the width /income has always used.
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export function Sparkline({ data, color, height = 36 }: Props) {
|
||||
export function Sparkline({ data, color, height = 36, width = 120 }: Props) {
|
||||
const points = data.map((v, i) => ({ i, v: v ?? 0 }));
|
||||
const max = Math.max(0.001, ...data.filter((v): v is number => v != null));
|
||||
// Pad values: if all 0, draw a flat line at 0 for visual presence
|
||||
|
|
@ -17,11 +20,14 @@ export function Sparkline({ data, color, height = 36 }: Props) {
|
|||
const displayMax = flat ? 1 : max;
|
||||
|
||||
return (
|
||||
<div style={{ width: 120, height }} className="shrink-0">
|
||||
<div style={{ width, height }} className="shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={displayData} margin={{ top: 2, right: 2, left: 2, bottom: 2 }}>
|
||||
<defs>
|
||||
<linearGradient id={`spark-${color.replace("#", "")}`} x1="0" y1="0" x2="0" y2="1">
|
||||
{/* Keyed by colour AND width: /sites and /income can both mount a sparkline
|
||||
with the same colour in one document, and identical ids would make the
|
||||
second one reuse the first one's gradient. */}
|
||||
<linearGradient id={`spark-${color.replace("#", "")}-${width}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
|
|
@ -31,7 +37,7 @@ export function Sparkline({ data, color, height = 36 }: Props) {
|
|||
dataKey="v"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#spark-${color.replace("#", "")})`}
|
||||
fill={`url(#spark-${color.replace("#", "")}-${width})`}
|
||||
isAnimationActive={false}
|
||||
baseValue={0}
|
||||
/>
|
||||
|
|
|
|||
117
lib/sites.ts
117
lib/sites.ts
|
|
@ -23,6 +23,30 @@ export async function syncSitesFromInvoices(pb: any, invoices: Invoice[]): Promi
|
|||
return byName.size ? Array.from(byName.values()) : existing;
|
||||
}
|
||||
|
||||
export type SiteInvoiceRef = {
|
||||
id: string;
|
||||
invoiceNo: string;
|
||||
invoiceDate: string;
|
||||
periodFrom: string;
|
||||
periodTo: string;
|
||||
/** This site's share of the invoice — the sum of the site's lines, not the header
|
||||
* total. Equal to the header total today, because an invoice carries one site, but
|
||||
* summing lines keeps it right if an invoice ever spans two. */
|
||||
totalIncl: number;
|
||||
hasPdf: boolean;
|
||||
};
|
||||
|
||||
/** One month of billing for a site. A month holds one or more invoices. */
|
||||
export type SiteMonth = {
|
||||
key: string; // "2026-09"
|
||||
year: number;
|
||||
month: number; // 1-12
|
||||
totalExcl: number;
|
||||
vat: number;
|
||||
totalIncl: number;
|
||||
invoices: SiteInvoiceRef[];
|
||||
};
|
||||
|
||||
export type SiteSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -32,13 +56,50 @@ export type SiteSummary = {
|
|||
/** What the debtors were billed in total. */
|
||||
revenueIncl: number;
|
||||
invoices: { id: string; invoiceNo: string; invoiceDate: string; totalIncl: number }[];
|
||||
/** The calendar year the `months` series covers, so the chart is never ambiguous. */
|
||||
year: number;
|
||||
/** 12 slots of billed amount incl VAT. null = no invoice that month, which is
|
||||
* different from a billed zero. Same convention as IncomeRow.months. */
|
||||
months: (number | null)[];
|
||||
/** Only the months that actually have an invoice, ascending. Drives the expanded
|
||||
* panel, so a month with no billing is simply absent rather than a zero row. */
|
||||
monthly: SiteMonth[];
|
||||
machineCount: number;
|
||||
lineCount: number;
|
||||
/** True once the site has map coordinates. Carried on the summary so the table —
|
||||
* a client component — can render the marker without also receiving the site list. */
|
||||
pinned: boolean;
|
||||
/** Lines with neither a plant item nor a site — the work still outstanding. */
|
||||
unbookedCount: number;
|
||||
unbookedAmount: number;
|
||||
};
|
||||
|
||||
/** The year the chart should show: the most recent one any invoice was booked into.
|
||||
* Scrolling forward a year re-bases every series without a schema change. */
|
||||
export function latestInvoiceYear(invoices: Invoice[]): number {
|
||||
return invoices.reduce((y, i) => (i.booked_year > y ? i.booked_year : y), new Date().getUTCFullYear());
|
||||
}
|
||||
|
||||
// Which month an invoice is billed in.
|
||||
//
|
||||
// `booked_month`/`booked_year` are already written by the seed from the month of
|
||||
// `period_to` (scripts/seed.mjs), which is the same rule that posts the income into
|
||||
// monthly_income. Reusing that field is what keeps a site's month here equal to the
|
||||
// same month on /income — a site chart that disagreed with the income screen would be
|
||||
// worse than no chart. The date fallback only covers a record imported before those
|
||||
// fields existed.
|
||||
function invoiceMonth(inv: Invoice): { year: number; month: number } {
|
||||
if (inv.booked_month >= 1 && inv.booked_month <= 12 && inv.booked_year) {
|
||||
return { year: inv.booked_year, month: inv.booked_month };
|
||||
}
|
||||
const d = inv.period_to || inv.invoice_date;
|
||||
if (d) {
|
||||
const dt = new Date(d);
|
||||
if (!Number.isNaN(dt.getTime())) return { year: dt.getUTCFullYear(), month: dt.getUTCMonth() + 1 };
|
||||
}
|
||||
return { year: 0, month: 0 };
|
||||
}
|
||||
|
||||
// Per-site revenue and VAT, derived by walking invoice lines through their invoice.
|
||||
export function buildSiteSummaries(
|
||||
sites: Site[],
|
||||
|
|
@ -47,7 +108,7 @@ export function buildSiteSummaries(
|
|||
assets: Asset[],
|
||||
): SiteSummary[] {
|
||||
const invById = new Map(invoices.map((i) => [i.id, i]));
|
||||
const assetById = new Map(assets.map((a) => [a.id, a]));
|
||||
const year = latestInvoiceYear(invoices);
|
||||
|
||||
return sites
|
||||
.map((site) => {
|
||||
|
|
@ -62,6 +123,56 @@ export function buildSiteSummaries(
|
|||
if (inv) invMap.set(inv.id, { id: inv.id, invoiceNo: inv.invoice_no, invoiceDate: inv.invoice_date, totalIncl: inv.total_incl });
|
||||
}
|
||||
|
||||
// Bucket the same lines by billing month. One invoice contributes several lines to
|
||||
// a site (PHI 166 has five), so an invoice is listed ONCE per month and its amount
|
||||
// accumulates across those lines — otherwise a site would show a card per line.
|
||||
const buckets = new Map<string, SiteMonth>();
|
||||
const refsByMonth = new Map<string, Map<string, SiteInvoiceRef>>();
|
||||
for (const l of siteLines) {
|
||||
const inv = invById.get(l.invoice);
|
||||
if (!inv) continue;
|
||||
const m = invoiceMonth(inv);
|
||||
if (!m.year || !m.month) continue;
|
||||
const key = `${m.year}-${String(m.month).padStart(2, "0")}`;
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { key, year: m.year, month: m.month, totalExcl: 0, vat: 0, totalIncl: 0, invoices: [] };
|
||||
buckets.set(key, bucket);
|
||||
refsByMonth.set(key, new Map());
|
||||
}
|
||||
bucket.totalExcl += l.amount_excl || 0;
|
||||
bucket.vat += l.vat || 0;
|
||||
bucket.totalIncl += l.total_incl || 0;
|
||||
|
||||
const refs = refsByMonth.get(key)!;
|
||||
const seen = refs.get(inv.id);
|
||||
if (seen) {
|
||||
seen.totalIncl += l.total_incl || 0;
|
||||
} else {
|
||||
refs.set(inv.id, {
|
||||
id: inv.id,
|
||||
invoiceNo: inv.invoice_no,
|
||||
invoiceDate: inv.invoice_date,
|
||||
periodFrom: inv.period_from,
|
||||
periodTo: inv.period_to,
|
||||
totalIncl: l.total_incl || 0,
|
||||
hasPdf: Boolean(inv.source_pdf),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const monthly = Array.from(buckets.values())
|
||||
.sort((a, b) => a.key.localeCompare(b.key))
|
||||
.map((b) => ({
|
||||
...b,
|
||||
invoices: Array.from(refsByMonth.get(b.key)!.values()).sort((x, y) => x.invoiceNo.localeCompare(y.invoiceNo)),
|
||||
}));
|
||||
|
||||
// 12-slot series for the sparkline. Months from OTHER years are dropped rather
|
||||
// than folded in — a Dec-2025 invoice must not paint a point on Jan.
|
||||
const months: (number | null)[] = Array(12).fill(null);
|
||||
for (const b of monthly) if (b.year === year) months[b.month - 1] = b.totalIncl;
|
||||
|
||||
const unbooked = siteLines.filter((l) => !l.isBooked && !l.booked_site);
|
||||
return {
|
||||
id: site.id,
|
||||
|
|
@ -70,8 +181,12 @@ export function buildSiteSummaries(
|
|||
vat,
|
||||
revenueIncl: incl,
|
||||
invoices: Array.from(invMap.values()).sort((a, b) => b.invoiceDate.localeCompare(a.invoiceDate)),
|
||||
year,
|
||||
months,
|
||||
monthly,
|
||||
machineCount: new Set(siteLines.map((l) => l.booked_asset).filter(Boolean)).size,
|
||||
lineCount: siteLines.length,
|
||||
pinned: Boolean(site.latitude),
|
||||
unbookedCount: unbooked.length,
|
||||
unbookedAmount: unbooked.reduce((s, l) => s + (l.total_incl || 0), 0),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue