The client asked for two things on the running-cost pages: drop the two prose notes, and show the year-to-date figure next to the total for the current month. - /running loses the "Shown to Jan, Feb..." and "A re-cut of costs..." paragraphs. The tiles carry the year and the month instead. - /running shows "Nov so far" beside "YTD running cost", and the "made of" line is printed for the month as well as for the year, so the month's total is explained on the page and not only in the grid. - The cards follow the income cards: YTD spend | Nov so far side by side from md, the month on its own line below md, and the sparkline yields its width below xl so the vehicle NAME is never truncated to pay for a decorative trend. Below sm the name wraps instead of being cut short. - /running/insurance shows the premium in force this month, and its year-to-date figure now stops at the months that have happened. December is a rate on file, not money spent, so including it made this page a full year while the Overview showed eleven months - the same cost kind reading as two different numbers on two screens. - /running/financing shows "Committed for Nov" beside a new year-to-date instalment figure. One rule, three screens. elapsedMonths() in lib/income-month.ts counts the months that have happened and is the same clock the income pages use, and financeByAsset() / fleetFinanceByMonth() in lib/running-costs.ts are the only finance rule the Overview and the financing register both read. The Overview's insurance and finance year-to-date figures now equal the insurance and financing pages' figures exactly. lib/running-month.test.ts asserts it, including the insurance cross-check between the register and the Overview. A settled agreement still contributes nothing to any month - the instalments TLB06 was paying in January-October are not in the Overview's finance figure. The client confirmed the financing is correct as it stands, so that is left alone and recorded in AGENTS.md rather than quietly changed.
391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
import type { Asset, FinancingTerm, InsuranceRate, InvoiceLine, MonthlyIncome, MonthlyRunningCost, Operator } from "./data";
|
|
import { computeProfit, type ProfitOptions } from "./profit";
|
|
import { currentRate, monthlyCost, validatePeriods, type RatePeriod } from "./insurance";
|
|
import { financeCalc, toInput, type FinanceCalc } from "./financing";
|
|
import { exclFromIncl } from "./purchase-booking";
|
|
import { COST_KINDS, type MachineCosts } from "./running-costs";
|
|
|
|
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;
|
|
/** Per month, VAT-INCLUSIVE: the workbook figure plus any purchased parts. */
|
|
months: number[];
|
|
ytd: number;
|
|
/** Per month, net of VAT at the confirmed rate. Shown under `months`, never instead of it. */
|
|
monthsExcl: number[];
|
|
ytdExcl: number;
|
|
/** The purchased portion of each month, so the Overview can say what came from where. */
|
|
purchasesIncl: number[];
|
|
/** False means NO cost of any kind is recorded for that month — which is different
|
|
* from a month that genuinely costs R0. */
|
|
knownMonths: boolean[];
|
|
byKind: { repairs: number[]; purchases: number[]; insurance: number[]; finance: number[] };
|
|
/** The same four kinds summed for the year, so a card can show WHAT its spend is made
|
|
* of. The parts are the four kinds and nothing else, so they always add up to `ytd`. */
|
|
byKindYtd: { repairs: number; purchases: number; insurance: number; finance: number };
|
|
};
|
|
|
|
export function buildRunningRows(
|
|
assets: Asset[],
|
|
// All four cost kinds per machine per month — repairs, purchases, insurance and
|
|
// finance, assembled in lib/running-costs.ts. Everything a vehicle costs to run.
|
|
costs?: Map<string, MachineCosts>,
|
|
): 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,
|
|
// Net of VAT at the confirmed rate, kept alongside so the Overview can show
|
|
// incl above excl rather than making a reader back the VAT out.
|
|
monthsExcl: Array(12).fill(0),
|
|
ytdExcl: 0,
|
|
purchasesIncl: Array(12).fill(0),
|
|
knownMonths: Array(12).fill(false),
|
|
byKind: { repairs: Array(12).fill(0), purchases: Array(12).fill(0), insurance: Array(12).fill(0), finance: Array(12).fill(0) },
|
|
byKindYtd: { repairs: 0, purchases: 0, insurance: 0, finance: 0 },
|
|
});
|
|
}
|
|
if (!costs) return Array.from(byAsset.values());
|
|
|
|
for (const [assetId, kinds] of costs) {
|
|
const row = byAsset.get(assetId);
|
|
if (!row) continue;
|
|
for (let i = 0; i < 12; i++) {
|
|
let incl = 0;
|
|
for (const k of COST_KINDS) {
|
|
const v = kinds[k][i] ?? 0;
|
|
incl += v;
|
|
row.byKind[k][i] += v;
|
|
}
|
|
if (incl <= 0) continue;
|
|
row.months[i] += incl;
|
|
row.ytd += incl;
|
|
row.purchasesIncl[i] += kinds.purchases[i] ?? 0;
|
|
row.monthsExcl[i] += exclFromIncl(incl);
|
|
row.ytdExcl += exclFromIncl(incl);
|
|
row.knownMonths[i] = true;
|
|
}
|
|
}
|
|
// The four kinds summed for the year. These parts ARE the vehicle's spend — there is
|
|
// no fifth source — so they always add up to `ytd`, which is what the card shows.
|
|
for (const row of byAsset.values()) {
|
|
for (const k of COST_KINDS) row.byKindYtd[k] = row.byKind[k].reduce((s, v) => s + v, 0);
|
|
}
|
|
return Array.from(byAsset.values());
|
|
}
|
|
|
|
export type FinancingRow = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
plant: string;
|
|
/** Monthly instalment incl VAT, straight from the rate sheet. Not editable in the app. */
|
|
installment: number;
|
|
startYear: number;
|
|
startMonth: number;
|
|
endYear: number;
|
|
endMonth: number;
|
|
isSettled: boolean;
|
|
calc: FinanceCalc;
|
|
};
|
|
|
|
/**
|
|
* One row per finance agreement, with its settlement maths already resolved.
|
|
*
|
|
* A machine with no agreement is left OUT, as everywhere else here: it was not
|
|
* financed, which is a different statement from being financed at R0.
|
|
*/
|
|
export function buildFinancingRows(
|
|
assets: Asset[],
|
|
terms: FinancingTerm[],
|
|
now: { year: number; month: number },
|
|
): FinancingRow[] {
|
|
const byId = new Map(assets.map((a) => [a.id, a]));
|
|
const out: FinancingRow[] = [];
|
|
for (const t of terms) {
|
|
const a = byId.get(t.asset);
|
|
if (!a || !a.code) continue;
|
|
out.push({
|
|
id: t.id,
|
|
code: a.code,
|
|
name: a.name,
|
|
plant: t.plant || "",
|
|
installment: t.installment || 0,
|
|
startYear: t.start_year || 0,
|
|
startMonth: t.start_month || 0,
|
|
endYear: t.end_year || 0,
|
|
endMonth: t.end_month || 0,
|
|
isSettled: Boolean(t.is_settled),
|
|
calc: financeCalc(toInput(t, now)),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export type OperatorRow = {
|
|
/** null on a row that represents an operator with no machine of their own. */
|
|
machineId: string | null;
|
|
machineCode: string;
|
|
machineName: string;
|
|
operatorId: string | null;
|
|
operatorName: string;
|
|
employeeCode: string;
|
|
rate: number | null;
|
|
/** True when this row exists because of an operator, not a machine. */
|
|
isUnassignedOperator: boolean;
|
|
};
|
|
|
|
/**
|
|
* The operator register, keyed on the PLANT REGISTER but SCOPED to the sheet.
|
|
*
|
|
* Only machines the client's operator sheet lists appear — twenty vehicles. The
|
|
* register holds forty-one coded machines and the difference (containers, generators,
|
|
* truck trailers) can never have a person allocated to them, so listing them would
|
|
* present twenty-odd permanent blanks as if they were staffing gaps. The machine's
|
|
* NAME and CODE still come from the register, so the list stays linked to it.
|
|
*
|
|
* Every in-scope machine gets a row, whether or not anyone is on it: an unstaffed
|
|
* machine is exactly what a reader needs to see. A machine with no operator carries
|
|
* blank operator cells; that is NOT the same as an operator with no rate.
|
|
*
|
|
* Operators the sheet gave no machine get their own row at the end with blank machine
|
|
* cells, because they are people on the payroll and must not vanish from the register.
|
|
*
|
|
* Assets with NO code are skipped even if flagged: the sheet addresses machines by
|
|
* P/Unit, so an uncoded entry cannot be pointed at.
|
|
*/
|
|
export function buildOperatorRows(assets: Asset[], operators: Operator[]): OperatorRow[] {
|
|
const byMachine = new Map<string, Operator>();
|
|
const unassigned: Operator[] = [];
|
|
|
|
for (const o of operators) {
|
|
if (o.asset) byMachine.set(o.asset, o);
|
|
else unassigned.push(o);
|
|
}
|
|
|
|
// The sheet decides the baseline, but a machine someone has been allocated to ALWAYS
|
|
// appears — otherwise adding an operator to a machine that was not on the sheet would
|
|
// look like it silently did nothing. Adding a person must always be visible.
|
|
const inScope = assets.filter(
|
|
(a) => a.code && (a.on_operator_sheet || byMachine.has(a.id)),
|
|
);
|
|
|
|
const rows: OperatorRow[] = inScope.map((a) => {
|
|
const op = byMachine.get(a.id);
|
|
return {
|
|
machineId: a.id,
|
|
machineCode: a.code,
|
|
machineName: a.name,
|
|
operatorId: op?.id ?? null,
|
|
operatorName: op?.name ?? "",
|
|
employeeCode: op?.employee_code ?? "",
|
|
rate: op ? op.rate ?? null : null,
|
|
isUnassignedOperator: false,
|
|
};
|
|
});
|
|
|
|
for (const op of unassigned) {
|
|
rows.push({
|
|
machineId: null,
|
|
machineCode: "",
|
|
machineName: "",
|
|
operatorId: op.id,
|
|
operatorName: op.name,
|
|
employeeCode: op.employee_code,
|
|
rate: op.rate ?? null,
|
|
isUnassignedOperator: true,
|
|
});
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
export type InsuranceRow = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
// The cost per month, resolved against the premium in force THAT month.
|
|
// `null` = no premium on record for that month — never 0 (see lib/insurance.ts).
|
|
months: (number | null)[];
|
|
// Sum of the months we actually know, INSIDE the year-to-date window. Months with
|
|
// no premium are excluded rather than counted as zero, so this is never quietly
|
|
// smaller than the time that has actually passed.
|
|
ytd: number;
|
|
/** Months of the whole year that carry a premium — gaps included. */
|
|
coveredMonths: number;
|
|
/** Months inside the year-to-date window that carry a premium. What `ytd` sums. */
|
|
chargedMonths: number;
|
|
currentRate: number | null;
|
|
// The machine's premium history, newest first — the point of the page.
|
|
periods: RatePeriod[];
|
|
problems: string[];
|
|
};
|
|
|
|
/**
|
|
* One row per insured machine, built from RATE PERIODS (`insurance_rates`), not from
|
|
* `cost_lines`. The rates are the source of truth; the monthly cost lines are
|
|
* derived from them by the seed, so reading the rates directly is what keeps this
|
|
* page showing a premium change rather than a flat rate re-keyed twelve times.
|
|
*
|
|
* A machine with no rate at all is left OUT, as `buildRunningRows` does for running
|
|
* cost: "no premium on record" is not "R0 of insurance".
|
|
*/
|
|
export function buildInsuranceRows(
|
|
assets: Asset[],
|
|
rates: InsuranceRate[],
|
|
// The last month that has HAPPENED, 1-based. The year-to-date figure sums only the
|
|
// months up to here: a premium for December is a rate that is on file, not a cost the
|
|
// client has incurred yet, and including it made this page's YTD disagree with the
|
|
// Overview's insurance column by exactly one month.
|
|
throughMonth = 12,
|
|
): InsuranceRow[] {
|
|
const byId = new Map(assets.map((a) => [a.id, a]));
|
|
const grouped = new Map<string, RatePeriod[]>();
|
|
|
|
for (const r of rates) {
|
|
const a = byId.get(r.asset);
|
|
if (!a || !a.code) continue;
|
|
const list = grouped.get(a.id) || [];
|
|
list.push({ from: r.month_from, to: r.month_to || 0, rate: r.rate, source: r.source || "" });
|
|
grouped.set(a.id, list);
|
|
}
|
|
|
|
const out: InsuranceRow[] = [];
|
|
for (const [assetId, periods] of grouped) {
|
|
const a = byId.get(assetId)!;
|
|
const cost = monthlyCost(periods);
|
|
const window = cost.slice(0, Math.max(0, Math.min(12, throughMonth)));
|
|
const known = window.filter((v): v is number => v !== null);
|
|
out.push({
|
|
id: assetId,
|
|
code: a.code,
|
|
name: a.name,
|
|
months: cost,
|
|
ytd: known.reduce((s, v) => s + v, 0),
|
|
coveredMonths: cost.filter((v) => v !== null).length,
|
|
chargedMonths: known.length,
|
|
currentRate: currentRate(periods)?.rate ?? null,
|
|
// Newest first — the period you most likely came to read.
|
|
periods: [...periods].sort((x, y) => y.from - x.from),
|
|
problems: validatePeriods(periods),
|
|
});
|
|
}
|
|
return out;
|
|
}
|