'use client' import Link from 'next/link' import { useState, useEffect, useCallback, useRef } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Switch } from '@/components/ui/switch' import { Skeleton } from '@/components/ui/skeleton' import { AccountNumber } from '@/components/ui/account-number' import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal } from 'lucide-react' import { formatCurrency, formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { CashAccountSelector } from '@/components/common/CashAccountSelector' import { MatchVerifikationPicker, type UnlinkedGLLine } from '@/components/reconciliation/MatchVerifikationPicker' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, 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', } // journal_entries.source_type values that can appear on a bank-account GL line, // mapped to Swedish. Falls back to the raw value for anything unmapped so a new // enum value degrades to today's behaviour instead of an empty cell. const SOURCE_TYPE_LABELS: Record = { manual: 'Manuell', import: 'Import', bank_transaction: 'Banktransaktion', invoice_paid: 'Kundfaktura betald', invoice_cash_payment: 'Kontantfaktura', supplier_invoice_paid: 'Leverantörsfaktura betald', supplier_invoice_cash_payment: 'Leverantörsfaktura (kontant)', salary_payment: 'Löneutbetalning', system: 'System', inbox_item: 'Inkorgsunderlag', currency_revaluation: 'Valutaomvärdering', year_end: 'Bokslut', reminder_fee: 'Påminnelseavgift', } // Same thresholds as MatchVerifikationPicker's confidenceBadge: the dry-run // preview must read identically to the per-row picker. function confidenceLabel(confidence: number): { label: string variant: 'success' | 'secondary' | 'outline' } { if (confidence >= 0.85) return { label: 'Stark', variant: 'success' } if (confidence >= 0.6) return { label: 'Trolig', variant: 'secondary' } return { label: 'Svag', variant: 'outline' } } /** Pre-tick strong matches; fuzzy (0.75) stays unticked for explicit opt-in. */ const PRESELECT_CONFIDENCE = 0.85 const matchKey = (transactionId: string, journalEntryId: string) => `${transactionId}:${journalEntryId}` // One-click bookings for transactions with no upstream invoice/voucher to match // against: the common "stuck on the unmatched list" cause (small ränteintäkter, // bankavgifter, valutakursdifferenser). These reuse the existing bank_finance // booking templates; the categorize endpoint rewrites the bank leg to the // transaction's actual settlement account, so they book correctly on ANY cash // account (1930, a savings account, a EUR account…), not just 1930. // `account` is the non-bank leg (revenue/cost): the bank leg is the selected // account. Income templates apply to positive amounts, expense to negative. const QUICK_BOOK_TEMPLATES: { id: string label: string account: string direction: 'income' | 'expense' }[] = [ { id: 'bank_interest_income', label: 'ränteintäkt', account: '8310', direction: 'income' }, { id: 'bank_currency_gain', label: 'valutakursvinst', account: '3960', direction: 'income' }, { id: 'bank_fees', label: 'bankavgift', account: '6570', direction: 'expense' }, { id: 'bank_interest_expense', label: 'räntekostnad', account: '8410', direction: 'expense' }, { id: 'bank_currency_loss', label: 'valutakursförlust', account: '7960', direction: 'expense' }, ] // ============================================================ // 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 gl_1930_correction_adjustment: number difference: number is_reconciled: boolean matched_count: number unmatched_transaction_count: number unmatched_gl_line_count: number } 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 } // ============================================================ // Component // ============================================================ interface BankReconciliationViewProps { /** * The fiscal period to reconcile, from the page-level räkenskapsår selector in * the report header (FocusedReport). The view no longer owns a selector of its * own: that duplicate, hidden behind the loading skeleton, deadlocked the page * (#771). */ periodId: string /** period_start / period_end of that period; seeds the date window (#751). */ periodBounds: { start: string; end: string } | null } export function BankReconciliationView({ periodId, periodBounds }: BankReconciliationViewProps) { 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) // The window is scoped to a fiscal period (issue #751/#771): a bank // reconciliation is inherently per-period, and a "full history" window spans // the fiscal-year boundary: mixing a prior period's movements with the current // year's IB and manufacturing a phantom difference equal to the IB. The period // is owned by the page-level FiscalYearSelector in the report header and passed // in as props, so the view always mounts with a known window. It used to host // its OWN selector inside the action bar and gate the first fetch on a // `periodReady` flag, but that selector lived below the loading-skeleton // early-return, so it never mounted, the flag never flipped, and the page hung // on a permanent skeleton (#771). dateFrom/dateTo are seeded from periodBounds // here and stay editable as a manual override (applied via "Filtrera"). const [dateFrom, setDateFrom] = useState(periodBounds?.start ?? '') const [dateTo, setDateTo] = useState(() => { const today = new Date().toISOString().slice(0, 10) if (periodBounds && periodBounds.end < today) return periodBounds.end return today }) const [accountNumber, setAccountNumber] = useState('1930') const [cashAccounts, setCashAccounts] = useState([]) // Date filters apply on demand (the "Filtrera" button or an account switch), // never on every keystroke. Editing a date used to re-create fetchAll and // re-trigger its effect: the "switching months reloads automatically" // annoyance. fetchAll reads the live dates from refs so an explicit run always // uses the latest typed values without putting them in its dependency array. const dateFromRef = useRef(dateFrom) const dateToRef = useRef(dateTo) useEffect(() => { dateFromRef.current = dateFrom }, [dateFrom]) useEffect(() => { dateToRef.current = dateTo }, [dateTo]) const [dryRunResults, setDryRunResults] = useState(null) // Which preview rows apply on "Tillämpa". Strong matches (≥0.85) are // pre-ticked; fuzzy ones require an explicit opt-in tick. const [selectedPairs, setSelectedPairs] = useState>(new Set()) // The date window the on-screen lists were last fetched with. Preview/apply // read THIS window (not the live inputs) so they can never run against a // different window than the lists the user is looking at; a mismatch between // typed and applied dates renders a "klicka Filtrera" hint instead. const [appliedDates, setAppliedDates] = useState<{ from: string; to: string } | null>(null) const [runLoading, setRunLoading] = useState(false) const [applyLoading, setApplyLoading] = useState(false) const [linkLoading, setLinkLoading] = useState(null) const [unlinkLoading, setUnlinkLoading] = useState(null) // Per-verifikat loading for the "Märk som ingående balans" re-tag action. const [markLoading, setMarkLoading] = useState(null) const [actionLoading, setActionLoading] = useState(null) // Opt-in: also surface vouchers already matched to a bank transaction as // candidates, so a second/third transaction can be attached to the same // verifikat (N:1, e.g. a salary run paid out in several transfers). Only // affects the per-row picker candidates; the "Omatchade verifikationer" table // below stays unmatched-only (it lists vouchers that still need a transaction). const [includeMatched, setIncludeMatched] = useState(false) 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>({}) // True when the unmatched list hit the API's 500-row cap: surfaced so a long // date range doesn't silently hide rows and let the user think they're done. const [unmatchedTruncated, setUnmatchedTruncated] = useState(false) // Per-transaction ranked match candidates, lazily fetched when the row's // picker is first focused. Passing transaction_id to /unmatched-entries makes // the server rank candidates and attach confidence: the same intelligence // MatchVoucherDialog gets on the Transactions page. Keyed by transaction id; // cleared whenever the lists refetch (the candidate set may have changed). const [rankedCandidates, setRankedCandidates] = useState>({}) const rankedFetchInFlight = useRef>(new Set()) // Bumped whenever fetchAll clears the ranked cache: an in-flight ranked // response from before the clear must not repopulate the fresh cache, or // that row's picker would show pre-refetch candidates until the next reload. const rankedGenerationRef = useRef(0) // Aborts the previous in-flight load when the account/date filters change, so // a slow stale response can't overwrite the freshly-selected account's data // (the intermittent "flips between accounts" bug). const fetchAbortRef = useRef(null) 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' // glLines feeds the per-row picker (which may include already-matched vouchers // when includeMatched is on). The "Omatchade verifikationer" table below must // stay unmatched-only: a voucher with a linked transaction isn't something // that still needs one. const unmatchedGlLines = glLines.filter((l) => !(l.linked_transaction_count ?? 0)) // The typed dates differ from what the lists (and preview/apply) run against. // Surfaced as a hint so a user can't edit a date, skip Filtrera, and believe // the preview covered the window they typed. const datesDirty = appliedDates !== null && (appliedDates.from !== dateFrom || appliedDates.to !== dateTo) 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 (opts?: { silent?: boolean }) => { // Cancel any in-flight load: it may be for a different account. Without // this, switching accounts quickly lets an older response land last and // overwrite the current account's data. fetchAbortRef.current?.abort() const controller = new AbortController() fetchAbortRef.current = controller const { signal } = controller // Only wholesale reloads (mount, account/period switch, Filtrera) show the // skeleton. Row mutations refresh silently and update in place: the old // behaviour unmounted the entire page on EVERY link/unlink/quick-book, // losing scroll position and flashing 30 skeletons for 30 matches. if (!opts?.silent) { setLoading(true) // A wholesale reload means the window/account/candidate set may have // changed: a preview computed for the previous window must not leave an // enabled "Tillämpa" button behind. Silent row-mutation refetches keep // the preview (same window; the intersection guard on apply covers rows // that got linked meanwhile). setDryRunResults(null) setSelectedPairs(new Set()) } setError(null) // The candidate pool changes with the data: drop stale per-row rankings // and invalidate any ranked fetch already in flight. setRankedCandidates({}) rankedFetchInFlight.current.clear() rankedGenerationRef.current++ try { const fromValue = dateFromRef.current const toValue = dateToRef.current const params = new URLSearchParams() if (fromValue) params.set('date_from', fromValue) if (toValue) params.set('date_to', toValue) params.set('account_number', accountNumber) const qs = `?${params}` // The candidate-lines fetch optionally includes already-matched vouchers // (for N:1); the status endpoint must NOT (its movement/diff is computed // independently) so it keeps the plain qs. const glParams = new URLSearchParams(params) if (includeMatched) glParams.set('include_matched', 'true') const glQs = `?${glParams}` const txParams = new URLSearchParams() txParams.set('currency', accountCurrency) txParams.set('account_number', accountNumber) if (fromValue) txParams.set('date_from', fromValue) if (toValue) txParams.set('date_to', toValue) const unmatchedQs = `?unmatched=true&${txParams}` const reconciledQs = `?reconciled=true&${txParams}` const [statusRes, glRes, unmatchedRes, matchedRes] = await Promise.all([ fetch(`/api/reconciliation/bank/status${qs}`, { signal }), fetch(`/api/reconciliation/bank/unmatched-entries${glQs}`, { signal }), fetch(`/api/transactions${unmatchedQs}`, { signal }), fetch(`/api/transactions${reconciledQs}`, { signal }), ]) const [statusData, glData, unmatchedData, matchedData] = await Promise.all([ statusRes.json(), glRes.json(), unmatchedRes.json(), matchedRes.json(), ]) // A newer load superseded this one while we awaited: discard these // stale results rather than clobber the current account's data. if (signal.aborted) return if (statusData.data) setStatus(statusData.data) setGlLines(glData.data || []) setUnmatchedTx(unmatchedData.data || []) setMatchedTx(matchedData.data || []) setUnmatchedTruncated(Boolean(unmatchedData.has_more)) // Record the window these lists represent: preview/apply run against it. setAppliedDates({ from: fromValue, to: toValue }) // 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`, { signal }) const ignoredData = await ignoredRes.json() if (!signal.aborted) setIgnoredTx(ignoredData.data || []) } catch { if (!signal.aborted) setIgnoredTx([]) } } catch (e) { // Aborts are expected when the user switches account/date quickly. if (signal.aborted || (e instanceof DOMException && e.name === 'AbortError')) return console.error('[reconciliation] fetchAll failed', e) setError('Kunde inte hämta avstämningsdata') } finally { // Only the latest load owns the spinner; a superseded load must not flip // it off while the fresh one is still running. if (!signal.aborted) setLoading(false) } // Deliberately excludes dateFrom/dateTo: editing a date must NOT auto-fetch // (it read from refs above). Re-runs on account / currency change, on the // matched-toggle flip (which changes the candidate set), and on mount; the // "Filtrera" button calls fetchAll() explicitly for date changes. }, [accountNumber, accountCurrency, includeMatched]) // Re-seed the date window whenever the selected räkenskapsår changes (driven // by the page-level FiscalYearSelector in the report header). dateTo is clamped // to today for the current (open) year so we don't claim to reconcile into the // future; a past year ends at its period_end. // // We write dateFromRef/dateToRef SYNCHRONOUSLY here, not just the state: fetchAll // reads the window from the refs, and the [dateFrom]/[dateTo] sync effects above // only refresh them on the NEXT commit: too late for the fetch effect below, // which runs on this same period-switch commit. Without the synchronous ref // write the first load after a year switch would use the PREVIOUS period's // window (off-by-one). This effect MUST stay declared ABOVE the fetch effect so // React runs it first. // // Keyed on periodId ONLY: switching the bank account must re-fetch (via the // fetch effect, whose fetchAll identity changes) but must NOT re-seed the dates // and discard a manual "Datum från/till" edit. useEffect(() => { if (!periodBounds) return const today = new Date().toISOString().slice(0, 10) const from = periodBounds.start const to = periodBounds.end < today ? periodBounds.end : today setDateFrom(from) setDateTo(to) dateFromRef.current = from dateToRef.current = to // eslint-disable-next-line react-hooks/exhaustive-deps }, [periodId]) // Load on mount, when the bank account / currency / matched-toggle change // (fetchAll identity), and when the räkenskapsår switches (periodId). fetchAll // reads the window from the refs, which the effect above has already refreshed // for a period switch. Manual date edits intentionally do NOT auto-fetch: that // stays on the explicit "Filtrera" button (which calls fetchAll() directly). useEffect(() => { fetchAll() }, [fetchAll, periodId]) // Reset transient per-account UI state when the selected account changes. A // verifikation pick or a dry-run preview computed for the previous account is // meaningless against the new one, and applying it would cross-link. useEffect(() => { setSelectedMatch({}) setDryRunResults(null) setSelectedPairs(new Set()) }, [accountNumber]) const handleDryRun = async () => { setRunLoading(true) setDryRunResults(null) setSelectedPairs(new Set()) try { const res = await fetch('/api/reconciliation/bank/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ // The APPLIED window: never the live inputs, which may not match the // lists on screen until the user clicks Filtrera. date_from: appliedDates?.from || undefined, date_to: appliedDates?.to || undefined, account_number: accountNumber, dry_run: true, }), }) const result = await res.json() if (!res.ok || result.error) { // An error envelope parses as JSON, so the catch below never fires for // it: without this check the button just stopped spinning and NOTHING // rendered, leaving the user staring at an unchanged page. setError( typeof result.error === 'string' ? result.error : 'Kunde inte köra förhandsgranskning', ) return } if (result.data?.matches) { const matches = result.data.matches as DryRunMatch[] setDryRunResults(matches) setSelectedPairs( new Set( matches .filter((m) => m.confidence >= PRESELECT_CONFIDENCE) .map((m) => matchKey(m.transaction_id, m.journal_entry_id)), ), ) } } catch { setError('Kunde inte köra förhandsgranskning') } finally { setRunLoading(false) } } const toggleMatchSelection = (key: string) => { setSelectedPairs((prev) => { const next = new Set(prev) if (next.has(key)) next.delete(key) else next.add(key) return next }) } // Matches RunReconciliationSchema's selected_matches .max(500). A first // reconciliation after a year of imports can preview (and pre-tick) far more // than 500 matches: applied in sequential chunks so the flow never dead-ends // on the payload cap. Chunking is safe: the server intersects each chunk with // a fresh match run, so pairs applied by an earlier chunk simply drop out of // later ones and unselected pairs are never applied. const APPLY_CHUNK_SIZE = 500 const handleApply = async () => { if (!dryRunResults || selectedPairs.size === 0) return const selected = dryRunResults.filter((m) => selectedPairs.has(matchKey(m.transaction_id, m.journal_entry_id)), ) const requested = selected.length setApplyLoading(true) let applied = 0 let failed = false let failMessage: string | undefined try { for (let i = 0; i < selected.length; i += APPLY_CHUNK_SIZE) { const chunk = selected.slice(i, i + APPLY_CHUNK_SIZE) const res = await fetch('/api/reconciliation/bank/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date_from: appliedDates?.from || undefined, date_to: appliedDates?.to || undefined, account_number: accountNumber, dry_run: false, selected_matches: chunk.map((m) => ({ transaction_id: m.transaction_id, journal_entry_id: m.journal_entry_id, })), }), }) const result = await res.json() if (!res.ok || result.error) { failed = true failMessage = typeof result.error === 'string' ? result.error : undefined break } applied += result.data?.applied ?? 0 } } catch { failed = true } // Report what actually happened: the old flow cleared the preview and // said nothing, even when the API had failed outright. if (failed) { toast({ variant: 'destructive', title: applied > 0 ? `${applied} av ${requested} matchningar tillämpade; resten misslyckades` : 'Kunde inte tillämpa matchningarna', description: failMessage, }) } else if (applied === requested) { toast({ variant: 'success', title: `${applied} ${applied === 1 ? 'matchning tillämpad' : 'matchningar tillämpade'}`, }) } else { toast({ variant: 'destructive', title: `${applied} av ${requested} matchningar tillämpade`, description: 'Resten kunde inte tillämpas: underlaget kan ha ändrats sedan förhandsgranskningen. Kör en ny förhandsgranskning.', }) } // Refetch whenever anything may have been written; on a clean failure with // zero applied the preview survives so the user can retry. try { if (!failed || applied > 0) { setDryRunResults(null) setSelectedPairs(new Set()) await fetchAll({ silent: true }) } } finally { setApplyLoading(false) } } /** * Lazily fetch ranked, confidence-scored candidates for one transaction the * first time its picker is focused. The endpoint ranks and attaches * confidence when transaction_id is passed: without it every row shows the * same unranked list and no Stark/Trolig/Svag badges (the intelligence the * Transactions page's MatchVoucherDialog has had all along). */ const ensureRankedCandidates = useCallback( async (transactionId: string) => { if (rankedCandidates[transactionId] || rankedFetchInFlight.current.has(transactionId)) { return } rankedFetchInFlight.current.add(transactionId) const generation = rankedGenerationRef.current try { const params = new URLSearchParams() // The APPLIED window: the same one the lists and preview run against. // Reading the live refs here would let a typed-but-not-filtered date // silently give this row a different candidate set than the tables on // screen. Refs are only the pre-first-load fallback. const from = appliedDates?.from ?? dateFromRef.current const to = appliedDates?.to ?? dateToRef.current if (from) params.set('date_from', from) if (to) params.set('date_to', to) params.set('account_number', accountNumber) params.set('transaction_id', transactionId) if (includeMatched) params.set('include_matched', 'true') const res = await fetch(`/api/reconciliation/bank/unmatched-entries?${params}`) const json = await res.json() // Discard if fetchAll cleared the cache while we were in flight: // committing would pin pre-refetch candidates on this row. if (res.ok && Array.isArray(json.data) && rankedGenerationRef.current === generation) { setRankedCandidates((prev) => ({ ...prev, [transactionId]: json.data })) } } catch { // Non-critical: the picker falls back to the unranked shared list. } finally { rankedFetchInFlight.current.delete(transactionId) } }, [accountNumber, includeMatched, rankedCandidates, appliedDates], ) 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, account_number: accountNumber, }), }) const result = await res.json() if (!res.ok || result.error) { // Row-level failures surface next to where the user is working: the // old top-of-page banner was off-screen when acting on row 40. toast({ variant: 'destructive', title: 'Kunde inte matcha transaktionen', description: typeof result.error === 'string' ? result.error : undefined, }) } else { setSelectedMatch((prev) => { const next = { ...prev } delete next[transactionId] return next }) toast({ variant: 'success', title: 'Transaktionen matchades mot verifikationen' }) await fetchAll({ silent: true }) } } catch { toast({ variant: 'destructive', title: 'Kunde inte matcha transaktionen' }) } 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 (!res.ok || result.error) { toast({ variant: 'destructive', title: 'Kunde inte avmatcha transaktionen', description: typeof result.error === 'string' ? result.error : undefined, }) } else { toast({ variant: 'success', title: 'Matchningen togs bort' }) await fetchAll({ silent: true }) } } catch { toast({ variant: 'destructive', title: 'Kunde inte avmatcha transaktionen' }) } finally { setUnlinkLoading(null) } } /** * Re-tag a manual/import voucher that is really an ingående balans as * source_type='opening_balance'. Such a voucher (common after a migration * where the IB was booked as an ordinary verifikat) otherwise stays in the * period movement and shows up as a phantom difference equal to the IB. After * re-tagging it drops out of the diff and is surfaced as "IB: räknas inte". */ const handleMarkOpeningBalance = async (journalEntryId: string) => { setMarkLoading(journalEntryId) try { const res = await fetch('/api/reconciliation/bank/mark-opening-balance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ journal_entry_id: journalEntryId }), }) const result = await res.json() if (!res.ok || result.error) { toast({ variant: 'destructive', title: 'Kunde inte markera verifikationen som ingående balans', description: typeof result.error === 'string' ? result.error : undefined, }) } else { toast({ variant: 'success', title: 'Verifikationen markerades som ingående balans' }) await fetchAll({ silent: true }) } } catch { toast({ variant: 'destructive', title: 'Kunde inte markera verifikationen som ingående balans', }) } finally { setMarkLoading(null) } } /** * Inline one-click booking for an unmatched transaction with no upstream * voucher to match against (ränteintäkter, bankavgifter, valutakurs- * differenser). Calls the standard categorize endpoint with a bank_finance * template so the resulting verifikation is identical to the /transactions * flow: no parallel booking path. The categorize endpoint rewrites the bank * leg to the transaction's actual settlement account, so this is correct on * any cash account. */ const handleQuickBook = async (transactionId: string, templateId: 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: templateId, confirm_no_match: true, }), }) const result = await res.json() if (!res.ok || result.error) { toast({ variant: 'destructive', title: 'Kunde inte bokföra transaktionen', description: result.error?.message || (typeof result.error === 'string' ? result.error : undefined), }) return } if (result.journal_entry_error) { toast({ variant: 'destructive', title: 'Kunde inte bokföra transaktionen', description: result.journal_entry_error, }) return } toast({ variant: 'success', title: 'Transaktionen bokfördes' }) await fetchAll({ silent: true }) } catch { toast({ variant: 'destructive', title: 'Kunde inte bokföra transaktionen' }) } 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, tx.currency)} (${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) { toast({ variant: 'destructive', title: 'Kunde inte ignorera transaktionen', description: typeof result.error === 'string' ? result.error : undefined, }) return } await fetchAll({ silent: true }) toast({ title: 'Transaktionen ignorerad', description: `${tx.description}: ${formatCurrency(tx.amount, tx.currency)}`, action: ( handleUnignore(tx.id)} > Ångra ), }) } catch { toast({ variant: 'destructive', title: '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) { toast({ variant: 'destructive', title: 'Kunde inte återställa transaktionen', description: typeof result.error === 'string' ? result.error : undefined, }) return } await fetchAll({ silent: true }) } catch { toast({ variant: 'destructive', title: 'Kunde inte återställa transaktionen' }) } finally { setActionLoading(null) } } if (loading) { return ( ) } 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, accountCurrency)}
{/* GL-side figures (bokfört, IB, rättelser, differens) stay in SEK: journal entries are booked in SEK regardless of the cash account's currency. Only the bank-feed total above is in the account's own currency. */}
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.

)} {status.gl_1930_correction_adjustment !== 0 && (

Varav rättelser och stornon på i perioden:{' '} {formatCurrency(status.gl_1930_correction_adjustment)} , ingår i det bokförda beloppet och i avstämningen, precis som i balansräkningen.

)}
Matchade: {status.matched_count} Omatchade transaktioner: {status.unmatched_transaction_count} Omatchade verifikationer: {status.unmatched_gl_line_count}
)} {/* Action Bar */}
setDateFrom(e.target.value)} className="mt-1" />
setDateTo(e.target.value)} className="mt-1" />
{dryRunResults && dryRunResults.length > 0 && ( )}
{datesDirty && (

Datumfiltret är ändrat men inte tillämpat: klicka Filtrera för att uppdatera listorna innan du förhandsgranskar.

)} {/* Dry Run Preview */} {dryRunResults && dryRunResults.length > 0 && ( Förhandsgranskning: {dryRunResults.length}{' '} {dryRunResults.length === 1 ? 'matchning hittad' : 'matchningar hittade'}

Starka träffar är förvalda. Ungefärliga träffar kräver att du bockar i dem själv: granska verifikationen först.

{dryRunResults.map((m) => { const key = matchKey(m.transaction_id, m.journal_entry_id) const badge = confidenceLabel(m.confidence) return ( ) })}
Transaktion Datum Belopp Verifikation Datum Metod Träff
toggleMatchSelection(key)} aria-label={`Tillämpa matchning för ${m.transaction_description}`} /> {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} {badge.label}
)} {dryRunResults && dryRunResults.length === 0 && ( Inga automatiska matchningar hittades. )} {/* Unmatched Transactions */} {unmatchedTx.length > 0 && (

Omatchade transaktioner ({unmatchedTx.length})

{unmatchedGlLines.length > 0 && (

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

)}
{unmatchedTruncated && (

Visar de senaste 500 transaktionerna: begränsa datumintervallet för att se fler.

)}
{unmatchedTx.map((tx) => { const isPositive = tx.amount > 0 // Quick-book options matching the transaction's direction. The // bank leg books to the SELECTED account (the categorize endpoint // rewrites it from the cash_account_id), so these are correct on // any account, not just 1930. const quickBooks = QUICK_BOOK_TEMPLATES.filter((t) => isPositive ? t.direction === 'income' : t.direction === 'expense', ) return (
{/* Header row: meta + description + amount + menu */}
{formatDate(tx.date)} · {tx.currency} {tx.reference && ( <> · Ref: {tx.reference} )}
{tx.description}
{isPositive ? '+' : ''} {formatCurrency(tx.amount, tx.currency)}
{quickBooks.length > 0 && ( <> Bokför direkt {quickBooks.map((t) => { // Read as "debit mot credit": income debits the // bank (selected account), credits revenue; // expense debits the cost account, credits bank. const legs = isPositive ? `${accountNumber} mot ${t.account}` : `${t.account} mot ${accountNumber}` return ( handleQuickBook(tx.id, t.id)} disabled={actionLoading === tx.id} >
Bokför som {t.label} {legs}
) })} )} 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å )}
ensureRankedCandidates(tx.id)} > setSelectedMatch((prev) => ({ ...prev, [tx.id]: v })) } disabled={linkLoading === tx.id || glLines.length === 0} />
) })}
)} {/* Unmatched GL Lines */} {unmatchedGlLines.length > 0 && ( Omatchade verifikationer på ({unmatchedGlLines.length})

Är en manuellt eller importerat bokförd verifikation egentligen en ingående balans? Markera den som IB: då räknas den inte med i avstämningen utan visas separat som ingående balans.

{unmatchedGlLines.map((line) => { const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount const isRetaggable = line.source_type === 'manual' || line.source_type === 'import' return ( ) })}
Ver.nr Datum Beskrivning Belopp Typ
{formatVoucher(line)} {formatDate(line.entry_date)} {line.line_description || line.entry_description} {formatCurrency(amount)} {SOURCE_TYPE_LABELS[line.source_type] ?? line.source_type} {isRetaggable && ( )}
)} {/* 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
{formatDate(tx.date)} {tx.description} {tx.currency} {formatCurrency(tx.amount, tx.currency)}
)}
)} {/* Recently Matched */} {matchedTx.length > 0 && ( setShowMatched(!showMatched)} >
{showMatched ? ( ) : ( )} Matchade transaktioner ({matchedTx.length})
{showMatched && (
{matchedTx.map((tx) => ( ))}
Datum Beskrivning Belopp Metod Verifikation
{formatDate(tx.date)} {tx.description} {formatCurrency(tx.amount, accountCurrency)} {tx.reconciliation_method && ( {METHOD_LABELS[tx.reconciliation_method] || tx.reconciliation_method} )} {tx.journal_entry_id && ( Öppna verifikat )} {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. )}
) }