refactor(ui): migrate remaining inline pages to the concept design language (#1470)
* refactor(ui): migrate remaining inline pages to the concept design language Catch-up pass for surfaces the 2026-07 UI migration missed: - Bankavstämning: de-boxed toolbar, dry-table sections (preview, omatchade verifikationer, ignorerade, matchade), instructional copy moved behind the page "?" (HelpPopover via FocusedReport, sv+en), AttnLine for the dirty- dates hint, EmptyState for the blank page, space-y-8 rhythm. - Report detail views (trial balance, income statement, balance sheet, resultat-/balansrapport, reskontror, huvudbok, grundbok, dimension-P&L): shared Skeleton/Error/EmptyState shells, border-2 totals bands flattened to hairline cards with font-display tabular-nums headline numbers, ReportSectionTable rebuilt on the group-band idiom, font-mono money -> tabular-nums, house tablist for Förenklad/Detaljerad, GL filter de-boxed onto Input primitives, verdicts follow chips-mark-exceptions. - Extensions browse: PageHeader, locked section headers, rounded-lg secondary icon tiles, flat hover shift on cards, p-6 content. - Återkommande fakturor: page-level list moved off ui/table onto dry-table with hover-revealed quiet row actions; Skeleton loading. - Help: EmptyState for no search hits, flat hover shift on resource links. - Chart of accounts: spinner loading blocks -> Skeleton rows. - Kunskap graph + salary calendar popovers: rounded-lg, Input/Textarea primitives instead of hand-rolled shadow-sm controls. No logic, endpoint, or data changes. Verified via sandbox screenshots; lint 0 errors, 13214 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): review triage: skip empty industry sectors, keyboard path to schedule edit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
645ed0a53e
commit
02a5d10538
@@ -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] 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] 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] 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] 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] 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.
|
[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 */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-4 mb-8">
|
<div className="flex items-start justify-between gap-4 mb-8">
|
||||||
<div className="flex items-start gap-4">
|
<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">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-secondary flex-shrink-0">
|
||||||
<Icon className="h-7 w-7 text-primary" />
|
<Icon className="h-6 w-6 text-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-2xl leading-8 tracking-tight">{extensionName}</h1>
|
<h1 className="font-display text-2xl leading-8 tracking-tight">{extensionName}</h1>
|
||||||
@@ -95,14 +95,14 @@ export default async function ExtensionDetailPage({
|
|||||||
{/* Details */}
|
{/* Details */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<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">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
{extensionLongDescription}
|
{extensionLongDescription}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<p className="text-sm text-muted-foreground">
|
||||||
{dataPatternLabels[definition.dataPattern]}
|
{dataPatternLabels[definition.dataPattern]}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export default async function SectorExtensionsPage({
|
|||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start gap-4 mb-8">
|
<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">
|
<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-primary" />
|
<Icon className="h-6 w-6 text-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-2xl leading-8 tracking-tight">{sectorName}</h1>
|
<h1 className="font-display text-2xl leading-8 tracking-tight">{sectorName}</h1>
|
||||||
@@ -50,7 +50,7 @@ export default async function SectorExtensionsPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Extensions grid */}
|
{/* 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 => (
|
{sector.extensions.map(ext => (
|
||||||
<ExtensionCard key={ext.slug} extension={ext} />
|
<ExtensionCard key={ext.slug} extension={ext} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { getTranslations } from 'next-intl/server'
|
import { getTranslations } from 'next-intl/server'
|
||||||
import { SECTORS } from '@/lib/extensions/sectors'
|
import { SECTORS } from '@/lib/extensions/sectors'
|
||||||
import { sectorNameKey } from '@/lib/extensions/i18n'
|
import { sectorNameKey } from '@/lib/extensions/i18n'
|
||||||
|
import { PageHeader } from '@/components/ui/page-header'
|
||||||
import ExtensionCard from '@/components/extensions/ExtensionCard'
|
import ExtensionCard from '@/components/extensions/ExtensionCard'
|
||||||
import SectorCard from '@/components/extensions/SectorCard'
|
import SectorCard from '@/components/extensions/SectorCard'
|
||||||
|
|
||||||
export default async function ExtensionsPage() {
|
export default async function ExtensionsPage() {
|
||||||
const t = await getTranslations('extensions')
|
const t = await getTranslations('extensions')
|
||||||
const generalSector = SECTORS.find(s => s.slug === 'general')
|
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 = (() => {
|
const generalSectorName = (() => {
|
||||||
if (!generalSector) return ''
|
if (!generalSector) return ''
|
||||||
@@ -16,21 +19,16 @@ export default async function ExtensionsPage() {
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-8">
|
||||||
<div className="mb-8">
|
<PageHeader title={t('page_title')} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* General extensions */}
|
{/* General extensions */}
|
||||||
{generalSector && (
|
{generalSector && (
|
||||||
<section className="mb-8">
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
|
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-4">
|
||||||
{generalSectorName}
|
{generalSectorName}
|
||||||
</h2>
|
</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 => (
|
{generalSector.extensions.map(ext => (
|
||||||
<ExtensionCard key={ext.slug} extension={ext} />
|
<ExtensionCard key={ext.slug} extension={ext} />
|
||||||
))}
|
))}
|
||||||
@@ -38,17 +36,19 @@ export default async function ExtensionsPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Industry sectors */}
|
{/* Industry sectors (hidden while no industry sector ships extensions) */}
|
||||||
<section>
|
{industrySectors.length > 0 && (
|
||||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
|
<section>
|
||||||
{t('industry_tools')}
|
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-4">
|
||||||
</h2>
|
{t('industry_tools')}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
|
</h2>
|
||||||
{industrySectors.map(sector => (
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 stagger-enter">
|
||||||
<SectorCard key={sector.slug} sector={sector} />
|
{industrySectors.map(sector => (
|
||||||
))}
|
<SectorCard key={sector.slug} sector={sector} />
|
||||||
</div>
|
))}
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { HelpLink } from '@/components/ui/info-tooltip'
|
import { HelpLink } from '@/components/ui/info-tooltip'
|
||||||
import { PageHeader } from '@/components/ui/page-header'
|
import { PageHeader } from '@/components/ui/page-header'
|
||||||
|
import { EmptyState } from '@/components/ui/empty-state'
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
@@ -377,14 +378,11 @@ export default function HelpPage() {
|
|||||||
{/* Terms list */}
|
{/* Terms list */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{filteredTerms.length === 0 ? (
|
{filteredTerms.length === 0 ? (
|
||||||
<Card>
|
<EmptyState
|
||||||
<CardContent className="py-12 text-center">
|
icon={Search}
|
||||||
<Search className="h-8 w-8 text-muted-foreground mx-auto mb-3" />
|
title={t('no_results_title')}
|
||||||
<p className="text-muted-foreground">
|
description={t('no_results', { query: searchQuery })}
|
||||||
{t('no_results', { query: searchQuery })}
|
/>
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
filteredTerms.map((term) => (
|
filteredTerms.map((term) => (
|
||||||
<TermCard
|
<TermCard
|
||||||
@@ -408,7 +406,7 @@ export default function HelpPage() {
|
|||||||
<a
|
<a
|
||||||
href="/docs/arkivplan-mall.md"
|
href="/docs/arkivplan-mall.md"
|
||||||
download
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<FileDown className="h-4 w-4" />
|
<FileDown className="h-4 w-4" />
|
||||||
@@ -421,7 +419,7 @@ export default function HelpPage() {
|
|||||||
<a
|
<a
|
||||||
href="/docs/systemdokumentation-mall.md"
|
href="/docs/systemdokumentation-mall.md"
|
||||||
download
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<FileDown className="h-4 w-4" />
|
<FileDown className="h-4 w-4" />
|
||||||
@@ -444,7 +442,7 @@ export default function HelpPage() {
|
|||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<HelpLink
|
<HelpLink
|
||||||
href="https://www.skatteverket.se/foretag/foretagarguiden.4.361dc8c15312eff6fd1f87f.html"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
@@ -456,7 +454,7 @@ export default function HelpPage() {
|
|||||||
</HelpLink>
|
</HelpLink>
|
||||||
<HelpLink
|
<HelpLink
|
||||||
href="https://www.verksamt.se/"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
|||||||
@@ -4,18 +4,12 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { PageHeader } from '@/components/ui/page-header'
|
import { PageHeader } from '@/components/ui/page-header'
|
||||||
import {
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
Table,
|
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, HOVER_REVEAL_CLASS } from '@/components/ui/dry-table'
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import { EmptyState } from '@/components/ui/empty-state'
|
import { EmptyState } from '@/components/ui/empty-state'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
DestructiveConfirmDialog,
|
DestructiveConfirmDialog,
|
||||||
useDestructiveConfirm,
|
useDestructiveConfirm,
|
||||||
@@ -196,134 +190,144 @@ export default function RecurringInvoicesPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Card>
|
<div className="space-y-2">
|
||||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
{[1, 2, 3, 4].map((i) => (
|
||||||
{t('loading')}
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
</CardContent>
|
))}
|
||||||
</Card>
|
</div>
|
||||||
) : schedules.length === 0 ? (
|
) : schedules.length === 0 ? (
|
||||||
<Card>
|
<EmptyState
|
||||||
<CardContent className="p-0">
|
icon={Repeat}
|
||||||
<EmptyState
|
title={t('empty_title')}
|
||||||
icon={Repeat}
|
description={t('empty_description')}
|
||||||
title={t('empty_title')}
|
actionLabel={canWrite ? t('new_schedule') : undefined}
|
||||||
description={t('empty_description')}
|
onAction={canWrite ? openNewSchedule : undefined}
|
||||||
actionLabel={canWrite ? t('new_schedule') : undefined}
|
/>
|
||||||
onAction={canWrite ? openNewSchedule : undefined}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<div className="overflow-x-auto">
|
||||||
<CardContent className="p-0">
|
<table className="w-full border-collapse text-[13px]">
|
||||||
<Table>
|
<thead>
|
||||||
<TableHeader>
|
<tr>
|
||||||
<TableRow>
|
<th className={TH_CLASS}>{t('th_name')}</th>
|
||||||
<TableHead>{t('th_name')}</TableHead>
|
<th className={TH_CLASS}>{t('th_customer')}</th>
|
||||||
<TableHead>{t('th_customer')}</TableHead>
|
<th className={TH_CLASS}>{t('th_day')}</th>
|
||||||
<TableHead className="tabular-nums">{t('th_day')}</TableHead>
|
<th className={TH_CLASS}>{t('th_next_run')}</th>
|
||||||
<TableHead className="tabular-nums">{t('th_next_run')}</TableHead>
|
<th className={TH_CLASS}>{t('th_status')}</th>
|
||||||
<TableHead>{t('th_status')}</TableHead>
|
<th className={`${TH_CLASS} text-right`}>{t('th_generated')}</th>
|
||||||
<TableHead className="tabular-nums text-right">{t('th_generated')}</TableHead>
|
<th className={`${TH_CLASS} text-right`}>{t('th_actions')}</th>
|
||||||
<TableHead className="text-right">{t('th_actions')}</TableHead>
|
</tr>
|
||||||
</TableRow>
|
</thead>
|
||||||
</TableHeader>
|
<tbody className="stagger-enter">
|
||||||
<TableBody>
|
{schedules.map((s) => (
|
||||||
{schedules.map((s) => (
|
<tr
|
||||||
<TableRow
|
key={s.id}
|
||||||
key={s.id}
|
className={cn(
|
||||||
className={canWrite ? 'cursor-pointer' : undefined}
|
'group transition-colors duration-150 hover:bg-secondary/35',
|
||||||
onClick={canWrite ? () => openEdit(s.id) : undefined}
|
canWrite && 'cursor-pointer',
|
||||||
>
|
)}
|
||||||
<TableCell className="font-medium">
|
onClick={canWrite ? () => openEdit(s.id) : undefined}
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
{s.name}
|
<td className={`${TD_CLASS} font-medium`}>
|
||||||
{s.last_run_warning && (
|
<div className="flex items-center gap-2">
|
||||||
<AlertTriangle
|
{/* Focusable edit affordance: the row onClick is mouse-only,
|
||||||
className="h-4 w-4 text-warning-foreground"
|
so keyboard users open the editor through the name. */}
|
||||||
aria-label={s.last_run_warning}
|
{canWrite ? (
|
||||||
/>
|
<button
|
||||||
)}
|
type="button"
|
||||||
</div>
|
className="hover:underline underline-offset-4"
|
||||||
</TableCell>
|
onClick={(e) => {
|
||||||
<TableCell className="text-muted-foreground">
|
e.stopPropagation()
|
||||||
{s.customer?.name ?? '-'}
|
openEdit(s.id)
|
||||||
</TableCell>
|
}}
|
||||||
<TableCell className="tabular-nums">
|
>
|
||||||
{s.day_of_month}
|
{s.name}
|
||||||
<span className="text-muted-foreground">
|
</button>
|
||||||
{' · '}
|
|
||||||
{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>
|
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="font-normal">{t('status_paused')}</Badge>
|
s.name
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
{s.last_run_warning && (
|
||||||
<TableCell className="tabular-nums text-right">
|
<AlertTriangle
|
||||||
{s.generated_count}
|
className="h-4 w-4 text-warning-foreground"
|
||||||
</TableCell>
|
aria-label={s.last_run_warning}
|
||||||
<TableCell className="text-right">
|
/>
|
||||||
|
)}
|
||||||
|
</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
|
<div
|
||||||
className="flex justify-end gap-2"
|
className="flex justify-end gap-4 whitespace-nowrap"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{canWrite && (
|
<button
|
||||||
<>
|
type="button"
|
||||||
<Button
|
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
|
||||||
variant="secondary"
|
disabled={runningId !== null}
|
||||||
size="sm"
|
onClick={() => runNow(s)}
|
||||||
disabled={runningId !== null}
|
>
|
||||||
onClick={() => runNow(s)}
|
{t('run_now')}
|
||||||
>
|
</button>
|
||||||
{t('run_now')}
|
<button
|
||||||
</Button>
|
type="button"
|
||||||
<Button
|
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
|
||||||
variant="secondary"
|
disabled={togglingId !== null}
|
||||||
size="sm"
|
onClick={() => togglePause(s)}
|
||||||
disabled={togglingId !== null}
|
>
|
||||||
onClick={() => togglePause(s)}
|
{s.status === 'active' ? t('pause') : t('resume')}
|
||||||
>
|
</button>
|
||||||
{s.status === 'active' ? t('pause') : t('resume')}
|
<button
|
||||||
</Button>
|
type="button"
|
||||||
<Button
|
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
|
||||||
variant="ghost"
|
disabled={deletingId !== null}
|
||||||
size="sm"
|
onClick={() => deleteSchedule(s)}
|
||||||
disabled={deletingId !== null}
|
>
|
||||||
onClick={() => deleteSchedule(s)}
|
{t('delete')}
|
||||||
>
|
</button>
|
||||||
{t('delete')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
)}
|
||||||
</TableRow>
|
</td>
|
||||||
))}
|
</tr>
|
||||||
</TableBody>
|
))}
|
||||||
</Table>
|
</tbody>
|
||||||
</CardContent>
|
</table>
|
||||||
</Card>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<NewRecurringScheduleDialog
|
<NewRecurringScheduleDialog
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function AgentKnowledgePanel() {
|
|||||||
// Mirrors the loaded layout: graph hero, section header, two profile cards.
|
// Mirrors the loaded layout: graph hero, section header, two profile cards.
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<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">
|
<div className="space-y-4">
|
||||||
<Skeleton className="h-4 w-40" />
|
<Skeleton className="h-4 w-40" />
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<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) {
|
if (model.payees.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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 }}
|
style={{ background: INK, borderColor: HAIR, color: MUTED }}
|
||||||
>
|
>
|
||||||
{t('none_cp')}
|
{t('none_cp')}
|
||||||
@@ -304,7 +304,7 @@ export function LedgerGraph({ deep, companyName }: { deep: DeepLedgerContext; co
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative overflow-hidden rounded-xl border"
|
className="relative overflow-hidden rounded-lg border border-border"
|
||||||
style={{
|
style={{
|
||||||
borderColor: HAIR_STRONG,
|
borderColor: HAIR_STRONG,
|
||||||
background: `radial-gradient(120% 120% at 50% 42%, #17171b 0%, ${INK} 62%)`,
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Switch } from '@/components/ui/switch'
|
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 { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { AccountNumber } from '@/components/ui/account-number'
|
import { AccountNumber } from '@/components/ui/account-number'
|
||||||
@@ -528,9 +529,10 @@ export default function ChartOfAccountsManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-8 text-center text-muted-foreground">
|
<div className="space-y-2 py-2">
|
||||||
<Loader2 className="mx-auto mb-2 h-5 w-5 animate-spin" />
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
{t('loading')}
|
<Skeleton key={i} className="h-8 w-full" />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : view === 'my-accounts' ? (
|
) : view === 'my-accounts' ? (
|
||||||
filteredAccounts.length === 0 ? (
|
filteredAccounts.length === 0 ? (
|
||||||
@@ -669,9 +671,10 @@ export default function ChartOfAccountsManager() {
|
|||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{referenceLoading && referenceAccounts.length === 0 ? (
|
{referenceLoading && referenceAccounts.length === 0 ? (
|
||||||
<div className="p-8 text-center text-muted-foreground">
|
<div className="space-y-2 py-2">
|
||||||
<Loader2 className="mx-auto mb-2 h-5 w-5 animate-spin" />
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
{t('loading')}
|
<Skeleton key={i} className="h-8 w-full" />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : filteredReference.length === 0 ? (
|
) : filteredReference.length === 0 ? (
|
||||||
<p className="px-1 py-12 text-center text-sm text-muted-foreground">{t('no_matches')}</p>
|
<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 (
|
return (
|
||||||
<Card className="group relative">
|
<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 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">
|
<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-primary" />
|
<Icon className="h-5 w-5 text-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ export default async function SectorCard({ sector }: { sector: Sector }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/extensions/${sector.slug}`} className="h-full">
|
<Link href={`/extensions/${sector.slug}`} className="h-full">
|
||||||
<Card className="group hover:border-primary/30 transition-colors cursor-pointer h-full">
|
<Card className="group h-full cursor-pointer transition-colors duration-150 hover:bg-secondary/60">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-start gap-3">
|
<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">
|
<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-primary" />
|
<Icon className="h-5 w-5 text-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium group-hover:text-primary transition-colors">
|
<h3 className="text-sm font-medium">
|
||||||
{name}
|
{name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{description}</p>
|
<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 { Badge } from '@/components/ui/badge'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
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 { 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 { formatCurrency, formatDate } from '@/lib/utils'
|
||||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||||
import { CashAccountSelector } from '@/components/common/CashAccountSelector'
|
import { CashAccountSelector } from '@/components/common/CashAccountSelector'
|
||||||
@@ -878,7 +881,7 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-8">
|
||||||
{error && (
|
{error && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-3 text-center text-destructive text-sm">
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>Avstämning mot <AccountNumber number={accountNumber} /></CardTitle>
|
<CardTitle>Avstämning mot <AccountNumber number={accountNumber} /></CardTitle>
|
||||||
{status.is_reconciled ? (
|
{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>
|
<Badge variant="destructive">Ej avstämd</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
@@ -963,139 +963,124 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Bar */}
|
{/* Toolbar: flat on the panel, no box (UI-migration language) */}
|
||||||
<Card>
|
<div className="space-y-3">
|
||||||
<CardContent className="pt-6">
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<CashAccountSelector
|
||||||
<CashAccountSelector
|
value={accountNumber}
|
||||||
value={accountNumber}
|
onChange={setAccountNumber}
|
||||||
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>
|
</div>
|
||||||
{datesDirty && (
|
<div>
|
||||||
<p className="mt-3 text-xs text-muted-foreground">
|
<Label>Datum till</Label>
|
||||||
Datumfiltret är ändrat men inte tillämpat: klicka Filtrera för att uppdatera
|
<Input
|
||||||
listorna innan du förhandsgranskar.
|
type="date"
|
||||||
</p>
|
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>
|
</div>
|
||||||
</Card>
|
{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 */}
|
{/* Dry Run Preview */}
|
||||||
{dryRunResults && dryRunResults.length > 0 && (
|
{dryRunResults && dryRunResults.length > 0 && (
|
||||||
<Card>
|
<section className="space-y-3">
|
||||||
<CardHeader>
|
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
<CardTitle className="text-base">
|
Förhandsgranskning ({dryRunResults.length})
|
||||||
Förhandsgranskning: {dryRunResults.length}{' '}
|
</h2>
|
||||||
{dryRunResults.length === 1 ? 'matchning hittad' : 'matchningar hittade'}
|
<div className="overflow-x-auto">
|
||||||
</CardTitle>
|
<table className="w-full border-collapse text-[13px]">
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<thead>
|
||||||
Starka träffar är förvalda. Ungefärliga träffar kräver att du bockar i dem själv:
|
<tr>
|
||||||
granska verifikationen först.
|
<th className={`${TH_CLASS} w-8`}></th>
|
||||||
</p>
|
<th className={TH_CLASS}>Transaktion</th>
|
||||||
</CardHeader>
|
<th className={`${TH_CLASS} w-24`}>Datum</th>
|
||||||
<CardContent>
|
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
|
||||||
<div className="overflow-x-auto">
|
<th className={`${TH_CLASS} w-8 text-center`}>↔</th>
|
||||||
<table className="w-full text-sm">
|
<th className={TH_CLASS}>Verifikation</th>
|
||||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
<th className={`${TH_CLASS} w-24`}>Datum</th>
|
||||||
<tr className="border-b text-left">
|
<th className={`${TH_CLASS} w-28`}>Metod</th>
|
||||||
<th className="py-2 w-8"></th>
|
<th className={`${TH_CLASS} w-20`}>Träff</th>
|
||||||
<th className="py-2">Transaktion</th>
|
</tr>
|
||||||
<th className="py-2 w-24">Datum</th>
|
</thead>
|
||||||
<th className="py-2 w-28 text-right">Belopp</th>
|
<tbody className="stagger-enter">
|
||||||
<th className="py-2 w-8 text-center">↔</th>
|
{dryRunResults.map((m) => {
|
||||||
<th className="py-2">Verifikation</th>
|
const key = matchKey(m.transaction_id, m.journal_entry_id)
|
||||||
<th className="py-2 w-24">Datum</th>
|
const badge = confidenceLabel(m.confidence)
|
||||||
<th className="py-2 w-28">Metod</th>
|
return (
|
||||||
<th className="py-2 w-20">Träff</th>
|
<tr key={key} className="transition-colors duration-150 hover:bg-secondary/35">
|
||||||
</tr>
|
<td className={TD_CLASS}>
|
||||||
</thead>
|
<Checkbox
|
||||||
<tbody>
|
checked={selectedPairs.has(key)}
|
||||||
{dryRunResults.map((m) => {
|
onCheckedChange={() => toggleMatchSelection(key)}
|
||||||
const key = matchKey(m.transaction_id, m.journal_entry_id)
|
aria-label={`Tillämpa matchning för ${m.transaction_description}`}
|
||||||
const badge = confidenceLabel(m.confidence)
|
/>
|
||||||
return (
|
</td>
|
||||||
<tr key={key} className="border-b last:border-0">
|
<td className={`${TD_CLASS} truncate max-w-[180px]`}>{m.transaction_description}</td>
|
||||||
<td className="py-2">
|
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(m.transaction_date)}</td>
|
||||||
<Checkbox
|
<td className={`${TD_CLASS} text-right tabular-nums`}>{formatAmount(m.transaction_amount)}</td>
|
||||||
checked={selectedPairs.has(key)}
|
<td className={`${TD_CLASS} text-center text-muted-foreground`}>↔</td>
|
||||||
onCheckedChange={() => toggleMatchSelection(key)}
|
<td className={TD_CLASS}>
|
||||||
aria-label={`Tillämpa matchning för ${m.transaction_description}`}
|
<Link
|
||||||
/>
|
href={`/bookkeeping/${m.journal_entry_id}`}
|
||||||
</td>
|
className="font-mono text-xs underline-offset-2 hover:underline"
|
||||||
<td className="py-2 truncate max-w-[180px]">{m.transaction_description}</td>
|
target="_blank"
|
||||||
<td className="py-2 tabular-nums">{formatDate(m.transaction_date)}</td>
|
>
|
||||||
<td className="py-2 text-right tabular-nums">{formatAmount(m.transaction_amount)}</td>
|
{formatVoucher(m)}
|
||||||
<td className="py-2 text-center text-muted-foreground">↔</td>
|
</Link>
|
||||||
<td className="py-2">
|
<span className="ml-2 text-muted-foreground truncate">{m.entry_description}</span>
|
||||||
<Link
|
</td>
|
||||||
href={`/bookkeeping/${m.journal_entry_id}`}
|
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(m.entry_date)}</td>
|
||||||
className="font-mono text-xs underline-offset-2 hover:underline"
|
<td className={`${TD_CLASS} text-xs text-muted-foreground`}>
|
||||||
target="_blank"
|
{METHOD_LABELS[m.method] || m.method}
|
||||||
>
|
</td>
|
||||||
{formatVoucher(m)}
|
<td className={TD_CLASS}>
|
||||||
</Link>
|
<Badge variant={badge.variant}>{badge.label}</Badge>
|
||||||
<span className="ml-2 text-muted-foreground truncate">{m.entry_description}</span>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
<td className="py-2 tabular-nums">{formatDate(m.entry_date)}</td>
|
)
|
||||||
<td className="py-2">
|
})}
|
||||||
<span className="text-xs text-muted-foreground">
|
</tbody>
|
||||||
{METHOD_LABELS[m.method] || m.method}
|
</table>
|
||||||
</span>
|
</div>
|
||||||
</td>
|
</section>
|
||||||
<td className="py-2">
|
|
||||||
<Badge variant={badge.variant}>{badge.label}</Badge>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dryRunResults && dryRunResults.length === 0 && (
|
{dryRunResults && dryRunResults.length === 0 && (
|
||||||
<Card>
|
<p className="text-sm text-muted-foreground">
|
||||||
<CardContent className="p-6 text-center text-muted-foreground">
|
Inga automatiska matchningar hittades.
|
||||||
Inga automatiska matchningar hittades.
|
</p>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Unmatched Transactions */}
|
{/* Unmatched Transactions */}
|
||||||
@@ -1278,230 +1263,208 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
|
|||||||
|
|
||||||
{/* Unmatched GL Lines */}
|
{/* Unmatched GL Lines */}
|
||||||
{unmatchedGlLines.length > 0 && (
|
{unmatchedGlLines.length > 0 && (
|
||||||
<Card>
|
<section className="space-y-3">
|
||||||
<CardHeader>
|
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
<CardTitle className="text-base">
|
Omatchade verifikationer på <AccountNumber number={accountNumber} /> ({unmatchedGlLines.length})
|
||||||
Omatchade verifikationer på <AccountNumber number={accountNumber} /> ({unmatchedGlLines.length})
|
</h2>
|
||||||
</CardTitle>
|
<div className="overflow-x-auto">
|
||||||
</CardHeader>
|
<table className="w-full border-collapse text-[13px]">
|
||||||
<CardContent>
|
<thead>
|
||||||
<p className="mb-3 text-xs text-muted-foreground">
|
<tr>
|
||||||
Ä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.
|
<th className={`${TH_CLASS} w-16`}>Ver.nr</th>
|
||||||
</p>
|
<th className={`${TH_CLASS} w-24`}>Datum</th>
|
||||||
<div className="overflow-x-auto">
|
<th className={TH_CLASS}>Beskrivning</th>
|
||||||
<table className="w-full text-sm">
|
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
|
||||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
<th className={`${TH_CLASS} w-24`}>Typ</th>
|
||||||
<tr className="border-b text-left">
|
<th className={`${TH_CLASS} w-36`}></th>
|
||||||
<th className="py-2 w-16">Ver.nr</th>
|
</tr>
|
||||||
<th className="py-2 w-24">Datum</th>
|
</thead>
|
||||||
<th className="py-2">Beskrivning</th>
|
<tbody className="stagger-enter">
|
||||||
<th className="py-2 w-28 text-right">Belopp</th>
|
{unmatchedGlLines.map((line) => {
|
||||||
<th className="py-2 w-24">Typ</th>
|
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
|
||||||
<th className="py-2 w-36"></th>
|
const isRetaggable = line.source_type === 'manual' || line.source_type === 'import'
|
||||||
</tr>
|
return (
|
||||||
</thead>
|
<tr key={line.line_id} className="group transition-colors duration-150 hover:bg-secondary/35">
|
||||||
<tbody>
|
<td className={TD_CLASS}>
|
||||||
{unmatchedGlLines.map((line) => {
|
<Link
|
||||||
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
|
href={`/bookkeeping/${line.journal_entry_id}`}
|
||||||
const isRetaggable = line.source_type === 'manual' || line.source_type === 'import'
|
className="font-mono text-xs underline-offset-2 hover:underline"
|
||||||
return (
|
target="_blank"
|
||||||
<tr key={line.line_id} className="border-b last:border-0">
|
>
|
||||||
<td className="py-2">
|
{formatVoucher(line)}
|
||||||
<Link
|
</Link>
|
||||||
href={`/bookkeeping/${line.journal_entry_id}`}
|
</td>
|
||||||
className="font-mono text-xs underline-offset-2 hover:underline"
|
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(line.entry_date)}</td>
|
||||||
target="_blank"
|
<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)}
|
{markLoading === line.journal_entry_id ? 'Markerar…' : 'Märk som IB'}
|
||||||
</Link>
|
</Button>
|
||||||
</td>
|
)}
|
||||||
<td className="py-2 tabular-nums">{formatDate(line.entry_date)}</td>
|
</td>
|
||||||
<td className="py-2 truncate max-w-[300px]">
|
</tr>
|
||||||
{line.line_description || line.entry_description}
|
)
|
||||||
</td>
|
})}
|
||||||
<td className="py-2 text-right tabular-nums">
|
</tbody>
|
||||||
{formatCurrency(amount)}
|
</table>
|
||||||
</td>
|
</div>
|
||||||
<td className="py-2 text-xs text-muted-foreground">
|
</section>
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ignored transactions (undo) */}
|
{/* Ignored transactions (undo) */}
|
||||||
{ignoredTx.length > 0 && (
|
{ignoredTx.length > 0 && (
|
||||||
<Card>
|
<section className="space-y-3">
|
||||||
<CardHeader
|
<button
|
||||||
className="cursor-pointer"
|
type="button"
|
||||||
|
aria-expanded={showIgnored}
|
||||||
onClick={() => setShowIgnored(!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 ? (
|
||||||
{showIgnored ? (
|
<ChevronDown className="h-4 w-4" />
|
||||||
<ChevronDown className="h-4 w-4" />
|
) : (
|
||||||
) : (
|
<ChevronRight className="h-4 w-4" />
|
||||||
<ChevronRight className="h-4 w-4" />
|
)}
|
||||||
)}
|
Ignorerade transaktioner ({ignoredTx.length})
|
||||||
<CardTitle className="text-base">
|
</button>
|
||||||
Ignorerade transaktioner ({ignoredTx.length})
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
{showIgnored && (
|
{showIgnored && (
|
||||||
<CardContent>
|
<div className="overflow-x-auto">
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
<table className="w-full border-collapse text-[13px]">
|
||||||
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.
|
<thead>
|
||||||
</p>
|
<tr>
|
||||||
<div className="overflow-x-auto">
|
<th className={`${TH_CLASS} w-24`}>Datum</th>
|
||||||
<table className="w-full text-sm">
|
<th className={TH_CLASS}>Beskrivning</th>
|
||||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
<th className={`${TH_CLASS} w-20`}>Valuta</th>
|
||||||
<tr className="border-b text-left">
|
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
|
||||||
<th className="py-2 w-24">Datum</th>
|
<th className={`${TH_CLASS} w-28`}></th>
|
||||||
<th className="py-2">Beskrivning</th>
|
</tr>
|
||||||
<th className="py-2 w-20">Valuta</th>
|
</thead>
|
||||||
<th className="py-2 w-28 text-right">Belopp</th>
|
<tbody className="stagger-enter">
|
||||||
<th className="py-2 w-28"></th>
|
{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>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody>
|
</tbody>
|
||||||
{ignoredTx.map((tx) => (
|
</table>
|
||||||
<tr key={tx.id} className="border-b last:border-0 text-muted-foreground">
|
</div>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recently Matched */}
|
{/* Recently Matched */}
|
||||||
{matchedTx.length > 0 && (
|
{matchedTx.length > 0 && (
|
||||||
<Card>
|
<section className="space-y-3">
|
||||||
<CardHeader
|
<button
|
||||||
className="cursor-pointer"
|
type="button"
|
||||||
|
aria-expanded={showMatched}
|
||||||
onClick={() => setShowMatched(!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 ? (
|
||||||
{showMatched ? (
|
<ChevronDown className="h-4 w-4" />
|
||||||
<ChevronDown className="h-4 w-4" />
|
) : (
|
||||||
) : (
|
<ChevronRight className="h-4 w-4" />
|
||||||
<ChevronRight className="h-4 w-4" />
|
)}
|
||||||
)}
|
Matchade transaktioner ({matchedTx.length})
|
||||||
<CardTitle className="text-base">
|
</button>
|
||||||
Matchade transaktioner ({matchedTx.length})
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
{showMatched && (
|
{showMatched && (
|
||||||
<CardContent>
|
<div className="overflow-x-auto">
|
||||||
<div className="overflow-x-auto">
|
<table className="w-full border-collapse text-[13px]">
|
||||||
<table className="w-full text-sm">
|
<thead>
|
||||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
<tr>
|
||||||
<tr className="border-b text-left">
|
<th className={`${TH_CLASS} w-24`}>Datum</th>
|
||||||
<th className="py-2 w-24">Datum</th>
|
<th className={TH_CLASS}>Beskrivning</th>
|
||||||
<th className="py-2">Beskrivning</th>
|
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
|
||||||
<th className="py-2 w-28 text-right">Belopp</th>
|
<th className={`${TH_CLASS} w-32`}>Metod</th>
|
||||||
<th className="py-2 w-32">Metod</th>
|
<th className={`${TH_CLASS} w-28`}>Verifikation</th>
|
||||||
<th className="py-2 w-28">Verifikation</th>
|
<th className={`${TH_CLASS} w-24`}></th>
|
||||||
<th className="py-2 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>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody>
|
</tbody>
|
||||||
{matchedTx.map((tx) => (
|
</table>
|
||||||
<tr key={tx.id} className="border-b last:border-0">
|
</div>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{unmatchedTx.length === 0 && glLines.length === 0 && matchedTx.length === 0 && ignoredTx.length === 0 && !loading && (
|
{unmatchedTx.length === 0 && glLines.length === 0 && matchedTx.length === 0 && ignoredTx.length === 0 && !loading && (
|
||||||
<Card>
|
<EmptyState
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
icon={Landmark}
|
||||||
Inga transaktioner eller verifikationer att stämma av.
|
title="Inget att stämma av"
|
||||||
</CardContent>
|
description="Det finns inga banktransaktioner eller verifikationer i den valda perioden. Importera eller synka banktransaktioner så visas de här."
|
||||||
</Card>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DestructiveConfirmDialog {...confirmDialogProps} />
|
<DestructiveConfirmDialog {...confirmDialogProps} />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
|||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { ChevronLeft } from 'lucide-react'
|
import { ChevronLeft } from 'lucide-react'
|
||||||
import { PageHeader } from '@/components/ui/page-header'
|
import { PageHeader } from '@/components/ui/page-header'
|
||||||
|
import { HelpPopover } from '@/components/ui/help-popover'
|
||||||
import { EmptyState } from '@/components/ui/empty-state'
|
import { EmptyState } from '@/components/ui/empty-state'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
@@ -119,6 +120,20 @@ function FocusedReportInner({
|
|||||||
{!isStandalone && (
|
{!isStandalone && (
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={reportName}
|
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={
|
action={
|
||||||
<FyPicker
|
<FyPicker
|
||||||
value={selectedPeriod || null}
|
value={selectedPeriod || null}
|
||||||
|
|||||||
+259
-357
@@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { EmptyState } from '@/components/ui/empty-state'
|
import { EmptyState } from '@/components/ui/empty-state'
|
||||||
import { FyPicker } from '@/components/common/FyPicker'
|
import { FyPicker } from '@/components/common/FyPicker'
|
||||||
@@ -83,6 +83,36 @@ function formatAmount(amount: number): string {
|
|||||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
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(
|
function reportQuery(
|
||||||
periodId: string,
|
periodId: string,
|
||||||
range?: DateRangeValue,
|
range?: DateRangeValue,
|
||||||
@@ -132,35 +162,19 @@ export function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId:
|
|||||||
}, [periodId])
|
}, [periodId])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <ReportLoadingCard />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <ReportErrorCard message={error} />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-destructive">
|
|
||||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
|
||||||
{error}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.rows.length === 0) {
|
if (!data || data.rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<ReportEmptyState
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
title="Inga verifikationer i perioden"
|
||||||
Inga bokförda verifikationer i denna period.
|
description="Det finns inga bokförda verifikationer i den valda perioden."
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,30 +209,38 @@ export function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId:
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>Saldobalans</CardTitle>
|
<CardTitle>Saldobalans</CardTitle>
|
||||||
<div className="flex items-center gap-3">
|
<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
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === 'simplified'}
|
||||||
onClick={() => setViewMode('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'
|
viewMode === 'simplified'
|
||||||
? 'bg-secondary text-foreground'
|
? 'border border-border bg-card font-medium text-foreground'
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
Förenklad
|
Förenklad
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === 'detailed'}
|
||||||
onClick={() => setViewMode('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'
|
viewMode === 'detailed'
|
||||||
? 'bg-secondary text-foreground'
|
? 'border border-border bg-card font-medium text-foreground'
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
Detaljerad
|
Detaljerad
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{data.isBalanced ? (
|
{data.isBalanced ? (
|
||||||
<Badge variant="success">Balanserad</Badge>
|
<span className="text-sm text-muted-foreground">Balanserad</span>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="destructive">Ej balanserad</Badge>
|
<Badge variant="destructive">Ej balanserad</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -471,35 +493,19 @@ export function IncomeStatementView({ periodId, dateRange, dimensionFilter = nul
|
|||||||
}, [periodId, reportQs])
|
}, [periodId, reportQs])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <ReportLoadingCard />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <ReportErrorCard message={error} />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-destructive">
|
|
||||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
|
||||||
{error}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<ReportEmptyState
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
title="Ingen data för perioden"
|
||||||
Ingen data för denna period.
|
description="Det finns inget bokfört underlag för den valda perioden."
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,12 +527,12 @@ export function IncomeStatementView({ periodId, dateRange, dimensionFilter = nul
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Rörelseintäkter</CardTitle>
|
<CardTitle className="text-base">Rörelseintäkter</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<ReportSectionTable sections={data.revenue_sections} onNavigateToAccount={onNavigateToAccount} />
|
<ReportSectionTable
|
||||||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
sections={data.revenue_sections}
|
||||||
<span>Summa rörelseintäkter</span>
|
onNavigateToAccount={onNavigateToAccount}
|
||||||
<span>{formatAmount(data.total_revenue)} kr</span>
|
footer={{ label: 'Summa rörelseintäkter', amount: data.total_revenue }}
|
||||||
</div>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -535,24 +541,23 @@ export function IncomeStatementView({ periodId, dateRange, dimensionFilter = nul
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Rörelsekostnader</CardTitle>
|
<CardTitle className="text-base">Rörelsekostnader</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<ReportSectionTable sections={data.expense_sections} negate onNavigateToAccount={onNavigateToAccount} />
|
<ReportSectionTable
|
||||||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
sections={data.expense_sections}
|
||||||
<span>Summa rörelsekostnader</span>
|
negate
|
||||||
<span>-{formatAmount(data.total_expenses)} kr</span>
|
onNavigateToAccount={onNavigateToAccount}
|
||||||
</div>
|
footer={{ label: 'Summa rörelsekostnader', amount: data.total_expenses, negate: true }}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Operating result */}
|
{/* Operating result */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-4">
|
<CardContent className="flex items-baseline justify-between p-6">
|
||||||
<div className="flex justify-between font-bold text-lg">
|
<span className="text-sm font-medium">Rörelseresultat</span>
|
||||||
<span>Rörelseresultat</span>
|
<span className={`font-display text-xl tabular-nums ${data.total_revenue - data.total_expenses >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||||
<span className={data.total_revenue - data.total_expenses >= 0 ? 'text-success' : 'text-destructive'}>
|
{formatAmount(data.total_revenue - data.total_expenses)} kr
|
||||||
{formatAmount(data.total_revenue - data.total_expenses)} kr
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -562,25 +567,23 @@ export function IncomeStatementView({ periodId, dateRange, dimensionFilter = nul
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Finansiella poster</CardTitle>
|
<CardTitle className="text-base">Finansiella poster</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<ReportSectionTable sections={data.financial_sections} onNavigateToAccount={onNavigateToAccount} />
|
<ReportSectionTable
|
||||||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
sections={data.financial_sections}
|
||||||
<span>Summa finansiella poster</span>
|
onNavigateToAccount={onNavigateToAccount}
|
||||||
<span>{formatAmount(data.total_financial)} kr</span>
|
footer={{ label: 'Summa finansiella poster', amount: data.total_financial }}
|
||||||
</div>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Net result */}
|
{/* Net result */}
|
||||||
<Card className="border-2">
|
<Card>
|
||||||
<CardContent className="py-4">
|
<CardContent className="flex items-baseline justify-between p-6">
|
||||||
<div className="flex justify-between font-bold text-xl">
|
<span className="text-sm font-medium">Årets resultat</span>
|
||||||
<span>Årets resultat</span>
|
<span className={`font-display text-xl tabular-nums ${data.net_result >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||||
<span className={data.net_result >= 0 ? 'text-success' : 'text-destructive'}>
|
{formatAmount(data.net_result)} kr
|
||||||
{formatAmount(data.net_result)} kr
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -616,33 +619,19 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: {
|
|||||||
}, [periodId, reportQs])
|
}, [periodId, reportQs])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <ReportLoadingCard />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
|
||||||
Laddar balansräkning...
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <ReportErrorCard message={error} />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-destructive">
|
|
||||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
|
||||||
{error}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<ReportEmptyState
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
title="Ingen data för perioden"
|
||||||
Ingen data för denna period.
|
description="Det finns inget bokfört underlag för den valda perioden."
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,12 +651,12 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Tillgångar</CardTitle>
|
<CardTitle className="text-base">Tillgångar</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<ReportSectionTable sections={data.asset_sections} onNavigateToAccount={onNavigateToAccount} />
|
<ReportSectionTable
|
||||||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
sections={data.asset_sections}
|
||||||
<span>Summa tillgångar</span>
|
onNavigateToAccount={onNavigateToAccount}
|
||||||
<span>{formatAmount(data.total_assets)} kr</span>
|
footer={{ label: 'Summa tillgångar', amount: data.total_assets }}
|
||||||
</div>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -676,30 +665,26 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Eget kapital och skulder</CardTitle>
|
<CardTitle className="text-base">Eget kapital och skulder</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<ReportSectionTable sections={data.equity_liability_sections} onNavigateToAccount={onNavigateToAccount} />
|
<ReportSectionTable
|
||||||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
sections={data.equity_liability_sections}
|
||||||
<span>Summa eget kapital och skulder</span>
|
onNavigateToAccount={onNavigateToAccount}
|
||||||
<span>{formatAmount(data.total_equity_liabilities)} kr</span>
|
footer={{ label: 'Summa eget kapital och skulder', amount: data.total_equity_liabilities }}
|
||||||
</div>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Balance check */}
|
{/* Balance check */}
|
||||||
<Card className="border-2">
|
<Card>
|
||||||
<CardContent className="py-4">
|
<CardContent className="p-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="font-bold text-lg">Balanscheck</span>
|
<span className="text-sm font-medium">Balanscheck</span>
|
||||||
{isBalanced ? (
|
{isBalanced ? (
|
||||||
<Badge variant="success" className="text-base px-3 py-1">
|
<span className="text-sm text-muted-foreground">Balanserar</span>
|
||||||
Balanserar
|
|
||||||
</Badge>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<Badge variant="destructive" className="text-base px-3 py-1">
|
<Badge variant="destructive">Balanserar ej</Badge>
|
||||||
Balanserar ej
|
<p className="text-sm text-destructive mt-1 tabular-nums">
|
||||||
</Badge>
|
|
||||||
<p className="text-sm text-destructive mt-1">
|
|
||||||
Differens: {formatAmount(Math.abs(data.total_assets - data.total_equity_liabilities))} kr
|
Differens: {formatAmount(Math.abs(data.total_assets - data.total_equity_liabilities))} kr
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -747,35 +732,19 @@ export function ResultatrapportView({ periodId, dateRange, dimensionFilter = nul
|
|||||||
}, [periodId, reportQs])
|
}, [periodId, reportQs])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <ReportLoadingCard />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <ReportErrorCard message={error} />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-destructive">
|
|
||||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
|
||||||
{error}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.groups.length === 0) {
|
if (!data || data.groups.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<ReportEmptyState
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
title="Inga intäkter eller kostnader"
|
||||||
Inga bokförda intäkter eller kostnader i denna period.
|
description="Det finns inga bokförda intäkter eller kostnader i den valda perioden."
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -859,14 +828,14 @@ export function ResultatrapportView({ periodId, dateRange, dimensionFilter = nul
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-2">
|
<Card>
|
||||||
<CardContent className="py-4">
|
<CardContent className="p-6">
|
||||||
<div className="grid gap-x-6 items-baseline grid-cols-[1fr_auto_auto]">
|
<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="text-sm font-medium">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={`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
|
{formatAmount(data.net_result_current)} kr
|
||||||
</span>
|
</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` : '-'}
|
{hasPrior ? `${formatAmount(data.net_result_prior)} kr` : '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -907,33 +876,19 @@ export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }:
|
|||||||
}, [periodId, reportQs])
|
}, [periodId, reportQs])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <ReportLoadingCard />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
|
||||||
Laddar balansrapport...
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <ReportErrorCard message={error} />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-destructive">
|
|
||||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
|
||||||
{error}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.groups.length === 0) {
|
if (!data || data.groups.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<ReportEmptyState
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
title="Inga balansposter"
|
||||||
Inga balansposter i denna period.
|
description="Det finns inga bokförda balansposter i den valda perioden."
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1005,8 +960,8 @@ export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }:
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-2">
|
<Card>
|
||||||
<CardContent className="py-4 space-y-2">
|
<CardContent className="p-6 space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Summa tillgångar</span>
|
<span className="text-muted-foreground">Summa tillgångar</span>
|
||||||
<span className="tabular-nums">{formatAmount(data.total_assets_ub)} kr</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>
|
<span className="tabular-nums">{formatAmount(data.beraknat_resultat)} kr</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center pt-2 border-t">
|
<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 ? (
|
{data.is_balanced ? (
|
||||||
<Badge variant="success" className="text-base px-3 py-1">
|
<span className="text-sm text-muted-foreground">Balanserar</span>
|
||||||
Balanserar
|
|
||||||
</Badge>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<Badge variant="destructive" className="text-base px-3 py-1">
|
<Badge variant="destructive">Balanserar ej</Badge>
|
||||||
Balanserar ej
|
|
||||||
</Badge>
|
|
||||||
{data.imbalance_diagnosis && (
|
{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
|
Differens: {formatAmount(Math.abs(data.imbalance_diagnosis.differens))} kr
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -1053,45 +1004,64 @@ function ReportSectionTable({
|
|||||||
sections,
|
sections,
|
||||||
negate,
|
negate,
|
||||||
onNavigateToAccount,
|
onNavigateToAccount,
|
||||||
|
footer,
|
||||||
}: {
|
}: {
|
||||||
sections: { title: string; rows: { account_number: string; account_name: string; amount: number }[]; subtotal: number }[]
|
sections: { title: string; rows: { account_number: string; account_name: string; amount: number }[]; subtotal: number }[]
|
||||||
negate?: boolean
|
negate?: boolean
|
||||||
onNavigateToAccount?: (account: string) => void
|
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) {
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="overflow-x-auto">
|
||||||
{sections.map((section) => (
|
<table className="w-full text-sm min-w-[400px]">
|
||||||
<div key={section.title}>
|
<tbody>
|
||||||
<h4 className="text-sm font-semibold text-muted-foreground mb-1">{section.title}</h4>
|
{sections.map((section) => (
|
||||||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[400px]">
|
<React.Fragment key={section.title}>
|
||||||
<tbody>
|
<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) => (
|
{section.rows.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.account_number}
|
key={row.account_number}
|
||||||
className={`border-b last:border-0 ${onNavigateToAccount ? 'cursor-pointer hover:bg-muted/50 transition-colors' : ''}`}
|
className={`border-b last:border-0 ${onNavigateToAccount ? 'cursor-pointer hover:bg-muted/50 transition-colors' : ''}`}
|
||||||
onClick={onNavigateToAccount ? () => onNavigateToAccount(row.account_number) : undefined}
|
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="px-4 py-1.5 w-20"><AccountNumber number={row.account_number} name={row.account_name} /></td>
|
||||||
<td className="py-1">{row.account_name}</td>
|
<td className="px-4 py-1.5">{row.account_name}</td>
|
||||||
<td className="py-1 text-right w-28">
|
<td className="px-4 py-1.5 text-right tabular-nums w-32 whitespace-nowrap">
|
||||||
{negate ? `-${formatAmount(row.amount)}` : formatAmount(row.amount)} kr
|
{fmt(row.amount, negate)} kr
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
<tr className="border-b font-medium">
|
||||||
</table></div>
|
<td colSpan={2} className="px-4 py-1.5 text-right text-muted-foreground">Summa</td>
|
||||||
<div className="flex justify-between text-sm font-medium border-t pt-1 mt-1">
|
<td className="px-4 py-1.5 text-right tabular-nums whitespace-nowrap">{fmt(section.subtotal, negate)} kr</td>
|
||||||
<span>{section.title}</span>
|
</tr>
|
||||||
<span>
|
</React.Fragment>
|
||||||
{negate ? `-${formatAmount(section.subtotal)}` : formatAmount(section.subtotal)} kr
|
))}
|
||||||
</span>
|
</tbody>
|
||||||
</div>
|
{footer && (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2524,33 +2494,19 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) {
|
|||||||
}, [periodId, asOfDate])
|
}, [periodId, asOfDate])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <ReportLoadingCard />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
|
||||||
Laddar leverantörsreskontra...
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <ReportErrorCard message={error} />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-destructive">
|
|
||||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
|
||||||
{error}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || !data.ledger) {
|
if (!data || !data.ledger) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<ReportEmptyState
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
title="Ingen leverantörsreskontra"
|
||||||
Ingen data tillgänglig.
|
description="Det finns inga leverantörsfakturor att visa för den valda perioden."
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2642,7 +2598,7 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) {
|
|||||||
|
|
||||||
{/* Reconciliation */}
|
{/* Reconciliation */}
|
||||||
{reconciliation && (
|
{reconciliation && (
|
||||||
<Card className="border-2">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Avstämning mot <AccountNumber number="2440" /></CardTitle>
|
<CardTitle>Avstämning mot <AccountNumber number="2440" /></CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -2650,21 +2606,21 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) {
|
|||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Leverantörsreskontra (summa utestående)</span>
|
<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>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span><AccountNumber number="2440" /> saldo (huvudbok)</span>
|
<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>
|
||||||
<div className="flex justify-between pt-2 border-t font-semibold">
|
<div className="flex justify-between pt-2 border-t font-semibold">
|
||||||
<span>Differens</span>
|
<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
|
{formatAmount(reconciliation.difference)} kr
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-2 space-y-2">
|
<div className="pt-2 space-y-2">
|
||||||
{reconciliation.is_reconciled ? (
|
{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>
|
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -2810,73 +2766,57 @@ export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFil
|
|||||||
}, [periodId, initialAccountFilter, dimensionFilter, dateRange])
|
}, [periodId, initialAccountFilter, dimensionFilter, dateRange])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <ReportLoadingCard />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
|
||||||
Laddar huvudbok...
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <ReportErrorCard message={error} />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-destructive">
|
|
||||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
|
||||||
{error}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.accounts.length === 0) {
|
if (!data || data.accounts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<ReportEmptyState
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
title="Inga verifikationer i perioden"
|
||||||
Inga bokförda verifikationer i denna period.
|
description="Det finns inga bokförda verifikationer i den valda perioden."
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/general-ledger/xlsx?${reportQuery(periodId, dateRange, dimensionFilter)}` }]} />
|
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/general-ledger/xlsx?${reportQuery(periodId, dateRange, dimensionFilter)}` }]} />
|
||||||
{/* Account range filter */}
|
{/* Account range filter: flat toolbar on the panel, no box */}
|
||||||
<Card>
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
<CardContent className="pt-6">
|
<div>
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<Label htmlFor="gl-account-from">Konto från</Label>
|
||||||
<div>
|
<Input
|
||||||
<Label>Konto från</Label>
|
id="gl-account-from"
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
value={accountFrom}
|
||||||
value={accountFrom}
|
onChange={(e) => setAccountFrom(e.target.value)}
|
||||||
onChange={(e) => setAccountFrom(e.target.value)}
|
placeholder="t.ex. 1510"
|
||||||
placeholder="t.ex. 1510"
|
className="mt-1 w-32"
|
||||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<Label htmlFor="gl-account-to">Konto till</Label>
|
||||||
<Label>Konto till</Label>
|
<Input
|
||||||
<input
|
id="gl-account-to"
|
||||||
type="text"
|
type="text"
|
||||||
value={accountTo}
|
value={accountTo}
|
||||||
onChange={(e) => setAccountTo(e.target.value)}
|
onChange={(e) => setAccountTo(e.target.value)}
|
||||||
placeholder="t.ex. 1519"
|
placeholder="t.ex. 1519"
|
||||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
className="mt-1 w-32"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => fetchData()} variant="outline">
|
<Button onClick={() => fetchData()} variant="outline">
|
||||||
Filtrera
|
Filtrera
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{data.period.start && (
|
{data.period.start && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground tabular-nums">
|
||||||
Period: {data.period.start}: {data.period.end} | {data.accounts.length} konton
|
Period {data.period.start} till {data.period.end} · {data.accounts.length} konton
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2933,16 +2873,16 @@ export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFil
|
|||||||
<td className="py-1.5 text-right tabular-nums">
|
<td className="py-1.5 text-right tabular-nums">
|
||||||
{line.credit > 0 ? formatAmount(line.credit) : ''}
|
{line.credit > 0 ? formatAmount(line.credit) : ''}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr className="font-semibold border-t-2">
|
<tr className="font-semibold border-t-2">
|
||||||
<td colSpan={3} className="py-2">Summa / Utgående balans</td>
|
<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 tabular-nums">{formatAmount(account.total_debit)}</td>
|
||||||
<td className="py-2 text-right">{formatAmount(account.total_credit)}</td>
|
<td className="py-2 text-right tabular-nums">{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.closing_balance)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table></div>
|
</table></div>
|
||||||
@@ -3022,33 +2962,19 @@ export function JournalRegisterView({ periodId }: { periodId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <ReportLoadingCard />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
|
||||||
Laddar grundbok...
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <ReportErrorCard message={error} />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-destructive">
|
|
||||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
|
||||||
{error}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.entries.length === 0) {
|
if (!data || data.entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<ReportEmptyState
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
title="Inga verifikationer i perioden"
|
||||||
Inga bokförda verifikationer i denna period.
|
description="Det finns inga bokförda verifikationer i den valda perioden."
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3056,8 +2982,8 @@ export function JournalRegisterView({ periodId }: { periodId: string }) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/journal-register/xlsx?period_id=${periodId}` }]} />
|
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/journal-register/xlsx?period_id=${periodId}` }]} />
|
||||||
{data.period.start && (
|
{data.period.start && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground tabular-nums">
|
||||||
Period: {data.period.start}: {data.period.end} | {data.total_entries} verifikationer
|
Period {data.period.start} till {data.period.end} · {data.total_entries} verifikationer
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -3324,33 +3250,19 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <ReportLoadingCard />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
|
||||||
Laddar kundreskontra...
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <ReportErrorCard message={error} />
|
||||||
<Card>
|
|
||||||
<CardContent className="p-8 text-center text-destructive">
|
|
||||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
|
||||||
{error}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || !data.ledger) {
|
if (!data || !data.ledger) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<ReportEmptyState
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
title="Ingen kundreskontra"
|
||||||
Ingen data tillgänglig.
|
description="Det finns inga kundfakturor att visa för den valda perioden."
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3471,7 +3383,7 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
|
|||||||
|
|
||||||
{/* Reconciliation */}
|
{/* Reconciliation */}
|
||||||
{reconciliation && (
|
{reconciliation && (
|
||||||
<Card className="border-2">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Avstämning mot <AccountNumber number="1510" /></CardTitle>
|
<CardTitle>Avstämning mot <AccountNumber number="1510" /></CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -3479,21 +3391,21 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
|
|||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Kundreskontra (summa utestående)</span>
|
<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>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Kundfordringar (<AccountNumber number="1510" /> + <AccountNumber number="1513" />) saldo</span>
|
<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>
|
||||||
<div className="flex justify-between pt-2 border-t font-semibold">
|
<div className="flex justify-between pt-2 border-t font-semibold">
|
||||||
<span>Differens</span>
|
<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
|
{formatAmount(reconciliation.difference)} kr
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-2 space-y-2">
|
<div className="pt-2 space-y-2">
|
||||||
{reconciliation.is_reconciled ? (
|
{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>
|
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -3593,11 +3505,7 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{pivotPicker}
|
{pivotPicker}
|
||||||
<Card>
|
<ReportLoadingCard />
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
|
||||||
Laddar rapport...
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3606,12 +3514,7 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{pivotPicker}
|
{pivotPicker}
|
||||||
<Card>
|
<ReportErrorCard message={error} />
|
||||||
<CardContent className="p-8 text-center text-destructive">
|
|
||||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
|
||||||
{error}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3620,11 +3523,10 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{pivotPicker}
|
{pivotPicker}
|
||||||
<Card>
|
<ReportEmptyState
|
||||||
<CardContent className="p-8 text-center text-muted-foreground">
|
title="Inga taggade poster"
|
||||||
Inga taggade intäkter eller kostnader i denna period.
|
description="Inga intäkter eller kostnader är taggade med en dimension i den valda perioden."
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3696,20 +3598,20 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-2">
|
<Card>
|
||||||
<CardContent className="py-4">
|
<CardContent className="p-6">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<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" />
|
<td className="px-4" />
|
||||||
{data.net_per_column.map((v, i) => (
|
{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)}
|
{formatAmount(v)}
|
||||||
</td>
|
</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
|
{formatAmount(data.net_total)} kr
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useLocale, useTranslations } from 'next-intl'
|
import { useLocale, useTranslations } from 'next-intl'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
DestructiveConfirmDialog,
|
DestructiveConfirmDialog,
|
||||||
useDestructiveConfirm,
|
useDestructiveConfirm,
|
||||||
@@ -696,7 +698,7 @@ function BulkWorkedDialog({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium" htmlFor="bulk-w-hours">{t('hours_per_day')}</label>
|
<label className="text-xs font-medium" htmlFor="bulk-w-hours">{t('hours_per_day')}</label>
|
||||||
<input
|
<Input
|
||||||
id="bulk-w-hours"
|
id="bulk-w-hours"
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -705,7 +707,7 @@ function BulkWorkedDialog({
|
|||||||
value={hours}
|
value={hours}
|
||||||
onChange={(e) => setHours(e.target.value)}
|
onChange={(e) => setHours(e.target.value)}
|
||||||
autoFocus
|
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">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{t('hours_zero_hint')}
|
{t('hours_zero_hint')}
|
||||||
@@ -714,13 +716,13 @@ function BulkWorkedDialog({
|
|||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium" htmlFor="bulk-w-notes">{t('notes_label')}</label>
|
<label className="text-xs font-medium" htmlFor="bulk-w-notes">{t('notes_label')}</label>
|
||||||
<textarea
|
<Textarea
|
||||||
id="bulk-w-notes"
|
id="bulk-w-notes"
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
maxLength={2000}
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -853,7 +855,7 @@ function BulkAbsenceDialog({
|
|||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium" htmlFor="bulk-a-hours">{t('hours_per_day')}</label>
|
<label className="text-xs font-medium" htmlFor="bulk-a-hours">{t('hours_per_day')}</label>
|
||||||
<input
|
<Input
|
||||||
id="bulk-a-hours"
|
id="bulk-a-hours"
|
||||||
type="number"
|
type="number"
|
||||||
min={0.5}
|
min={0.5}
|
||||||
@@ -861,19 +863,19 @@ function BulkAbsenceDialog({
|
|||||||
step={0.5}
|
step={0.5}
|
||||||
value={hours}
|
value={hours}
|
||||||
onChange={(e) => setHours(e.target.value)}
|
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>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium" htmlFor="bulk-a-notes">{t('notes_label')}</label>
|
<label className="text-xs font-medium" htmlFor="bulk-a-notes">{t('notes_label')}</label>
|
||||||
<textarea
|
<Textarea
|
||||||
id="bulk-a-notes"
|
id="bulk-a-notes"
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
maxLength={2000}
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -506,6 +506,7 @@
|
|||||||
"search_placeholder": "Search for a term...",
|
"search_placeholder": "Search for a term...",
|
||||||
"filter_all": "All",
|
"filter_all": "All",
|
||||||
"no_results": "No terms found for \"{query}\"",
|
"no_results": "No terms found for \"{query}\"",
|
||||||
|
"no_results_title": "No matches",
|
||||||
"templates_title": "Documents & Templates",
|
"templates_title": "Documents & Templates",
|
||||||
"templates_subtitle": "Statutory templates for your bookkeeping: download, fill in, and save",
|
"templates_subtitle": "Statutory templates for your bookkeeping: download, fill in, and save",
|
||||||
"external_resources_title": "External resources",
|
"external_resources_title": "External resources",
|
||||||
@@ -5732,6 +5733,10 @@
|
|||||||
"export": "Export",
|
"export": "Export",
|
||||||
"recent_heading": "Recently opened",
|
"recent_heading": "Recently opened",
|
||||||
"back_to_library": "Reports",
|
"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",
|
"switch_report": "Switch report",
|
||||||
"calendar_badge": "Calendar",
|
"calendar_badge": "Calendar",
|
||||||
"group_payroll": "Payroll",
|
"group_payroll": "Payroll",
|
||||||
|
|||||||
@@ -506,6 +506,7 @@
|
|||||||
"search_placeholder": "Sök efter term...",
|
"search_placeholder": "Sök efter term...",
|
||||||
"filter_all": "Alla",
|
"filter_all": "Alla",
|
||||||
"no_results": "Inga termer hittades för \"{query}\"",
|
"no_results": "Inga termer hittades för \"{query}\"",
|
||||||
|
"no_results_title": "Inga träffar",
|
||||||
"templates_title": "Dokument & Mallar",
|
"templates_title": "Dokument & Mallar",
|
||||||
"templates_subtitle": "Lagstadgade mallar för din bokföring: ladda ner, fyll i och spara",
|
"templates_subtitle": "Lagstadgade mallar för din bokföring: ladda ner, fyll i och spara",
|
||||||
"external_resources_title": "Externa resurser",
|
"external_resources_title": "Externa resurser",
|
||||||
@@ -5732,6 +5733,10 @@
|
|||||||
"export": "Exportera",
|
"export": "Exportera",
|
||||||
"recent_heading": "Senast öppnade",
|
"recent_heading": "Senast öppnade",
|
||||||
"back_to_library": "Rapporter",
|
"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",
|
"switch_report": "Byt rapport",
|
||||||
"calendar_badge": "Kalender",
|
"calendar_badge": "Kalender",
|
||||||
"group_payroll": "Lön",
|
"group_payroll": "Lön",
|
||||||
|
|||||||
Reference in New Issue
Block a user