Files
accounted/components/import/BankFileUploadStep.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

237 lines
8.2 KiB
TypeScript

'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" />
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>
)
}