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
parent 645ed0a53e
commit 02a5d10538
17 changed files with 781 additions and 883 deletions
+1
View File
@@ -838,6 +838,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-08] Per-voucher RC basis gaps (findRcBasisGaps) downgrade from filing-blocking ERROR to WARNING only under per-rate evidence: the 44xx/45xx basis accounts grouped by momssats must match ruta30/0.25, ruta31/0.12, ruta32/0.06 two-sided within 0.5 kr, all rate boxes non-negative, and no RC_OUTPUT_MISSING present. A first cross-rate-sum predicate was refuted by /skeptic (wrong-rate fiktiv moms reached parity and unblocked a 7 800 kr under-declaration; a negative rate box made the predicate vacuous), so the certificate is per-rate, which rutor alone cannot express (rutor 20-24 are partitioned by purchase type, not rate); evidence therefore flows from the account totals. Why downgrade at all: a moms-only rattelseverifikat carries fiktiv moms whose basbelopp lives in another (often reversed) verifikat, and no voucher arrangement satisfies both the per-voucher scan and the aggregate identity in that state, so the ERROR was an unfixable dead end (Orto Engineering 3DJake case 2026-08; support vouchers A169/A175/A177 joined the blocklist they were meant to clear). Data side repaired separately with voucher A177 restoring bank parity and the basis/moms identity exactly.
[2026-08-08] Fenced-JSON fix uses brace-slice, not fence-regex: also rescues preamble/postamble prose around the object, and degrades to the existing empty-result path when no braces exist.
[2026-08-08] extractJsonObject upgraded from brace-slice to depth-aware balanced scan after PR 1460 review: prose containing braces around the JSON no longer poisons the slice; first parseable candidate wins.
[2026-08-08] Inline-page UI catch-up applies chips-mark-exceptions to report verdicts uniformly: positive verdicts (Balanserad, Balanserar, Avstämd) render as muted text and only deviations keep a destructive Badge; page-flow instructional copy on Bankavstämning moved behind the FocusedReport "?" HelpPopover (convention 7) instead of being deleted, since the IB/preview semantics are load-bearing for correct reconciliation.
[2026-08-08] Journey branch question (PR 2 of the activation concept) renders only in mode='first' and persists initial_setup_path as a fire-and-forget PATCH: in mode='add' a silently-failed setActiveCompany (deliberately non-fatal in createCompanyFromOnboarding) would make the PATCH land on the PREVIOUS company's settings, and experienced multi-company users get the Hem checklist anyway; navigation never blocks on the PATCH because the checklist path is a nicety, not a prerequisite. Provider preselect at /import?mode=migration&provider=X auto-advances only for sieViaApi providers (fortnox/bjornlunden/briox): visma/bokio must land on the provider list where the "SIE krävs först" gate renders with its async connection status.
[2026-08-08] SIE export always emits #FORMAT PC8 even when bytes are UTF-8: the record is compulsory in the spec and strict importers (Visma Spiris) reject files without it, while real encoding is detected from bytes (Fortnox ships the same shape). Default bytes stay UTF-8; encoding=cp437 remains opt-in.
[2026-08-08] Login panel is method-stated (BankID hero default, remembered via accounted-login-method cookie) instead of a stacked method list: matches the Swedish bank/Fortnox convention, gives exactly one primary action per view; errors moved from boxed banner to a field-adjacent single line (NN/g 3/4/10), reset link surfaces from the second failed attempt.
@@ -72,8 +72,8 @@ export default async function ExtensionDetailPage({
{/* Header */}
<div className="flex items-start justify-between gap-4 mb-8">
<div className="flex items-start gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10 flex-shrink-0">
<Icon className="h-7 w-7 text-primary" />
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-secondary flex-shrink-0">
<Icon className="h-6 w-6 text-foreground" />
</div>
<div>
<h1 className="font-display text-2xl leading-8 tracking-tight">{extensionName}</h1>
@@ -95,14 +95,14 @@ export default async function ExtensionDetailPage({
{/* Details */}
<div className="space-y-6">
<div>
<h2 className="text-sm font-semibold mb-2">{t('description_heading')}</h2>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-2">{t('description_heading')}</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
{extensionLongDescription}
</p>
</div>
<div>
<h2 className="text-sm font-semibold mb-2">{t('data_source_heading')}</h2>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-2">{t('data_source_heading')}</h2>
<p className="text-sm text-muted-foreground">
{dataPatternLabels[definition.dataPattern]}
</p>
+3 -3
View File
@@ -40,8 +40,8 @@ export default async function SectorExtensionsPage({
{/* Header */}
<div className="flex items-start gap-4 mb-8">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 flex-shrink-0">
<Icon className="h-6 w-6 text-primary" />
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-secondary flex-shrink-0">
<Icon className="h-6 w-6 text-foreground" />
</div>
<div>
<h1 className="font-display text-2xl leading-8 tracking-tight">{sectorName}</h1>
@@ -50,7 +50,7 @@ export default async function SectorExtensionsPage({
</div>
{/* Extensions grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-enter">
{sector.extensions.map(ext => (
<ExtensionCard key={ext.slug} extension={ext} />
))}
+22 -22
View File
@@ -1,13 +1,16 @@
import { getTranslations } from 'next-intl/server'
import { SECTORS } from '@/lib/extensions/sectors'
import { sectorNameKey } from '@/lib/extensions/i18n'
import { PageHeader } from '@/components/ui/page-header'
import ExtensionCard from '@/components/extensions/ExtensionCard'
import SectorCard from '@/components/extensions/SectorCard'
export default async function ExtensionsPage() {
const t = await getTranslations('extensions')
const generalSector = SECTORS.find(s => s.slug === 'general')
const industrySectors = SECTORS.filter(s => s.slug !== 'general')
// Only sectors that actually ship extensions: a shell with zero extensions
// would render a dead card and an empty grid.
const industrySectors = SECTORS.filter(s => s.slug !== 'general' && s.extensions.length > 0)
const generalSectorName = (() => {
if (!generalSector) return ''
@@ -16,21 +19,16 @@ export default async function ExtensionsPage() {
})()
return (
<div>
<div className="mb-8">
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('page_title')}</h1>
<p className="text-sm text-muted-foreground mt-1">
{t('page_description')}
</p>
</div>
<div className="space-y-8">
<PageHeader title={t('page_title')} />
{/* General extensions */}
{generalSector && (
<section className="mb-8">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
<section>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-4">
{generalSectorName}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-enter">
{generalSector.extensions.map(ext => (
<ExtensionCard key={ext.slug} extension={ext} />
))}
@@ -38,17 +36,19 @@ export default async function ExtensionsPage() {
</section>
)}
{/* Industry sectors */}
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
{t('industry_tools')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
{industrySectors.map(sector => (
<SectorCard key={sector.slug} sector={sector} />
))}
</div>
</section>
{/* Industry sectors (hidden while no industry sector ships extensions) */}
{industrySectors.length > 0 && (
<section>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-4">
{t('industry_tools')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 stagger-enter">
{industrySectors.map(sector => (
<SectorCard key={sector.slug} sector={sector} />
))}
</div>
</section>
)}
</div>
)
}
+10 -12
View File
@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Input } from '@/components/ui/input'
import { HelpLink } from '@/components/ui/info-tooltip'
import { PageHeader } from '@/components/ui/page-header'
import { EmptyState } from '@/components/ui/empty-state'
import {
Search,
BookOpen,
@@ -377,14 +378,11 @@ export default function HelpPage() {
{/* Terms list */}
<div className="space-y-4">
{filteredTerms.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Search className="h-8 w-8 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">
{t('no_results', { query: searchQuery })}
</p>
</CardContent>
</Card>
<EmptyState
icon={Search}
title={t('no_results_title')}
description={t('no_results', { query: searchQuery })}
/>
) : (
filteredTerms.map((term) => (
<TermCard
@@ -408,7 +406,7 @@ export default function HelpPage() {
<a
href="/docs/arkivplan-mall.md"
download
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
className="p-3 rounded-lg border border-border transition-colors duration-150 hover:bg-secondary/60 block"
>
<div className="flex items-center gap-2">
<FileDown className="h-4 w-4" />
@@ -421,7 +419,7 @@ export default function HelpPage() {
<a
href="/docs/systemdokumentation-mall.md"
download
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
className="p-3 rounded-lg border border-border transition-colors duration-150 hover:bg-secondary/60 block"
>
<div className="flex items-center gap-2">
<FileDown className="h-4 w-4" />
@@ -444,7 +442,7 @@ export default function HelpPage() {
<div className="grid gap-3 sm:grid-cols-2">
<HelpLink
href="https://www.skatteverket.se/foretag/foretagarguiden.4.361dc8c15312eff6fd1f87f.html"
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
className="p-3 rounded-lg border border-border transition-colors duration-150 hover:bg-secondary/60 block"
>
<div className="flex items-center gap-2">
<ExternalLink className="h-4 w-4" />
@@ -456,7 +454,7 @@ export default function HelpPage() {
</HelpLink>
<HelpLink
href="https://www.verksamt.se/"
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
className="p-3 rounded-lg border border-border transition-colors duration-150 hover:bg-secondary/60 block"
>
<div className="flex items-center gap-2">
<ExternalLink className="h-4 w-4" />
+133 -129
View File
@@ -4,18 +4,12 @@ import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { PageHeader } from '@/components/ui/page-header'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, HOVER_REVEAL_CLASS } from '@/components/ui/dry-table'
import { EmptyState } from '@/components/ui/empty-state'
import { cn } from '@/lib/utils'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
@@ -196,134 +190,144 @@ export default function RecurringInvoicesPage() {
/>
{isLoading ? (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
{t('loading')}
</CardContent>
</Card>
<div className="space-y-2">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : schedules.length === 0 ? (
<Card>
<CardContent className="p-0">
<EmptyState
icon={Repeat}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={canWrite ? t('new_schedule') : undefined}
onAction={canWrite ? openNewSchedule : undefined}
/>
</CardContent>
</Card>
<EmptyState
icon={Repeat}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={canWrite ? t('new_schedule') : undefined}
onAction={canWrite ? openNewSchedule : undefined}
/>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('th_name')}</TableHead>
<TableHead>{t('th_customer')}</TableHead>
<TableHead className="tabular-nums">{t('th_day')}</TableHead>
<TableHead className="tabular-nums">{t('th_next_run')}</TableHead>
<TableHead>{t('th_status')}</TableHead>
<TableHead className="tabular-nums text-right">{t('th_generated')}</TableHead>
<TableHead className="text-right">{t('th_actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{schedules.map((s) => (
<TableRow
key={s.id}
className={canWrite ? 'cursor-pointer' : undefined}
onClick={canWrite ? () => openEdit(s.id) : undefined}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{s.name}
{s.last_run_warning && (
<AlertTriangle
className="h-4 w-4 text-warning-foreground"
aria-label={s.last_run_warning}
/>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{s.customer?.name ?? '-'}
</TableCell>
<TableCell className="tabular-nums">
{s.day_of_month}
<span className="text-muted-foreground">
{' · '}
{t('send_time', {
time: `${String(s.send_hour ?? 8).padStart(2, '0')}:00`,
})}
{/* Monthly is the norm; only a deviating cadence is
worth a label (chips-mark-exceptions convention). */}
{(s.interval_months ?? 1) > 1 && (
<>
{' · '}
{s.interval_months === 3
? t('interval_quarterly')
: s.interval_months === 6
? t('interval_semiannual')
: s.interval_months === 12
? t('interval_yearly')
: t('interval_every_n', { n: s.interval_months })}
</>
)}
</span>
</TableCell>
<TableCell className="tabular-nums">{formatDate(s.next_run_date)}</TableCell>
<TableCell>
{s.status === 'active' ? (
<span className="text-xs text-muted-foreground">{t('status_active')}</span>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={TH_CLASS}>{t('th_name')}</th>
<th className={TH_CLASS}>{t('th_customer')}</th>
<th className={TH_CLASS}>{t('th_day')}</th>
<th className={TH_CLASS}>{t('th_next_run')}</th>
<th className={TH_CLASS}>{t('th_status')}</th>
<th className={`${TH_CLASS} text-right`}>{t('th_generated')}</th>
<th className={`${TH_CLASS} text-right`}>{t('th_actions')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{schedules.map((s) => (
<tr
key={s.id}
className={cn(
'group transition-colors duration-150 hover:bg-secondary/35',
canWrite && 'cursor-pointer',
)}
onClick={canWrite ? () => openEdit(s.id) : undefined}
>
<td className={`${TD_CLASS} font-medium`}>
<div className="flex items-center gap-2">
{/* Focusable edit affordance: the row onClick is mouse-only,
so keyboard users open the editor through the name. */}
{canWrite ? (
<button
type="button"
className="hover:underline underline-offset-4"
onClick={(e) => {
e.stopPropagation()
openEdit(s.id)
}}
>
{s.name}
</button>
) : (
<Badge variant="outline" className="font-normal">{t('status_paused')}</Badge>
s.name
)}
</TableCell>
<TableCell className="tabular-nums text-right">
{s.generated_count}
</TableCell>
<TableCell className="text-right">
{s.last_run_warning && (
<AlertTriangle
className="h-4 w-4 text-warning-foreground"
aria-label={s.last_run_warning}
/>
)}
</div>
</td>
<td className={`${TD_CLASS} text-muted-foreground`}>
{s.customer?.name ?? '-'}
</td>
<td className={`${TD_CLASS} tabular-nums`}>
{s.day_of_month}
<span className="text-muted-foreground">
{' · '}
{t('send_time', {
time: `${String(s.send_hour ?? 8).padStart(2, '0')}:00`,
})}
{/* Monthly is the norm; only a deviating cadence is
worth a label (chips-mark-exceptions convention). */}
{(s.interval_months ?? 1) > 1 && (
<>
{' · '}
{s.interval_months === 3
? t('interval_quarterly')
: s.interval_months === 6
? t('interval_semiannual')
: s.interval_months === 12
? t('interval_yearly')
: t('interval_every_n', { n: s.interval_months })}
</>
)}
</span>
</td>
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(s.next_run_date)}</td>
<td className={TD_CLASS}>
{s.status === 'active' ? (
<span className="text-xs text-muted-foreground">{t('status_active')}</span>
) : (
<Badge variant="outline" className="font-normal">{t('status_paused')}</Badge>
)}
</td>
<td className={`${TD_CLASS} tabular-nums text-right`}>
{s.generated_count}
</td>
<td className={`${TD_CLASS} text-right`}>
{canWrite && (
<div
className="flex justify-end gap-2"
className="flex justify-end gap-4 whitespace-nowrap"
onClick={(e) => e.stopPropagation()}
>
{canWrite && (
<>
<Button
variant="secondary"
size="sm"
disabled={runningId !== null}
onClick={() => runNow(s)}
>
{t('run_now')}
</Button>
<Button
variant="secondary"
size="sm"
disabled={togglingId !== null}
onClick={() => togglePause(s)}
>
{s.status === 'active' ? t('pause') : t('resume')}
</Button>
<Button
variant="ghost"
size="sm"
disabled={deletingId !== null}
onClick={() => deleteSchedule(s)}
>
{t('delete')}
</Button>
</>
)}
<button
type="button"
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
disabled={runningId !== null}
onClick={() => runNow(s)}
>
{t('run_now')}
</button>
<button
type="button"
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
disabled={togglingId !== null}
onClick={() => togglePause(s)}
>
{s.status === 'active' ? t('pause') : t('resume')}
</button>
<button
type="button"
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
disabled={deletingId !== null}
onClick={() => deleteSchedule(s)}
>
{t('delete')}
</button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<NewRecurringScheduleDialog
@@ -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 <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: 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 <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 <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>
+5
View File
@@ -506,6 +506,7 @@
"search_placeholder": "Search for a term...",
"filter_all": "All",
"no_results": "No terms found for \"{query}\"",
"no_results_title": "No matches",
"templates_title": "Documents & Templates",
"templates_subtitle": "Statutory templates for your bookkeeping: download, fill in, and save",
"external_resources_title": "External resources",
@@ -5732,6 +5733,10 @@
"export": "Export",
"recent_heading": "Recently opened",
"back_to_library": "Reports",
"help_bank_reconciliation_scope": "The reconciliation runs against one bank account at a time (e.g. 1930). Other bank accounts, such as Plusgiro 1920, credit card 1940 or currency accounts, are reconciled separately: pick the account in the toolbar.",
"help_bank_reconciliation_preview": "The preview pre-selects strong matches. Approximate matches must be ticked manually after you have reviewed the voucher.",
"help_bank_reconciliation_ib": "Is a manually booked or imported voucher actually an opening balance? Mark it as IB and it is excluded from the reconciliation and shown separately.",
"help_bank_reconciliation_ignored": "Ignored transactions are hidden from the reconciliation without being booked. They do not affect the balance and can be restored at any time.",
"switch_report": "Switch report",
"calendar_badge": "Calendar",
"group_payroll": "Payroll",
+5
View File
@@ -506,6 +506,7 @@
"search_placeholder": "Sök efter term...",
"filter_all": "Alla",
"no_results": "Inga termer hittades för \"{query}\"",
"no_results_title": "Inga träffar",
"templates_title": "Dokument & Mallar",
"templates_subtitle": "Lagstadgade mallar för din bokföring: ladda ner, fyll i och spara",
"external_resources_title": "Externa resurser",
@@ -5732,6 +5733,10 @@
"export": "Exportera",
"recent_heading": "Senast öppnade",
"back_to_library": "Rapporter",
"help_bank_reconciliation_scope": "Avstämningen körs mot ett bankkonto i taget (t.ex. 1930). Övriga bankkonton, som Plusgiro 1920, kreditkort 1940 eller valutakonton, stäms av separat: välj konto i verktygsraden.",
"help_bank_reconciliation_preview": "Förhandsgranskningen förvaljer starka träffar. Ungefärliga träffar bockar du i själv efter att du granskat verifikationen.",
"help_bank_reconciliation_ib": "Är en manuellt bokförd eller importerad verifikation egentligen en ingående balans? Märk den som IB så räknas den inte med i avstämningen utan visas separat.",
"help_bank_reconciliation_ignored": "Ignorerade transaktioner döljs från avstämningen utan att bokföras. De påverkar inte saldot och kan återställas när som helst.",
"switch_report": "Byt rapport",
"calendar_badge": "Kalender",
"group_payroll": "Lön",