feat(reconciliation): promote bulk matching and bridge it from the inbox (#1571)
* feat(reconciliation): accept confidence_threshold on the bank run route Mirror the v1 route: RunReconciliationSchema gains an optional confidence_threshold (0..1) that passes through to runReconciliation as the server-side floor on the apply path. The UI sends 0.85 with a strong-only apply so a pair the fresh re-run scores lower is skipped instead of committed; omitting it keeps the legacy behavior where every selected pair applies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): promote the bulk match flow and bridge it from the inbox The dry-run preview with pre-ticked strong matches existed but was never found: users matched whole migrations row by row. Three discoverability changes, no engine changes: - Bankavstamning: an attention line above the toolbar while unmatched transactions exist and no preview has run, with Forhandsgranska promoted to the filled variant. When every ticked preview pair is a strong match (>= 0.85) the apply button relabels to 'Matcha X starka traffar' and the apply sends confidence_threshold 0.85; mixed selections keep the plain label and omit the floor so manually ticked weaker pairs still apply. - Autorun bridge: ?autorun=1 on /reports/bank-reconciliation runs the preview once, only after appliedDates is set and not while datesDirty, so it can never cover a different window than the on-screen lists. - Transactions inbox: with >= 5 unbooked bank rows visible, an attention line links to the reconciliation with autorun (static text + count, no probe; the preview is the honest source of how many actually match). The review step stays: autorun lands on the preview table, one click from apply, and the server intersection guard is untouched. 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
1eebb75269
commit
1b829883ae
@@ -2,6 +2,7 @@
|
||||
|
||||
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'
|
||||
@@ -173,9 +174,17 @@ interface BankReconciliationViewProps {
|
||||
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 }: BankReconciliationViewProps) {
|
||||
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[]>([])
|
||||
@@ -287,6 +296,24 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
|
||||
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')
|
||||
@@ -501,6 +528,23 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -543,6 +587,11 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
|
||||
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()
|
||||
@@ -1006,6 +1055,11 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
|
||||
|
||||
{/* 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}
|
||||
@@ -1033,7 +1087,11 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
|
||||
Filtrera
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button onClick={handleDryRun} disabled={runLoading || datesDirty} variant="outline">
|
||||
<Button
|
||||
onClick={handleDryRun}
|
||||
disabled={runLoading || datesDirty}
|
||||
variant={previewPromoted ? 'default' : 'outline'}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
{runLoading ? 'Analyserar...' : 'Förhandsgranska'}
|
||||
</Button>
|
||||
@@ -1042,7 +1100,9 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
{applyLoading
|
||||
? 'Tillämpar...'
|
||||
: `Tillämpa ${selectedPairs.size} ${selectedPairs.size === 1 ? 'matchning' : 'matchningar'}`}
|
||||
: allSelectedStrong
|
||||
? t('recon_apply_strong', { count: selectedPairs.size })
|
||||
: `Tillämpa ${selectedPairs.size} ${selectedPairs.size === 1 ? 'matchning' : 'matchningar'}`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -68,10 +68,13 @@ function FocusedReportInner({
|
||||
slug,
|
||||
initialPeriods,
|
||||
initialCompanyId,
|
||||
autoRun,
|
||||
}: {
|
||||
slug: string
|
||||
initialPeriods: FiscalPeriod[]
|
||||
initialCompanyId: string | null
|
||||
/** ?autorun=1: the bank-reconciliation view runs its preview once on load. */
|
||||
autoRun?: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
@@ -186,6 +189,7 @@ function FocusedReportInner({
|
||||
isEnskildFirma={isEnskildFirma}
|
||||
isAktiebolag={isAktiebolag}
|
||||
onNavigateToAccount={navigateToAccount}
|
||||
autoRun={autoRun}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
@@ -210,6 +214,7 @@ function FocusedView({
|
||||
isEnskildFirma,
|
||||
isAktiebolag,
|
||||
onNavigateToAccount,
|
||||
autoRun,
|
||||
}: {
|
||||
slug: string
|
||||
reportName: string
|
||||
@@ -221,6 +226,7 @@ function FocusedView({
|
||||
isEnskildFirma: boolean
|
||||
isAktiebolag: boolean
|
||||
onNavigateToAccount: (account: string) => void
|
||||
autoRun?: boolean
|
||||
}) {
|
||||
switch (slug) {
|
||||
case 'resultatrapport':
|
||||
@@ -252,7 +258,7 @@ function FocusedView({
|
||||
case 'supplier-ledger':
|
||||
return <SupplierLedgerView periodId={periodId} />
|
||||
case 'bank-reconciliation':
|
||||
return <BankReconciliationView periodId={periodId} periodBounds={periodBounds} />
|
||||
return <BankReconciliationView periodId={periodId} periodBounds={periodBounds} autoRun={autoRun} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -262,10 +268,13 @@ export function FocusedReport({
|
||||
slug,
|
||||
initialPeriods,
|
||||
initialCompanyId,
|
||||
autoRun,
|
||||
}: {
|
||||
slug: string
|
||||
initialPeriods: FiscalPeriod[]
|
||||
initialCompanyId: string | null
|
||||
/** ?autorun=1: the bank-reconciliation view runs its preview once on load. */
|
||||
autoRun?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Suspense fallback={<div className="space-y-8" />}>
|
||||
@@ -273,6 +282,7 @@ export function FocusedReport({
|
||||
slug={slug}
|
||||
initialPeriods={initialPeriods}
|
||||
initialCompanyId={initialCompanyId}
|
||||
autoRun={autoRun}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user