Files
accounted/components/reports/BankReconciliationView.tsx
T
Jakob WennbergandClaude Opus 4.8 b5c3c3ec04 fix(reconciliation): surface bank tx + match to existing verifikat, delete UX, DPA links (#623)
* fix(reconciliation): surface bank transactions via robust cash_account_id scoping

The per-account reconciliation scoping silently returned zero transactions for
companies whose rows were NULL or mis-assigned mid-backfill (e.g. Arcim: 138
transactions, 101 unbooked, yet Bankavstämning showed "0 kr" while the 1930 GL
movement and a large difference still displayed). Two causes, both fixed:

- scopeTransactionsToAccount used a fragile nested or(...,and(is.null,...))
  PostgREST filter. Replace it with a flat, reliable
  `currency = X AND (cash_account_id = id OR cash_account_id IS NULL)` and share
  the one implementation with /api/transactions so the status card and the lists
  can never drift.
- The original best-effort backfill only touched NULL rows and an earlier
  revision mis-assigned cash_account_id (the since-fixed min(uuid) bug), which
  migrations cannot self-correct. Add an idempotent repair migration that
  re-seeds the default 1930 account and re-derives cash_account_id (correcting
  non-NULL mis-assignments) for booked rows and single-account companies.

Also localise manualLink's user-facing errors to Swedish. Adds unit coverage for
the new filter shape and pg-real coverage for the repair (incl. the Arcim
single-account reproduction).

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

* feat(transactions): match a bank transaction to an existing verifikat

Adds a "Matcha mot befintlig verifikation" action to the Transactions inbox so a
bank line that is already booked elsewhere (a salary run, a Fortnox/manual
voucher, an invoice paid from the invoice page) can be linked to that existing
verifikat with no new bokföring — the capability previously lived only in
Reports → Bankavstämning.

- Extract the searchable MatchVerifikationPicker into a shared client component.
- New MatchVoucherDialog: resolves the tx's cash account, fetches candidates
  ranked server-side by reconciliation confidence, links via
  /api/reconciliation/bank/link (so the link is undoable in Bankavstämning).
- unmatched-entries route gains an optional transaction_id that ranks candidates
  (ranking stays server-side; the recon lib is not client-safe).
- Inbox row's overflow (⋯) menu gains the new action.

Also fixes the Bankavstämning view: editing the date no longer auto-reloads
(applies on Filtrera / account change only) and Datum till defaults to today.

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

* fix(transactions): actionable Swedish errors when a bank tx cannot be deleted

The delete route returned a hardcoded English 409 that getErrorMessage mapped to
the misleading generic "En konflikt uppstod. Ladda om sidan...". Return
structured bilingual envelopes instead:

- TRANSACTION_DELETE_BOOKED (409) for a booked/linked row — steers the user to
  unlink in Bankavstämning or storna the voucher.
- TRANSACTION_DELETE_HAS_AUDIT_TRAIL (409) for the real, common case where an
  unbooked row carries payment_match_log rows: the cascade hits the
  audit-immutability trigger (P0001), previously surfaced as a bare 500. Steers
  the user to match-to-voucher or ignore instead.

Updates the DELETE test suite and adds the P0001 case.

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

* feat(settings): surface DPA and privacy policy links

The /dpa page (personuppgiftsbiträdesavtal, GDPR Art. 28) was complete but
linked from nowhere. Add a "Sekretess och avtal" card on Inställningar → Konto
linking to /privacy and /dpa, and a reciprocal link to the DPA from the privacy
policy's sub-processor section. (The DPA already links back to /privacy.)

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

* fix(review): address PR #623 feedback

- unmatched-entries: when transaction_id is supplied but resolves to no row in
  the caller's company, return an empty candidate list instead of silently
  falling back to the full unranked list (Compliance Swarm V8.2.1, high).
- MatchVoucherDialog: preserve a manually-picked voucher when the candidate
  list reloads (e.g. "Visa alla datum") instead of discarding it (Greptile P2).
- DELETE /api/transactions/[id]: return the 404 as the structured
  { error: { code, message, message_en } } envelope like the handler's other
  errors, for a uniform contract (Greptile P2). Test updated.

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

* feat(bookkeeping): allow editing notes on a committed journal entry

Saving a note on a posted verifikation failed with "Committed entries are
immutable": enforce_journal_entry_immutability() had no posted→posted path, so a
notes-only UPDATE fell through to the final RAISE. `notes` is internal annotation
metadata (not verifikation content under BFL 5 kap. / BFNAR 2013:2), so add a
narrow carve-out that permits a notes-only change on a committed entry — verified
with a whole-row to_jsonb() diff so any other field change still raises, and only
when status is unchanged. Period-lock enforcement is unaffected.

CREATE OR REPLACE in a new migration (same pattern as
20260428160000_fix_journal_entry_immutability_delete_bypass); the migration-017
protections are extended, never weakened. Covered by a pg-real test asserting a
notes edit succeeds while amount/description/account edits still fail.

(Already applied to production; committing the file + test for repo consistency.)

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

* fix(sandbox): make pre-staged pending operations executor-complete

The seeded pending_operations existed only as display previews — approving them
failed because commit executors in lib/pending-operations/commit.ts validate
required fields on "Godkänn". Seed a backing invoice_inbox_items row and fill the
supplier-invoice and categorize params with every field the executors require
(inbox_item_id, full items array; real uncategorized transaction_id + category),
so the sandbox approval queue is actually approvable end to end.

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-01 21:08:06 +02:00

1032 lines
42 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
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 { 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'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { CashAccountSelector } from '@/components/common/CashAccountSelector'
import { MatchVerifikationPicker, type UnlinkedGLLine } from '@/components/reconciliation/MatchVerifikationPicker'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import type { CashAccount } from '@/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const METHOD_LABELS: Record<string, string> = {
auto_exact: 'Exakt matchning',
auto_date_range: 'Datumintervall',
auto_reference: 'Referensmatchning',
auto_fuzzy: 'Ungefärlig matchning',
manual: 'Manuell',
}
// One-click bookings for transactions with no upstream invoice/voucher to match
// against — the common "stuck on the unmatched list" cause (small ränteintäkter,
// bankavgifter, valutakursdifferenser). These reuse the existing bank_finance
// booking templates; the categorize endpoint rewrites the bank leg to the
// transaction's actual settlement account, so they book correctly on ANY cash
// account (1930, a savings account, a EUR account…), not just 1930.
// `account` is the non-bank leg (revenue/cost) — the bank leg is the selected
// account. Income templates apply to positive amounts, expense to negative.
const QUICK_BOOK_TEMPLATES: {
id: string
label: string
account: string
direction: 'income' | 'expense'
}[] = [
{ id: 'bank_interest_income', label: 'ränteintäkt', account: '8310', direction: 'income' },
{ id: 'bank_currency_gain', label: 'valutakursvinst', account: '3960', direction: 'income' },
{ id: 'bank_fees', label: 'bankavgift', account: '6570', direction: 'expense' },
{ id: 'bank_interest_expense', label: 'räntekostnad', account: '8410', direction: 'expense' },
{ id: 'bank_currency_loss', label: 'valutakursförlust', account: '7960', direction: 'expense' },
]
// ============================================================
// Types
// ============================================================
interface ReconciliationStatus {
bank_transaction_total: number
/**
* @deprecated Kept on the server response for back-compat. The UI no longer
* reads it — `gl_1930_period_movement` is required.
*/
gl_1930_balance: number
gl_1930_period_movement: number
gl_1930_opening_balance: number
gl_1930_correction_adjustment: number
difference: number
is_reconciled: boolean
matched_count: number
unmatched_transaction_count: number
unmatched_gl_line_count: number
}
interface UnmatchedTransaction {
id: string
date: string
description: string
amount: number
reference: string | null
currency: string
is_ignored?: boolean
}
interface MatchedTransaction {
id: string
date: string
description: string
amount: number
reconciliation_method: string | null
journal_entry_id: string | null
}
interface DryRunMatch {
transaction_id: string
transaction_date: string
transaction_description: string
transaction_amount: number
journal_entry_id: string
voucher_number: number
voucher_series: string
entry_date: string
entry_description: string
method: string
confidence: number
}
// ============================================================
// Component
// ============================================================
export function BankReconciliationView() {
const [status, setStatus] = useState<ReconciliationStatus | null>(null)
const [unmatchedTx, setUnmatchedTx] = useState<UnmatchedTransaction[]>([])
const [glLines, setGlLines] = useState<UnlinkedGLLine[]>([])
const [matchedTx, setMatchedTx] = useState<MatchedTransaction[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// dateFrom stays empty by default (full history) so nothing the user needs to
// reconcile is hidden on first load. dateTo defaults to today so the field
// isn't a blank "åååå-mm-dd" and the upper bound is concrete.
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState(() => new Date().toISOString().slice(0, 10))
const [accountNumber, setAccountNumber] = useState('1930')
const [cashAccounts, setCashAccounts] = useState<CashAccount[]>([])
// Date filters apply on demand (the "Filtrera" button or an account switch),
// never on every keystroke. Editing a date used to re-create fetchAll and
// re-trigger its effect — the "switching months reloads automatically"
// annoyance. fetchAll reads the live dates from refs so an explicit run always
// uses the latest typed values without putting them in its dependency array.
const dateFromRef = useRef(dateFrom)
const dateToRef = useRef(dateTo)
useEffect(() => {
dateFromRef.current = dateFrom
}, [dateFrom])
useEffect(() => {
dateToRef.current = dateTo
}, [dateTo])
const [dryRunResults, setDryRunResults] = useState<DryRunMatch[] | null>(null)
const [runLoading, setRunLoading] = useState(false)
const [applyLoading, setApplyLoading] = useState(false)
const [linkLoading, setLinkLoading] = useState<string | null>(null)
const [unlinkLoading, setUnlinkLoading] = useState<string | null>(null)
const [actionLoading, setActionLoading] = useState<string | null>(null)
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
// recovery affordance from anyone who didn't already know it was there.
const [showIgnored, setShowIgnored] = useState(true)
const [ignoredTx, setIgnoredTx] = useState<UnmatchedTransaction[]>([])
const [selectedMatch, setSelectedMatch] = useState<Record<string, string>>({})
// True when the unmatched list hit the API's 500-row cap — surfaced so a long
// date range doesn't silently hide rows and let the user think they're done.
const [unmatchedTruncated, setUnmatchedTruncated] = useState(false)
// Aborts the previous in-flight load when the account/date filters change, so
// a slow stale response can't overwrite the freshly-selected account's data
// (the intermittent "flips between accounts" bug).
const fetchAbortRef = useRef<AbortController | null>(null)
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
const { toast } = useToast()
// Derive the currency for the selected ledger account from cash_accounts.
// Without this the lists below would hardcode SEK and silently return zero
// rows for users on 1932 EUR (or any other non-SEK cash account).
const accountCurrency =
cashAccounts.find((a) => a.ledger_account === accountNumber)?.currency ?? 'SEK'
useEffect(() => {
let cancelled = false
fetch('/api/cash-accounts')
.then((r) => r.json())
.then((j) => {
if (!cancelled && Array.isArray(j.data)) setCashAccounts(j.data as CashAccount[])
})
.catch(() => {
// Non-critical — falls back to 'SEK' currency, matches old behaviour.
})
return () => {
cancelled = true
}
}, [])
const fetchAll = useCallback(async () => {
// Cancel any in-flight load — it may be for a different account. Without
// this, switching accounts quickly lets an older response land last and
// overwrite the current account's data.
fetchAbortRef.current?.abort()
const controller = new AbortController()
fetchAbortRef.current = controller
const { signal } = controller
setLoading(true)
setError(null)
try {
const fromValue = dateFromRef.current
const toValue = dateToRef.current
const params = new URLSearchParams()
if (fromValue) params.set('date_from', fromValue)
if (toValue) params.set('date_to', toValue)
params.set('account_number', accountNumber)
const qs = `?${params}`
const txParams = new URLSearchParams()
txParams.set('currency', accountCurrency)
txParams.set('account_number', accountNumber)
if (fromValue) txParams.set('date_from', fromValue)
if (toValue) txParams.set('date_to', toValue)
const unmatchedQs = `?unmatched=true&${txParams}`
const reconciledQs = `?reconciled=true&${txParams}`
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/transactions${unmatchedQs}`, { signal }),
fetch(`/api/transactions${reconciledQs}`, { signal }),
])
const [statusData, glData, unmatchedData, matchedData] = await Promise.all([
statusRes.json(),
glRes.json(),
unmatchedRes.json(),
matchedRes.json(),
])
// A newer load superseded this one while we awaited — discard these
// stale results rather than clobber the current account's data.
if (signal.aborted) return
if (statusData.data) setStatus(statusData.data)
setGlLines(glData.data || [])
setUnmatchedTx(unmatchedData.data || [])
setMatchedTx(matchedData.data || [])
setUnmatchedTruncated(Boolean(unmatchedData.has_more))
// Refresh the ignored list whenever the main lists refresh.
// Deliberately NOT filtered by account or currency — if a user ignored
// a row on 1932 EUR and then switched to 1930 SEK, the recovery card
// would disappear and the row would feel "stuck". Company-wide scope
// keeps the Återställ path reachable from any account selection. The
// date filter is also dropped so old ignores stay visible.
try {
const ignoredRes = await fetch(`/api/transactions?unmatched=true&only_ignored=true`, { signal })
const ignoredData = await ignoredRes.json()
if (!signal.aborted) setIgnoredTx(ignoredData.data || [])
} catch {
if (!signal.aborted) setIgnoredTx([])
}
} catch (e) {
// Aborts are expected when the user switches account/date quickly.
if (signal.aborted || (e instanceof DOMException && e.name === 'AbortError')) return
console.error('[reconciliation] fetchAll failed', e)
setError('Kunde inte hämta avstämningsdata')
} finally {
// Only the latest load owns the spinner; a superseded load must not flip
// it off while the fresh one is still running.
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])
useEffect(() => {
fetchAll()
}, [fetchAll])
// Reset transient per-account UI state when the selected account changes. A
// verifikation pick or a dry-run preview computed for the previous account is
// meaningless against the new one — and applying it would cross-link.
useEffect(() => {
setSelectedMatch({})
setDryRunResults(null)
}, [accountNumber])
const handleDryRun = async () => {
setRunLoading(true)
setDryRunResults(null)
try {
const res = await fetch('/api/reconciliation/bank/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
account_number: accountNumber,
dry_run: true,
}),
})
const result = await res.json()
if (result.data?.matches) {
setDryRunResults(result.data.matches)
}
} catch {
setError('Kunde inte köra förhandsgranskning')
} finally {
setRunLoading(false)
}
}
const handleApply = async () => {
setApplyLoading(true)
try {
await fetch('/api/reconciliation/bank/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
account_number: accountNumber,
dry_run: false,
}),
})
setDryRunResults(null)
await fetchAll()
} catch {
setError('Kunde inte tillämpa matchningar')
} finally {
setApplyLoading(false)
}
}
const handleManualLink = async (transactionId: string) => {
const journalEntryId = selectedMatch[transactionId]
if (!journalEntryId) return
setLinkLoading(transactionId)
try {
const res = await fetch('/api/reconciliation/bank/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transaction_id: transactionId,
journal_entry_id: journalEntryId,
account_number: accountNumber,
}),
})
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setSelectedMatch((prev) => {
const next = { ...prev }
delete next[transactionId]
return next
})
await fetchAll()
}
} catch {
setError('Kunde inte matcha transaktion')
} finally {
setLinkLoading(null)
}
}
const handleUnlink = async (transactionId: string) => {
setUnlinkLoading(transactionId)
try {
const res = await fetch('/api/reconciliation/bank/unlink', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId }),
})
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
await fetchAll()
}
} catch {
setError('Kunde inte avmatcha transaktion')
} finally {
setUnlinkLoading(null)
}
}
/**
* Inline one-click booking for an unmatched transaction with no upstream
* voucher to match against (ränteintäkter, bankavgifter, valutakurs-
* differenser). Calls the standard categorize endpoint with a bank_finance
* template so the resulting verifikation is identical to the /transactions
* flow — no parallel booking path. The categorize endpoint rewrites the bank
* leg to the transaction's actual settlement account, so this is correct on
* any cash account.
*/
const handleQuickBook = async (transactionId: string, templateId: string) => {
setActionLoading(transactionId)
try {
const res = await fetch(`/api/transactions/${transactionId}/categorize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
is_business: true,
template_id: templateId,
confirm_no_match: true,
}),
})
const result = await res.json()
if (!res.ok || result.error) {
setError(result.error?.message || result.error || 'Kunde inte bokföra transaktionen')
return
}
if (result.journal_entry_error) {
setError(result.journal_entry_error)
return
}
await fetchAll()
} catch {
setError('Kunde inte bokföra transaktionen')
} finally {
setActionLoading(null)
}
}
const handleIgnore = async (tx: UnmatchedTransaction) => {
// Even though Ignorera is fully reversible, it's still a state change the
// user could miss after a misclick — the row vanishes from the unmatched
// list immediately. Confirmation before the write + an explicit Ångra
// toast on success gives two recovery affordances. The persistent
// "Ignorerade transaktioner" card is the third.
const ok = await confirm({
title: 'Ignorera transaktionen?',
description: `${tx.description} — ${formatCurrency(tx.amount)} (${formatDate(tx.date)}) försvinner från avstämningen utan att bokföras. Du kan återställa den från "Ignorerade transaktioner" nedan när som helst.`,
confirmLabel: 'Ignorera',
cancelLabel: 'Avbryt',
variant: 'warning',
})
if (!ok) return
setActionLoading(tx.id)
try {
const res = await fetch(`/api/transactions/${tx.id}/ignore`, {
method: 'POST',
})
const result = await res.json()
if (!res.ok || result.error) {
setError(result.error || 'Kunde inte ignorera transaktionen')
return
}
await fetchAll()
toast({
title: 'Transaktionen ignorerad',
description: `${tx.description} — ${formatCurrency(tx.amount)}`,
action: (
<ToastAction
altText="Ångra ignorera"
onClick={() => handleUnignore(tx.id)}
>
Ångra
</ToastAction>
),
})
} catch {
setError('Kunde inte ignorera transaktionen')
} finally {
setActionLoading(null)
}
}
const handleUnignore = async (transactionId: string) => {
setActionLoading(transactionId)
try {
const res = await fetch(`/api/transactions/${transactionId}/ignore`, {
method: 'DELETE',
})
const result = await res.json()
if (!res.ok || result.error) {
setError(result.error || 'Kunde inte återställa transaktionen')
return
}
await fetchAll()
} catch {
setError('Kunde inte återställa transaktionen')
} finally {
setActionLoading(null)
}
}
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar bankavstämning...
</CardContent>
</Card>
)
}
if (error && !status) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
{error && (
<Card>
<CardContent className="py-3 text-center text-destructive text-sm">
<AlertCircle className="h-4 w-4 inline mr-1" />
{error}
<Button variant="ghost" size="sm" className="ml-2" onClick={() => setError(null)}>
Stäng
</Button>
</CardContent>
</Card>
)}
{/* Status Card */}
{status && (
<Card className="border-2">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Avstämning mot <AccountNumber number={accountNumber} /></CardTitle>
{status.is_reconciled ? (
<Badge className="bg-success/10 text-success">Avstämd</Badge>
) : (
<Badge variant="destructive">Ej avstämd</Badge>
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">
Avstämningen körs mot <AccountNumber number={accountNumber} /> ({accountCurrency}). Övriga bankkonton (t.ex. Plusgiro <AccountNumber number="1920" />, kreditkort <AccountNumber number="1940" /> eller valutakonton) stäms av separat — välj kontot i listan nedan.
</p>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Banktransaktioner i perioden</span>
<span className="font-mono">{formatCurrency(status.bank_transaction_total)}</span>
</div>
<div className="flex justify-between">
<span>Bokfört på <AccountNumber number={accountNumber} /> i perioden</span>
<span className="font-mono">
{formatCurrency(status.gl_1930_period_movement)}
</span>
</div>
<div className="flex justify-between pt-2 border-t font-semibold">
<span>Differens</span>
<span>
{formatCurrency(status.difference)}
</span>
</div>
{status.gl_1930_opening_balance !== 0 && (
<p className="pt-2 text-xs text-muted-foreground">
Ingående balans (IB) på <AccountNumber number={accountNumber} />:{' '}
<span className="font-mono">{formatCurrency(status.gl_1930_opening_balance)}</span>
{' '}— räknas inte i avstämningen.
</p>
)}
{status.gl_1930_correction_adjustment !== 0 && (
<p className="pt-2 text-xs text-muted-foreground">
Rättelser och stornon på <AccountNumber number={accountNumber} /> i perioden:{' '}
<span className="font-mono">{formatCurrency(status.gl_1930_correction_adjustment)}</span>
{' '}— bokföringsmässiga rättelser utan motsvarande bankhändelse, räknas inte i avstämningen.
</p>
)}
<div className="flex gap-4 pt-2 text-xs text-muted-foreground">
<span>Matchade: {status.matched_count}</span>
<span>Omatchade transaktioner: {status.unmatched_transaction_count}</span>
<span>Omatchade verifikationer: {status.unmatched_gl_line_count}</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Action Bar */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap items-end gap-4">
<CashAccountSelector
value={accountNumber}
onChange={setAccountNumber}
/>
<div>
<Label>Datum från</Label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<Label>Datum till</Label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<Button onClick={fetchAll} variant="outline">
Filtrera
</Button>
<div className="flex-1" />
<Button onClick={handleDryRun} disabled={runLoading} variant="outline">
<Eye className="h-4 w-4 mr-2" />
{runLoading ? 'Analyserar...' : 'Förhandsgranska'}
</Button>
{dryRunResults && dryRunResults.length > 0 && (
<Button onClick={handleApply} disabled={applyLoading}>
<Play className="h-4 w-4 mr-2" />
{applyLoading ? 'Tillämpar...' : `Tillämpa ${dryRunResults.length} matchningar`}
</Button>
)}
</div>
</CardContent>
</Card>
{/* Dry Run Preview */}
{dryRunResults && dryRunResults.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
Förhandsgranskning — {dryRunResults.length} matchningar hittade
</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2">Transaktion</th>
<th className="py-2 w-24">Datum</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-8 text-center">&harr;</th>
<th className="py-2">Verifikation</th>
<th className="py-2 w-24">Datum</th>
<th className="py-2 w-28">Metod</th>
</tr>
</thead>
<tbody>
{dryRunResults.map((m) => (
<tr key={m.transaction_id} className="border-b last:border-0">
<td className="py-2 truncate max-w-[180px]">{m.transaction_description}</td>
<td className="py-2 tabular-nums">{formatDate(m.transaction_date)}</td>
<td className="py-2 text-right font-mono">{formatAmount(m.transaction_amount)}</td>
<td className="py-2 text-center text-muted-foreground">&harr;</td>
<td className="py-2">
<span className="font-mono text-xs">{formatVoucher(m)}</span>
<span className="ml-2 text-muted-foreground truncate">{m.entry_description}</span>
</td>
<td className="py-2 tabular-nums">{formatDate(m.entry_date)}</td>
<td className="py-2">
<Badge variant="outline" className="text-xs">
{METHOD_LABELS[m.method] || m.method}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
{dryRunResults && dryRunResults.length === 0 && (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
Inga automatiska matchningar hittades.
</CardContent>
</Card>
)}
{/* Unmatched Transactions */}
{unmatchedTx.length > 0 && (
<section className="space-y-3">
<div className="flex items-baseline justify-between">
<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>
{unmatchedTruncated && (
<p className="text-xs text-muted-foreground">
Visar de senaste 500 transaktionerna — begränsa datumintervallet för att se fler.
</p>
)}
<div className="space-y-3">
{unmatchedTx.map((tx) => {
const isPositive = tx.amount > 0
// Quick-book options matching the transaction's direction. The
// bank leg books to the SELECTED account (the categorize endpoint
// rewrites it from the cash_account_id), so these are correct on
// any account, not just 1930.
const quickBooks = QUICK_BOOK_TEMPLATES.filter((t) =>
isPositive ? t.direction === 'income' : t.direction === 'expense',
)
return (
<div
key={tx.id}
className="rounded-lg border border-border bg-card p-4 space-y-4"
>
{/* Header row: meta + description + amount + menu */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground tabular-nums">
<span>{formatDate(tx.date)}</span>
<span aria-hidden>·</span>
<Badge variant="outline" className="text-[10px] uppercase tracking-wider">
{tx.currency}
</Badge>
{tx.reference && (
<>
<span aria-hidden>·</span>
<span>Ref: {tx.reference}</span>
</>
)}
</div>
<div className="mt-1.5 text-sm font-medium truncate">{tx.description}</div>
</div>
<div className="flex items-start gap-2 shrink-0">
<div
className={`font-display text-xl tabular-nums ${
isPositive ? 'text-success' : ''
}`}
>
{isPositive ? '+' : ''}
{formatCurrency(tx.amount)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
aria-label="Fler åtgärder"
disabled={actionLoading === tx.id}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
{quickBooks.length > 0 && (
<>
<DropdownMenuLabel className="text-[11px] font-normal uppercase tracking-wider text-muted-foreground">
Bokför direkt
</DropdownMenuLabel>
{quickBooks.map((t) => {
// Read as "debit mot credit": income debits the
// bank (selected account), credits revenue;
// expense debits the cost account, credits bank.
const legs = isPositive
? `${accountNumber} mot ${t.account}`
: `${t.account} mot ${accountNumber}`
return (
<DropdownMenuItem
key={t.id}
onClick={() => handleQuickBook(tx.id, t.id)}
disabled={actionLoading === tx.id}
>
<PiggyBank className="h-4 w-4" />
<div className="flex flex-col">
<span>Bokför som {t.label}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{legs}
</span>
</div>
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => handleIgnore(tx)}
disabled={actionLoading === tx.id}
>
<EyeOff className="h-4 w-4" />
<div className="flex flex-col">
<span>Ignorera transaktion…</span>
<span className="text-xs text-muted-foreground">
Dölj utan att bokföra. Går att återställa.
</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Match action row */}
<div className="pt-3 border-t border-border space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-[11px] uppercase tracking-wider text-muted-foreground">
Matcha mot verifikation
</Label>
{glLines.length === 0 && (
<span className="text-[11px] text-muted-foreground">
Inga omatchade verifikationer på <AccountNumber number={accountNumber} />
</span>
)}
</div>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<MatchVerifikationPicker
glLines={glLines}
value={selectedMatch[tx.id] || ''}
onChange={(v) =>
setSelectedMatch((prev) => ({ ...prev, [tx.id]: v }))
}
disabled={linkLoading === tx.id || glLines.length === 0}
/>
</div>
<Button
size="sm"
variant="outline"
disabled={!selectedMatch[tx.id] || linkLoading === tx.id}
onClick={() => handleManualLink(tx.id)}
className="shrink-0 h-10"
>
<Link2 className="h-3.5 w-3.5 mr-1.5" />
{linkLoading === tx.id ? 'Matchar…' : 'Matcha'}
</Button>
</div>
</div>
</div>
)
})}
</div>
</section>
)}
{/* Unmatched GL Lines */}
{glLines.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
Omatchade verifikationer på <AccountNumber number={accountNumber} /> ({glLines.length})
</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-16">Ver.nr</th>
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-24">Typ</th>
</tr>
</thead>
<tbody>
{glLines.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">
<td className="py-2 font-mono text-xs">
{formatVoucher(line)}
</td>
<td className="py-2 tabular-nums">{formatDate(line.entry_date)}</td>
<td className="py-2 truncate max-w-[300px]">
{line.line_description || line.entry_description}
</td>
<td className="py-2 text-right font-mono">
{formatCurrency(amount)}
</td>
<td className="py-2 text-xs text-muted-foreground">{line.source_type}</td>
</tr>
)
})}
</tbody>
</table>
</CardContent>
</Card>
)}
{/* Ignored transactions (undo) */}
{ignoredTx.length > 0 && (
<Card>
<CardHeader
className="cursor-pointer"
onClick={() => setShowIgnored(!showIgnored)}
>
<div className="flex items-center gap-2">
{showIgnored ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<CardTitle className="text-lg">
Ignorerade transaktioner ({ignoredTx.length})
</CardTitle>
</div>
</CardHeader>
{showIgnored && (
<CardContent>
<p className="text-xs text-muted-foreground mb-3">
Rader du valt att dölja från avstämningen. De påverkar inte saldot på <AccountNumber number={accountNumber} /> — de är bara gömda från listan.
</p>
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-20">Valuta</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-28"></th>
</tr>
</thead>
<tbody>
{ignoredTx.map((tx) => (
<tr key={tx.id} className="border-b last:border-0 text-muted-foreground">
<td className="py-2">{tx.date}</td>
<td className="py-2 truncate max-w-[300px]">{tx.description}</td>
<td className="py-2 text-xs">
<Badge variant="outline" className="text-xs">{tx.currency}</Badge>
</td>
<td className="py-2 text-right font-mono">
{formatCurrency(tx.amount)}
</td>
<td className="py-2">
<Button
size="sm"
variant="ghost"
disabled={actionLoading === tx.id}
onClick={() => handleUnignore(tx.id)}
>
{actionLoading === tx.id ? '...' : 'Återställ'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
)}
</Card>
)}
{/* Recently Matched */}
{matchedTx.length > 0 && (
<Card>
<CardHeader
className="cursor-pointer"
onClick={() => setShowMatched(!showMatched)}
>
<div className="flex items-center gap-2">
{showMatched ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<CardTitle className="text-lg">
Matchade transaktioner ({matchedTx.length})
</CardTitle>
</div>
</CardHeader>
{showMatched && (
<CardContent>
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-32">Metod</th>
<th className="py-2 w-24"></th>
</tr>
</thead>
<tbody>
{matchedTx.map((tx) => (
<tr key={tx.id} className="border-b last:border-0">
<td className="py-2">{tx.date}</td>
<td className="py-2 truncate max-w-[300px]">{tx.description}</td>
<td className="py-2 text-right font-mono">
{formatCurrency(tx.amount)}
</td>
<td className="py-2">
{tx.reconciliation_method && (
<Badge variant="outline" className="text-xs">
{METHOD_LABELS[tx.reconciliation_method] || tx.reconciliation_method}
</Badge>
)}
</td>
<td className="py-2">
{tx.reconciliation_method && (
<Button
size="sm"
variant="ghost"
disabled={unlinkLoading === tx.id}
onClick={() => handleUnlink(tx.id)}
>
<Unlink className="h-3 w-3 mr-1" />
{unlinkLoading === tx.id ? '...' : 'Avmatcha'}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
)}
</Card>
)}
{/* Empty state */}
{unmatchedTx.length === 0 && glLines.length === 0 && matchedTx.length === 0 && ignoredTx.length === 0 && !loading && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga transaktioner eller verifikationer att stämma av.
</CardContent>
</Card>
)}
<DestructiveConfirmDialog {...confirmDialogProps} />
</div>
)
}