From db53a828098d72b1ee30dd9b00c137fe16f4bd93 Mon Sep 17 00:00:00 2001 From: DEls Date: Tue, 20 Oct 2026 03:40:58 +0200 Subject: [PATCH] feat(management): rework Turnover Overview layout, KPI band and colours Show invoiced, forecast and awarded split in the turnover KPI; pin the cash result column and add per-month invoiced/forecast lines; stack the side panels in a column with a year comparison chart so both columns end level; reword the income-less-cost chart labels. KPI band follows the app greys in dark mode and a light grey in light mode, with all figures neutral except the accent-coloured turnover. Co-Authored-By: Claude Sonnet 5.5 --- .../management/management-charts.tsx | 4 +-- src/app/dashboard/management/management.css | 33 +++++++++++++++++-- .../dashboard/management/turnover-view.tsx | 32 ++++++++++++------ 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/app/dashboard/management/management-charts.tsx b/src/app/dashboard/management/management-charts.tsx index 526b7bc..3211d17 100644 --- a/src/app/dashboard/management/management-charts.tsx +++ b/src/app/dashboard/management/management-charts.tsx @@ -79,8 +79,8 @@ export function IncomeCostChart({ months, title = "Income & cost", incomeLabel = return ({ label: m.label, first: m.income, second: m.cost, difference: m.income === null || m.cost === null ? null : m.income - m.cost }))} series={[{ dataKey: "first", label: incomeLabel, color: chartColors.income }, { dataKey: "second", label: "Cost", color: chartColors.cost }, - { dataKey: "difference", label: "Income − cost", color: chartColors.result }]} emphasis={0} - description="Income − cost shows the monthly planned difference. It excludes retention payouts; values below zero are losses." />; + { dataKey: "difference", label: "Left over (income − cost)", color: chartColors.result }]} emphasis={0} + description="The teal line is what is left each month after costs. It excludes retention payouts; a value below zero means the month runs at a loss." />; } export function ResultChart({ months }: { months: PlanningMonth[] }) { diff --git a/src/app/dashboard/management/management.css b/src/app/dashboard/management/management.css index 901725d..0c869bc 100644 --- a/src/app/dashboard/management/management.css +++ b/src/app/dashboard/management/management.css @@ -1,4 +1,4 @@ -.management-board { --mgmt-ink: #202b3c; --mgmt-gold: var(--brand-on-dark); } +.management-board { --mgmt-ink: #d3d8df; --mgmt-gold: var(--brand-on-dark); } .mgmt-topbar { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:1.5rem 2rem; border-bottom:1px solid var(--border); background:var(--background); } .mgmt-eyebrow { display:flex; gap:.65rem; align-items:center; font-size:.67rem; font-weight:600; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-foreground); } .mgmt-eyebrow span { opacity:.45; } @@ -26,7 +26,7 @@ .mgmt-hero .mgmt-stat:first-child .mgmt-stat-value { color:var(--mgmt-gold); font-size:2.5rem; } .is-positive { color:#228978; } .is-negative { color:#cb5b65; } -.mgmt-hero .is-positive { color:#71d0b8; } +.mgmt-hero .is-positive { color:inherit; } .mgmt-hero .is-negative { color:#ff9fa6; } .mgmt-stat-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:1rem; margin-bottom:1.5rem; } .mgmt-stat-grid .mgmt-stat { border:1px solid var(--border); background:var(--card); border-radius:12px; padding:1.2rem; } @@ -150,3 +150,32 @@ .mgmt-edit-field { display:flex; flex-direction:column; gap:.3rem; font-size:.7rem; color:var(--muted-foreground); } .mgmt-edit-field .mgmt-cell-input { width:100%; } .mgmt-edit-variance { margin:0 0 .4rem; font-size:.72rem; color:var(--foreground); } + +.mgmt-stat > span.mgmt-split { display:flex; flex-direction:column; gap:.2rem; margin-top:.7rem; font-size:.67rem; } +.mgmt-split b { font-weight:600; } +.mgmt-cell-sub-row { display:flex; justify-content:flex-end; gap:.6rem; font-size:.58rem; font-style:italic; margin-top:.1rem; } +.mgmt-turnover-table th, .mgmt-turnover-table td { padding-left:.55rem; padding-right:.55rem; } +.mgmt-turnover-table th:last-child, .mgmt-turnover-table td:last-child { position:sticky; right:0; background:var(--card); border-left:1px solid var(--border); z-index:1; } +.mgmt-turnover-table th:first-child, .mgmt-turnover-table td:first-child { position:sticky; left:0; background:var(--card); z-index:1; padding-right:.9rem; } +.mgmt-turnover-columns { display:grid; grid-template-columns:minmax(0,1.6fr) minmax(0,1fr); gap:1.3rem; margin-bottom:1.3rem; align-items:stretch; } +.mgmt-turnover-col { display:flex; flex-direction:column; gap:1.3rem; min-width:0; } +@media (max-width:1100px) { .mgmt-turnover-columns { grid-template-columns:1fr; } } +.mgmt-hero .mgmt-split .is-invoiced { color:#5fd6a3; } +.mgmt-hero .mgmt-split .is-forecast { color:#c9a2ff; } +.mgmt-hero .mgmt-split .is-awarded { color:#ff9fa6; } +/* Right-hand panels share the leftover height so the two columns end level; each panel's chart sits centred in it. */ +.mgmt-turnover-col:last-child > .mgmt-panel { flex:1 1 auto; display:flex; flex-direction:column; } +.mgmt-turnover-col:last-child > .mgmt-panel > .mgmt-panel-heading + * { margin-top:auto; } +.mgmt-turnover-col:last-child > .mgmt-panel > :last-child { margin-bottom:auto; } +@media (max-width:1100px) { .mgmt-turnover-col:last-child > .mgmt-panel { flex:0 0 auto; } } +.dark .mgmt-hero { border:1px solid var(--border); } +.dark .management-board { --mgmt-ink: #2a3040; } +/* Light theme: the KPI band is a light grey with dark text, so it sits with the white cards instead of cutting a black block into the page. */ +html:not(.dark) .mgmt-hero { color:#2b2f36; border:1px solid #c3c9d2; } +html:not(.dark) .mgmt-hero-heading p, html:not(.dark) .mgmt-hero-heading span, html:not(.dark) .mgmt-hero .mgmt-stat p, html:not(.dark) .mgmt-hero .mgmt-stat > span { color:#555d6b; } +html:not(.dark) .mgmt-hero .mgmt-stat { border-left-color:#b4bcc7; } +html:not(.dark) .mgmt-hero .mgmt-stat:first-child .mgmt-stat-value { color:color-mix(in srgb, var(--primary-ink) 85%, #000); } +html:not(.dark) .mgmt-hero .is-negative { color:#b91c1c; } +html:not(.dark) .mgmt-hero .mgmt-split .is-invoiced { color:#166534; } +html:not(.dark) .mgmt-hero .mgmt-split .is-forecast { color:#7e22ce; } +html:not(.dark) .mgmt-hero .mgmt-split .is-awarded { color:#b91c1c; } diff --git a/src/app/dashboard/management/turnover-view.tsx b/src/app/dashboard/management/turnover-view.tsx index 36b1899..d8643c1 100644 --- a/src/app/dashboard/management/turnover-view.tsx +++ b/src/app/dashboard/management/turnover-view.tsx @@ -4,9 +4,10 @@ import { useState } from "react"; import Link from "next/link"; import { ArrowUpRight } from "lucide-react"; import type { ManagementBoard } from "@/lib/management/model"; -import { incomeBreakdown } from "@/lib/management/model"; -import { IncomeCostChart, ResultChart } from "./management-charts"; +import { calendarForecastStart, incomeBreakdown } from "@/lib/management/model"; +import { ComparisonChart, IncomeCostChart, ResultChart } from "./management-charts"; import { compactMoney, Filter, ManagementShell, money, Panel, percent, Reconciliation, Stat } from "./management-ui"; +import { chartColors } from "./management-charts"; export function TurnoverView({ board }: { board: ManagementBoard }) { const [period, setPeriod] = useState("all"); @@ -19,25 +20,36 @@ export function TurnoverView({ board }: { board: ManagementBoard }) { const cost = months.reduce((total, m) => total + m.cost, 0); const result = months.reduce((total, m) => total + m.result, 0); const payouts = months.reduce((total, m) => total + m.payout, 0); - const leaders = board.projects.filter((p) => !p.excludedFromCashflow).map((p) => ({ ...p, total: incomeBreakdown([p], indexes).total })).filter((p) => p.total > 0).sort((a, b) => b.total - a.total).slice(0, 5); + const included = board.projects.filter((p) => !p.excludedFromCashflow); + const split = incomeBreakdown(included, indexes); + const awarded = board.projects.filter((p) => p.excludedFromCashflow); + const awardedValue = awarded.reduce((total, p) => total + indexes.reduce((sub, i) => sub + (p.futureIncome?.[i] ?? p.income[i] ?? 0), 0), 0); + const splitFor = (i: number) => incomeBreakdown(included, [i]); + const comparison = board.incomeComparisons.slice(-2); + const calendarLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const leaders = board.projects.filter((p) => !p.excludedFromCashflow).map((p) => ({ ...p, total: incomeBreakdown([p], indexes).total })).filter((p) => p.total > 0).sort((a, b) => b.total - a.total).slice(0, 8); return -
{board.months.map((m) => )}{board.projects.filter((p) => !p.excludedFromCashflow).length} included jobs · {board.projects.filter((p) => p.excludedFromCashflow).length} future sites excluded
+
{board.months.map((m) => )}{included.length} included jobs · {awarded.length} awarded, not started{awarded.length > 0 ? ` (${compactMoney(awardedValue)}, excluded)` : ""}

{period === "all" ? board.periodLabel : `${months[0].name} ${board.planYear + (months[0].index >= 10 ? 1 : 0)}`}

All values in ZAR
- +

Planned turnover

{compactMoney(turnover)}
Included job schedules · {compactMoney(income)} after planned deductions + ● {compactMoney(split.actual ?? 0)} invoiced● {compactMoney(split.forecast ?? 0)} forecast{awarded.length > 0 && ● {compactMoney(awardedValue)} awarded, not started}
= 0 ? "positive" : "negative"} />
-
+
+
+
{months.map((m) => )}
Included job turnover and planned cashflow by month
MonthTurnoverPlanned deductionsIncome after deductionsCostsPayoutsCash result
{m.name}{money(m.projectIncome)}{(splitFor(m.index).actual ?? 0) !== 0 && Inv {compactMoney(splitFor(m.index).actual ?? 0)}}{(splitFor(m.index).forecast ?? 0) !== 0 && Fcst {compactMoney(splitFor(m.index).forecast ?? 0)}}{money(m.incomeDeduction)}{money(m.income)}{money(m.cost)}{m.payout === 0 ? "—" : money(m.payout)}{money(m.result)}
{period === "all" ? "Full period" : months[0].name}{money(turnover)}{money(deductions)}{money(income)}{money(cost)}{money(payouts)}{money(result)}

Red future sites are excluded. Income deductions use the month’s workbook factor. Formula-based future costs use that included income and the workbook’s cost rate; saved cost figures are retained for other months. Retention payouts are added once.

+
+
{period === "all" ? "End of planning period" : months[0].name}= 0 ? "is-positive" : "is-negative"}>{compactMoney(result)}
-
-
-
{months.map((m) => )}
Included job turnover and planned cashflow by month
MonthTurnoverPlanned deductionsIncome after deductionsCostsPayoutsCash result
{m.name}{money(m.projectIncome)}{money(m.incomeDeduction)}{money(m.income)}{money(m.cost)}{m.payout === 0 ? "—" : money(m.payout)}{money(m.result)}
{period === "all" ? "Full period" : months[0].name}{money(turnover)}{money(deductions)}{money(income)}{money(cost)}{money(payouts)}{money(result)}

Red future sites are excluded. Income deductions use the month’s workbook factor. Formula-based future costs use that included income and the workbook’s cost rate; saved cost figures are retained for other months. Retention payouts are added once.

- }> + {comparison.length === 2 && }> ({ label, previous: comparison[0].months[i].income, current: comparison[1].months[i].income }))} years={[comparison[0].year, comparison[1].year]} forecastStart={calendarForecastStart(comparison[1].year)} />} + }>
{leaders.map((p, i) =>
0{i + 1}{p.name}{compactMoney(p.total)}
)}

Selected job entries total {compactMoney(turnover)}. The workbook’s reported income is {compactMoney(board.totals.reportedIncome)}. See the reconciliation below.

+
;