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 <noreply@anthropic.com>
This commit is contained in:
parent
d9cf20dd02
commit
db53a82809
3 changed files with 55 additions and 14 deletions
|
|
@ -79,8 +79,8 @@ export function IncomeCostChart({ months, title = "Income & cost", incomeLabel =
|
|||
return <PairedMonthChart title={title} data={months.map((m) => ({ 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[] }) {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 <ManagementShell active="turnover" title="Turnover Overview" description="Income, spend and retention across the company’s planning portfolio." board={board}>
|
||||
<div className="mgmt-filters"><Filter label="Planning period" value={period} onChange={setPeriod}><option value="all">Full period · March–February</option>{board.months.map((m) => <option key={m.name} value={m.index}>{m.name} {board.planYear + (m.index >= 10 ? 1 : 0)}</option>)}</Filter><span className="ml-auto pb-2 text-xs text-muted-foreground">{board.projects.filter((p) => !p.excludedFromCashflow).length} included jobs · {board.projects.filter((p) => p.excludedFromCashflow).length} future sites excluded</span></div>
|
||||
<div className="mgmt-filters"><Filter label="Planning period" value={period} onChange={setPeriod}><option value="all">Full period · March–February</option>{board.months.map((m) => <option key={m.name} value={m.index}>{m.name} {board.planYear + (m.index >= 10 ? 1 : 0)}</option>)}</Filter><span className="ml-auto pb-2 text-xs text-muted-foreground">{included.length} included jobs · <span style={{ color: "#dc2626" }}>{awarded.length} awarded, not started{awarded.length > 0 ? ` (${compactMoney(awardedValue)}, excluded)` : ""}</span></span></div>
|
||||
<section className="mgmt-hero" aria-label="Planning headline figures"><div className="mgmt-hero-heading"><p>{period === "all" ? board.periodLabel : `${months[0].name} ${board.planYear + (months[0].index >= 10 ? 1 : 0)}`}</p><span>All values in ZAR</span></div><div className="mgmt-hero-stats">
|
||||
<Stat label="Planned turnover" value={compactMoney(turnover)} note={`Included job schedules · ${compactMoney(income)} after planned deductions`} />
|
||||
<div className="mgmt-stat"><p>Planned turnover</p><div className="mgmt-stat-value">{compactMoney(turnover)}</div><span>Included job schedules · {compactMoney(income)} after planned deductions</span>
|
||||
<span className="mgmt-split"><b className="is-invoiced">● {compactMoney(split.actual ?? 0)} invoiced</b><b className="is-forecast">● {compactMoney(split.forecast ?? 0)} forecast</b>{awarded.length > 0 && <b className="is-awarded">● {compactMoney(awardedValue)} awarded, not started</b>}</span></div>
|
||||
<Stat label="Planned costs" value={compactMoney(cost)} note="Saved costs + forecasts recalculated for included jobs" />
|
||||
<Stat label="Planned cash result" value={compactMoney(result)} note={`${percent(income === 0 ? null : result / income)} of income after deductions · includes payouts`} tone={result >= 0 ? "positive" : "negative"} />
|
||||
<Stat label="Retention at period end" value={compactMoney(months[months.length - 1].reportedRetentionClosing)} note="Saved workbook retention balance" />
|
||||
</div></section>
|
||||
<div className="mgmt-chart-grid">
|
||||
<div className="mgmt-turnover-columns">
|
||||
<div className="mgmt-turnover-col">
|
||||
<Panel title="Cashflow income & cost" subtitle={period === "all" ? "The shape of the March–February plan" : `${months[0].name} planning figures`}><IncomeCostChart months={months} title="Cashflow income & cost" incomeLabel="Income after deductions" /></Panel>
|
||||
<Panel title="Monthly plan" subtitle="Cash result = income after planned deductions + retention payouts − costs"><div className="mgmt-table-scroll"><table className="mgmt-table mgmt-turnover-table"><caption className="sr-only">Included job turnover and planned cashflow by month</caption><thead><tr><th>Month</th><th>Turnover</th><th>Planned deductions</th><th>Income after deductions</th><th>Costs</th><th>Payouts</th><th>Cash result</th></tr></thead><tbody>{months.map((m) => <tr key={m.name}><td><span className="mgmt-month"><i className={m.result < 0 ? "is-negative" : ""} />{m.name}</span></td><td>{money(m.projectIncome)}<span className="mgmt-cell-sub-row">{(splitFor(m.index).actual ?? 0) !== 0 && <i style={{ color: chartColors.payout }}>Inv {compactMoney(splitFor(m.index).actual ?? 0)}</i>}{(splitFor(m.index).forecast ?? 0) !== 0 && <i style={{ color: chartColors.forecast }}>Fcst {compactMoney(splitFor(m.index).forecast ?? 0)}</i>}</span></td><td>{money(m.incomeDeduction)}</td><td>{money(m.income)}</td><td>{money(m.cost)}</td><td>{m.payout === 0 ? "—" : money(m.payout)}</td><td className={m.result < 0 ? "is-negative" : "is-positive"}>{money(m.result)}</td></tr>)}</tbody><tfoot><tr><td>{period === "all" ? "Full period" : months[0].name}</td><td>{money(turnover)}</td><td>{money(deductions)}</td><td>{money(income)}</td><td>{money(cost)}</td><td>{money(payouts)}</td><td className={result < 0 ? "is-negative" : "is-positive"}>{money(result)}</td></tr></tfoot></table></div><p className="mgmt-source-note">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.</p></Panel>
|
||||
</div>
|
||||
<div className="mgmt-turnover-col">
|
||||
<Panel title="Cumulative planned cash result" subtitle="Includes retention payouts · starts from zero"><ResultChart months={chartMonths} /><div className="mgmt-result-caption"><span>{period === "all" ? "End of planning period" : months[0].name}</span><b className={result >= 0 ? "is-positive" : "is-negative"}>{compactMoney(result)}</b></div></Panel>
|
||||
</div>
|
||||
<div className="mgmt-chart-grid">
|
||||
<Panel title="Monthly plan" subtitle="Cash result = income after planned deductions + retention payouts − costs"><div className="mgmt-table-scroll"><table className="mgmt-table"><caption className="sr-only">Included job turnover and planned cashflow by month</caption><thead><tr><th>Month</th><th>Turnover</th><th>Planned deductions</th><th>Income after deductions</th><th>Costs</th><th>Payouts</th><th>Cash result</th></tr></thead><tbody>{months.map((m) => <tr key={m.name}><td><span className="mgmt-month"><i className={m.result < 0 ? "is-negative" : ""} />{m.name}</span></td><td>{money(m.projectIncome)}</td><td>{money(m.incomeDeduction)}</td><td>{money(m.income)}</td><td>{money(m.cost)}</td><td>{m.payout === 0 ? "—" : money(m.payout)}</td><td className={m.result < 0 ? "is-negative" : "is-positive"}>{money(m.result)}</td></tr>)}</tbody><tfoot><tr><td>{period === "all" ? "Full period" : months[0].name}</td><td>{money(turnover)}</td><td>{money(deductions)}</td><td>{money(income)}</td><td>{money(cost)}</td><td>{money(payouts)}</td><td className={result < 0 ? "is-negative" : "is-positive"}>{money(result)}</td></tr></tfoot></table></div><p className="mgmt-source-note">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.</p></Panel>
|
||||
<Panel title="Largest income schedules" subtitle={period === "all" ? "Included job detail · full planning period" : `Included job detail · ${months[0].name}`} action={<Link href={`/dashboard/management/projects?year=${board.planYear}`} aria-label="View all project forecasts"><ArrowUpRight size={17} /></Link>}>
|
||||
{comparison.length === 2 && <Panel title="Year comparison" subtitle={`${comparison[0].year} vs ${comparison[1].year} · calendar-year income by month`} action={<Link href={`/dashboard/management/comparison?year=${board.planYear}`} aria-label="Open the year comparison"><ArrowUpRight size={17} /></Link>}><ComparisonChart data={calendarLabels.map((label, i) => ({ 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)} /></Panel>}
|
||||
<Panel title="Largest income schedules" subtitle={`Ranked by scheduled income (invoiced plus forecast) · ${period === "all" ? "full planning period" : months[0].name}`} action={<Link href={`/dashboard/management/projects?year=${board.planYear}`} aria-label="View all project forecasts"><ArrowUpRight size={17} /></Link>}>
|
||||
<div className="space-y-5">{leaders.map((p, i) => <div key={p.id}><div className="mb-2 flex items-center justify-between gap-3 text-xs"><span className="min-w-0 truncate"><span className="mr-2 text-muted-foreground">0{i + 1}</span>{p.name}</span><strong className="shrink-0 font-medium tabular-nums">{compactMoney(p.total)}</strong></div><div className="h-1.5 rounded-full bg-muted"><div className="h-full rounded-full bg-[#d9a346]" style={{ width: `${p.total / Math.max(1, leaders[0].total) * 100}%` }} /></div></div>)}</div>
|
||||
<p className="mgmt-source-note">Selected job entries total {compactMoney(turnover)}. The workbook’s reported income is {compactMoney(board.totals.reportedIncome)}. See the reconciliation below.</p>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
<Reconciliation board={board} />
|
||||
</ManagementShell>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue