feat(management): income KPI tiles match chart colours, align table, purple forecast line
Income tab shows Actual, Forecast, Planned and a combined total with colour stripes matching the chart; month columns right-align with their headers; the forecast line under an actual uses the forecast purple; the red stack falls back to the forecast bar when a month has no invoiced figure yet. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a96f35366d
commit
d9cf20dd02
3 changed files with 18 additions and 8 deletions
|
|
@ -100,6 +100,11 @@ export function IncomeView({ board }: { board: ManagementBoard }) {
|
|||
const monthlyIncome = months.map((m) => ({ label: m.label, ...incomeBreakdown(jobs, [m.index]) }));
|
||||
const incomeChartData = incomeSeries(jobs, indexes, board.planYear);
|
||||
const totals = incomeBreakdown(jobs, indexes);
|
||||
const sumSeries = (pick: (point: (typeof incomeChartData)[number]) => number | null) => {
|
||||
const values = incomeChartData.map(pick).filter((value): value is number => value !== null);
|
||||
return values.length ? values.reduce((total, value) => total + value, 0) : null;
|
||||
};
|
||||
const seriesTotals = { actual: sumSeries((p) => p.actual), forecast: sumSeries((p) => p.forecast), notStarted: sumSeries((p) => p.notStarted) };
|
||||
const forecastLabel = "Forecast";
|
||||
return <ManagementShell active="income" title="Income" description="Review invoiced actual income and future forecasts by job and month." board={board}>
|
||||
<div className="mgmt-filters">
|
||||
|
|
@ -108,10 +113,10 @@ export function IncomeView({ board }: { board: ManagementBoard }) {
|
|||
{(query || month !== "all") && <button type="button" className="mgmt-reset" onClick={() => { setQuery(""); setMonth("all"); }}>Reset filters</button>}
|
||||
</div>
|
||||
<div className="mgmt-stat-grid">
|
||||
<Stat label="Actual income · invoiced" value={totals.actual === null ? "—" : compactMoney(totals.actual)} note="Black figures in the source workbook" />
|
||||
<Stat label={forecastLabel} value={totals.forecast === null ? "—" : compactMoney(totals.forecast)} note="Purple figures in the source workbook" />
|
||||
<Stat label="Actual + forecast" value={compactMoney(totals.total)} note="Selected jobs and months · excludes retention payouts" />
|
||||
<Stat label="Jobs in view" value={String(jobs.length)} note={`${months.length} months · ${board.periodLabel}`} />
|
||||
<Stat accent={chartColors.payout} label="Actual · invoiced" value={seriesTotals.actual === null ? "—" : compactMoney(seriesTotals.actual)} note="Already invoiced · green bars" />
|
||||
<Stat accent={chartColors.forecast} label={forecastLabel} value={seriesTotals.forecast === null ? "—" : compactMoney(seriesTotals.forecast)} note="Forecast for the months in view · purple bars" />
|
||||
<Stat accent="#dc2626" label="Planned · not started" value={seriesTotals.notStarted === null ? "—" : compactMoney(seriesTotals.notStarted)} note="Awarded jobs, excluded from totals · red bars" />
|
||||
<Stat accent={[chartColors.payout, chartColors.forecast, "#dc2626"]} label="Total · actual + forecast + planned" value={compactMoney(totals.total + (seriesTotals.notStarted ?? 0))} note={`Actual where invoiced, otherwise forecast, plus planned · ${jobs.length} jobs · ${months.length} months`} />
|
||||
</div>
|
||||
<Panel title="Actual income & forecast" subtitle="Purple: forecast · green: invoiced actuals · red: awarded, not yet started" action={<strong className="text-sm tabular-nums">Period total: {money(totals.total)}</strong>}><IncomeForecastChart data={incomeChartData} />
|
||||
<p className="mgmt-source-note">The chart shows individual monthly amounts. The period total sums all months in view. Red future sites stay out of the cashflow totals; their planned amount is the red bar in the chart. Click an amount in the table below to enter the actual.</p>
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ export function Panel({ title, subtitle, action, children, className }: { title:
|
|||
return <section className={cn("mgmt-panel", className)}><div className="mgmt-panel-heading"><div><h2>{title}</h2>{subtitle && <p>{subtitle}</p>}</div>{action}</div>{children}</section>;
|
||||
}
|
||||
|
||||
export function Stat({ label, value, note, tone, compact }: { label: string; value: string; note?: string; tone?: "positive" | "negative"; compact?: boolean }) {
|
||||
return <div className={cn("mgmt-stat", compact && "is-compact")}><p>{label}</p><div className={cn("mgmt-stat-value", tone && `is-${tone}`)}>{value}</div>{note && <span>{note}</span>}</div>;
|
||||
export function Stat({ label, value, note, tone, compact, accent }: { label: string; value: string; note?: string; tone?: "positive" | "negative"; compact?: boolean; accent?: string | string[] }) {
|
||||
return <div className={cn("mgmt-stat", compact && "is-compact", accent && "has-accent")} style={accent ? ({ "--stat-accent": Array.isArray(accent) ? `linear-gradient(to bottom, ${accent.map((color, i) => `${color} ${(i / accent.length) * 100}% ${((i + 1) / accent.length) * 100}%`).join(", ")})` : accent } as React.CSSProperties) : undefined}><p>{label}</p><div className={cn("mgmt-stat-value", tone && `is-${tone}`)}>{value}</div>{note && <span>{note}</span>}</div>;
|
||||
}
|
||||
|
||||
export function Change({ value, percentage = false }: { value: number; percentage?: boolean }) {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@
|
|||
.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; }
|
||||
.mgmt-stat-grid .mgmt-stat.has-accent { position:relative; overflow:hidden; }
|
||||
.mgmt-stat-grid .mgmt-stat.has-accent::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--stat-accent); }
|
||||
.mgmt-stat.is-compact .mgmt-stat-value { font-size:1.65rem; }
|
||||
.mgmt-panel { min-width:0; padding:1.4rem; border:1px solid var(--border); background:var(--card); border-radius:12px; }
|
||||
.mgmt-panel-heading { display:flex; justify-content:space-between; align-items:flex-start; gap:1rem; margin-bottom:1.2rem; }
|
||||
|
|
@ -97,7 +99,8 @@
|
|||
.mgmt-heat-cell { display:block; border-radius:4px; padding:.55rem .45rem; min-width:60px; color:var(--foreground); }
|
||||
.mgmt-project-table td { padding:.45rem .25rem; }
|
||||
.mgmt-project-table td.mgmt-sticky { padding:.8rem .5rem; }
|
||||
.mgmt-project-table th { padding:.7rem .4rem; }
|
||||
.mgmt-project-table th { padding:.7rem .7rem .7rem .4rem; }
|
||||
.mgmt-project-table .mgmt-cell-button { text-align:right; }
|
||||
.mgmt-project-total { font-weight:650; padding-right:.6rem!important; }
|
||||
.mgmt-project-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:1rem; margin-bottom:1.5rem; }
|
||||
.mgmt-project-summary .mgmt-stat { border-left:1px solid var(--border); padding-left:1.1rem; }
|
||||
|
|
@ -126,6 +129,8 @@
|
|||
|
||||
.mgmt-retention-table th.mgmt-sticky { text-align:left; font-size:.71rem; letter-spacing:0; color:var(--foreground); }
|
||||
.mgmt-retention-table td { padding:.85rem .8rem; }
|
||||
.mgmt-retention-table th:not(.mgmt-sticky) { padding:.7rem .8rem; }
|
||||
.mgmt-retention-table .mgmt-cell-button { text-align:right; margin:-1px; width:calc(100% + 2px); padding:0 1px; }
|
||||
.mgmt-retention-table .mgmt-project-meta { white-space:normal; max-width:280px; }
|
||||
|
||||
.mgmt-cell-button { display:block; width:100%; border:1px solid transparent; border-radius:4px; background-color:transparent; font:inherit; color:inherit; text-align:center; cursor:pointer; }
|
||||
|
|
@ -140,7 +145,7 @@
|
|||
.mgmt-field { font-size:.72rem; color:var(--muted-foreground); }
|
||||
.mgmt-select { padding:.35rem .5rem; border:1px solid var(--border); border-radius:6px; background:var(--background); color:var(--foreground); font-size:.75rem; }
|
||||
|
||||
.mgmt-cell-sub { display:block; font-size:.58rem; font-style:italic; font-weight:400; color:var(--muted-foreground); margin-top:.1rem; white-space:nowrap; }
|
||||
.mgmt-cell-sub { display:block; font-size:.58rem; font-style:italic; font-weight:400; color:#a855f7; margin-top:.1rem; white-space:nowrap; }
|
||||
.mgmt-edit-fields { display:grid; grid-template-columns:1fr 1fr; gap:.9rem; margin:1rem 0 .6rem; }
|
||||
.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%; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue