"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(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. */}
{totals.monthsBilled} month{totals.monthsBilled === 1 ? "" : "s"} billed includes {ZAR(totals.totalVat)} VAT {totals.unbooked > 0 && ( {totals.unbooked} unbooked line{totals.unbooked === 1 ? "" : "s"} )} · sorted by {sort}
Sort: {[ { id: "revenue", label: "Revenue" }, { id: "name", label: "Name" }, ].map((s) => ( ))}
{view === "cards" ? ( ) : ( )} ); } function CardsView({ rows, year, color, expanded, setExpanded, }: { rows: SiteSummary[]; year: number; color: string; expanded: string | null; setExpanded: (id: string | null) => void; }) { return (
{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 (
{isOpen && }
); })}
); } 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 (
{/* The 12-month strip, laid out exactly as IncomeView's so the two read alike. */}
{site.months.map((v, i) => (
{MONTH_NAMES[i]}
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"}
))}
{inYear.length <= 1 && (

{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.`}

)} {other.length > 0 && (

Also billed in {other.map((m) => m.year).filter((v, i, a) => a.indexOf(v) === i).join(", ")} — not on this chart, which shows {year}.

)} Open the full story for {site.name}
); } function GridView({ rows, year, color }: { rows: SiteSummary[]; year: number; color: string }) { return (
{rows.map((r) => { const billed = r.monthly.filter((m) => m.year === year).length; return ( ); })}
Site Billed {year} Invoices Machines Revenue ex VAT VAT Total incl VAT Unbooked
{r.name}
{billed} of 12 months {r.invoices.length} {r.machineCount} {ZAR2(r.revenueExcl)} {ZAR2(r.vat)}
{ZAR2(r.revenueIncl)}
ex {ZAR2(r.revenueExcl)}
VAT {ZAR2(r.vat)}
{r.unbookedCount > 0 ? ( {r.unbookedCount} unbooked ) : ( — )}
Total {ZAR(rows.reduce((s, r) => s + r.revenueExcl, 0))} {ZAR(rows.reduce((s, r) => s + r.vat, 0))} {ZAR(rows.reduce((s, r) => s + r.revenueIncl, 0))}
); }