feat(reconciliation): explain the difference instead of just printing it (#1737)

The bankavstämning card showed three movement sums and a red difference,
leaving the user to work out what the difference consisted of. The page
already knew, exactly: every krona of it is (unmatched bank rows) minus
(unmatched vouchers). Verified on prod for Arcim 1930 over 2025-07-17..
2026-08-20: 403 565,42 bank, 332 680,93 booked, 70 884,49 difference, of
which -277 799,92 sits in 74 unmatched transactions and -348 684,41 in 4
unmatched vouchers, leaving exactly 0,00 unexplained.

Engine: getReconciliationStatus gains unmatched_transaction_total,
unmatched_gl_line_total and unexplained_difference. The residual, not the
raw difference, is the figure that can mean something is wrong: a
difference is expected to be large mid-year and says nothing on its own.
unmatched_gl_line_total is null rather than 0 on a foreign account, whose
candidate lines carry no amount in that currency, and the card falls back
to the flat figures there.

Also fixes the candidate fetch's window: it used the caller's raw dateFrom
while both other sides were clamped to the opening-balance floor, so a
window opening before the account's IB (the v1 endpoint's default, or any
multi-year range) counted vouchers from a period the reconciliation
deliberately drops.

UI: the card becomes a bridge whose two middle rows both explain the
number and navigate to the list that resolves them, above a matched/total
progress rule. Three stacked paragraphs of legal prose collapse into one
line plus a tooltip, keeping the amounts on screen. The permanent
destructive "Ej avstämd" badge is gone: being mid-year and unreconciled is
the normal state, so it marked nothing (convention 5); Avstämd is now what
gets the chip.

The unmatched list becomes one line per transaction (convention 4). It
rendered a ~230px card per row, each with an always-open, always-empty
match field: for a real backlog that is thousands of pixels of empty
search boxes, and it gave the rarest action the only visible affordance
while bokför and ignorera hid behind the row menu. The picker, and its
ranked-candidate fetch, now run for the one row the user opens.

A non-zero residual is stated factually, never in destructive red:
measured over the 206 single-1930-account companies with >=10
transactions, 136 are exactly 0,00 and 63 are >=100 kr out, dominated by
ledger lines the candidate RPC hides (posted/storno on 127 companies)
rather than user error. Surfacing those is follow-up work.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 12:41:55 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 5
parent a92c492dbe
commit b5e908f9ea
9 changed files with 699 additions and 231 deletions
+439 -222
View File
@@ -1,7 +1,7 @@
'use client'
import Link from 'next/link'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Fragment, 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'
@@ -11,6 +11,8 @@ 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 { Progress } from '@/components/ui/progress'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { EmptyState } from '@/components/ui/empty-state'
import { AttnLine } from '@/components/ui/attn-line'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
@@ -43,6 +45,27 @@ function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
/** A bridge step, always carrying its sign so the column reads as a running
* adjustment. Only the plus is added: Intl already renders negatives with a
* real minus sign (U+2212), and prefixing an ASCII hyphen to an absolute value
* would put a different glyph in this column than every other amount on the
* page. */
function formatSigned(amount: number, currency?: string): string {
const rendered = formatCurrency(amount, currency)
return amount > 0 ? `+${rendered}` : rendered
}
/** Anchors for the bridge rows: clicking a step scrolls to the list it names.
* The dashboard panel is the scroll container, so scrollIntoView (which walks
* up to the nearest scrollable ancestor) is correct here and window.scrollTo
* would not be. */
const UNMATCHED_TX_SECTION_ID = 'recon-unmatched-transactions'
const UNMATCHED_GL_SECTION_ID = 'recon-unmatched-gl-lines'
function scrollToSection(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
const METHOD_LABELS: Record<string, string> = {
auto_exact: 'Exakt matchning',
auto_date_range: 'Datumintervall',
@@ -130,7 +153,15 @@ interface ReconciliationStatus {
is_reconciled: boolean
matched_count: number
unmatched_transaction_count: number
/** Sum behind unmatched_transaction_count: one leg of the bridge. */
unmatched_transaction_total: number
unmatched_gl_line_count: number
/** Sum behind unmatched_gl_line_count, signed like a bank movement. null on a
* foreign account whose candidate lines carry no amount in that currency. */
unmatched_gl_line_total: number | null
/** What is left of the difference once both lists are accounted for; null
* whenever unmatched_gl_line_total is. */
unexplained_difference: number | null
}
interface UnmatchedTransaction {
@@ -280,6 +311,10 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
// 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[]>>({})
/** The one unmatched row whose match picker is open. Single-open by design:
* the picker is a heavy control (it fetches ranked candidates per row), and
* rendering one per row is exactly what made this list unusable. */
const [expandedTxId, setExpandedTxId] = useState<string | null>(null)
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
@@ -701,6 +736,18 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
[accountNumber, includeMatched, rankedCandidates, appliedDates],
)
/** Open one row's match picker (closing any other) and fetch its ranked
* candidates. The fetch used to hang off onFocusCapture on an always-rendered
* picker; it now runs on expand, which is the same moment the user asks for
* candidates but only for rows they actually open. */
const toggleExpandedTx = useCallback(
(transactionId: string) => {
setExpandedTxId((current) => (current === transactionId ? null : transactionId))
void ensureRankedCandidates(transactionId)
},
[ensureRankedCandidates],
)
const handleManualLink = async (transactionId: string) => {
const journalEntryId = selectedMatch[transactionId]
if (!journalEntryId) return
@@ -731,6 +778,7 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
delete next[transactionId]
return next
})
setExpandedTxId((current) => (current === transactionId ? null : current))
toast({ variant: 'success', title: 'Transaktionen matchades mot verifikationen' })
await fetchAll({ silent: true })
}
@@ -1010,6 +1058,51 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
)
}
// Bridge derivations. `unexplained_difference` is null exactly when the GL
// side cannot be expressed in the account's currency (a foreign account: the
// candidate RPCs project no FX columns), and the card falls back to the flat
// figures rather than showing a bridge whose middle row has no honest amount.
const bridgeDerivable = status?.unexplained_difference != null
const reconcilableCount = status
? status.matched_count + status.unmatched_transaction_count
: 0
const matchedPercent =
reconcilableCount > 0 ? Math.round((status!.matched_count / reconcilableCount) * 100) : 0
// What the reconciliation leaves out, as one line instead of three stacked
// paragraphs. Amounts stay on screen (BFL: the user must be able to see what
// was excluded); the legal reasoning moved into the tooltip beside them.
const excludedItems: string[] = []
if (status) {
if (status.gl_1930_opening_balance !== 0) {
excludedItems.push(
t('recon_excl_ib', { amount: formatCurrency(status.gl_1930_opening_balance) })
)
}
if (status.ignored_transaction_count > 0) {
excludedItems.push(
t('recon_excl_ignored', {
count: status.ignored_transaction_count,
amount: formatCurrency(status.ignored_transaction_total, accountCurrency),
})
)
}
}
const exclusionNotes: string[] = []
if (excludedItems.length > 0) {
exclusionNotes.push(t('recon_excl_prefix', { items: excludedItems.join(', ') }))
}
// Corrections are the opposite case: INCLUDED, exactly as on the balance
// sheet. Stated here because a large correction figure is the single most
// common "why is the booked amount so big" question on this card.
if (status && status.gl_1930_correction_adjustment !== 0) {
exclusionNotes.push(
t('recon_excl_corrections', {
amount: formatCurrency(status.gl_1930_correction_adjustment),
})
)
}
return (
<div className="space-y-8">
{error && (
@@ -1030,72 +1123,152 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Avstämning mot <AccountNumber number={accountNumber} /></CardTitle>
{/* Convention 5: chips mark exceptions. Being mid-year and not yet
reconciled is the NORMAL state, so a permanent destructive
"Ej avstämd" badge marked nothing and just manufactured alarm.
Fully reconciled is the state worth marking. */}
{status.is_reconciled ? (
<span className="text-sm text-muted-foreground">Avstämd</span>
<Badge variant="success">Avstämd</Badge>
) : (
<Badge variant="destructive">Ej avstämd</Badge>
<span className="text-sm text-muted-foreground">
{t('recon_open_items', {
count:
status.unmatched_transaction_count + status.unmatched_gl_line_count,
})}
</span>
)}
</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 på <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.ignored_transaction_count > 0 && (
<p className="pt-2 text-xs text-muted-foreground">
{t('recon_ignored_note', {
count: status.ignored_transaction_count,
amount: formatCurrency(status.ignored_transaction_total, accountCurrency),
})}
</p>
<div className="space-y-5 text-sm">
{/* Reconciliation is a count-down-to-zero task; the only progress
signal used to be three comma-separated numbers in 12px grey. */}
{reconcilableCount > 0 && (
<div className="space-y-1.5">
<Progress value={matchedPercent} className="h-1" />
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{t('recon_progress', {
matched: status.matched_count,
total: reconcilableCount,
})}
</span>
<span className="tabular-nums">{matchedPercent} %</span>
</div>
</div>
)}
{status.gl_1930_opening_balance !== 0 && (
<p className="pt-2 text-xs text-muted-foreground">
Ingående balans (IB) på <AccountNumber number={accountNumber} />:{' '}
{/* THE BRIDGE. The old card printed the bank total, the ledger
total and a red difference, leaving the user to work out what
the difference consisted of: the page already knew, exactly.
Every krona of it is (unmatched bank rows) - (unmatched
vouchers), so the two middle rows both explain the number AND
navigate to the list that resolves them. Only the residual
after those two can mean something is actually wrong.
Falls back to the flat figures when the residual is not
derivable (a foreign account: see unmatched_gl_line_total). */}
<div className="space-y-2">
<div className="flex justify-between">
<span>Banktransaktioner i perioden</span>
<span className="tabular-nums">
{formatCurrency(status.gl_1930_opening_balance)}
{formatCurrency(status.bank_transaction_total, accountCurrency)}
</span>
</div>
{bridgeDerivable && (
<>
{status.unmatched_transaction_count > 0 && (
<button
type="button"
onClick={() => scrollToSection(UNMATCHED_TX_SECTION_ID)}
className="-mx-2 flex w-[calc(100%+1rem)] items-baseline justify-between gap-4 rounded-sm px-2 py-1 text-left text-muted-foreground transition-colors duration-150 hover:bg-secondary/60 hover:text-foreground"
>
<span>
{t('recon_bridge_unmatched_tx', {
count: status.unmatched_transaction_count,
})}
</span>
<span className="tabular-nums">
{formatSigned(-status.unmatched_transaction_total, accountCurrency)}
</span>
</button>
)}
{status.unmatched_gl_line_count > 0 && (
<button
type="button"
onClick={() => scrollToSection(UNMATCHED_GL_SECTION_ID)}
className="-mx-2 flex w-[calc(100%+1rem)] items-baseline justify-between gap-4 rounded-sm px-2 py-1 text-left text-muted-foreground transition-colors duration-150 hover:bg-secondary/60 hover:text-foreground"
>
<span>
{t('recon_bridge_unmatched_gl', {
count: status.unmatched_gl_line_count,
})}
</span>
<span className="tabular-nums">
{formatSigned(status.unmatched_gl_line_total ?? 0, accountCurrency)}
</span>
</button>
)}
</>
)}
{/* 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 border-t pt-2">
<span>
Bokfört på <AccountNumber number={accountNumber} /> i perioden
</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 på <AccountNumber number={accountNumber} /> i perioden:{' '}
<span className="tabular-nums">
{formatCurrency(status.gl_1930_correction_adjustment)}
{formatCurrency(status.gl_1930_period_movement)}
</span>
, ingår i det bokförda beloppet och i avstämningen, precis som i balansräkningen.
</div>
{bridgeDerivable ? (
<div className="flex items-baseline justify-between gap-4 pt-1 font-semibold">
<span className="flex items-center gap-1.5">
{t('recon_unexplained_label')}
<InfoTooltip content={t('recon_unexplained_help')} />
</span>
<span
className={`tabular-nums ${
Math.abs(status.unexplained_difference ?? 0) < 0.01 ? 'text-success' : ''
}`}
>
{formatCurrency(status.unexplained_difference ?? 0, accountCurrency)}
</span>
</div>
) : (
<div className="flex justify-between pt-1 font-semibold">
<span>Differens</span>
<span
className={`tabular-nums ${
status.is_reconciled ? 'text-success' : 'text-destructive'
}`}
>
{formatCurrency(status.difference)}
</span>
</div>
)}
{bridgeDerivable && Math.abs(status.unexplained_difference ?? 0) >= 0.01 && (
<p className="text-xs text-muted-foreground">
{t('recon_unexplained_note')}
</p>
)}
</div>
{/* What the reconciliation deliberately leaves out. Three stacked
paragraphs of legal prose used to sit in the card; the amounts
stay visible (they are compliance-relevant) but the reasoning
moved behind the tooltip. */}
{exclusionNotes.length > 0 && (
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
<span>{exclusionNotes.join(' ')}</span>
<InfoTooltip content={t('recon_exclusions_help')} className="mt-0.5 shrink-0" />
</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>
@@ -1234,7 +1407,7 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
{/* Unmatched Transactions */}
{unmatchedTx.length > 0 && (
<section className="space-y-3">
<section id={UNMATCHED_TX_SECTION_ID} className="scroll-mt-4 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})
@@ -1260,193 +1433,237 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
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' : ''
{/* One line per transaction (locked convention 4). This list used to
render a ~230px card per row, each carrying an always-open, always
empty "Matcha mot verifikation" search field: for a company with a
real backlog that is thousands of pixels of empty search boxes, and
it gave the RAREST action (pairing with an existing voucher) the
only visible affordance while the common ones (bokför, ignorera)
stayed hidden behind the row menu. The picker now renders for the
one row the user opens, and its ranked candidates are fetched then. */}
<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-32 text-right`}>Belopp</th>
<th className={`${TH_CLASS} w-32`}></th>
<th className={`${TH_CLASS} w-10`}></th>
</tr>
</thead>
<tbody className="stagger-enter">
{unmatchedTx.map((tx) => {
const isPositive = tx.amount > 0
const isOpen = expandedTxId === tx.id
// 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 (
<Fragment key={tx.id}>
<tr
className={`transition-colors duration-150 hover:bg-secondary/35 ${
isOpen ? 'bg-secondary/35' : ''
}`}
>
{isPositive ? '+' : ''}
{formatCurrency(tx.amount, tx.currency)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<td className={`${TD_CLASS} tabular-nums text-muted-foreground`}>
{formatDate(tx.date)}
</td>
<td className={TD_CLASS}>
<button
type="button"
onClick={() => toggleExpandedTx(tx.id)}
aria-expanded={isOpen}
className="flex max-w-full items-center gap-1.5 text-left transition-colors duration-150 hover:text-foreground"
>
{isOpen ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{tx.description}</span>
{/* Inline, not a sub-row: convention 4 keeps list
rows one line high. */}
{tx.reference && (
<span className="shrink-0 text-xs text-muted-foreground">
· {tx.reference}
</span>
)}
</button>
</td>
<td
className={`${TD_CLASS} text-right tabular-nums ${
isPositive ? 'text-success' : ''
}`}
>
{isPositive ? '+' : ''}
{formatCurrency(tx.amount, tx.currency)}
</td>
<td className={`${TD_CLASS} text-right`}>
{/* Opens the picker; the button INSIDE it performs the
match. Two controls labelled "Matcha" in one row
would read as the same action twice. */}
<Button
size="icon"
size="sm"
variant="ghost"
className="h-8 w-8"
aria-label="Fler åtgärder"
disabled={actionLoading === tx.id}
className="h-8 text-xs"
onClick={() => toggleExpandedTx(tx.id)}
>
<MoreHorizontal className="h-4 w-4" />
{isOpen ? 'Stäng' : 'Välj verifikat'}
</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 />
</>
)}
</td>
<td className={`${TD_CLASS} text-right`}>
<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
onClick={() => handleIgnore(tx)}
key={account.id}
onClick={() => handleMoveToAccount(tx, account)}
disabled={actionLoading === tx.id}
>
<EyeOff className="h-4 w-4" />
<ArrowRightLeft 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>
Flytta till {account.name || `Bankkonto ${account.currency}`}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{account.ledger_account}
</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 på <AccountNumber number={accountNumber} />
))}
<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>
<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>
)
})}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
{isOpen && (
<tr className="bg-secondary/20">
<td colSpan={5} className="px-3 pb-4 pt-1 align-top">
<div className="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 på{' '}
<AccountNumber number={accountNumber} />
</span>
)}
</div>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<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="h-10 shrink-0"
>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
{linkLoading === tx.id ? 'Matchar…' : 'Matcha'}
</Button>
</div>
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
</section>
)}
{/* Unmatched GL Lines */}
{unmatchedGlLines.length > 0 && (
<section className="space-y-3">
<section id={UNMATCHED_GL_SECTION_ID} className="scroll-mt-4 space-y-3">
<h2 className="text-sm uppercase tracking-wider text-muted-foreground">
Omatchade verifikationer på <AccountNumber number={accountNumber} /> ({unmatchedGlLines.length})
</h2>
@@ -1535,7 +1752,7 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
<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>
<th className={`${TH_CLASS} w-32`}></th>
</tr>
</thead>
<tbody className="stagger-enter">