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:
Jakob Wennberg
2026-02-20 14:21:28 +01:00
co-authored by Claude Opus 4.6
parent 838dc6b8b5
commit 885f362a29
60 changed files with 8232 additions and 853 deletions
-167
View File
@@ -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>
)
}
-102
View File
@@ -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>
)
}
-2
View File
@@ -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>
)
}
+178
View File
@@ -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
&quot;okategoriserade&quot; 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>
)
}
+216
View File
@@ -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>
)
}
+166
View File
@@ -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>
)
}
+236
View File
@@ -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>
)
}
+2 -2
View File
@@ -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,
+103 -169
View File
@@ -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
+5 -5
View File
@@ -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"
/>
)