Two changes to the income overview, sharing one rule for what "the month" means (lib/income-month.ts). POSTED INCOME PER SITE /income gains Vehicles / Sites views, and /sites defaults to the SAME posted-income view with a separate Internal billed invoices view. buildSiteIncomeRows (lib/site-income.ts) is the shared calculation: actual monthly_income supplies the amount, year and month, and the linked invoice supplies its historical site. It never uses assets.site, fuel-log locations or today's map assignment. - Unposted invoice lines are billing, not posted income. - Historical workbook income with no site evidence stays in Unallocated historical income; external income, whose site field names the SUPPLIER, stays in Unallocated external income. - Missing source references stay visible as unallocated invoice income. No group is dropped to make a site list look cleaner and no site is guessed to force a total. - No site profit is calculated without an explicit cost-allocation rule. - Invoice writes use revalidateInvoicePages, which invalidates both registers, Income, Sites/details, Charts and Dashboard. THE LIVE MONTH The tiles show the year-to-date income and profit AND the current month, and every vehicle card shows the current month income beside its year figure. - The month is the live CALENDAR month and it is shown even at R0. Income for a month is usually entered near the end of it, so the current month is a figure that starts at R0 and grows. - The month index is computed on the SERVER and passed down as a number. The browser never works it out for itself: two clocks disagreeing would show two different months on the same page. - A month profit is a dash until that month costs are posted. September and October carry invoice income with no posted fixed costs (their costs exist only as forecast rows), so a profit there would be income minus zero - a 100% margin that does not tie back to the year-to-date profit. It becomes a number by itself the moment the workbook posts that month. - The 12-month strip shows the live month as R0 rather than a dash, so a card cannot contradict itself. Months after it stay a dash. - The sparkline on a collapsed card is hidden below xl: the content column is only ~700px wide between md and xl, and the vehicle name must not be truncated to pay for a decorative trend.
188 lines
19 KiB
TypeScript
188 lines
19 KiB
TypeScript
"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<SiteIncomeRow["kind"], string> = {
|
|
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<SiteIncomeRow["kind"], string> = {
|
|
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<string | null>(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 (
|
|
<div className="min-w-0">
|
|
<div className="mb-6 flex min-w-0 flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
|
{title}
|
|
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<div className="rounded-2xl bg-gradient-to-br from-[#00798C] to-[#003D5B] px-5 py-3 text-white shadow-lg shadow-teal-500/20">
|
|
<div className="text-xs text-white/80">Posted income · {year}</div>
|
|
<div className="break-words text-2xl font-semibold tabular-nums">{ZAR2(totals.income)}</div>
|
|
{/* The live month, under the year figure — same rule as the vehicle cards. */}
|
|
{monthLabel && (
|
|
<div className="mt-1 text-xs text-white/80">
|
|
{monthLabel} so far <span className="font-semibold text-white tabular-nums">{ZAR2(monthTotal)}</span>
|
|
</div>
|
|
)}
|
|
<div className="mt-1 text-xs text-white/80">All vehicles and income groups</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-gray-100 bg-white px-5 py-3 shadow-sm dark:border-border dark:bg-surface">
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">Without a site allocation</div>
|
|
<div className="break-words text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(totals.unallocated)}</div>
|
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{ZAR2(totals.allocated)} allocated to sites</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="mb-5 flex min-w-0 flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
|
<span className="rounded-md bg-[#00798C]/10 px-2.5 py-1 font-medium text-[#00798C]">{rows.filter(row => row.kind === "site").length} sites</span>
|
|
{totals.vat > 0 && <span className="rounded-md bg-[#EDAE49]/15 px-2.5 py-1 font-medium text-[#8a6420] dark:text-[#EDAE49]">{ZAR2(totals.vat)} known invoice VAT</span>}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div role="group" aria-label="Site income view" className="flex items-center gap-1 rounded-lg border border-gray-200 bg-white p-0.5 dark:border-border dark:bg-white/5">
|
|
{([{ value: "cards", label: "Cards", icon: Sparkles }, { value: "grid", label: "Grid", icon: LayoutGrid }] as const).map(({ value, label, icon: Icon }) => <button key={value} type="button" onClick={() => setView(value)} aria-pressed={view === value} aria-label={`${label} view`} className={cn("flex min-h-[44px] min-w-[44px] items-center gap-1.5 rounded-md px-3 text-xs font-medium transition", view === value ? "bg-[#003D5B] text-white dark:bg-[#00798C]" : "text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white")}><Icon className="h-3.5 w-3.5" />{label}</button>)}
|
|
</div>
|
|
<div className="hidden items-center gap-1 lg:flex" role="group" aria-label="Sort site income">
|
|
<span className="mr-1 text-xs text-gray-400">Sort:</span>
|
|
{(["income", "name"] as const).map(value => <button type="button" key={value} onClick={() => setSort(value)} aria-pressed={sort === value} className={cn("min-h-[44px] min-w-[44px] rounded px-3 text-xs font-medium", sort === value ? "bg-[#EDAE49] text-[#003D5B]" : "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/5")}>{value === "income" ? "Income" : "Name"}</button>)}
|
|
</div>
|
|
<div className="lg:hidden"><label className="sr-only" htmlFor={`${id}-sort`}>Sort site income</label><select id={`${id}-sort`} value={sort} onChange={event => setSort(event.target.value as typeof sort)} className="min-h-[44px] rounded-lg border border-gray-200 bg-white px-3 text-xs dark:border-white/10 dark:bg-surface"><option value="income">Sort by Income</option><option value="name">Sort by Name</option></select></div>
|
|
</div>
|
|
</div>
|
|
<p className="mb-4 text-xs leading-relaxed text-gray-500 dark:text-gray-400">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.</p>
|
|
{rows.length === 0 ? <p className="rounded-xl border border-gray-100 bg-white p-6 text-sm text-gray-500 dark:border-border dark:bg-surface dark:text-gray-400">No posted income recorded for {year}.</p> : view === "cards" ? (
|
|
<div className="space-y-2">{sorted.map(row => {
|
|
const open = row.id === expanded;
|
|
const panelId = `${id}-${row.id}`;
|
|
return <div key={row.id} className="min-w-0 overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm transition hover:shadow-md dark:border-border dark:bg-surface">
|
|
<button type="button" onClick={() => toggle(row)} aria-expanded={open} aria-controls={panelId} className="w-full min-w-0 p-4 text-left">
|
|
<div className="flex min-h-[44px] min-w-0 items-center gap-3 sm:gap-4">
|
|
<MapPin className="h-4 w-4 shrink-0 text-[#00798C]" />
|
|
<div className="min-w-0 flex-1"><div className="break-words text-sm font-medium text-[#003D5B] dark:text-white">{row.name}</div><div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{kindLabels[row.kind]}</div></div>
|
|
{/* The sparkline yields its width to the month figure below xl — the same
|
|
trade the vehicle cards make, so the two screens read alike. */}
|
|
<div className="hidden shrink-0 xl:block"><SiteSparkline data={row.months} color="#00798C" /></div>
|
|
{/* The money: the year figure and the live month, side by side. */}
|
|
<div className="hidden shrink-0 items-start gap-4 md:flex">
|
|
<div className="w-28 text-right"><div className="text-xs uppercase tracking-wider text-gray-400">Posted income</div><div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(row.income)}</div></div>
|
|
{monthLabel && <div className="w-28 text-right"><div className="whitespace-nowrap text-xs uppercase tracking-wider text-gray-400">{monthLabel} so far</div><div className="text-sm font-semibold tabular-nums text-[#00798C] dark:text-[#EDAE49]">{ZAR2(row.months[monthIndex] ?? 0)}</div></div>}
|
|
</div>
|
|
<ChevronRight className={cn("h-4 w-4 shrink-0 text-gray-400 transition-transform", open && "rotate-90")} />
|
|
</div>
|
|
<div className="mt-2 flex min-w-0 items-center justify-between gap-3 md:hidden"><SiteSparkline data={row.months} color="#00798C" width={72} height={28} /><div className="min-w-0 text-right"><div className="text-xs uppercase tracking-wider text-gray-400">Posted income</div><div className="break-words text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(row.income)}</div></div></div>
|
|
{/* The live month, on its own line below md — the same rule as the vehicle cards. */}
|
|
{monthLabel && (
|
|
<div className="mt-2 flex min-w-0 items-center justify-between gap-3 md:hidden">
|
|
<span className="text-xs uppercase tracking-wider text-gray-400">{monthLabel} so far</span>
|
|
<span className="text-sm font-semibold tabular-nums text-[#00798C] dark:text-[#EDAE49]">{ZAR2(row.months[monthIndex] ?? 0)}</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
{open && <div id={panelId}><SiteIncomeDetails row={row} year={year} monthIndex={monthIndex} /></div>}
|
|
</div>;
|
|
})}</div>
|
|
) : (
|
|
<>
|
|
<div className="relative min-w-0 overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm dark:border-border dark:bg-surface">
|
|
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
|
|
<table className="w-full min-w-[1250px] text-sm">
|
|
<thead><tr className="border-b border-gray-100 bg-gray-50 text-xs uppercase tracking-wider text-gray-500 dark:border-border dark:bg-surface-muted dark:text-gray-400"><th className="sticky left-0 z-10 min-w-[170px] max-w-[220px] bg-gray-50 px-4 py-3 text-left font-medium dark:bg-surface-muted">Site / income group</th>{MONTH_NAMES.map((month, index) => <th key={month} className={cn("min-w-[88px] px-2 py-3 text-right font-medium", index === monthIndex && "text-[#00798C] dark:text-[#EDAE49]")}>{month}</th>)}<th className="px-4 py-3 text-right font-medium">Posted income</th></tr></thead>
|
|
<tbody>{sorted.map(row => <tr key={row.id} className="border-b border-gray-50 dark:border-border"><td className="sticky left-0 z-10 max-w-[220px] bg-white px-4 py-2 dark:bg-surface"><button type="button" onClick={() => toggle(row)} aria-expanded={expanded === row.id} aria-controls={`${id}-grid-detail`} className="flex min-h-[44px] w-full items-center gap-2 text-left text-sm font-medium text-[#003D5B] dark:text-white"><ChevronRight className={cn("h-4 w-4 shrink-0 text-gray-400", expanded === row.id && "rotate-90")} /><span className="break-words">{row.name}</span></button></td>{row.months.map((value, index) => <td key={index} className={cn("whitespace-nowrap px-2 py-3 text-right tabular-nums", value == null ? "text-gray-400 dark:text-gray-500" : "text-gray-700 dark:text-gray-300")}>{monthCellText(value, index, monthIndex)}</td>)}<td className="whitespace-nowrap px-4 py-3 text-right font-semibold tabular-nums text-[#00798C]">{ZAR2(row.income)}</td></tr>)}</tbody>
|
|
<tfoot><tr className="border-t-2 border-gray-200 bg-gray-50 font-semibold dark:border-white/15 dark:bg-surface-muted"><td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-[#003D5B] dark:bg-surface-muted dark:text-white">All posted income</td>{totals.months.map((value, index) => <td key={index} className="whitespace-nowrap px-2 py-3 text-right tabular-nums text-gray-600 dark:text-gray-300">{monthCellText(value, index, monthIndex)}</td>)}<td className="whitespace-nowrap px-4 py-3 text-right tabular-nums text-[#003D5B] dark:text-white">{ZAR2(totals.income)}</td></tr></tfoot>
|
|
</table>
|
|
</div><ScrollFade />
|
|
</div>
|
|
{selected && <div id={`${id}-grid-detail`} className="mt-3 overflow-hidden rounded-xl border border-gray-100 bg-white dark:border-border dark:bg-surface"><h2 className="px-4 pt-4 text-sm font-semibold text-[#003D5B] dark:text-white">{selected.name}</h2><SiteIncomeDetails row={selected} year={year} monthIndex={monthIndex} /></div>}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 <div className="min-w-0 border-t border-gray-100 bg-gray-50/50 px-4 py-4 dark:border-border dark:bg-white/[0.04]">
|
|
<p className="mb-4 text-xs leading-relaxed text-gray-500 dark:text-gray-400">{descriptions[row.kind]}</p>
|
|
<div className="grid grid-cols-3 gap-2 text-xs md:grid-cols-6 lg:grid-cols-12">{row.months.map((value, index) => <div key={index} className="min-w-0 text-center"><div className={`mb-1 text-xs ${index === monthIndex ? "font-semibold text-[#00798C] dark:text-[#EDAE49]" : "text-gray-400"}`}>{MONTH_NAMES[index]}</div><div className={cn("break-words rounded py-1.5 tabular-nums", value == null ? "text-gray-400 dark:text-gray-500" : "bg-[#00798C]/5 text-[#00798C] dark:bg-[#00798C]/15")}>{monthCellText(value, index, monthIndex)}</div></div>)}</div>
|
|
<div className="mt-4 grid grid-cols-1 gap-3 border-t border-gray-100 pt-3 text-xs dark:border-border sm:grid-cols-3"><div><div className="text-gray-400">Posted income</div><div className="font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(row.income)}</div></div><div><div className="text-gray-400">Known invoice VAT included</div><div className="font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(row.invoiceVat)}</div></div><div><div className="text-gray-400">Vehicles contributing</div><div className="font-semibold text-[#003D5B] dark:text-white">{vehicleCount}</div></div></div>
|
|
{row.siteId && <Link href={`/sites/${row.siteId}`} className="mt-3 inline-flex min-h-[44px] items-center gap-1.5 text-sm font-medium text-[#30638E] dark:text-gray-300">View site details <ChevronRight className="h-4 w-4" /></Link>}
|
|
<h3 className="mb-3 mt-4 text-sm font-semibold text-[#003D5B] dark:text-white">Posted contributions · {year}</h3>
|
|
<div className="max-h-[480px] space-y-4 overflow-y-auto">
|
|
{byMonth.map(group => <section key={group.month}><h4 className="mb-2 text-xs font-semibold text-gray-500 dark:text-gray-400">{group.name} {year}</h4><ul className="divide-y divide-gray-100 dark:divide-white/10">{group.entries.map((entry, index) => <Contribution key={`${entry.id}-${index}`} entry={entry} />)}</ul></section>)}
|
|
{byMonth.length === 0 && <p className="text-xs text-gray-500 dark:text-gray-400">No contribution details recorded.</p>}
|
|
</div>
|
|
</div>;
|
|
}
|
|
|
|
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 <li className="flex min-w-0 flex-col gap-1 py-2.5 text-xs sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
|
<div className="min-w-0"><p className="break-words font-medium text-[#003D5B] dark:text-white">{entry.code || "Unknown vehicle"}{entry.name ? ` · ${entry.name}` : ""}</p><p className="mt-0.5 text-gray-500 dark:text-gray-400">{source}</p>{entry.invoiceId && <Link href={register} className="inline-flex min-h-[44px] items-center text-sm font-medium text-[#30638E] dark:text-gray-300">{entry.invoiceNo || "View invoice register"}</Link>}</div>
|
|
<div className="shrink-0 font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(entry.amount)}</div>
|
|
</li>;
|
|
}
|
|
|
|
// 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 <svg aria-hidden="true" width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="shrink-0">
|
|
{groups.map((points, index) => {
|
|
const path = points.map((point, i) => `${i ? "L" : "M"}${point.x},${point.y}`).join(" ");
|
|
return <g key={index}><path d={`${path} L${points.at(-1)!.x},${y(0)} L${points[0].x},${y(0)} Z`} fill={color} fillOpacity="0.12" /><path d={path} fill="none" stroke={color} strokeWidth="1.5" />{points.length === 1 && <circle cx={points[0].x} cy={points[0].y} r="2" fill={color} />}</g>;
|
|
})}
|
|
</svg>;
|
|
}
|