* 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>
1032 lines
42 KiB
TypeScript
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">↔</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">↔</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>
|
|
)
|
|
}
|