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>
337 lines
16 KiB
TypeScript
337 lines
16 KiB
TypeScript
"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)}`;
|
|
}
|