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:
co-authored by
Claude Fable 5
parent
86071334cb
commit
01dbef4015
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user