feat(reports): behandlingshistorik report (BFL 5 kap. 11 §, BFNAR 2013:2 p. 9.16) (#1787)
* feat(reports): behandlingshistorik report (BFL 5 kap. 11 §, BFNAR 2013:2 p. 9.16) Adds the per-räkenskapsår processing history as a first-class report in Rapporter (Export & arkiv), with CSV/XLSX export. Until now the behandlingshistorik only existed as raw audit_log JSON inside the Säkerhetsbackup ZIP; revisorer ask for a readable per-year document. - lib/reports/behandlingshistorik.ts: read model over journal_entries (committed_at = registreringsdatum, the complete source of bokföringsposter), the trigger-written audit_log (storno, deletions, diffs, kontoplan, settings, period lock/unlock/close, API keys, dimensions, accruals), the rättelse log, company_migration_resets, sie_imports and bank_file_imports. Field-level diffs with Swedish labels; company_settings restricted to processing-relevant keys (p. 9.16 second paragraph); kontoplan seeding and bulk underlag deletions collapse into one summary row; actor labels for users, API keys, MCP, agent, cron and system; fiscal-year mode unions audit rows touching the year's entries regardless of timestamp (bokslut/storno land after period_end), date-range mode narrows by registration time. - GET /api/reports/behandlingshistorik?period_id&from_date&to_date&category&format (json|csv|xlsx), withRouteContext + Zod, e-mail labels via service-role profiles lookup scoped to the ids in the result, app version stamped. - Report catalog row + focused view (category filter, export menu), sv/en. - Tests: 30 read-model tests, 10 route tests; smoke-tested read-only on prod. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kw2CFCEt8MxzbJiXMAgMVi * fix(reports): keep behandlingshistorik queries statically resolvable for the schema guard tests/schema/no-phantom-columns.test.ts counts `.or()` calls with non-literal arguments as unresolvable and holds a ceiling (379); the report added two. The audit_log table/action filter is now a string literal in the call (pinned to AUDITED_TABLES / GLOBAL_ACTIONS by a unit test), and the migration-reset lookup is two plain `.eq()` queries instead of an interpolated `.or()`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kw2CFCEt8MxzbJiXMAgMVi --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
d3409183c0
commit
4be51aae67
@@ -0,0 +1,187 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AlertCircle, History } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
|
||||
import { formatDateTime } from '@/lib/utils'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type { DateRangeValue } from '@/components/common/ReportDateRange'
|
||||
import {
|
||||
BEHANDLINGSHISTORIK_CATEGORIES,
|
||||
type BehandlingshistorikCategory,
|
||||
type BehandlingshistorikReport,
|
||||
} from '@/lib/reports/behandlingshistorik-types'
|
||||
|
||||
type CategoryFilter = 'all' | BehandlingshistorikCategory
|
||||
|
||||
function buildQuery(periodId: string, dateRange: DateRangeValue): string {
|
||||
const params = new URLSearchParams({ period_id: periodId })
|
||||
if (dateRange.fromDate) params.set('from_date', dateRange.fromDate)
|
||||
if (dateRange.toDate) params.set('to_date', dateRange.toDate)
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandlingshistorik report body (BFL 5 kap. 11 §). Event labels arrive in
|
||||
* Swedish from the read model (räkenskapsinformation); the chrome here
|
||||
* (headers, filter, counts) is translated.
|
||||
*/
|
||||
export function BehandlingshistorikView({
|
||||
periodId,
|
||||
dateRange,
|
||||
}: {
|
||||
periodId: string
|
||||
dateRange: DateRangeValue
|
||||
}) {
|
||||
const t = useTranslations('reports')
|
||||
const [data, setData] = useState<BehandlingshistorikReport | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState<CategoryFilter>('all')
|
||||
|
||||
const query = buildQuery(periodId, dateRange)
|
||||
|
||||
useEffect(() => {
|
||||
if (!periodId) return
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/reports/behandlingshistorik?${query}`)
|
||||
const result = await res.json()
|
||||
if (cancelled) return
|
||||
if (!res.ok || result.error) {
|
||||
setError(getErrorMessage(result))
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError(t('bh_error'))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
run()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// t is stable for the mounted locale; the query string is the real input.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [periodId, query])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!data) return []
|
||||
if (filter === 'all') return data.events
|
||||
return data.events.filter((e) => e.category === filter)
|
||||
}, [data, filter])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-2">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Skeleton key={i} className="h-4 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-destructive">
|
||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.events.length === 0) {
|
||||
return <EmptyState icon={History} title={t('bh_empty_title')} description={t('bh_empty_desc')} />
|
||||
}
|
||||
|
||||
const filterOptions = [
|
||||
{ value: 'all' as CategoryFilter, label: t('bh_filter_all'), count: data.total_events },
|
||||
...BEHANDLINGSHISTORIK_CATEGORIES.filter((c) => data.by_category[c] > 0).map((c) => ({
|
||||
value: c as CategoryFilter,
|
||||
label: t(`bh_cat_${c}`),
|
||||
count: data.by_category[c],
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="overflow-x-auto">
|
||||
<SegmentedControl
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
options={filterOptions}
|
||||
aria-label={t('bh_filter_aria')}
|
||||
/>
|
||||
</div>
|
||||
<ReportExportMenu
|
||||
items={[
|
||||
{ format: 'xlsx', href: `/api/reports/behandlingshistorik?${query}&format=xlsx` },
|
||||
{ format: 'csv', href: `/api/reports/behandlingshistorik?${query}&format=csv` },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground tabular-nums">
|
||||
{t('bh_summary', { count: data.total_events })} · {data.range.from} {t('bh_range_to')} {data.range.to}
|
||||
{data.app_version ? ` · ${t('bh_version')}: ${data.app_version}` : ''}
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[760px]">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="px-4 py-2 w-40">{t('bh_col_time')}</th>
|
||||
<th className="px-4 py-2">{t('bh_col_event')}</th>
|
||||
<th className="px-4 py-2 w-56">{t('bh_col_actor')}</th>
|
||||
<th className="px-4 py-2">{t('bh_col_details')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.map((e) => (
|
||||
<tr key={e.id} className="border-b last:border-0 align-top">
|
||||
<td className="px-4 py-2 tabular-nums whitespace-nowrap text-muted-foreground">
|
||||
{formatDateTime(e.occurred_at)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div>{e.event}</div>
|
||||
{e.object && (
|
||||
<div className="font-mono text-xs text-muted-foreground">{e.object}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground break-words">{e.actor.label}</td>
|
||||
<td className="px-4 py-2">
|
||||
{e.details.length > 0 && (
|
||||
<ul className="space-y-0.5 text-xs text-muted-foreground">
|
||||
{e.details.map((d, i) => (
|
||||
<li key={i}>{d}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -56,6 +56,10 @@ const INK2DeclarationView = dynamic(() =>
|
||||
import('./INK2DeclarationView').then((module) => ({ default: module.INK2DeclarationView })),
|
||||
{ loading: ReportViewLoading },
|
||||
)
|
||||
const BehandlingshistorikView = dynamic(() =>
|
||||
import('./BehandlingshistorikView').then((module) => ({ default: module.BehandlingshistorikView })),
|
||||
{ loading: ReportViewLoading },
|
||||
)
|
||||
const BankReconciliationView = dynamic(() =>
|
||||
import('./BankReconciliationView').then((module) => ({ default: module.BankReconciliationView })),
|
||||
{ loading: ReportViewLoading },
|
||||
@@ -269,6 +273,8 @@ function FocusedView({
|
||||
return <ARLedgerView periodId={periodId} />
|
||||
case 'supplier-ledger':
|
||||
return <SupplierLedgerView periodId={periodId} />
|
||||
case 'behandlingshistorik':
|
||||
return <BehandlingshistorikView periodId={periodId} dateRange={dateRange} />
|
||||
case 'bank-reconciliation':
|
||||
return (
|
||||
<BankReconciliationView
|
||||
|
||||
Reference in New Issue
Block a user