Files
accounted/components/import/BankFileColumnMappingStep.tsx
T
Jakob Wennberg 885f362a29 feat: add bank file import as core, move Enable Banking to extension
Replace PSD2 bank integration as the default with file-based bank
import (CSV/XML), which better suits Swedish sole traders and small
companies. Enable Banking is now an opt-in extension.

- Phase 1: Extract generic transaction ingestion service (ingest.ts)
  with dedup, auto-categorization, and OCR-based invoice matching
- Phase 2: Bank file parser library supporting Nordea, SEB, Swedbank,
  Handelsbanken CSV formats and ISO 20022 camt.053 XML
- Phase 3: Database migration adding import_source, reference columns
  and bank_file_imports tracking table
- Phase 4: Import wizard UI (5-step flow) and API routes for parse/execute
- Phase 5: Move Enable Banking to extensions/enable-banking/ with
  commented-out loader entry for opt-in activation
- Phase 6: 104 new tests (ingestion + all parser formats), fixing
  Nordea detection overlap and camt.053 XML tag collision bugs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:21:28 +01:00

302 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ArrowLeft, ArrowRight, Columns3 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import type { GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
interface BankFileColumnMappingStepProps {
headers: string[]
previewRows: string[][]
onConfirm: (mapping: GenericCSVColumnMapping) => void
onBack: () => void
}
export default function BankFileColumnMappingStep({
headers,
previewRows,
onConfirm,
onBack,
}: BankFileColumnMappingStepProps) {
const [dateCol, setDateCol] = useState<number>(-1)
const [descCol, setDescCol] = useState<number>(-1)
const [amountCol, setAmountCol] = useState<number>(-1)
const [referenceCol, setReferenceCol] = useState<number>(-1)
const [counterpartyCol, setCounterpartyCol] = useState<number>(-1)
const [balanceCol, setBalanceCol] = useState<number>(-1)
const [delimiter, setDelimiter] = useState<string>(',')
const [decimalSep, setDecimalSep] = useState<',' | '.'>(',')
const isValid = dateCol >= 0 && descCol >= 0 && amountCol >= 0
const handleConfirm = () => {
const mapping: GenericCSVColumnMapping = {
date: dateCol,
description: descCol,
amount: amountCol,
...(referenceCol >= 0 && { reference: referenceCol }),
...(counterpartyCol >= 0 && { counterparty: counterpartyCol }),
...(balanceCol >= 0 && { balance: balanceCol }),
delimiter,
decimal_separator: decimalSep,
skip_rows: 1, // Skip header
date_format: 'YYYY-MM-DD',
}
onConfirm(mapping)
}
const columnOptions = headers.map((h, i) => ({ label: `${i + 1}: ${h}`, value: i }))
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Columns3 className="h-5 w-5" />
Kolumnmappning
</CardTitle>
<CardDescription>
Vi kunde inte identifiera bankformatet automatiskt. Mappa kolumnerna manuellt.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Delimiter and decimal settings */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Avgränsare</Label>
<Select value={delimiter} onValueChange={setDelimiter}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value=",">Komma (,)</SelectItem>
<SelectItem value=";">Semikolon (;)</SelectItem>
<SelectItem value="\t">Tab</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Decimalavgränsare</Label>
<Select value={decimalSep} onValueChange={(v) => setDecimalSep(v as ',' | '.')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value=",">Komma (1 234,56)</SelectItem>
<SelectItem value=".">Punkt (1234.56)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Required column mappings */}
<div>
<h3 className="text-sm font-medium mb-3">Obligatoriska kolumner</h3>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Datum *</Label>
<Select
value={dateCol >= 0 ? dateCol.toString() : ''}
onValueChange={(v) => setDateCol(parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value.toString()}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Beskrivning *</Label>
<Select
value={descCol >= 0 ? descCol.toString() : ''}
onValueChange={(v) => setDescCol(parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value.toString()}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Belopp *</Label>
<Select
value={amountCol >= 0 ? amountCol.toString() : ''}
onValueChange={(v) => setAmountCol(parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj kolumn" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value.toString()}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Optional column mappings */}
<div>
<h3 className="text-sm font-medium mb-3">Valfria kolumner</h3>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Referens/OCR</Label>
<Select
value={referenceCol >= 0 ? referenceCol.toString() : 'none'}
onValueChange={(v) => setReferenceCol(v === 'none' ? -1 : parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Ingen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Ingen</SelectItem>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value.toString()}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Motpart</Label>
<Select
value={counterpartyCol >= 0 ? counterpartyCol.toString() : 'none'}
onValueChange={(v) => setCounterpartyCol(v === 'none' ? -1 : parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Ingen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Ingen</SelectItem>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value.toString()}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Saldo</Label>
<Select
value={balanceCol >= 0 ? balanceCol.toString() : 'none'}
onValueChange={(v) => setBalanceCol(v === 'none' ? -1 : parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Ingen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Ingen</SelectItem>
{columnOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value.toString()}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Live preview */}
{isValid && previewRows.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Förhandsgranskning</CardTitle>
<CardDescription>
tolkas dina data med den valda mappningen
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border max-h-64 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead className="text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{previewRows.slice(1, 6).map((row, i) => {
const amountStr = row[amountCol] || '0'
const amount = decimalSep === ','
? parseFloat(amountStr.replace(/\s/g, '').replace(',', '.'))
: parseFloat(amountStr.replace(/\s/g, ''))
return (
<TableRow key={i}>
<TableCell className="font-mono text-sm">{row[dateCol] || ''}</TableCell>
<TableCell className="text-sm">{row[descCol] || ''}</TableCell>
<TableCell
className={`text-right font-mono text-sm ${
!isNaN(amount) && amount >= 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{!isNaN(amount) ? formatCurrency(amount) : amountStr}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* Navigation */}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button onClick={handleConfirm} disabled={!isValid}>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)
}