mega-plant/components/SitesOverview.tsx
Dave 7b3ec2fbd8 feat(income): posted income per site, and the LIVE month on the overview
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.
2026-11-05 00:19:06 +02:00

120 lines
6.5 KiB
TypeScript

"use client";
import { useState } from "react";
import { AlertTriangle, MapPin, MapPinned } from "lucide-react";
import { SiteIncomeView } from "@/components/SiteIncomeView";
import { SitesView } from "@/components/SitesView";
import type { SiteIncomeRow } from "@/lib/site-income";
import type { SiteSummary } from "@/lib/sites";
import { ZAR2 } from "@/lib/utils";
export function SitesOverview({ postedRows, billedRows, year, invoiceCount, monthIndex }: {
postedRows: SiteIncomeRow[];
billedRows: SiteSummary[];
year: number;
invoiceCount: number;
monthIndex: number;
}) {
const [view, setView] = useState<"posted" | "billed">("posted");
const totalIncl = billedRows.reduce((sum, row) => sum + row.revenueIncl, 0);
const totalExcl = billedRows.reduce((sum, row) => sum + row.revenueExcl, 0);
const totalVat = billedRows.reduce((sum, row) => sum + row.vat, 0);
const outstanding = billedRows.reduce((sum, row) => sum + row.unbookedCount, 0);
const unpinned = billedRows.filter((row) => !row.pinned).length;
return (
<>
<div className="mb-6 flex min-w-0 flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-[#EDAE49]/20 to-[#00798C]/15 text-[#00798C]">
<MapPin className="h-5 w-5" aria-hidden="true" />
</div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Sites</h1>
</div>
<div role="group" aria-label="Site amounts" className="grid w-full grid-cols-2 gap-1 rounded-xl border border-gray-200 bg-white p-1 dark:border-border dark:bg-surface lg:w-auto">
{([
["posted", "Posted income"],
["billed", "Billed invoices"],
] as const).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setView(value)}
aria-pressed={view === value}
aria-controls="sites-amounts-panel"
className={`min-h-[44px] min-w-[44px] rounded-lg px-3 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#00798C] ${view === value
? "bg-[#003D5B] text-white dark:bg-[#00798C]"
: "text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"}`}
>
{label}
</button>
))}
</div>
</div>
<section id="sites-amounts-panel" aria-label={view === "posted" ? "Posted site income" : "Internal billed invoices"}>
{view === "posted" ? (
<SiteIncomeView
rows={postedRows}
year={year}
monthIndex={monthIndex}
title={
<div className="min-w-0">
<h2 className="text-xl font-semibold text-[#003D5B] dark:text-white">Posted income · {year}</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Income attributed to the historical site on each invoice. External income and unallocated workbook income remain included.
</p>
</div>
}
/>
) : (
<>
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<h2 className="text-xl font-semibold text-[#003D5B] dark:text-white">Internal billed invoices · {year}</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
What each site was billed per month from {invoiceCount} imported internal invoice{invoiceCount === 1 ? "" : "s"}.
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Billing includes unbooked lines. External billing is outside this view.</p>
</div>
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 lg:shrink-0">
<div className="min-w-0 rounded-2xl border border-gray-100 bg-white px-5 py-3 dark:border-white/5 dark:bg-surface">
<div className="text-xs text-gray-500 dark:text-gray-400">Billed (ex VAT)</div>
<div className="break-words text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(totalExcl)}</div>
<div className="text-xs tabular-nums text-gray-500 dark:text-gray-400">+ {ZAR2(totalVat)} VAT</div>
</div>
<div className="min-w-0 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">Billed (incl VAT)</div>
<div className="break-words text-2xl font-semibold tabular-nums">{ZAR2(totalIncl)}</div>
</div>
</div>
</div>
{unpinned > 0 && (
<div className="mb-5 flex items-start gap-3 rounded-xl border border-[#EDAE49]/20 bg-[#EDAE49]/10 px-4 py-3">
<MapPinned className="mt-0.5 h-4 w-4 shrink-0 text-[#8a6420] dark:text-[#EDAE49]" aria-hidden="true" />
<p className="text-sm text-[#8a6420] dark:text-[#EDAE49]">
{unpinned} of {billedRows.length} billed sites have no coordinates yet. Open a site to pin it.
</p>
</div>
)}
{outstanding > 0 && (
<div className="mb-5 flex items-start gap-3 rounded-xl border border-[#D1495B]/15 bg-[#D1495B]/5 px-4 py-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-[#D1495B]" aria-hidden="true" />
<p className="text-sm text-[#D1495B]">
{outstanding} line{outstanding === 1 ? "" : "s"} across these sites still have no machine or booked site. Open an invoice and assign one. Their billed amounts are included above.
</p>
</div>
)}
{billedRows.length ? <SitesView rows={billedRows} year={year} /> : (
<div className="rounded-2xl border border-gray-100 bg-white p-8 text-center dark:border-white/5 dark:bg-surface">
<p className="text-sm text-gray-500 dark:text-gray-400">No internal site billing recorded for {year}.</p>
<p className="mt-1 text-sm text-gray-400 dark:text-gray-500">Sites appear here as internal invoices are imported.</p>
</div>
)}
</>
)}
</section>
</>
);
}