"use client"; import { useId, useMemo, useState, type ReactNode } from "react"; import Link from "next/link"; import { ChevronRight, LayoutGrid, MapPin, Sparkles } from "lucide-react"; import { ScrollFade } from "./ScrollFade"; import { MONTH_NAMES } from "@/lib/data"; import { EXTERNAL_INVOICES_PATH, INTERNAL_INVOICES_PATH } from "@/lib/invoice-routes"; import type { SiteIncomeContribution, SiteIncomeRow } from "@/lib/site-income"; import { cn, ZAR2 } from "@/lib/utils"; const descriptions: Record = { site: "Posted invoice income attributed to this invoice site. A vehicle's current location does not change this history.", external: "External invoice income without an internal site allocation. It remains part of posted income.", historical: "Historical workbook income has no recorded site allocation. Today's vehicle locations cannot establish its original sites.", unallocated: "Posted income with no reliable invoice site allocation. These amounts stay visible so the total reconciles.", }; const kindLabels: Record = { site: "Allocated site", external: "External income", historical: "Historical income", unallocated: "Unallocated income", }; const amount = (value: number | null) => value == null ? "—" : ZAR2(value); /** * The text for one month cell. * * The LIVE month reads R0 rather than a dash: it is a month that is still filling up, * not a month with nothing in it. Months AFTER it stay a dash — they have not happened * yet, and a dash says exactly that. */ const monthCellText = (value: number | null, index: number, monthIndex: number) => value != null ? (value > 0 ? ZAR2(value) : "R 0") : index === monthIndex ? "R 0" : "—"; export function SiteIncomeView({ rows, year, title, monthIndex }: { rows: SiteIncomeRow[]; year: number; title?: ReactNode; monthIndex: number }) { const id = useId(); const [view, setView] = useState<"cards" | "grid">("cards"); const [sort, setSort] = useState<"income" | "name">("income"); const [expanded, setExpanded] = useState(null); const monthLabel = monthIndex >= 0 ? MONTH_NAMES[monthIndex] : null; const sorted = useMemo(() => [...rows].sort((a, b) => sort === "income" ? b.income - a.income || a.name.localeCompare(b.name) : a.name.localeCompare(b.name)), [rows, sort]); const totals = useMemo(() => ({ income: rows.reduce((sum, row) => sum + row.income, 0), allocated: rows.filter(row => row.kind === "site").reduce((sum, row) => sum + row.income, 0), unallocated: rows.filter(row => row.kind !== "site").reduce((sum, row) => sum + row.income, 0), vat: rows.reduce((sum, row) => sum + row.invoiceVat, 0), months: MONTH_NAMES.map((_, index) => { const values = rows.map(row => row.months[index]).filter((value): value is number => value != null); return values.length ? values.reduce((sum, value) => sum + value, 0) : null; }), }), [rows]); const monthTotal = useMemo(() => rows.reduce((sum, row) => sum + (row.months[monthIndex] ?? 0), 0), [rows, monthIndex]); const selected = rows.find(row => row.id === expanded); const toggle = (row: SiteIncomeRow) => setExpanded(current => current === row.id ? null : row.id); return (
{title}
Posted income · {year}
{ZAR2(totals.income)}
{/* The live month, under the year figure — same rule as the vehicle cards. */} {monthLabel && (
{monthLabel} so far {ZAR2(monthTotal)}
)}
All vehicles and income groups
Without a site allocation
{ZAR2(totals.unallocated)}
{ZAR2(totals.allocated)} allocated to sites
{rows.filter(row => row.kind === "site").length} sites {totals.vat > 0 && {ZAR2(totals.vat)} known invoice VAT}
{([{ value: "cards", label: "Cards", icon: Sparkles }, { value: "grid", label: "Grid", icon: LayoutGrid }] as const).map(({ value, label, icon: Icon }) => )}
Sort: {(["income", "name"] as const).map(value => )}

Site income follows the site recorded on each invoice and the month the income was posted. Historical workbook and external income without a site stay in separate groups. Moving a vehicle today does not move its past income. VAT shown is known invoice VAT already included in posted amounts; workbook VAT is not inferred.

{rows.length === 0 ?

No posted income recorded for {year}.

: view === "cards" ? (
{sorted.map(row => { const open = row.id === expanded; const panelId = `${id}-${row.id}`; return
{open &&
}
; })}
) : ( <>
{MONTH_NAMES.map((month, index) => )}{sorted.map(row => {row.months.map((value, index) => )})}{totals.months.map((value, index) => )}
Site / income group{month}Posted income
{monthCellText(value, index, monthIndex)}{ZAR2(row.income)}
All posted income{monthCellText(value, index, monthIndex)}{ZAR2(totals.income)}
{selected &&

{selected.name}

} )}
); } function SiteIncomeDetails({ row, year, monthIndex }: { row: SiteIncomeRow; year: number; monthIndex: number }) { const byMonth = MONTH_NAMES.map((name, index) => ({ name, month: index + 1, entries: row.contributions.filter(item => item.month === index + 1) })).filter(group => group.entries.length > 0); const vehicleCount = new Set(row.contributions.filter(item => item.assetId).map(item => item.assetId)).size; return

{descriptions[row.kind]}

{row.months.map((value, index) =>
{MONTH_NAMES[index]}
{monthCellText(value, index, monthIndex)}
)}
Posted income
{ZAR2(row.income)}
Known invoice VAT included
{ZAR2(row.invoiceVat)}
Vehicles contributing
{vehicleCount}
{row.siteId && View site details }

Posted contributions · {year}

{byMonth.map(group =>

{group.name} {year}

    {group.entries.map((entry, index) => )}
)} {byMonth.length === 0 &&

No contribution details recorded.

}
; } function Contribution({ entry }: { entry: SiteIncomeContribution }) { const register = entry.source === "internal" ? INTERNAL_INVOICES_PATH : EXTERNAL_INVOICES_PATH; const source = entry.source === "workbook" ? "Historical workbook" : entry.source === "internal" ? "Internal invoice" : entry.source === "external" ? "External invoice" : "Unclassified income"; return
  • {entry.code || "Unknown vehicle"}{entry.name ? ` · ${entry.name}` : ""}

    {source}

    {entry.invoiceId && {entry.invoiceNo || "View invoice register"}}
    {ZAR2(entry.amount)}
  • ; } // Fixed-size SVG stays measurable inside responsive card variants and preserves // missing months as gaps instead of plotting a month with no posting as zero. function SiteSparkline({ data, color, width = 120, height = 36 }: { data: (number | null)[]; color: string; width?: number; height?: number }) { const values = data.filter((value): value is number => value != null); const min = Math.min(0, ...values), max = Math.max(0, ...values); const range = max - min || 1; const y = (value: number) => height - 2 - (value - min) / range * (height - 4); const groups: { x: number; y: number }[][] = []; let run: { x: number; y: number }[] = []; data.forEach((value, index) => { if (value == null) { if (run.length) groups.push(run); run = []; } else run.push({ x: 2 + index / Math.max(1, data.length - 1) * (width - 4), y: y(value) }); }); if (run.length) groups.push(run); return ; }