feat(reconciliation): the Avstämning page: one body for every account with an outside truth (#1834)
* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade The engine half of the reconciliation page (design: Avstämningsmotorn). - lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket, händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad, bokfört), the item buckets the page shows (proposed, unmatched external, unmatched ledger, matched, ignored, upcoming), opening_difference, unexplained_difference (0,00 by construction when data is consistent), dead-link handling (a link to a reversed/draft entry counts as unlinked and is flagged), awaiting_external for ledger lines within 5 days of the snapshot, staleness, and a window that scopes item lists without hiding older rows. Core reads skattekonto_transactions and the extension's snapshot row directly; no @/extensions import. - lib/reconciliation/gl-balance.ts: one ledger-balance helper with the trial-balance predicate status IN (posted, reversed). The drift check summed posted only, which misstated 1630 for any company with a storno on the account; skattekonto-drift.ts now delegates to the helper. - Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id / suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now assigns one-to-one across rows (AGI period first, then nearest date) and falls back to an entry whose 1630 lines net to the amount (split lines); a proposal is never a link. - lib/reconciliation/service.ts + schemas.ts: the account-keyed facade (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts (enabled cash accounts folded per IBAN, skattekonto when configured) and getAccountStatus dispatching to the bank engine or the new one; shared Zod shapes for the v1 registry, MCP schemas and the UI (PR 2). Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window, window scoping, failed ledger read, live-linked entries never proposed; matcher one-to-one and split-line cases; proposal refresh writes/clears; service dedupe and dispatch. No UI in this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet) The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in five places. Switch to roundOre from @/lib/money and ratchet the baseline down by the three occurrences this removes net of the matcher rewrite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link. - lib/reconciliation/items.ts: listAccountItems per account_key, the page's buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored, upcoming), limit/offset; skattekonto from the engine, bank from the scoped transactions + unlinked GL lines (netted per entry). - lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run, partial success with codes), unmatchLink, setItemIgnored; emits reconciliation.matched / reconciliation.unmatched. - lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a skattekonto row (single line or entry net on 1630, live-link guard, race-safe update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry until its tests are ported. - Dashboard routes /api/reconciliation/accounts[...]: list, status, items, links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply directly (a human clicked). - v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six, withApiV1, new scopes reconciliation:read / reconciliation:write (write is a staging scope for SoD), Idempotency-Key + dry_run on writes, registered for OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes and their transactions:* scopes unchanged. - MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path untouched), new gnubok_list_reconciliation_items (default catalog), gnubok_reconcile_match (stages reconciliation_match, preflight = status) and gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry moved to search. Executors in commit.ts; risk tiers medium/low; migration pair 20260823130000/130001 adds the two op types to the CHECK constraint (value list = live prod as of 2026-08-23 + the two); close_period loadout updated. Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/ happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and apiskill:check green; no type errors in changed files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard The six new v1 reconciliation endpoints and the two new scopes were not recorded in the spec snapshot, and setSkattekontoRowIgnored updated through one conditional payload, which the phantom-column scanner cannot read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): the Avstämning page, one body for every account with an outside truth /reconciliation in Arbeta (after Transaktioner), on the approved layout: an account rail on the left (bank accounts and the skattekonto, logo or monogram, last fetch, status dot, URL-owned selection), and for the selected account four tiles (outside, ledger, difference, unexplained), the bridge that explains the difference, an actions row (link the proposed pairs, book the unbooked skattekonto events, run the bank matcher) and a full-width table banded by bucket with proposal rows linkable one by one. Every read and write goes through the PR 2 dashboard routes, so the page shows exactly what the v1 API and the MCP tools see. Also: nav item, command palette entry, sv/en strings. Period picker, manual match mode and sign-off are deliberately not here (PR 4/5). 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
3a62c5419e
commit
c609dbb228
@@ -22,6 +22,7 @@ import {
|
||||
Settings,
|
||||
HelpCircle,
|
||||
ArrowRight,
|
||||
Scale,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -68,6 +69,7 @@ const PAGE_ENTRIES: Entry[] = [
|
||||
// ReportDescriptor.searchTerms instead, where the library shows a list.
|
||||
{ id: 'rapport-huvudbok', label: 'Visa rapport: Huvudbok', icon: BookOpen, href: '/reports/huvudbok', keywords: 'rapport huvudbok ledger general konto saldo transaktioner per konto verifikat verifikationer verifikationer per konto kontoutdrag kontoanalys kontokort kontohistorik balance account statement transactions vouchers' },
|
||||
{ id: 'rapport-kundreskontra', label: 'Visa rapport: Kundreskontra', icon: Users, href: '/reports/kundreskontra', keywords: 'rapport kundreskontra ar kundfordringar' },
|
||||
{ id: 'avstamning', label: 'Avstämning', hint: 'Stäm av bank och skattekonto', icon: Scale, href: '/reconciliation', keywords: 'avstämning stäm av bank skattekonto matcha reconcile reconciliation 1630 1930' },
|
||||
{ id: 'rapport-bankavstamning', label: 'Bankavstämning', hint: 'Stäm av bank mot bokföring', icon: ArrowLeftRight, href: '/reports/bank-reconciliation', keywords: 'avstämning stäm av bank matcha banktransaktioner reconcile reconciliation 1930' },
|
||||
{ id: 'importera', label: 'Importera', icon: Upload, href: '/import' },
|
||||
{ id: 'granskning', label: 'Granskning', icon: ClipboardCheck, href: '/pending', keywords: 'pending review' },
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
Sparkles,
|
||||
Percent,
|
||||
Landmark,
|
||||
Scale,
|
||||
CalendarClock,
|
||||
CalendarRange,
|
||||
FileCheck,
|
||||
@@ -118,6 +119,7 @@ type NavLabelKey =
|
||||
| 'suppliers'
|
||||
| 'review'
|
||||
| 'transactions'
|
||||
| 'reconciliation'
|
||||
| 'bookkeeping'
|
||||
| 'chart_of_accounts'
|
||||
| 'dimensions'
|
||||
@@ -208,6 +210,7 @@ const navItems: NavItem[] = [
|
||||
{ href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'arbeta' },
|
||||
{ href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'arbeta', requiredCapability: EXTENSION_REQUIRED_CAPABILITY['general/invoice-inbox'] },
|
||||
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'arbeta' },
|
||||
{ href: '/reconciliation', labelKey: 'reconciliation', icon: Scale, group: 'arbeta' },
|
||||
{ href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'arbeta' },
|
||||
{ href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' },
|
||||
// Webshop orders: visible only for companies that actually have a webshop
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton'
|
||||
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, HOVER_REVEAL_CLASS } from '@/components/ui/dry-table'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type {
|
||||
ReconciliationAccount,
|
||||
ReconciliationItem,
|
||||
ReconciliationItemBucket,
|
||||
ReconciliationStatus,
|
||||
} from '@/lib/reconciliation/schemas'
|
||||
import type { SkattekontoBatchRowResult, SkattekontoTransactionWithSuggestion } from '@/types/skatteverket'
|
||||
|
||||
const SkattekontoBookDialog = dynamic(
|
||||
() => import('@/components/skattekonto/SkattekontoBookDialog'),
|
||||
{ loading: DialogLoadingSkeleton },
|
||||
)
|
||||
|
||||
/**
|
||||
* The body of the Avstämning page for one selected account: the four tiles
|
||||
* (outside, ledger, difference, unexplained), the bridge that explains the
|
||||
* difference, the actions row, and the full-width banded table of the rows
|
||||
* behind the bridge. Everything comes from the PR 2 dashboard routes; the
|
||||
* same service functions feed the v1 API and the MCP tools, so what the page
|
||||
* shows is what an agent sees.
|
||||
*/
|
||||
|
||||
interface ItemsPayload {
|
||||
items: ReconciliationItem[]
|
||||
count: number
|
||||
total_count: number
|
||||
has_more: boolean
|
||||
older_unmatched_count: number
|
||||
}
|
||||
|
||||
const BUCKET_ORDER: ReconciliationItemBucket[] = [
|
||||
'proposed',
|
||||
'unmatched_external',
|
||||
'unmatched_ledger',
|
||||
'matched',
|
||||
'ignored',
|
||||
'upcoming',
|
||||
]
|
||||
|
||||
/** Buckets that start folded: they explain the bridge but are not work. */
|
||||
const FOLDED_BY_DEFAULT: ReadonlySet<ReconciliationItemBucket> = new Set(['matched', 'ignored'])
|
||||
|
||||
const ITEMS_LIMIT = 200
|
||||
|
||||
interface AccountOverviewProps {
|
||||
account: ReconciliationAccount
|
||||
/** Called after any write so the rail can refresh its status dots. */
|
||||
onChanged: () => void
|
||||
}
|
||||
|
||||
export function AccountOverview({ account, onChanged }: AccountOverviewProps) {
|
||||
const t = useTranslations('reconciliation')
|
||||
const locale = useLocale()
|
||||
const { toast } = useToast()
|
||||
const [status, setStatus] = useState<ReconciliationStatus | null>(null)
|
||||
const [items, setItems] = useState<ItemsPayload | null>(null)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [unfolded, setUnfolded] = useState<Set<ReconciliationItemBucket>>(new Set())
|
||||
const [bookRow, setBookRow] = useState<ReconciliationItem | null>(null)
|
||||
|
||||
const isSkv = account.kind === 'skattekonto'
|
||||
const base = `/api/reconciliation/accounts/${encodeURIComponent(account.account_key)}`
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [statusRes, itemsRes] = await Promise.all([
|
||||
fetch(base),
|
||||
fetch(`${base}/items?limit=${ITEMS_LIMIT}`),
|
||||
])
|
||||
setLoadError(false)
|
||||
if (!statusRes.ok || !itemsRes.ok) {
|
||||
setLoadError(true)
|
||||
return
|
||||
}
|
||||
const statusJson = await statusRes.json()
|
||||
const itemsJson = await itemsRes.json()
|
||||
setStatus(statusJson.data as ReconciliationStatus)
|
||||
setItems(itemsJson.data as ItemsPayload)
|
||||
} catch {
|
||||
setLoadError(true)
|
||||
}
|
||||
}, [base])
|
||||
|
||||
// The workspace keys this component on account_key, so a new account is a
|
||||
// fresh mount: no state to reset here.
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await load()
|
||||
onChanged()
|
||||
}, [load, onChanged])
|
||||
|
||||
const byBucket = useMemo(() => {
|
||||
const map = new Map<ReconciliationItemBucket, ReconciliationItem[]>()
|
||||
for (const b of BUCKET_ORDER) map.set(b, [])
|
||||
for (const item of items?.items ?? []) map.get(item.bucket)?.push(item)
|
||||
return map
|
||||
}, [items])
|
||||
|
||||
const proposedCount = status?.counts.proposed ?? 0
|
||||
const bookableIds = useMemo(
|
||||
() =>
|
||||
(byBucket.get('unmatched_external') ?? [])
|
||||
.filter((i) => i.item_type === 'skattekonto_transaction' && i.actions.includes('book'))
|
||||
.map((i) => i.item_id),
|
||||
[byBucket],
|
||||
)
|
||||
|
||||
// ---- writes -------------------------------------------------------------
|
||||
|
||||
async function postJson(url: string, body: unknown, method: 'POST' | 'DELETE' = 'POST') {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const json = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title: t('toast_failed'),
|
||||
description: getUserErrorMessage(json, { statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return null
|
||||
}
|
||||
return json.data as Record<string, unknown>
|
||||
}
|
||||
|
||||
async function matchProposals() {
|
||||
setBusy('proposals')
|
||||
try {
|
||||
const data = await postJson(`${base}/links`, { use_proposals: true })
|
||||
if (data) {
|
||||
const applied = (data.applied as unknown[]).length
|
||||
const skipped = (data.skipped as unknown[]).length
|
||||
toast({
|
||||
title: skipped > 0 ? t('toast_matched_skipped', { applied, skipped }) : t('toast_matched', { applied }),
|
||||
})
|
||||
await refresh()
|
||||
}
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function matchOne(item: ReconciliationItem) {
|
||||
if (!item.proposal) return
|
||||
setBusy(item.item_id)
|
||||
try {
|
||||
const data = await postJson(`${base}/links`, {
|
||||
pairs: [{ external_ids: [item.item_id], journal_entry_ids: [item.proposal.journal_entry_id] }],
|
||||
})
|
||||
if (data) {
|
||||
const skipped = data.skipped as Array<{ message: string }>
|
||||
if (skipped.length > 0) {
|
||||
toast({ title: t('toast_failed'), description: skipped[0].message, variant: 'destructive' })
|
||||
} else {
|
||||
toast({ title: t('toast_matched', { applied: 1 }) })
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function unmatchOne(item: ReconciliationItem) {
|
||||
setBusy(item.item_id)
|
||||
try {
|
||||
const data = await postJson(`${base}/links/${item.item_id}`, undefined, 'DELETE')
|
||||
if (data) {
|
||||
toast({ title: t('toast_unmatched') })
|
||||
await refresh()
|
||||
}
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function setIgnored(item: ReconciliationItem, ignored: boolean) {
|
||||
setBusy(item.item_id)
|
||||
try {
|
||||
const data = await postJson(`${base}/items/${item.item_id}/ignore`, { ignored })
|
||||
if (data) {
|
||||
toast({ title: ignored ? t('toast_ignored') : t('toast_unignored') })
|
||||
await refresh()
|
||||
}
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function bookAll() {
|
||||
if (bookableIds.length === 0) return
|
||||
setBusy('book')
|
||||
try {
|
||||
const data = await postJson('/api/extensions/ext/skatteverket/skattekonto/transaktioner/bokfor-batch', {
|
||||
ids: bookableIds,
|
||||
})
|
||||
if (data) {
|
||||
const results = (data.results ?? []) as SkattekontoBatchRowResult[]
|
||||
const ok = results.filter((r) => r.ok).length
|
||||
const failed = results.length - ok
|
||||
toast({
|
||||
title: failed > 0 ? t('toast_book_partial', { ok, failed }) : t('toast_booked', { count: ok }),
|
||||
variant: failed > 0 && ok === 0 ? 'destructive' : undefined,
|
||||
})
|
||||
await refresh()
|
||||
}
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- render -------------------------------------------------------------
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<AttnLine action={{ label: t('older_show'), onClick: () => void load() }}>
|
||||
{t('load_failed')}
|
||||
</AttnLine>
|
||||
)
|
||||
}
|
||||
|
||||
if (!status || !items) {
|
||||
return (
|
||||
<div className="space-y-6" aria-busy>
|
||||
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-background p-4">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="mt-3 h-6 w-28" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-4 w-72" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currency = status.currency
|
||||
const money = (n: number | null | undefined) => (n == null ? t('tile_unknown') : formatCurrency(n, currency))
|
||||
const asOfDate = status.as_of.slice(0, 10)
|
||||
const fetchedAt = isSkv ? status.skattekonto?.fetched_at : account.source.synced_at
|
||||
const sourceLabel = isSkv ? t('source_skv') : t('source_bank')
|
||||
|
||||
const tiles: Array<{ key: string; label: string; value: string; sub: string; tone?: 'ok' | 'attn' }> = [
|
||||
{
|
||||
key: 'external',
|
||||
label: isSkv ? t('tile_external_skv') : t('tile_external_bank'),
|
||||
value: money(status.external_balance),
|
||||
sub: fetchedAt ? t('tile_synced', { date: formatDate(fetchedAt) }) : t('rail_never_synced'),
|
||||
},
|
||||
{
|
||||
key: 'ledger',
|
||||
label: isSkv
|
||||
? t('tile_ledger', { account: status.account_number })
|
||||
: t('tile_ledger_bank', { account: status.account_number }),
|
||||
value: money(status.ledger_balance),
|
||||
sub: t('tile_per', { date: formatDate(asOfDate) }),
|
||||
},
|
||||
{ key: 'difference', label: t('tile_difference'), value: money(status.difference), sub: '' },
|
||||
{
|
||||
key: 'unexplained',
|
||||
label: t('tile_unexplained'),
|
||||
value: money(status.unexplained_difference),
|
||||
sub: '',
|
||||
tone: status.unexplained_difference == null ? undefined : status.is_reconciled ? 'ok' : 'attn',
|
||||
},
|
||||
]
|
||||
|
||||
const unexplained = status.unexplained_difference
|
||||
const attn = status.stale
|
||||
? t('stale_line', { source: sourceLabel })
|
||||
: unexplained != null && Math.abs(unexplained) >= 0.005
|
||||
? t('unexplained_line', { amount: formatCurrency(unexplained, currency) })
|
||||
: null
|
||||
|
||||
const bucketLabel = (bucket: ReconciliationItemBucket): string => {
|
||||
switch (bucket) {
|
||||
case 'proposed':
|
||||
return t('bucket_proposed')
|
||||
case 'unmatched_external':
|
||||
return isSkv ? t('bucket_unmatched_external_skv') : t('bucket_unmatched_external_bank')
|
||||
case 'unmatched_ledger':
|
||||
return isSkv ? t('bucket_unmatched_ledger_skv') : t('bucket_unmatched_ledger_bank')
|
||||
case 'matched':
|
||||
return t('bucket_matched')
|
||||
case 'ignored':
|
||||
return t('bucket_ignored')
|
||||
case 'upcoming':
|
||||
return t('bucket_upcoming')
|
||||
}
|
||||
}
|
||||
|
||||
const openWork =
|
||||
(byBucket.get('proposed')?.length ?? 0) +
|
||||
(byBucket.get('unmatched_external')?.length ?? 0) +
|
||||
(byBucket.get('unmatched_ledger')?.length ?? 0)
|
||||
|
||||
const bankRunHref = '/reports/bank-reconciliation?autorun=1'
|
||||
const bankViewHref = '/reports/bank-reconciliation'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tiles: label + number, nothing else. */}
|
||||
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border stagger-enter">
|
||||
{tiles.map((tile) => (
|
||||
<div key={tile.key} className="bg-background px-4 py-3.5">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.07em] text-muted-foreground">{tile.label}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 text-[22px] font-semibold leading-tight tabular-nums',
|
||||
tile.tone === 'ok' && 'text-success',
|
||||
tile.tone === 'attn' && 'text-warning',
|
||||
)}
|
||||
data-ph-mask
|
||||
>
|
||||
{tile.value}
|
||||
</div>
|
||||
{tile.sub && <div className="mt-0.5 text-[11.5px] text-muted-foreground">{tile.sub}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{attn ? (
|
||||
<AttnLine>{attn}</AttnLine>
|
||||
) : status.is_reconciled ? (
|
||||
<p className="text-[13px] text-muted-foreground">{t('reconciled_line')}</p>
|
||||
) : null}
|
||||
|
||||
{/* Bridge: how the difference is explained. */}
|
||||
{status.bridge.length > 0 && (
|
||||
<dl className="max-w-[520px] text-[13px]">
|
||||
{status.bridge.map((line) => (
|
||||
<div
|
||||
key={line.key}
|
||||
className="flex items-baseline justify-between gap-4 border-b border-border/60 py-1.5 last:border-b-0"
|
||||
>
|
||||
<dt className="text-muted-foreground">
|
||||
{locale === 'en' ? line.label_en : line.label_sv}
|
||||
{line.count != null && line.count > 0 && (
|
||||
<span className="ml-1.5 tabular-nums text-muted-foreground/70" data-ph-mask>
|
||||
({line.count})
|
||||
</span>
|
||||
)}
|
||||
</dt>
|
||||
<dd className="tabular-nums" data-ph-mask>
|
||||
{formatCurrency(line.amount, currency)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{/* Actions row: the work, then the way to the richer surfaces. */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
{proposedCount > 0 && (
|
||||
<Button size="sm" onClick={() => void matchProposals()} disabled={busy !== null}>
|
||||
{t('action_match_proposals', { count: proposedCount })}
|
||||
</Button>
|
||||
)}
|
||||
{isSkv && bookableIds.length > 0 && (
|
||||
<Button size="sm" variant="outline" onClick={() => void bookAll()} disabled={busy !== null}>
|
||||
{t('action_book_rows', { count: bookableIds.length })}
|
||||
</Button>
|
||||
)}
|
||||
{!isSkv && (
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href={bankRunHref}>{t('action_run_bank_matcher')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
<span className="ml-auto">
|
||||
<Link href={isSkv ? '/skattekonto' : bankViewHref} className={QUIET_LINK_CLASS}>
|
||||
{isSkv ? t('action_open_skattekonto') : t('action_open_bank_view')}
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{items.older_unmatched_count > 0 && (
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
{t('older_unmatched', { count: items.older_unmatched_count })}
|
||||
{' · '}
|
||||
<Link href={isSkv ? '/skattekonto' : bankViewHref} className={QUIET_LINK_CLASS}>
|
||||
{t('older_show')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The table: full width, banded by bucket, paired proposal rows. */}
|
||||
{items.items.length === 0 ? (
|
||||
<p className="text-[13px] text-muted-foreground">{t('all_clear')}</p>
|
||||
) : (
|
||||
<div className="-mx-4 overflow-x-auto sm:mx-0">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={cn(TH_CLASS, 'w-[110px]')}>{t('col_date')}</th>
|
||||
<th className={TH_CLASS}>{t('col_event')}</th>
|
||||
<th className={cn(TH_CLASS, 'w-[140px] text-right')}>{t('col_amount')}</th>
|
||||
<th className={cn(TH_CLASS, 'w-[34%]')}>{t('col_voucher')}</th>
|
||||
<th className={cn(TH_CLASS, 'w-[170px]')} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{BUCKET_ORDER.map((bucket) => {
|
||||
const rows = byBucket.get(bucket) ?? []
|
||||
if (rows.length === 0) return null
|
||||
const folded = FOLDED_BY_DEFAULT.has(bucket) && !unfolded.has(bucket)
|
||||
const toggle = () =>
|
||||
setUnfolded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(bucket)) next.delete(bucket)
|
||||
else next.add(bucket)
|
||||
return next
|
||||
})
|
||||
return (
|
||||
<Fragment key={bucket}>
|
||||
<tr className="bg-muted/30">
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<span>
|
||||
{bucketLabel(bucket)}
|
||||
<span className="ml-1.5 font-normal tabular-nums text-muted-foreground/70" data-ph-mask>
|
||||
{rows.length}
|
||||
</span>
|
||||
</span>
|
||||
{FOLDED_BY_DEFAULT.has(bucket) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="normal-case tracking-normal text-[12px] font-normal text-muted-foreground underline decoration-border underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
{folded ? t('show_all', { count: rows.length }) : t('hide')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{!folded &&
|
||||
rows.map((item) => (
|
||||
<ItemRow
|
||||
key={item.item_id}
|
||||
item={item}
|
||||
isSkv={isSkv}
|
||||
sourceLabel={sourceLabel}
|
||||
currency={currency}
|
||||
busy={busy === item.item_id}
|
||||
anyBusy={busy !== null}
|
||||
onMatch={() => void matchOne(item)}
|
||||
onUnmatch={() => void unmatchOne(item)}
|
||||
onIgnore={() => void setIgnored(item, true)}
|
||||
onUnignore={() => void setIgnored(item, false)}
|
||||
onBook={() => setBookRow(item)}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{items.has_more && (
|
||||
<p className="px-4 pt-3 text-[12px] text-muted-foreground">{t('truncated', { count: ITEMS_LIMIT })}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openWork === 0 && items.items.length > 0 && (
|
||||
<p className="text-[13px] text-muted-foreground">{t('all_clear')}</p>
|
||||
)}
|
||||
|
||||
{isSkv && (
|
||||
<SkattekontoBookDialog
|
||||
row={bookRow ? toDialogRow(bookRow) : null}
|
||||
open={bookRow !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setBookRow(null)
|
||||
}}
|
||||
onBooked={() => {
|
||||
setBookRow(null)
|
||||
void refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking dialog reads six fields of a skattekonto row (id, date, text,
|
||||
* amount, booking_suggestion, booking_gate). The reconciliation item carries
|
||||
* the first four; the suggestion is left undefined on purpose, which routes
|
||||
* the dialog to its draft-confirm path (the /skattekonto page is the place
|
||||
* with full rule-based suggestions).
|
||||
*/
|
||||
function toDialogRow(item: ReconciliationItem): SkattekontoTransactionWithSuggestion {
|
||||
return {
|
||||
id: item.item_id,
|
||||
transaktionsdatum: item.date,
|
||||
transaktionstext: item.description,
|
||||
belopp_skatteverket: item.amount,
|
||||
booking_suggestion: undefined,
|
||||
booking_gate: null,
|
||||
} as unknown as SkattekontoTransactionWithSuggestion
|
||||
}
|
||||
|
||||
interface ItemRowProps {
|
||||
item: ReconciliationItem
|
||||
isSkv: boolean
|
||||
sourceLabel: string
|
||||
currency: string
|
||||
busy: boolean
|
||||
anyBusy: boolean
|
||||
onMatch: () => void
|
||||
onUnmatch: () => void
|
||||
onIgnore: () => void
|
||||
onUnignore: () => void
|
||||
onBook: () => void
|
||||
}
|
||||
|
||||
function ItemRow({
|
||||
item,
|
||||
isSkv,
|
||||
sourceLabel,
|
||||
currency,
|
||||
busy,
|
||||
anyBusy,
|
||||
onMatch,
|
||||
onUnmatch,
|
||||
onIgnore,
|
||||
onUnignore,
|
||||
onBook,
|
||||
}: ItemRowProps) {
|
||||
const t = useTranslations('reconciliation')
|
||||
const can = (a: ReconciliationItem['actions'][number]) => item.actions.includes(a)
|
||||
const voucherOf = (e: { voucher_series?: string | null; voucher_number?: number | null }) =>
|
||||
e.voucher_number != null ? formatVoucher({ voucher_series: e.voucher_series, voucher_number: e.voucher_number }) : null
|
||||
|
||||
// The voucher column: for a ledger item, its own voucher; for an external
|
||||
// item, the linked or proposed verifikat.
|
||||
let voucherCell: React.ReactNode = null
|
||||
if (item.side === 'ledger') {
|
||||
const v = voucherOf(item)
|
||||
voucherCell = (
|
||||
<span className="flex items-center gap-2">
|
||||
<Link href={`/bookkeeping/${item.item_id}`} className={QUIET_LINK_CLASS} data-ph-mask>
|
||||
{v ?? item.item_id.slice(0, 8)}
|
||||
</Link>
|
||||
{item.entry_status === 'draft' && <Chip>{t('chip_draft')}</Chip>}
|
||||
{item.entry_status === 'reversed' && <Chip>{t('chip_reversed')}</Chip>}
|
||||
{item.awaiting_external && <Chip>{t('chip_awaiting', { source: sourceLabel })}</Chip>}
|
||||
</span>
|
||||
)
|
||||
} else if (item.proposal) {
|
||||
const p = item.proposal
|
||||
voucherCell = (
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<Link href={`/bookkeeping/${p.journal_entry_id}`} className={QUIET_LINK_CLASS} data-ph-mask>
|
||||
{voucherOf(p) ?? p.journal_entry_id.slice(0, 8)}
|
||||
</Link>
|
||||
<span className="text-[11.5px] tabular-nums text-muted-foreground">{formatDate(p.entry_date)}</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t('confidence', { percent: Math.round(p.confidence * 100) })}
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate text-[12px] text-muted-foreground" data-ph-mask>
|
||||
{p.description}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
} else if (item.linked_journal_entry_id) {
|
||||
voucherCell = (
|
||||
<span className="flex items-center gap-2">
|
||||
<Link href={`/bookkeeping/${item.linked_journal_entry_id}`} className={QUIET_LINK_CLASS} data-ph-mask>
|
||||
{item.linked_journal_entry_id.slice(0, 8)}
|
||||
</Link>
|
||||
{item.link_problem === 'entry_draft' && <Chip>{t('chip_draft')}</Chip>}
|
||||
{item.link_problem === 'entry_reversed' && <Chip>{t('chip_reversed')}</Chip>}
|
||||
{item.link_problem === 'entry_missing' && <Chip>{t('chip_missing')}</Chip>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const openHref = item.item_type === 'transaction' ? `/transactions?highlight=${item.item_id}` : null
|
||||
|
||||
return (
|
||||
<tr className="group">
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>{formatDate(item.date)}</td>
|
||||
<td className={cn(TD_CLASS, 'max-w-0')}>
|
||||
<span className="block truncate" data-ph-mask title={item.description}>
|
||||
{item.description}
|
||||
</span>
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')} data-ph-mask>
|
||||
{formatCurrency(item.amount, currency)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'max-w-0')}>{voucherCell}</td>
|
||||
<td className={cn(TD_CLASS, 'text-right')}>
|
||||
<span className="flex items-center justify-end gap-1.5">
|
||||
{can('match') && item.proposal && (
|
||||
<Button size="sm" variant="outline" onClick={onMatch} disabled={anyBusy} aria-busy={busy}>
|
||||
{t('row_match')}
|
||||
</Button>
|
||||
)}
|
||||
{can('book') && isSkv && (
|
||||
<Button size="sm" variant="outline" onClick={onBook} disabled={anyBusy}>
|
||||
{t('row_book')}
|
||||
</Button>
|
||||
)}
|
||||
{can('book') && !isSkv && openHref && (
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href={openHref}>{t('row_open')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
{can('review') && item.side === 'ledger' && (
|
||||
<Link href={`/bookkeeping/${item.item_id}`} className={QUIET_LINK_CLASS}>
|
||||
{t('row_review')}
|
||||
</Link>
|
||||
)}
|
||||
{can('unmatch') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUnmatch}
|
||||
disabled={anyBusy}
|
||||
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
|
||||
>
|
||||
{t('row_unmatch')}
|
||||
</button>
|
||||
)}
|
||||
{can('ignore') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onIgnore}
|
||||
disabled={anyBusy}
|
||||
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
|
||||
>
|
||||
{t('row_ignore')}
|
||||
</button>
|
||||
)}
|
||||
{can('unignore') && (
|
||||
<button type="button" onClick={onUnignore} disabled={anyBusy} className={QUIET_LINK_CLASS}>
|
||||
{t('row_unignore')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="whitespace-nowrap rounded-full bg-muted px-1.5 py-px text-[10.5px] text-muted-foreground">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import type { ReconciliationAccount } from '@/lib/reconciliation/schemas'
|
||||
|
||||
/**
|
||||
* The account rail of the Avstämning page: one row per account with an
|
||||
* outside truth (bank accounts, the skattekonto), logo or monogram, the
|
||||
* account number, when its outside side was last fetched, and a status dot.
|
||||
* Selection is URL-owned (?account=) by the workspace; the rail only reports.
|
||||
*/
|
||||
|
||||
const DOT_CLASS: Record<NonNullable<ReconciliationAccount['status']>['state'] | 'unknown', string> = {
|
||||
reconciled: 'bg-success',
|
||||
open: 'bg-warning',
|
||||
stale: 'bg-warning',
|
||||
not_configured: 'bg-muted-foreground/40',
|
||||
unknown: 'bg-muted-foreground/40',
|
||||
}
|
||||
|
||||
function monogram(name: string): string {
|
||||
const words = name.trim().split(/\s+/).filter(Boolean)
|
||||
if (words.length === 0) return '?'
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
|
||||
return (words[0][0] + words[1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
export function AccountLogo({ account, className }: { account: ReconciliationAccount; className?: string }) {
|
||||
if (account.logo_url) {
|
||||
return (
|
||||
<Image
|
||||
src={account.logo_url}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className={cn('h-5 w-5 shrink-0 rounded-sm object-contain', className)}
|
||||
unoptimized
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded-sm bg-secondary text-[9px] font-semibold tracking-tight text-secondary-foreground',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{monogram(account.name)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface ReconciliationRailProps {
|
||||
accounts: ReconciliationAccount[]
|
||||
selectedKey: string | null
|
||||
onSelect: (accountKey: string) => void
|
||||
}
|
||||
|
||||
export function ReconciliationRail({ accounts, selectedKey, onSelect }: ReconciliationRailProps) {
|
||||
const t = useTranslations('reconciliation')
|
||||
return (
|
||||
<nav aria-label={t('rail_heading')} className="stagger-enter">
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{accounts.map((account) => {
|
||||
const selected = account.account_key === selectedKey
|
||||
const state = account.status?.state ?? 'unknown'
|
||||
const synced = account.source.synced_at
|
||||
const open = account.status
|
||||
? account.status.open_counts.proposed +
|
||||
account.status.open_counts.unmatched_external +
|
||||
account.status.open_counts.unmatched_ledger
|
||||
: 0
|
||||
return (
|
||||
<li key={account.account_key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(account.account_key)}
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors duration-150',
|
||||
selected ? 'bg-secondary' : 'hover:bg-muted/60',
|
||||
account.superseded_by && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<AccountLogo account={account} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[13px] font-medium text-foreground" data-ph-mask>
|
||||
{account.name}
|
||||
</span>
|
||||
{account.superseded_by && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-px text-[10px] text-muted-foreground">
|
||||
{t('rail_superseded')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block truncate text-[11.5px] text-muted-foreground tabular-nums">
|
||||
<span data-ph-mask>{account.account_number}</span>
|
||||
{' · '}
|
||||
{synced ? t('rail_synced', { date: formatDate(synced) }) : t('rail_never_synced')}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{open > 0 && (
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground" data-ph-mask>
|
||||
{open}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
aria-label={t(`state_${state === 'unknown' ? 'not_configured' : state}`)}
|
||||
title={t(`state_${state === 'unknown' ? 'not_configured' : state}`)}
|
||||
className={cn('h-2 w-2 rounded-full', DOT_CLASS[state])}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Scale } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { ReconciliationAccount } from '@/lib/reconciliation/schemas'
|
||||
import { ReconciliationRail } from './ReconciliationRail'
|
||||
import { AccountOverview } from './AccountOverview'
|
||||
|
||||
/**
|
||||
* /reconciliation: one page for every account with an outside truth. The
|
||||
* rail on the left lists the accounts (bank accounts, the skattekonto) with
|
||||
* their status; the body shows the selected account's bridge and the rows
|
||||
* behind it. Selection lives in the URL (?account=) so a link lands on the
|
||||
* right account and a reload keeps it.
|
||||
*/
|
||||
export function ReconciliationWorkspace() {
|
||||
const t = useTranslations('reconciliation')
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const [accounts, setAccounts] = useState<ReconciliationAccount[] | null>(null)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/reconciliation/accounts')
|
||||
setLoadError(false)
|
||||
if (!res.ok) {
|
||||
setLoadError(true)
|
||||
return
|
||||
}
|
||||
const json = await res.json()
|
||||
setAccounts((json.data?.accounts ?? []) as ReconciliationAccount[])
|
||||
} catch {
|
||||
setLoadError(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const requestedKey = searchParams.get('account')
|
||||
const selected = useMemo(() => {
|
||||
if (!accounts || accounts.length === 0) return null
|
||||
return (
|
||||
accounts.find((a) => a.account_key === requestedKey) ??
|
||||
accounts.find((a) => !a.superseded_by) ??
|
||||
accounts[0]
|
||||
)
|
||||
}, [accounts, requestedKey])
|
||||
|
||||
const select = useCallback(
|
||||
(accountKey: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('account', accountKey)
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
)
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
help={
|
||||
<HelpPopover>
|
||||
<p>{t('help_text')}</p>
|
||||
</HelpPopover>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{header}
|
||||
<AttnLine action={{ label: t('older_show'), onClick: () => void load() }}>{t('load_failed')}</AttnLine>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (accounts === null) {
|
||||
return (
|
||||
<div className="space-y-6" aria-busy>
|
||||
{header}
|
||||
<div className="grid gap-8 lg:grid-cols-[220px_1fr]">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{header}
|
||||
<EmptyState
|
||||
icon={Scale}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_body')}
|
||||
actionLabel={t('empty_connect_bank')}
|
||||
actionHref="/settings/banking"
|
||||
secondaryActionLabel={t('empty_connect_skv')}
|
||||
secondaryActionHref="/settings/skatteverket"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{header}
|
||||
<div className="grid gap-8 lg:grid-cols-[220px_1fr]">
|
||||
<ReconciliationRail accounts={accounts} selectedKey={selected?.account_key ?? null} onSelect={select} />
|
||||
<div className="min-w-0">
|
||||
{selected && <AccountOverview key={selected.account_key} account={selected} onChanged={() => void load()} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user