Files
a7dcaac6ad 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>
2026-09-08 21:19:19 +02:00

112 lines
4.4 KiB
TypeScript

import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { sparsePatchBody } from '@/lib/api/sparse-patch'
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
import type { KPIPreferences } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const EXTENSION_ID = 'core/kpi'
const KEY = 'preferences'
/**
* Deliberately carries no `.default()`: the defaults belong to
* mergeWithDefaults() on the read path, not to the parse of a write. A schema
* default here would resurrect on every save exactly the way a `.partial()`
* schema's defaults do. Keys are all optional so the route can tell "the
* caller set this" from "the caller said nothing about this".
*/
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 }) => {
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', EXTENSION_ID)
.eq('key', KEY)
.single()
const preferences = mergeWithDefaults((data?.value as Partial<KPIPreferences>) ?? {})
return NextResponse.json({ data: preferences })
})
export const PUT = withRouteContext(
'kpi.preferences.update',
async (request, { supabase, companyId, user }) => {
const validation = await validateBody(request, sparsePatchBody(UpdateKPIPreferencesSchema))
if (!validation.success) return validation.response
const prefs = validation.data
// Validate account overrides: must be 4-digit numeric strings
if (prefs.accountOverrides) {
for (const [kpiId, accounts] of Object.entries(prefs.accountOverrides)) {
for (const acc of accounts) {
if (!/^\d{4}$/.test(acc)) {
return NextResponse.json(
{ error: `Invalid account number "${acc}" in ${kpiId}: must be 4 digits` },
{ status: 400 }
)
}
}
}
}
// Merge over what is STORED, not over the defaults. mergeWithDefaults()
// fills every absent key with its default value, so saving one setting
// used to reset the other two: a PUT of `{ accountOverrides: … }` wiped
// visibleKpis and kpiOrder back to factory settings. Defaults still apply
// to a row that has never been written (and on the read path), but they no
// longer overwrite a stored choice the caller never mentioned. The
// settings dialog always sends the complete object (its reset button fills
// the draft with getDefaultPreferences() before saving), so its behaviour
// is unchanged; only sparse callers stop losing data.
const { data: existing } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', EXTENSION_ID)
.eq('key', KEY)
.maybeSingle()
const stored = mergeWithDefaults((existing?.value as Partial<KPIPreferences>) ?? {})
const merged: KPIPreferences = { ...stored, ...prefs }
// KPI preferences are COMPANY-scoped, not per-user: the read paths (GET
// above and the KPI report route) filter on (company_id, extension_id,
// key) with no user filter, and the key carries no user id. user_id is
// stored purely as "who wrote this last" attribution. The upsert must
// therefore arbitrate on the company-scoped unique constraint: migration
// 20260330130000 dropped UNIQUE (user_id, extension_id, key) in favor of
// UNIQUE (company_id, extension_id, key), so naming the old column trio
// here makes Postgres fail every save with 42P10 (no matching constraint).
const { data, error } = await supabase
.from('extension_data')
.upsert(
{
user_id: user.id,
company_id: companyId,
extension_id: EXTENSION_ID,
key: KEY,
value: merged,
},
{ onConflict: 'company_id,extension_id,key' }
)
.select()
.single()
if (error) {
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data: data.value })
},
{ requireWrite: true }
)