Files
accounted/components/reports/BankReconciliationView.tsx
T
Jakob Wennberg 0d3ba5268d fix(transactions): close the booking duplicate guard's blind spots (#1573)
* fix(transactions): close booking duplicate guard blind spots G1-G3

The booking-time duplicate guard missed the most common bank-fee twin
shapes:

- G1: the sibling scan matched on the EXACT date only, so a duplicate
  import with a drifted date (CSV bokforingsdag vs PSD2 valutadag) was
  invisible. The scan now uses a +-3 day window with a deterministic
  ranking where exact-date candidates always outrank drifted ones
  (force=true re-detection stays bound to the reviewed candidate).
- G2: booked-ness required transactions.journal_entry_id, so bulk-booked
  (transaction_voucher_links) and multi-allocated (invoice_payments /
  supplier_invoice_payments) siblings read as unbooked. The scan now
  batch-fetches the anchor rows and resolves the verifikat via
  getPrimaryJournalEntryId (is_transaction_booked semantics).
- G3: the ledger scan excluded every voucher linked to any transaction,
  so a voucher booked from a date-drifted duplicate row escaped BOTH
  halves and the booking proceeded with no warning. A voucher whose
  linking transaction itself matches the target (same ore in the same
  currency, compatible cash account, date in the window) is now returned
  as the twin with transaction_id set.

All candidate picks keep explicit total-order tiebreakers so a force
re-detect returns the same candidate the user reviewed, and the
SEK-or-null amount contract is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): offer match/ignore for sibling duplicates and route all 409s into the dialog

The duplicate dialog hid its match action for sibling-transaction
candidates (canMatch required transaction_id === null), so the user who
most needed steering saw only 'Bokfor anda'. manualLink explicitly
allows N:1 links, so the match action is now offered for both candidate
kinds. Sibling candidates get question-form body copy ('vill du matcha
mot verifikatet i stallet?') and an additional 'Ignorera transaktionen'
action via the existing POST /api/transactions/[id]/ignore, which is the
correct resolution when the row itself is a duplicate import (matching
would double-count the bank side, booking the ledger side).

Two clients dead-ended the TRANSACTION_BOOK_POSSIBLE_DUPLICATE 409 in a
destructive toast with no way forward:

- the counterparty-template branch of handleQuickReviewConfirm now sets
  the shared duplicateWarning state exactly like runCategorize, with the
  force retry bound to the reviewed candidate's voucher
- BankReconciliationView's quick-book now opens the same dialog, with
  match/ignore refreshing the reconciliation lists

New sv/en strings: dialog_duplicate_body_sibling,
dialog_duplicate_ignore, dialog_duplicate_ignore_failed. File-level
parity tests pin the 409 routing and the dialog affordances.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): duplicate guard on the bulk-book samlingsverifikation path

/api/transactions/bulk-book never called detectBookingDuplicate, so a
batch containing an already-booked twin minted a second verifikat with
no warning. The route now runs the shared per-tx guard before the RPC,
with intra-batch exclusions (the other selected txs are distinct events
the user picked, and the link-existing target voucher is the batch's own
destination), returning 409 TRANSACTION_BOOK_POSSIBLE_DUPLICATE with the
candidate and the flagged tx id.

BulkBookDialog routes the 409 into DuplicateBookingDialog for review
(view voucher / cancel / book anyway) instead of a dead-end toast;
'Bokfor anda' re-runs the batch with force=true. On force the route
re-detects and records each dismissed candidate as
BankTransactionDuplicateDismissed in behandlingshistorik (BFNAR 2013:2
kap 8), parity with the /categorize bypass. Detection failures stay
fail-open. Note: the MCP RPC twin (gnubok_bulk_book_transactions)
bypasses this route and remains unguarded; guarding inside the RPC needs
a migration and is out of scope here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): gate the duplicate-dialog ignore hint on the action being present

The sibling body copy mentioned ignoring the row, but two render sites
(the manual booking form and the bulk dialog) show sibling candidates
without the ignore action. The guidance now lives in a separate
dialog_duplicate_ignore_hint string rendered only when the Ignorera
button itself renders, so copy never points at a button that is not
there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:21:55 +02:00

1682 lines
73 KiB
TypeScript

'use client'
import Link from 'next/link'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslations } from 'next-intl'
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 { EmptyState } from '@/components/ui/empty-state'
import { AttnLine } from '@/components/ui/attn-line'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { AccountNumber } from '@/components/ui/account-number'
import { AlertCircle, ArrowRightLeft, ChevronDown, ChevronRight, Landmark, 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 DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog'
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
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'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const METHOD_LABELS: Record<string, string> = {
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<string, string> = {
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
/**
* Deep-link bridge (?autorun=1, e.g. from the transactions inbox banner):
* runs the dry-run preview automatically ONCE, only after the first load has
* recorded appliedDates and while the typed dates still match it, so the
* preview can never cover a different window than the on-screen lists.
*/
autoRun?: boolean
}
export function BankReconciliationView({ periodId, periodBounds, autoRun }: BankReconciliationViewProps) {
const t = useTranslations('reports')
const [status, setStatus] = useState<ReconciliationStatus | null>(null)
const [unmatchedTx, setUnmatchedTx] = useState<UnmatchedTransaction[]>([])
const [glLines, setGlLines] = useState<UnlinkedGLLine[]>([])
const [matchedTx, setMatchedTx] = useState<MatchedTransaction[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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<CashAccount[]>([])
// 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<DryRunMatch[] | null>(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<Set<string>>(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<string | null>(null)
const [unlinkLoading, setUnlinkLoading] = useState<string | null>(null)
// Per-verifikat loading for the "Märk som ingående balans" re-tag action.
const [markLoading, setMarkLoading] = useState<string | null>(null)
const [actionLoading, setActionLoading] = useState<string | null>(null)
// Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE) fired
// for a quick-book: opened as the shared match/ignore/book-anyway dialog
// instead of a dead-end toast; this page's whole purpose is matching.
const [duplicateWarning, setDuplicateWarning] = useState<{
transactionId: string
retry: () => Promise<void>
candidate: BookedDuplicateCandidate
} | null>(null)
const [duplicateProcessing, setDuplicateProcessing] = useState(false)
// 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<UnmatchedTransaction[]>([])
const [selectedMatch, setSelectedMatch] = useState<Record<string, string>>({})
// 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<Record<string, UnlinkedGLLine[]>>({})
const rankedFetchInFlight = useRef<Set<string>>(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<AbortController | null>(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)
// Promote the preview flow while there is unmatched work and no preview has
// run yet: an attention line above the toolbar plus the Förhandsgranska
// button in the default (filled) variant. Suppressed while datesDirty: that
// state owns the page's single attention line and disables the button anyway.
const previewPromoted = unmatchedTx.length > 0 && dryRunResults === null && !datesDirty
// Every ticked preview pair is a strong match (>= the Stark badge floor):
// the apply button relabels to "Matcha X starka träffar" and the apply
// request carries confidence_threshold so the server re-run enforces the
// same floor. Manually ticked weaker pairs drop back to the plain label and
// an unthresholded apply.
const allSelectedStrong =
dryRunResults !== null &&
selectedPairs.size > 0 &&
dryRunResults
.filter((m) => selectedPairs.has(matchKey(m.transaction_id, m.journal_entry_id)))
.every((m) => m.confidence >= PRESELECT_CONFIDENCE)
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)
}
}
// One-shot autorun bridge (?autorun=1): trigger the same dry-run the
// Förhandsgranska button runs, exactly once, and only once the first load
// has recorded appliedDates with the typed dates still matching it
// (datesDirty false). Firing earlier could preview a different window than
// the on-screen lists. Consumed even when there is nothing to preview, so a
// later data refresh never surprises the user with an unprompted run.
const autoRunConsumedRef = useRef(false)
useEffect(() => {
if (!autoRun || autoRunConsumedRef.current) return
if (loading || !appliedDates || datesDirty) return
autoRunConsumedRef.current = true
if (unmatchedTx.length > 0) void handleDryRun()
// handleDryRun is recreated every render; the consumed-ref guarantees the
// single run, so depending on it would only add noise.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoRun, loading, appliedDates, datesDirty, unmatchedTx.length])
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,
})),
// Strong-only apply: when every ticked pair is >= the Stark floor,
// ask the server to enforce that floor on its fresh re-run too. A
// mixed selection omits it so manually ticked weaker pairs still
// apply (the intersection guard still protects them).
...(allSelectedStrong ? { confidence_threshold: PRESELECT_CONFIDENCE } : {}),
}),
})
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,
// Set after the user confirmed the duplicate warning: force is bound to
// the reviewed candidate's voucher and re-detected server-side.
forceOpts?: { expectedDuplicateJournalEntryId: 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,
...(forceOpts
? { force: true, expected_duplicate_journal_entry_id: forceOpts.expectedDuplicateJournalEntryId }
: {}),
}),
})
const result = await res.json()
if (!res.ok || result.error) {
const candidate = result?.error?.details?.candidate as BookedDuplicateCandidate | undefined
if (result?.error?.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE' && candidate) {
// The affärshändelse already looks booked. On the reconciliation
// page the right resolutions (match the voucher, ignore a duplicate
// import, or book anyway) all live in the shared dialog: never
// dead-end in a toast with no way forward.
setDuplicateWarning({
transactionId,
retry: () =>
handleQuickBook(transactionId, templateId, {
expectedDuplicateJournalEntryId: candidate.journal_entry_id,
}),
candidate,
})
return
}
toast({
variant: 'destructive',
title: 'Kunde inte bokföra transaktionen',
description: getUserErrorMessage(result.error) || (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)
}
}
/**
* Move a transaction to another of the company's cash accounts (PATCH
* /api/transactions/[id]/cash-account). The row then leaves THIS account's
* unmatched list and surfaces on the target account's reconciliation, which
* is the fix for rows stuck under the wrong (or the primary) account:
* cross-account matching is deliberately blocked, so the row must move to
* where its verifikat lives. Server-side gating rejects booked/matched rows.
*/
const handleMoveToAccount = async (tx: UnmatchedTransaction, target: CashAccount) => {
setActionLoading(tx.id)
try {
const res = await fetch(`/api/transactions/${tx.id}/cash-account`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_number: target.ledger_account }),
})
const result = await res.json()
if (!res.ok || result.error) {
toast({
variant: 'destructive',
title: 'Kunde inte flytta transaktionen',
description:
getUserErrorMessage(result.error) ||
(typeof result.error === 'string' ? result.error : undefined),
})
return
}
toast({
variant: 'success',
title: `Transaktionen flyttades till ${target.name || `Bankkonto ${target.currency}`} (${target.ledger_account})`,
})
// Both accounts' totals change (the row leaves this report and joins the
// target's), so refresh the whole view, status card included.
await fetchAll({ silent: true })
} catch {
toast({ variant: 'destructive', title: 'Kunde inte flytta 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: (
<ToastAction
altText="Ångra ignorera"
onClick={() => handleUnignore(tx.id)}
>
Ångra
</ToastAction>
),
})
} 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 (
<Card>
<CardContent className="p-6 space-y-3">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
)
}
if (error && !status) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
return (
<div className="space-y-8">
{error && (
<Card>
<CardContent className="py-3 text-center text-destructive text-sm">
<AlertCircle className="h-4 w-4 inline mr-1" />
{error}
<Button variant="ghost" size="sm" className="ml-2" onClick={() => setError(null)}>
Stäng
</Button>
</CardContent>
</Card>
)}
{/* Status Card */}
{status && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Avstämning mot <AccountNumber number={accountNumber} /></CardTitle>
{status.is_reconciled ? (
<span className="text-sm text-muted-foreground">Avstämd</span>
) : (
<Badge variant="destructive">Ej avstämd</Badge>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Banktransaktioner i perioden</span>
<span className="tabular-nums">
{formatCurrency(status.bank_transaction_total, accountCurrency)}
</span>
</div>
{/* 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. */}
<div className="flex justify-between">
<span>Bokfört <AccountNumber number={accountNumber} /> i perioden</span>
<span className="tabular-nums">
{formatCurrency(status.gl_1930_period_movement)}
</span>
</div>
<div className="flex justify-between pt-2 border-t font-semibold">
<span>Differens</span>
<span
className={`tabular-nums ${
status.is_reconciled ? 'text-success' : 'text-destructive'
}`}
>
{formatCurrency(status.difference)}
</span>
</div>
{status.gl_1930_opening_balance !== 0 && (
<p className="pt-2 text-xs text-muted-foreground">
Ingående balans (IB) <AccountNumber number={accountNumber} />:{' '}
<span className="tabular-nums">
{formatCurrency(status.gl_1930_opening_balance)}
</span>
, räknas inte i avstämningen.
</p>
)}
{status.gl_1930_correction_adjustment !== 0 && (
<p className="pt-2 text-xs text-muted-foreground">
Varav rättelser och stornon <AccountNumber number={accountNumber} /> i perioden:{' '}
<span className="tabular-nums">
{formatCurrency(status.gl_1930_correction_adjustment)}
</span>
, ingår i det bokförda beloppet och i avstämningen, precis som i balansräkningen.
</p>
)}
<div className="flex gap-4 pt-2 text-xs text-muted-foreground">
<span>Matchade: {status.matched_count}</span>
<span>Omatchade transaktioner: {status.unmatched_transaction_count}</span>
<span>Omatchade verifikationer: {status.unmatched_gl_line_count}</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Toolbar: flat on the panel, no box (UI-migration language) */}
<div className="space-y-3">
{/* Promote the bulk flow before the first preview: many users never
found Förhandsgranska and matched a whole migration row by row. */}
{previewPromoted && !runLoading && (
<AttnLine>{t('recon_unmatched_attn', { count: unmatchedTx.length })}</AttnLine>
)}
<div className="flex flex-wrap items-end gap-4">
<CashAccountSelector
value={accountNumber}
onChange={setAccountNumber}
/>
<div>
<Label>Datum från</Label>
<Input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Datum till</Label>
<Input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="mt-1"
/>
</div>
<Button onClick={() => fetchAll()} variant={datesDirty ? 'default' : 'outline'}>
Filtrera
</Button>
<div className="flex-1" />
<Button
onClick={handleDryRun}
disabled={runLoading || datesDirty}
variant={previewPromoted ? 'default' : 'outline'}
>
<Eye className="h-4 w-4 mr-2" />
{runLoading ? 'Analyserar...' : 'Förhandsgranska'}
</Button>
{dryRunResults && dryRunResults.length > 0 && (
<Button onClick={handleApply} disabled={applyLoading || selectedPairs.size === 0}>
<Play className="h-4 w-4 mr-2" />
{applyLoading
? 'Tillämpar...'
: allSelectedStrong
? t('recon_apply_strong', { count: selectedPairs.size })
: `Tillämpa ${selectedPairs.size} ${selectedPairs.size === 1 ? 'matchning' : 'matchningar'}`}
</Button>
)}
</div>
{datesDirty && (
<AttnLine>
Datumfiltret är ändrat men inte tillämpat: klicka Filtrera för att uppdatera
listorna innan du förhandsgranskar.
</AttnLine>
)}
</div>
{/* Dry Run Preview */}
{dryRunResults && dryRunResults.length > 0 && (
<section className="space-y-3">
<h2 className="text-sm uppercase tracking-wider text-muted-foreground">
Förhandsgranskning ({dryRunResults.length})
</h2>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={`${TH_CLASS} w-8`}></th>
<th className={TH_CLASS}>Transaktion</th>
<th className={`${TH_CLASS} w-24`}>Datum</th>
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
<th className={`${TH_CLASS} w-8 text-center`}>&harr;</th>
<th className={TH_CLASS}>Verifikation</th>
<th className={`${TH_CLASS} w-24`}>Datum</th>
<th className={`${TH_CLASS} w-28`}>Metod</th>
<th className={`${TH_CLASS} w-20`}>Träff</th>
</tr>
</thead>
<tbody className="stagger-enter">
{dryRunResults.map((m) => {
const key = matchKey(m.transaction_id, m.journal_entry_id)
const badge = confidenceLabel(m.confidence)
return (
<tr key={key} className="transition-colors duration-150 hover:bg-secondary/35">
<td className={TD_CLASS}>
<Checkbox
checked={selectedPairs.has(key)}
onCheckedChange={() => toggleMatchSelection(key)}
aria-label={`Tillämpa matchning för ${m.transaction_description}`}
/>
</td>
<td className={`${TD_CLASS} truncate max-w-[180px]`}>{m.transaction_description}</td>
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(m.transaction_date)}</td>
<td className={`${TD_CLASS} text-right tabular-nums`}>{formatAmount(m.transaction_amount)}</td>
<td className={`${TD_CLASS} text-center text-muted-foreground`}>&harr;</td>
<td className={TD_CLASS}>
<Link
href={`/bookkeeping/${m.journal_entry_id}`}
className="font-mono text-xs underline-offset-2 hover:underline"
target="_blank"
>
{formatVoucher(m)}
</Link>
<span className="ml-2 text-muted-foreground truncate">{m.entry_description}</span>
</td>
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(m.entry_date)}</td>
<td className={`${TD_CLASS} text-xs text-muted-foreground`}>
{METHOD_LABELS[m.method] || m.method}
</td>
<td className={TD_CLASS}>
<Badge variant={badge.variant}>{badge.label}</Badge>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
)}
{dryRunResults && dryRunResults.length === 0 && (
<p className="text-sm text-muted-foreground">
Inga automatiska matchningar hittades.
</p>
)}
{/* Unmatched Transactions */}
{unmatchedTx.length > 0 && (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm uppercase tracking-wider text-muted-foreground">
Omatchade transaktioner ({unmatchedTx.length})
</h2>
<div className="flex items-center gap-3">
{unmatchedGlLines.length > 0 && (
<p className="text-xs text-muted-foreground">
{unmatchedGlLines.length} verifikation{unmatchedGlLines.length === 1 ? '' : 'er'} att matcha mot
</p>
)}
<label className="flex cursor-pointer items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
<Switch
checked={includeMatched}
onCheckedChange={setIncludeMatched}
aria-label="Visa även matchade verifikationer"
/>
Visa matchade
</label>
</div>
</div>
{unmatchedTruncated && (
<p className="text-xs text-muted-foreground">
Visar de senaste 500 transaktionerna: begränsa datumintervallet för att se fler.
</p>
)}
<div className="space-y-3">
{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',
)
// Other enabled cash accounts this row could move to. Same
// currency only: the server hard-rejects a cross-currency move
// (the row would vanish from every report's currency scope).
const moveTargets = cashAccounts.filter(
(a) =>
a.enabled &&
a.ledger_account !== accountNumber &&
a.currency.toUpperCase() === tx.currency.toUpperCase(),
)
return (
<div
key={tx.id}
className="rounded-lg border border-border bg-card p-4 space-y-4"
>
{/* Header row: meta + description + amount + menu */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground tabular-nums">
<span>{formatDate(tx.date)}</span>
<span aria-hidden>·</span>
<span>{tx.currency}</span>
{tx.reference && (
<>
<span aria-hidden>·</span>
<span>Ref: {tx.reference}</span>
</>
)}
</div>
<div className="mt-1.5 text-sm font-medium truncate">{tx.description}</div>
</div>
<div className="flex items-start gap-2 shrink-0">
<div
className={`font-display text-xl tabular-nums ${
isPositive ? 'text-success' : ''
}`}
>
{isPositive ? '+' : ''}
{formatCurrency(tx.amount, tx.currency)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
aria-label="Fler åtgärder"
disabled={actionLoading === tx.id}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
{quickBooks.length > 0 && (
<>
<DropdownMenuLabel className="text-[11px] font-normal uppercase tracking-wider text-muted-foreground">
Bokför direkt
</DropdownMenuLabel>
{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 (
<DropdownMenuItem
key={t.id}
onClick={() => handleQuickBook(tx.id, t.id)}
disabled={actionLoading === tx.id}
>
<PiggyBank className="h-4 w-4" />
<div className="flex flex-col">
<span>Bokför som {t.label}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{legs}
</span>
</div>
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator />
</>
)}
{moveTargets.length > 0 && (
<>
<DropdownMenuLabel className="text-[11px] font-normal uppercase tracking-wider text-muted-foreground">
Flytta till annat konto
</DropdownMenuLabel>
{moveTargets.map((account) => (
<DropdownMenuItem
key={account.id}
onClick={() => handleMoveToAccount(tx, account)}
disabled={actionLoading === tx.id}
>
<ArrowRightLeft className="h-4 w-4" />
<div className="flex flex-col">
<span>
Flytta till {account.name || `Bankkonto ${account.currency}`}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{account.ledger_account}
</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => handleIgnore(tx)}
disabled={actionLoading === tx.id}
>
<EyeOff className="h-4 w-4" />
<div className="flex flex-col">
<span>Ignorera transaktion</span>
<span className="text-xs text-muted-foreground">
Dölj utan att bokföra. Går att återställa.
</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Match action row */}
<div className="pt-3 border-t border-border space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-[11px] uppercase tracking-wider text-muted-foreground">
Matcha mot verifikation
</Label>
{glLines.length === 0 && (
<span className="text-[11px] text-muted-foreground">
Inga omatchade verifikationer <AccountNumber number={accountNumber} />
</span>
)}
</div>
<div className="flex items-start gap-2">
<div
className="flex-1 min-w-0"
// First focus fetches ranked, confidence-scored candidates
// for THIS transaction (server-side ranking via
// transaction_id). Until they land the picker shows the
// shared unranked pool, so nothing blocks.
onFocusCapture={() => ensureRankedCandidates(tx.id)}
>
<MatchVerifikationPicker
glLines={rankedCandidates[tx.id] ?? glLines}
value={selectedMatch[tx.id] || ''}
onChange={(v) =>
setSelectedMatch((prev) => ({ ...prev, [tx.id]: v }))
}
disabled={linkLoading === tx.id || glLines.length === 0}
/>
</div>
<Button
size="sm"
variant="outline"
disabled={!selectedMatch[tx.id] || linkLoading === tx.id}
onClick={() => handleManualLink(tx.id)}
className="shrink-0 h-10"
>
<Link2 className="h-3.5 w-3.5 mr-1.5" />
{linkLoading === tx.id ? 'Matchar…' : 'Matcha'}
</Button>
</div>
</div>
</div>
)
})}
</div>
</section>
)}
{/* Unmatched GL Lines */}
{unmatchedGlLines.length > 0 && (
<section className="space-y-3">
<h2 className="text-sm uppercase tracking-wider text-muted-foreground">
Omatchade verifikationer <AccountNumber number={accountNumber} /> ({unmatchedGlLines.length})
</h2>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={`${TH_CLASS} w-16`}>Ver.nr</th>
<th className={`${TH_CLASS} w-24`}>Datum</th>
<th className={TH_CLASS}>Beskrivning</th>
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
<th className={`${TH_CLASS} w-24`}>Typ</th>
<th className={`${TH_CLASS} w-36`}></th>
</tr>
</thead>
<tbody className="stagger-enter">
{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 (
<tr key={line.line_id} className="group transition-colors duration-150 hover:bg-secondary/35">
<td className={TD_CLASS}>
<Link
href={`/bookkeeping/${line.journal_entry_id}`}
className="font-mono text-xs underline-offset-2 hover:underline"
target="_blank"
>
{formatVoucher(line)}
</Link>
</td>
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(line.entry_date)}</td>
<td className={`${TD_CLASS} truncate max-w-[300px]`}>
{line.line_description || line.entry_description}
</td>
<td className={`${TD_CLASS} text-right tabular-nums`}>
{formatCurrency(amount)}
</td>
<td className={`${TD_CLASS} text-xs text-muted-foreground`}>
{SOURCE_TYPE_LABELS[line.source_type] ?? line.source_type}
</td>
<td className={`${TD_CLASS} text-right`}>
{isRetaggable && (
<Button
size="sm"
variant="ghost"
className="h-8 text-xs"
disabled={markLoading === line.journal_entry_id}
onClick={() => handleMarkOpeningBalance(line.journal_entry_id)}
title="Markera verifikationen som ingående balans: den utesluts då från avstämningen"
>
{markLoading === line.journal_entry_id ? 'Markerar…' : 'Märk som IB'}
</Button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
)}
{/* Ignored transactions (undo) */}
{ignoredTx.length > 0 && (
<section className="space-y-3">
<button
type="button"
aria-expanded={showIgnored}
onClick={() => setShowIgnored(!showIgnored)}
className="flex items-center gap-2 text-sm font-medium uppercase tracking-wider text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
{showIgnored ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
Ignorerade transaktioner ({ignoredTx.length})
</button>
{showIgnored && (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={`${TH_CLASS} w-24`}>Datum</th>
<th className={TH_CLASS}>Beskrivning</th>
<th className={`${TH_CLASS} w-20`}>Valuta</th>
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
<th className={`${TH_CLASS} w-28`}></th>
</tr>
</thead>
<tbody className="stagger-enter">
{ignoredTx.map((tx) => (
<tr key={tx.id} className="text-muted-foreground transition-colors duration-150 hover:bg-secondary/35">
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(tx.date)}</td>
<td className={`${TD_CLASS} truncate max-w-[300px]`}>{tx.description}</td>
<td className={`${TD_CLASS} text-xs tabular-nums`}>{tx.currency}</td>
<td className={`${TD_CLASS} text-right tabular-nums`}>
{formatCurrency(tx.amount, tx.currency)}
</td>
<td className={TD_CLASS}>
<Button
size="sm"
variant="ghost"
disabled={actionLoading === tx.id}
onClick={() => handleUnignore(tx.id)}
>
{actionLoading === tx.id ? '...' : 'Återställ'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{/* Recently Matched */}
{matchedTx.length > 0 && (
<section className="space-y-3">
<button
type="button"
aria-expanded={showMatched}
onClick={() => setShowMatched(!showMatched)}
className="flex items-center gap-2 text-sm font-medium uppercase tracking-wider text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
{showMatched ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
Matchade transaktioner ({matchedTx.length})
</button>
{showMatched && (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={`${TH_CLASS} w-24`}>Datum</th>
<th className={TH_CLASS}>Beskrivning</th>
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
<th className={`${TH_CLASS} w-32`}>Metod</th>
<th className={`${TH_CLASS} w-28`}>Verifikation</th>
<th className={`${TH_CLASS} w-24`}></th>
</tr>
</thead>
<tbody className="stagger-enter">
{matchedTx.map((tx) => (
<tr key={tx.id} className="transition-colors duration-150 hover:bg-secondary/35">
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(tx.date)}</td>
<td className={`${TD_CLASS} truncate max-w-[300px]`}>{tx.description}</td>
<td className={`${TD_CLASS} text-right tabular-nums`}>
{formatCurrency(tx.amount, accountCurrency)}
</td>
<td className={`${TD_CLASS} text-xs text-muted-foreground`}>
{tx.reconciliation_method
? METHOD_LABELS[tx.reconciliation_method] || tx.reconciliation_method
: null}
</td>
<td className={TD_CLASS}>
{tx.journal_entry_id && (
<Link
href={`/bookkeeping/${tx.journal_entry_id}`}
className="text-xs underline-offset-2 hover:underline"
target="_blank"
>
Öppna verifikat
</Link>
)}
</td>
<td className={TD_CLASS}>
{tx.reconciliation_method && (
<Button
size="sm"
variant="ghost"
disabled={unlinkLoading === tx.id}
onClick={() => handleUnlink(tx.id)}
>
<Unlink className="h-3 w-3 mr-1" />
{unlinkLoading === tx.id ? '...' : 'Avmatcha'}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{/* Empty state */}
{unmatchedTx.length === 0 && glLines.length === 0 && matchedTx.length === 0 && ignoredTx.length === 0 && !loading && (
<EmptyState
icon={Landmark}
title="Inget att stämma av"
description="Det finns inga banktransaktioner eller verifikationer i den valda perioden. Importera eller synka banktransaktioner så visas de här."
/>
)}
{duplicateWarning && (
<DuplicateBookingDialog
candidate={duplicateWarning.candidate}
processing={duplicateProcessing}
onCancel={() => setDuplicateWarning(null)}
matchTransaction={{
id: duplicateWarning.transactionId,
// The view is scoped to one ledger account; resolve its cash
// account so the match links on the account being reconciled.
cash_account_id: cashAccounts.find((a) => a.ledger_account === accountNumber)?.id ?? null,
currency:
unmatchedTx.find((t) => t.id === duplicateWarning.transactionId)?.currency ??
accountCurrency,
}}
onMatched={async () => {
setDuplicateWarning(null)
toast({ variant: 'success', title: 'Transaktionen matchades mot verifikatet' })
await fetchAll({ silent: true })
}}
onIgnored={async () => {
setDuplicateWarning(null)
toast({ variant: 'success', title: 'Transaktionen ignorerad' })
await fetchAll({ silent: true })
}}
onBookAnyway={async () => {
const retry = duplicateWarning?.retry
setDuplicateProcessing(true)
try {
setDuplicateWarning(null)
if (retry) await retry()
} finally {
setDuplicateProcessing(false)
}
}}
/>
)}
<DestructiveConfirmDialog {...confirmDialogProps} />
</div>
)
}