The field is `assets.is_active`. All 7 inactive machines have no income at all — the three generators, two porta camps, AEB Plant Versekering and the Diesel Truck — so on the income list they were a flat row of zeros and nothing else, reading as "idle" when they are actually out of the fleet. They are now hidden by default. The rows are not dropped from the data: the filter lives in the view, so Active / All / Inactive is a client-side toggle with no refetch, and each shows its count so a filter that silently hides rows is obvious. A machine hidden by the filter also collapses if it was expanded. The YTD income and profit tiles moved into the view so they sum only what is visible — otherwise the headline would report the whole fleet while the list below it showed a subset. /charts is unchanged: it passes no filter and still sees everything. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
146 lines
5.2 KiB
TypeScript
146 lines
5.2 KiB
TypeScript
import type { Asset, InvoiceLine, MonthlyIncome, MonthlyRunningCost } from "./data";
|
|
import { computeProfit, type ProfitOptions } from "./profit";
|
|
|
|
export type IncomeRow = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
months: (number | null)[]; // Part A.2: null = no data (forecast), 0 = earned zero
|
|
fixedMonths: (number | null)[];
|
|
runningMonths: (number | null)[];
|
|
profit: number[];
|
|
income: number;
|
|
fixed: number;
|
|
running: number;
|
|
ytdProfit: number;
|
|
lastActualMonth: number; // Part A.3: clip x-axis
|
|
// Machines the client has stood down. They still hold their history, so they are kept
|
|
// in the data and filtered in the view rather than dropped here — /income defaults to
|
|
// active only, but the client can switch to All or Inactive.
|
|
isActive: boolean;
|
|
// Portion of `income` that came from invoices, and how much of that is VAT.
|
|
// Booked amounts are VAT-inclusive (see lib/invoice-booking-policy.json), so without
|
|
// this the per-machine figure is not explainable.
|
|
invoiceIncome: number;
|
|
invoiceVat: number;
|
|
// Every invoice that contributed to this machine, so the card can show the combined
|
|
// total and break it down by invoice and site without another query.
|
|
};
|
|
|
|
// Per Part A.2: months beyond lastActualMonth are null in `months`/`fixedMonths`/`runningMonths`.
|
|
// `running` is derived from monthly_running_costs; null means we have no running data for that month.
|
|
export function buildIncomeRows(
|
|
assets: Asset[],
|
|
income: MonthlyIncome[],
|
|
running?: MonthlyRunningCost[],
|
|
profitOpts: ProfitOptions = {},
|
|
// Invoice line items, used only to attribute the VAT already inside each booked
|
|
// amount back to the machine that earned it.
|
|
invoiceLines?: InvoiceLine[],
|
|
): IncomeRow[] {
|
|
// Determine the last actual month globally (Part A.2/A.3)
|
|
const lastActualMonth = income.reduce((m, r) => r.income > 0 && r.month > m ? r.month : m, 0);
|
|
|
|
const byAsset = new Map<string, IncomeRow>();
|
|
for (const a of assets) {
|
|
if (!a.code) continue;
|
|
byAsset.set(a.id, {
|
|
id: a.id, code: a.code, name: a.name,
|
|
isActive: a.is_active !== false,
|
|
months: Array(12).fill(null),
|
|
fixedMonths: Array(12).fill(null),
|
|
runningMonths: Array(12).fill(null),
|
|
profit: Array(12).fill(0),
|
|
income: 0, fixed: 0, running: 0, ytdProfit: 0,
|
|
lastActualMonth,
|
|
invoiceIncome: 0, invoiceVat: 0,
|
|
});
|
|
}
|
|
for (const r of income) {
|
|
const row = byAsset.get(r.asset);
|
|
if (!row) continue;
|
|
const i = r.month - 1;
|
|
// Part A.2: only null out months BEYOND the last actual month. Months up to and including
|
|
// lastActualMonth get their real value (which may be 0 — a real plotted zero).
|
|
//
|
|
// ACCUMULATE, do not assign: a machine can have several income rows in one month —
|
|
// a workbook row plus one per invoice line booked to it. Assigning made the cell show
|
|
// only the last row, so SS01 (two September invoices) displayed R20,700 while its own
|
|
// YTD total included both (R35,305), and the September column total came out short.
|
|
if (r.month <= lastActualMonth) {
|
|
row.months[i] = (row.months[i] ?? 0) + r.income;
|
|
row.fixedMonths[i] = (row.fixedMonths[i] ?? 0) + r.fixed_expenses;
|
|
}
|
|
row.income += r.income;
|
|
row.fixed += r.fixed_expenses;
|
|
}
|
|
// Per Part 1.5: running cost is derived from monthly_running_costs, not stored on income.
|
|
for (const c of running ?? []) {
|
|
const row = byAsset.get(c.asset);
|
|
if (!row) continue;
|
|
row.running += c.amount;
|
|
if (c.month <= lastActualMonth) {
|
|
row.runningMonths[c.month - 1] = c.amount;
|
|
}
|
|
}
|
|
|
|
// Per-month profit is computed ONCE, after income and running have both been
|
|
// accumulated, so it reflects the whole month rather than whichever row happened to be
|
|
// read last. Previously this lived inside the two loops above and inherited the same
|
|
// last-write-wins bug.
|
|
for (const row of byAsset.values()) {
|
|
for (let i = 0; i < 12; i++) {
|
|
if (i + 1 > lastActualMonth) continue;
|
|
row.profit[i] = computeProfit(
|
|
{
|
|
income: row.months[i] ?? 0,
|
|
fixed: row.fixedMonths[i] ?? 0,
|
|
running: row.runningMonths[i] ?? 0,
|
|
},
|
|
profitOpts,
|
|
);
|
|
}
|
|
}
|
|
// Attribute invoice-derived income and its embedded VAT per machine.
|
|
for (const l of invoiceLines ?? []) {
|
|
const row = byAsset.get(l.booked_asset);
|
|
if (!row) continue;
|
|
row.invoiceIncome += l.total_incl || 0;
|
|
row.invoiceVat += l.vat || 0;
|
|
}
|
|
|
|
for (const row of byAsset.values()) {
|
|
row.ytdProfit = computeProfit({ income: row.income, fixed: row.fixed, running: row.running }, profitOpts);
|
|
}
|
|
return Array.from(byAsset.values());
|
|
}
|
|
|
|
export type RunningRow = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
months: number[];
|
|
ytd: number;
|
|
};
|
|
|
|
export function buildRunningRows(
|
|
assets: Asset[],
|
|
costs: MonthlyRunningCost[]
|
|
): RunningRow[] {
|
|
const byAsset = new Map<string, RunningRow>();
|
|
for (const a of assets) {
|
|
if (!a.code) continue;
|
|
byAsset.set(a.id, {
|
|
id: a.id, code: a.code, name: a.name,
|
|
months: Array(12).fill(0),
|
|
ytd: 0,
|
|
});
|
|
}
|
|
for (const c of costs) {
|
|
const row = byAsset.get(c.asset);
|
|
if (!row) continue;
|
|
row.months[c.month - 1] = c.amount;
|
|
row.ytd += c.amount;
|
|
}
|
|
return Array.from(byAsset.values());
|
|
}
|