'use client' import React, { useState, useEffect, useCallback } 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 } from 'lucide-react' import { formatCurrency } from '@/lib/utils' function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } const METHOD_LABELS: Record = { auto_exact: 'Exakt matchning', auto_date_range: 'Datumintervall', auto_reference: 'Referensmatchning', auto_fuzzy: 'Ungefärlig matchning', manual: 'Manuell', } // ============================================================ // Types // ============================================================ interface ReconciliationStatus { bank_transaction_total: number gl_1930_balance: 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 } 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(null) const [unmatchedTx, setUnmatchedTx] = useState([]) const [glLines, setGlLines] = useState([]) const [matchedTx, setMatchedTx] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') const [dryRunResults, setDryRunResults] = useState(null) const [runLoading, setRunLoading] = useState(false) const [applyLoading, setApplyLoading] = useState(false) const [linkLoading, setLinkLoading] = useState(null) const [unlinkLoading, setUnlinkLoading] = useState(null) const [showMatched, setShowMatched] = useState(false) const [selectedMatch, setSelectedMatch] = useState>({}) const fetchAll = useCallback(async () => { setLoading(true) setError(null) try { const params = new URLSearchParams() if (dateFrom) params.set('date_from', dateFrom) if (dateTo) params.set('date_to', dateTo) const qs = params.toString() ? `?${params}` : '' const [statusRes, glRes, unmatchedRes, matchedRes] = await Promise.all([ fetch(`/api/reconciliation/bank/status${qs}`), fetch(`/api/reconciliation/bank/unmatched-entries${qs}`), fetch(`/api/transactions?unmatched=true¤cy=SEK${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`), fetch(`/api/transactions?reconciled=true¤cy=SEK${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`), ]) const [statusData, glData, unmatchedData, matchedData] = await Promise.all([ statusRes.json(), glRes.json(), unmatchedRes.json(), matchedRes.json(), ]) if (statusData.data) setStatus(statusData.data) setGlLines(glData.data || []) setUnmatchedTx(unmatchedData.data || []) setMatchedTx(matchedData.data || []) } catch { setError('Kunde inte hämta avstämningsdata') } finally { setLoading(false) } }, [dateFrom, dateTo]) useEffect(() => { fetchAll() }, [fetchAll]) 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, 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, 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, }), }) 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) } } if (loading) { return ( Laddar bankavstämning... ) } if (error && !status) { return ( {error} ) } return (
{error && ( {error} )} {/* Status Card */} {status && (
Avstämning mot {status.is_reconciled ? ( Avstämd ) : ( Ej avstämd )}
Banktransaktioner (summa) {formatCurrency(status.bank_transaction_total)}
saldo (huvudbok) {formatCurrency(status.gl_1930_balance)}
Differens {formatCurrency(status.difference)}
Matchade: {status.matched_count} Omatchade transaktioner: {status.unmatched_transaction_count} Omatchade verifikationer: {status.unmatched_gl_line_count}
)} {/* Action Bar */}
setDateFrom(e.target.value)} className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm" />
setDateTo(e.target.value)} className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm" />
{dryRunResults && dryRunResults.length > 0 && ( )}
{/* Dry Run Preview */} {dryRunResults && dryRunResults.length > 0 && ( Förhandsgranskning — {dryRunResults.length} matchningar hittade {dryRunResults.map((m) => ( ))}
Transaktion Datum Belopp Verifikation Datum Metod
{m.transaction_description} {m.transaction_date} {formatAmount(m.transaction_amount)} {m.voucher_series}{m.voucher_number} {m.entry_description} {m.entry_date} {METHOD_LABELS[m.method] || m.method}
)} {dryRunResults && dryRunResults.length === 0 && ( Inga automatiska matchningar hittades. )} {/* Unmatched Transactions */} {unmatchedTx.length > 0 && ( Omatchade transaktioner ({unmatchedTx.length}) {unmatchedTx.map((tx) => ( ))}
Datum Beskrivning Belopp Referens Föreslå verifikation
{tx.date} {tx.description} {formatCurrency(tx.amount)} {tx.reference || '—'}
)} {/* Unmatched GL Lines */} {glLines.length > 0 && ( Omatchade verifikationer på ({glLines.length}) {glLines.map((line) => { const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount return ( ) })}
Ver.nr Datum Beskrivning Belopp Typ
{line.voucher_series}{line.voucher_number} {line.entry_date} {line.line_description || line.entry_description} {formatCurrency(amount)} {line.source_type}
)} {/* Recently Matched */} {matchedTx.length > 0 && ( setShowMatched(!showMatched)} >
{showMatched ? ( ) : ( )} Matchade transaktioner ({matchedTx.length})
{showMatched && ( {matchedTx.map((tx) => ( ))}
Datum Beskrivning Belopp Metod
{tx.date} {tx.description} {formatCurrency(tx.amount)} {tx.reconciliation_method && ( {METHOD_LABELS[tx.reconciliation_method] || tx.reconciliation_method} )} {tx.reconciliation_method && ( )}
)}
)} {/* Empty state */} {unmatchedTx.length === 0 && glLines.length === 0 && matchedTx.length === 0 && !loading && ( Inga transaktioner eller verifikationer att stämma av. )}
) }