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.
120 lines
6.5 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|