mega-plant/components/SitesView.tsx
David 813c187272 Sites: a card list that reads like the income screen
The client likes the vehicles overview and asked for the site list to work
the same way, so /sites now defaults to Cards — same toolbar, same card
shape, same 12-month strip on expand, same three-up footer as IncomeView.
The old table is kept as a Grid toggle rather than deleted.

The detail link lives inside the expanded panel only: a card is a <button>,
so a nested <a> there would be invalid HTML.
2026-10-03 09:20:20 +02:00

438 lines
20 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
AlertTriangle,
ArrowRight,
ChevronRight,
LayoutGrid,
MapPin,
Sparkles,
} from "lucide-react";
import { Sparkline } from "@/components/Sparkline";
import { ScrollFade } from "@/components/ScrollFade";
import { MONTH_NAMES } from "@/lib/data";
import type { 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 grid view's totals shift.
const COLS = 10;
// The sites screen deliberately mirrors IncomeView: same card shape, same row rhythm,
// same 12-month strip on expand, same toolbar. A site reads exactly like a machine —
// a name, a sparkline of what it was billed each month, the year total, and the last
// entry beside it — so someone who likes one screen already knows the other.
//
// The month a figure sits under is the invoice's `booked_month`, the same field the
// seed posts into monthly_income. A site's September total here is therefore the same
// rand as the September column on /income; deriving the month any other way is the one
// way to make the two screens quietly disagree.
export function SitesView({
rows,
year,
color = "#00798C",
}: {
rows: SiteSummary[];
year: number;
color?: string;
}) {
const [view, setView] = useState<"cards" | "grid">("cards");
const [sort, setSort] = useState<"revenue" | "name">("revenue");
const [expanded, setExpanded] = useState<string | null>(null);
const sorted = useMemo(() => {
return [...rows].sort((a, b) =>
sort === "revenue" ? b.revenueIncl - a.revenueIncl : a.name.localeCompare(b.name),
);
}, [rows, sort]);
const totals = useMemo(() => {
const totalIncl = rows.reduce((s, r) => s + r.revenueIncl, 0);
const totalVat = rows.reduce((s, r) => s + r.vat, 0);
const unbooked = rows.reduce((s, r) => s + r.unbookedCount, 0);
const monthsBilled = new Set(
rows.flatMap((r) => r.monthly.filter((m) => m.year === year).map((m) => m.key)),
).size;
return { totalIncl, totalVat, unbooked, monthsBilled };
}, [rows, year]);
return (
<>
{/* Same toolbar shape as IncomeView: summary pills, view toggle, sort. */}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-between gap-3 mb-5">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
<span className="px-2.5 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-medium">
{totals.monthsBilled} month{totals.monthsBilled === 1 ? "" : "s"} billed
</span>
<span className="px-2.5 py-1 rounded-md bg-[#EDAE49]/15 text-[#8a6420] dark:text-[#EDAE49] font-medium">
includes {ZAR(totals.totalVat)} VAT
</span>
{totals.unbooked > 0 && (
<span className="px-2.5 py-1 rounded-md bg-[#D1495B]/10 text-[#D1495B] font-medium">
{totals.unbooked} unbooked line{totals.unbooked === 1 ? "" : "s"}
</span>
)}
<span className="hidden md:inline">· sorted by {sort}</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-white dark:bg-white/5 border border-gray-200 dark:border-border">
<button
onClick={() => setView("cards")}
aria-label="Cards view"
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
view === "cards"
? "bg-[#003D5B] text-white dark:bg-[#00798C]"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
}`}
>
<Sparkles className="w-3.5 h-3.5" /> Cards
</button>
<button
onClick={() => setView("grid")}
aria-label="Grid view"
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
view === "grid"
? "bg-[#003D5B] text-white dark:bg-[#00798C]"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
}`}
>
<LayoutGrid className="w-3.5 h-3.5" /> Grid
</button>
</div>
<div className="hidden sm:flex items-center gap-1">
<span className="text-xs text-gray-400 dark:text-text-soft mr-1">Sort:</span>
{[
{ id: "revenue", label: "Revenue" },
{ id: "name", label: "Name" },
].map((s) => (
<button
key={s.id}
onClick={() => setSort(s.id as typeof sort)}
className={`min-h-[44px] px-2 text-xs font-medium rounded transition ${
sort === s.id
? "bg-[#EDAE49] text-white"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
{s.label}
</button>
))}
</div>
<div className="sm:hidden">
<label className="sr-only" htmlFor="sites-sort">
Sort by
</label>
<select
id="sites-sort"
value={sort}
onChange={(e) => setSort(e.target.value as typeof sort)}
className="min-h-[44px] px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5"
>
<option value="revenue">Sort by Revenue</option>
<option value="name">Sort by Name</option>
</select>
</div>
</div>
</div>
{view === "cards" ? (
<CardsView
rows={sorted}
year={year}
color={color}
expanded={expanded}
setExpanded={setExpanded}
/>
) : (
<GridView rows={sorted} year={year} color={color} />
)}
</>
);
}
function CardsView({
rows,
year,
color,
expanded,
setExpanded,
}: {
rows: SiteSummary[];
year: number;
color: string;
expanded: string | null;
setExpanded: (id: string | null) => void;
}) {
return (
<div className="space-y-2">
{rows.map((r) => {
const isOpen = expanded === r.id;
// The last entry in the series: the most recent month this site was billed.
const inYear = r.monthly.filter((m) => m.year === year);
const last = inYear.length ? inYear[inYear.length - 1] : null;
const lastInvoice = last ? last.invoices[0] : null;
return (
<div
key={r.id}
className="bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition"
>
<button onClick={() => setExpanded(isOpen ? null : r.id)} className="w-full text-left p-4">
{/* Row 1: name + sparkline + year total + last entry + chevron */}
<div className="flex items-center gap-3 sm:gap-4 min-h-[44px]">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm font-medium text-[#003D5B] dark:text-white min-w-0">
<MapPin className="w-3.5 h-3.5 text-[#EDAE49] shrink-0" />
<span className="truncate">{r.name}</span>
{r.unbookedCount > 0 && (
<span
title={`${r.unbookedCount} line(s) still unbooked`}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-[#D1495B]/10 text-[#D1495B] text-xs font-semibold shrink-0"
>
<AlertTriangle className="w-3 h-3" />
{r.unbookedCount}
</span>
)}
</div>
</div>
<div className="hidden sm:block shrink-0">
<Sparkline data={r.months} color={color} />
</div>
<div className="text-right shrink-0 w-32 hidden md:block">
<div className="text-xs text-gray-400 dark:text-text-soft uppercase tracking-wider">
Billed {year}
</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
{ZAR2(r.revenueIncl)}
</div>
</div>
{/* The last entry, beside the year total. */}
<div
title={lastInvoice ? `${lastInvoice.invoiceNo} · ${lastInvoice.invoiceDate?.slice(0, 10)}` : "No invoice yet"}
className="shrink-0 hidden md:flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-semibold bg-[#00798C]/10 text-[#00798C] dark:bg-[#00798C]/20 whitespace-nowrap"
>
{last ? (
<>
{MONTH_NAMES[last.month - 1]}
<span className="tabular-nums">{ZAR(last.totalIncl)}</span>
</>
) : (
"—"
)}
</div>
<ChevronRight
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
/>
</div>
{/* Row 2 (mobile only): sparkline + year total + last entry */}
<div className="flex sm:hidden items-center justify-between gap-3 pt-2 text-xs">
<Sparkline data={r.months} color={color} width={72} height={28} />
<div className="text-right">
<div className="text-xs text-gray-400 dark:text-text-soft uppercase tracking-wider">
Billed {year}
</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
{ZAR2(r.revenueIncl)}
</div>
</div>
<div
className="shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-semibold bg-[#00798C]/10 text-[#00798C] dark:bg-[#00798C]/20 whitespace-nowrap"
>
{last ? `${MONTH_NAMES[last.month - 1]} ${ZAR(last.totalIncl)}` : "—"}
</div>
</div>
</button>
{isOpen && <SitePanel site={r} year={year} />}
</div>
);
})}
</div>
);
}
function SitePanel({ site, year }: { site: SiteSummary; year: number }) {
const inYear = site.monthly.filter((m) => m.year === year);
const other = site.monthly.filter((m) => m.year !== year);
return (
<div className="border-t border-gray-100 dark:border-border px-4 py-4 bg-gray-50/50 dark:bg-white/[0.04]">
{/* The 12-month strip, laid out exactly as IncomeView's so the two read alike. */}
<div className="grid grid-cols-3 md:grid-cols-6 lg:grid-cols-12 gap-2 text-xs">
{site.months.map((v, i) => (
<div key={i} className="text-center">
<div className="text-gray-400 dark:text-text-soft text-xs mb-1">{MONTH_NAMES[i]}</div>
<div
className={`py-1.5 rounded tabular-nums ${
(v ?? 0) > 0
? "bg-[#00798C]/5 text-[#00798C] dark:bg-[#00798C]/15"
: "text-gray-300 dark:text-text-soft"
}`}
>
{v == null ? "—" : v > 0 ? ZAR2(v) : "R 0"}
</div>
</div>
))}
</div>
{inYear.length <= 1 && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
{inYear.length === 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>
)}
{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>
)}
<Link
href={`/sites/${site.id}`}
className="mt-3 inline-flex items-center justify-center gap-2 w-full sm:w-auto min-h-[44px] px-4 rounded-xl bg-[#003D5B] dark:bg-[#00798C] text-white text-sm font-medium hover:opacity-90 transition"
>
Open the full story for {site.name}
<ArrowRight className="w-4 h-4" />
</Link>
</div>
);
}
function GridView({ rows, year, color }: { rows: SiteSummary[]; year: number; color: string }) {
return (
<div className="relative bg-white dark:bg-surface 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 billed = r.monthly.filter((m) => m.year === year).length;
return (
<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-surface">
<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>
<ArrowRight className="w-3.5 h-3.5 opacity-0 group-hover:opacity-100 transition shrink-0" />
</Link>
<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>
<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 />
</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="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>
);
}