* refactor: remove unnecessary secondary action from EmptyInvoices component

* feat: add direct provider layer and provider_consents migration

Replace Arcim Sync gateway dependency with direct provider clients
for Fortnox, Visma, Briox, Bokio, and Björn Lundén. Adds OAuth
config, rate limiting, retry logic, data fetching, and consent
storage via new provider_consents/tokens/otc tables.

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

* refactor: migrate arcim extension to direct provider APIs

Replace Arcim Sync gateway calls with direct provider API access.
Use FortnoxClient.getText() for SIE endpoints that return plain text
instead of JSON. OAuth callback now returns HTML with postMessage
to communicate with the opener window instead of redirecting.

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

* fix: use OAuth popup window instead of new tab

Open provider login in a centered popup that auto-closes on
completion via postMessage, keeping the user on a single tab.
Falls back to redirect flow if popup is blocked.

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

* feat: add connection status, consent reuse, and SIE duplicate detection

- Add listConsents() to query active consents by company
- Add GET /status route returning consents, SIE import history, and
  entity counts
- /connect reuses existing accepted consent instead of creating
  duplicates, and cleans up abandoned (status 0) consents
- /status only returns accepted (status 1) consents
- /sie-data checks each file's SHA-256 hash against sie_imports to
  report per-file import status (alreadyImported, importedAt)
- /sie-data blocks on SIE validation failure (mirrors manual upload)
- /import-sie validates unmapped accounts and auto-activates missing
  BAS accounts in chart_of_accounts (mirrors manual upload)

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

* feat: show active connections, SIE file status, and smart re-sync UI

- ProviderStep shows active connections with last import date, entity
  counts, "Synka igen" button, and disconnect option
- Already-connected providers greyed out in selection grid
- OptionsStep shows per-fiscal-year import status (imported vs new)
- SIE toggle disabled with explanation when all files already imported
- handleStartMigration skips already-imported SIE files
- Auto-skip mapping step and disable SIE on re-sync when up to date
- Result step hides empty "0 importerade" rows and shows
  "Allt är uppdaterat" when nothing new was fetched
- OptionRow supports disabled state

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

* fix: adjust COMING_SOON_PROVIDERS based on NODE_ENV for development and production

* Removed duplicate

* Removed duplicate

* refactor: redesign reports page navigation from grid boxes to bordered card layout

Replace the 4-column grid of uneven TabsList boxes with a CSS grid card
using auto-sized columns separated by 1px border dividers. All sections
now share equal height via items-stretch, with clear visual separation
between groups.

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

* chore: trigger Vercel deployment

* Update supabase/migrations/20260402010000_provider_consents.sql

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update lib/providers/rate-limiter.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* chore: re-trigger checks after migration sync

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Mattsson
2026-04-07 10:30:20 +02:00
committed by GitHub
co-authored by Claude Opus 4.6 greptile-apps[bot]
parent e2d9c8bb85
commit e42da5c32b
41 changed files with 5219 additions and 488 deletions
@@ -122,17 +122,57 @@ interface PreviewData {
} | null
}
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 ──────────────────────────────────────
const COMING_SOON_PROVIDERS = new Set<ArcimProvider>(['visma', 'bjornlunden', 'briox'])
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<ArcimProvider>(
process.env.NODE_ENV === 'development'
? ['bjornlunden', 'briox']
: ['visma', 'bjornlunden', 'briox']
)
const PROVIDER_LOGOS: Record<ArcimProvider, string> = {
fortnox: '/logos/fortnox.svg',
@@ -142,53 +182,159 @@ const PROVIDER_LOGOS: Record<ArcimProvider, string> = {
briox: '/logos/Briox_logo.png',
}
function ProviderStep({ onSelect }: { onSelect: (provider: ArcimProvider) => void }) {
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) ?? []
return (
<div className="space-y-4">
{/* Existing connections */}
{activeConsents.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Aktiva anslutningar</CardTitle>
<CardDescription>
Du har redan anslutna leverantörer. Synka igen för att hämta ny data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{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 (
<div
key={consent.id}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4"
>
<img
src={PROVIDER_LOGOS[consent.provider]}
alt={providerInfo?.name ?? consent.provider}
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">{providerInfo?.name ?? consent.provider}</p>
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
<CheckCircle className="h-3 w-3" />
Ansluten
</span>
</div>
<div className="mt-0.5 space-y-0.5">
{consent.companyName && (
<p className="text-xs text-muted-foreground">{consent.companyName}</p>
)}
{lastImport ? (
<p className="text-xs text-muted-foreground">
Senaste import: {new Date(lastImport.imported_at ?? lastImport.created_at).toLocaleDateString('sv-SE')}
{lastImport.transactions_count != null && ` — ${lastImport.transactions_count} verifikationer`}
</p>
) : (
<p className="text-xs text-muted-foreground">
Ansluten {consent.createdAt ? new Date(consent.createdAt).toLocaleDateString('sv-SE') : ''}
</p>
)}
{(connectionStatus?.entityCounts.customers ?? 0) > 0 && (
<p className="text-xs text-muted-foreground">
{connectionStatus?.entityCounts.customers} kunder, {connectionStatus?.entityCounts.suppliers} leverantörer, {connectionStatus?.entityCounts.invoices} fakturor
</p>
)}
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onResync(consent.provider, consent.id)}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Synka igen
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => onDisconnect(consent.id)}
>
<XCircle className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Provider selection */}
<Card>
<CardHeader>
<CardTitle>Välj ditt nuvarande bokföringssystem</CardTitle>
<CardTitle>{activeConsents.length > 0 ? 'Anslut ytterligare system' : '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>
)}
{isLoadingStatus ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{ARCIM_PROVIDERS.map((provider) => {
const comingSoon = COMING_SOON_PROVIDERS.has(provider.id)
const alreadyConnected = activeConsents.some(c => c.provider === provider.id)
return (
<button
key={provider.id}
disabled={comingSoon || alreadyConnected}
className={`relative flex items-center gap-4 rounded-lg border p-4 text-left transition-all ${
comingSoon || alreadyConnected
? 'cursor-not-allowed border-border/50 opacity-60'
: 'border-border hover:border-primary/50 hover:bg-accent/50 active:scale-[0.98]'
}`}
onClick={() => !comingSoon && !alreadyConnected && 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>
)}
{alreadyConnected && (
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
Ansluten
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{alreadyConnected ? 'Använd "Synka igen" ovan' : provider.authType === 'oauth' ? 'Anslut via inloggning' : 'Anslut med API-nyckel'}
</p>
</div>
<p className="text-xs text-muted-foreground">
{provider.authType === 'oauth' ? 'Anslut via inloggning' : 'Anslut med API-nyckel'}
</p>
</div>
</button>
)
})}
</div>
</button>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
@@ -285,14 +431,21 @@ function ConnectStep({
{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.
Klicka nedan för att logga in i {providerName}.
Fönstret stängs automatiskt när du är klar.
</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
className="min-h-11"
onClick={() => {
const w = 600
const h = 700
const left = window.screenX + (window.outerWidth - w) / 2
const top = window.screenY + (window.outerHeight - h) / 2
window.open(authUrl, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
}}
>
Logga in i {providerName}
<ExternalLink className="ml-2 h-4 w-4" />
</Button>
</div>
)}
@@ -532,12 +685,14 @@ function MappingStep({
function OptionsStep({
options,
sieAvailable,
sieData,
onChange,
onStart,
onBack,
}: {
options: MigrationOptions
sieAvailable: boolean
sieData: SIEData | null
onChange: (options: MigrationOptions) => void
onStart: () => void
onBack: () => void
@@ -548,6 +703,10 @@ function OptionsStep({
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)')
@@ -579,11 +738,39 @@ function OptionsStep({
<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')}
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}
/>
{options.importSIEData && (
{/* Per-file import status */}
{fileStatuses.length > 0 && (
<div className="ml-4 space-y-1.5">
{fileStatuses.map((fs) => (
<div key={fs.fiscalYear} className="flex items-center gap-2 text-xs">
{fs.alreadyImported ? (
<>
<CheckCircle className="h-3.5 w-3.5 text-emerald-500" />
<span className="text-muted-foreground">
Räkenskapsår {fs.fiscalYear} — importerad {fs.importedAt ? new Date(fs.importedAt).toLocaleDateString('sv-SE') : ''}
</span>
</>
) : (
<>
<Calendar className="h-3.5 w-3.5 text-primary" />
<span className="font-medium">Räkenskapsår {fs.fiscalYear} — ny data att importera</span>
</>
)}
</div>
))}
</div>
)}
{options.importSIEData && !allSieImported && (
<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" />
@@ -679,17 +866,22 @@ function OptionRow({
description,
checked,
onChange,
disabled,
}: {
icon: React.ReactNode
label: string
description: string
checked: boolean
onChange: () => void
disabled?: boolean
}) {
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}
className={cn(
'flex items-center gap-3 rounded-lg border border-border p-3 transition-colors',
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer hover:bg-accent/50'
)}
onClick={() => !disabled && onChange()}
>
<div className="text-muted-foreground">{icon}</div>
<div className="flex-1">
@@ -698,7 +890,8 @@ function OptionRow({
</div>
<Switch
checked={checked}
onCheckedChange={onChange}
onCheckedChange={() => !disabled && onChange()}
disabled={disabled}
onClick={(e) => e.stopPropagation()}
/>
</div>
@@ -994,6 +1187,16 @@ function ResultStep({
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
@@ -1004,13 +1207,16 @@ function ResultStep({
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5">
<StatusIcon status={overallIcon} />
{anySieFailed ? 'Migrering delvis genomförd' :
<StatusIcon status={nothingNew ? 'success' : overallIcon} />
{nothingNew ? 'Allt är uppdaterat' :
anySieFailed ? 'Migrering delvis genomförd' :
!allSieSucceeded ? 'Migrering klar med anmärkningar' :
'Migrering klar'}
</CardTitle>
<CardDescription className="text-sm">
{totalJournalEntries > 0 && (
{nothingNew ? (
'Det finns ingen ny data att importera från leverantören.'
) : totalJournalEntries > 0 ? (
<>
<span className="tabular-nums font-medium text-foreground">
{totalJournalEntries.toLocaleString('sv-SE')}
@@ -1023,7 +1229,7 @@ function ResultStep({
</span>
)}
</>
)}
) : null}
</CardDescription>
</CardHeader>
</Card>
@@ -1044,60 +1250,71 @@ function ResultStep({
)}
{/* ── API import results (company info, customers, etc.) ── */}
{results && (
<div className="space-y-2">
<h3 className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<FileText className="h-4 w-4" />
Övriga data
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{results.companyInfo && (
<EntityResultRow
icon={<Building2 className="h-4 w-4" />}
label="Företagsinformation"
status={results.companyInfo.imported ? 'success' : 'skipped'}
statusText={results.companyInfo.imported ? 'Importerad' : 'Hoppades över'}
/>
)}
{results.customers && (
<EntityResultRow
icon={<Users className="h-4 w-4" />}
label="Kunder"
status="success"
statusText={`${results.customers.imported} importerade`}
detail={results.customers.skipped > 0 ? `${results.customers.skipped} fanns redan` : undefined}
/>
)}
{results.suppliers && (
<EntityResultRow
icon={<Truck className="h-4 w-4" />}
label="Leverantörer"
status="success"
statusText={`${results.suppliers.imported} importerade`}
detail={results.suppliers.skipped > 0 ? `${results.suppliers.skipped} fanns redan` : undefined}
/>
)}
{results.salesInvoices && (
<EntityResultRow
icon={<FileText className="h-4 w-4" />}
label="Kundfakturor"
status="success"
statusText={`${results.salesInvoices.imported} importerade`}
detail={results.salesInvoices.skipped > 0 ? `${results.salesInvoices.skipped} hoppades över` : undefined}
/>
)}
{results.supplierInvoices && (
<EntityResultRow
icon={<FileText className="h-4 w-4" />}
label="Leverantörsfakturor"
status="success"
statusText={`${results.supplierInvoices.imported} importerade`}
detail={results.supplierInvoices.skipped > 0 ? `${results.supplierInvoices.skipped} hoppades över` : undefined}
/>
)}
{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 (
<div className="space-y-2">
<h3 className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<FileText className="h-4 w-4" />
Övriga data
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{hasCompanyInfo && (
<EntityResultRow
icon={<Building2 className="h-4 w-4" />}
label="Företagsinformation"
status="success"
statusText="Importerad"
/>
)}
{hasCustomers && (
<EntityResultRow
icon={<Users className="h-4 w-4" />}
label="Kunder"
status="success"
statusText={`${results.customers!.imported} importerade`}
detail={results.customers!.skipped > 0 ? `${results.customers!.skipped} fanns redan` : undefined}
/>
)}
{hasSuppliers && (
<EntityResultRow
icon={<Truck className="h-4 w-4" />}
label="Leverantörer"
status="success"
statusText={`${results.suppliers!.imported} importerade`}
detail={results.suppliers!.skipped > 0 ? `${results.suppliers!.skipped} fanns redan` : undefined}
/>
)}
{hasSalesInvoices && (
<EntityResultRow
icon={<FileText className="h-4 w-4" />}
label="Kundfakturor"
status="success"
statusText={`${results.salesInvoices!.imported} importerade`}
detail={results.salesInvoices!.skipped > 0 ? `${results.salesInvoices!.skipped} hoppades över` : undefined}
/>
)}
{hasSupplierInvoices && (
<EntityResultRow
icon={<FileText className="h-4 w-4" />}
label="Leverantörsfakturor"
status="success"
statusText={`${results.supplierInvoices!.imported} importerade`}
detail={results.supplierInvoices!.skipped > 0 ? `${results.supplierInvoices!.skipped} hoppades över` : undefined}
/>
)}
</div>
</div>
</div>
)}
)
})()}
{/* ── Next steps ── */}
<Card className="bg-muted/50">
@@ -1193,8 +1410,12 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
const [step, setStep] = useState<WizardStep>('provider')
const [isLoading, setIsLoading] = useState(false)
const [isLoadingStatus, setIsLoadingStatus] = useState(true)
const [error, setError] = useState<string | null>(null)
// Connection status (existing connections + import history)
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus | null>(null)
// Connection state
const [selectedProvider, setSelectedProvider] = useState<ArcimProvider | null>(null)
const [consentId, setConsentId] = useState<string | null>(null)
@@ -1228,6 +1449,28 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
? ((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) => {
@@ -1279,6 +1522,12 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
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)
}
@@ -1288,7 +1537,37 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
} 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) => {
@@ -1352,12 +1631,26 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
}
}, [loadPreview])
// Check for OAuth callback on mount
// 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') {
setError('OAuth-anslutningen misslyckades. Försök igen.')
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [loadPreview])
// Load SIE data when entering mapping step
const loadSIEData = useCallback(async () => {
if (!consentId) return
@@ -1376,8 +1669,13 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
const data = await res.json()
setSieData(data)
// Auto-skip mapping step if all accounts are mapped
if (data.mappingStats.unmapped === 0) {
// 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) {
@@ -1431,38 +1729,42 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
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}...`)
// 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)
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 (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}...`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `SIE import HTTP ${res.status}`)
}
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,
},
}),
})
const result = await res.json() as ImportResult
setSieImportResults(prev => [...prev, result])
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `SIE import HTTP ${res.status}`)
}
if (!result.success && result.errors.length > 0) {
// Log but don't fail — continue with API import
// Non-critical — continue with API import
const result = await res.json() as ImportResult
setSieImportResults(prev => [...prev, result])
}
}
}
@@ -1527,7 +1829,9 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setMigrationResults(null)
setSieImportResults([])
setError(null)
}, [])
// Refresh status so provider step shows updated import history
fetchStatus()
}, [fetchStatus])
// ── Render ─────────────────────────────────────────────────────
@@ -1562,7 +1866,13 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
{/* Step content */}
{step === 'provider' && (
<ProviderStep onSelect={handleSelectProvider} />
<ProviderStep
onSelect={handleSelectProvider}
onResync={handleResync}
onDisconnect={handleDisconnect}
connectionStatus={connectionStatus}
isLoadingStatus={isLoadingStatus}
/>
)}
{step === 'connect' && selectedProvider && (
@@ -1606,6 +1916,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
<OptionsStep
options={migrationOptions}
sieAvailable={preview?.sieAvailable ?? false}
sieData={sieData}
onChange={setMigrationOptions}
onStart={handleStartMigration}
onBack={() => preview?.sieAvailable ? setStep('mapping') : setStep('preview')}