* 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>
264 lines
10 KiB
TypeScript
264 lines
10 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 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 "{search}"
|
||
</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>
|
||
)
|
||
}
|