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.
438 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|