'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, buttonVariants } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' import { useToast } from '@/components/ui/use-toast' import { cn } from '@/lib/utils' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import Link from 'next/link' import { FallbackPrompt } from '@/components/ui/fallback-prompt' import { ArrowLeft, ArrowRight, Loader2, AlertCircle, CheckCircle, Building2, Users, Truck, FileText, Database, ExternalLink, Info, RotateCcw, AlertTriangle, ChevronDown, ChevronRight, Calendar, XCircle, BookOpen, } from 'lucide-react' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden' const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token' }[] = [ { id: 'fortnox', name: 'Fortnox', authType: 'oauth' }, { id: 'visma', name: 'Visma eEkonomi', authType: 'oauth' }, { id: 'bokio', name: 'Bokio', authType: 'token' }, { id: 'bjornlunden', name: 'Björn Lundén', authType: 'token' }, { id: 'briox', name: 'Briox', authType: 'token' }, ] interface SkipReasons { duplicate?: number inactive?: number failed?: number noMatch?: number } interface MigrationResults { companyInfo?: { imported: boolean } customers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons } suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons } salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons } supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons } } import AccountMappingStep from '@/components/import/AccountMappingStep' import type { AccountMapping, ImportResult, ParsedSIEFile } from '@/lib/import/types' import type { BASAccount } from '@/types' // ── Types ──────────────────────────────────────────────────────── type WizardStep = 'provider' | 'connect' | 'preview' | 'mapping' | 'options' | 'migrating' | 'result' const STEPS: WizardStep[] = ['provider', 'connect', 'preview', 'mapping', 'options', 'migrating', 'result'] const STEP_LABELS: Record = { provider: 'Välj system', connect: 'Anslut', preview: 'Förhandsgranskning', mapping: 'Kontomappning', options: 'Alternativ', migrating: 'Migrerar', result: 'Resultat', } const MONTH_NAMES = [ 'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni', 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December', ] interface MigrationOptions { importCompanyInfo: boolean importSIEData: boolean importCustomers: boolean importSuppliers: boolean importSalesInvoices: boolean importSupplierInvoices: boolean voucherSeries: string } const DEFAULT_OPTIONS: MigrationOptions = { importCompanyInfo: true, importSIEData: true, importCustomers: true, importSuppliers: true, importSalesInvoices: true, importSupplierInvoices: true, voucherSeries: 'B', } interface PreviewData { consent: { id: string provider: ArcimProvider status: number companyName?: string } companyInfo: { company_name: string | null org_number: string | null vat_number: string | null fiscal_year_start_month: number address_line1: string | null postal_code: string | null city: string | null phone: string | null email: string | null } | null sieAvailable: boolean sieStats: { accountCount: number transactionCount: number fiscalYears: number[] } | null hasSieData: boolean } interface SIEFileStatus { fiscalYear: number alreadyImported: boolean importedAt: string | null } interface SIEData { parsed: ParsedSIEFile mappings: AccountMapping[] mappingStats: { total: number; mapped: number; unmapped: number } rawContent: string[] fileStatuses: SIEFileStatus[] allImported: boolean newFileCount: number basAccounts: BASAccount[] } // ── Provider selection step ────────────────────────────────────── interface ConnectionStatus { consents: { id: string provider: ArcimProvider status: number companyName?: string createdAt?: string }[] sieImports: { id: string filename: string status: string accounts_count: number | null transactions_count: number | null company_name: string | null fiscal_year_start: string | null fiscal_year_end: string | null imported_at: string | null created_at: string }[] entityCounts: { customers: number suppliers: number invoices: number } } const COMING_SOON_PROVIDERS = new Set( process.env.NODE_ENV === 'development' ? ['bjornlunden', 'briox'] : ['visma', 'bjornlunden', 'briox'] ) const PROVIDER_LOGOS: Record = { fortnox: '/logos/fortnox.svg', visma: '/logos/visma.jpeg', bokio: '/logos/bokio.png', bjornlunden: '/logos/bjornlunden.png', briox: '/logos/Briox_logo.png', } function ProviderStep({ onSelect, onResync, onDisconnect, connectionStatus, isLoadingStatus, }: { onSelect: (provider: ArcimProvider) => void onResync: (provider: ArcimProvider, consentId: string) => void onDisconnect: (consentId: string) => void connectionStatus: ConnectionStatus | null isLoadingStatus: boolean }) { const activeConsents = connectionStatus?.consents.filter(c => c.status === 1) ?? [] const hasSieImport = (connectionStatus?.sieImports.filter(i => i.status === 'completed').length ?? 0) > 0 const allFortnox = activeConsents.length > 0 && activeConsents.every(c => c.provider === 'fortnox') const showSieRequiredBanner = !isLoadingStatus && !hasSieImport && !allFortnox return (
{/* SIE-required banner (not relevant for Fortnox — it fetches SIE itself) */} {showSieRequiredBanner && (

SIE-import krävs först

Bokio, Visma, Björn Lundén och Briox hämtar endast kunder, leverantörer och fakturor via API:et. Bokföringsdata (kontoplan, verifikationer och balanser) måste importeras via SIE-fil först. Gäller inte Fortnox — där hämtar vi SIE direkt via API:et.

Ladda upp SIE-fil
)} {/* Existing connections */} {activeConsents.length > 0 && ( Aktiva anslutningar Du har redan anslutna leverantörer. Synka igen för att hämta ny data. {activeConsents.map((consent) => { const providerInfo = ARCIM_PROVIDERS.find(p => p.id === consent.provider) const completedImports = connectionStatus?.sieImports.filter(i => i.status === 'completed') ?? [] const lastImport = completedImports[0] return (
{providerInfo?.name

{providerInfo?.name ?? consent.provider}

Ansluten
{consent.companyName && (

{consent.companyName}

)} {lastImport ? (

Senaste import: {new Date(lastImport.imported_at ?? lastImport.created_at).toLocaleDateString('sv-SE')} {lastImport.transactions_count != null && ` — ${lastImport.transactions_count} verifikationer`}

) : (

Ansluten {consent.createdAt ? new Date(consent.createdAt).toLocaleDateString('sv-SE') : ''}

)} {(connectionStatus?.entityCounts.customers ?? 0) > 0 && (

{connectionStatus?.entityCounts.customers} kunder, {connectionStatus?.entityCounts.suppliers} leverantörer, {connectionStatus?.entityCounts.invoices} fakturor

)}
) })}
)} {/* Provider selection */} {activeConsents.length > 0 ? 'Anslut ytterligare system' : 'Välj ditt nuvarande bokföringssystem'} Vi hämtar bokföringsdata via SIE och kunder, leverantörer och fakturor via API:et. {isLoadingStatus ? (
) : (
{ARCIM_PROVIDERS.map((provider) => { const comingSoon = COMING_SOON_PROVIDERS.has(provider.id) const alreadyConnected = activeConsents.some(c => c.provider === provider.id) return ( ) })}
)}
) } // ── Connect step (OAuth redirect or token input) ──────────────── function ConnectStep({ provider, authType, isLoading, error, authUrl, consentId, onTokenSubmit, onBack, }: { provider: ArcimProvider authType: 'oauth' | 'token' | null isLoading: boolean error: string | null authUrl: string | null consentId: string | null onTokenSubmit: (apiToken: string, companyId: string) => void onBack: () => void }) { const providerName = ARCIM_PROVIDERS.find(p => p.id === provider)?.name ?? provider const [apiToken, setApiToken] = useState('') const [companyId, setCompanyId] = useState('') // BL uses server-side client credentials — only needs company ID, no API key const isClientCredentials = provider === 'bjornlunden' const needsApiToken = !isClientCredentials const needsCompanyId = provider === 'bokio' || provider === 'bjornlunden' const tokenDescription = isClientCredentials ? `Ange ditt företags-ID (GUID) från Björn Lundén. gnubok ansluter automatiskt via sin integrationspartner-åtkomst.` : `Ange din API-nyckel från ${providerName} för att ge gnubok tillgång att läsa din bokföringsdata.` const tokenHelpText = isClientCredentials ? `Hittas i Björn Lundén under Inställningar \u2192 Företagsinformation (GUID-format).` : provider === 'bokio' ? `Du hittar din API-nyckel i ${providerName} under Inställningar \u2192 Integrationer \u2192 API. Ditt företags-ID är det GUID som syns i URL:en när du är inloggad, t.ex. https://app.bokio.se/ditt-företags-id/settings-r/private-integrations.` : `Du hittar din applikationstoken i ${providerName} under Administration \u2192 Integrationer.` const canSubmit = isClientCredentials ? !!companyId : !!(apiToken && (!needsCompanyId || companyId)) return (
Anslut till {providerName} {authType === 'token' ? tokenDescription : `Logga in i ${providerName} för att ge gnubok tillgång att läsa din bokföringsdata.` } {isLoading && (

Förbereder anslutning...

)} {error && ( <>

Anslutning misslyckades

{error}

{provider === 'fortnox' && (

Obs: Fortnox kräver ett aktivt integrationstillägg (tillkostnadsbelagd tilläggstjänst) för att kunna använda integrationer. Kontrollera att detta är aktiverat i ditt Fortnox-konto.

)}
)} {/* OAuth flow */} {authType === 'oauth' && authUrl && !isLoading && (

Klicka nedan för att logga in i {providerName}. Fönstret stängs automatiskt när du är klar.

)} {/* Token-based flow */} {authType === 'token' && consentId && !isLoading && (

{tokenHelpText}

{needsApiToken && (
setApiToken(e.target.value)} />
)} {needsCompanyId && (
setCompanyId(e.target.value)} />
)}
)}
) } // ── Preview step ──────────────────────────────────────────────── function PreviewStep({ preview, isLoading, error, onContinue, onBack, }: { preview: PreviewData | null isLoading: boolean error: string | null onContinue: () => void onBack: () => void }) { const providerName = preview ? ARCIM_PROVIDERS.find(p => p.id === preview.consent.provider)?.name ?? preview.consent.provider : '' return (
Anslutet till {providerName} {isLoading && (

Hämtar bokföringsdata...

)} {error && ( <>

{error}

)} {/* SIE stats summary */} {preview?.sieAvailable && preview.sieStats && (

Hittade {preview.sieStats.accountCount} konton och {preview.sieStats.transactionCount} verifikationer

{preview.sieStats.fiscalYears.length === 1 ? `Räkenskapsår ${preview.sieStats.fiscalYears[0]}` : `${preview.sieStats.fiscalYears.length} räkenskapsår: ${preview.sieStats.fiscalYears.join(', ')}` }

)} {preview && !preview.sieAvailable && !isLoading && preview.hasSieData && (

SIE-data redan importerad

Bokföringsdata har redan importerats via SIE-fil. Du kan fortsätta med att importera kunder, leverantörer och fakturor.

)} {preview && !preview.sieAvailable && !isLoading && !preview.hasSieData && (

SIE-import krävs

Bokföringsdata (kontoplan, verifikationer och balanser) måste importeras via SIE-fil innan kunder, leverantörer och fakturor kan hämtas. Exportera en SIE-fil från {ARCIM_PROVIDERS.find(p => p.id === preview.consent.provider)?.name ?? 'ditt bokföringssystem'} och ladda upp den i gnubok.

Gå till SIE-importen
)}
) } function InfoItem({ label, value }: { label: string; value: string | null }) { return (

{label}

{value || '—'}

) } // ── Mapping step (wraps AccountMappingStep) ───────────────────── function MappingStep({ sieData, isLoading, error, onMappingChange, onContinue, onBack, }: { sieData: SIEData | null isLoading: boolean error: string | null onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void onContinue: () => void onBack: () => void }) { if (isLoading) { return (

Analyserar bokföringsdata och förbereder kontomappning...

) } if (error) { return (

Kunde inte ladda SIE-data

{error}

) } if (!sieData) return null return ( ) } // ── Options step ──────────────────────────────────────────────── function OptionsStep({ options, sieAvailable, sieData, provider, onChange, onStart, onBack, }: { options: MigrationOptions sieAvailable: boolean sieData: SIEData | null provider: ArcimProvider | null onChange: (options: MigrationOptions) => void onStart: () => void onBack: () => void }) { const [showConfirm, setShowConfirm] = useState(false) const toggleOption = (key: keyof MigrationOptions) => { onChange({ ...options, [key]: !options[key] }) } const fileStatuses = sieData?.fileStatuses ?? [] const allSieImported = sieData?.allImported ?? false const newFileCount = sieData?.newFileCount ?? 0 const selectedItems: string[] = [] if (options.importCompanyInfo) selectedItems.push('Företagsinformation') if (sieAvailable && options.importSIEData) selectedItems.push('Bokföringsdata (SIE)') if (options.importCustomers) selectedItems.push('Kunder') if (options.importSuppliers) selectedItems.push('Leverantörer') if (options.importSalesInvoices) selectedItems.push('Kundfakturor') if (options.importSupplierInvoices) selectedItems.push('Leverantörsfakturor') return (
Vad vill du importera? Bokföringsdata importeras via SIE-fil. Kunder, leverantörer och fakturor hämtas via API:et. } label="Företagsinformation" description="Namn, organisationsnummer, adress" checked={options.importCompanyInfo} onChange={() => toggleOption('importCompanyInfo')} /> {sieAvailable && ( <> } label="Bokföringsdata (SIE)" description={allSieImported ? 'All bokföringsdata är redan importerad — inga ändringar' : newFileCount > 0 ? `${newFileCount} ny(a) räkenskapsår att importera` : 'Kontoplan, ingående balanser och verifikationer' } checked={options.importSIEData && !allSieImported} onChange={() => !allSieImported && toggleOption('importSIEData')} disabled={allSieImported} /> {/* Per-file import status */} {fileStatuses.length > 0 && (
{fileStatuses.map((fs) => (
{fs.alreadyImported ? ( <> Räkenskapsår {fs.fiscalYear} — importerad {fs.importedAt ? new Date(fs.importedAt).toLocaleDateString('sv-SE') : ''} ) : ( <> Räkenskapsår {fs.fiscalYear} — ny data att importera )}
))}
)} {options.importSIEData && !allSieImported && (

Verifikationsserie

Serie för importerade verifikationer

onChange({ ...options, voucherSeries: e.target.value.toUpperCase() || 'B' })} maxLength={2} />
)} )} } label="Kunder" description="Kund-register med kontaktuppgifter" checked={options.importCustomers} onChange={() => toggleOption('importCustomers')} /> } label="Leverantörer" description="Leverantör-register med bankuppgifter" checked={options.importSuppliers} onChange={() => toggleOption('importSuppliers')} /> } label="Kundfakturor" description="Alla kundfakturor (betalda och obetalda)" checked={options.importSalesInvoices} onChange={() => toggleOption('importSalesInvoices')} /> } label="Leverantörsfakturor" description={provider === 'fortnox' ? 'Endast obetalda leverantörsfakturor hämtas. Historiska betalda fakturor finns kvar i Fortnox.' : 'Alla leverantörsfakturor (betalda och obetalda)'} checked={options.importSupplierInvoices} onChange={() => toggleOption('importSupplierInvoices')} />
{ setShowConfirm(false) onStart() }} isSubmitting={false} title="Starta migrering" warningText="Bokföringsdata, kunder, leverantörer och fakturor importeras till gnubok. Se till att ingen annan import pågår." confirmLabel="Starta migrering" >

Följande importeras:

    {selectedItems.map((item) => (
  • {item}
  • ))}
) } function OptionRow({ icon, label, description, checked, onChange, disabled, }: { icon: React.ReactNode label: string description: string checked: boolean onChange: () => void disabled?: boolean }) { return (
!disabled && onChange()} >
{icon}

{label}

{description}

!disabled && onChange()} disabled={disabled} onClick={(e) => e.stopPropagation()} />
) } // ── Migrating step (progress) ─────────────────────────────────── function MigratingStep({ currentStep, progress }: { currentStep: string; progress: number }) { return ( Migrering pågår Vänta medan vi hämtar och importerar din bokföringsdata. Det kan ta några minuter.
{progress}%

{currentStep}

) } // ── Result step ───────────────────────────────────────────────── /** Format a fiscal year label from ISO dates, e.g. "2024-01-01" → "2024" or "2024/2025" */ function formatFiscalYearLabel(start: string, end: string): string { const startYear = start.slice(0, 4) const endYear = end.slice(0, 4) return startYear === endYear ? startYear : `${startYear}/${endYear}` } /** Determine the overall status icon and color for a single FY import */ function getFYStatus(r: ImportResult): { icon: 'success' | 'warning' | 'error'; label: string } { if (r.errors.length > 0 && r.journalEntriesCreated === 0) { return { icon: 'error', label: 'Misslyckades' } } if (r.errors.length > 0 || (r.details?.skippedVouchers && r.details.skippedVouchers.total > 0)) { return { icon: 'warning', label: 'Delvis importerad' } } return { icon: 'success', label: 'Importerad' } } const StatusIcon = ({ status }: { status: 'success' | 'warning' | 'error' }) => { if (status === 'error') return if (status === 'warning') return return } /** Expandable per-fiscal-year detail card */ function FiscalYearResult({ result, index }: { result: ImportResult; index: number }) { const [expanded, setExpanded] = useState(false) const status = getFYStatus(result) const d = result.details const fyLabel = d?.fiscalYear ? formatFiscalYearLabel(d.fiscalYear.start, d.fiscalYear.end) : `Räkenskapsår ${index + 1}` return (
{/* Header — always visible */} {/* Expanded details */} {expanded && (
{/* Errors — shown prominently */} {result.errors.length > 0 && (

{result.errors.length === 1 ? '1 fel vid import' : `${result.errors.length} fel vid import`}

{result.errors.map((e, i) => (

{e}

))}
)} {/* Opening balance adjustment */} {d?.openingBalance && (

Ingående balanser justerade

{d.openingBalance.explanation === 'unallocated_result' && ( <> Differens på {Math.abs(d.openingBalance.imbalance).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK bokförd på konto {d.openingBalance.bookedToAccount}. Detta beror troligen på att föregående års resultat inte allokerats till eget kapital i källsystemet — vanligt vid byte av bokföringsprogram. )} {d.openingBalance.explanation === 'excluded_accounts' && ( <> Exkluderade systemkonton (t.ex. Fortnox 0099) hade ingående saldon. Differensen ({Math.abs(d.openingBalance.imbalance).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK) bokförd på konto {d.openingBalance.bookedToAccount}. )} {d.openingBalance.explanation === 'rounding' && ( <> Avrundningsdifferens ({Math.abs(d.openingBalance.imbalance).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK) bokförd på konto {d.openingBalance.bookedToAccount}. )} {!d.openingBalance.explanation && ( <> Differens på {Math.abs(d.openingBalance.imbalance).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK bokförd på konto {d.openingBalance.bookedToAccount}. )}

)} {/* Skipped vouchers breakdown */} {d?.skippedVouchers && d.skippedVouchers.total > 0 && (

{d.skippedVouchers.total} verifikationer hoppades över

Ofullständiga verifikationer i källsystemet som inte kan importeras. Saldon har justerats automatiskt via omföringsverifikation.

{d.skippedVouchers.unbalanced > 0 && (
Obalanserade {d.skippedVouchers.unbalanced}
)} {d.skippedVouchers.unmapped > 0 && (
Ej mappade konton {d.skippedVouchers.unmapped}
)} {d.skippedVouchers.singleLine > 0 && (
Enradsverifikationer {d.skippedVouchers.singleLine}
)} {d.skippedVouchers.empty > 0 && (
Tomma {d.skippedVouchers.empty}
)}
)} {/* Migration adjustment info */} {d?.migrationAdjustment?.created && (

Omföringsverifikation skapad

{d.migrationAdjustment.accountsAdjusted} konton justerade för att saldon ska matcha källsystemet. Verifikationen kompenserar för hoppade verifikationer så att dina balansräkning och resultaträkning stämmer.

)} {/* Retry info (only shown if retries happened) */} {d && d.retriedBatches > 0 && (

{d.retriedBatches} {d.retriedBatches === 1 ? 'batch' : 'batcher'} behövde omförsök {d.failedBatches > 0 && ( {' · '}{d.failedBatches} misslyckades trots omförsök )}

)}
)}
) } function ResultStep({ results, sieResults, error, onDone, onRetry, }: { results: MigrationResults | null sieResults: ImportResult[] error: string | null onDone: () => void onRetry: () => void }) { if (error) { return (

Migreringen misslyckades

{error}

) } const hasResults = results || sieResults.length > 0 if (!hasResults) return null // Compute combined SIE stats const totalJournalEntries = sieResults.reduce((sum, r) => sum + r.journalEntriesCreated, 0) const totalErrors = sieResults.reduce((sum, r) => sum + r.errors.length, 0) const totalSkipped = sieResults.reduce((sum, r) => (r.details?.skippedVouchers?.total || 0) + sum, 0) const allSieSucceeded = sieResults.length > 0 && sieResults.every(r => r.success) const anySieFailed = sieResults.some(r => r.errors.length > 0 && r.journalEntriesCreated === 0) // Check if anything meaningful was imported via entities // Company info is always re-fetched (upsert) so it doesn't count as "new" const entityImported = results && ( (results.customers && (results.customers.imported > 0 || results.customers.skipped > 0)) || (results.suppliers && (results.suppliers.imported > 0 || results.suppliers.skipped > 0)) || (results.salesInvoices && (results.salesInvoices.imported > 0 || results.salesInvoices.skipped > 0)) || (results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0)) ) const nothingNew = sieResults.length === 0 && !entityImported // Overall status const overallIcon = anySieFailed ? 'error' as const : (!allSieSucceeded || totalErrors > 0) ? 'warning' as const : 'success' as const return (
{/* ── Header card with overall summary ── */} {nothingNew ? 'Allt är uppdaterat' : anySieFailed ? 'Migrering delvis genomförd' : !allSieSucceeded ? 'Migrering klar med anmärkningar' : 'Migrering klar'} {nothingNew ? ( 'Det finns ingen ny data att importera från leverantören.' ) : totalJournalEntries > 0 ? ( <> {totalJournalEntries.toLocaleString('sv-SE')} {' verifikationer importerade'} {sieResults.length > 1 && ` över ${sieResults.length} räkenskapsår`} {totalSkipped > 0 && ( {' · '}{totalSkipped} hoppade över )} ) : null} {/* ── Per-fiscal-year SIE breakdown ── */} {sieResults.length > 0 && (

Bokföringsdata (SIE)

{sieResults.map((r, i) => ( ))}
)} {/* ── API import results (company info, customers, etc.) ── */} {results && (() => { const hasCompanyInfo = results.companyInfo?.imported const hasCustomers = results.customers && (results.customers.imported > 0 || results.customers.skipped > 0) const hasSuppliers = results.suppliers && (results.suppliers.imported > 0 || results.suppliers.skipped > 0) const hasSalesInvoices = results.salesInvoices && (results.salesInvoices.imported > 0 || results.salesInvoices.skipped > 0) const hasSupplierInvoices = results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0) const hasAnything = hasCompanyInfo || hasCustomers || hasSuppliers || hasSalesInvoices || hasSupplierInvoices if (!hasAnything) return null return (

Övriga data

{hasCompanyInfo && ( } label="Företagsinformation" status="success" statusText="Importerad" /> )} {hasCustomers && ( } label="Kunder" status="success" statusText={`${results.customers!.imported} importerade`} detail={results.customers!.skipped > 0 ? formatSkipReasons(results.customers!.skipReasons, 'customer') ?? `${results.customers!.skipped} hoppades över` : undefined} /> )} {hasSuppliers && ( } label="Leverantörer" status="success" statusText={`${results.suppliers!.imported} importerade`} detail={results.suppliers!.skipped > 0 ? formatSkipReasons(results.suppliers!.skipReasons, 'supplier') ?? `${results.suppliers!.skipped} hoppades över` : undefined} /> )} {hasSalesInvoices && ( } label="Kundfakturor" status="success" statusText={`${results.salesInvoices!.imported} importerade`} detail={results.salesInvoices!.skipped > 0 ? formatSkipReasons(results.salesInvoices!.skipReasons, 'invoice') ?? `${results.salesInvoices!.skipped} hoppades över` : undefined} /> )} {hasSupplierInvoices && ( } label="Leverantörsfakturor" status="success" statusText={`${results.supplierInvoices!.imported} importerade`} detail={results.supplierInvoices!.skipped > 0 ? formatSkipReasons(results.supplierInvoices!.skipReasons, 'invoice') ?? `${results.supplierInvoices!.skipped} hoppades över` : undefined} /> )}
) })()} {/* ── Next steps ── */} Nästa steg
1

Granska importerade verifikationer

Kontrollera att bokföringen ser korrekt ut i huvudboken

2

Stäm av balansräkningen

Jämför ingående balanser och saldon mot ditt tidigare system

3

Kontrollera kunder och leverantörer

Verifiera kontaktuppgifter, organisationsnummer och bankinfo

) } function formatSkipReasons(reasons?: SkipReasons, entityType?: 'customer' | 'supplier' | 'invoice'): string | undefined { if (!reasons) return undefined const parts: string[] = [] if (reasons.duplicate) parts.push(`${reasons.duplicate} fanns redan`) if (reasons.inactive) parts.push(`${reasons.inactive} inaktiv${reasons.inactive > 1 ? 'a' : ''}`) if (reasons.noMatch) { const matchLabel = entityType === 'invoice' ? 'utan matchning' : 'utan matchning' parts.push(`${reasons.noMatch} ${matchLabel}`) } if (reasons.failed) parts.push(`${reasons.failed} misslyckades`) return parts.length > 0 ? parts.join(', ') : undefined } /** Simple row for non-SIE entity results (customers, invoices, etc.) */ function EntityResultRow({ icon, label, status, statusText, detail, }: { icon: React.ReactNode label: string status: 'success' | 'skipped' statusText: string detail?: string }) { return (
{icon}

{label}

{statusText}

{detail &&

{detail}

}
) } // ── Main wizard ───────────────────────────────────────────────── export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) { const { toast } = useToast() const [step, setStep] = useState('provider') const [isLoading, setIsLoading] = useState(false) const [isLoadingStatus, setIsLoadingStatus] = useState(true) const [error, setError] = useState(null) // Connection status (existing connections + import history) const [connectionStatus, setConnectionStatus] = useState(null) // Connection state const [selectedProvider, setSelectedProvider] = useState(null) const [consentId, setConsentId] = useState(null) const [authUrl, setAuthUrl] = useState(null) const [authType, setAuthType] = useState<'oauth' | 'token' | null>(null) // Preview state const [preview, setPreview] = useState(null) // SIE data state (held between mapping and execution steps) const [sieData, setSieData] = useState(null) // Options state const [migrationOptions, setMigrationOptions] = useState(DEFAULT_OPTIONS) // Migration state const [migrationStep, setMigrationStep] = useState('') const [migrationProgress, setMigrationProgress] = useState(0) const [migrationResults, setMigrationResults] = useState(null) const [sieImportResults, setSieImportResults] = useState([]) // Wizard progress — only user-interactive steps const userSteps = STEPS.filter(s => { if (s === 'migrating' || s === 'result') return false if (s === 'mapping' && !preview?.sieAvailable) return false return true }) const currentUserStepIndex = userSteps.indexOf(step) const isInteractiveStep = currentUserStepIndex !== -1 const progressPercent = isInteractiveStep ? ((currentUserStepIndex + 1) / userSteps.length) * 100 : 100 // ── Fetch connection status on mount ─────────────────────────── const fetchStatus = useCallback(async () => { try { setIsLoadingStatus(true) const res = await fetch('/api/extensions/ext/arcim-migration/status') if (res.ok) { const data = await res.json() setConnectionStatus(data) } } catch { // Non-critical — just means we can't show existing connections } finally { setIsLoadingStatus(false) } }, []) useEffect(() => { fetchStatus() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // ── Step handlers ────────────────────────────────────────────── const loadPreview = useCallback(async (cId: string) => { setStep('preview') setIsLoading(true) setError(null) try { const res = await fetch(`/api/extensions/ext/arcim-migration/preview?consentId=${cId}`) if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data.error || `HTTP ${res.status}`) } const data = await res.json() setPreview(data) setConsentId(cId) // If SIE is not available, disable SIE import by default if (!data.sieAvailable) { setMigrationOptions(prev => ({ ...prev, importSIEData: false })) } } catch (err) { setError(err instanceof Error ? err.message : 'Kunde inte hämta förhandsgranskning') } finally { setIsLoading(false) } }, []) const handleSelectProvider = useCallback(async (provider: ArcimProvider) => { setSelectedProvider(provider) setStep('connect') setIsLoading(true) setError(null) try { const res = await fetch('/api/extensions/ext/arcim-migration/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider }), }) if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data.error || `HTTP ${res.status}`) } const data = await res.json() setConsentId(data.consentId) setAuthType(data.authType) if (data.alreadyConnected) { // Existing connection — skip auth, go straight to preview await loadPreview(data.consentId) return } if (data.authType === 'oauth' && data.authUrl) { setAuthUrl(data.authUrl) } // Token-based providers stay on connect step for credential input } catch (err) { setError(err instanceof Error ? err.message : 'Anslutning misslyckades') } finally { setIsLoading(false) } }, [loadPreview]) // Re-sync with existing consent — go straight to preview const handleResync = useCallback(async (provider: ArcimProvider, existingConsentId: string) => { setSelectedProvider(provider) setConsentId(existingConsentId) setMigrationOptions(DEFAULT_OPTIONS) setMigrationResults(null) setSieImportResults([]) setSieData(null) await loadPreview(existingConsentId) }, [loadPreview]) // Disconnect an existing consent const handleDisconnect = useCallback(async (consentIdToDelete: string) => { try { const res = await fetch('/api/extensions/ext/arcim-migration/disconnect', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ consentId: consentIdToDelete }), }) if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data.error || 'Kunde inte koppla från') } toast({ title: 'Frånkopplad', description: 'Anslutningen har tagits bort.' }) await fetchStatus() } catch (err) { toast({ title: 'Fel', description: err instanceof Error ? err.message : 'Något gick fel', variant: 'destructive' }) } }, [toast, fetchStatus]) // Handle token submission for token-based providers (Bokio, etc.) const handleTokenSubmit = useCallback(async (apiToken: string, companyId: string) => { if (!consentId || !selectedProvider) return setIsLoading(true) setError(null) try { const res = await fetch('/api/extensions/ext/arcim-migration/submit-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ consentId, provider: selectedProvider, apiToken, companyId: companyId || undefined, }), }) if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data.error || `HTTP ${res.status}`) } // Token stored — consent is now accepted, proceed to preview await loadPreview(consentId) } catch (err) { setError(err instanceof Error ? err.message : 'Kunde inte ansluta') } finally { setIsLoading(false) } }, [consentId, selectedProvider, loadPreview]) // Handle OAuth callback via URL params const handleOAuthReturn = useCallback(async () => { // Check URL for migration callback params const url = new URL(window.location.href) const migrationStatus = url.searchParams.get('migration') const callbackConsentId = url.searchParams.get('consentId') if (migrationStatus === 'connected' && callbackConsentId) { // Clean URL url.searchParams.delete('migration') url.searchParams.delete('consentId') window.history.replaceState({}, '', url.pathname) await loadPreview(callbackConsentId) } else if (migrationStatus === 'error') { const callbackProvider = url.searchParams.get('provider') as ArcimProvider | null const reason = url.searchParams.get('reason') || 'OAuth-anslutningen misslyckades. Försök igen.' url.searchParams.delete('migration') url.searchParams.delete('provider') url.searchParams.delete('reason') window.history.replaceState({}, '', url.pathname) setError(reason) toast({ title: 'Anslutning misslyckades', description: reason, variant: 'destructive' }) if (callbackProvider) { setSelectedProvider(callbackProvider) setStep('connect') } else { setStep('provider') } } }, [loadPreview, toast]) // Check for OAuth callback on mount (fallback for non-popup flow) useEffect(() => { handleOAuthReturn() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Listen for postMessage from OAuth popup useEffect(() => { function handleMessage(event: MessageEvent) { if (event.origin !== window.location.origin) return if (event.data?.type === 'arcim-oauth-success' && event.data.consentId) { loadPreview(event.data.consentId) } else if (event.data?.type === 'arcim-oauth-error') { const reason = typeof event.data.reason === 'string' && event.data.reason ? event.data.reason : 'OAuth-anslutningen misslyckades. Försök igen.' setError(reason) toast({ title: 'Anslutning misslyckades', description: reason, variant: 'destructive' }) } } window.addEventListener('message', handleMessage) return () => window.removeEventListener('message', handleMessage) }, [loadPreview, toast]) // Load SIE data when entering mapping step const loadSIEData = useCallback(async () => { if (!consentId) return setStep('mapping') setIsLoading(true) setError(null) try { const res = await fetch(`/api/extensions/ext/arcim-migration/sie-data?consentId=${consentId}`) if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data.error || `HTTP ${res.status}`) } const data = await res.json() setSieData(data) // If all SIE files are already imported, disable SIE import by default if (data.allImported) { setMigrationOptions(prev => ({ ...prev, importSIEData: false })) } // Auto-skip mapping step if all accounts are mapped or all files already imported if (data.mappingStats.unmapped === 0 || data.allImported) { setStep('options') } } catch (err) { setError(err instanceof Error ? err.message : 'Kunde inte hämta SIE-data') } finally { setIsLoading(false) } }, [consentId]) const handlePreviewContinue = useCallback(() => { if (preview?.sieAvailable) { // Load SIE data for mapping step loadSIEData() } else { // Skip mapping step — no SIE available setStep('options') } }, [preview, loadSIEData]) const handleMappingChange = useCallback((sourceAccount: string, targetAccount: string, targetName: string) => { if (!sieData) return const updatedMappings = sieData.mappings.map(m => m.sourceAccount === sourceAccount ? { ...m, targetAccount, targetName, isOverride: true, matchType: 'manual' as const, confidence: 1 } : m ) setSieData(prev => prev ? { ...prev, mappings: updatedMappings, mappingStats: { ...prev.mappingStats, unmapped: updatedMappings.filter(m => !m.targetAccount).length, mapped: updatedMappings.filter(m => m.targetAccount).length, }, } : null) }, [sieData]) const handleStartMigration = useCallback(async () => { if (!consentId) return setStep('migrating') setMigrationStep('Startar migrering...') setMigrationProgress(5) setError(null) try { // ── Phase 1: SIE import ────────────────────────────────── if (migrationOptions.importSIEData && sieData && sieData.rawContent.length > 0) { setMigrationStep('Importerar bokföringsdata (SIE)...') setMigrationProgress(10) setSieImportResults([]) // Only import files that haven't been imported yet const filesToImport = sieData.rawContent .map((content, i) => ({ content, status: sieData.fileStatuses?.[i] })) .filter(f => !f.status?.alreadyImported) if (filesToImport.length === 0) { // All files already imported — skip SIE phase setSieImportResults([]) } else { for (let i = 0; i < filesToImport.length; i++) { const progress = 10 + Math.round((i / filesToImport.length) * 40) setMigrationProgress(progress) setMigrationStep(`Importerar bokföringsdata (SIE) — fil ${i + 1} av ${filesToImport.length}...`) const res = await fetch('/api/extensions/ext/arcim-migration/import-sie', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rawContent: filesToImport[i].content, mappings: sieData.mappings, options: { createFiscalPeriod: true, importOpeningBalances: true, importTransactions: true, voucherSeries: migrationOptions.voucherSeries, }, }), }) if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data.error || `SIE import HTTP ${res.status}`) } const result = await res.json() as ImportResult setSieImportResults(prev => [...prev, result]) } } } // ── Phase 2: API import (customers, suppliers, invoices) ── const hasApiImport = migrationOptions.importCompanyInfo || migrationOptions.importCustomers || migrationOptions.importSuppliers || migrationOptions.importSalesInvoices || migrationOptions.importSupplierInvoices if (hasApiImport) { setMigrationStep('Importerar kunder, leverantörer och fakturor...') setMigrationProgress(55) const res = await fetch('/api/extensions/ext/arcim-migration/migrate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ consentId, importCompanyInfo: migrationOptions.importCompanyInfo, importCustomers: migrationOptions.importCustomers, importSuppliers: migrationOptions.importSuppliers, importSalesInvoices: migrationOptions.importSalesInvoices, importSupplierInvoices: migrationOptions.importSupplierInvoices, }), }) if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data.error || `HTTP ${res.status}`) } const data = await res.json() setMigrationResults(data.results) } // Mark consent as fully accepted now that import is complete if (consentId) { await fetch('/api/extensions/ext/arcim-migration/accept', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ consentId }), }).catch(() => { /* best-effort */ }) } setMigrationProgress(100) setStep('result') toast({ title: 'Migrering klar', description: 'Din bokföringsdata har importerats.', }) } catch (err) { const msg = err instanceof Error ? err.message : 'Migrering misslyckades' setError(msg) setStep('result') } }, [consentId, migrationOptions, sieData, toast]) const handleDone = useCallback(() => { // Reset wizard setStep('provider') setSelectedProvider(null) setConsentId(null) setAuthUrl(null) setAuthType(null) setPreview(null) setSieData(null) setMigrationOptions(DEFAULT_OPTIONS) setMigrationResults(null) setSieImportResults([]) setError(null) // Refresh status so provider step shows updated import history fetchStatus() }, [fetchStatus]) // ── Render ───────────────────────────────────────────────────── return (
{/* Progress bar — only during interactive steps */} {step !== 'provider' && isInteractiveStep && (
Steg {currentUserStepIndex + 1}/{userSteps.length}: {STEP_LABELS[step]} {userSteps.map((s) => ( {STEP_LABELS[s]} ))}
)} {/* Step content */} {step === 'provider' && ( )} {step === 'connect' && selectedProvider && ( { setStep('provider') setError(null) }} /> )} {step === 'preview' && ( setStep('provider')} /> )} {step === 'mapping' && ( setStep('options')} onBack={() => setStep('preview')} /> )} {step === 'options' && ( preview?.sieAvailable ? setStep('mapping') : setStep('preview')} /> )} {step === 'migrating' && ( )} {step === 'result' && ( { setError(null) setStep('options') }} /> )}
) }