Files
accounted/components/reconciliation/MatchVerifikationPicker.tsx
T
abff8d90f6 refactor(ui): validation as errors on attempt, help text behind the ? (#1561)
The founder flagged the app as bloated with standing instructional text.

Ny verifikation: the what's-missing lines (Ange en beskrivning, Minst
två rader...) rendered from the first frame because their only gate was
form validity, which an empty form fails: instructions dressed as
validation. The submit buttons now stay enabled and an attempt on an
incomplete form is what surfaces the lines, in destructive red, per the
error-on-submit idiom. The Enter-to-advance flow keeps the old
completeness predicate so navigation is untouched.

Matcha mot befintlig verifikation: the two-sentence explainer moved
behind a ? (HelpPopover, convention 7), the N:1 note tightened, the
Visa även matchade switch became a quiet link (switches are settings
idiom), and Stark träff, the normal auto-selected case, renders as
muted text instead of a chip (chips mark exceptions, convention 5).

Deleted always-visible paraphrase lines and their orphaned keys:
items_card_description, picker_description, references_subtitle,
sort_stack_hint, dimensions hints, document_help, and the dead
fill_balance_hint key that had no render site at all.

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

278 lines
11 KiB
TypeScript

'use client'
import { useState, useEffect, useMemo, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Search, X } from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
/**
* Map the endpoint's 0-1 match confidence (attached only when candidates are
* ranked for a specific transaction) to a strength label, so the user can tell
* an exact-amount hit from a fuzzy guess before vouching for an immutable
* verifikat. Returns null when no confidence was attached.
*
* Chips mark exceptions (convention 5): a strong hit is the normal,
* auto-selected case and renders as muted text; only the fuzzy guesses that
* deserve a second look get a chip.
*/
function confidenceMark(
confidence: number | undefined,
): { label: string; variant: 'secondary' | 'outline' | null } | null {
if (confidence == null) return null
if (confidence >= 0.85) return { label: 'Stark träff', variant: null }
if (confidence >= 0.6) return { label: 'Trolig träff', variant: 'secondary' }
return { label: 'Svag träff', variant: 'outline' }
}
/**
* A posted journal entry line on a cash account (e.g. 1930) not yet linked to
* any bank transaction: a candidate for manual reconciliation. Mirrors the
* `UnlinkedGLLine` returned by GET /api/reconciliation/bank/unmatched-entries.
*
* Defined here (not imported from lib/reconciliation/bank-reconciliation) so the
* client bundle never pulls in that module's server-only dependencies (event
* bus, match-log). The optional `confidence` is attached when the endpoint
* ranks candidates for a specific transaction.
*/
export interface UnlinkedGLLine {
line_id: string
journal_entry_id: string
debit_amount: number
credit_amount: number
line_description: string | null
entry_date: string
voucher_number: number
voucher_series: string
entry_description: string
source_type: string
confidence?: number
/** How many bank transactions already settle this entry on the account being
* matched (links on OTHER cash accounts, e.g. a transfer's outgoing leg,
* don't count). > 0 means the voucher is already matched on this account:
* surfaced (behind the "visa matchade" opt-in) so a second/third
* transaction can be attached to it (N:1). */
linked_transaction_count?: number
}
interface MatchPickerProps {
glLines: UnlinkedGLLine[]
value: string
onChange: (journalEntryId: string) => void
disabled?: boolean
placeholder?: string
/**
* Render the candidate list in normal document flow (always visible below the
* search box) instead of as an absolutely-positioned overlay. Use inside a
* Dialog or any `overflow-y-auto` container: an absolute dropdown is clipped at
* the container's edge (the "klipper i dialogerna" bug: the list got cut off
* and the dialog couldn't scroll to it). The reconciliation view keeps the
* compact overlay (one picker per transaction row); the modal uses inline.
*/
inline?: boolean
}
/**
* Inline combobox for choosing a journal entry to match a bank transaction
* against. The native <select> couldn't be searched, and the unmatched-GL list
* routinely runs to hundreds of rows (historical SIE imports), so the old UX
* forced users to scroll a giant unsorted dropdown. This picker filters by
* voucher number, date, amount or description as the user types, and renders
* the selected verifikation as a removable chip.
*
* Extracted from BankReconciliationView so the Transactions-page
* MatchVoucherDialog can reuse the exact same picker.
*/
export function MatchVerifikationPicker({
glLines,
value,
onChange,
disabled,
placeholder = 'Sök ver.nr, datum, belopp eller beskrivning…',
inline = false,
}: MatchPickerProps) {
// `open` controls the overlay dropdown only. In inline mode the list is always
// rendered, so the setOpen() writes in the handlers below are harmless no-ops
// there (the inline branch never reads `open`).
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// Inline mode shows the list permanently, so there's nothing to close on an
// outside click: the overlay-only dismissal handler would be dead weight.
if (inline || !open) return
function onDocMouseDown(e: MouseEvent) {
if (!containerRef.current?.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDocMouseDown)
return () => document.removeEventListener('mousedown', onDocMouseDown)
}, [open, inline])
const selected = glLines.find((l) => l.journal_entry_id === value) || null
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
const base = q.length === 0
? glLines
: glLines.filter((line) => {
const amt = (line.debit_amount > 0 ? line.debit_amount : line.credit_amount).toString()
return (
formatVoucher(line).toLowerCase().includes(q) ||
line.entry_date.toLowerCase().includes(q) ||
amt.includes(q) ||
(line.entry_description || '').toLowerCase().includes(q) ||
(line.line_description || '').toLowerCase().includes(q)
)
})
return base.slice(0, 25)
}, [search, glLines])
if (selected) {
const amount = selected.debit_amount > 0 ? selected.debit_amount : -selected.credit_amount
// Suppress the match-strength badge on an already-matched verifikat so a
// green "Stark träff" can't visually encourage an accidental double-match:
// "Redan matchad" is the signal that matters there (N:1 stays opt-in).
const strength =
(selected.linked_transaction_count ?? 0) > 0 ? null : confidenceMark(selected.confidence)
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-secondary/40 px-3 py-2 text-sm">
<span className="font-mono text-xs shrink-0">{formatVoucher(selected)}</span>
<span className="text-muted-foreground shrink-0 tabular-nums">{formatDate(selected.entry_date)}</span>
<span className="tabular-nums shrink-0">{formatCurrency(amount)}</span>
<span className="truncate text-muted-foreground flex-1 min-w-0">{selected.entry_description}</span>
{strength && (
strength.variant ? (
<Badge variant={strength.variant} className="shrink-0 text-[10px]">
{strength.label}
</Badge>
) : (
<span className="shrink-0 text-[11px] text-muted-foreground">{strength.label}</span>
)
)}
{(selected.linked_transaction_count ?? 0) > 0 && (
<Badge variant="secondary" className="shrink-0 text-[10px]">
Redan matchad
</Badge>
)}
<Button
type="button"
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0"
onClick={() => onChange('')}
disabled={disabled}
aria-label="Avmarkera verifikation"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)
}
// The candidate list: shared by the inline and overlay layouts below.
const listContent =
filtered.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
Inga verifikationer matchar &quot;{search}&quot;
</div>
) : (
<div className="max-h-72 overflow-y-auto">
{filtered.map((line) => {
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
const strength =
(line.linked_transaction_count ?? 0) > 0 ? null : confidenceMark(line.confidence)
return (
<button
key={line.line_id}
type="button"
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-secondary/60 focus:bg-secondary/60 focus:outline-none"
onMouseDown={(e) => {
// mousedown beats blur: without this the popover closes
// before the click registers when the user has tabbed
// through and uses keyboard.
e.preventDefault()
}}
onClick={() => {
onChange(line.journal_entry_id)
setSearch('')
setOpen(false)
}}
>
<span className="font-mono text-xs shrink-0 w-12">{formatVoucher(line)}</span>
<span className="text-muted-foreground shrink-0 tabular-nums w-24">{formatDate(line.entry_date)}</span>
<span className="tabular-nums shrink-0 w-24 text-right">{formatCurrency(amount)}</span>
<span className="truncate text-muted-foreground flex-1">
{line.line_description || line.entry_description}
</span>
{strength && (
strength.variant ? (
<Badge variant={strength.variant} className="shrink-0 text-[10px]">
{strength.label}
</Badge>
) : (
<span className="shrink-0 text-[11px] text-muted-foreground">{strength.label}</span>
)
)}
{(line.linked_transaction_count ?? 0) > 0 && (
<Badge variant="secondary" className="shrink-0 text-[10px]">
Matchad
</Badge>
)}
</button>
)
})}
{glLines.length > filtered.length && (
<div className="px-3 py-2 text-[11px] text-muted-foreground border-t border-border bg-secondary/30">
Visar {filtered.length} av {glLines.length}: sök för att filtrera fler.
</div>
)}
</div>
)
const searchBox = (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
placeholder={placeholder}
disabled={disabled}
className="pl-9"
/>
</div>
)
// Inline: list lives in normal flow so it can never be clipped by a scroll
// container (Dialog). The enclosing modal scrolls if the whole thing is tall.
if (inline) {
return (
<div ref={containerRef} className="space-y-2">
{searchBox}
<div className="overflow-hidden rounded-lg border border-border bg-popover">
{listContent}
</div>
</div>
)
}
// Overlay: compact, opens on focus, dismisses on outside click. Right for the
// reconciliation view's one-picker-per-row layout.
return (
<div ref={containerRef} className="relative">
{searchBox}
{open && (
<div className="absolute z-20 mt-1 w-full overflow-hidden rounded-lg border border-border bg-popover shadow-[var(--shadow-md)]">
{listContent}
</div>
)}
</div>
)
}