* feat(reconciliation): scope bank reconciliation per cash account via transactions.cash_account_id A company with two same-currency cash accounts (e.g. checking 1930 + a savings account) saw every SEK transaction on every account, and the status card summed across both — reconciliation filtered transactions by CURRENCY while filtering GL lines by ACCOUNT (issue #604). Bind each bank transaction to the cash_accounts row it settled on: - New nullable transactions.cash_account_id FK (ON DELETE SET NULL — a bank transaction is räkenskapsinformation, BFL 7 kap, and must survive cash-account deletion) + a best-effort 4-pass backfill. - All reconciliation/transaction queries scope to the selected account with a NULL->currency fallback, so legacy/un-backfilled rows never disappear mid-backfill. - ingestTransactions stamps cash_account_id from the batch's settlementAccount; categorize + manualLink resolve and use it. - Bank leg now books to the transaction's actual settlement account via applySettlementAccount (no-op for 1930), so interest/fees on a savings/EUR account reconcile instead of mis-booking to 1930. - manualLink cross-checks the transaction's account and requires a voucher line on the selected account (no silent cross-account links). - BankReconciliationView: quick-book menu for any settlement account, in-flight request abort on account/date switch, 500-row truncation notice, per-account state reset. - pg-real coverage for the FK, all backfill passes, account-scoped query isolation, and cross-company isolation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): stop marking paid invoices and credit notes as overdue update_overdue_supplier_invoices() (the daily pg_cron job) flipped every past-due 'registered'/'approved' row to 'overdue' without looking at the outstanding balance. Credit notes — created 'registered', remaining 0, due today — got flipped the next day, surfacing as "Förfallen" with "kvar att betala 0 kr"; so did any fully-paid invoice left in 'registered'/'approved'. Guard the cron on remaining_amount > 0.005 (the "fully paid" threshold used by the payment/match paths) and is_credit_note = false, and backfill the rows already mis-flagged (credit notes -> 'registered', paid -> 'paid' with paid_at stamped only when missing). pg-real coverage for the guarded function and the one-off backfill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): refresh dokumentinkorg on realtime row changes The InvoiceInboxWorkspace only refetched on mount and on explicit in-component actions. When an inbox item was resolved out of band — the in-app agent sheet committing a staged create_supplier_invoice_from_inbox / book-direct op, the /pending page approving one, or another tab booking it — none of those paths called fetchItems(), so the booked underlag stayed in "Att göra" until a manual reload (issue #600). Add invoice_inbox_items to the supabase_realtime publication (mirrors the /pending fix in 20260520120100) and subscribe in the workspace, refetching the whole list on any change so derived status/counts/ordering stay authoritative. RLS scopes the channel to the user's company. fetchItems now preserves optimistic upload placeholders so a refetch firing mid-upload can't drop an in-flight row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(privacy): disclose EU AI inference via Amazon Bedrock (eu-north-1) Update the privacy policy and DPA to state that AI inference, when AI features are enabled, runs inside the EU via Amazon Bedrock (eu-north-1, Stockholm) using Anthropic's Claude models — no transfer to a third country, prompts not retained after the call or used for model training. Add AWS as a subprocessor row and refresh the "last updated" dates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): rename invoice_inbox_realtime to avoid version collision main's #617 shipped 20260605120000_transactions_original_description.sql — the same version this branch used for the inbox-realtime publication. The Supabase migration tracker keys on the numeric version, not the filename, so the preview branch failed with a duplicate-key error on supabase_migrations.schema_migrations (version 20260605120000 already exists). Rename to the unique version 20260605120500; the body (ALTER PUBLICATION) is order-independent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reconciliation): align run guard with status; harden filter interpolation Addresses PR review (greptile + compliance swarm): - The v1 and core bank/run routes rejected an unknown account uniformly, including the default '1930', while the status routes were lenient for '1930'. A company reconciling its primary SEK account without a cash_accounts row got 200 from status but 400 from run. Make run match status: '1930' falls back to currency-only scoping (cashAccountId undefined); non-default unknown accounts are still rejected. Adds a test. - /api/transactions accepts a user-supplied `currency` query param that was interpolated raw into a PostgREST .or() filter. Reject anything that isn't a 3-letter ISO code — RLS already scopes to the company, but an unsanitized value could otherwise malform/widen the filter. Assert currency/cashAccountId shape in scopeTransactionsToAccount as well. - categorize: log (instead of silently swallowing) a cash_accounts settlement-account lookup error, so a fall-back-to-1930 mis-booking is observable in the audit log. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): correct backfill UPDATE..FROM join; idempotent realtime publication Two SQL errors that only surface on real Postgres (CI pg-real + Supabase preview) — the unit suite mocks Supabase, so neither was caught locally. - Backfill pass (a): `UPDATE transactions t ... FROM journal_entry_lines jel JOIN cash_accounts ca ON ca.company_id = t.company_id` referenced the UPDATE target `t` inside the FROM join's ON clause, which Postgres rejects ("invalid reference to FROM-clause entry for table t"). Move the company match to WHERE; the JOIN now relates jel<->ca only. Semantics unchanged. - invoice_inbox_realtime: `ALTER PUBLICATION ... ADD TABLE` is not idempotent (SQLSTATE 42710 if the table is already a member). The earlier version-collision push partially applied it on the Supabase preview branch, so the re-apply errored. Guard with a pg_publication_tables existence check. Both statements validated against a real Postgres: the single-line tx binds, the two-bank-line transfer stays NULL, and the publication add runs twice cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): backfill pass (c) uses array_agg, not min(uuid) Postgres has no min() aggregate for uuid, so pass (c)'s min(id) raised "function min(uuid) does not exist" on apply (CI pg-real + Supabase). The HAVING count(*) = 1 already guarantees one row per group, so (array_agg(id))[1] returns that single id. Validated the full backfill (all four passes) and the overdue migration against a real Postgres: every pass binds / falls through as intended, and the overdue guard + backfill produce the right statuses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(compliance): add RoPA entry for Amazon Bedrock AI inference (GDPR Art.30) The privacy policy now discloses AI inference (transaction categorization + document/receipt OCR) via Amazon Bedrock as a processing activity, but .compliance/ropa.yaml had no matching Art.30 record. Add it: opt-in consent basis, EU-region (eu-north-1) inference with no third-country transfer, prompts not retained or used for model training. Mirrors the privacy-page disclosure shipped in this PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1171 lines
46 KiB
TypeScript
1171 lines
46 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo, 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 { Input } from '@/components/ui/input'
|
|
import { AccountNumber } from '@/components/ui/account-number'
|
|
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal, Search, X } from 'lucide-react'
|
|
import { formatCurrency, formatDate } from '@/lib/utils'
|
|
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
|
import { CashAccountSelector } from '@/components/common/CashAccountSelector'
|
|
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 UnlinkedGLLine {
|
|
line_id: string
|
|
journal_entry_id: string
|
|
debit_amount: number
|
|
credit_amount: number
|
|
line_description: string | null
|
|
entry_date: string
|
|
voucher_number: number
|
|
voucher_series: string
|
|
entry_description: string
|
|
source_type: string
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ============================================================
|
|
// Searchable verifikation picker
|
|
// ============================================================
|
|
|
|
/**
|
|
* Inline combobox for choosing a journal entry to match a bank transaction
|
|
* against. The native <select> couldn't be searched, and the unmatched-GL list
|
|
* routinely runs to hundreds of rows (historical SIE imports), so the old UX
|
|
* forced users to scroll a giant unsorted dropdown. This picker filters by
|
|
* voucher number, date, amount or description as the user types, and renders
|
|
* the selected verifikation as a removable chip.
|
|
*/
|
|
interface MatchPickerProps {
|
|
glLines: UnlinkedGLLine[]
|
|
value: string
|
|
onChange: (journalEntryId: string) => void
|
|
disabled?: boolean
|
|
placeholder?: string
|
|
}
|
|
|
|
function MatchVerifikationPicker({
|
|
glLines,
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
placeholder = 'Sök ver.nr, datum, belopp eller beskrivning…',
|
|
}: MatchPickerProps) {
|
|
const [open, setOpen] = useState(false)
|
|
const [search, setSearch] = useState('')
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (!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])
|
|
|
|
const selected = glLines.find((l) => l.journal_entry_id === value) || null
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toLowerCase()
|
|
const base = q.length === 0
|
|
? glLines
|
|
: glLines.filter((line) => {
|
|
const amt = (line.debit_amount > 0 ? line.debit_amount : line.credit_amount).toString()
|
|
return (
|
|
formatVoucher(line).toLowerCase().includes(q) ||
|
|
line.entry_date.toLowerCase().includes(q) ||
|
|
amt.includes(q) ||
|
|
(line.entry_description || '').toLowerCase().includes(q) ||
|
|
(line.line_description || '').toLowerCase().includes(q)
|
|
)
|
|
})
|
|
return base.slice(0, 25)
|
|
}, [search, glLines])
|
|
|
|
if (selected) {
|
|
const amount = selected.debit_amount > 0 ? selected.debit_amount : -selected.credit_amount
|
|
return (
|
|
<div className="flex items-center gap-2 rounded-lg border border-border bg-secondary/40 px-3 py-2 text-sm">
|
|
<span className="font-mono text-xs shrink-0">{formatVoucher(selected)}</span>
|
|
<span className="text-muted-foreground shrink-0 tabular-nums">{formatDate(selected.entry_date)}</span>
|
|
<span className="font-mono tabular-nums shrink-0">{formatCurrency(amount)}</span>
|
|
<span className="truncate text-muted-foreground">{selected.entry_description}</span>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
className="ml-auto h-6 w-6 shrink-0"
|
|
onClick={() => onChange('')}
|
|
disabled={disabled}
|
|
aria-label="Avmarkera verifikation"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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>
|
|
{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>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================
|
|
// 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)
|
|
|
|
const [dateFrom, setDateFrom] = useState('')
|
|
const [dateTo, setDateTo] = useState('')
|
|
const [accountNumber, setAccountNumber] = useState('1930')
|
|
const [cashAccounts, setCashAccounts] = useState<CashAccount[]>([])
|
|
|
|
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 params = new URLSearchParams()
|
|
if (dateFrom) params.set('date_from', dateFrom)
|
|
if (dateTo) params.set('date_to', dateTo)
|
|
params.set('account_number', accountNumber)
|
|
const qs = `?${params}`
|
|
|
|
const txParams = new URLSearchParams()
|
|
txParams.set('currency', accountCurrency)
|
|
txParams.set('account_number', accountNumber)
|
|
if (dateFrom) txParams.set('date_from', dateFrom)
|
|
if (dateTo) txParams.set('date_to', dateTo)
|
|
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)
|
|
}
|
|
}, [dateFrom, dateTo, 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>
|
|
)
|
|
}
|