diff --git a/DECISIONS.md b/DECISIONS.md index 4b3558b6..f8a2a611 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1671,6 +1671,7 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/kpi/page.tsx b/app/(dashboard)/kpi/page.tsx index a2ed27f4..b6f6fabe 100644 --- a/app/(dashboard)/kpi/page.tsx +++ b/app/(dashboard)/kpi/page.tsx @@ -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 && } + {/* Same rule as the panes: a layout flag is only honoured once the + stored layout is known. */} + {preferences?.showMonthlyTable && } )} diff --git a/app/api/kpi/preferences/__tests__/route.test.ts b/app/api/kpi/preferences/__tests__/route.test.ts index 1608929c..7b5e41dc 100644 --- a/app/api/kpi/preferences/__tests__/route.test.ts +++ b/app/api/kpi/preferences/__tests__/route.test.ts @@ -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 () => { diff --git a/app/api/kpi/preferences/route.ts b/app/api/kpi/preferences/route.ts index b2cc14ea..bab2285c 100644 --- a/app/api/kpi/preferences/route.ts +++ b/app/api/kpi/preferences/route.ts @@ -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 }) => { diff --git a/components/kpi/KPIMonthsTable.tsx b/components/kpi/KPIMonthsTable.tsx new file mode 100644 index 00000000..5e62cfbd --- /dev/null +++ b/components/kpi/KPIMonthsTable.tsx @@ -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 ( +
+
+

+ {t('trend_title')} +

+
+
+
+ + + + + + + + + + + {rows.map((m) => ( + + + + + + + ))} + + + + + + + + + +
{t('months_col_month')}{t('trend_legend_income')}{t('trend_legend_expenses')}{t('trend_legend_net')}
{m.label}{formatCurrency(m.income)}{formatCurrency(m.expenses)} + {formatCurrency(m.net)} +
{t('months_total')}{formatCurrency(total.income)}{formatCurrency(total.expenses)} + {formatCurrency(total.net)} +
+
+
+ ) +} diff --git a/components/kpi/KPISettingsDialog.tsx b/components/kpi/KPISettingsDialog.tsx index c3176f0d..2078d666 100644 --- a/components/kpi/KPISettingsDialog.tsx +++ b/components/kpi/KPISettingsDialog.tsx @@ -210,6 +210,25 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
) })} + {/* The month table is a layout flag, not a metric: its own row + after the KPI list, no expand panel, no account override. */} +
+
+
+

{t('settings_monthly_table_label')}

+

+ {t('settings_monthly_table_description')} +

+
+ + setDraft((prev) => ({ ...prev, showMonthlyTable: checked })) + } + aria-label={t('settings_monthly_table_label')} + /> +
+
diff --git a/components/kpi/__tests__/load-preferences.test.ts b/components/kpi/__tests__/load-preferences.test.ts index 52016788..6296dbb7 100644 --- a/components/kpi/__tests__/load-preferences.test.ts +++ b/components/kpi/__tests__/load-preferences.test.ts @@ -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 { diff --git a/components/kpi/__tests__/months-table.test.ts b/components/kpi/__tests__/months-table.test.ts new file mode 100644 index 00000000..15e930e7 --- /dev/null +++ b/components/kpi/__tests__/months-table.test.ts @@ -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([]) + }) +}) diff --git a/components/kpi/__tests__/save-preferences.test.ts b/components/kpi/__tests__/save-preferences.test.ts index 0a572008..cfae1137 100644 --- a/components/kpi/__tests__/save-preferences.test.ts +++ b/components/kpi/__tests__/save-preferences.test.ts @@ -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 })) diff --git a/components/kpi/months-table.ts b/components/kpi/months-table.ts new file mode 100644 index 00000000..e76ca723 --- /dev/null +++ b/components/kpi/months-table.ts @@ -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): 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), + }, + } +} diff --git a/components/kpi/save-preferences.ts b/components/kpi/save-preferences.ts index 7de39669..320dff68 100644 --- a/components/kpi/save-preferences.ts +++ b/components/kpi/save-preferences.ts @@ -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, } } diff --git a/lib/reports/__tests__/kpi-definitions.test.ts b/lib/reports/__tests__/kpi-definitions.test.ts new file mode 100644 index 00000000..9d772887 --- /dev/null +++ b/lib/reports/__tests__/kpi-definitions.test.ts @@ -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) + }) +}) diff --git a/lib/reports/kpi-definitions.ts b/lib/reports/kpi-definitions.ts index 417e1103..44647034 100644 --- a/lib/reports/kpi-definitions.ts +++ b/lib/reports/kpi-definitions.ts @@ -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): KPIPreference visibleKpis: prefs.visibleKpis ?? defaults.visibleKpis, kpiOrder: prefs.kpiOrder ?? defaults.kpiOrder, accountOverrides: prefs.accountOverrides ?? defaults.accountOverrides, + showMonthlyTable: prefs.showMonthlyTable ?? defaults.showMonthlyTable, } } diff --git a/messages/en.json b/messages/en.json index d416d5f4..676c10da 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 8668b8cc..418ff115 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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", diff --git a/types/index.ts b/types/index.ts index 6f34f4c3..7035c881 100644 --- a/types/index.ts +++ b/types/index.ts @@ -4071,6 +4071,12 @@ export interface KPIPreferences { visibleKpis: string[] kpiOrder: string[] accountOverrides: Record + /** + * 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 } // ============================================================