refactor(ui): migrate remaining inline pages to the concept design language (#1470)
* refactor(ui): migrate remaining inline pages to the concept design language Catch-up pass for surfaces the 2026-07 UI migration missed: - Bankavstämning: de-boxed toolbar, dry-table sections (preview, omatchade verifikationer, ignorerade, matchade), instructional copy moved behind the page "?" (HelpPopover via FocusedReport, sv+en), AttnLine for the dirty- dates hint, EmptyState for the blank page, space-y-8 rhythm. - Report detail views (trial balance, income statement, balance sheet, resultat-/balansrapport, reskontror, huvudbok, grundbok, dimension-P&L): shared Skeleton/Error/EmptyState shells, border-2 totals bands flattened to hairline cards with font-display tabular-nums headline numbers, ReportSectionTable rebuilt on the group-band idiom, font-mono money -> tabular-nums, house tablist for Förenklad/Detaljerad, GL filter de-boxed onto Input primitives, verdicts follow chips-mark-exceptions. - Extensions browse: PageHeader, locked section headers, rounded-lg secondary icon tiles, flat hover shift on cards, p-6 content. - Återkommande fakturor: page-level list moved off ui/table onto dry-table with hover-revealed quiet row actions; Skeleton loading. - Help: EmptyState for no search hits, flat hover shift on resource links. - Chart of accounts: spinner loading blocks -> Skeleton rows. - Kunskap graph + salary calendar popovers: rounded-lg, Input/Textarea primitives instead of hand-rolled shadow-sm controls. No logic, endpoint, or data changes. Verified via sandbox screenshots; lint 0 errors, 13214 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): review triage: skip empty industry sectors, keyboard path to schedule edit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- 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
645ed0a53e
commit
02a5d10538
+259
-357
@@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AlertCircle, Check, ChevronDown, ChevronRight, ExternalLink, FileCode, FileDown, Percent } from 'lucide-react'
|
||||
import { AlertCircle, Check, ChevronDown, ChevronRight, ExternalLink, FileCode, FileDown, FileText, Percent } from 'lucide-react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { FyPicker } from '@/components/common/FyPicker'
|
||||
@@ -83,6 +83,36 @@ function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
// Shared shells for the report bodies, so all views read as the same
|
||||
// instrument: Skeleton while loading, EmptyState when the period has no data,
|
||||
// a quiet destructive card on fetch errors (design.md primitives).
|
||||
function ReportLoadingCard() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function ReportErrorCard({ message }: { message: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-destructive">
|
||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||||
{message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ReportEmptyState({ title, description }: { title: string; description: string }) {
|
||||
return <EmptyState icon={FileText} title={title} description={description} />
|
||||
}
|
||||
|
||||
function reportQuery(
|
||||
periodId: string,
|
||||
range?: DateRangeValue,
|
||||
@@ -132,35 +162,19 @@ export function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId:
|
||||
}, [periodId])
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
if (!data || data.rows.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Inga bokförda verifikationer i denna period.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportEmptyState
|
||||
title="Inga verifikationer i perioden"
|
||||
description="Det finns inga bokförda verifikationer i den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -195,30 +209,38 @@ export function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId:
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Saldobalans</CardTitle>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-border bg-card p-0.5">
|
||||
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={viewMode === 'simplified'}
|
||||
onClick={() => setViewMode('simplified')}
|
||||
className={`px-3 py-1 text-xs rounded-sm transition-colors ${
|
||||
className={cn(
|
||||
'rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
|
||||
viewMode === 'simplified'
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
? 'border border-border bg-card font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Förenklad
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={viewMode === 'detailed'}
|
||||
onClick={() => setViewMode('detailed')}
|
||||
className={`px-3 py-1 text-xs rounded-sm transition-colors ${
|
||||
className={cn(
|
||||
'rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
|
||||
viewMode === 'detailed'
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
? 'border border-border bg-card font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Detaljerad
|
||||
</button>
|
||||
</div>
|
||||
{data.isBalanced ? (
|
||||
<Badge variant="success">Balanserad</Badge>
|
||||
<span className="text-sm text-muted-foreground">Balanserad</span>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej balanserad</Badge>
|
||||
)}
|
||||
@@ -471,35 +493,19 @@ export function IncomeStatementView({ periodId, dateRange, dimensionFilter = nul
|
||||
}, [periodId, reportQs])
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Ingen data för denna period.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportEmptyState
|
||||
title="Ingen data för perioden"
|
||||
description="Det finns inget bokfört underlag för den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -521,12 +527,12 @@ export function IncomeStatementView({ periodId, dateRange, dimensionFilter = nul
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Rörelseintäkter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ReportSectionTable sections={data.revenue_sections} onNavigateToAccount={onNavigateToAccount} />
|
||||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
||||
<span>Summa rörelseintäkter</span>
|
||||
<span>{formatAmount(data.total_revenue)} kr</span>
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
<ReportSectionTable
|
||||
sections={data.revenue_sections}
|
||||
onNavigateToAccount={onNavigateToAccount}
|
||||
footer={{ label: 'Summa rörelseintäkter', amount: data.total_revenue }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -535,24 +541,23 @@ export function IncomeStatementView({ periodId, dateRange, dimensionFilter = nul
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Rörelsekostnader</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ReportSectionTable sections={data.expense_sections} negate onNavigateToAccount={onNavigateToAccount} />
|
||||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
||||
<span>Summa rörelsekostnader</span>
|
||||
<span>-{formatAmount(data.total_expenses)} kr</span>
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
<ReportSectionTable
|
||||
sections={data.expense_sections}
|
||||
negate
|
||||
onNavigateToAccount={onNavigateToAccount}
|
||||
footer={{ label: 'Summa rörelsekostnader', amount: data.total_expenses, negate: true }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Operating result */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex justify-between font-bold text-lg">
|
||||
<span>Rörelseresultat</span>
|
||||
<span className={data.total_revenue - data.total_expenses >= 0 ? 'text-success' : 'text-destructive'}>
|
||||
{formatAmount(data.total_revenue - data.total_expenses)} kr
|
||||
</span>
|
||||
</div>
|
||||
<CardContent className="flex items-baseline justify-between p-6">
|
||||
<span className="text-sm font-medium">Rörelseresultat</span>
|
||||
<span className={`font-display text-xl tabular-nums ${data.total_revenue - data.total_expenses >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatAmount(data.total_revenue - data.total_expenses)} kr
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -562,25 +567,23 @@ export function IncomeStatementView({ periodId, dateRange, dimensionFilter = nul
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Finansiella poster</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ReportSectionTable sections={data.financial_sections} onNavigateToAccount={onNavigateToAccount} />
|
||||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
||||
<span>Summa finansiella poster</span>
|
||||
<span>{formatAmount(data.total_financial)} kr</span>
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
<ReportSectionTable
|
||||
sections={data.financial_sections}
|
||||
onNavigateToAccount={onNavigateToAccount}
|
||||
footer={{ label: 'Summa finansiella poster', amount: data.total_financial }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Net result */}
|
||||
<Card className="border-2">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex justify-between font-bold text-xl">
|
||||
<span>Årets resultat</span>
|
||||
<span className={data.net_result >= 0 ? 'text-success' : 'text-destructive'}>
|
||||
{formatAmount(data.net_result)} kr
|
||||
</span>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex items-baseline justify-between p-6">
|
||||
<span className="text-sm font-medium">Årets resultat</span>
|
||||
<span className={`font-display text-xl tabular-nums ${data.net_result >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatAmount(data.net_result)} kr
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -616,33 +619,19 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: {
|
||||
}, [periodId, reportQs])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar balansräkning...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Ingen data för denna period.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportEmptyState
|
||||
title="Ingen data för perioden"
|
||||
description="Det finns inget bokfört underlag för den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -662,12 +651,12 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Tillgångar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ReportSectionTable sections={data.asset_sections} onNavigateToAccount={onNavigateToAccount} />
|
||||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
||||
<span>Summa tillgångar</span>
|
||||
<span>{formatAmount(data.total_assets)} kr</span>
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
<ReportSectionTable
|
||||
sections={data.asset_sections}
|
||||
onNavigateToAccount={onNavigateToAccount}
|
||||
footer={{ label: 'Summa tillgångar', amount: data.total_assets }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -676,30 +665,26 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Eget kapital och skulder</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ReportSectionTable sections={data.equity_liability_sections} onNavigateToAccount={onNavigateToAccount} />
|
||||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
||||
<span>Summa eget kapital och skulder</span>
|
||||
<span>{formatAmount(data.total_equity_liabilities)} kr</span>
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
<ReportSectionTable
|
||||
sections={data.equity_liability_sections}
|
||||
onNavigateToAccount={onNavigateToAccount}
|
||||
footer={{ label: 'Summa eget kapital och skulder', amount: data.total_equity_liabilities }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Balance check */}
|
||||
<Card className="border-2">
|
||||
<CardContent className="py-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold text-lg">Balanscheck</span>
|
||||
<span className="text-sm font-medium">Balanscheck</span>
|
||||
{isBalanced ? (
|
||||
<Badge variant="success" className="text-base px-3 py-1">
|
||||
Balanserar
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">Balanserar</span>
|
||||
) : (
|
||||
<div className="text-right">
|
||||
<Badge variant="destructive" className="text-base px-3 py-1">
|
||||
Balanserar ej
|
||||
</Badge>
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
<Badge variant="destructive">Balanserar ej</Badge>
|
||||
<p className="text-sm text-destructive mt-1 tabular-nums">
|
||||
Differens: {formatAmount(Math.abs(data.total_assets - data.total_equity_liabilities))} kr
|
||||
</p>
|
||||
</div>
|
||||
@@ -747,35 +732,19 @@ export function ResultatrapportView({ periodId, dateRange, dimensionFilter = nul
|
||||
}, [periodId, reportQs])
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
if (!data || data.groups.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Inga bokförda intäkter eller kostnader i denna period.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportEmptyState
|
||||
title="Inga intäkter eller kostnader"
|
||||
description="Det finns inga bokförda intäkter eller kostnader i den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -859,14 +828,14 @@ export function ResultatrapportView({ periodId, dateRange, dimensionFilter = nul
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2">
|
||||
<CardContent className="py-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid gap-x-6 items-baseline grid-cols-[1fr_auto_auto]">
|
||||
<span className="font-bold text-lg">Beräknat resultat</span>
|
||||
<span className={`tabular-nums font-bold text-lg w-32 text-right ${data.net_result_current >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
<span className="text-sm font-medium">Beräknat resultat</span>
|
||||
<span className={`font-display text-xl tabular-nums w-32 text-right ${data.net_result_current >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatAmount(data.net_result_current)} kr
|
||||
</span>
|
||||
<span className="tabular-nums text-base text-muted-foreground w-32 text-right">
|
||||
<span className="tabular-nums text-sm text-muted-foreground w-32 text-right">
|
||||
{hasPrior ? `${formatAmount(data.net_result_prior)} kr` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -907,33 +876,19 @@ export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }:
|
||||
}, [periodId, reportQs])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar balansrapport...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
if (!data || data.groups.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Inga balansposter i denna period.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportEmptyState
|
||||
title="Inga balansposter"
|
||||
description="Det finns inga bokförda balansposter i den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1005,8 +960,8 @@ export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }:
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2">
|
||||
<CardContent className="py-4 space-y-2">
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Summa tillgångar</span>
|
||||
<span className="tabular-nums">{formatAmount(data.total_assets_ub)} kr</span>
|
||||
@@ -1020,18 +975,14 @@ export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }:
|
||||
<span className="tabular-nums">{formatAmount(data.beraknat_resultat)} kr</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-2 border-t">
|
||||
<span className="font-bold text-lg">Balanscheck</span>
|
||||
<span className="text-sm font-medium">Balanscheck</span>
|
||||
{data.is_balanced ? (
|
||||
<Badge variant="success" className="text-base px-3 py-1">
|
||||
Balanserar
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">Balanserar</span>
|
||||
) : (
|
||||
<div className="text-right">
|
||||
<Badge variant="destructive" className="text-base px-3 py-1">
|
||||
Balanserar ej
|
||||
</Badge>
|
||||
<Badge variant="destructive">Balanserar ej</Badge>
|
||||
{data.imbalance_diagnosis && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
<p className="text-sm text-destructive mt-1 tabular-nums">
|
||||
Differens: {formatAmount(Math.abs(data.imbalance_diagnosis.differens))} kr
|
||||
</p>
|
||||
)}
|
||||
@@ -1053,45 +1004,64 @@ function ReportSectionTable({
|
||||
sections,
|
||||
negate,
|
||||
onNavigateToAccount,
|
||||
footer,
|
||||
}: {
|
||||
sections: { title: string; rows: { account_number: string; account_name: string; amount: number }[]; subtotal: number }[]
|
||||
negate?: boolean
|
||||
onNavigateToAccount?: (account: string) => void
|
||||
/** Report total, rendered as the table's tfoot (e.g. "Summa tillgångar"). */
|
||||
footer?: { label: string; amount: number; negate?: boolean }
|
||||
}) {
|
||||
const fmt = (amount: number, neg?: boolean) =>
|
||||
neg ? `-${formatAmount(amount)}` : formatAmount(amount)
|
||||
|
||||
if (sections.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Inga poster.</p>
|
||||
return <p className="p-6 pt-0 text-sm text-muted-foreground">Inga poster.</p>
|
||||
}
|
||||
|
||||
// One table with group-band rows (design.md tabular rules), matching the
|
||||
// Resultatrapport/Balansrapport idiom, instead of one boxed sub-table per
|
||||
// section with a repeated title line.
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{sections.map((section) => (
|
||||
<div key={section.title}>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-1">{section.title}</h4>
|
||||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[400px]">
|
||||
<tbody>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[400px]">
|
||||
<tbody>
|
||||
{sections.map((section) => (
|
||||
<React.Fragment key={section.title}>
|
||||
<tr className="bg-muted/30">
|
||||
<td colSpan={3} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">
|
||||
{section.title}
|
||||
</td>
|
||||
</tr>
|
||||
{section.rows.map((row) => (
|
||||
<tr
|
||||
key={row.account_number}
|
||||
className={`border-b last:border-0 ${onNavigateToAccount ? 'cursor-pointer hover:bg-muted/50 transition-colors' : ''}`}
|
||||
onClick={onNavigateToAccount ? () => onNavigateToAccount(row.account_number) : undefined}
|
||||
>
|
||||
<td className="py-1 w-16"><AccountNumber number={row.account_number} name={row.account_name} /></td>
|
||||
<td className="py-1">{row.account_name}</td>
|
||||
<td className="py-1 text-right w-28">
|
||||
{negate ? `-${formatAmount(row.amount)}` : formatAmount(row.amount)} kr
|
||||
<td className="px-4 py-1.5 w-20"><AccountNumber number={row.account_number} name={row.account_name} /></td>
|
||||
<td className="px-4 py-1.5">{row.account_name}</td>
|
||||
<td className="px-4 py-1.5 text-right tabular-nums w-32 whitespace-nowrap">
|
||||
{fmt(row.amount, negate)} kr
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div className="flex justify-between text-sm font-medium border-t pt-1 mt-1">
|
||||
<span>{section.title}</span>
|
||||
<span>
|
||||
{negate ? `-${formatAmount(section.subtotal)}` : formatAmount(section.subtotal)} kr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<tr className="border-b font-medium">
|
||||
<td colSpan={2} className="px-4 py-1.5 text-right text-muted-foreground">Summa</td>
|
||||
<td className="px-4 py-1.5 text-right tabular-nums whitespace-nowrap">{fmt(section.subtotal, negate)} kr</td>
|
||||
</tr>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
{footer && (
|
||||
<tfoot>
|
||||
<tr className="font-medium">
|
||||
<td colSpan={2} className="px-4 py-2">{footer.label}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums whitespace-nowrap">{fmt(footer.amount, footer.negate)} kr</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2524,33 +2494,19 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||||
}, [periodId, asOfDate])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar leverantörsreskontra...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
if (!data || !data.ledger) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Ingen data tillgänglig.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportEmptyState
|
||||
title="Ingen leverantörsreskontra"
|
||||
description="Det finns inga leverantörsfakturor att visa för den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2642,7 +2598,7 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||||
|
||||
{/* Reconciliation */}
|
||||
{reconciliation && (
|
||||
<Card className="border-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Avstämning mot <AccountNumber number="2440" /></CardTitle>
|
||||
</CardHeader>
|
||||
@@ -2650,21 +2606,21 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Leverantörsreskontra (summa utestående)</span>
|
||||
<span className="font-mono">{formatAmount(reconciliation.supplier_ledger_total)} kr</span>
|
||||
<span className="tabular-nums">{formatAmount(reconciliation.supplier_ledger_total)} kr</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span><AccountNumber number="2440" /> saldo (huvudbok)</span>
|
||||
<span className="font-mono">{formatAmount(reconciliation.account_2440_balance)} kr</span>
|
||||
<span className="tabular-nums">{formatAmount(reconciliation.account_2440_balance)} kr</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t font-semibold">
|
||||
<span>Differens</span>
|
||||
<span className={reconciliation.is_reconciled ? 'text-success' : 'text-destructive'}>
|
||||
<span className={`tabular-nums ${reconciliation.is_reconciled ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatAmount(reconciliation.difference)} kr
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-2 space-y-2">
|
||||
{reconciliation.is_reconciled ? (
|
||||
<Badge variant="success">Avstämd</Badge>
|
||||
<span className="text-sm text-muted-foreground">Avstämd</span>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
|
||||
)}
|
||||
@@ -2810,73 +2766,57 @@ export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFil
|
||||
}, [periodId, initialAccountFilter, dimensionFilter, dateRange])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar huvudbok...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
if (!data || data.accounts.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Inga bokförda verifikationer i denna period.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportEmptyState
|
||||
title="Inga verifikationer i perioden"
|
||||
description="Det finns inga bokförda verifikationer i den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/general-ledger/xlsx?${reportQuery(periodId, dateRange, dimensionFilter)}` }]} />
|
||||
{/* Account range filter */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<Label>Konto från</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountFrom}
|
||||
onChange={(e) => setAccountFrom(e.target.value)}
|
||||
placeholder="t.ex. 1510"
|
||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Konto till</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountTo}
|
||||
onChange={(e) => setAccountTo(e.target.value)}
|
||||
placeholder="t.ex. 1519"
|
||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => fetchData()} variant="outline">
|
||||
Filtrera
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Account range filter: flat toolbar on the panel, no box */}
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<Label htmlFor="gl-account-from">Konto från</Label>
|
||||
<Input
|
||||
id="gl-account-from"
|
||||
type="text"
|
||||
value={accountFrom}
|
||||
onChange={(e) => setAccountFrom(e.target.value)}
|
||||
placeholder="t.ex. 1510"
|
||||
className="mt-1 w-32"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="gl-account-to">Konto till</Label>
|
||||
<Input
|
||||
id="gl-account-to"
|
||||
type="text"
|
||||
value={accountTo}
|
||||
onChange={(e) => setAccountTo(e.target.value)}
|
||||
placeholder="t.ex. 1519"
|
||||
className="mt-1 w-32"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => fetchData()} variant="outline">
|
||||
Filtrera
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{data.period.start && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Period: {data.period.start}: {data.period.end} | {data.accounts.length} konton
|
||||
<p className="text-sm text-muted-foreground tabular-nums">
|
||||
Period {data.period.start} till {data.period.end} · {data.accounts.length} konton
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -2933,16 +2873,16 @@ export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFil
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{line.credit > 0 ? formatAmount(line.credit) : ''}
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-mono tabular-nums">{formatAmount(line.balance)}</td>
|
||||
<td className="py-1.5 text-right tabular-nums">{formatAmount(line.balance)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="font-semibold border-t-2">
|
||||
<td colSpan={3} className="py-2">Summa / Utgående balans</td>
|
||||
<td className="py-2 text-right">{formatAmount(account.total_debit)}</td>
|
||||
<td className="py-2 text-right">{formatAmount(account.total_credit)}</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(account.closing_balance)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(account.total_debit)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(account.total_credit)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(account.closing_balance)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table></div>
|
||||
@@ -3022,33 +2962,19 @@ export function JournalRegisterView({ periodId }: { periodId: string }) {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar grundbok...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
if (!data || data.entries.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Inga bokförda verifikationer i denna period.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportEmptyState
|
||||
title="Inga verifikationer i perioden"
|
||||
description="Det finns inga bokförda verifikationer i den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3056,8 +2982,8 @@ export function JournalRegisterView({ periodId }: { periodId: string }) {
|
||||
<div className="space-y-4">
|
||||
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/journal-register/xlsx?period_id=${periodId}` }]} />
|
||||
{data.period.start && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Period: {data.period.start}: {data.period.end} | {data.total_entries} verifikationer
|
||||
<p className="text-sm text-muted-foreground tabular-nums">
|
||||
Period {data.period.start} till {data.period.end} · {data.total_entries} verifikationer
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -3324,33 +3250,19 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar kundreskontra...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
if (!data || !data.ledger) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Ingen data tillgänglig.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportEmptyState
|
||||
title="Ingen kundreskontra"
|
||||
description="Det finns inga kundfakturor att visa för den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3471,7 +3383,7 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
|
||||
{/* Reconciliation */}
|
||||
{reconciliation && (
|
||||
<Card className="border-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Avstämning mot <AccountNumber number="1510" /></CardTitle>
|
||||
</CardHeader>
|
||||
@@ -3479,21 +3391,21 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Kundreskontra (summa utestående)</span>
|
||||
<span className="font-mono">{formatAmount(reconciliation.ar_ledger_total)} kr</span>
|
||||
<span className="tabular-nums">{formatAmount(reconciliation.ar_ledger_total)} kr</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Kundfordringar (<AccountNumber number="1510" /> + <AccountNumber number="1513" />) saldo</span>
|
||||
<span className="font-mono">{formatAmount(reconciliation.account_1510_balance)} kr</span>
|
||||
<span className="tabular-nums">{formatAmount(reconciliation.account_1510_balance)} kr</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t font-semibold">
|
||||
<span>Differens</span>
|
||||
<span className={reconciliation.is_reconciled ? 'text-success' : 'text-destructive'}>
|
||||
<span className={`tabular-nums ${reconciliation.is_reconciled ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatAmount(reconciliation.difference)} kr
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-2 space-y-2">
|
||||
{reconciliation.is_reconciled ? (
|
||||
<Badge variant="success">Avstämd</Badge>
|
||||
<span className="text-sm text-muted-foreground">Avstämd</span>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
|
||||
)}
|
||||
@@ -3593,11 +3505,7 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{pivotPicker}
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar rapport...
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportLoadingCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3606,12 +3514,7 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da
|
||||
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>
|
||||
<ReportErrorCard message={error} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3620,11 +3523,10 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da
|
||||
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>
|
||||
<ReportEmptyState
|
||||
title="Inga taggade poster"
|
||||
description="Inga intäkter eller kostnader är taggade med en dimension i den valda perioden."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3696,20 +3598,20 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2">
|
||||
<CardContent className="py-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<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 text-sm font-medium">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'}`}>
|
||||
<td key={i} className={`px-4 text-right tabular-nums font-medium 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'}`}>
|
||||
<td className={`px-4 text-right font-display text-xl tabular-nums w-32 ${data.net_total >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatAmount(data.net_total)} kr
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user