Files
accounted/components/reports/BankReconciliationView.tsx
T
Jakob Wennberg 66a4027f1e feat: BAS data overhaul, currency revaluation, expenses, UI polish, and cleanup
- Update BAS account catalog with comprehensive SRU codes and K2 flags
- Add currency revaluation service with tests and API route
- Add expenses page and account deletion API
- Enhance booking templates with new patterns and improved tests
- Improve transaction categorization with template picker and description matching
- Polish dashboard, onboarding, import, and transaction UIs
- Refactor year-end service for multi-step closing
- Move SRU generator to ne-bilaga, remove standalone SRU export
- Remove unused dev docs, mock data, and extension hooks
- Add invoice delivery note sequences migration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:19:56 +01:00

600 lines
21 KiB
TypeScript

'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<string, string> = {
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<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 [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 [showMatched, setShowMatched] = useState(false)
const [selectedMatch, setSelectedMatch] = useState<Record<string, string>>({})
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&currency=SEK${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
fetch(`/api/transactions?reconciled=true&currency=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 (
<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="1930" /></CardTitle>
{status.is_reconciled ? (
<Badge className="bg-green-100 text-green-800">Avstämd</Badge>
) : (
<Badge variant="destructive">Ej avstämd</Badge>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Banktransaktioner (summa)</span>
<span className="font-mono">{formatCurrency(status.bank_transaction_total)}</span>
</div>
<div className="flex justify-between">
<span><AccountNumber number="1930" /> saldo (huvudbok)</span>
<span className="font-mono">{formatCurrency(status.gl_1930_balance)}</span>
</div>
<div className="flex justify-between pt-2 border-t font-semibold">
<span>Differens</span>
<span>
{formatCurrency(status.difference)}
</span>
</div>
<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">
<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>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2">Transaktion</th>
<th className="py-2 w-24">Datum</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-8 text-center">&harr;</th>
<th className="py-2">Verifikation</th>
<th className="py-2 w-24">Datum</th>
<th className="py-2 w-28">Metod</th>
</tr>
</thead>
<tbody>
{dryRunResults.map((m) => (
<tr key={m.transaction_id} className="border-b last:border-0">
<td className="py-2 truncate max-w-[180px]">{m.transaction_description}</td>
<td className="py-2">{m.transaction_date}</td>
<td className="py-2 text-right font-mono">{formatAmount(m.transaction_amount)}</td>
<td className="py-2 text-center text-muted-foreground">&harr;</td>
<td className="py-2">
<span className="font-mono text-xs">{m.voucher_series}{m.voucher_number}</span>
<span className="ml-2 text-muted-foreground truncate">{m.entry_description}</span>
</td>
<td className="py-2">{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 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
Omatchade transaktioner ({unmatchedTx.length})
</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<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">Referens</th>
<th className="py-2 w-64">Föreslå verifikation</th>
<th className="py-2 w-24"></th>
</tr>
</thead>
<tbody>
{unmatchedTx.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-[200px]">{tx.description}</td>
<td className="py-2 text-right font-mono">
{formatCurrency(tx.amount)}
</td>
<td className="py-2 text-xs text-muted-foreground">{tx.reference || '—'}</td>
<td className="py-2">
<select
value={selectedMatch[tx.id] || ''}
onChange={(e) =>
setSelectedMatch((prev) => ({ ...prev, [tx.id]: e.target.value }))
}
className="w-full rounded-md border border-input bg-background px-2 py-1 text-xs"
>
<option value="">Välj verifikation...</option>
{glLines.map((line) => {
const lineAmount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
return (
<option key={line.line_id} value={line.journal_entry_id}>
{line.voucher_series}{line.voucher_number} | {line.entry_date} | {formatCurrency(lineAmount)} | {line.entry_description}
</option>
)
})}
</select>
</td>
<td className="py-2">
<Button
size="sm"
variant="outline"
disabled={!selectedMatch[tx.id] || linkLoading === tx.id}
onClick={() => handleManualLink(tx.id)}
>
<Link2 className="h-3 w-3 mr-1" />
{linkLoading === tx.id ? '...' : 'Matcha'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
{/* Unmatched GL Lines */}
{glLines.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
Omatchade verifikationer <AccountNumber number="1930" /> ({glLines.length})
</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<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">
{line.voucher_series}{line.voucher_number}
</td>
<td className="py-2">{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>
)}
{/* 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>
<tr className="border-b text-left text-muted-foreground">
<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 && !loading && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga transaktioner eller verifikationer att stämma av.
</CardContent>
</Card>
)}
</div>
)
}