feat(dimensions): PR4 reports — dimension-filtered P&L + Resultat per projekt/kostnadsställe (#862)

* feat(dimensions): PR4 reports — dimension-filtered P&L everywhere + Resultat per projekt/kostnadsställe

The Project P&L milestone of the dimensions plan (dev_docs §7 PR4).

One choke point lights up everything: generateTrialBalance gains
options.dimensions (SIE dim → code map) pushed down as jsonb containment
(dimensions @>, served by idx_jel_dimensions_gin) on both line queries, with
company-wide opening balances dropped when filtered (they cannot be
dimension-scoped; P&L-safe by whitelist). Resultatrapport, resultaträkning,
huvudbok, monthly-breakdown and the TB drill-down inherit the filter; the
KPI route filters only its P&L-side inputs (income statement, months,
expense composition) — never cash/VAT.

New report lib/reports/dimension-pnl.ts — "Resultat per projekt/
kostnadsställe" (Fortnox Resultatrapport projekt): value-as-column matrix
over one dimension with an explicit "(Utan dimension)" bucket computed as
the residual against the same trial-balance pass resultatrapport uses, so
every row and the Totalt column reconcile with the unfiltered
resultatrapport by construction. Registered in REPORT_CATALOG (visible only
when dimensions_enabled), slug-routed view + xlsx export.

UI: DimensionFilter (dimension + value picker, persistent "Filtrerad — ej
fullständig rapport" chip) mounts in FocusedReport for catalog entries
flagged dimensions: true; huvudbok rows show line dim codes.

Statutory exclusion pinned by TEST, not convention:
lib/reports/__tests__/dimension-statutory-guard.test.ts fails if the filter
parser leaks into balance sheet, balansrapport, kassaflöde, VAT, SIE or
full-archive routes/generators, or if the catalog whitelist widens.

MCP: new gnubok_get_dimension_pnl (reports:read); dimensions filter arg on
get_trial_balance/get_income_statement/get_general_ledger with
resolve-don't-select (names → registry codes, resolution echoes);
query_journal totals fixed to aggregate the FULL match set (was silently
slice-scoped while claiming otherwise) with an honest totals_scope field,
plus group_by / group_by_dimension aggregation.

Also: voucher-detail dim-6 badge now uses the registry name instead of the
non-standard "PR" abbreviation (#859 review follow-up).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dimensions): address #862 review — export disclosure, prior-column suppression, period-label honesty, route hardening

- Filtered XLSX/PDF exports now carry the partial-view disclosure past the
  file boundary (BFNAR 2013:2): filename suffix (-dim6-p001), a
  "Filtrerad … — ej fullständig rapport" row on every sheet, and a header
  note/title line in the PDFs.
- Resultatrapport drops the prior-year column when a dimension filter is
  active — project codes are time-limited under K2/K3, so "this code last
  year" may be a different project (same rule as narrowed date ranges).
- dimension-pnl no longer accepts fromDate: the matrix is cumulative from
  period_start by design (closing-balance semantics), and the period label
  now states exactly that instead of echoing a lower bound that was never
  applied. Routes/MCP tool updated to toDate-only.
- dimension-pnl routes 404 on an unknown/foreign period id and cap dim_no
  to 4 digits (matching the MCP tool's PostgREST-path guard, which the
  generator now also enforces itself).
- Statutory-guard test's generateTrialBalance call-site scan is paren-aware
  instead of a 300-char window; added fully-untagged and injection-guard
  test cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 15:20:47 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 86071334cb
commit 01dbef4015
41 changed files with 2694 additions and 129 deletions
+125
View File
@@ -0,0 +1,125 @@
'use client'
import { useEffect, useState } from 'react'
import { X } from 'lucide-react'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
import { useCompanySettings } from '@/components/settings/useSettings'
import { fetchDimensions, type DimensionDto } from '@/components/dimensions/types'
export type DimensionFilterValue = {
/** SIE dimension number as a string ('1' kostnadsställe, '6' projekt). */
dimNo: string
/** Selected object code. */
code: string
}
interface Props {
value: DimensionFilterValue | null
onChange: (next: DimensionFilterValue | null) => void
}
/**
* Per-dimension value filter for the P&L-safe reports (resultatrapport,
* resultaträkning, huvudbok, KPI). Mounted by FocusedReport next to
* ReportDateRange, only for catalog entries flagged `dimensions: true`.
*
* Renders nothing unless company_settings.dimensions_enabled — companies
* that never activated dimensions see literally nothing changed.
*
* When active it shows a persistent "Filtrerad … — ej fullständig rapport"
* chip: a dimension-scoped view is a partial view and must never be read as
* the complete report. Strings are hardcoded Swedish per the report-surface
* convention (same as DimensionCombobox).
*/
export function DimensionFilter({ value, onChange }: Props) {
const { settings } = useCompanySettings()
const [dims, setDims] = useState<DimensionDto[]>([])
// Which dimension the picker targets while no value is selected yet;
// once a value is picked, `value.dimNo` is the source of truth.
const [pendingDimNo, setPendingDimNo] = useState('6')
const enabled = settings?.dimensions_enabled === true
useEffect(() => {
if (!enabled) return
let cancelled = false
fetchDimensions()
.then((rows) => {
if (!cancelled) setDims(rows)
})
.catch(() => {
// Best-effort: without the registry the filter simply doesn't render.
})
return () => {
cancelled = true
}
}, [enabled])
if (!enabled || dims.length === 0) return null
const activeDimNo = value?.dimNo ?? pendingDimNo
const activeDim = dims.find((d) => String(d.sie_dim_no) === activeDimNo)
return (
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Dimension
</Label>
<div className="flex flex-wrap items-center gap-2">
<Select
value={activeDimNo}
onValueChange={(dimNo) => {
// Switching dimension clears the picked value — codes are
// namespaced per dimension.
if (value) onChange(null)
setPendingDimNo(dimNo)
}}
>
<SelectTrigger className="h-10 w-[170px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{dims.map((d) => (
<SelectItem key={d.sie_dim_no} value={String(d.sie_dim_no)}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="w-[200px]">
<DimensionCombobox
sieDimNo={activeDimNo}
value={value?.code ?? null}
onChange={(code) =>
onChange(code ? { dimNo: activeDimNo, code } : null)
}
/>
</div>
{value && (
<Button
variant="ghost"
size="icon"
onClick={() => onChange(null)}
aria-label="Rensa dimensionsfilter"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{value && (
<Badge variant="warning" className="w-fit">
Filtrerad: {activeDim?.name ?? `Dim ${value.dimNo}`} {value.code} — ej fullständig rapport
</Badge>
)}
</div>
)
}
+16 -4
View File
@@ -12,7 +12,8 @@ import { Skeleton } from '@/components/ui/skeleton'
import { useCompany } from '@/contexts/CompanyContext'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange'
import { DATE_RANGE_SLUGS, getReport } from '@/lib/reports/catalog'
import { DimensionFilter, type DimensionFilterValue } from '@/components/reports/DimensionFilter'
import { DATE_RANGE_SLUGS, DIMENSION_FILTER_SLUGS, getReport } from '@/lib/reports/catalog'
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
import { PeriodiskSammanstallningView } from '@/components/reports/PeriodiskSammanstallningView'
import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView'
@@ -28,6 +29,7 @@ import {
GeneralLedgerView,
JournalRegisterView,
ARLedgerView,
DimensionPnlView,
} from '@/components/reports/views'
/**
@@ -46,6 +48,7 @@ function FocusedReportInner({ slug }: { slug: string }) {
const [selectedPeriod, setSelectedPeriod] = useState('')
const [selectedPeriodBounds, setSelectedPeriodBounds] = useState<{ start: string; end: string } | null>(null)
const [dateRange, setDateRange] = useState<DateRangeValue>({})
const [dimensionFilter, setDimensionFilter] = useState<DimensionFilterValue | null>(null)
const [isReady, setIsReady] = useState(false)
const report = getReport(slug)
@@ -101,6 +104,10 @@ function FocusedReportInner({ slug }: { slug: string }) {
/>
)}
{DIMENSION_FILTER_SLUGS.has(slug) && selectedPeriod && (
<DimensionFilter value={dimensionFilter} onChange={setDimensionFilter} />
)}
{!isReady && !isPeriodless ? (
<Card>
<CardContent className="p-6 space-y-4">
@@ -114,6 +121,7 @@ function FocusedReportInner({ slug }: { slug: string }) {
periodId={selectedPeriod}
periodBounds={selectedPeriodBounds}
dateRange={dateRange}
dimensionFilter={dimensionFilter}
accountFilter={accountFilter}
isEnskildFirma={isEnskildFirma}
isAktiebolag={isAktiebolag}
@@ -136,6 +144,7 @@ function FocusedView({
periodId,
periodBounds,
dateRange,
dimensionFilter,
accountFilter,
isEnskildFirma,
isAktiebolag,
@@ -145,6 +154,7 @@ function FocusedView({
periodId: string
periodBounds: { start: string; end: string } | null
dateRange: DateRangeValue
dimensionFilter: DimensionFilterValue | null
accountFilter: string | null
isEnskildFirma: boolean
isAktiebolag: boolean
@@ -152,13 +162,15 @@ function FocusedView({
}) {
switch (slug) {
case 'resultatrapport':
return <ResultatrapportView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
return <ResultatrapportView periodId={periodId} dateRange={dateRange} dimensionFilter={dimensionFilter} onNavigateToAccount={onNavigateToAccount} />
case 'dimension-pnl':
return <DimensionPnlView periodId={periodId} dateRange={dateRange} />
case 'balansrapport':
return <BalansrapportView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
case 'trial-balance':
return <TrialBalanceView periodId={periodId} onNavigateToAccount={onNavigateToAccount} />
case 'income-statement':
return <IncomeStatementView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
return <IncomeStatementView periodId={periodId} dateRange={dateRange} dimensionFilter={dimensionFilter} onNavigateToAccount={onNavigateToAccount} />
case 'balance-sheet':
return <BalanceSheetView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
case 'vat-declaration':
@@ -170,7 +182,7 @@ function FocusedView({
case 'ink2-declaration':
return isAktiebolag ? <INK2DeclarationView periodId={periodId} /> : null
case 'huvudbok':
return <GeneralLedgerView periodId={periodId} initialAccountFilter={accountFilter} />
return <GeneralLedgerView periodId={periodId} initialAccountFilter={accountFilter} dimensionFilter={dimensionFilter} />
case 'grundbok':
return <JournalRegisterView periodId={periodId} />
case 'kundreskontra':
+3 -1
View File
@@ -20,14 +20,16 @@ import type { EntityType } from '@/types'
export function ReportLibrary({
entityType,
hasEmployees,
dimensionsEnabled,
onOpen,
}: {
entityType?: EntityType
hasEmployees?: boolean
dimensionsEnabled?: boolean
onOpen: (slug: string) => void
}) {
const t = useTranslations('reports')
const sections = getLibrarySections(entityType, hasEmployees)
const sections = getLibrarySections(entityType, hasEmployees, dimensionsEnabled)
return (
<div className="space-y-8">
+249 -11
View File
@@ -29,12 +29,14 @@ import type {
} from '@/lib/reports/source-lines'
import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart'
import type { DateRangeValue } from '@/components/common/ReportDateRange'
import type { DimensionFilterValue } from '@/components/reports/DimensionFilter'
import type {
TrialBalanceRow,
IncomeStatementReport,
BalanceSheetReport,
ResultatrapportReport,
BalansrapportReport,
DimensionPnlReport,
VatDeclaration,
VatPeriodType,
} from '@/types'
@@ -43,10 +45,18 @@ function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function reportQuery(periodId: string, range?: DateRangeValue): string {
function reportQuery(
periodId: string,
range?: DateRangeValue,
dimensionFilter?: DimensionFilterValue | null,
): string {
const params = new URLSearchParams({ period_id: periodId })
if (range?.fromDate) params.set('from_date', range.fromDate)
if (range?.toDate) params.set('to_date', range.toDate)
if (dimensionFilter) {
params.set('dim_no', dimensionFilter.dimNo)
params.set('dim_code', dimensionFilter.code)
}
return params.toString()
}
@@ -369,13 +379,13 @@ function TrialBalanceDetailedRow({
</>
)
}
export function IncomeStatementView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) {
export function IncomeStatementView({ periodId, dateRange, dimensionFilter = null, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; dimensionFilter?: DimensionFilterValue | null; onNavigateToAccount: (account: string) => void }) {
const [data, setData] = useState<IncomeStatementReport | null>(null)
const [monthlyData, setMonthlyData] = useState<MonthlyDataPoint[]>([])
const [monthlyLoading, setMonthlyLoading] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const reportQs = reportQuery(periodId, dateRange)
const reportQs = reportQuery(periodId, dateRange, dimensionFilter)
useEffect(() => {
setLoading(true)
@@ -399,7 +409,9 @@ export function IncomeStatementView({ periodId, dateRange, onNavigateToAccount }
// Monthly breakdown is full-period by design (it IS the per-month view),
// so the date range only affects the headline numbers above the chart.
fetch(`/api/reports/monthly-breakdown?period_id=${periodId}`)
// The dimension filter DOES apply — a dimension-scoped view must not
// silently chart company-wide months.
fetch(`/api/reports/monthly-breakdown?${reportQuery(periodId, undefined, dimensionFilter)}`)
.then((res) => res.json())
.then((result) => {
if (result.data?.months) {
@@ -648,11 +660,11 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: {
)
}
export function ResultatrapportView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) {
export function ResultatrapportView({ periodId, dateRange, dimensionFilter = null, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; dimensionFilter?: DimensionFilterValue | null; onNavigateToAccount: (account: string) => void }) {
const [data, setData] = useState<ResultatrapportReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const reportQs = reportQuery(periodId, dateRange)
const reportQs = reportQuery(periodId, dateRange, dimensionFilter)
useEffect(() => {
setLoading(true)
@@ -1775,6 +1787,7 @@ interface GeneralLedgerData {
debit: number
credit: number
balance: number
dimensions?: Record<string, string>
}[]
closing_balance: number
total_debit: number
@@ -1783,7 +1796,7 @@ interface GeneralLedgerData {
period: { start: string; end: string }
}
export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId: string; initialAccountFilter: string | null }) {
export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFilter = null }: { periodId: string; initialAccountFilter: string | null; dimensionFilter?: DimensionFilterValue | null }) {
const [data, setData] = useState<GeneralLedgerData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -1799,6 +1812,10 @@ export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId
const params = new URLSearchParams({ period_id: periodId })
if (from) params.set('account_from', from)
if (to) params.set('account_to', to)
if (dimensionFilter) {
params.set('dim_no', dimensionFilter.dimNo)
params.set('dim_code', dimensionFilter.code)
}
const res = await fetch(`/api/reports/general-ledger?${params}`)
const result = await res.json()
if (result.error) {
@@ -1811,7 +1828,7 @@ export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId
} finally {
setLoading(false)
}
}, [periodId, accountFrom, accountTo])
}, [periodId, accountFrom, accountTo, dimensionFilter])
// When initialAccountFilter changes (drill-down from another report), apply it
useEffect(() => {
@@ -1822,7 +1839,7 @@ export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId
} else {
fetchData()
}
}, [periodId, initialAccountFilter])
}, [periodId, initialAccountFilter, dimensionFilter])
if (loading) {
return (
@@ -1857,7 +1874,7 @@ export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId
return (
<div className="space-y-4">
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/general-ledger/xlsx?period_id=${periodId}` }]} />
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/general-ledger/xlsx?${reportQuery(periodId, undefined, dimensionFilter)}` }]} />
{/* Account range filter */}
<Card>
<CardContent className="pt-6">
@@ -1931,7 +1948,17 @@ export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId
</Link>
</td>
<td className="py-1.5">{formatDate(line.date)}</td>
<td className="py-1.5 truncate max-w-[200px]">{line.description}</td>
<td className="py-1.5 max-w-[240px]">
<span className="truncate block">{line.description}</span>
{line.dimensions && Object.keys(line.dimensions).length > 0 && (
<span className="text-[11px] text-muted-foreground tabular-nums">
{Object.entries(line.dimensions)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, code]) => code)
.join(' · ')}
</span>
)}
</td>
<td className="py-1.5 text-right tabular-nums">
{line.debit > 0 ? formatAmount(line.debit) : ''}
</td>
@@ -2505,3 +2532,214 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
</div>
)
}
// --- Resultat per projekt/kostnadsställe (dimension P&L matrix) ---
export function DimensionPnlView({ periodId, dateRange }: { periodId: string; dateRange: DateRangeValue }) {
// Loading is DERIVED (result key ≠ current query string) instead of a
// setState at effect start — keeps react-hooks/set-state-in-effect clean
// and is race-safe when the pivot/date changes mid-flight.
const [result, setResult] = useState<{
qs: string
data: DimensionPnlReport | null
error: string | null
} | null>(null)
const [dims, setDims] = useState<{ sie_dim_no: number; name: string }[]>([])
const [dimNo, setDimNo] = useState('6')
const reportQs = `${reportQuery(periodId, dateRange)}&dim_no=${encodeURIComponent(dimNo)}`
// Registered dimensions for the pivot picker (best-effort; the report
// defaults to projekt if the registry read fails).
useEffect(() => {
fetch('/api/dimensions')
.then((res) => res.json())
.then((payload) => {
if (Array.isArray(payload.data)) {
setDims(payload.data.map((d: { sie_dim_no: number; name: string }) => ({ sie_dim_no: d.sie_dim_no, name: d.name })))
}
})
.catch(() => {})
}, [])
useEffect(() => {
let cancelled = false
fetch(`/api/reports/dimension-pnl?${reportQs}`)
.then((res) => res.json())
.then((payload) => {
if (cancelled) return
if (payload.error) {
setResult({
qs: reportQs,
data: null,
error: typeof payload.error === 'string' ? payload.error : 'Kunde inte hämta rapporten',
})
} else {
setResult({ qs: reportQs, data: payload.data, error: null })
}
})
.catch(() => {
if (!cancelled) setResult({ qs: reportQs, data: null, error: 'Kunde inte hämta rapporten' })
})
return () => {
cancelled = true
}
}, [reportQs])
const loading = result?.qs !== reportQs
const error = loading ? null : result?.error ?? null
const data = loading ? null : result?.data ?? null
const pivotPicker = dims.length > 1 && (
<div className="flex flex-wrap items-center gap-1.5">
{dims.map((d) => {
const active = String(d.sie_dim_no) === dimNo
return (
<button
key={d.sie_dim_no}
type="button"
onClick={() => setDimNo(String(d.sie_dim_no))}
className={
active
? 'px-3 py-1.5 text-xs rounded-md border transition-colors duration-150 bg-secondary border-border text-foreground'
: 'px-3 py-1.5 text-xs rounded-md border transition-colors duration-150 bg-transparent border-border text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
}
>
{d.name}
</button>
)
})}
</div>
)
if (loading) {
return (
<div className="space-y-4">
{pivotPicker}
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar rapport...
</CardContent>
</Card>
</div>
)
}
if (error) {
return (
<div className="space-y-4">
{pivotPicker}
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
</div>
)
}
if (!data || data.groups.length === 0) {
return (
<div className="space-y-4">
{pivotPicker}
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga taggade intäkter eller kostnader i denna period.
</CardContent>
</Card>
</div>
)
}
const columnLabel = (c: DimensionPnlReport['columns'][number]) =>
c.code === null ? '(Utan dimension)' : c.code
const colCount = 2 + data.columns.length + 1
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
{pivotPicker || <span />}
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/dimension-pnl/xlsx?${reportQs}` }]} />
</div>
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] uppercase tracking-wider text-muted-foreground">
<th className="text-left font-medium px-4 py-2 w-20">Konto</th>
<th className="text-left font-medium px-4 py-2">Kontonamn</th>
{data.columns.map((c, i) => (
<th key={i} className="text-right font-medium px-4 py-2 w-32 tabular-nums" title={c.name ?? undefined}>
{columnLabel(c)}
</th>
))}
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Totalt</th>
</tr>
</thead>
<tbody>
{data.groups.map((group) => (
<React.Fragment key={group.class}>
<tr className="bg-muted/30">
<td colSpan={colCount} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">
{group.class_label}
</td>
</tr>
{group.rows.map((row) => (
<tr key={row.account_number} className="border-b last:border-0">
<td className="px-4 py-1.5">
<AccountNumber number={row.account_number} name={row.account_name} />
</td>
<td className="px-4 py-1.5">{row.account_name}</td>
{row.values.map((v, i) => (
<td key={i} className="px-4 py-1.5 text-right tabular-nums">
{Math.abs(v) >= 0.005 ? formatAmount(v) : ''}
</td>
))}
<td className="px-4 py-1.5 text-right tabular-nums font-medium">{formatAmount(row.total)}</td>
</tr>
))}
<tr className="border-b font-medium">
<td colSpan={2} className="px-4 py-1.5 text-right text-muted-foreground">
Summa
</td>
{group.subtotals.map((v, i) => (
<td key={i} className="px-4 py-1.5 text-right tabular-nums">{formatAmount(v)}</td>
))}
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(group.subtotal_total)}</td>
</tr>
</React.Fragment>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card className="border-2">
<CardContent className="py-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<tbody>
<tr>
<td className="px-4 font-bold text-lg">Beräknat resultat</td>
<td className="px-4" />
{data.net_per_column.map((v, i) => (
<td key={i} className={`px-4 text-right tabular-nums font-semibold w-32 ${v >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatAmount(v)}
</td>
))}
<td className={`px-4 text-right tabular-nums font-bold text-lg w-32 ${data.net_total >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatAmount(data.net_total)} kr
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
}