fix(reconciliation): secondary-account scoping, dialog clipping, and N:1 matching (#624)
* fix(reconciliation): secondary-account scoping, dialog clipping, and N:1 matching Three follow-ups to per-account bank reconciliation (PR #623): - Secondary same-currency accounts (e.g. a 1931 savings account) double-counted the company's unassigned (NULL cash_account_id) transactions, inflating their bank total and showing a large bogus difference while 1930 still reconciled. Only the primary cash account now claims NULL rows; every other account scopes strictly to its own id. `includeUnassigned` is threaded through all status/run/list call sites from cash_accounts.is_primary. - The "Matcha mot befintlig verifikation" picker's dropdown was absolutely positioned inside the dialog's overflow-y-auto container and got clipped. Add an `inline` mode that renders the candidate list in normal flow; the dialog uses it, the reconciliation view keeps the compact overlay. - N:1 matching: several bank transactions can now settle one verifikat (a salary run paid in multiple transfers, an invoice paid in instalments). New get_account_gl_lines_for_matching RPC surfaces already-matched vouchers with a linked_transaction_count behind a "Visa även matchade verifikationer" toggle; manualLink's 1:1 guard is relaxed (the aggregate difference still catches mis-links). Tests: extended bank-reconciliation unit tests (strict scope + N:1), rewrote the cash_account_id isolation pg test to prove NULL rows land on the primary account only, and added a pg test for the new RPC. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reconciliation): address PR review — accurate "att matcha mot" count - BankReconciliationView: the "N verifikationer att matcha mot" hint counted glLines (which includes already-matched vouchers when "Visa matchade" is on), overcounting the vouchers that still need a transaction. Use unmatchedGlLines so the label is correct regardless of the toggle (matches the table below). - MatchVerifikationPicker: document that `open` is overlay-only; the setOpen() writes are intentional no-ops in inline mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b5c3c3ec04
commit
094bd85e81
@@ -3,6 +3,7 @@
|
||||
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'
|
||||
@@ -29,6 +30,10 @@ export interface UnlinkedGLLine {
|
||||
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 {
|
||||
@@ -37,6 +42,15 @@ interface MatchPickerProps {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,19 +70,25 @@ export function MatchVerifikationPicker({
|
||||
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(() => {
|
||||
if (!open) return
|
||||
// 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])
|
||||
}, [open, inline])
|
||||
|
||||
const selected = glLines.find((l) => l.journal_entry_id === value) || null
|
||||
|
||||
@@ -97,11 +117,16 @@ export function MatchVerifikationPicker({
|
||||
<span className="text-muted-foreground shrink-0 tabular-nums">{formatDate(selected.entry_date)}</span>
|
||||
<span className="font-mono tabular-nums shrink-0">{formatCurrency(amount)}</span>
|
||||
<span className="truncate text-muted-foreground">{selected.entry_description}</span>
|
||||
{(selected.linked_transaction_count ?? 0) > 0 && (
|
||||
<Badge variant="secondary" className="ml-auto shrink-0 text-[10px]">
|
||||
Redan matchad
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="ml-auto h-6 w-6 shrink-0"
|
||||
className={`${(selected.linked_transaction_count ?? 0) > 0 ? '' : 'ml-auto'} h-6 w-6 shrink-0`}
|
||||
onClick={() => onChange('')}
|
||||
disabled={disabled}
|
||||
aria-label="Avmarkera verifikation"
|
||||
@@ -112,65 +137,93 @@ export function MatchVerifikationPicker({
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
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="font-mono 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>
|
||||
{(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">
|
||||
<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>
|
||||
{searchBox}
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-1 w-full overflow-hidden rounded-lg border border-border bg-popover shadow-[var(--shadow-md)]">
|
||||
{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
|
||||
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="font-mono 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>
|
||||
</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>
|
||||
)}
|
||||
{listContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal } from 'lucide-react'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
@@ -154,6 +155,13 @@ export function BankReconciliationView() {
|
||||
const [unlinkLoading, setUnlinkLoading] = useState<string | null>(null)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
|
||||
// Opt-in: also surface vouchers already matched to a bank transaction as
|
||||
// candidates, so a second/third transaction can be attached to the same
|
||||
// verifikat (N:1 — e.g. a salary run paid out in several transfers). Only
|
||||
// affects the per-row picker candidates; the "Omatchade verifikationer" table
|
||||
// below stays unmatched-only (it lists vouchers that still need a transaction).
|
||||
const [includeMatched, setIncludeMatched] = useState(false)
|
||||
|
||||
const [showMatched, setShowMatched] = useState(false)
|
||||
// Default expanded so users discover the undo path. The card itself only
|
||||
// renders when ignoredTx.length > 0 — collapsing it by default hid the
|
||||
@@ -178,6 +186,12 @@ export function BankReconciliationView() {
|
||||
const accountCurrency =
|
||||
cashAccounts.find((a) => a.ledger_account === accountNumber)?.currency ?? 'SEK'
|
||||
|
||||
// glLines feeds the per-row picker (which may include already-matched vouchers
|
||||
// when includeMatched is on). The "Omatchade verifikationer" table below must
|
||||
// stay unmatched-only — a voucher with a linked transaction isn't something
|
||||
// that still needs one.
|
||||
const unmatchedGlLines = glLines.filter((l) => !(l.linked_transaction_count ?? 0))
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/cash-accounts')
|
||||
@@ -213,6 +227,13 @@ export function BankReconciliationView() {
|
||||
params.set('account_number', accountNumber)
|
||||
const qs = `?${params}`
|
||||
|
||||
// The candidate-lines fetch optionally includes already-matched vouchers
|
||||
// (for N:1); the status endpoint must NOT — its movement/diff is computed
|
||||
// independently — so it keeps the plain qs.
|
||||
const glParams = new URLSearchParams(params)
|
||||
if (includeMatched) glParams.set('include_matched', 'true')
|
||||
const glQs = `?${glParams}`
|
||||
|
||||
const txParams = new URLSearchParams()
|
||||
txParams.set('currency', accountCurrency)
|
||||
txParams.set('account_number', accountNumber)
|
||||
@@ -223,7 +244,7 @@ export function BankReconciliationView() {
|
||||
|
||||
const [statusRes, glRes, unmatchedRes, matchedRes] = await Promise.all([
|
||||
fetch(`/api/reconciliation/bank/status${qs}`, { signal }),
|
||||
fetch(`/api/reconciliation/bank/unmatched-entries${qs}`, { signal }),
|
||||
fetch(`/api/reconciliation/bank/unmatched-entries${glQs}`, { signal }),
|
||||
fetch(`/api/transactions${unmatchedQs}`, { signal }),
|
||||
fetch(`/api/transactions${reconciledQs}`, { signal }),
|
||||
])
|
||||
@@ -269,9 +290,10 @@ export function BankReconciliationView() {
|
||||
if (!signal.aborted) setLoading(false)
|
||||
}
|
||||
// Deliberately excludes dateFrom/dateTo: editing a date must NOT auto-fetch
|
||||
// (it read from refs above). Re-runs only on account / currency change and
|
||||
// mount; the "Filtrera" button calls fetchAll() explicitly for date changes.
|
||||
}, [accountNumber, accountCurrency])
|
||||
// (it read from refs above). Re-runs on account / currency change, on the
|
||||
// matched-toggle flip (which changes the candidate set), and on mount; the
|
||||
// "Filtrera" button calls fetchAll() explicitly for date changes.
|
||||
}, [accountNumber, accountCurrency, includeMatched])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll()
|
||||
@@ -682,15 +704,25 @@ export function BankReconciliationView() {
|
||||
{/* Unmatched Transactions */}
|
||||
{unmatchedTx.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Omatchade transaktioner ({unmatchedTx.length})
|
||||
</h2>
|
||||
{glLines.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{glLines.length} verifikation{glLines.length === 1 ? '' : 'er'} att matcha mot
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
{unmatchedGlLines.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{unmatchedGlLines.length} verifikation{unmatchedGlLines.length === 1 ? '' : 'er'} att matcha mot
|
||||
</p>
|
||||
)}
|
||||
<label className="flex cursor-pointer items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
|
||||
<Switch
|
||||
checked={includeMatched}
|
||||
onCheckedChange={setIncludeMatched}
|
||||
aria-label="Visa även matchade verifikationer"
|
||||
/>
|
||||
Visa matchade
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{unmatchedTruncated && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -843,11 +875,11 @@ export function BankReconciliationView() {
|
||||
)}
|
||||
|
||||
{/* Unmatched GL Lines */}
|
||||
{glLines.length > 0 && (
|
||||
{unmatchedGlLines.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Omatchade verifikationer på <AccountNumber number={accountNumber} /> ({glLines.length})
|
||||
Omatchade verifikationer på <AccountNumber number={accountNumber} /> ({unmatchedGlLines.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -862,7 +894,7 @@ export function BankReconciliationView() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{glLines.map((line) => {
|
||||
{unmatchedGlLines.map((line) => {
|
||||
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
|
||||
return (
|
||||
<tr key={line.line_id} className="border-b last:border-0">
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
MatchVerifikationPicker,
|
||||
type UnlinkedGLLine,
|
||||
@@ -74,9 +75,13 @@ export function MatchVoucherDialog({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [wideRange, setWideRange] = useState(false)
|
||||
// Opt-in: also surface vouchers already matched to another bank transaction,
|
||||
// so several transactions can settle one verifikat (N:1 — a salary run paid in
|
||||
// multiple transfers, an invoice paid in instalments).
|
||||
const [includeMatched, setIncludeMatched] = useState(false)
|
||||
|
||||
const loadCandidates = useCallback(
|
||||
async (tx: TransactionWithInvoice, wide: boolean) => {
|
||||
async (tx: TransactionWithInvoice, wide: boolean, matched: boolean) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Resolve the settlement account from the company's cash accounts.
|
||||
@@ -98,6 +103,7 @@ export function MatchVoucherDialog({
|
||||
const params = new URLSearchParams()
|
||||
params.set('account_number', account)
|
||||
params.set('transaction_id', tx.id)
|
||||
if (matched) params.set('include_matched', 'true')
|
||||
if (!wide) {
|
||||
params.set('date_from', shiftDate(tx.date, -WINDOW_DAYS))
|
||||
params.set('date_to', shiftDate(tx.date, WINDOW_DAYS))
|
||||
@@ -112,9 +118,15 @@ export function MatchVoucherDialog({
|
||||
// Auto-select a strong match only when nothing is chosen yet. Toggling
|
||||
// "Visa alla datum" reloads with a wider set — it must NOT discard a
|
||||
// voucher the user already picked. (selected resets to '' on close.)
|
||||
// Never auto-select an already-matched voucher — N:1 must be a
|
||||
// deliberate choice, not the default when "visa matchade" is on.
|
||||
const top = lines[0]
|
||||
setSelected((prev) =>
|
||||
prev ? prev : top && (top.confidence ?? 0) >= 0.85 ? top.journal_entry_id : '',
|
||||
prev
|
||||
? prev
|
||||
: top && (top.confidence ?? 0) >= 0.85 && !(top.linked_transaction_count ?? 0)
|
||||
? top.journal_entry_id
|
||||
: '',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -123,11 +135,12 @@ export function MatchVoucherDialog({
|
||||
[],
|
||||
)
|
||||
|
||||
// (Re)load whenever the dialog opens for a transaction, or the range widens.
|
||||
// (Re)load whenever the dialog opens for a transaction, the range widens, or
|
||||
// the user toggles already-matched vouchers in/out.
|
||||
useEffect(() => {
|
||||
if (!open || !transaction) return
|
||||
void loadCandidates(transaction, wideRange)
|
||||
}, [open, transaction, wideRange, loadCandidates])
|
||||
void loadCandidates(transaction, wideRange, includeMatched)
|
||||
}, [open, transaction, wideRange, includeMatched, loadCandidates])
|
||||
|
||||
// Reset transient state when the dialog closes so the next open starts clean.
|
||||
useEffect(() => {
|
||||
@@ -135,6 +148,7 @@ export function MatchVoucherDialog({
|
||||
setGlLines([])
|
||||
setSelected('')
|
||||
setWideRange(false)
|
||||
setIncludeMatched(false)
|
||||
setAccountFallback(false)
|
||||
}, [open])
|
||||
|
||||
@@ -223,35 +237,52 @@ export function MatchVoucherDialog({
|
||||
</div>
|
||||
) : glLines.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
<p>Inga omatchade verifikationer på {accountNumber} i perioden.</p>
|
||||
{!wideRange && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => setWideRange(true)}
|
||||
>
|
||||
Visa alla datum
|
||||
</Button>
|
||||
)}
|
||||
<p>
|
||||
{includeMatched
|
||||
? `Inga verifikationer på ${accountNumber} i perioden.`
|
||||
: `Inga omatchade verifikationer på ${accountNumber} i perioden.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{selectedLine && (selectedLine.confidence ?? 0) >= 0.85 && (
|
||||
<Badge variant="success" className="mb-1">Föreslagen träff</Badge>
|
||||
)}
|
||||
<MatchVerifikationPicker glLines={glLines} value={selected} onChange={setSelected} />
|
||||
{!wideRange && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
onClick={() => setWideRange(true)}
|
||||
>
|
||||
Hittar du inte verifikationen? Visa alla datum
|
||||
</button>
|
||||
{selectedLine &&
|
||||
(selectedLine.confidence ?? 0) >= 0.85 &&
|
||||
!(selectedLine.linked_transaction_count ?? 0) && (
|
||||
<Badge variant="success" className="mb-1">Föreslagen träff</Badge>
|
||||
)}
|
||||
<MatchVerifikationPicker glLines={glLines} value={selected} onChange={setSelected} inline />
|
||||
{(selectedLine?.linked_transaction_count ?? 0) > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Verifikationen är redan matchad mot {selectedLine?.linked_transaction_count}{' '}
|
||||
transaktion{(selectedLine?.linked_transaction_count ?? 0) === 1 ? '' : 'er'}.
|
||||
Kopplingen lägger till den här transaktionen också — t.ex. en lön utbetald i
|
||||
flera överföringar.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Discovery affordances — widen the date window, and surface vouchers
|
||||
already matched so another transaction can be attached (N:1). */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 pt-1">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-muted-foreground">
|
||||
<Switch
|
||||
checked={includeMatched}
|
||||
onCheckedChange={setIncludeMatched}
|
||||
aria-label="Visa även matchade verifikationer"
|
||||
/>
|
||||
Visa även matchade verifikationer
|
||||
</label>
|
||||
{!wideRange && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
onClick={() => setWideRange(true)}
|
||||
>
|
||||
Visa alla datum
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
Reference in New Issue
Block a user