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
@@ -1,167 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { getDaysUntilExpiry, isConsentExpiringSoon } from '@/lib/banking/enable-banking'
|
||||
import {
|
||||
CreditCard,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import type { BankConnection } from '@/types'
|
||||
|
||||
interface BankConnectionStatusProps {
|
||||
connection: BankConnection
|
||||
onSync: (connectionId: string) => void
|
||||
onDisconnect: (connectionId: string) => void
|
||||
isSyncing?: boolean
|
||||
}
|
||||
|
||||
export function BankConnectionStatus({
|
||||
connection,
|
||||
onSync,
|
||||
onDisconnect,
|
||||
isSyncing = false,
|
||||
}: BankConnectionStatusProps) {
|
||||
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
|
||||
const isExpiring = isConsentExpiringSoon(connection.consent_expires)
|
||||
|
||||
const statusConfig = {
|
||||
active: {
|
||||
icon: CheckCircle,
|
||||
color: 'text-green-600',
|
||||
label: 'Aktiv',
|
||||
variant: 'success' as const,
|
||||
},
|
||||
pending: {
|
||||
icon: Loader2,
|
||||
color: 'text-yellow-600',
|
||||
label: 'Väntar',
|
||||
variant: 'warning' as const,
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
color: 'text-red-600',
|
||||
label: 'Fel',
|
||||
variant: 'destructive' as const,
|
||||
},
|
||||
revoked: {
|
||||
icon: XCircle,
|
||||
color: 'text-gray-600',
|
||||
label: 'Bortkopplad',
|
||||
variant: 'secondary' as const,
|
||||
},
|
||||
}
|
||||
|
||||
const status = statusConfig[connection.status as keyof typeof statusConfig] || statusConfig.error
|
||||
const StatusIcon = status.icon
|
||||
|
||||
// Parse accounts from connection
|
||||
const accounts = (connection.accounts_data as Array<{
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}>) || []
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{connection.bank_name}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<StatusIcon className={`h-3 w-3 ${status.color}`} />
|
||||
<span>{status.label}</span>
|
||||
{connection.last_synced_at && (
|
||||
<>
|
||||
<span>-</span>
|
||||
<span>Synkad {formatDate(connection.last_synced_at)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{connection.status === 'active' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSync(connection.id)}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDisconnect(connection.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Consent expiry warning */}
|
||||
{isExpiring && daysUntilExpiry !== null && (
|
||||
<div className="flex items-center gap-2 p-3 bg-warning/10 rounded-lg">
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
<span className="text-sm">
|
||||
Samtycket går ut om {daysUntilExpiry} {daysUntilExpiry === 1 ? 'dag' : 'dagar'}.
|
||||
Förnya genom att ansluta igen.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accounts list */}
|
||||
{accounts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Konton</p>
|
||||
<div className="space-y-2">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.uid}
|
||||
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{account.name || account.iban || 'Okänt konto'}
|
||||
</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{account.balance !== undefined && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: account.currency,
|
||||
}).format(account.balance)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import Script from 'next/script'
|
||||
|
||||
export interface Bank {
|
||||
name: string
|
||||
country: string
|
||||
logo?: string
|
||||
bic?: string
|
||||
}
|
||||
|
||||
interface BankSelectorProps {
|
||||
onSelect: (bank: Bank) => void
|
||||
selectedBank?: Bank | null
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
country?: string
|
||||
sandbox?: boolean
|
||||
}
|
||||
|
||||
export function BankSelector({
|
||||
onSelect,
|
||||
isLoading = false,
|
||||
className,
|
||||
country = 'SE',
|
||||
sandbox = true, // Default to sandbox for development
|
||||
}: BankSelectorProps) {
|
||||
const widgetRef = useRef<HTMLElement | null>(null)
|
||||
const [scriptLoaded, setScriptLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!scriptLoaded) return
|
||||
|
||||
const widget = widgetRef.current
|
||||
if (!widget) return
|
||||
|
||||
const handleSelected = (e: Event) => {
|
||||
const customEvent = e as CustomEvent
|
||||
const detail = customEvent.detail as {
|
||||
name: string
|
||||
country: string
|
||||
psuType: string
|
||||
sandbox: boolean
|
||||
}
|
||||
|
||||
onSelect({
|
||||
name: detail.name,
|
||||
country: detail.country,
|
||||
})
|
||||
}
|
||||
|
||||
widget.addEventListener('selected', handleSelected)
|
||||
|
||||
return () => {
|
||||
widget.removeEventListener('selected', handleSelected)
|
||||
}
|
||||
}, [onSelect, scriptLoaded])
|
||||
|
||||
const handleScriptLoad = () => {
|
||||
setScriptLoaded(true)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center p-8', className)}>
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Ansluter...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<Script
|
||||
src="https://tilisy.enablebanking.com/lib/widgets.umd.min.js"
|
||||
onLoad={handleScriptLoad}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<link
|
||||
href="https://tilisy.enablebanking.com/lib/widgets.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
{/* @ts-expect-error - Enable Banking custom element */}
|
||||
<enablebanking-aspsp-list
|
||||
ref={widgetRef}
|
||||
country={country}
|
||||
psu-type="business"
|
||||
service="AIS"
|
||||
{...(sandbox ? { sandbox: true } : {})}
|
||||
style={{ minHeight: '200px' }}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{sandbox ? 'Sandbox-läge: Använd testbanker för utveckling' : 'Välj din bank för att fortsätta'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { BankSelector, type Bank } from './BankSelector'
|
||||
export { BankConnectionStatus } from './BankConnectionStatus'
|
||||
@@ -0,0 +1,301 @@
|
||||
'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>
|
||||
Så 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import { useState } 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 { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Play,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
Link2,
|
||||
Calendar,
|
||||
} from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { BankFileParseResult } from '@/lib/import/bank-file/types'
|
||||
|
||||
interface BankFileConfirmStepProps {
|
||||
parseResult: BankFileParseResult
|
||||
onExecute: (options: { skip_duplicates: boolean; auto_categorize: boolean }) => void
|
||||
onBack: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export default function BankFileConfirmStep({
|
||||
parseResult,
|
||||
onExecute,
|
||||
onBack,
|
||||
isLoading,
|
||||
}: BankFileConfirmStepProps) {
|
||||
const [skipDuplicates, setSkipDuplicates] = useState(true)
|
||||
const [autoCategorize, setAutoCategorize] = useState(true)
|
||||
|
||||
const { transactions, stats, date_from, date_to, format_name } = parseResult
|
||||
const refsCount = transactions.filter((t) => t.reference).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bekräfta import</CardTitle>
|
||||
<CardDescription>
|
||||
Granska sammanfattningen och importera transaktionerna.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-xs">Transaktioner</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold">{stats.parsed_rows}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-xs">Period</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium">
|
||||
{date_from} – {date_to}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-600 mb-1">
|
||||
<span className="text-xs">Inkomster</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-green-600">
|
||||
{formatCurrency(stats.total_income)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-red-600 mb-1">
|
||||
<span className="text-xs">Utgifter</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-red-600">
|
||||
{formatCurrency(stats.total_expenses)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional info */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">Format: {format_name}</Badge>
|
||||
{refsCount > 0 && (
|
||||
<Badge variant="outline" className="text-blue-600 border-blue-300">
|
||||
<Link2 className="mr-1 h-3 w-3" />
|
||||
{refsCount} med OCR/referens
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
<h3 className="text-sm font-medium">Importinställningar</h3>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="skip-duplicates"
|
||||
checked={skipDuplicates}
|
||||
onCheckedChange={(checked) => setSkipDuplicates(checked === true)}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="skip-duplicates" className="text-sm font-medium cursor-pointer">
|
||||
Hoppa över dubletter
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Transaktioner som redan finns i systemet importeras inte igen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="auto-categorize"
|
||||
checked={autoCategorize}
|
||||
onCheckedChange={(checked) => setAutoCategorize(checked === true)}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="auto-categorize" className="text-sm font-medium cursor-pointer">
|
||||
Auto-kategorisera kända transaktioner
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skapar automatiskt bokföringsposter för transaktioner med hög konfidens
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning note */}
|
||||
<div className="flex gap-3 p-3 bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Importerade transaktioner som inte automatiskt kategoriseras visas som
|
||||
"okategoriserade" på transaktionssidan. Du kan kategorisera dem manuellt
|
||||
efteråt.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={onBack} disabled={isLoading}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onExecute({
|
||||
skip_duplicates: skipDuplicates,
|
||||
auto_categorize: autoCategorize,
|
||||
})}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Importerar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Importera {stats.parsed_rows} transaktioner
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { BankFileParseResult } from '@/lib/import/bank-file/types'
|
||||
|
||||
interface BankFilePreviewStepProps {
|
||||
parseResult: BankFileParseResult
|
||||
existingTransactionCount: number
|
||||
onContinue: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export default function BankFilePreviewStep({
|
||||
parseResult,
|
||||
existingTransactionCount,
|
||||
onContinue,
|
||||
onBack,
|
||||
}: BankFilePreviewStepProps) {
|
||||
const { transactions, stats, issues, date_from, date_to, format_name } = parseResult
|
||||
const hasIssues = issues.filter((i) => i.severity === 'error').length > 0
|
||||
const warnings = issues.filter((i) => i.severity === 'warning')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">Transaktioner</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{stats.parsed_rows}</p>
|
||||
{stats.skipped_rows > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{stats.skipped_rows} rader hoppades över
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-sm">Period</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium">
|
||||
{date_from || '–'} till {date_to || '–'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-green-600 mb-1">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
<span className="text-sm">Inkomster</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-green-600">
|
||||
{formatCurrency(stats.total_income)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-red-600 mb-1">
|
||||
<TrendingDown className="h-4 w-4" />
|
||||
<span className="text-sm">Utgifter</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-red-600">
|
||||
{formatCurrency(stats.total_expenses)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Format and duplicate info */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="secondary">
|
||||
Format: {format_name}
|
||||
</Badge>
|
||||
{existingTransactionCount > 0 && (
|
||||
<Badge variant="outline" className="text-yellow-600 border-yellow-300">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
{existingTransactionCount} befintliga transaktioner i samma period
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<Card className="border-yellow-300">
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2 text-yellow-600">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{warnings.length} varning{warnings.length !== 1 ? 'ar' : ''}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{warnings.slice(0, 10).map((issue, i) => (
|
||||
<p key={i} className="text-xs text-muted-foreground">
|
||||
Rad {issue.row}: {issue.message}
|
||||
</p>
|
||||
))}
|
||||
{warnings.length > 10 && (
|
||||
<p className="text-xs text-muted-foreground font-medium">
|
||||
...och {warnings.length - 10} till
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Transaction preview table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Transaktioner</CardTitle>
|
||||
<CardDescription>
|
||||
Förhandsgranskning av de {Math.min(transactions.length, 50)} första transaktionerna
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border max-h-96 overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-28">Datum</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="text-right w-32">Belopp</TableHead>
|
||||
{transactions.some((t) => t.balance != null) && (
|
||||
<TableHead className="text-right w-32">Saldo</TableHead>
|
||||
)}
|
||||
{transactions.some((t) => t.reference) && (
|
||||
<TableHead className="w-32">Referens</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.slice(0, 50).map((tx, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-mono text-sm">{tx.date}</TableCell>
|
||||
<TableCell className="text-sm">{tx.description}</TableCell>
|
||||
<TableCell
|
||||
className={`text-right font-mono text-sm ${
|
||||
tx.amount >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(tx.amount)}
|
||||
</TableCell>
|
||||
{transactions.some((t) => t.balance != null) && (
|
||||
<TableCell className="text-right font-mono text-sm text-muted-foreground">
|
||||
{tx.balance != null ? formatCurrency(tx.balance) : '–'}
|
||||
</TableCell>
|
||||
)}
|
||||
{transactions.some((t) => t.reference) && (
|
||||
<TableCell className="text-sm">
|
||||
{tx.reference ? (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{tx.reference}
|
||||
</Badge>
|
||||
) : (
|
||||
'–'
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{transactions.length > 50 && (
|
||||
<p className="text-sm text-muted-foreground mt-2 text-center">
|
||||
Visar 50 av {transactions.length} transaktioner
|
||||
</p>
|
||||
)}
|
||||
</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={onContinue} disabled={hasIssues || transactions.length === 0}>
|
||||
Fortsätt
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
FileText,
|
||||
Link2,
|
||||
Sparkles,
|
||||
Copy,
|
||||
ArrowRight,
|
||||
RotateCcw,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import type { IngestResult } from '@/lib/transactions/ingest'
|
||||
|
||||
interface BankFileResultStepProps {
|
||||
result: IngestResult
|
||||
onNewImport: () => void
|
||||
}
|
||||
|
||||
export default function BankFileResultStep({
|
||||
result,
|
||||
onNewImport,
|
||||
}: BankFileResultStepProps) {
|
||||
const isSuccess = result.imported > 0 || result.duplicates > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status header */}
|
||||
<Card className={isSuccess ? 'border-green-300' : 'border-destructive/50'}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{isSuccess ? (
|
||||
<>
|
||||
<CheckCircle className="h-6 w-6 text-green-600" />
|
||||
Import genomförd
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-6 w-6 text-destructive" />
|
||||
Import misslyckades
|
||||
</>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isSuccess
|
||||
? `${result.imported} transaktioner importerades framgångsrikt.`
|
||||
: `${result.errors} fel uppstod under importen.`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">Importerade</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-green-600">{result.imported}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Copy className="h-4 w-4" />
|
||||
<span className="text-sm">Dubletter</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-muted-foreground">{result.duplicates}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span className="text-sm">Auto-kategoriserade</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{result.auto_categorized}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<span className="text-sm">Fakturamatchade</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{result.auto_matched_invoices}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Next steps */}
|
||||
{isSuccess && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Nästa steg</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium flex-shrink-0">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Granska okategoriserade transaktioner</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.imported - result.auto_categorized > 0
|
||||
? `${result.imported - result.auto_categorized} transaktioner behöver kategoriseras manuellt.`
|
||||
: 'Alla transaktioner kategoriserades automatiskt.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium flex-shrink-0">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Bekräfta fakturamatchningar</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.auto_matched_invoices > 0
|
||||
? `${result.auto_matched_invoices} transaktioner matchades mot fakturor. Bekräfta dessa på transaktionssidan.`
|
||||
: 'Inga automatiska fakturamatchningar hittades.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium flex-shrink-0">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Importera fler kontoutdrag</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Importera löpande kontoutdrag för att hålla bokföringen uppdaterad.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={onNewImport}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Ny import
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
{isSuccess && (
|
||||
<Button asChild>
|
||||
<Link href="/transactions">
|
||||
Visa transaktioner
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -82,8 +82,8 @@ export default function NewUserChecklist({
|
||||
},
|
||||
{
|
||||
id: 'bank',
|
||||
label: 'Koppla bank',
|
||||
description: 'Se transaktioner automatiskt (valfritt)',
|
||||
label: 'Importera transaktioner',
|
||||
description: 'Importera kontoutdrag från din bank',
|
||||
href: '/import',
|
||||
icon: Building2,
|
||||
completed: hasBankConnected,
|
||||
|
||||
@@ -9,8 +9,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { Loader2, ArrowLeft, Landmark, SkipForward, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { BankSelector, type Bank } from '@/components/banking/BankSelector'
|
||||
import { Loader2, ArrowLeft, Landmark, SkipForward } from 'lucide-react'
|
||||
|
||||
const manualBankSchema = z.object({
|
||||
bank_name: z.string().optional(),
|
||||
@@ -37,10 +36,6 @@ export default function Step6ConnectBank({
|
||||
onComplete,
|
||||
isSaving,
|
||||
}: Step6Props) {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showManual, setShowManual] = useState(false)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -55,33 +50,6 @@ export default function Step6ConnectBank({
|
||||
},
|
||||
})
|
||||
|
||||
const handleBankSelect = async (bank: Bank) => {
|
||||
setIsConnecting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/banking/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
aspsp_name: bank.name,
|
||||
aspsp_country: bank.country,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Kunde inte ansluta bank')
|
||||
}
|
||||
|
||||
window.location.href = data.authorization_url
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ett fel uppstod')
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onManualSubmit = (data: ManualBankData) => {
|
||||
onComplete(data)
|
||||
}
|
||||
@@ -89,9 +57,10 @@ export default function Step6ConnectBank({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Anslut din bank</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Bankuppgifter</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Koppla din bank för att automatiskt importera transaktioner via PSD2.
|
||||
Ange dina bankuppgifter så visas de på dina fakturor.
|
||||
Du kan importera transaktioner från din bank efteråt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -99,159 +68,125 @@ export default function Step6ConnectBank({
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Landmark className="h-5 w-5" />
|
||||
Välj din bank
|
||||
Bankuppgifter för fakturor
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Vi använder säker bankintegration (PSD2) för att hämta dina transaktioner.
|
||||
Vi kan aldrig flytta pengar eller göra ändringar.
|
||||
Dessa uppgifter visas på dina fakturor så att kunder kan betala dig.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<BankSelector
|
||||
onSelect={handleBankSelect}
|
||||
isLoading={isConnecting}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 text-destructive rounded-lg text-sm">
|
||||
{error}
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onManualSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_name">Bank</Label>
|
||||
<Input
|
||||
id="bank_name"
|
||||
placeholder="t.ex. Nordea, SEB, Swedbank"
|
||||
{...register('bank_name')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<h4 className="font-medium mb-2">Säker anslutning</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Krypterad anslutning via PSD2</li>
|
||||
<li>• Vi kan endast läsa transaktioner</li>
|
||||
<li>• Du kan koppla bort när som helst</li>
|
||||
<li>• Samtycke gäller i 90 dagar</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Collapsible manual bank details */}
|
||||
<div className="border-t pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowManual(!showManual)}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full"
|
||||
>
|
||||
{showManual ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
<span>Ange bankuppgifter manuellt</span>
|
||||
</button>
|
||||
|
||||
{showManual && (
|
||||
<form onSubmit={handleSubmit(onManualSubmit)} className="space-y-4 mt-4 animate-fade-in">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dessa uppgifter visas på dina fakturor så att kunder kan betala dig.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är clearingnummer?</p>
|
||||
<p>De första 4-5 siffrorna i ditt kontonummer som identifierar din bank.</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>Nordea: 3300</li>
|
||||
<li>SEB: 5000</li>
|
||||
<li>Swedbank: 8XXX</li>
|
||||
<li>Handelsbanken: 6XXX</li>
|
||||
<li>Avanza: 9550/9551</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
side="top"
|
||||
>
|
||||
<Label htmlFor="clearing_number">Clearingnummer</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
placeholder="XXXX"
|
||||
{...register('clearing_number')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
placeholder="XXX XXX XXX"
|
||||
{...register('account_number')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="font-medium mb-4">Internationella betalningar (valfritt)</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_name">Bank</Label>
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är IBAN?</p>
|
||||
<p>Internationellt bankkontonummer. Svenska IBAN börjar med SE och har 24 tecken totalt.</p>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label htmlFor="iban">IBAN</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="bank_name"
|
||||
placeholder="t.ex. Nordea, SEB, Swedbank"
|
||||
{...register('bank_name')}
|
||||
id="iban"
|
||||
placeholder="SE00 0000 0000 0000 0000 0000"
|
||||
{...register('iban')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är clearingnummer?</p>
|
||||
<p>De första 4-5 siffrorna i ditt kontonummer som identifierar din bank.</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>Nordea: 3300</li>
|
||||
<li>SEB: 5000</li>
|
||||
<li>Swedbank: 8XXX</li>
|
||||
<li>Handelsbanken: 6XXX</li>
|
||||
<li>Avanza: 9550/9551</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
side="top"
|
||||
>
|
||||
<Label htmlFor="clearing_number">Clearingnummer</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
placeholder="XXXX"
|
||||
{...register('clearing_number')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
placeholder="XXX XXX XXX"
|
||||
{...register('account_number')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är BIC/SWIFT?</p>
|
||||
<p>Bankens internationella id-kod. Används tillsammans med IBAN för utlandsbetalningar.</p>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label htmlFor="bic">BIC/SWIFT</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="bic"
|
||||
placeholder="XXXXSESS"
|
||||
{...register('bic')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="font-medium mb-4">Internationella betalningar (valfritt)</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är IBAN?</p>
|
||||
<p>Internationellt bankkontonummer. Svenska IBAN börjar med SE och har 24 tecken totalt.</p>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label htmlFor="iban">IBAN</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="iban"
|
||||
placeholder="SE00 0000 0000 0000 0000 0000"
|
||||
{...register('iban')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är BIC/SWIFT?</p>
|
||||
<p>Bankens internationella id-kod. Används tillsammans med IBAN för utlandsbetalningar.</p>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label htmlFor="bic">BIC/SWIFT</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="bic"
|
||||
placeholder="XXXXSESS"
|
||||
{...register('bic')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Du kan importera kontoutdrag (CSV, XML) från din bank under <strong>Import</strong>-sidan
|
||||
efter att du slutfört registreringen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara bankuppgifter och slutför'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara och slutför'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
@@ -260,7 +195,6 @@ export default function Step6ConnectBank({
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onSkip}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
<SkipForward className="mr-2 h-4 w-4" />
|
||||
Hoppa över
|
||||
|
||||
@@ -117,8 +117,8 @@ export function EmptyTransactions() {
|
||||
<EmptyState
|
||||
icon={ArrowLeftRight}
|
||||
title="Inga transaktioner"
|
||||
description="Koppla din bank för att automatiskt importera transaktioner, eller lägg till dem manuellt."
|
||||
actionLabel="Koppla bank"
|
||||
description="Importera kontoutdrag från din bank eller lägg till transaktioner manuellt."
|
||||
actionLabel="Importera transaktioner"
|
||||
actionHref="/import"
|
||||
secondaryActionLabel="Lägg till manuellt"
|
||||
secondaryActionHref="/transactions/new"
|
||||
@@ -152,9 +152,9 @@ export function NoBankConnected() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Building2}
|
||||
title="Ingen bank kopplad"
|
||||
description="Koppla din bank för att automatiskt importera transaktioner och få bättre koll på ekonomin."
|
||||
actionLabel="Koppla bank"
|
||||
title="Inga transaktioner importerade"
|
||||
description="Importera kontoutdrag från din bank för att automatiskt bokföra och få bättre koll på ekonomin."
|
||||
actionLabel="Importera transaktioner"
|
||||
actionHref="/import"
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user