'use client' import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import dynamic from 'next/dynamic' import Link from 'next/link' import { useLocale, useTranslations } from 'next-intl' import { useSearchParams } from 'next/navigation' 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' import { SignoffDialog, type SignoffSubmitInput } from './SignoffDialog' import { ReconciliationUnderlag } from './ReconciliationUnderlag' import { MatcherPreview, type MatcherMatch } from './MatcherPreview' import { InfoTooltip } from '@/components/ui/info-tooltip' 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 export interface ReconciliationWindow { from: string to: string } interface AccountOverviewProps { account: ReconciliationAccount /** The other bank accounts in the rail: targets for "Flytta till konto". */ otherBankAccounts?: ReconciliationAccount[] /** The account rail. Rendered inside the summary grid so the items table below can span the full page width (the approved layout). */ rail: ReactNode /** The selected period: scopes the bank bridge and the item windows; its end is the default sign-off date. */ window: ReconciliationWindow /** Called after any write so the rail can refresh its status dots. */ onChanged: () => void } export function AccountOverview({ account, rail, otherBankAccounts = [], window, 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 [signoffOpen, setSignoffOpen] = useState(false) const [matcher, setMatcher] = useState(null) const searchParams = useSearchParams() const autorunRequested = searchParams.get('autorun') === '1' const autorunDone = useRef(false) const isSkv = account.kind === 'skattekonto' // Manual accounts have no rows to match or book: the body is the balance // bridge (IB, movement, UB against a specification or the signer's // underlag) and the sign-off. const isManual = account.kind === 'manual' const base = `/api/reconciliation/accounts/${encodeURIComponent(account.account_key)}` const load = useCallback(async () => { try { const qs = new URLSearchParams({ date_from: window.from, date_to: window.to }) const [statusRes, itemsRes] = await Promise.all([ fetch(`${base}?${qs.toString()}`), fetch(`${base}/items?limit=${ITEMS_LIMIT}&${qs.toString()}`), ]) 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, window.from, window.to]) // 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) } } async function submitSignoff(input: SignoffSubmitInput) { const res = await fetch(`${base}/signoff`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }) const json = await res.json().catch(() => ({})) if (!res.ok) return getUserErrorMessage(json, { statusCode: res.status }) setSignoffOpen(false) toast({ title: t('toast_signed_off', { date: formatDate(input.through_date) }) }) await refresh() return null } async function reopen(signoffId: string) { setBusy('reopen') try { const data = await postJson(`${base}/signoff/${signoffId}/reopen`, {}) if (data) { toast({ title: t('toast_reopened') }) await refresh() } } finally { setBusy(null) } } async function runMatcher() { if (!status) return setBusy('matcher') try { const data = await postJson('/api/reconciliation/bank/run', { date_from: window.from, date_to: window.to, account_number: status.account_number, dry_run: true, }) if (data) setMatcher((data.matches ?? []) as MatcherMatch[]) } finally { setBusy(null) } } async function applyMatches(pairs: MatcherMatch[], strongOnly: boolean) { if (!status || pairs.length === 0) return setBusy('matcher') try { const data = await postJson('/api/reconciliation/bank/run', { date_from: window.from, date_to: window.to, account_number: status.account_number, dry_run: false, selected_matches: pairs.map((m) => ({ transaction_id: m.transaction_id, journal_entry_id: m.journal_entry_id, })), // Strong-only applies re-enforce the floor server-side, same as the // old bank view; a single hand-picked weaker pair omits it. ...(strongOnly ? { confidence_threshold: 0.85 } : {}), }) if (data) { const applied = (data.applied as number) ?? 0 toast({ title: t('toast_matched', { applied }) }) const appliedKeys = new Set(pairs.map((m) => `${m.transaction_id}:${m.journal_entry_id}`)) setMatcher((prev) => (prev ? prev.filter((m) => !appliedKeys.has(`${m.transaction_id}:${m.journal_entry_id}`)) : prev)) await refresh() } } finally { setBusy(null) } } async function markOpeningBalance(item: ReconciliationItem) { setBusy(item.item_id) try { const data = await postJson('/api/reconciliation/bank/mark-opening-balance', { journal_entry_id: item.item_id }) if (data !== null) { toast({ title: t('toast_marked_ib') }) await refresh() } } finally { setBusy(null) } } async function moveToAccount(item: ReconciliationItem, target: ReconciliationAccount) { setBusy(item.item_id) try { const res = await fetch(`/api/transactions/${item.item_id}/cash-account`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ account_number: target.account_number }), }) const json = await res.json().catch(() => ({})) if (!res.ok) { toast({ title: t('toast_failed'), description: getUserErrorMessage(json, { statusCode: res.status }), variant: 'destructive' }) return } toast({ title: t('toast_moved', { account: `${target.name} (${target.account_number})` }) }) await refresh() } finally { setBusy(null) } } // ?autorun=1 (the old bankavstämning deep link from the transactions inbox): // run the matcher preview once the bridge is up, once per mount. useEffect(() => { if (!autorunRequested || autorunDone.current || isSkv || !status) return autorunDone.current = true void runMatcher() // eslint-disable-next-line react-hooks/exhaustive-deps -- fire once when the status first loads }, [autorunRequested, isSkv, status]) // ---- render ------------------------------------------------------------- if (loadError) { return (
{rail}
void load() }}> {t('load_failed')}
) } if (!status || !items) { return (
{rail}
{[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 specification = isManual ? (status.manual?.specification ?? null) : null const specificationLabel = specification ? (locale === 'en' ? specification.label_en : specification.label_sv) : null const bankRaw = status.bank as { bank_transaction_inflow?: number; bank_transaction_outflow?: number; bank_transaction_count?: number } | null const bankBreakdown = !isSkv && bankRaw && typeof bankRaw.bank_transaction_inflow === 'number' && typeof bankRaw.bank_transaction_outflow === 'number' ? t('tile_bank_breakdown', { inflow: formatCurrency(bankRaw.bank_transaction_inflow, currency), outflow: formatCurrency(Math.abs(bankRaw.bank_transaction_outflow), currency), count: bankRaw.bank_transaction_count ?? 0, }) : null const externalTile = isManual ? { key: 'external', label: specificationLabel ?? t('tile_external_manual'), value: money(status.external_balance), sub: specification ? t('tile_spec_today') : status.external_balance != null && status.signoff ? t('tile_external_signed', { date: formatDate(status.signoff.through_date) }) : t('tile_external_manual_sub'), } : { key: 'external', label: isSkv ? t('tile_external_skv') : t('tile_external_bank'), // The bank tile is the period sum (what its label says), which lives on // the bridge; external_balance is the reported bank balance and is often // unknown, which rendered as "okänt" next to a bridge that knows better. value: money( isSkv ? status.external_balance : (status.bridge.find((l) => l.key === 'bank_transactions')?.amount ?? status.external_balance), ), // Bank: the gross split makes the net self-explanatory; skattekonto: when it was fetched. sub: bankBreakdown ?? (fetchedAt ? t('tile_synced', { date: formatDate(fetchedAt) }) : t('rail_never_synced')), } const tiles: Array<{ key: string; label: string; value: string; sub: string; tone?: 'ok' | 'attn'; help?: string }> = [ externalTile, { 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'), help: t('tile_unexplained_help'), value: money(status.unexplained_difference), sub: '', tone: status.unexplained_difference == null ? undefined : status.is_reconciled ? 'ok' : 'attn', }, ] // What the bank itself reports (F7): booked + available balance from the // last PSD2 balance refresh, with its fetch date. Point-in-time, so it // lives outside the movement-based tiles. const bankReportedRaw = !isSkv && !isManual ? (status.bank as { bank_reported_balance?: number | null bank_reported_available_balance?: number | null bank_balance_updated_at?: string | null } | null) : null // Both the amount and its fetch timestamp must exist: a balance of unknown // age labeled with today's date is exactly the misleading staleness the // timestamp exists to prevent, so without it the line is omitted entirely. const bankReportedLine = bankReportedRaw && typeof bankReportedRaw.bank_reported_balance === 'number' && bankReportedRaw.bank_balance_updated_at ? typeof bankReportedRaw.bank_reported_available_balance === 'number' ? t('bank_reported_line_available', { amount: formatCurrency(bankReportedRaw.bank_reported_balance, currency), available: formatCurrency(bankReportedRaw.bank_reported_available_balance, currency), date: formatDate(bankReportedRaw.bank_balance_updated_at), }) : t('bank_reported_line', { amount: formatCurrency(bankReportedRaw.bank_reported_balance, currency), date: formatDate(bankReportedRaw.bank_balance_updated_at), }) : null 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) // Default sign-off date: the window end, never past today nor past the // skattekonto snapshot. The button hides when that date is already signed. const todayIso = new Date().toISOString().slice(0, 10) const signoffMaxDate = isSkv ? (asOfDate < todayIso ? asOfDate : todayIso) : todayIso const signoffDefaultDate = window.to < signoffMaxDate ? window.to : signoffMaxDate const signoffEnabled = !status.signoff || status.signoff.through_date < signoffDefaultDate return (
{rail}
{/* Tiles: label + number, nothing else. */}
{tiles.map((tile) => (
{tile.label} {tile.help && }
{tile.value}
{tile.sub &&
{tile.sub}
}
))}
{bankReportedLine && (

{bankReportedLine}

)} {attn ? ( {attn} ) : status.is_reconciled ? (

{t('reconciled_line')}

) : null} {status.signoff && (

{t('signed_off_line', { date: formatDate(status.signoff.through_date), when: formatDate(status.signoff.signed_at) })} {status.signoff.note && · {t('signed_off_forced')}}

)} {/* 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 && !isManual && ( )} {signoffEnabled && ( )} {isSkv && ( {t('action_open_skattekonto')} )} {isManual && ( {t('action_open_ledger')} )}
{/* Underlag for the balansdag in play: the signed date, else the date the next sign-off would cover. */} = signoffDefaultDate ? status.signoff.through_date : signoffDefaultDate} /> {items.older_unmatched_count > 0 && (

{t('older_unmatched', { count: items.older_unmatched_count })} {isSkv && ( <> {' · '} {t('older_show')} )}

)}
{matcher !== null && !isSkv && ( void applyMatches(pairs, strongOnly)} onClose={() => setMatcher(null)} /> )} {/* The table: full width, banded by bucket, paired proposal rows. */} {isManual ? (

{specificationLabel ? t('manual_spec_hint', { label: specificationLabel }) : t('manual_hint')}

) : 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)} onMarkIb={!isSkv && item.side === 'ledger' && item.bucket === 'unmatched_ledger' ? () => void markOpeningBalance(item) : undefined} moveTargets={!isSkv && item.item_type === 'transaction' && item.bucket === 'unmatched_external' ? otherBankAccounts : []} onMove={(target) => void moveToAccount(item, target)} /> ))} ) })}
{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 /** "Märk som IB" for a ledger row without a bank counterpart (bank accounts). */ onMarkIb?: () => void /** Other bank accounts a stray transaction can be moved to. */ moveTargets: ReconciliationAccount[] onMove: (target: ReconciliationAccount) => void } function ItemRow({ item, isSkv, sourceLabel, currency, busy, anyBusy, onMatch, onUnmatch, onIgnore, onUnignore, onBook, onMarkIb, moveTargets, onMove, }: 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') && ( )} {onMarkIb && ( )} {moveTargets.length > 0 && ( )} ) } function Chip({ children }: { children: React.ReactNode }) { return ( {children} ) }