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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
0ed26c2eca
commit
1b54bcd4bd
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user