feat(kpi): monthly revenue, expenses and result table under Nyckeltal, toggle in Anpassa (#2433)

The KPI payload has carried income, expenses and net per month since the
aggregates RPC, but after the Recharts trend chart was dropped only the net
column was rendered (the bars pane). A fiscal year's month-by-month sums
were therefore fetched and never shown (#2196).

- New components/kpi/KPIMonthsTable.tsx: full-width dry table (Manad,
  Intakter, Kostnader, Resultat) with the period totals as the last row,
  rendered between the panes and the cost story. Rows and totals come from
  the pure helper components/kpi/months-table.ts.
- New preference showMonthlyTable (default true) on KPIPreferences: filled
  by mergeWithDefaults on read, accepted by the preferences route, sent
  whole by the dialog, required by readPreferencesBody. A boolean, not a
  KPI_DEFINITIONS id: stored kpiOrder arrays would hide a new id for every
  existing company.
- One Switch row in the Anpassa dialog after the KPI list.
- Reuses the orphaned kpi.trend_* keys; adds months_col_month, months_total
  and the two settings keys in sv and en.
- Tests: helper rows/totals/inactive flags, defaults + merge, route accepts
  false and rejects a string; fixtures updated for the new field.

Closes #2196


Claude-Session: https://claude.ai/code/session_0179bdetHyofL6ATfQxB5wP5

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-08 21:19:19 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 5e2498bc2f
commit a7dcaac6ad
16 changed files with 238 additions and 2 deletions
+65
View File
@@ -0,0 +1,65 @@
'use client'
import { useTranslations } from 'next-intl'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { cn, formatCurrency } from '@/lib/utils'
import type { KPIReport } from '@/types'
import { monthsTableRows } from './months-table'
/**
* Income, expenses and net result month by month for the selected fiscal
* year, with the period totals as the last row (#2196). Full width like
* KPIBreakdown: the bars pane only has room for the net column, and this
* table is where the two other columns the payload already carries show.
*/
export function KPIMonthsTable({ report }: { report: KPIReport }) {
const t = useTranslations('kpi')
if (report.months.length === 0) return null
const { rows, total } = monthsTableRows(report)
const numeric = 'whitespace-nowrap text-right tabular-nums'
return (
<div>
<div className="mb-1 flex items-center gap-3 px-1">
<h2 className="text-xs uppercase tracking-wider text-muted-foreground">
{t('trend_title')}
</h2>
<div className="h-px flex-1 bg-border/60" />
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-full')}>{t('months_col_month')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('trend_legend_income')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('trend_legend_expenses')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('trend_legend_net')}</th>
</tr>
</thead>
<tbody>
{rows.map((m) => (
<tr key={m.label} className={cn(m.inactive && 'text-muted-foreground/60')}>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>{m.label}</td>
<td className={cn(TD_CLASS, numeric)}>{formatCurrency(m.income)}</td>
<td className={cn(TD_CLASS, numeric)}>{formatCurrency(m.expenses)}</td>
<td className={cn(TD_CLASS, numeric, m.net < 0 && !m.inactive && 'text-destructive')}>
{formatCurrency(m.net)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="font-medium">
<td className={cn(TD_CLASS, 'border-b-0 whitespace-nowrap')}>{t('months_total')}</td>
<td className={cn(TD_CLASS, 'border-b-0', numeric)}>{formatCurrency(total.income)}</td>
<td className={cn(TD_CLASS, 'border-b-0', numeric)}>{formatCurrency(total.expenses)}</td>
<td className={cn(TD_CLASS, 'border-b-0', numeric, total.net < 0 && 'text-destructive')}>
{formatCurrency(total.net)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)
}
+19
View File
@@ -210,6 +210,25 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
</div>
)
})}
{/* The month table is a layout flag, not a metric: its own row
after the KPI list, no expand panel, no account override. */}
<div className="rounded-lg border border-border p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{t('settings_monthly_table_label')}</p>
<p className="text-xs text-muted-foreground">
{t('settings_monthly_table_description')}
</p>
</div>
<Switch
checked={draft.showMonthlyTable}
onCheckedChange={(checked) =>
setDraft((prev) => ({ ...prev, showMonthlyTable: checked }))
}
aria-label={t('settings_monthly_table_label')}
/>
</div>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t mt-2">
@@ -11,6 +11,7 @@ const STORED: KPIPreferences = {
visibleKpis: ['netResult', 'cashPosition'],
kpiOrder: ['cashPosition', 'netResult', 'vatLiability'],
accountOverrides: { cashPosition: ['1930', '1940'] },
showMonthlyTable: true,
}
function jsonResponse(body: unknown, status = 200): Response {
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest'
import { monthsTableRows } from '@/components/kpi/months-table'
const report = {
months: [
{ label: 'Jan', income: 1000, expenses: 400, net: 600 },
{ label: 'Feb', income: 0, expenses: 250, net: -250 },
{ label: 'Mar', income: 0, expenses: 0, net: 0 },
],
totalRevenue: 1000,
totalExpenses: 650,
netResult: 350.004,
}
describe('monthsTableRows', () => {
it('keeps one row per month in report order with the payload figures', () => {
const { rows } = monthsTableRows(report)
expect(rows.map((r) => r.label)).toEqual(['Jan', 'Feb', 'Mar'])
expect(rows[0]).toMatchObject({ income: 1000, expenses: 400, net: 600 })
expect(rows[1]).toMatchObject({ income: 0, expenses: 250, net: -250 })
})
it('marks only a month with no movement at all as inactive', () => {
const { rows } = monthsTableRows(report)
expect(rows.map((r) => r.inactive)).toEqual([false, false, true])
})
it('foots to the report totals the panes print, rounded to öre', () => {
const { total } = monthsTableRows(report)
expect(total).toEqual({ income: 1000, expenses: 650, net: 350 })
})
it('yields no rows for an empty period', () => {
expect(monthsTableRows({ ...report, months: [] }).rows).toEqual([])
})
})
@@ -11,6 +11,7 @@ const PREFS: KPIPreferences = {
visibleKpis: ['netResult', 'cashPosition'],
kpiOrder: ['cashPosition', 'netResult', 'vatLiability'],
accountOverrides: { cashPosition: ['1930', '1940'] },
showMonthlyTable: true,
}
function jsonResponse(body: unknown, status = 200): Response {
@@ -64,6 +65,7 @@ describe('saveKPIPreferences', () => {
visibleKpis: ['netResult', 'cashPosition'],
kpiOrder: ['cashPosition', 'netResult', 'vatLiability'],
accountOverrides: { cashPosition: ['1930', '1940'], vatLiability: ['2611'] },
showMonthlyTable: false,
}
globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse({ data: stored }))
+43
View File
@@ -0,0 +1,43 @@
import { roundOre } from '@/lib/money'
import type { KPIReport } from '@/types'
/**
* Rows for the month-by-month table under Nyckeltal (#2196): the report's
* `months` (one per month of the fiscal period, zero-filled) plus the
* period totals the panes already show, so the table foots to the same
* figures. Pure so the shape is testable without rendering.
*/
export interface MonthsTableRow {
label: string
income: number
expenses: number
net: number
/** No movement at all: ahead of the last booking or before the first one. */
inactive: boolean
}
export interface MonthsTable {
rows: MonthsTableRow[]
total: { income: number; expenses: number; net: number }
}
export function monthsTableRows(report: Pick<KPIReport, 'months' | 'totalRevenue' | 'totalExpenses' | 'netResult'>): MonthsTable {
const rows = report.months.map((m) => ({
label: m.label,
income: m.income,
expenses: m.expenses,
net: m.net,
inactive: m.income === 0 && m.expenses === 0 && m.net === 0,
}))
// The report totals are the numbers the panes print; the months sum to the
// same values since #2201 (reversed originals counted in both), so the
// footer reads the totals rather than re-adding the rows.
return {
rows,
total: {
income: roundOre(report.totalRevenue),
expenses: roundOre(report.totalExpenses),
net: roundOre(report.netResult),
},
}
}
+5 -1
View File
@@ -72,7 +72,10 @@ export function readPreferencesBody(body: unknown): KPIPreferences | null {
!Array.isArray(candidate.kpiOrder) ||
!overrides ||
typeof overrides !== 'object' ||
Array.isArray(overrides)
Array.isArray(overrides) ||
// Both routes merge defaults before answering, so the flag is always a
// boolean from this server; anything else is not a preferences object.
typeof candidate.showMonthlyTable !== 'boolean'
) {
return null
}
@@ -81,6 +84,7 @@ export function readPreferencesBody(body: unknown): KPIPreferences | null {
visibleKpis: candidate.visibleKpis,
kpiOrder: candidate.kpiOrder,
accountOverrides: overrides,
showMonthlyTable: candidate.showMonthlyTable,
}
}