diff --git a/DECISIONS.md b/DECISIONS.md index 012af294..888b0dfe 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1178,3 +1178,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-24] No-IBAN reconnect pairing (issue #1709) uses only per-currency exactly-one-each-side elimination, deliberately WITHOUT name equality: ASPSPs reformat product names between consents, so requiring it would silently disable the fix for the banks that need it, while the one-per-currency guard already bounds a mis-pair to skipping rows whose account+date+amount+occurrence all collide. upsertFromPsd2 needed no change: its explicit reuse_cash_account_id promote path already covers a same-connection holder, so the fix only names the paired row from the callback. [2026-08-23] Reconciliation engine (PR 1): the skattekonto status engine lives in core lib/reconciliation (reads the core table + the extension snapshot row in extension_data directly) instead of in the skatteverket extension: core must never import @/extensions/*, and the reconciliation facade must work with zero extensions; the matcher stays in the extension and writes its proposals to the row at sync time. Proposals are propose-only (suggested_journal_entry_id is never a link); the same per-entry one-to-one assignment replaces per-row "exactly one candidate". Ledger balances everywhere in reconciliation use the trial-balance predicate status IN (posted, reversed): the drift check summed posted only, which misstated 1630 for every company with a storno on the account. [2026-08-23] Reconciliation doors (PR 2): dashboard routes, the v1 API and the MCP tools all call lib/reconciliation/{service,items,actions}.ts; no door re-implements a link. Policy lives in the door: page + REST apply directly, MCP stages (reconciliation_match / reconciliation_unmatch pending operations, executors in commit.ts). The MCP write tools are catalogVisibility search (and gnubok_link_transaction_to_journal_entry moved to search) because the tools/list payload ceiling (59 900 tokens) left no room for them in the default catalog; the reads (status with account_key, items) stay default and the items description points at the write. The skattekonto link now has its canonical implementation in core lib/skatteverket/skattekonto-link.ts (needed by core doors; core must not import the extension); the extension route still uses its own matchSkattekontoToEntry until its queued-mock tests are ported, then it delegates. New scopes reconciliation:read/write; gnubok_get_reconciliation_status keeps reports:read and the legacy bank routes keep transactions:* so no existing key is cut off. +[2026-08-23] Avstämning page (PR 3) ships without the period picker, the manual two-pane match mode and the sign-off button: the page renders the approved 'Vald riktning' layout (rail + tiles + bridge + actions + banded table) over the PR 2 dashboard routes only, so that it is verifiable on its own; period + sign-off arrive together in PR 4 (both are period-bound), manual N:M matching with residual booking in PR 5. Bank accounts get the same generic body plus links to the existing bank view for the matcher run rather than embedding the 1900-line BankReconciliationView: one body for every account kind is the point of the page, and embedding would have doubled the header. diff --git a/app/(dashboard)/reconciliation/page.tsx b/app/(dashboard)/reconciliation/page.tsx new file mode 100644 index 00000000..3190d0ba --- /dev/null +++ b/app/(dashboard)/reconciliation/page.tsx @@ -0,0 +1,11 @@ +import { Suspense } from 'react' +import { ReconciliationWorkspace } from '@/components/reconciliation/ReconciliationWorkspace' + +// useSearchParams in the workspace needs a Suspense boundary above it. +export default function ReconciliationPage() { + return ( + + + + ) +} diff --git a/components/common/CommandPalette.tsx b/components/common/CommandPalette.tsx index a9291826..39ff6651 100644 --- a/components/common/CommandPalette.tsx +++ b/components/common/CommandPalette.tsx @@ -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' }, diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 79b97793..b83b70f4 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -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 diff --git a/components/reconciliation/AccountOverview.tsx b/components/reconciliation/AccountOverview.tsx new file mode 100644 index 00000000..90c7e96a --- /dev/null +++ b/components/reconciliation/AccountOverview.tsx @@ -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 = 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(null) + const [items, setItems] = useState(null) + const [loadError, setLoadError] = useState(false) + const [busy, setBusy] = useState(null) + const [unfolded, setUnfolded] = useState>(new Set()) + const [bookRow, setBookRow] = useState(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() + 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 + } + + 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 ( + void load() }}> + {t('load_failed')} + + ) + } + + if (!status || !items) { + return ( +
+
+ {[0, 1, 2, 3].map((i) => ( +
+ + +
+ ))} +
+ + +
+ ) + } + + 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 ( +
+ {/* Tiles: label + number, nothing else. */} +
+ {tiles.map((tile) => ( +
+
{tile.label}
+
+ {tile.value} +
+ {tile.sub &&
{tile.sub}
} +
+ ))} +
+ + {attn ? ( + {attn} + ) : status.is_reconciled ? ( +

{t('reconciled_line')}

+ ) : null} + + {/* Bridge: how the difference is explained. */} + {status.bridge.length > 0 && ( +
+ {status.bridge.map((line) => ( +
+
+ {locale === 'en' ? line.label_en : line.label_sv} + {line.count != null && line.count > 0 && ( + + ({line.count}) + + )} +
+
+ {formatCurrency(line.amount, currency)} +
+
+ ))} +
+ )} + + {/* Actions row: the work, then the way to the richer surfaces. */} +
+ {proposedCount > 0 && ( + + )} + {isSkv && bookableIds.length > 0 && ( + + )} + {!isSkv && ( + + )} + + + {isSkv ? t('action_open_skattekonto') : t('action_open_bank_view')} + + +
+ + {items.older_unmatched_count > 0 && ( +

+ {t('older_unmatched', { count: items.older_unmatched_count })} + {' · '} + + {t('older_show')} + +

+ )} + + {/* The table: full width, banded by bucket, paired proposal rows. */} + {items.items.length === 0 ? ( +

{t('all_clear')}

+ ) : ( +
+ + + + + + + + + + + {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 ( + + + + + {!folded && + rows.map((item) => ( + void matchOne(item)} + onUnmatch={() => void unmatchOne(item)} + onIgnore={() => void setIgnored(item, true)} + onUnignore={() => void setIgnored(item, false)} + onBook={() => setBookRow(item)} + /> + ))} + + ) + })} + +
{t('col_date')}{t('col_event')}{t('col_amount')}{t('col_voucher')} +
+ + + {bucketLabel(bucket)} + + {rows.length} + + + {FOLDED_BY_DEFAULT.has(bucket) && ( + + )} + +
+ {items.has_more && ( +

{t('truncated', { count: ITEMS_LIMIT })}

+ )} +
+ )} + + {openWork === 0 && items.items.length > 0 && ( +

{t('all_clear')}

+ )} + + {isSkv && ( + { + if (!open) setBookRow(null) + }} + onBooked={() => { + setBookRow(null) + void refresh() + }} + /> + )} +
+ ) +} + +/** + * 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 = ( + + + {v ?? item.item_id.slice(0, 8)} + + {item.entry_status === 'draft' && {t('chip_draft')}} + {item.entry_status === 'reversed' && {t('chip_reversed')}} + {item.awaiting_external && {t('chip_awaiting', { source: sourceLabel })}} + + ) + } else if (item.proposal) { + const p = item.proposal + voucherCell = ( + + + + {voucherOf(p) ?? p.journal_entry_id.slice(0, 8)} + + {formatDate(p.entry_date)} + + {t('confidence', { percent: Math.round(p.confidence * 100) })} + + + + {p.description} + + + ) + } else if (item.linked_journal_entry_id) { + voucherCell = ( + + + {item.linked_journal_entry_id.slice(0, 8)} + + {item.link_problem === 'entry_draft' && {t('chip_draft')}} + {item.link_problem === 'entry_reversed' && {t('chip_reversed')}} + {item.link_problem === 'entry_missing' && {t('chip_missing')}} + + ) + } + + const openHref = item.item_type === 'transaction' ? `/transactions?highlight=${item.item_id}` : null + + return ( + + {formatDate(item.date)} + + + {item.description} + + + + {formatCurrency(item.amount, currency)} + + {voucherCell} + + + {can('match') && item.proposal && ( + + )} + {can('book') && isSkv && ( + + )} + {can('book') && !isSkv && openHref && ( + + )} + {can('review') && item.side === 'ledger' && ( + + {t('row_review')} + + )} + {can('unmatch') && ( + + )} + {can('ignore') && ( + + )} + {can('unignore') && ( + + )} + + + + ) +} + +function Chip({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/components/reconciliation/ReconciliationRail.tsx b/components/reconciliation/ReconciliationRail.tsx new file mode 100644 index 00000000..86a108c3 --- /dev/null +++ b/components/reconciliation/ReconciliationRail.tsx @@ -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['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 ( + + ) + } + return ( + + {monogram(account.name)} + + ) +} + +interface ReconciliationRailProps { + accounts: ReconciliationAccount[] + selectedKey: string | null + onSelect: (accountKey: string) => void +} + +export function ReconciliationRail({ accounts, selectedKey, onSelect }: ReconciliationRailProps) { + const t = useTranslations('reconciliation') + return ( + + ) +} diff --git a/components/reconciliation/ReconciliationWorkspace.tsx b/components/reconciliation/ReconciliationWorkspace.tsx new file mode 100644 index 00000000..adb5df10 --- /dev/null +++ b/components/reconciliation/ReconciliationWorkspace.tsx @@ -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(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 = ( + +

{t('help_text')}

+ + } + /> + ) + + if (loadError) { + return ( +
+ {header} + void load() }}>{t('load_failed')} +
+ ) + } + + if (accounts === null) { + return ( +
+ {header} +
+
+ + +
+ +
+
+ ) + } + + if (accounts.length === 0) { + return ( +
+ {header} + +
+ ) + } + + return ( +
+ {header} +
+ +
+ {selected && void load()} />} +
+
+
+ ) +} diff --git a/messages/en.json b/messages/en.json index 4c9fb0ee..0d282da0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -91,6 +91,7 @@ "suppliers": "Suppliers", "review": "Review", "transactions": "Transactions", + "reconciliation": "Reconciliation", "bookkeeping": "Bookkeeping", "chart_of_accounts": "Chart of accounts", "dimensions": "Cost centres & projects", @@ -7793,6 +7794,82 @@ "skattekonto_body": "Connect with BankID and the balance and events are fetched continuously, ready to book.", "skattekonto_primary": "Connect Skatteverket" }, + "reconciliation": { + "title": "Reconciliation", + "help_text": "Every account with a truth outside the ledger, the bank or the tax account, is reconciled here. What exists outside is compared with what is booked, each row on one side is linked to a row on the other, and whatever remains is explained or booked.", + "rail_heading": "Accounts", + "rail_synced": "fetched {date}", + "rail_never_synced": "never fetched", + "rail_superseded": "replaced", + "state_reconciled": "Reconciled", + "state_open": "Open items", + "state_stale": "Stale data", + "state_not_configured": "Not connected", + "empty_title": "Nothing to reconcile yet", + "empty_body": "Connect the bank or Skatteverket and the accounts show up here.", + "empty_connect_bank": "Connect bank", + "empty_connect_skv": "Connect Skatteverket", + "load_failed": "Could not load the reconciliation. Try again in a moment.", + "tile_external_skv": "Balance at Skatteverket", + "tile_external_bank": "Bank transactions in the period", + "tile_ledger": "Booked on {account}", + "tile_ledger_bank": "Booked on {account} in the period", + "tile_difference": "Difference", + "tile_unexplained": "Unexplained", + "tile_per": "as of {date}", + "tile_synced": "fetched {date}", + "tile_unknown": "unknown", + "reconciled_line": "Everything is explained: the difference is covered by the rows below.", + "unexplained_line": "{amount} is not explained yet.", + "stale_line": "The data from {source} is older than seven days. Fetch again before trusting the numbers.", + "source_skv": "Skatteverket", + "source_bank": "the bank", + "bridge_heading": "How it adds up", + "action_match_proposals": "Link {count} proposed", + "action_book_rows": "Book {count} events", + "action_open_skattekonto": "Open the tax account", + "action_run_bank_matcher": "Match automatically", + "action_open_bank_view": "Open bank reconciliation", + "older_unmatched": "{count} older unmatched rows before the period start", + "older_show": "Show", + "col_date": "Date", + "col_event": "Event", + "col_amount": "Amount", + "col_voucher": "Voucher", + "bucket_proposed": "To link: proposed pairs", + "bucket_unmatched_external_skv": "At Skatteverket, missing in the ledger", + "bucket_unmatched_external_bank": "At the bank, missing in the ledger", + "bucket_unmatched_ledger_skv": "In the ledger, missing at Skatteverket", + "bucket_unmatched_ledger_bank": "In the ledger, missing at the bank", + "bucket_matched": "Linked", + "bucket_ignored": "Ignored", + "bucket_upcoming": "Upcoming at Skatteverket", + "row_match": "Link", + "row_unmatch": "Unlink", + "row_book": "Book", + "row_review": "Review", + "row_ignore": "Ignore", + "row_unignore": "Restore", + "row_open": "Open", + "chip_awaiting": "awaiting {source}", + "chip_draft": "draft", + "chip_reversed": "reversed", + "chip_missing": "missing", + "chip_proposal_via_total": "via the voucher total", + "confidence": "{percent}% confident", + "show_all": "Show all {count}", + "hide": "Hide", + "all_clear": "No open items. Everything on the account is linked.", + "toast_matched": "{applied} linked", + "toast_matched_skipped": "{applied} linked, {skipped} skipped", + "toast_unmatched": "Link removed", + "toast_ignored": "Row ignored", + "toast_unignored": "Row restored", + "toast_booked": "{count} booked", + "toast_book_partial": "{ok} booked, {failed} failed", + "toast_failed": "That did not work", + "truncated": "Showing the first {count} rows per group." + }, "skattekonto": { "help_text": "The balance and events are fetched from Skatteverket and synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically; anything that cannot be matched is flagged in the list. Pay in via bankgiro 5050-1055 with your OCR number.", "copy": "copy", diff --git a/messages/sv.json b/messages/sv.json index caf7ae45..2478a090 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -91,6 +91,7 @@ "suppliers": "Leverantörer", "review": "Granskning", "transactions": "Transaktioner", + "reconciliation": "Avstämning", "bookkeeping": "Bokföring", "chart_of_accounts": "Kontoplan", "dimensions": "Kostnadsställen & projekt", @@ -7793,6 +7794,82 @@ "skattekonto_body": "Anslut med BankID så hämtas saldo och händelser löpande, färdiga att bokföra.", "skattekonto_primary": "Anslut Skatteverket" }, + "reconciliation": { + "title": "Avstämning", + "help_text": "Varje konto med en sanning utanför bokföringen, banken eller skattekontot, stäms av här. Det som finns utanför jämförs med det som är bokfört, varje rad på ena sidan kopplas till en rad på den andra, och det som blir kvar förklaras eller bokförs.", + "rail_heading": "Konton", + "rail_synced": "hämtat {date}", + "rail_never_synced": "aldrig hämtat", + "rail_superseded": "ersatt", + "state_reconciled": "Avstämt", + "state_open": "Öppna poster", + "state_stale": "Gamla uppgifter", + "state_not_configured": "Ej kopplat", + "empty_title": "Inget att stämma av ännu", + "empty_body": "Koppla banken eller Skatteverket så dyker kontona upp här.", + "empty_connect_bank": "Koppla bank", + "empty_connect_skv": "Koppla Skatteverket", + "load_failed": "Kunde inte hämta avstämningen. Försök igen om en stund.", + "tile_external_skv": "Saldo hos Skatteverket", + "tile_external_bank": "Banktransaktioner i perioden", + "tile_ledger": "Bokfört på {account}", + "tile_ledger_bank": "Bokfört på {account} i perioden", + "tile_difference": "Differens", + "tile_unexplained": "Oförklarat", + "tile_per": "per {date}", + "tile_synced": "hämtat {date}", + "tile_unknown": "okänt", + "reconciled_line": "Allt är förklarat: differensen täcks av raderna nedan.", + "unexplained_line": "{amount} är inte förklarat ännu.", + "stale_line": "Uppgifterna från {source} är äldre än sju dagar. Hämta igen innan du litar på siffrorna.", + "source_skv": "Skatteverket", + "source_bank": "banken", + "bridge_heading": "Så hänger det ihop", + "action_match_proposals": "Koppla {count} föreslagna", + "action_book_rows": "Bokför {count} händelser", + "action_open_skattekonto": "Öppna skattekontot", + "action_run_bank_matcher": "Matcha automatiskt", + "action_open_bank_view": "Öppna bankavstämningen", + "older_unmatched": "{count} äldre omatchade rader före periodstart", + "older_show": "Visa", + "col_date": "Datum", + "col_event": "Händelse", + "col_amount": "Belopp", + "col_voucher": "Verifikat", + "bucket_proposed": "Att koppla: föreslagna par", + "bucket_unmatched_external_skv": "Hos Skatteverket, saknas i bokföringen", + "bucket_unmatched_external_bank": "På banken, saknas i bokföringen", + "bucket_unmatched_ledger_skv": "I bokföringen, saknas hos Skatteverket", + "bucket_unmatched_ledger_bank": "I bokföringen, saknas på banken", + "bucket_matched": "Kopplade", + "bucket_ignored": "Ignorerade", + "bucket_upcoming": "Kommande hos Skatteverket", + "row_match": "Koppla", + "row_unmatch": "Koppla bort", + "row_book": "Bokför", + "row_review": "Granska", + "row_ignore": "Ignorera", + "row_unignore": "Återställ", + "row_open": "Öppna", + "chip_awaiting": "väntar på {source}", + "chip_draft": "utkast", + "chip_reversed": "makulerat", + "chip_missing": "saknas", + "chip_proposal_via_total": "via verifikatets summa", + "confidence": "{percent} % säker", + "show_all": "Visa alla {count}", + "hide": "Dölj", + "all_clear": "Inga öppna poster. Allt på kontot är kopplat.", + "toast_matched": "{applied} kopplade", + "toast_matched_skipped": "{applied} kopplade, {skipped} hoppades över", + "toast_unmatched": "Kopplingen togs bort", + "toast_ignored": "Raden ignoreras", + "toast_unignored": "Raden är tillbaka", + "toast_booked": "{count} bokförda", + "toast_book_partial": "{ok} bokförda, {failed} misslyckades", + "toast_failed": "Det gick inte", + "truncated": "Visar de första {count} raderna per grupp." + }, "skattekonto": { "help_text": "Saldot och händelserna hämtas från Skatteverket och synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt; det som inte kan matchas flaggas i listan. Betala in via bankgiro 5050-1055 med ditt OCR-nummer.", "copy": "kopiera",