feat(reconciliation): inline automatic matcher + Oförklarat tooltip (#1843)

* feat(reconciliation): run the automatic matcher inline on the Avstämning page

'Matcha automatiskt' no longer sends the user to the bank report: it
runs the dry-run on the page and renders the candidate pairs in the
page's own paired-row grammar (voucher, date, confidence chip), applied
per row or all strong ones at once (floor 0.85, re-enforced server-side
via the run endpoint's intersection guard, same as the old view). The
Oförklarat tile gets a '?' tooltip saying what the number means: 0 =
everything explained even if rows remain to book; anything else = an
inconsistency, look before trusting. First bite of absorbing the old
bank view (manual N:M + residual booking and the redirect follow in 6b).

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

* fix(reconciliation): add the col_proposal string the matcher preview references

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>
This commit is contained in:
Jakob Wennberg
2026-08-24 15:18:56 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 0ed26c2eca
commit 1b54bcd4bd
5 changed files with 214 additions and 7 deletions
+66 -5
View File
@@ -21,6 +21,8 @@ import type {
} from '@/lib/reconciliation/schemas'
import type { SkattekontoBatchRowResult, SkattekontoTransactionWithSuggestion } from '@/types/skatteverket'
import { SignoffDialog } from './SignoffDialog'
import { MatcherPreview, type MatcherMatch } from './MatcherPreview'
import { InfoTooltip } from '@/components/ui/info-tooltip'
const SkattekontoBookDialog = dynamic(
() => import('@/components/skattekonto/SkattekontoBookDialog'),
@@ -84,6 +86,7 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
const [unfolded, setUnfolded] = useState<Set<ReconciliationItemBucket>>(new Set())
const [bookRow, setBookRow] = useState<ReconciliationItem | null>(null)
const [signoffOpen, setSignoffOpen] = useState(false)
const [matcher, setMatcher] = useState<MatcherMatch[] | null>(null)
const isSkv = account.kind === 'skattekonto'
const base = `/api/reconciliation/accounts/${encodeURIComponent(account.account_key)}`
@@ -269,6 +272,51 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
}
}
async function runMatcher() {
if (!status) return
setBusy('matcher')
try {
const data = await postJson('/api/reconciliation/bank/run', {
date_from: window.from,
date_to: window.to,
account_number: status.account_number,
dry_run: true,
})
if (data) setMatcher((data.matches ?? []) as MatcherMatch[])
} finally {
setBusy(null)
}
}
async function applyMatches(pairs: MatcherMatch[], strongOnly: boolean) {
if (!status || pairs.length === 0) return
setBusy('matcher')
try {
const data = await postJson('/api/reconciliation/bank/run', {
date_from: window.from,
date_to: window.to,
account_number: status.account_number,
dry_run: false,
selected_matches: pairs.map((m) => ({
transaction_id: m.transaction_id,
journal_entry_id: m.journal_entry_id,
})),
// Strong-only applies re-enforce the floor server-side, same as the
// old bank view; a single hand-picked weaker pair omits it.
...(strongOnly ? { confidence_threshold: 0.85 } : {}),
})
if (data) {
const applied = (data.applied as number) ?? 0
toast({ title: t('toast_matched', { applied }) })
const appliedKeys = new Set(pairs.map((m) => `${m.transaction_id}:${m.journal_entry_id}`))
setMatcher((prev) => (prev ? prev.filter((m) => !appliedKeys.has(`${m.transaction_id}:${m.journal_entry_id}`)) : prev))
await refresh()
}
} finally {
setBusy(null)
}
}
// ---- render -------------------------------------------------------------
if (loadError) {
@@ -310,7 +358,7 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
const fetchedAt = isSkv ? status.skattekonto?.fetched_at : account.source.synced_at
const sourceLabel = isSkv ? t('source_skv') : t('source_bank')
const tiles: Array<{ key: string; label: string; value: string; sub: string; tone?: 'ok' | 'attn' }> = [
const tiles: Array<{ key: string; label: string; value: string; sub: string; tone?: 'ok' | 'attn'; help?: string }> = [
{
key: 'external',
label: isSkv ? t('tile_external_skv') : t('tile_external_bank'),
@@ -336,6 +384,7 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
{
key: 'unexplained',
label: t('tile_unexplained'),
help: t('tile_unexplained_help'),
value: money(status.unexplained_difference),
sub: '',
tone: status.unexplained_difference == null ? undefined : status.is_reconciled ? 'ok' : 'attn',
@@ -371,7 +420,6 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
(byBucket.get('unmatched_external')?.length ?? 0) +
(byBucket.get('unmatched_ledger')?.length ?? 0)
const bankRunHref = '/reports/bank-reconciliation?autorun=1'
const bankViewHref = '/reports/bank-reconciliation'
// Default sign-off date: the window end, never past today nor past the
@@ -390,7 +438,10 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border stagger-enter">
{tiles.map((tile) => (
<div key={tile.key} className="bg-background px-4 py-3.5">
<div className="text-[11px] font-medium uppercase tracking-[0.07em] text-muted-foreground">{tile.label}</div>
<div className="flex items-center gap-1 text-[11px] font-medium uppercase tracking-[0.07em] text-muted-foreground">
{tile.label}
{tile.help && <InfoTooltip content={tile.help} iconClassName="h-3 w-3" />}
</div>
<div
className={cn(
'mt-1 text-[22px] font-semibold leading-tight tabular-nums',
@@ -466,8 +517,8 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
</Button>
)}
{!isSkv && (
<Button size="sm" variant="outline" asChild>
<Link href={bankRunHref}>{t('action_run_bank_matcher')}</Link>
<Button size="sm" variant="outline" onClick={() => void runMatcher()} disabled={busy !== null} aria-busy={busy === 'matcher'}>
{t('action_run_bank_matcher')}
</Button>
)}
{signoffEnabled && (
@@ -495,6 +546,16 @@ export function AccountOverview({ account, rail, window, onChanged }: AccountOve
</div>
</div>
{matcher !== null && !isSkv && (
<MatcherPreview
matches={matcher}
currency={currency}
busy={busy !== null}
onApply={(pairs, strongOnly) => void applyMatches(pairs, strongOnly)}
onClose={() => setMatcher(null)}
/>
)}
{/* The table: full width, banded by bucket, paired proposal rows. */}
{items.items.length === 0 ? (
<p className="text-[13px] text-muted-foreground">{t('all_clear')}</p>
@@ -0,0 +1,131 @@
'use client'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
/**
* The automatic matcher's dry-run preview, inline on the Avstämning page:
* candidate transaction↔verifikat pairs with confidence, applied per row or
* all strong ones at once. Same endpoint and same server-side intersection
* guard as the old bank view; this is only the surface. Pairs at or above
* STRONG_CONFIDENCE mirror the old view's preselect floor.
*/
export const STRONG_CONFIDENCE = 0.85
export interface MatcherMatch {
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
}
interface MatcherPreviewProps {
matches: MatcherMatch[]
currency: string
busy: boolean
onApply: (pairs: MatcherMatch[], strongOnly: boolean) => void
onClose: () => void
}
export function MatcherPreview({ matches, currency, busy, onApply, onClose }: MatcherPreviewProps) {
const t = useTranslations('reconciliation')
const strong = matches.filter((m) => m.confidence >= STRONG_CONFIDENCE)
if (matches.length === 0) {
return (
<p className="text-[13px] text-muted-foreground">
{t('matcher_none')}{' '}
<button type="button" onClick={onClose} className={QUIET_LINK_CLASS}>
{t('matcher_close')}
</button>
</p>
)
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<p className="text-[13px]">
{t('matcher_title', { count: matches.length })}
</p>
{strong.length > 0 && (
<Button size="sm" onClick={() => onApply(strong, true)} disabled={busy}>
{t('matcher_apply_strong', { count: strong.length })}
</Button>
)}
<button type="button" onClick={onClose} disabled={busy} className={cn(QUIET_LINK_CLASS, 'ml-auto')}>
{t('matcher_close')}
</button>
</div>
<div className="-mx-4 overflow-x-auto sm:mx-0">
<table className="w-full text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-[110px]')}>{t('col_date')}</th>
<th className={TH_CLASS}>{t('col_event')}</th>
<th className={cn(TH_CLASS, 'w-[140px] text-right')}>{t('col_amount')}</th>
<th className={cn(TH_CLASS, 'w-[34%]')}>{t('col_proposal')}</th>
<th className={cn(TH_CLASS, 'w-[120px]')} />
</tr>
</thead>
<tbody className="stagger-enter">
{matches.map((m) => (
<tr key={`${m.transaction_id}:${m.journal_entry_id}`} className="group">
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(m.transaction_date)}
</td>
<td className={cn(TD_CLASS, 'max-w-0')}>
<span className="block truncate" data-ph-mask title={m.transaction_description}>
{m.transaction_description}
</span>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')} data-ph-mask>
{formatCurrency(m.transaction_amount, currency)}
</td>
<td className={cn(TD_CLASS, 'max-w-0')}>
<span className="flex items-center gap-2">
<span className="tabular-nums" data-ph-mask>
{formatVoucher({ voucher_series: m.voucher_series, voucher_number: m.voucher_number })}
</span>
<span className="text-[11.5px] tabular-nums text-muted-foreground">{formatDate(m.entry_date)}</span>
<span
className={cn(
'whitespace-nowrap rounded-full px-1.5 py-px text-[10.5px]',
m.confidence >= STRONG_CONFIDENCE
? 'bg-success/10 text-success'
: 'bg-muted text-muted-foreground',
)}
>
{m.confidence >= STRONG_CONFIDENCE
? t('matcher_strong')
: t('confidence', { percent: Math.round(m.confidence * 100) })}
</span>
</span>
<span className="block truncate text-[12px] text-muted-foreground" data-ph-mask>
{m.entry_description}
</span>
</td>
<td className={cn(TD_CLASS, 'text-right')}>
<Button size="sm" variant="outline" onClick={() => onApply([m], false)} disabled={busy}>
{t('row_match')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}