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
parent 5e2498bc2f
commit a7dcaac6ad
16 changed files with 238 additions and 2 deletions
+1
View File
@@ -1671,6 +1671,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-08] Medelantal anställda (Not 2, ÅRL 5:20 §) gets a whole-number override on arsredovisning_narratives (migration 20260908130127) instead of the free-text note override the support request asked for. Why: the number keeps the statutory sentence and the iXBRL MedelantaletAnstallda fact correct; free text would let a non-compliant note through and could not be tagged. One resolver (lib/salary/medelantal.ts resolveMedelantalAnstallda: override, else FTE average over employees) feeds the K2 and K3 note builders and the iXBRL input, which also reads the previous period's override so the jämförelseår column shows the same figure the previous year's document did. Rejected: rounding 0.5 up globally (silently changes every company's note and does nothing for the 148 of 195 aktiebolag with salary but no employees rows); asking the user to backdate employment_start (fixes one company, misstates the hire date).
[2026-09-08] Issue #2413 BAS 2026 kontogrupp 12: kept 1249/1259/1269 in the catalog renamed after their free heads and dropped only 1241/1242/1251/1261, instead of removing all seven retired sub-accounts and moving the asset module's vehicle/computer defaults to BAS 2026 (1226/1224 on 1229): the asset module's DEFAULT_ACCOUNTS_BY_CATEGORY still books vehicles on 1240/1249 and computers on 1250/1259 (31 live assets in prod, guard test requires the triple in BAS_REFERENCE), so dropping the contra accounts would have forced a depreciation-default change into a label fix; that change is the founder's call and lives in #2414. The prod backfill renames only the exact catalog literal next to a free-labelled head, so old-BAS imports (1240 Bilar + 1249 Ack. avskr. bilar) and user renames stay untouched.
[2026-09-08] Migration files must carry their own BEGIN/COMMIT when they use transaction-only statements (LOCK TABLE, SET LOCAL, SET CONSTRAINTS): CI replays each file with psql -f in autocommit and the Supabase branch runner does the same on prod, so the bare LOCK TABLE in 20260908113353 (#2413, PR #2419) failed both and stalled prod's migration queue behind it. Prod never recorded the failed version, so the file was deleted and re-issued as 20260908120449 rather than edited in place.
[2026-09-08] Issue #2196 Nyckeltal per month: the month table (income, expenses, net) is ON by default and toggled by a boolean preference showMonthlyTable instead of a KPI_DEFINITIONS entry. Why: every stored preferences row already carries a complete kpiOrder array, and KPIPanes renders from that array, so a new definition id would stay hidden for every existing company no matter its defaultVisible; a boolean absent from the stored JSON resolves through mergeWithDefaults on read. The payload has carried income and expenses per month since the RPC (only net was rendered after the Recharts chart was dropped), so no API or migration change. #2185's multi-year view stays separate: it needs several fiscal periods fetched side by side, which this page does not do.
[2026-09-08] #2425 ROT/RUT: the picker's begäran rows keep single-click handoff and gain a checkbox per row for a bundled pick, instead of a new multi-select mode or an ambiguity-tolerant matcher. Why: the server (request_ids, settleRotRutPayoutRequestSet) and the confirm dialog already take N begäran since #2360; only the manual fallback was 1:1, and a firm whose jobs share a price hits the matcher's ambiguity refusal often. Guessing between two same-amount begäran would clear the wrong 1513 fordran, so the choice stays with the user.
[2026-09-08] Issue #2426 ROT/RUT begäran state in the invoice list: normal states (Att begära, Skapad, Uppladdad, Beviljad) render as muted text and only Delvis beviljad and Avslagen get a chip (design convention 5: a fönsterputsfirma where every invoice is ROT/RUT would otherwise carry the same chip on every row); the filter is a third ContextPicker (?rotrut=) gated on rot_rut_enabled or an invoiced deduction, not extra entries in the status view, so a ROT/RUT state can be combined with any status view and companies without ROT/RUT never see it. One predicate (lib/invoices/rot-rut-list-status.ts) feeds the column, the filter and its counts; the state is read from a rot_rut_payout_request_items embed on the existing list query, no new API and no migration.
[2026-09-08] Receipt purchase date: fixed the extraction prompt's description of invoice.invoiceDate (schema comment plus an explicit "invoiceDate on receipts" rule) instead of adding a receipt-specific purchaseDate field. Why: prod (last 30 days) showed receipts losing the date on 46% of items (75% via WhatsApp) while supplier invoices lost it on 0.4%, and purchaseTime was filled on nearly every affected receipt: the date was described as a bare ISO field under the invoice block beside a purchaseTime rule marked "receipts only", and the model read that as "invoice-only". A new field would need a schema change, a UI column and every consumer (WhatsApp M4 ack date, the Bokför entry-date default that falls back to today, the worklist) taught a second date; the prompt fix stops the class with no data-shape change. Existing undated items are not re-extracted by this; a backfill is a separate call.
+4
View File
@@ -11,6 +11,7 @@ import { AttnLine } from '@/components/ui/attn-line'
import { useToast } from '@/components/ui/use-toast'
import { FyPicker } from '@/components/common/FyPicker'
import { KPIPanes, KPIBreakdown } from '@/components/kpi/KPIStory'
import { KPIMonthsTable } from '@/components/kpi/KPIMonthsTable'
import { KPISettingsDialog } from '@/components/kpi/KPISettingsDialog'
import { saveKPIPreferences } from '@/components/kpi/save-preferences'
import { loadKPIPreferences } from '@/components/kpi/load-preferences'
@@ -223,6 +224,9 @@ export default function KpiPage() {
unknown they stay off rather than render defaults as if they
were the user's. The cost story below reads only the report. */}
{preferences && <KPIPanes report={report} preferences={preferences} />}
{/* Same rule as the panes: a layout flag is only honoured once the
stored layout is known. */}
{preferences?.showMonthlyTable && <KPIMonthsTable report={report} />}
<KPIBreakdown report={report} />
</div>
)}
@@ -178,6 +178,7 @@ describe('PUT /api/kpi/preferences merge semantics', () => {
visibleKpis: ['kpi_a'],
kpiOrder: ['kpi_a', 'kpi_b'],
accountOverrides: { kpi_b: ['4010'] },
showMonthlyTable: true,
})
})
@@ -186,6 +187,7 @@ describe('PUT /api/kpi/preferences merge semantics', () => {
visibleKpis: ['kpi_z'],
kpiOrder: ['kpi_z'],
accountOverrides: { kpi_z: ['3010'] },
showMonthlyTable: false,
}
const { value } = await put(full, stored)
expect(value).toEqual(full)
@@ -202,7 +204,18 @@ describe('PUT /api/kpi/preferences merge semantics', () => {
it('an empty body stores the stored value unchanged', async () => {
const { value } = await put({}, stored)
expect(value).toEqual(stored)
expect(value).toEqual({ ...stored, showMonthlyTable: true })
})
it('stores showMonthlyTable false and leaves the other keys alone', async () => {
const { res, value } = await put({ showMonthlyTable: false }, stored)
expect(res.status).toBe(200)
expect(value).toEqual({ ...stored, showMonthlyTable: false })
})
it('rejects a non-boolean showMonthlyTable', async () => {
const { res } = await put({ showMonthlyTable: 'yes' }, stored)
expect(res.status).toBe(400)
})
it('upserts against the company-scoped unique constraint', async () => {
+1
View File
@@ -21,6 +21,7 @@ const UpdateKPIPreferencesSchema = z.object({
visibleKpis: z.array(z.string()).optional(),
kpiOrder: z.array(z.string()).optional(),
accountOverrides: z.record(z.string(), z.array(z.string())).optional(),
showMonthlyTable: z.boolean().optional(),
})
export const GET = withRouteContext('kpi.preferences.get', async (_request, { supabase, companyId }) => {
+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,
}
}
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import {
getDefaultPreferences,
mergeWithDefaults,
KPI_DEFINITIONS,
} from '@/lib/reports/kpi-definitions'
describe('KPI preferences defaults', () => {
it('shows the monthly table by default', () => {
expect(getDefaultPreferences().showMonthlyTable).toBe(true)
})
it('does not model the monthly table as a KPI definition', () => {
// A definition id would be hidden by every stored kpiOrder array (#2196).
expect(KPI_DEFINITIONS.some((d) => d.id === 'showMonthlyTable')).toBe(false)
expect(getDefaultPreferences().kpiOrder).not.toContain('showMonthlyTable')
})
it('fills showMonthlyTable from the defaults for a row stored before the flag existed', () => {
const merged = mergeWithDefaults({
visibleKpis: ['netResult'],
kpiOrder: ['netResult'],
accountOverrides: {},
})
expect(merged.showMonthlyTable).toBe(true)
})
it('keeps a stored false', () => {
expect(mergeWithDefaults({ showMonthlyTable: false }).showMonthlyTable).toBe(false)
})
})
+2
View File
@@ -78,6 +78,7 @@ export function getDefaultPreferences(): KPIPreferences {
visibleKpis: KPI_DEFINITIONS.filter((d) => d.defaultVisible).map((d) => d.id),
kpiOrder: ALL_KPI_IDS,
accountOverrides: {},
showMonthlyTable: true,
}
}
@@ -87,5 +88,6 @@ export function mergeWithDefaults(prefs: Partial<KPIPreferences>): KPIPreference
visibleKpis: prefs.visibleKpis ?? defaults.visibleKpis,
kpiOrder: prefs.kpiOrder ?? defaults.kpiOrder,
accountOverrides: prefs.accountOverrides ?? defaults.accountOverrides,
showMonthlyTable: prefs.showMonthlyTable ?? defaults.showMonthlyTable,
}
}
+4
View File
@@ -5954,6 +5954,8 @@
"trend_legend_income": "Revenue",
"trend_legend_expenses": "Expenses",
"trend_legend_net": "Net result",
"months_col_month": "Month",
"months_total": "Total",
"expense_mix_title": "Expenses by class",
"expense_mix_empty": "No posted expenses yet",
"expense_mix_total": "Total",
@@ -6010,6 +6012,8 @@
"settings_account_hint": "Enter account numbers separated by commas (e.g. {example})",
"settings_reset_field": "Reset to default",
"settings_reset_all": "Reset all",
"settings_monthly_table_label": "Monthly table",
"settings_monthly_table_description": "Revenue, expenses and result month by month.",
"help_text": "The key figures are computed from the books for the selected fiscal year. You control the panes via Customize; expense classes and suppliers sum the period's spending.",
"costs_title": "Largest expenses",
"bars_title": "Result per month",
+4
View File
@@ -5954,6 +5954,8 @@
"trend_legend_income": "Intäkter",
"trend_legend_expenses": "Kostnader",
"trend_legend_net": "Resultat",
"months_col_month": "Månad",
"months_total": "Summa",
"expense_mix_title": "Kostnader per klass",
"expense_mix_empty": "Inga bokförda kostnader ännu",
"expense_mix_total": "Totalt",
@@ -6010,6 +6012,8 @@
"settings_account_hint": "Ange kontonummer separerade med komma (t.ex. {example})",
"settings_reset_field": "Återställ till standard",
"settings_reset_all": "Återställ allt",
"settings_monthly_table_label": "Tabell per månad",
"settings_monthly_table_description": "Intäkter, kostnader och resultat månad för månad.",
"help_text": "Nyckeltalen räknas ur bokföringen för det valda räkenskapsåret. Panelerna styr du själv via Anpassa; kostnadsslag och leverantörer summerar periodens utgifter.",
"costs_title": "Största kostnaderna",
"bars_title": "Resultat per månad",
+6
View File
@@ -4071,6 +4071,12 @@ export interface KPIPreferences {
visibleKpis: string[]
kpiOrder: string[]
accountOverrides: Record<string, string[]>
/**
* The month-by-month table (income, expenses, net) under the panes. A
* boolean rather than a KPI_DEFINITIONS id on purpose: every stored row
* already carries a complete kpiOrder, which would hide a new id (#2196).
*/
showMonthlyTable: boolean
}
// ============================================================