'use client' import { useState, useCallback, useEffect } from 'react' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Progress } from '@/components/ui/progress' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info } from 'lucide-react' import { cn } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector' import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import type { BankConnection } from '@/types' // Bank file import components import BankFileUploadStep from '@/components/import/BankFileUploadStep' import BankFilePreviewStep from '@/components/import/BankFilePreviewStep' import BankFileColumnMappingStep from '@/components/import/BankFileColumnMappingStep' import BankFileConfirmStep from '@/components/import/BankFileConfirmStep' import BankFileResultStep from '@/components/import/BankFileResultStep' // SIE import components import SIEUploadStep from '@/components/import/SIEUploadStep' import SIEPreviewStep from '@/components/import/SIEPreviewStep' import AccountMappingStep from '@/components/import/AccountMappingStep' import ImportReviewStep, { type ImportExecuteOptions } from '@/components/import/ImportReviewStep' import ImportResultStep from '@/components/import/ImportResultStep' import { applyMappingOverride } from '@/lib/import/account-mapper' import { getCSVHeaders, getCSVPreview } from '@/lib/import/bank-file/formats/generic-csv' import type { BankFileParseResult, BankFileFormatId, GenericCSVColumnMapping } from '@/lib/import/bank-file/types' import type { IngestResult } from '@/lib/transactions/ingest' import type { ImportWizardStep, ParsedSIEFile, AccountMapping, ImportPreview, ImportResult, ParseIssue, } from '@/lib/import/types' import type { BASAccount } from '@/types' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import dynamic from 'next/dynamic' const MigrationWizard = dynamic( () => import('@/components/extensions/general/ArcimMigrationWorkspace'), { ssr: false, loading: () =>
Laddar migreringsverktyg...
} ) // ============================================================ // Bank File Import Wizard Steps // ============================================================ type BankFileStep = 'upload' | 'preview' | 'column_mapping' | 'confirm' | 'result' const BANK_STEPS: BankFileStep[] = ['upload', 'preview', 'confirm', 'result'] const BANK_STEPS_WITH_MAPPING: BankFileStep[] = ['upload', 'preview', 'column_mapping', 'confirm', 'result'] const BANK_STEP_LABELS: Record = { upload: 'Ladda upp', preview: 'Förhandsgranskning', column_mapping: 'Kolumnmappning', confirm: 'Bekräfta', result: 'Resultat', } function BankFileImportWizard() { const { toast } = useToast() const [bankStep, setBankStep] = useState('upload') const [bankIsLoading, setBankIsLoading] = useState(false) const [bankError, setBankError] = useState(null) // Parse results const [parseResult, setParseResult] = useState(null) const [detectedFormat, setDetectedFormat] = useState(null) const [detectedFormatName, setDetectedFormatName] = useState(null) const [fileHash, setFileHash] = useState('') const [filename, setFilename] = useState('') const [rawFileContent, setRawFileContent] = useState('') // Column mapping for generic CSV const [csvHeaders, setCsvHeaders] = useState([]) const [csvPreview, setCsvPreview] = useState([]) // Import result const [ingestResult, setIngestResult] = useState(null) const steps = parseResult?.format === 'generic_csv' ? BANK_STEPS_WITH_MAPPING : BANK_STEPS const currentStepIndex = steps.indexOf(bankStep) const progress = ((currentStepIndex + 1) / steps.length) * 100 const handleFileSelect = useCallback(async (file: File, formatOverride?: BankFileFormatId) => { setBankError(null) setBankIsLoading(true) try { const formData = new FormData() formData.append('file', file) if (formatOverride) { formData.append('format', formatOverride) } const res = await fetch('/api/import/bank-file/parse', { method: 'POST', body: formData, }) const data = await res.json() if (!res.ok) { if (data.error === 'duplicate') { setBankError(data.message) } else { setBankError(data.error || 'Kunde inte läsa filen') } return } setParseResult(data.data.parse_result) setDetectedFormat(data.data.detected_format) setDetectedFormatName(data.data.detected_format_name) setFileHash(data.data.file_hash) setFilename(data.data.filename) // Store headers for generic CSV mapping if (data.data.headers) { setCsvHeaders(data.data.headers) } // Read raw file content for CSV preview const text = await file.text() setRawFileContent(text) if (data.data.parse_result.format === 'generic_csv') { setCsvHeaders(getCSVHeaders(text)) setCsvPreview(getCSVPreview(text, ',', 6)) } const txCount = data.data.parse_result.transactions.length if (txCount > 0) { setBankStep('preview') toast({ title: 'Fil analyserad', description: `${txCount} transaktioner hittades`, }) } else if (data.data.parse_result.format === 'generic_csv' || !data.data.detected_format) { setBankError('Kunde inte identifiera bankformatet. Välj bank manuellt eller använd "Annan CSV".') } else { // Format detected but no transactions parsed — parser couldn't extract rows setBankError('Filen kunde läsas men inga transaktioner hittades. Kontrollera att filen innehåller transaktionsdata och inte bara rubriker.') } } catch (err) { setBankError(err instanceof Error ? err.message : 'Kunde inte läsa filen') } finally { setBankIsLoading(false) } }, [toast]) const handleColumnMappingConfirm = useCallback(async (mapping: GenericCSVColumnMapping) => { // Re-parse with mapping via the generic CSV parser const { parseGenericCSV } = await import('@/lib/import/bank-file/formats/generic-csv') const result = parseGenericCSV(rawFileContent, mapping) setParseResult(result) setBankStep('confirm') }, [rawFileContent]) const handleExecuteImport = useCallback(async (options: { skip_duplicates: boolean; auto_categorize: boolean }) => { if (!parseResult) return setBankIsLoading(true) setBankError(null) try { const res = await fetch('/api/import/bank-file/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transactions: parseResult.transactions, format: parseResult.format, filename, file_hash: fileHash, ...options, }), }) const data = await res.json() if (!res.ok) { setBankError(data.error || 'Importen misslyckades') return } setIngestResult(data.data) setBankStep('result') toast({ title: 'Import genomförd', description: `${data.data.imported} transaktioner importerades`, }) } catch (err) { setBankError(err instanceof Error ? err.message : 'Importen misslyckades') } finally { setBankIsLoading(false) } }, [parseResult, filename, fileHash, toast]) const handleNewImport = () => { setBankStep('upload') setParseResult(null) setDetectedFormat(null) setDetectedFormatName(null) setFileHash('') setFilename('') setIngestResult(null) setBankError(null) setCsvHeaders([]) setCsvPreview([]) setRawFileContent('') } return (
{/* Progress */}
Steg {currentStepIndex + 1}/{steps.length}: {BANK_STEP_LABELS[bankStep]} {steps.map((s, i) => ( {BANK_STEP_LABELS[s]} ))}
{/* Step content */} {bankStep === 'upload' && ( )} {bankStep === 'preview' && parseResult && ( { if (parseResult.format === 'generic_csv') { setBankStep('column_mapping') } else { setBankStep('confirm') } }} onBack={() => setBankStep('upload')} /> )} {bankStep === 'column_mapping' && ( setBankStep('preview')} /> )} {bankStep === 'confirm' && parseResult && ( { if (parseResult.format === 'generic_csv') { setBankStep('column_mapping') } else { setBankStep('preview') } }} isLoading={bankIsLoading} /> )} {bankStep === 'result' && ingestResult && ( )}
) } // ============================================================ // SIE Import Wizard (unchanged, extracted into component) // ============================================================ const SIE_STEP_LABELS: Record = { upload: 'Ladda upp', preview: 'Förhandsgranskning', mapping: 'Kontomappning', review: 'Bekräfta', result: 'Resultat', } function SIEImportWizard() { const { toast } = useToast() const [step, setStep] = useState('upload') const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [file, setFile] = useState(null) const [, setParsed] = useState(null) const [mappings, setMappings] = useState([]) const [basAccounts, setBasAccounts] = useState([]) const [preview, setPreview] = useState(null) const [issues, setIssues] = useState([]) const [importResult, setImportResult] = useState(null) const [, setSieAccounts] = useState<{ number: string; name: string }[]>([]) const [isCreatingAccounts, setIsCreatingAccounts] = useState(false) // Skip the mapping step when all accounts are already mapped const hasUnmapped = mappings.some((m) => !m.targetAccount) const sieSteps: ImportWizardStep[] = hasUnmapped ? ['upload', 'preview', 'mapping', 'review', 'result'] : ['upload', 'preview', 'review', 'result'] const currentStepIndex = sieSteps.indexOf(step) const progress = ((currentStepIndex + 1) / sieSteps.length) * 100 const handleFileSelect = useCallback(async (selectedFile: File) => { setFile(selectedFile) setError(null) setIsLoading(true) try { const formData = new FormData() formData.append('file', selectedFile) const res = await fetch('/api/import/sie/parse', { method: 'POST', body: formData, }) const data = await res.json() if (!res.ok) { if (data.error === 'duplicate') { setError(data.message) } else if (data.error === 'validation') { setError(`${data.message}: ${data.errors?.join(', ') || 'Unknown validation error'}`) } else { setError(data.error || 'Failed to parse file') } return } setParsed({ header: data.parsed.header, accounts: data.parsed.accounts, openingBalances: [], closingBalances: [], resultBalances: [], vouchers: [], issues: data.parsed.issues, stats: data.parsed.stats, }) setMappings(data.mappings) setPreview(data.preview) setIssues(data.parsed.issues) setSieAccounts(data.parsed.accounts) const accountsRes = await fetch('/api/bookkeeping/accounts') if (accountsRes.ok) { const accountsData = await accountsRes.json() setBasAccounts(accountsData.data || []) } setStep('preview') toast({ title: 'Fil analyserad', description: `${data.parsed.stats.totalAccounts} konton och ${data.parsed.stats.totalVouchers} verifikationer hittades`, }) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to parse file') } finally { setIsLoading(false) } }, [toast]) const handleMappingChange = useCallback((sourceAccount: string, targetAccount: string, targetName: string) => { setMappings((prev) => applyMappingOverride(prev, sourceAccount, targetAccount, targetName)) setPreview((prev) => { if (!prev) return prev const updatedMappings = applyMappingOverride(mappings, sourceAccount, targetAccount, targetName) const mapped = updatedMappings.filter((m) => m.targetAccount).length const unmapped = updatedMappings.length - mapped const lowConfidence = updatedMappings.filter((m) => m.targetAccount && m.confidence < 0.7).length return { ...prev, mappingStatus: { ...prev.mappingStatus, mapped, unmapped, lowConfidence, }, } }) }, [mappings]) const missingAccounts = mappings .filter((m) => !m.targetAccount) .map((m) => ({ number: m.sourceAccount, name: m.sourceName })) const handleCreateAccounts = useCallback(async () => { if (missingAccounts.length === 0) return setIsCreatingAccounts(true) try { const res = await fetch('/api/import/sie/create-accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accounts: missingAccounts }), }) const data = await res.json() if (!res.ok) { toast({ title: 'Kunde inte skapa konton', description: data.error || 'Försök igen.', variant: 'destructive' }) return } toast({ title: 'Konton skapade', description: `${data.created} nya konton har lagts till i din kontoplan` }) if (file) { const formData = new FormData() formData.append('file', file) const parseRes = await fetch('/api/import/sie/parse', { method: 'POST', body: formData }) const parseData = await parseRes.json() if (parseRes.ok) { setMappings(parseData.mappings) setPreview(parseData.preview) const accountsRes = await fetch('/api/bookkeeping/accounts') if (accountsRes.ok) { const accountsData = await accountsRes.json() setBasAccounts(accountsData.data || []) } } } } catch (err) { toast({ title: 'Kunde inte skapa konton', description: err instanceof Error ? err.message : 'Försök igen.', variant: 'destructive' }) } finally { setIsCreatingAccounts(false) } }, [missingAccounts, file, toast]) const handleExecuteImport = useCallback(async (options: ImportExecuteOptions) => { if (!file) { setError('No file selected'); return } setIsLoading(true) setError(null) try { const formData = new FormData() formData.append('file', file) formData.append('mappings', JSON.stringify(mappings)) formData.append('options', JSON.stringify(options)) const res = await fetch('/api/import/sie/execute', { method: 'POST', body: formData }) const data = await res.json() if (!res.ok) { if (data.result) { setImportResult(data.result) } else { setError(data.error || 'Import failed'); return } } else { setImportResult(data.result) } setStep('result') if (data.result?.success) { toast({ title: 'Import genomförd', description: `${data.result.journalEntriesCreated} verifikationer skapades` }) } } catch (err) { setError(err instanceof Error ? err.message : 'Import failed') } finally { setIsLoading(false) } }, [file, mappings, toast]) const goToStep = (targetStep: ImportWizardStep) => { setStep(targetStep); setError(null) } const goBack = () => { const i = sieSteps.indexOf(step); if (i > 0) setStep(sieSteps[i - 1]) } const handleNewImport = () => { setStep('upload'); setFile(null); setParsed(null); setMappings([]) setPreview(null); setIssues([]); setImportResult(null); setError(null) setSieAccounts([]); setIsCreatingAccounts(false) } return (
Steg {currentStepIndex + 1}/{sieSteps.length}: {SIE_STEP_LABELS[step]} {sieSteps.map((s, i) => ( {SIE_STEP_LABELS[s]} ))}
{step === 'upload' && } {step === 'preview' && preview && ( goToStep(hasUnmapped ? 'mapping' : 'review')} onBack={goBack} /> )} {step === 'mapping' && ( goToStep('review')} onBack={goBack} /> )} {step === 'review' && preview && ( )} {step === 'result' && importResult && }
) } // ============================================================ // PSD2 Bank Connection (inline, from Enable Banking extension) // ============================================================ function PSD2ConnectWizard() { const { toast } = useToast() const supabase = createClient() const { dialogProps, confirm } = useDestructiveConfirm() const [bankConnections, setBankConnections] = useState([]) const [syncingConnectionId, setSyncingConnectionId] = useState(null) const [isConnecting, setIsConnecting] = useState(false) const [connectingBankName, setConnectingBankName] = useState(null) const [isLoading, setIsLoading] = useState(true) useEffect(() => { fetchConnections() }, []) async function fetchConnections() { setIsLoading(true) const { data: { user } } = await supabase.auth.getUser() if (!user) return const { data: connections } = await supabase .from('bank_connections') .select('*') .eq('user_id', user.id) .order('created_at', { ascending: false }) setBankConnections(connections || []) setIsLoading(false) } async function handleConnectBank(bank: Bank) { setIsConnecting(true) setConnectingBankName(bank.name) try { const response = await fetch('/api/extensions/ext/enable-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) } window.location.href = data.authorization_url } catch (error) { toast({ title: 'Kunde inte ansluta bank', description: error instanceof Error ? error.message : 'Försök igen.', variant: 'destructive', }) setIsConnecting(false) setConnectingBankName(null) } } async function handleSyncTransactions(connectionId: string) { setSyncingConnectionId(connectionId) try { const response = await fetch('/api/extensions/ext/enable-banking/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_id: connectionId }), }) const data = await response.json() if (!response.ok) { throw new Error(data.error) } toast({ title: 'Synkronisering klar', description: `${data.imported} nya transaktioner importerade`, }) fetchConnections() } catch (error) { toast({ title: 'Synkronisering misslyckades', description: error instanceof Error ? error.message : 'Försök igen.', variant: 'destructive', }) } setSyncingConnectionId(null) } async function handleDisconnectBank(connectionId: string) { const ok = await confirm({ title: 'Koppla bort bank?', description: 'PSD2-samtycket kommer återkallas. Befintliga transaktioner påverkas inte.', confirmLabel: 'Koppla bort', variant: 'warning', }) if (!ok) return try { const response = await fetch('/api/extensions/ext/enable-banking/disconnect', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_id: connectionId }), }) if (!response.ok) { const data = await response.json() throw new Error(data.error || 'Disconnect failed') } toast({ title: 'Bank bortkopplad', description: 'Bankanslutningen och PSD2-samtycket har återkallats', }) fetchConnections() } catch (error) { toast({ title: 'Kunde inte koppla bort bank', description: error instanceof Error ? error.message : 'Försök igen.', variant: 'destructive', }) } } if (isLoading) { return (
) } const activeConnections = bankConnections.filter((c) => c.status === 'active') return (
{/* Connected banks */} {activeConnections.length > 0 && ( Anslutna banker {activeConnections.map((connection) => ( ))} )} {/* Connect new bank */} Anslut din bank Välj din bank nedan för att koppla ditt konto via PSD2. Transaktioner synkas automatiskt varje dag.
) } // ============================================================ // Import Page with Selection Cards // ============================================================ type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'migration' export default function ImportPage() { const [mode, setMode] = useState(null) const [userId, setUserId] = useState('') const [isSandbox, setIsSandbox] = useState(false) // Fetch authenticated user ID and sandbox status useEffect(() => { const supabase = createClient() supabase.auth.getUser().then(({ data: { user } }) => { if (!user) return setUserId(user.id) supabase .from('company_settings') .select('is_sandbox') .eq('user_id', user.id) .single() .then(({ data }) => { if (data?.is_sandbox) setIsSandbox(true) }) }) }, []) // Auto-detect OAuth callback or deep-link mode from query params (disabled in sandbox) useEffect(() => { if (isSandbox) return const params = new URLSearchParams(window.location.search) if (params.get('migration')) { setMode('migration') } else { const modeParam = params.get('mode') if (modeParam && ['psd2', 'bank', 'sie', 'migration'].includes(modeParam)) { setMode(modeParam as ImportMode) } } }, [isSandbox]) // Extensions are active if compiled in — no runtime toggle check needed const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking') const hasMigrationExtension = ENABLED_EXTENSION_IDS.has('arcim-migration') return (
{/* Header */}

Importera

Importera banktransaktioner eller bokföringsdata till ditt företag

{mode === null && ( <> {isSandbox && (

Import är inte tillgängligt i sandlådemiljön. Skapa ett konto för att importera data.

)}
{hasBankingExtension && ( { if (!isSandbox) setMode('psd2') }} onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('psd2') } }} >

Koppla bank

Anslut ditt bankkonto direkt och synka transaktioner automatiskt via PSD2.

Rekommenderat

)} { if (!isSandbox) setMode('bank') }} onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('bank') } }} >

Banktransaktioner

Importera kontoutdrag från din bank. Stöder CSV, OFX och de flesta svenska banker.

CSV, OFX, SEB, Swedbank, Handelsbanken, Nordea m.fl.

{ if (!isSandbox) setMode('sie') }} onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('sie') } }} >

Bokföringsdata (SIE)

Importera verifikationer och kontoplan från ett annat bokföringsprogram via SIE-filer.

SIE4-format (.se, .si)

{hasMigrationExtension === true && ( { if (!isSandbox) setMode('migration') }} onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('migration') } }} >

Migrera från annat system

Flytta bokföring, kunder, leverantörer och fakturor från Fortnox, Visma, Bokio, Björn Lundén eller Briox.

SIE-data, kunder, leverantörer, fakturor

)}
)} {mode !== null && ( )} {mode === 'psd2' && } {mode === 'bank' && } {mode === 'sie' && } {mode === 'migration' && }
) }