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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
838dc6b8b5
commit
885f362a29
@@ -0,0 +1,236 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Upload,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Building2,
|
||||
HelpCircle,
|
||||
} from 'lucide-react'
|
||||
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
|
||||
|
||||
const FORMAT_NAMES: Record<string, string> = {
|
||||
nordea: 'Nordea',
|
||||
seb: 'SEB',
|
||||
swedbank: 'Swedbank',
|
||||
handelsbanken: 'Handelsbanken',
|
||||
generic_csv: 'CSV (manuell mappning)',
|
||||
camt053: 'ISO 20022 camt.053',
|
||||
}
|
||||
|
||||
interface BankFileUploadStepProps {
|
||||
onFileSelect: (file: File, formatOverride?: BankFileFormatId) => void
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
detectedFormat?: string | null
|
||||
detectedFormatName?: string | null
|
||||
}
|
||||
|
||||
export default function BankFileUploadStep({
|
||||
onFileSelect,
|
||||
isLoading,
|
||||
error,
|
||||
detectedFormat,
|
||||
detectedFormatName,
|
||||
}: BankFileUploadStepProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [formatOverride, setFormatOverride] = useState<BankFileFormatId | undefined>(undefined)
|
||||
|
||||
const acceptedExtensions = '.csv,.txt,.xml'
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
|
||||
const files = e.dataTransfer.files
|
||||
if (files.length > 0) {
|
||||
const file = files[0]
|
||||
const ext = file.name.toLowerCase()
|
||||
if (ext.endsWith('.csv') || ext.endsWith('.txt') || ext.endsWith('.xml')) {
|
||||
setSelectedFile(file)
|
||||
onFileSelect(file, formatOverride)
|
||||
}
|
||||
}
|
||||
}, [onFileSelect, formatOverride])
|
||||
|
||||
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) {
|
||||
setSelectedFile(files[0])
|
||||
onFileSelect(files[0], formatOverride)
|
||||
}
|
||||
}, [onFileSelect, formatOverride])
|
||||
|
||||
const handleFormatChange = (value: string) => {
|
||||
const format = value === 'auto' ? undefined : value as BankFileFormatId
|
||||
setFormatOverride(format)
|
||||
if (selectedFile) {
|
||||
onFileSelect(selectedFile, format)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" />
|
||||
Ladda upp kontoutdrag
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Exportera transaktioner som CSV eller XML från din internetbank och ladda upp filen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Format override */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium whitespace-nowrap">Bank/format:</label>
|
||||
<Select
|
||||
value={formatOverride || 'auto'}
|
||||
onValueChange={handleFormatChange}
|
||||
>
|
||||
<SelectTrigger className="w-64">
|
||||
<SelectValue placeholder="Automatisk identifiering" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Automatisk identifiering</SelectItem>
|
||||
<SelectItem value="nordea">Nordea</SelectItem>
|
||||
<SelectItem value="seb">SEB</SelectItem>
|
||||
<SelectItem value="swedbank">Swedbank</SelectItem>
|
||||
<SelectItem value="handelsbanken">Handelsbanken</SelectItem>
|
||||
<SelectItem value="camt053">ISO 20022 camt.053 (XML)</SelectItem>
|
||||
<SelectItem value="generic_csv">Annan CSV (manuell mappning)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-lg p-8 text-center transition-colors
|
||||
${isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
|
||||
${error ? 'border-destructive bg-destructive/5' : ''}
|
||||
${isLoading ? 'pointer-events-none opacity-50' : 'cursor-pointer hover:border-primary/50'}
|
||||
`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => document.getElementById('bank-file-input')?.click()}
|
||||
>
|
||||
<input
|
||||
id="bank-file-input"
|
||||
type="file"
|
||||
accept={acceptedExtensions}
|
||||
className="hidden"
|
||||
onChange={handleFileInput}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<FileText className="mx-auto h-12 w-12 text-muted-foreground animate-pulse" />
|
||||
<p className="text-muted-foreground">Analyserar fil...</p>
|
||||
<Progress value={33} className="w-48 mx-auto" />
|
||||
</div>
|
||||
) : selectedFile && detectedFormat ? (
|
||||
<div className="space-y-4">
|
||||
<CheckCircle className="mx-auto h-12 w-12 text-green-600" />
|
||||
<div>
|
||||
<p className="font-medium">{selectedFile.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(selectedFile.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
<Badge variant="secondary" className="mt-2">
|
||||
<Building2 className="mr-1 h-3 w-3" />
|
||||
{detectedFormatName || FORMAT_NAMES[detectedFormat] || detectedFormat}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Upload className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Dra och släpp bankfil här</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
CSV, TXT eller XML (max 10 MB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg flex gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-destructive">Kunde inte läsa filen</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bank export instructions */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
Så exporterar du från din bank
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm space-y-3">
|
||||
<div>
|
||||
<p className="font-medium">Nordea</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konton → Välj konto → Transaktioner → Exportera (CSV)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">SEB</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konton → Kontoutdrag → Hämta som fil (CSV)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Swedbank</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konton → Transaktioner → Exportera kontoutdrag (CSV)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Handelsbanken</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konton → Transaktioner → Ladda ner (CSV)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user