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:
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">↔</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">↔</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`}>↔</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`}>↔</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} />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user