Files
accounted/components/reconciliation/MatchVerifikationPicker.tsx
T
Jakob WennbergandClaude Opus 4.8 36e3f6ceb0 Design critique: normalize daily-operator flows + UX improvements (#741)
* style(design): normalize daily-operator flows to locked design system

Sweep the dashboard, transactions, reconciliation, invoicing and supplier
flows for design-system violations (.claude/rules/design.md):

- font-medium removed from Hedvig display headings/numerals
- font-mono -> tabular-nums on monetary values (voucher ids stay mono)
- raw Tailwind status colors -> Badge variants / muted-alert pattern
- semantic colors removed from chrome backgrounds (deadline widgets, icon halos)
- hand-rolled skeletons/empty-states -> Skeleton / EmptyState primitives
- opacity-suffixed borders, the invisible warning-foreground count color, and
  shadow-sm/rounded-xl on non-overlay surfaces normalized

The four files that also received UX changes carry their token fixes in the
following commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ux): clearer dashboard CTA, match confidence, AI provenance, invoice actions

Four high-impact UX fixes from the design critique (these files also carry their
design-system token normalization):

- Dashboard: render the next-best-action hero for every agent-built company, not
  only 'slim' nav density, so there is always one obvious next step instead of
  four equal-weight metric tiles.
- Reconciliation: surface the match engine's 0-1 confidence as a graded strength
  badge (Stark / Trolig / Svag traff) in the shared verifikat picker rows and
  selected chip; drop the uninformative binary "Foreslagen traff" badge from the
  match dialog.
- Supplier inbox: show AI-filled provenance per extracted field (a success dot
  that clears once the user verifies/edits the value) so misparsed amounts/dates
  get proofread before they post to an immutable verifikat.
- Invoice detail: keep each status's primary action only in the header row; the
  sidebar "Status actions" card now holds secondary/reversible actions only
  (makulera, ta bort, skapa kreditnota, manual-send alternative), removing the
  duplicated CTAs and closing a viewer-permission gap on the old sidebar buttons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ux): Tier-B medium design-critique fixes across daily-operator flows

- Reconciliation: standardise the match-confirm verb on "Matcha" (was "Koppla"
  in MatchVoucherDialog) and replace the hand-rolled date <input>s in the bank
  reconciliation view with the Input primitive.
- Supplier inbox: fold the two alternative bookings (Skapa leverantorsfaktura /
  Bokfor som verifikat) behind a single "Andra satt att bokfora" dropdown so the
  default path (Matcha mot transaktion) stays the lone primary action.
- Duplicate-payment guard: demote the "Skapa ny verifikation anda" escape hatch
  to a ghost button so the safe "Koppla till befintlig" path dominates.
- Onboarding: raise the "start fresh" escape hatch from a muted text link to a
  visible secondary button; normalise the checklist's off-scale spacing.
- Supplier flows: finish the font-mono -> tabular-nums sweep on monetary values
  in the supplier-invoice detail / create / review surfaces (ids stay mono).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(deadlines): keep overdue rows visually distinct (destructive chrome is allowed)

The Tier-A normalization stripped all semantic-color row tints from the deadline
widgets, but design.md exempts --destructive ("only --destructive survives in
chrome"). Restore a subtle bg-destructive/5 on OVERDUE rows so missed tax/AGI
deadlines (-> skattetillagg) stay noticeable in a list scan; action-needed
(warning) rows stay clean since warning is data-only. Surfaced by the Swedish
compliance review on #741.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reconciliation): hide match-strength badge on already-matched verifikat

Per the Swedish compliance review on #741: a green "Stark traff" confidence badge
rendered alongside "Redan matchad" could visually nudge an accidental double-match
of a posted verifikat (a BFL 5 kap audit-trail concern). Suppress the strength
badge when linked_transaction_count > 0 so "Redan matchad" is the lone signal
there; N:1 matching stays an explicit opt-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:23:33 +02:00

264 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 labelled strength badge, 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.
*/
function confidenceBadge(
confidence: number | undefined,
): { label: string; variant: 'success' | 'secondary' | 'outline' } | null {
if (confidence == null) return null
if (confidence >= 0.85) return { label: 'Stark träff', variant: 'success' }
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 point at this entry. > 0 means the
* voucher is already matched — 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 : confidenceBadge(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 && (
<Badge variant={strength.variant} className="shrink-0 text-[10px]">
{strength.label}
</Badge>
)}
{(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 : confidenceBadge(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 && (
<Badge variant={strength.variant} className="shrink-0 text-[10px]">
{strength.label}
</Badge>
)}
{(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>
)
}