'use client' import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { AccountNumber } from '@/components/ui/account-number' import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal, Search, X } from 'lucide-react' import { formatCurrency, formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { CashAccountSelector } from '@/components/common/CashAccountSelector' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { DestructiveConfirmDialog, useDestructiveConfirm, } from '@/components/ui/destructive-confirm-dialog' import { useToast } from '@/components/ui/use-toast' import { ToastAction } from '@/components/ui/toast' import type { CashAccount } from '@/types' function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } const METHOD_LABELS: Record = { auto_exact: 'Exakt matchning', auto_date_range: 'Datumintervall', auto_reference: 'Referensmatchning', auto_fuzzy: 'Ungefärlig matchning', manual: 'Manuell', } // ============================================================ // Types // ============================================================ interface ReconciliationStatus { bank_transaction_total: number /** * @deprecated Kept on the server response for back-compat. The UI no longer * reads it — `gl_1930_period_movement` is required. */ gl_1930_balance: number gl_1930_period_movement: number gl_1930_opening_balance: number difference: number is_reconciled: boolean matched_count: number unmatched_transaction_count: number unmatched_gl_line_count: number } interface UnlinkedGLLine { line_id: string journal_entry_id: string debit_amount: number credit_amount: number line_description: string | null entry_date: string voucher_number: number voucher_series: string entry_description: string source_type: string } interface UnmatchedTransaction { id: string date: string description: string amount: number reference: string | null currency: string is_ignored?: boolean } interface MatchedTransaction { id: string date: string description: string amount: number reconciliation_method: string | null journal_entry_id: string | null } interface DryRunMatch { transaction_id: string transaction_date: string transaction_description: string transaction_amount: number journal_entry_id: string voucher_number: number voucher_series: string entry_date: string entry_description: string method: string confidence: number } // ============================================================ // Searchable verifikation picker // ============================================================ /** * Inline combobox for choosing a journal entry to match a bank transaction * against. The native { setSearch(e.target.value) setOpen(true) }} onFocus={() => setOpen(true)} placeholder={placeholder} disabled={disabled} className="pl-9" /> {open && (
{filtered.length === 0 ? (
Inga verifikationer matchar "{search}"
) : (
{filtered.map((line) => { const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount return ( ) })} {glLines.length > filtered.length && (
Visar {filtered.length} av {glLines.length} — sök för att filtrera fler.
)}
)}
)} ) } // ============================================================ // Component // ============================================================ export function BankReconciliationView() { const [status, setStatus] = useState(null) const [unmatchedTx, setUnmatchedTx] = useState([]) const [glLines, setGlLines] = useState([]) const [matchedTx, setMatchedTx] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') const [accountNumber, setAccountNumber] = useState('1930') const [cashAccounts, setCashAccounts] = useState([]) const [dryRunResults, setDryRunResults] = useState(null) const [runLoading, setRunLoading] = useState(false) const [applyLoading, setApplyLoading] = useState(false) const [linkLoading, setLinkLoading] = useState(null) const [unlinkLoading, setUnlinkLoading] = useState(null) const [actionLoading, setActionLoading] = useState(null) const [showMatched, setShowMatched] = useState(false) // Default expanded so users discover the undo path. The card itself only // renders when ignoredTx.length > 0 — collapsing it by default hid the // recovery affordance from anyone who didn't already know it was there. const [showIgnored, setShowIgnored] = useState(true) const [ignoredTx, setIgnoredTx] = useState([]) const [selectedMatch, setSelectedMatch] = useState>({}) const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm() const { toast } = useToast() // Derive the currency for the selected ledger account from cash_accounts. // Without this the lists below would hardcode SEK and silently return zero // rows for users on 1932 EUR (or any other non-SEK cash account). const accountCurrency = cashAccounts.find((a) => a.ledger_account === accountNumber)?.currency ?? 'SEK' useEffect(() => { let cancelled = false fetch('/api/cash-accounts') .then((r) => r.json()) .then((j) => { if (!cancelled && Array.isArray(j.data)) setCashAccounts(j.data as CashAccount[]) }) .catch(() => { // Non-critical — falls back to 'SEK' currency, matches old behaviour. }) return () => { cancelled = true } }, []) const fetchAll = useCallback(async () => { setLoading(true) setError(null) try { const params = new URLSearchParams() if (dateFrom) params.set('date_from', dateFrom) if (dateTo) params.set('date_to', dateTo) params.set('account_number', accountNumber) const qs = `?${params}` const txParams = new URLSearchParams() txParams.set('currency', accountCurrency) txParams.set('account_number', accountNumber) if (dateFrom) txParams.set('date_from', dateFrom) if (dateTo) txParams.set('date_to', dateTo) const unmatchedQs = `?unmatched=true&${txParams}` const reconciledQs = `?reconciled=true&${txParams}` const [statusRes, glRes, unmatchedRes, matchedRes] = await Promise.all([ fetch(`/api/reconciliation/bank/status${qs}`), fetch(`/api/reconciliation/bank/unmatched-entries${qs}`), fetch(`/api/transactions${unmatchedQs}`), fetch(`/api/transactions${reconciledQs}`), ]) const [statusData, glData, unmatchedData, matchedData] = await Promise.all([ statusRes.json(), glRes.json(), unmatchedRes.json(), matchedRes.json(), ]) if (statusData.data) setStatus(statusData.data) setGlLines(glData.data || []) setUnmatchedTx(unmatchedData.data || []) setMatchedTx(matchedData.data || []) // Refresh the ignored list whenever the main lists refresh. // Deliberately NOT filtered by account or currency — if a user ignored // a row on 1932 EUR and then switched to 1930 SEK, the recovery card // would disappear and the row would feel "stuck". Company-wide scope // keeps the Återställ path reachable from any account selection. The // date filter is also dropped so old ignores stay visible. try { const ignoredRes = await fetch(`/api/transactions?unmatched=true&only_ignored=true`) const ignoredData = await ignoredRes.json() setIgnoredTx(ignoredData.data || []) } catch { setIgnoredTx([]) } } catch (e) { console.error('[reconciliation] fetchAll failed', e) setError('Kunde inte hämta avstämningsdata') } finally { setLoading(false) } }, [dateFrom, dateTo, accountNumber, accountCurrency]) useEffect(() => { fetchAll() }, [fetchAll]) const handleDryRun = async () => { setRunLoading(true) setDryRunResults(null) try { const res = await fetch('/api/reconciliation/bank/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date_from: dateFrom || undefined, date_to: dateTo || undefined, account_number: accountNumber, dry_run: true, }), }) const result = await res.json() if (result.data?.matches) { setDryRunResults(result.data.matches) } } catch { setError('Kunde inte köra förhandsgranskning') } finally { setRunLoading(false) } } const handleApply = async () => { setApplyLoading(true) try { await fetch('/api/reconciliation/bank/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date_from: dateFrom || undefined, date_to: dateTo || undefined, account_number: accountNumber, dry_run: false, }), }) setDryRunResults(null) await fetchAll() } catch { setError('Kunde inte tillämpa matchningar') } finally { setApplyLoading(false) } } const handleManualLink = async (transactionId: string) => { const journalEntryId = selectedMatch[transactionId] if (!journalEntryId) return setLinkLoading(transactionId) try { const res = await fetch('/api/reconciliation/bank/link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transaction_id: transactionId, journal_entry_id: journalEntryId, }), }) const result = await res.json() if (result.error) { setError(result.error) } else { setSelectedMatch((prev) => { const next = { ...prev } delete next[transactionId] return next }) await fetchAll() } } catch { setError('Kunde inte matcha transaktion') } finally { setLinkLoading(null) } } const handleUnlink = async (transactionId: string) => { setUnlinkLoading(transactionId) try { const res = await fetch('/api/reconciliation/bank/unlink', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transaction_id: transactionId }), }) const result = await res.json() if (result.error) { setError(result.error) } else { await fetchAll() } } catch { setError('Kunde inte avmatcha transaktion') } finally { setUnlinkLoading(null) } } /** * Inline shortcut for the most common "stuck on the unmatched list" cause: * a small ränteintäkt that has no upstream voucher to match against. Calls * the standard categorize endpoint with the existing bank_interest_income * template so the resulting verifikation is identical to the /transactions * flow — no parallel booking path. */ const handleBookInterestIncome = async (transactionId: string) => { setActionLoading(transactionId) try { const res = await fetch(`/api/transactions/${transactionId}/categorize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_business: true, template_id: 'bank_interest_income', confirm_no_match: true, }), }) const result = await res.json() if (!res.ok || result.error) { setError(result.error?.message || result.error || 'Kunde inte bokföra ränteintäkten') return } if (result.journal_entry_error) { setError(result.journal_entry_error) return } await fetchAll() } catch { setError('Kunde inte bokföra ränteintäkten') } finally { setActionLoading(null) } } const handleIgnore = async (tx: UnmatchedTransaction) => { // Even though Ignorera is fully reversible, it's still a state change the // user could miss after a misclick — the row vanishes from the unmatched // list immediately. Confirmation before the write + an explicit Ångra // toast on success gives two recovery affordances. The persistent // "Ignorerade transaktioner" card is the third. const ok = await confirm({ title: 'Ignorera transaktionen?', description: `${tx.description} — ${formatCurrency(tx.amount)} (${formatDate(tx.date)}) försvinner från avstämningen utan att bokföras. Du kan återställa den från "Ignorerade transaktioner" nedan när som helst.`, confirmLabel: 'Ignorera', cancelLabel: 'Avbryt', variant: 'warning', }) if (!ok) return setActionLoading(tx.id) try { const res = await fetch(`/api/transactions/${tx.id}/ignore`, { method: 'POST', }) const result = await res.json() if (!res.ok || result.error) { setError(result.error || 'Kunde inte ignorera transaktionen') return } await fetchAll() toast({ title: 'Transaktionen ignorerad', description: `${tx.description} — ${formatCurrency(tx.amount)}`, action: ( handleUnignore(tx.id)} > Ångra ), }) } catch { setError('Kunde inte ignorera transaktionen') } finally { setActionLoading(null) } } const handleUnignore = async (transactionId: string) => { setActionLoading(transactionId) try { const res = await fetch(`/api/transactions/${transactionId}/ignore`, { method: 'DELETE', }) const result = await res.json() if (!res.ok || result.error) { setError(result.error || 'Kunde inte återställa transaktionen') return } await fetchAll() } catch { setError('Kunde inte återställa transaktionen') } finally { setActionLoading(null) } } if (loading) { return ( Laddar bankavstämning... ) } if (error && !status) { return ( {error} ) } return (
{error && ( {error} )} {/* Status Card */} {status && (
Avstämning mot {status.is_reconciled ? ( Avstämd ) : ( Ej avstämd )}

Avstämningen körs mot ({accountCurrency}). Övriga bankkonton (t.ex. Plusgiro , kreditkort eller valutakonton) stäms av separat — välj kontot i listan nedan.

Banktransaktioner i perioden {formatCurrency(status.bank_transaction_total)}
Bokfört på i perioden {formatCurrency(status.gl_1930_period_movement)}
Differens {formatCurrency(status.difference)}
{status.gl_1930_opening_balance !== 0 && (

Ingående balans (IB) på :{' '} {formatCurrency(status.gl_1930_opening_balance)} {' '}— räknas inte i avstämningen.

)}
Matchade: {status.matched_count} Omatchade transaktioner: {status.unmatched_transaction_count} Omatchade verifikationer: {status.unmatched_gl_line_count}
)} {/* Action Bar */}
setDateFrom(e.target.value)} className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm" />
setDateTo(e.target.value)} className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm" />
{dryRunResults && dryRunResults.length > 0 && ( )}
{/* Dry Run Preview */} {dryRunResults && dryRunResults.length > 0 && ( Förhandsgranskning — {dryRunResults.length} matchningar hittade {dryRunResults.map((m) => ( ))}
Transaktion Datum Belopp ↔ Verifikation Datum Metod
{m.transaction_description} {formatDate(m.transaction_date)} {formatAmount(m.transaction_amount)} ↔ {formatVoucher(m)} {m.entry_description} {formatDate(m.entry_date)} {METHOD_LABELS[m.method] || m.method}
)} {dryRunResults && dryRunResults.length === 0 && ( Inga automatiska matchningar hittades. )} {/* Unmatched Transactions */} {unmatchedTx.length > 0 && (

Omatchade transaktioner ({unmatchedTx.length})

{glLines.length > 0 && (

{glLines.length} verifikation{glLines.length === 1 ? '' : 'er'} att matcha mot

)}
{unmatchedTx.map((tx) => { // Piggy-bank shortcut hardcodes the 1930↔8310 ränteintäkt template, // so only offer it on a SEK account using 1930. On EUR (1932) or // other settlement accounts the booking would post the EUR amount // to the SEK cash account — silently wrong, hide it. const canBookInterest = tx.amount > 0 && accountNumber === '1930' const isPositive = tx.amount > 0 return (
{/* Header row: meta + description + amount + menu */}
{formatDate(tx.date)} · {tx.currency} {tx.reference && ( <> · Ref: {tx.reference} )}
{tx.description}
{isPositive ? '+' : ''} {formatCurrency(tx.amount)}
{canBookInterest && ( handleBookInterestIncome(tx.id)} disabled={actionLoading === tx.id} >
Bokför som ränteintäkt 1930 mot 8310, ingen moms
)} handleIgnore(tx)} disabled={actionLoading === tx.id} >
Ignorera transaktion… Dölj utan att bokföra. Går att återställa.
{/* Match action row */}
{glLines.length === 0 && ( Inga omatchade verifikationer på )}
setSelectedMatch((prev) => ({ ...prev, [tx.id]: v })) } disabled={linkLoading === tx.id || glLines.length === 0} />
) })}
)} {/* Unmatched GL Lines */} {glLines.length > 0 && ( Omatchade verifikationer på ({glLines.length}) {glLines.map((line) => { const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount return ( ) })}
Ver.nr Datum Beskrivning Belopp Typ
{formatVoucher(line)} {formatDate(line.entry_date)} {line.line_description || line.entry_description} {formatCurrency(amount)} {line.source_type}
)} {/* Ignored transactions (undo) */} {ignoredTx.length > 0 && ( setShowIgnored(!showIgnored)} >
{showIgnored ? ( ) : ( )} Ignorerade transaktioner ({ignoredTx.length})
{showIgnored && (

Rader du valt att dölja från avstämningen. De påverkar inte saldot på — de är bara gömda från listan.

{ignoredTx.map((tx) => ( ))}
Datum Beskrivning Valuta Belopp
{tx.date} {tx.description} {tx.currency} {formatCurrency(tx.amount)}
)}
)} {/* Recently Matched */} {matchedTx.length > 0 && ( setShowMatched(!showMatched)} >
{showMatched ? ( ) : ( )} Matchade transaktioner ({matchedTx.length})
{showMatched && ( {matchedTx.map((tx) => ( ))}
Datum Beskrivning Belopp Metod
{tx.date} {tx.description} {formatCurrency(tx.amount)} {tx.reconciliation_method && ( {METHOD_LABELS[tx.reconciliation_method] || tx.reconciliation_method} )} {tx.reconciliation_method && ( )}
)}
)} {/* Empty state */} {unmatchedTx.length === 0 && glLines.length === 0 && matchedTx.length === 0 && ignoredTx.length === 0 && !loading && ( Inga transaktioner eller verifikationer att stämma av. )}
) }