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.
278 lines
No EOL
14 KiB
TypeScript
278 lines
No EOL
14 KiB
TypeScript
import {hasInvoiceTarget} from "@/lib/invoice-register";
|
|
import type { Asset, BookedLine, Invoice } from "@/lib/data";
|
|
import { ZAR2 } from "@/lib/utils";
|
|
import { AlertTriangle, CheckCircle2 } from "lucide-react";
|
|
import { MachineCombobox } from "@/components/MachineCombobox";
|
|
import { ScrollFade } from "@/components/ScrollFade";
|
|
import { SiteBookingSelect, SiteBookingLegend } from "@/components/SiteBookingSelect";
|
|
import { InvoiceUploader, AttachedDocument } from "@/components/InvoiceUploader";
|
|
|
|
export function InvoiceSummary({
|
|
invoice,
|
|
lines,
|
|
assets,
|
|
embedded,
|
|
}: {
|
|
invoice: Invoice;
|
|
lines: BookedLine[];
|
|
assets: Asset[];
|
|
// Inside the expandable invoice row the surrounding card already shows the number and
|
|
// site, so the header block is dropped to avoid saying the same thing twice.
|
|
embedded?: boolean;
|
|
}) {
|
|
const unbooked = lines.filter((l) => !hasInvoiceTarget(l));
|
|
const siteOnly = lines.filter(l=>!l.isBooked && l.booked_site);
|
|
// Two different numbers, both worth showing.
|
|
// ex-VAT = what actually lands in the P&L as income for the machine.
|
|
// incl-VAT= what the debtor actually pays (ex-VAT + SARS VAT).
|
|
// Booking is done ex-VAT: VAT is collected for SARS, never income.
|
|
const period = `${invoice.period_from.slice(0, 10)} → ${invoice.period_to.slice(0, 10)}`;
|
|
const periodMonth = new Date(invoice.period_to).toLocaleDateString("en-ZA", {
|
|
month: "long",
|
|
year: "numeric",
|
|
timeZone: "UTC",
|
|
});
|
|
|
|
return (
|
|
<div
|
|
className={
|
|
embedded
|
|
? "bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl shadow-sm overflow-hidden"
|
|
: "bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-2xl shadow-sm overflow-hidden mb-6"
|
|
}
|
|
>
|
|
{!embedded && (
|
|
<div className="p-5 border-b border-gray-100 dark:border-border">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-mono text-sm font-semibold px-2.5 py-1 rounded-md bg-[#00798C]/10 text-[#00798C]">
|
|
{invoice.invoice_no}
|
|
</span>
|
|
{/* Site is transcribed from the invoice's own description. There is no
|
|
sites collection yet — this is a plain label until one exists. */}
|
|
{invoice.site && (
|
|
<span className="text-sm px-2.5 py-1 rounded-md bg-[#EDAE49]/15 text-[#8a6420] dark:text-[#EDAE49]">
|
|
{invoice.site}
|
|
</span>
|
|
)}
|
|
<span className="text-sm font-medium text-[#003D5B] dark:text-white">{invoice.debtor}</span>
|
|
</div>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">{invoice.description}</p>
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
<span>Order {invoice.order_no || "—"}</span>
|
|
<span>Debtor {invoice.debtor_no || "—"}</span>
|
|
<span>{period}</span>
|
|
<span>{invoice.currency || "ZAR"}</span>
|
|
</div>
|
|
</div>
|
|
{/* The headline is the VAT-inclusive invoice total — what the debtor pays. */}
|
|
<div className="text-right shrink-0">
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">Invoice total (incl VAT)</div>
|
|
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
|
{ZAR2(invoice.total_incl)}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400 tabular-nums">
|
|
{ZAR2(invoice.total_excl)} + {ZAR2(invoice.vat)} VAT
|
|
{invoice.vat_pct ? ` @ ${invoice.vat_pct}%` : ""}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{embedded && (
|
|
<div className="px-4 py-3 border-b border-gray-100 dark:border-border flex flex-wrap items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-[#003D5B] dark:text-white">
|
|
{invoice.debtor} · {invoice.description}
|
|
</div>
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
<span>Order {invoice.order_no || "—"}</span>
|
|
<span>Debtor {invoice.debtor_no || "—"}</span>
|
|
<span>{period}</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">Invoice total (incl VAT)</div>
|
|
<div className="text-xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
|
{ZAR2(invoice.total_incl)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Booking status. An unassigned line is never guessed at — it stays visible
|
|
and the shortfall is stated, rather than being charged to a wrong machine. */}
|
|
{unbooked.length > 0 ? (
|
|
<div className="flex items-start gap-3 px-5 py-3 bg-[#D1495B]/5 border-b border-[#D1495B]/15">
|
|
<AlertTriangle className="w-4 h-4 text-[#D1495B] shrink-0 mt-0.5" />
|
|
<div className="text-sm text-[#D1495B] dark:text-brick-ink">
|
|
<p>
|
|
{unbooked.length} line{unbooked.length > 1 ? "s" : ""} not yet booked to a machine — choose a
|
|
plant item below.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-start gap-3 px-5 py-3 bg-[#00798C]/5 border-b border-[#00798C]/15">
|
|
<CheckCircle2 className="w-4 h-4 text-[#00798C] shrink-0 mt-0.5" />
|
|
<div className="text-sm text-[#00798C]">
|
|
<p>{lines.filter(l=>l.isBooked).length} lines booked to {periodMonth} machine income.{siteOnly.length>0 && ` ${siteOnly.length} allocated to a site only; excluded from machine income.`}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="px-5 py-3 border-b border-gray-100 dark:border-white/5">
|
|
<SiteBookingLegend />
|
|
</div>
|
|
|
|
<InvoiceTable lines={lines} assets={assets} />
|
|
|
|
<div className="p-5 border-t border-gray-100 dark:border-border bg-gray-50/50 dark:bg-white/[0.04]">
|
|
<div className="flex flex-col lg:flex-row lg:items-start justify-between gap-6">
|
|
<div className="lg:max-w-sm w-full">
|
|
<div className="text-sm font-medium text-[#003D5B] dark:text-white mb-2">
|
|
Source document
|
|
</div>
|
|
{invoice.source_pdf ? (
|
|
<AttachedDocument invoiceNo={invoice.invoice_no} invoiceId={invoice.id} />
|
|
) : (
|
|
<InvoiceUploader invoiceNo={invoice.invoice_no} />
|
|
)}
|
|
</div>
|
|
|
|
<dl className="w-full lg:w-80 shrink-0 space-y-2">
|
|
<TotalRow label="Hire (excl VAT)" value={invoice.total_excl} />
|
|
{invoice.escalation > 0 && <TotalRow label="Escalation" value={invoice.escalation} />}
|
|
<TotalRow
|
|
label={`VAT${invoice.vat_pct ? ` @ ${invoice.vat_pct}%` : ""}`}
|
|
value={invoice.vat}
|
|
/>
|
|
<div className="border-t border-gray-200 dark:border-white/10 pt-2">
|
|
<TotalRow label="Invoice total (incl VAT)" value={invoice.total_incl} strong />
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
function TotalRow({ label, value, strong }: { label: string; value: number; strong?: boolean }) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-4">
|
|
<dt className="text-sm text-gray-500 dark:text-gray-400">{label}</dt>
|
|
<dd
|
|
className={`text-sm tabular-nums ${
|
|
strong ? "font-semibold text-[#003D5B] dark:text-white" : "text-gray-700 dark:text-gray-300"
|
|
}`}
|
|
>
|
|
{ZAR2(value || 0)}
|
|
</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: Asset[] }) {
|
|
const machines = assets
|
|
.filter((a) => a.is_active !== false)
|
|
.map((a) => ({ id: a.id, code: a.code || "", name: a.name, category: a.category || "Other" }));
|
|
|
|
return (
|
|
// Rule 1 + 4: the table scrolls inside its own container, so the page itself never
|
|
// scrolls sideways at 375px. `-webkit-overflow-scrolling` is not a Tailwind utility —
|
|
// written as a class it is inert — so it is set inline to actually take effect.
|
|
<div className="group relative">
|
|
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
|
|
<table className="w-full text-sm min-w-[900px]">
|
|
<thead>
|
|
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-border bg-gray-50/50 dark:bg-white/[0.04]">
|
|
<th className="text-left py-3 px-5 font-medium sticky left-0 z-10 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur">
|
|
Machine
|
|
</th>
|
|
<th className="text-left py-3 px-3 font-medium">Description</th>
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Qty</th>
|
|
<th className="text-left py-3 px-3 font-medium">Unit</th>
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Rate</th>
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Hire ex VAT</th>
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">VAT</th>
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Total incl</th>
|
|
<th className="text-left py-3 px-3 font-medium whitespace-nowrap min-w-[240px]">
|
|
Booked to
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{lines.map((l) => (
|
|
<tr
|
|
key={l.id}
|
|
className={`border-b border-gray-50 dark:border-border ${
|
|
// SOLID, not an alpha tint — see the note in InvoiceRow. A translucent
|
|
// sticky cell lets the columns it covers show through it on scroll.
|
|
hasInvoiceTarget(l) ? "hover:bg-gray-50/40 dark:hover:bg-white/[0.02]" : "bg-[#FDF3F4] dark:bg-[#292934]"
|
|
}`}
|
|
>
|
|
{/* Sticky first column — keeps the machine identifiable while scrolling.
|
|
It needs its own opaque background, so it has to repeat the row's
|
|
unbooked tint; otherwise the flag disappears under the white. */}
|
|
<td
|
|
className={`py-3 px-5 sticky left-0 z-10 ${
|
|
hasInvoiceTarget(l) ? "bg-white dark:bg-surface" : "bg-[#FDF3F4] dark:bg-[#292934]"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2 whitespace-nowrap">
|
|
<span className="text-xs text-gray-400 dark:text-text-soft tabular-nums">{l.line_no}</span>
|
|
<span className="font-mono text-xs font-semibold text-[#00798C]">{l.machine_code}</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">{l.description}</td>
|
|
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{l.qty}</td>
|
|
<td className="py-3 px-3 text-gray-500 dark:text-gray-400">{l.unit}</td>
|
|
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{ZAR2(l.rate)}</td>
|
|
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{ZAR2(l.amount_excl)}</td>
|
|
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{ZAR2(l.vat)}</td>
|
|
<td className="py-3 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
|
|
{ZAR2(l.total_incl)}
|
|
</td>
|
|
<td className="py-3 px-3 min-w-[240px]">
|
|
{/* Stacked, not side by side. At 768px the Booked to column is only
|
|
~166px, so two 200px pickers could never fit across — as flex
|
|
children they were compressed into each other by ~12px, and once
|
|
forced not to shrink they spilled PAST the edge of the table. The
|
|
column now declares a min-width, the controls stack inside it, and
|
|
the table scrolls inside its own box instead of overflowing. */}
|
|
<div className="flex flex-col gap-2">
|
|
<div className="min-w-[220px]">
|
|
<MachineCombobox
|
|
lineId={l.id}
|
|
value={l.booked_asset || ""}
|
|
machines={machines}
|
|
lineCode={l.machine_code}
|
|
lineDescription={l.description}
|
|
/>
|
|
</div>
|
|
{(!l.isBooked || l.booked_site) && (
|
|
<SiteBookingSelect lineId={l.id} value={l.booked_site || ""} />
|
|
)}
|
|
</div>
|
|
{/* Both pickers save the moment you choose — there is no Save button to
|
|
miss, which is exactly why an unmatched row read as "nothing happens".
|
|
Say so on the row rather than leaving it to be discovered. */}
|
|
{!hasInvoiceTarget(l) && (
|
|
<p className="mt-1.5 text-xs text-[#D1495B]">
|
|
Pick a machine above to save it. No match in the register? Book it to the
|
|
site instead — it posts no income, because no machine carries it.
|
|
</p>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<ScrollFade />
|
|
</div>
|
|
);
|
|
} |