Files
accounted/components/extensions/general/ArcimMigrationWorkspace.tsx
T
Jakob WennbergandClaude Opus 4.6 a7be1aab17 feat: comprehensive UI design audit and normalization (#28)
* feat: import system improvements, INK2 fix, and Swedish text corrections

- SIE parser: Windows-1252 and CP437 encoding detection and decoding
- Bank file parser: add Nordea Business (Företag) CSV format
- Bank file parser: improve format detection for SEB, Länsförsäkringar, generic CSV
- INK2 engine: calculate årets resultat (7222) from income statement for open fiscal years
- Dashboard: parallel Supabase queries, simplified dashboard page
- Fix Swedish characters (å, ä, ö) in BAS data descriptions, validation messages, AI consent disclosures
- Import wizard UI improvements across all steps
- Migration: add 'bas_range' match type to sie_account_mappings constraint
- Extensive new tests for SIE parser encoding and bank file parser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: arcim migration wizard UX fixes, Sentry setup, and extension scaffolding

Arcim migration wizard improvements:
- Progress bar now excludes non-interactive steps (migrating/result)
- Fix OAuth text to match target="_blank" behavior (new tab, not redirect)
- Display month names instead of "Månad X" in preview
- Fix Swedish typo "förifylla" in no-company-info message
- Replace native checkboxes with shadcn Switch in options step
- Add ConfirmationDialog before starting migration
- Show progress percentage during migration
- Add "Nästa steg" guidance and navigation links in result step
- Add "Försök igen" button in error state (returns to options)
- Add Bokio company ID help text (GUID from URL)
- Add Fortnox integration add-on hint on connection failure

Also includes: SIE import system improvements, INK2 fixes, Swedish text
corrections, Sentry error tracking setup, and arcim-migration extension
scaffolding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review feedback

- Fix OAuth error recovery blank page (restore provider from URL params)
- Pass real userId to MigrationWizard instead of empty string
- Remove ~50 debug console.log statements from sie-import.ts
- Fix comment referencing account 3740 → 3741

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: comprehensive UI design audit and normalization

Dashboard audit:
- Fix muted-foreground contrast (4.31:1 → 5.08:1) for WCAG AA
- Add prefers-reduced-motion media query for all animations
- Replace border-l-2 accent anti-pattern with subtle full-border colors
- Add aria-expanded to toggle buttons, role="status" to live counters
- Fix touch targets on deadline buttons (28px → 36px)
- Vary section spacing for rhythm (mb-12/mb-10/mb-8)
- Remove unused imports and dead code

Transactions audit + hardening:
- Add pagination (200 per page) with "Ladda fler" button
- Replace height animation with transform-only exit animation
- Show batch progress in floating action bar during processing
- Fix batch bar mobile overlap (bottom-20 on mobile)
- Replace clickable badges with proper button elements
- Add safe area padding to fullscreen swipe view
- Add response.ok check to suggestion fetch
- Add truncation to invoice number buttons

Invoicing audit:
- Remove border-l-4 accent pattern from invoice cards
- Replace string concatenation with cn() utility

Systemic sweep (34 files):
- All page headings: font-bold → font-display font-medium (Fraunces)
- All stat numbers: font-bold → font-display font-medium tabular-nums
- All hard-coded blue/amber/emerald colors → design tokens
- Remove all dark mode overrides (tokens handle automatically)
- Tint pure white card background to 99%

Design context added to CLAUDE.md with brand personality,
aesthetic direction, and 5 design principles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: bookkeeping flow audit — design system, accessibility, UX

- Replace raw <select> with shadcn Select component (JournalEntryForm)
- Add confirmation dialog for account deletion (ChartOfAccountsManager)
- Remove console.error from production code (JournalEntryList, JournalEntryForm)
- Fix contradictory h-7/min-h-[44px] button sizing → h-10 (ChartOfAccountsManager)
- Increase BAS catalog "Lägg till" touch target h-7 → h-9
- Improve loading state with spinner (JournalEntryList)
- Improve empty state with icon, description, and guidance (JournalEntryList)
- Add response.ok check on journal entry fetch
- Add aria-expanded to entry expand buttons
- Add tabular-nums to desktop debit/credit columns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: onboarding and empty state improvements

Onboarding:
- Replace font-serif with font-display (Fraunces) for brand consistency
- Remove console.error calls from production code

Empty states:
- Fix broken /transactions/new link in EmptyTransactions (route doesn't exist)
- Add actionHref fallback to EmptyCustomers when no onAction prop provided
- Improve EmptyTransactions description copy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clarify Swedish UX copy — terminology, errors, descriptions

Terminology consistency:
- "Försenad" → "Förfallen" for overdue invoices (customers/[id])
- "bokföringsorder" → actionable description in bookkeeping page
- "verifikation har bifogats" → "underlag har bifogats" in doc warning
- "Fortsätt ändå" → "Bokför utan underlag" (specific action)

Error messages — replace generic "Fel" + "Något gick fel" with specific:
- "Något gick fel vid bokföring" → "Transaktionen kunde inte bokföras"
- "Något gick fel vid matchning" → "Transaktionen kunde inte matchas"
- "Kunde inte hämta X" → "Kunde inte ladda X" + recovery hint
- Add "Försök igen" guidance to all error toasts

Page descriptions — replace redundant with actionable:
- Invoices: "Skapa och hantera" → "Skicka, följ betalningar, skapa kreditnotor"
- Bookkeeping: list of features → actionable description

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: design critique — dashboard affordance, reports description

Dashboard:
- Add ChevronRight indicator to clickable summary cards
  (Att få betalt, Koppla bank) to distinguish from static cards
- Add cursor-pointer to linked cards

Reports:
- Replace feature list description with actionable guidance
  "Huvudbok, grundbok..." → "Generera skattedeklarationer..."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace generic "Fel" error toasts with specific messages

Deadlines: 5 generic "Fel" → specific per-action titles
  (create, toggle, edit, delete, load)
Expenses detail: 5 generic "Fel" → specific per-action titles
  (load, approve, pay, credit, delete)
Expenses new: 3 generic "Fel" → instructional validation messages
  (supplier name, supplier selection, invoice number)
Customers: 1 generic "Fel" → specific load error with recovery hint

All error toasts now follow pattern:
  title = what failed, description = how to recover

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace all remaining generic "Fel" error toasts (37 instances)

Systematic sweep across 12 dashboard pages replacing generic
title: 'Fel' with context-specific error titles:

- Load errors: "Kunde inte ladda [resurs]"
- Action errors: "[Åtgärd] misslyckades"
- Validation: "[Fält] saknas"

Every error toast now tells the user what failed without needing
to read the description. Recovery hints added where missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: import flow — normalize stat typography, remove console.warn

- Replace font-bold with font-display font-medium on 13 stat numbers
  across SIEPreviewStep, BankFilePreviewStep, BankFileConfirmStep,
  ImportResultStep (missed by systemic sweep since these are in
  components/import/, not app/(dashboard)/)
- Add tabular-nums to stat numbers displaying counts/currency
- Remove console.warn in ArcimMigrationWorkspace

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: final cleanup — console statements, remaining font-bold stats

Remove production console statements:
- Step1EntityType: remove debug console.warn (dead code after onNext)
- TransactionBookingDialog: remove console.error on doc link failure
- JournalEntryAttachments: remove 3 console.error calls

Normalize remaining font-bold stat displays:
- SwipeCategorizationView: 3 instances (completion, amount displays)
- NEDeclarationView: yearly result heading + value

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review feedback

loadMoreTransactions: add inbox item enrichment matching fetchTransactions
- Paginated transactions now fetch invoice_inbox_items in parallel
- Fixes missing document indicator, template suggestions, and inbox
  match card for transactions loaded via "Ladda fler"

fetchAllPages: add maxPages guard (default 500) to prevent infinite loop
- If Arcim gateway returns hasMore:true indefinitely, the loop now
  exits after 500 pages instead of running forever

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:48:40 +01:00

1381 lines
48 KiB
TypeScript

'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 { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { useToast } from '@/components/ui/use-toast'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import Link from 'next/link'
import {
ArrowLeft,
ArrowRight,
Loader2,
AlertCircle,
CheckCircle,
Building2,
Users,
Truck,
FileText,
Database,
ExternalLink,
Info,
RotateCcw,
} 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 MigrationResults {
companyInfo?: { imported: boolean }
customers?: { total: number; imported: number; skipped: number }
suppliers?: { total: number; imported: number; skipped: number }
salesInvoices?: { total: number; imported: number; skipped: number }
supplierInvoices?: { total: number; imported: number; skipped: number }
}
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<WizardStep, string> = {
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
}
interface SIEData {
parsed: ParsedSIEFile
mappings: AccountMapping[]
mappingStats: { total: number; mapped: number; unmapped: number }
rawContent: string[]
basAccounts: BASAccount[]
}
// ── Provider selection step ──────────────────────────────────────
const COMING_SOON_PROVIDERS = new Set<ArcimProvider>(['visma', 'bjornlunden', 'briox'])
const PROVIDER_LOGOS: Record<ArcimProvider, string> = {
fortnox: '/logos/fortnox.svg',
visma: '/logos/visma.jpeg',
bokio: '/logos/bokio.png',
bjornlunden: '/logos/bjornlunden.png',
briox: '/logos/Briox_logo.png',
}
function ProviderStep({ onSelect }: { onSelect: (provider: ArcimProvider) => void }) {
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Välj ditt nuvarande bokföringssystem</CardTitle>
<CardDescription>
Vi hämtar bokföringsdata via SIE och kunder, leverantörer och fakturor via API:et.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
{ARCIM_PROVIDERS.map((provider) => {
const comingSoon = COMING_SOON_PROVIDERS.has(provider.id)
return (
<button
key={provider.id}
disabled={comingSoon}
className={`relative flex items-center gap-4 rounded-lg border p-4 text-left transition-all ${
comingSoon
? 'cursor-not-allowed border-border/50 opacity-60'
: 'border-border hover:border-primary/50 hover:bg-accent/50 active:scale-[0.98]'
}`}
onClick={() => !comingSoon && onSelect(provider.id)}
>
<img
src={PROVIDER_LOGOS[provider.id]}
alt={provider.name}
className="h-10 w-10 shrink-0 rounded-lg object-contain"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">{provider.name}</p>
{comingSoon && (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
Kommer snart
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{provider.authType === 'oauth' ? 'Anslut via inloggning' : 'Anslut med API-nyckel'}
</p>
</div>
</button>
)
})}
</div>
</CardContent>
</Card>
</div>
)
}
// ── 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 (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Anslut till {providerName}</CardTitle>
<CardDescription>
{authType === 'token'
? tokenDescription
: `Logga in i ${providerName} för att ge gnubok tillgång att läsa din bokföringsdata.`
}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoading && (
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<p>Förbereder anslutning...</p>
</div>
)}
{error && (
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div>
<p className="font-medium text-destructive">Anslutning misslyckades</p>
<p className="text-sm text-muted-foreground">{error}</p>
{provider === 'fortnox' && (
<p className="mt-1 text-sm text-muted-foreground">
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.
</p>
)}
</div>
</div>
)}
{/* OAuth flow */}
{authType === 'oauth' && authUrl && !isLoading && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Klicka nedan för att logga in i {providerName} i ett nytt fönster.
När du är klar skickas du tillbaka hit automatiskt.
</p>
<Button asChild className="min-h-11">
<a href={authUrl} target="_blank" rel="noopener noreferrer">
Logga in i {providerName}
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</div>
)}
{/* Token-based flow */}
{authType === 'token' && consentId && !isLoading && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{tokenHelpText}
</p>
<div className="space-y-3">
{needsApiToken && (
<div>
<label htmlFor="apiToken" className="text-sm font-medium">
{provider === 'briox' ? 'Applikationstoken' : 'API-nyckel'}
</label>
<Input
id="apiToken"
type="password"
placeholder={provider === 'briox' ? 'Klistra in din applikationstoken' : 'Klistra in din API-nyckel'}
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
/>
</div>
)}
{needsCompanyId && (
<div>
<label htmlFor="companyId" className="text-sm font-medium">
Företags-ID
</label>
<Input
id="companyId"
placeholder={isClientCredentials ? 'GUID från företagsinställningar' : 'GUID från URL:en, t.ex. 14ccad83-67f6-49bd-...'}
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
/>
</div>
)}
<Button
className="min-h-11"
onClick={() => onTokenSubmit(apiToken, companyId)}
disabled={!canSubmit}
>
Anslut
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
<div className="flex">
<Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
</div>
</div>
)
}
// ── 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 (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Anslutet till {providerName}</CardTitle>
<CardDescription>
Vi har hämtat information om ditt företag. Kontrollera att det stämmer.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{isLoading && (
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<p>Hämtar företagsinformation och bokföringsdata...</p>
</div>
)}
{error && (
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<p className="text-sm text-muted-foreground">{error}</p>
</div>
)}
{preview?.companyInfo && (
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem label="Företagsnamn" value={preview.companyInfo.company_name} />
<InfoItem label="Organisationsnummer" value={preview.companyInfo.org_number} />
<InfoItem label="Momsregistrering" value={preview.companyInfo.vat_number} />
<InfoItem label="Räkenskapsår startar" value={MONTH_NAMES[preview.companyInfo.fiscal_year_start_month - 1] ?? `Månad ${preview.companyInfo.fiscal_year_start_month}`} />
<InfoItem label="Adress" value={preview.companyInfo.address_line1} />
<InfoItem
label="Postort"
value={[preview.companyInfo.postal_code, preview.companyInfo.city].filter(Boolean).join(' ') || null}
/>
<InfoItem label="Telefon" value={preview.companyInfo.phone} />
<InfoItem label="E-post" value={preview.companyInfo.email} />
</div>
)}
{preview && !preview.companyInfo && !isLoading && (
<p className="text-sm text-muted-foreground">
Ingen företagsinformation kunde hämtas. Du kan fylla i uppgifterna manuellt under Inställningar.
</p>
)}
{/* SIE stats summary */}
{preview?.sieAvailable && preview.sieStats && (
<div className="flex gap-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
<Database className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
<div>
<p className="text-sm font-medium">
Hittade {preview.sieStats.accountCount} konton och {preview.sieStats.transactionCount} verifikationer
</p>
<p className="text-xs text-muted-foreground">
{preview.sieStats.fiscalYears.length === 1
? `Räkenskapsår ${preview.sieStats.fiscalYears[0]}`
: `${preview.sieStats.fiscalYears.length} räkenskapsår: ${preview.sieStats.fiscalYears.join(', ')}`
}
</p>
</div>
</div>
)}
{preview && !preview.sieAvailable && !isLoading && (
<div className="flex gap-3 rounded-lg border border-warning/30 bg-warning/10 p-4">
<Info className="mt-0.5 h-5 w-5 shrink-0 text-warning-foreground" />
<div>
<p className="text-sm font-medium text-warning-foreground">SIE-hämtning inte tillgänglig</p>
<p className="text-xs text-muted-foreground">
SIE-hämtning är inte tillgänglig för denna leverantör ännu. Du kan importera SIE-filen manuellt via SIE-importen.
</p>
</div>
</div>
)}
</CardContent>
</Card>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button className="min-h-11" onClick={onContinue} disabled={isLoading}>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)
}
function InfoItem({ label, value }: { label: string; value: string | null }) {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm font-medium">{value || '—'}</p>
</div>
)
}
// ── 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 (
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<p>Analyserar bokföringsdata och förbereder kontomappning...</p>
</div>
</CardContent>
</Card>
)
}
if (error) {
return (
<div className="space-y-4">
<Card>
<CardContent className="pt-6">
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div>
<p className="font-medium text-destructive">Kunde inte ladda SIE-data</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
</CardContent>
</Card>
<Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
</div>
)
}
if (!sieData) return null
return (
<AccountMappingStep
mappings={sieData.mappings}
basAccounts={sieData.basAccounts}
onMappingChange={onMappingChange}
onContinue={onContinue}
onBack={onBack}
/>
)
}
// ── Options step ────────────────────────────────────────────────
function OptionsStep({
options,
sieAvailable,
onChange,
onStart,
onBack,
}: {
options: MigrationOptions
sieAvailable: boolean
onChange: (options: MigrationOptions) => void
onStart: () => void
onBack: () => void
}) {
const [showConfirm, setShowConfirm] = useState(false)
const toggleOption = (key: keyof MigrationOptions) => {
onChange({ ...options, [key]: !options[key] })
}
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 (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Vad vill du importera?</CardTitle>
<CardDescription>
Bokföringsdata importeras via SIE-fil. Kunder, leverantörer och fakturor hämtas via API:et.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<OptionRow
icon={<Building2 className="h-4 w-4" />}
label="Företagsinformation"
description="Namn, organisationsnummer, adress"
checked={options.importCompanyInfo}
onChange={() => toggleOption('importCompanyInfo')}
/>
{sieAvailable && (
<>
<OptionRow
icon={<Database className="h-4 w-4" />}
label="Bokföringsdata (SIE)"
description="Kontoplan, ingående balanser och verifikationer"
checked={options.importSIEData}
onChange={() => toggleOption('importSIEData')}
/>
{options.importSIEData && (
<div className="flex items-center gap-3 rounded-lg border border-border p-3 ml-4">
<div className="text-muted-foreground">
<FileText className="h-4 w-4" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Verifikationsserie</p>
<p className="text-xs text-muted-foreground">Serie för importerade verifikationer</p>
</div>
<Input
className="w-16 text-center"
value={options.voucherSeries}
onChange={(e) => onChange({ ...options, voucherSeries: e.target.value.toUpperCase() || 'B' })}
maxLength={2}
/>
</div>
)}
</>
)}
<OptionRow
icon={<Users className="h-4 w-4" />}
label="Kunder"
description="Kund-register med kontaktuppgifter"
checked={options.importCustomers}
onChange={() => toggleOption('importCustomers')}
/>
<OptionRow
icon={<Truck className="h-4 w-4" />}
label="Leverantörer"
description="Leverantör-register med bankuppgifter"
checked={options.importSuppliers}
onChange={() => toggleOption('importSuppliers')}
/>
<OptionRow
icon={<FileText className="h-4 w-4" />}
label="Kundfakturor (öppna)"
description="Obetalda kundfakturor"
checked={options.importSalesInvoices}
onChange={() => toggleOption('importSalesInvoices')}
/>
<OptionRow
icon={<FileText className="h-4 w-4" />}
label="Leverantörsfakturor (öppna)"
description="Obetalda leverantörsfakturor"
checked={options.importSupplierInvoices}
onChange={() => toggleOption('importSupplierInvoices')}
/>
</CardContent>
</Card>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button className="min-h-11" onClick={() => setShowConfirm(true)} disabled={selectedItems.length === 0}>
Starta migrering
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
<ConfirmationDialog
open={showConfirm}
onOpenChange={setShowConfirm}
onConfirm={() => {
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"
>
<div className="space-y-2">
<p className="text-sm font-medium">Följande importeras:</p>
<ul className="space-y-1">
{selectedItems.map((item) => (
<li key={item} className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="h-3.5 w-3.5 text-primary" />
{item}
</li>
))}
</ul>
</div>
</ConfirmationDialog>
</div>
)
}
function OptionRow({
icon,
label,
description,
checked,
onChange,
}: {
icon: React.ReactNode
label: string
description: string
checked: boolean
onChange: () => void
}) {
return (
<div
className="flex cursor-pointer items-center gap-3 rounded-lg border border-border p-3 transition-colors hover:bg-accent/50"
onClick={onChange}
>
<div className="text-muted-foreground">{icon}</div>
<div className="flex-1">
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<Switch
checked={checked}
onCheckedChange={onChange}
onClick={(e) => e.stopPropagation()}
/>
</div>
)
}
// ── Migrating step (progress) ───────────────────────────────────
function MigratingStep({ currentStep, progress }: { currentStep: string; progress: number }) {
return (
<Card>
<CardHeader>
<CardTitle>Migrering pågår</CardTitle>
<CardDescription>
Vänta medan vi hämtar och importerar din bokföringsdata. Det kan ta några minuter.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<div className="flex justify-end">
<span className="text-xs text-muted-foreground">{progress}%</span>
</div>
<Progress value={progress} className="h-3" />
</div>
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<p className="text-sm">{currentStep}</p>
</div>
</CardContent>
</Card>
)
}
// ── Result step ─────────────────────────────────────────────────
function ResultStep({
results,
sieResults,
error,
onDone,
onRetry,
}: {
results: MigrationResults | null
sieResults: ImportResult[]
error: string | null
onDone: () => void
onRetry: () => void
}) {
if (error) {
return (
<div className="space-y-4">
<Card>
<CardContent className="pt-6">
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div>
<p className="font-medium text-destructive">Migreringen misslyckades</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
</CardContent>
</Card>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onDone}>Klar</Button>
<Button className="min-h-11" onClick={onRetry}>
<RotateCcw className="mr-2 h-4 w-4" />
Försök igen
</Button>
</div>
</div>
)
}
const hasResults = results || sieResults.length > 0
if (!hasResults) return null
// Compute combined SIE totals from all FY imports
const totalJournalEntries = sieResults.reduce((sum, r) => sum + r.journalEntriesCreated, 0)
const allSieErrors = sieResults.flatMap(r => r.errors)
const allSieWarnings = sieResults.flatMap(r => r.warnings)
const allSieSucceeded = sieResults.length > 0 && sieResults.every(r => r.success)
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
Migrering klar
</CardTitle>
<CardDescription>
Din bokföringsdata har importerats till gnubok.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
{/* SIE import results — combined summary */}
{sieResults.length > 0 && (
<ResultRow
label="Bokföringsdata (SIE)"
value={allSieSucceeded ? 'Importerad' : 'Delvis importerad'}
detail={totalJournalEntries > 0
? `${totalJournalEntries} verifikationer skapade (${sieResults.length} räkenskapsår)`
: `${sieResults.length} räkenskapsår importerade`
}
errors={allSieErrors}
warnings={allSieWarnings}
/>
)}
{/* API import results */}
{results?.companyInfo && (
<ResultRow label="Företagsinformation" value={results.companyInfo.imported ? 'Importerad' : 'Hoppad'} />
)}
{results?.customers && (
<ResultRow
label="Kunder"
value={`${results.customers.imported} importerade`}
detail={results.customers.skipped > 0 ? `${results.customers.skipped} fanns redan` : undefined}
/>
)}
{results?.suppliers && (
<ResultRow
label="Leverantörer"
value={`${results.suppliers.imported} importerade`}
detail={results.suppliers.skipped > 0 ? `${results.suppliers.skipped} fanns redan` : undefined}
/>
)}
{results?.salesInvoices && (
<ResultRow
label="Kundfakturor"
value={`${results.salesInvoices.imported} importerade`}
detail={results.salesInvoices.skipped > 0 ? `${results.salesInvoices.skipped} hoppade` : undefined}
/>
)}
{results?.supplierInvoices && (
<ResultRow
label="Leverantörsfakturor"
value={`${results.supplierInvoices.imported} importerade`}
detail={results.supplierInvoices.skipped > 0 ? `${results.supplierInvoices.skipped} hoppade` : undefined}
/>
)}
</div>
</CardContent>
</Card>
{/* Next steps guidance */}
<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="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground">
1
</div>
<div>
<p className="font-medium">Granska importerade verifikationer</p>
<p className="text-sm text-muted-foreground">Kontrollera att bokföringen ser korrekt ut</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground">
2
</div>
<div>
<p className="font-medium">Kontrollera kunder och leverantörer</p>
<p className="text-sm text-muted-foreground">Verifiera att kontaktuppgifter och bankinfo stämmer</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground">
3
</div>
<div>
<p className="font-medium">Verifiera balanserna i rapporterna</p>
<p className="text-sm text-muted-foreground">Jämför med ditt tidigare system</p>
</div>
</div>
</CardContent>
</Card>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onDone}>
<RotateCcw className="mr-2 h-4 w-4" />
Ny migrering
</Button>
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="outline" className="min-h-11" asChild>
<Link href="/customers">
Visa kunder
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button className="min-h-11" asChild>
<Link href="/bookkeeping">
Visa bokföring
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div>
)
}
function ResultRow({
label,
value,
detail,
errors,
warnings,
}: {
label: string
value: string
detail?: string
errors?: string[]
warnings?: string[]
}) {
return (
<div className="rounded-lg border border-border p-3">
<p className="text-sm font-medium">{label}</p>
<p className="text-sm text-muted-foreground">{value}</p>
{detail && <p className="text-xs text-muted-foreground">{detail}</p>}
{warnings && warnings.length > 0 && (
<p className="mt-1 text-xs text-warning-foreground">{warnings.join('. ')}</p>
)}
{errors && errors.length > 0 && (
<details className="mt-1">
<summary className="cursor-pointer text-xs text-destructive">
{errors.length} fel
</summary>
<ul className="mt-1 space-y-0.5 text-xs text-muted-foreground">
{errors.slice(0, 5).map((e, i) => <li key={i}>{e}</li>)}
{errors.length > 5 && <li>...och {errors.length - 5} till</li>}
</ul>
</details>
)}
</div>
)
}
// ── Main wizard ─────────────────────────────────────────────────
export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps) {
const { toast } = useToast()
const [step, setStep] = useState<WizardStep>('provider')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Connection state
const [selectedProvider, setSelectedProvider] = useState<ArcimProvider | null>(null)
const [consentId, setConsentId] = useState<string | null>(null)
const [authUrl, setAuthUrl] = useState<string | null>(null)
const [authType, setAuthType] = useState<'oauth' | 'token' | null>(null)
// Preview state
const [preview, setPreview] = useState<PreviewData | null>(null)
// SIE data state (held between mapping and execution steps)
const [sieData, setSieData] = useState<SIEData | null>(null)
// Options state
const [migrationOptions, setMigrationOptions] = useState<MigrationOptions>(DEFAULT_OPTIONS)
// Migration state
const [migrationStep, setMigrationStep] = useState('')
const [migrationProgress, setMigrationProgress] = useState(0)
const [migrationResults, setMigrationResults] = useState<MigrationResults | null>(null)
const [sieImportResults, setSieImportResults] = useState<ImportResult[]>([])
// 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
// ── 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.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)
}
}, [])
// 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
url.searchParams.delete('migration')
url.searchParams.delete('provider')
window.history.replaceState({}, '', url.pathname)
setError('OAuth-anslutningen misslyckades. Försök igen.')
if (callbackProvider) {
setSelectedProvider(callbackProvider)
setStep('connect')
} else {
setStep('provider')
}
}
}, [loadPreview])
// Check for OAuth callback on mount
useEffect(() => {
handleOAuthReturn()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 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)
// Auto-skip mapping step if all accounts are mapped
if (data.mappingStats.unmapped === 0) {
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([])
// Import all fiscal years' SIE content
for (let i = 0; i < sieData.rawContent.length; i++) {
const progress = 10 + Math.round((i / sieData.rawContent.length) * 40)
setMigrationProgress(progress)
setMigrationStep(`Importerar bokföringsdata (SIE) — fil ${i + 1} av ${sieData.rawContent.length}...`)
const res = await fetch('/api/extensions/ext/arcim-migration/import-sie', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rawContent: sieData.rawContent[i],
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])
if (!result.success && result.errors.length > 0) {
// Log but don't fail — continue with API import
// Non-critical — continue with API import
}
}
}
// ── 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)
}
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)
}, [])
// ── Render ─────────────────────────────────────────────────────
return (
<div className="space-y-6">
{/* Progress bar — only during interactive steps */}
{step !== 'provider' && isInteractiveStep && (
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
{userSteps.map((s) => (
<span
key={s}
className={userSteps.indexOf(s) <= currentUserStepIndex ? 'font-medium text-primary' : 'text-muted-foreground'}
>
{STEP_LABELS[s]}
</span>
))}
</div>
<Progress value={progressPercent} className="h-2" />
</div>
</CardContent>
</Card>
)}
{/* Step content */}
{step === 'provider' && (
<ProviderStep onSelect={handleSelectProvider} />
)}
{step === 'connect' && selectedProvider && (
<ConnectStep
provider={selectedProvider}
authType={authType}
isLoading={isLoading}
error={error}
authUrl={authUrl}
consentId={consentId}
onTokenSubmit={handleTokenSubmit}
onBack={() => {
setStep('provider')
setError(null)
}}
/>
)}
{step === 'preview' && (
<PreviewStep
preview={preview}
isLoading={isLoading}
error={error}
onContinue={handlePreviewContinue}
onBack={() => setStep('provider')}
/>
)}
{step === 'mapping' && (
<MappingStep
sieData={sieData}
isLoading={isLoading}
error={error}
onMappingChange={handleMappingChange}
onContinue={() => setStep('options')}
onBack={() => setStep('preview')}
/>
)}
{step === 'options' && (
<OptionsStep
options={migrationOptions}
sieAvailable={preview?.sieAvailable ?? false}
onChange={setMigrationOptions}
onStart={handleStartMigration}
onBack={() => preview?.sieAvailable ? setStep('mapping') : setStep('preview')}
/>
)}
{step === 'migrating' && (
<MigratingStep currentStep={migrationStep} progress={migrationProgress} />
)}
{step === 'result' && (
<ResultStep
results={migrationResults}
sieResults={sieImportResults}
error={error}
onDone={handleDone}
onRetry={() => {
setError(null)
setStep('options')
}}
/>
)}
</div>
)
}