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:
Jakob Wennberg
2026-08-08 16:40:19 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 645ed0a53e
commit 02a5d10538
17 changed files with 781 additions and 883 deletions
@@ -63,7 +63,7 @@ export function AgentKnowledgePanel() {
// Mirrors the loaded layout: graph hero, section header, two profile cards.
return (
<div className="space-y-8">
<Skeleton className="h-96 w-full rounded-xl" />
<Skeleton className="h-96 w-full rounded-lg" />
<div className="space-y-4">
<Skeleton className="h-4 w-40" />
<div className="grid gap-4 md:grid-cols-2">
+2 -2
View File
@@ -277,7 +277,7 @@ export function LedgerGraph({ deep, companyName }: { deep: DeepLedgerContext; co
if (model.payees.length === 0) {
return (
<div
className="rounded-xl border p-16 text-center text-sm"
className="rounded-lg border border-border p-16 text-center text-sm"
style={{ background: INK, borderColor: HAIR, color: MUTED }}
>
{t('none_cp')}
@@ -304,7 +304,7 @@ export function LedgerGraph({ deep, companyName }: { deep: DeepLedgerContext; co
return (
<div
className="relative overflow-hidden rounded-xl border"
className="relative overflow-hidden rounded-lg border border-border"
style={{
borderColor: HAIR_STRONG,
background: `radial-gradient(120% 120% at 50% 42%, #17171b 0%, ${INK} 62%)`,
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { useToast } from '@/components/ui/use-toast'
import { AccountNumber } from '@/components/ui/account-number'
@@ -528,9 +529,10 @@ export default function ChartOfAccountsManager() {
</div>
{loading ? (
<div className="p-8 text-center text-muted-foreground">
<Loader2 className="mx-auto mb-2 h-5 w-5 animate-spin" />
{t('loading')}
<div className="space-y-2 py-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : view === 'my-accounts' ? (
filteredAccounts.length === 0 ? (
@@ -669,9 +671,10 @@ export default function ChartOfAccountsManager() {
) : (
<div>
{referenceLoading && referenceAccounts.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Loader2 className="mx-auto mb-2 h-5 w-5 animate-spin" />
{t('loading')}
<div className="space-y-2 py-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : filteredReference.length === 0 ? (
<p className="px-1 py-12 text-center text-sm text-muted-foreground">{t('no_matches')}</p>
+3 -3
View File
@@ -18,10 +18,10 @@ export default async function ExtensionCard({ extension }: { extension: Extensio
return (
<Card className="group relative">
<CardContent className="pt-6">
<CardContent className="p-6">
<div className="flex items-start gap-3 min-w-0">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
<Icon className="h-5 w-5 text-primary" />
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary flex-shrink-0">
<Icon className="h-5 w-5 text-foreground" />
</div>
<div className="min-w-0">
<Link
+5 -5
View File
@@ -17,14 +17,14 @@ export default async function SectorCard({ sector }: { sector: Sector }) {
return (
<Link href={`/extensions/${sector.slug}`} className="h-full">
<Card className="group hover:border-primary/30 transition-colors cursor-pointer h-full">
<CardContent className="pt-6">
<Card className="group h-full cursor-pointer transition-colors duration-150 hover:bg-secondary/60">
<CardContent className="p-6">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
<Icon className="h-5 w-5 text-primary" />
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary flex-shrink-0">
<Icon className="h-5 w-5 text-foreground" />
</div>
<div>
<h3 className="text-sm font-medium group-hover:text-primary transition-colors">
<h3 className="text-sm font-medium">
{name}
</h3>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{description}</p>
+294 -331
View File
@@ -10,8 +10,11 @@ import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import { AttnLine } from '@/components/ui/attn-line'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { AccountNumber } from '@/components/ui/account-number'
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal } from 'lucide-react'
import { AlertCircle, ChevronDown, ChevronRight, Landmark, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal } from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { CashAccountSelector } from '@/components/common/CashAccountSelector'
@@ -878,7 +881,7 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
}
return (
<div className="space-y-4">
<div className="space-y-8">
{error && (
<Card>
<CardContent className="py-3 text-center text-destructive text-sm">
@@ -898,14 +901,11 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
<div className="flex items-center justify-between">
<CardTitle>Avstämning mot <AccountNumber number={accountNumber} /></CardTitle>
{status.is_reconciled ? (
<Badge variant="success">Avstämd</Badge>
<span className="text-sm text-muted-foreground">Avstämd</span>
) : (
<Badge variant="destructive">Ej avstämd</Badge>
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">
Avstämningen körs mot <AccountNumber number={accountNumber} /> ({accountCurrency}). Övriga bankkonton (t.ex. Plusgiro <AccountNumber number="1920" />, kreditkort <AccountNumber number="1940" /> eller valutakonton) stäms av separat. Välj kontot i listan nedan.
</p>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
@@ -963,139 +963,124 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
</Card>
)}
{/* Action Bar */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap items-end gap-4">
<CashAccountSelector
value={accountNumber}
onChange={setAccountNumber}
{/* Toolbar: flat on the panel, no box (UI-migration language) */}
<div className="space-y-3">
<div className="flex flex-wrap items-end gap-4">
<CashAccountSelector
value={accountNumber}
onChange={setAccountNumber}
/>
<div>
<Label>Datum från</Label>
<Input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="mt-1"
/>
<div>
<Label>Datum från</Label>
<Input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Datum till</Label>
<Input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="mt-1"
/>
</div>
<Button onClick={() => fetchAll()} variant={datesDirty ? 'default' : 'outline'}>
Filtrera
</Button>
<div className="flex-1" />
<Button onClick={handleDryRun} disabled={runLoading || datesDirty} variant="outline">
<Eye className="h-4 w-4 mr-2" />
{runLoading ? 'Analyserar...' : 'Förhandsgranska'}
</Button>
{dryRunResults && dryRunResults.length > 0 && (
<Button onClick={handleApply} disabled={applyLoading || selectedPairs.size === 0}>
<Play className="h-4 w-4 mr-2" />
{applyLoading
? 'Tillämpar...'
: `Tillämpa ${selectedPairs.size} ${selectedPairs.size === 1 ? 'matchning' : 'matchningar'}`}
</Button>
)}
</div>
{datesDirty && (
<p className="mt-3 text-xs text-muted-foreground">
Datumfiltret är ändrat men inte tillämpat: klicka Filtrera för att uppdatera
listorna innan du förhandsgranskar.
</p>
<div>
<Label>Datum till</Label>
<Input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="mt-1"
/>
</div>
<Button onClick={() => fetchAll()} variant={datesDirty ? 'default' : 'outline'}>
Filtrera
</Button>
<div className="flex-1" />
<Button onClick={handleDryRun} disabled={runLoading || datesDirty} variant="outline">
<Eye className="h-4 w-4 mr-2" />
{runLoading ? 'Analyserar...' : 'Förhandsgranska'}
</Button>
{dryRunResults && dryRunResults.length > 0 && (
<Button onClick={handleApply} disabled={applyLoading || selectedPairs.size === 0}>
<Play className="h-4 w-4 mr-2" />
{applyLoading
? 'Tillämpar...'
: `Tillämpa ${selectedPairs.size} ${selectedPairs.size === 1 ? 'matchning' : 'matchningar'}`}
</Button>
)}
</CardContent>
</Card>
</div>
{datesDirty && (
<AttnLine>
Datumfiltret är ändrat men inte tillämpat: klicka Filtrera för att uppdatera
listorna innan du förhandsgranskar.
</AttnLine>
)}
</div>
{/* Dry Run Preview */}
{dryRunResults && dryRunResults.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">
Förhandsgranskning: {dryRunResults.length}{' '}
{dryRunResults.length === 1 ? 'matchning hittad' : 'matchningar hittade'}
</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
Starka träffar är förvalda. Ungefärliga träffar kräver att du bockar i dem själv:
granska verifikationen först.
</p>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-8"></th>
<th className="py-2">Transaktion</th>
<th className="py-2 w-24">Datum</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-8 text-center">&harr;</th>
<th className="py-2">Verifikation</th>
<th className="py-2 w-24">Datum</th>
<th className="py-2 w-28">Metod</th>
<th className="py-2 w-20">Träff</th>
</tr>
</thead>
<tbody>
{dryRunResults.map((m) => {
const key = matchKey(m.transaction_id, m.journal_entry_id)
const badge = confidenceLabel(m.confidence)
return (
<tr key={key} className="border-b last:border-0">
<td className="py-2">
<Checkbox
checked={selectedPairs.has(key)}
onCheckedChange={() => toggleMatchSelection(key)}
aria-label={`Tillämpa matchning för ${m.transaction_description}`}
/>
</td>
<td className="py-2 truncate max-w-[180px]">{m.transaction_description}</td>
<td className="py-2 tabular-nums">{formatDate(m.transaction_date)}</td>
<td className="py-2 text-right tabular-nums">{formatAmount(m.transaction_amount)}</td>
<td className="py-2 text-center text-muted-foreground">&harr;</td>
<td className="py-2">
<Link
href={`/bookkeeping/${m.journal_entry_id}`}
className="font-mono text-xs underline-offset-2 hover:underline"
target="_blank"
>
{formatVoucher(m)}
</Link>
<span className="ml-2 text-muted-foreground truncate">{m.entry_description}</span>
</td>
<td className="py-2 tabular-nums">{formatDate(m.entry_date)}</td>
<td className="py-2">
<span className="text-xs text-muted-foreground">
{METHOD_LABELS[m.method] || m.method}
</span>
</td>
<td className="py-2">
<Badge variant={badge.variant}>{badge.label}</Badge>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
<section className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Förhandsgranskning ({dryRunResults.length})
</h2>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={`${TH_CLASS} w-8`}></th>
<th className={TH_CLASS}>Transaktion</th>
<th className={`${TH_CLASS} w-24`}>Datum</th>
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
<th className={`${TH_CLASS} w-8 text-center`}>&harr;</th>
<th className={TH_CLASS}>Verifikation</th>
<th className={`${TH_CLASS} w-24`}>Datum</th>
<th className={`${TH_CLASS} w-28`}>Metod</th>
<th className={`${TH_CLASS} w-20`}>Träff</th>
</tr>
</thead>
<tbody className="stagger-enter">
{dryRunResults.map((m) => {
const key = matchKey(m.transaction_id, m.journal_entry_id)
const badge = confidenceLabel(m.confidence)
return (
<tr key={key} className="transition-colors duration-150 hover:bg-secondary/35">
<td className={TD_CLASS}>
<Checkbox
checked={selectedPairs.has(key)}
onCheckedChange={() => toggleMatchSelection(key)}
aria-label={`Tillämpa matchning för ${m.transaction_description}`}
/>
</td>
<td className={`${TD_CLASS} truncate max-w-[180px]`}>{m.transaction_description}</td>
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(m.transaction_date)}</td>
<td className={`${TD_CLASS} text-right tabular-nums`}>{formatAmount(m.transaction_amount)}</td>
<td className={`${TD_CLASS} text-center text-muted-foreground`}>&harr;</td>
<td className={TD_CLASS}>
<Link
href={`/bookkeeping/${m.journal_entry_id}`}
className="font-mono text-xs underline-offset-2 hover:underline"
target="_blank"
>
{formatVoucher(m)}
</Link>
<span className="ml-2 text-muted-foreground truncate">{m.entry_description}</span>
</td>
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(m.entry_date)}</td>
<td className={`${TD_CLASS} text-xs text-muted-foreground`}>
{METHOD_LABELS[m.method] || m.method}
</td>
<td className={TD_CLASS}>
<Badge variant={badge.variant}>{badge.label}</Badge>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
)}
{dryRunResults && dryRunResults.length === 0 && (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
Inga automatiska matchningar hittades.
</CardContent>
</Card>
<p className="text-sm text-muted-foreground">
Inga automatiska matchningar hittades.
</p>
)}
{/* Unmatched Transactions */}
@@ -1278,230 +1263,208 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
{/* Unmatched GL Lines */}
{unmatchedGlLines.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">
Omatchade verifikationer på <AccountNumber number={accountNumber} /> ({unmatchedGlLines.length})
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-3 text-xs text-muted-foreground">
Är en manuellt eller importerat bokförd verifikation egentligen en ingående balans? Markera den som IB: då räknas den inte med i avstämningen utan visas separat som ingående balans.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-16">Ver.nr</th>
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-24">Typ</th>
<th className="py-2 w-36"></th>
</tr>
</thead>
<tbody>
{unmatchedGlLines.map((line) => {
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
const isRetaggable = line.source_type === 'manual' || line.source_type === 'import'
return (
<tr key={line.line_id} className="border-b last:border-0">
<td className="py-2">
<Link
href={`/bookkeeping/${line.journal_entry_id}`}
className="font-mono text-xs underline-offset-2 hover:underline"
target="_blank"
<section className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Omatchade verifikationer på <AccountNumber number={accountNumber} /> ({unmatchedGlLines.length})
</h2>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={`${TH_CLASS} w-16`}>Ver.nr</th>
<th className={`${TH_CLASS} w-24`}>Datum</th>
<th className={TH_CLASS}>Beskrivning</th>
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
<th className={`${TH_CLASS} w-24`}>Typ</th>
<th className={`${TH_CLASS} w-36`}></th>
</tr>
</thead>
<tbody className="stagger-enter">
{unmatchedGlLines.map((line) => {
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
const isRetaggable = line.source_type === 'manual' || line.source_type === 'import'
return (
<tr key={line.line_id} className="group transition-colors duration-150 hover:bg-secondary/35">
<td className={TD_CLASS}>
<Link
href={`/bookkeeping/${line.journal_entry_id}`}
className="font-mono text-xs underline-offset-2 hover:underline"
target="_blank"
>
{formatVoucher(line)}
</Link>
</td>
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(line.entry_date)}</td>
<td className={`${TD_CLASS} truncate max-w-[300px]`}>
{line.line_description || line.entry_description}
</td>
<td className={`${TD_CLASS} text-right tabular-nums`}>
{formatCurrency(amount)}
</td>
<td className={`${TD_CLASS} text-xs text-muted-foreground`}>
{SOURCE_TYPE_LABELS[line.source_type] ?? line.source_type}
</td>
<td className={`${TD_CLASS} text-right`}>
{isRetaggable && (
<Button
size="sm"
variant="ghost"
className="h-8 text-xs"
disabled={markLoading === line.journal_entry_id}
onClick={() => handleMarkOpeningBalance(line.journal_entry_id)}
title="Markera verifikationen som ingående balans: den utesluts då från avstämningen"
>
{formatVoucher(line)}
</Link>
</td>
<td className="py-2 tabular-nums">{formatDate(line.entry_date)}</td>
<td className="py-2 truncate max-w-[300px]">
{line.line_description || line.entry_description}
</td>
<td className="py-2 text-right tabular-nums">
{formatCurrency(amount)}
</td>
<td className="py-2 text-xs text-muted-foreground">
{SOURCE_TYPE_LABELS[line.source_type] ?? line.source_type}
</td>
<td className="py-2 text-right">
{isRetaggable && (
<Button
size="sm"
variant="ghost"
className="h-8 text-xs"
disabled={markLoading === line.journal_entry_id}
onClick={() => handleMarkOpeningBalance(line.journal_entry_id)}
title="Markera verifikationen som ingående balans: den utesluts då från avstämningen"
>
{markLoading === line.journal_entry_id ? 'Markerar…' : 'Märk som IB'}
</Button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
{markLoading === line.journal_entry_id ? 'Markerar…' : 'Märk som IB'}
</Button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
)}
{/* Ignored transactions (undo) */}
{ignoredTx.length > 0 && (
<Card>
<CardHeader
className="cursor-pointer"
<section className="space-y-3">
<button
type="button"
aria-expanded={showIgnored}
onClick={() => setShowIgnored(!showIgnored)}
className="flex items-center gap-2 text-sm font-medium uppercase tracking-wider text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<div className="flex items-center gap-2">
{showIgnored ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<CardTitle className="text-base">
Ignorerade transaktioner ({ignoredTx.length})
</CardTitle>
</div>
</CardHeader>
{showIgnored ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
Ignorerade transaktioner ({ignoredTx.length})
</button>
{showIgnored && (
<CardContent>
<p className="text-xs text-muted-foreground mb-3">
Rader du valt att dölja från avstämningen. De påverkar inte saldot på <AccountNumber number={accountNumber} />: de är bara gömda från listan.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-20">Valuta</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-28"></th>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={`${TH_CLASS} w-24`}>Datum</th>
<th className={TH_CLASS}>Beskrivning</th>
<th className={`${TH_CLASS} w-20`}>Valuta</th>
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
<th className={`${TH_CLASS} w-28`}></th>
</tr>
</thead>
<tbody className="stagger-enter">
{ignoredTx.map((tx) => (
<tr key={tx.id} className="text-muted-foreground transition-colors duration-150 hover:bg-secondary/35">
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(tx.date)}</td>
<td className={`${TD_CLASS} truncate max-w-[300px]`}>{tx.description}</td>
<td className={`${TD_CLASS} text-xs tabular-nums`}>{tx.currency}</td>
<td className={`${TD_CLASS} text-right tabular-nums`}>
{formatCurrency(tx.amount, tx.currency)}
</td>
<td className={TD_CLASS}>
<Button
size="sm"
variant="ghost"
disabled={actionLoading === tx.id}
onClick={() => handleUnignore(tx.id)}
>
{actionLoading === tx.id ? '...' : 'Återställ'}
</Button>
</td>
</tr>
</thead>
<tbody>
{ignoredTx.map((tx) => (
<tr key={tx.id} className="border-b last:border-0 text-muted-foreground">
<td className="py-2 tabular-nums">{formatDate(tx.date)}</td>
<td className="py-2 truncate max-w-[300px]">{tx.description}</td>
<td className="py-2 text-xs">
<span className="tabular-nums">{tx.currency}</span>
</td>
<td className="py-2 text-right tabular-nums">
{formatCurrency(tx.amount, tx.currency)}
</td>
<td className="py-2">
<Button
size="sm"
variant="ghost"
disabled={actionLoading === tx.id}
onClick={() => handleUnignore(tx.id)}
>
{actionLoading === tx.id ? '...' : 'Återställ'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
))}
</tbody>
</table>
</div>
)}
</Card>
</section>
)}
{/* Recently Matched */}
{matchedTx.length > 0 && (
<Card>
<CardHeader
className="cursor-pointer"
<section className="space-y-3">
<button
type="button"
aria-expanded={showMatched}
onClick={() => setShowMatched(!showMatched)}
className="flex items-center gap-2 text-sm font-medium uppercase tracking-wider text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<div className="flex items-center gap-2">
{showMatched ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<CardTitle className="text-base">
Matchade transaktioner ({matchedTx.length})
</CardTitle>
</div>
</CardHeader>
{showMatched ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
Matchade transaktioner ({matchedTx.length})
</button>
{showMatched && (
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-32">Metod</th>
<th className="py-2 w-28">Verifikation</th>
<th className="py-2 w-24"></th>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={`${TH_CLASS} w-24`}>Datum</th>
<th className={TH_CLASS}>Beskrivning</th>
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
<th className={`${TH_CLASS} w-32`}>Metod</th>
<th className={`${TH_CLASS} w-28`}>Verifikation</th>
<th className={`${TH_CLASS} w-24`}></th>
</tr>
</thead>
<tbody className="stagger-enter">
{matchedTx.map((tx) => (
<tr key={tx.id} className="transition-colors duration-150 hover:bg-secondary/35">
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(tx.date)}</td>
<td className={`${TD_CLASS} truncate max-w-[300px]`}>{tx.description}</td>
<td className={`${TD_CLASS} text-right tabular-nums`}>
{formatCurrency(tx.amount, accountCurrency)}
</td>
<td className={`${TD_CLASS} text-xs text-muted-foreground`}>
{tx.reconciliation_method
? METHOD_LABELS[tx.reconciliation_method] || tx.reconciliation_method
: null}
</td>
<td className={TD_CLASS}>
{tx.journal_entry_id && (
<Link
href={`/bookkeeping/${tx.journal_entry_id}`}
className="text-xs underline-offset-2 hover:underline"
target="_blank"
>
Öppna verifikat
</Link>
)}
</td>
<td className={TD_CLASS}>
{tx.reconciliation_method && (
<Button
size="sm"
variant="ghost"
disabled={unlinkLoading === tx.id}
onClick={() => handleUnlink(tx.id)}
>
<Unlink className="h-3 w-3 mr-1" />
{unlinkLoading === tx.id ? '...' : 'Avmatcha'}
</Button>
)}
</td>
</tr>
</thead>
<tbody>
{matchedTx.map((tx) => (
<tr key={tx.id} className="border-b last:border-0">
<td className="py-2 tabular-nums">{formatDate(tx.date)}</td>
<td className="py-2 truncate max-w-[300px]">{tx.description}</td>
<td className="py-2 text-right tabular-nums">
{formatCurrency(tx.amount, accountCurrency)}
</td>
<td className="py-2">
{tx.reconciliation_method && (
<span className="text-xs text-muted-foreground">
{METHOD_LABELS[tx.reconciliation_method] || tx.reconciliation_method}
</span>
)}
</td>
<td className="py-2">
{tx.journal_entry_id && (
<Link
href={`/bookkeeping/${tx.journal_entry_id}`}
className="text-xs underline-offset-2 hover:underline"
target="_blank"
>
Öppna verifikat
</Link>
)}
</td>
<td className="py-2">
{tx.reconciliation_method && (
<Button
size="sm"
variant="ghost"
disabled={unlinkLoading === tx.id}
onClick={() => handleUnlink(tx.id)}
>
<Unlink className="h-3 w-3 mr-1" />
{unlinkLoading === tx.id ? '...' : 'Avmatcha'}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
))}
</tbody>
</table>
</div>
)}
</Card>
</section>
)}
{/* Empty state */}
{unmatchedTx.length === 0 && glLines.length === 0 && matchedTx.length === 0 && ignoredTx.length === 0 && !loading && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga transaktioner eller verifikationer att stämma av.
</CardContent>
</Card>
<EmptyState
icon={Landmark}
title="Inget att stämma av"
description="Det finns inga banktransaktioner eller verifikationer i den valda perioden. Importera eller synka banktransaktioner så visas de här."
/>
)}
<DestructiveConfirmDialog {...confirmDialogProps} />
+15
View File
@@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { ChevronLeft } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
import { HelpPopover } from '@/components/ui/help-popover'
import { EmptyState } from '@/components/ui/empty-state'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
@@ -119,6 +120,20 @@ function FocusedReportInner({
{!isStandalone && (
<PageHeader
title={reportName}
// Page help behind a "?" (UI-migration convention 7): the report
// bodies carry no instructional copy in the page flow.
help={
slug === 'bank-reconciliation' ? (
<HelpPopover>
<div className="space-y-2">
<p>{t('help_bank_reconciliation_scope')}</p>
<p>{t('help_bank_reconciliation_preview')}</p>
<p>{t('help_bank_reconciliation_ib')}</p>
<p>{t('help_bank_reconciliation_ignored')}</p>
</div>
</HelpPopover>
) : undefined
}
action={
<FyPicker
value={selectedPeriod || null}
+259 -357
View File
@@ -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>
+10 -8
View File
@@ -29,6 +29,8 @@ import {
} from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
@@ -696,7 +698,7 @@ function BulkWorkedDialog({
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-xs font-medium" htmlFor="bulk-w-hours">{t('hours_per_day')}</label>
<input
<Input
id="bulk-w-hours"
type="number"
min={0}
@@ -705,7 +707,7 @@ function BulkWorkedDialog({
value={hours}
onChange={(e) => setHours(e.target.value)}
autoFocus
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm tabular-nums shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="tabular-nums"
/>
<p className="text-[11px] text-muted-foreground">
{t('hours_zero_hint')}
@@ -714,13 +716,13 @@ function BulkWorkedDialog({
<div className="space-y-1.5">
<label className="text-xs font-medium" htmlFor="bulk-w-notes">{t('notes_label')}</label>
<textarea
<Textarea
id="bulk-w-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
maxLength={2000}
className="flex w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="min-h-0"
/>
</div>
@@ -853,7 +855,7 @@ function BulkAbsenceDialog({
<div className="space-y-1.5">
<label className="text-xs font-medium" htmlFor="bulk-a-hours">{t('hours_per_day')}</label>
<input
<Input
id="bulk-a-hours"
type="number"
min={0.5}
@@ -861,19 +863,19 @@ function BulkAbsenceDialog({
step={0.5}
value={hours}
onChange={(e) => setHours(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm tabular-nums shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="tabular-nums"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium" htmlFor="bulk-a-notes">{t('notes_label')}</label>
<textarea
<Textarea
id="bulk-a-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
maxLength={2000}
className="flex w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="min-h-0"
/>
</div>