Files
accounted/app/(dashboard)/import/page.tsx
T
Jakob WennbergandClaude Opus 5 3d02a74147 refactor(ui): system hygiene sweep from the UI craft audit (#1281)
Raw palette: SalaryCalendar was the only file in the salary cluster still
on bg-red-100 / text-amber-800 style classes, with zero dark: variants, so
every absence pill rendered paper-white on a near-black page. All 30 are
now alpha-over-token on the semantic scale, which needs no dark: variant
(the approach badge.tsx already takes). Eight absence types share four
tones, so fill carries a second axis: filled vs outlined separates the
types whose Lucide icon is identical (Heart is parental, pregnancy and
care_relative; Activity is study and other_leave).

Primitives:
- select and dropdown-menu now scale from their trigger via the Radix
  transform-origin variables instead of from their own middle. Dialog stays
  centred: modals are not anchored to a trigger.
- toast used fade-out-80, the one animate-* class with no @utility in
  globals.css, so --tw-exit-opacity fell back to 1 and the toast slid away
  at full opacity. Enter and exit now also share one path per breakpoint
  (top on mobile where the viewport is a full-width bar, right on desktop
  where it is a corner card) using max-sm:/sm: rather than stacking both,
  which would have produced a diagonal.
- slide-over exited on ease-in, which delays the moment the user has
  already decided to leave; it enters on the drawer curve and now leaves
  on it too.
- progress animated transition-all where only transform changes.
- One app-wide TooltipProvider in the root layout. skipDelayDuration is
  provider-scoped, so a provider per instance meant the grace window could
  never fire and every account number in a huvudbok re-paid the full 200ms.

Also: nine hand-rolled animate-pulse placeholders in three different greys
to the Skeleton primitive, 25 CardTitle text-lg to the locked text-base,
two always-on chips to muted text (a chip on every row is not an
exception), the three extensions titles onto the display face, the help
filter chips onto pill geometry, and the dead border-border/60 on the
/import row.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:33:40 +02:00

2347 lines
84 KiB
TypeScript

'use client'
import { useState, useCallback, useEffect } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { PageHeader } from '@/components/ui/page-header'
import { HelpPopover } from '@/components/ui/help-popover'
import { AttnLine } from '@/components/ui/attn-line'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ArrowLeft, CreditCard, Landmark, Loader2, ChevronRight, Download, AlertTriangle } from 'lucide-react'
import { cn, formatDate } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import type { OpeningBalanceParseResult, OpeningBalanceExecuteResult, DetectedColumns } from '@/lib/import/opening-balance/types'
// Register import (customers/suppliers) components
import RegisterColumnMappingStep, { type RegisterColumnSpec } from '@/components/import/RegisterColumnMappingStep'
import type { RegisterResult } from '@/components/import/RegisterResultStep'
import type {
CustomerImportParseResult,
AnnotatedCustomerRow,
DetectedCustomerColumns,
} from '@/lib/import/customers/types'
import type {
SupplierImportParseResult,
AnnotatedSupplierRow,
DetectedSupplierColumns,
} from '@/lib/import/suppliers/types'
import type {
ArticleImportParseResult,
AnnotatedArticleRow,
DetectedArticleColumns,
} from '@/lib/import/articles/types'
import type { ImportExecuteOptions } from '@/components/import/ImportReviewStep'
import { applyMappingOverride } from '@/lib/import/account-mapper'
import type { BankFileParseResult, BankFileFormatId, GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
import type { IngestResult } from '@/lib/transactions/ingest'
import type {
ImportWizardStep,
ParsedSIEFile,
AccountMapping,
ImportPreview,
ImportResult,
ParseIssue,
} from '@/lib/import/types'
import type { BASAccount } from '@/types'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import dynamic from 'next/dynamic'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import CloudBackupCard from '@/extensions/general/cloud-backup/components/CloudBackupCard'
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
const MigrationWizard = dynamic(
() => import('@/components/extensions/general/ArcimMigrationWorkspace'),
{ ssr: false, loading: () => <div className="flex items-center gap-3 text-muted-foreground p-6"><Loader2 className="h-5 w-5 animate-spin" />Laddar migreringsverktyg...</div> }
)
function ImportStepLoading() {
return (
<div className="flex min-h-48 items-center justify-center" role="status">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)
}
const BankFileUploadStep = dynamic(() => import('@/components/import/BankFileUploadStep'), { loading: ImportStepLoading })
const BankFilePreviewStep = dynamic(() => import('@/components/import/BankFilePreviewStep'), { loading: ImportStepLoading })
const BankFileColumnMappingStep = dynamic(() => import('@/components/import/BankFileColumnMappingStep'), { loading: ImportStepLoading })
const BankFileConfirmStep = dynamic(() => import('@/components/import/BankFileConfirmStep'), { loading: ImportStepLoading })
const BankFileResultStep = dynamic(() => import('@/components/import/BankFileResultStep'), { loading: ImportStepLoading })
const OpeningBalanceUploadStep = dynamic(() => import('@/components/import/OpeningBalanceUploadStep'), { loading: ImportStepLoading })
const OpeningBalanceColumnMappingStep = dynamic(() => import('@/components/import/OpeningBalanceColumnMappingStep'), { loading: ImportStepLoading })
const OpeningBalanceEditStep = dynamic(() => import('@/components/import/OpeningBalanceEditStep'), { loading: ImportStepLoading })
const OpeningBalancePeriodStep = dynamic(() => import('@/components/import/OpeningBalancePeriodStep'), { loading: ImportStepLoading })
const OpeningBalanceResultStep = dynamic(() => import('@/components/import/OpeningBalanceResultStep'), { loading: ImportStepLoading })
const RegisterUploadStep = dynamic(() => import('@/components/import/RegisterUploadStep'), { loading: ImportStepLoading })
const CustomersEditStep = dynamic(() => import('@/components/import/CustomersEditStep'), { loading: ImportStepLoading })
const SuppliersEditStep = dynamic(() => import('@/components/import/SuppliersEditStep'), { loading: ImportStepLoading })
const ArticlesEditStep = dynamic(() => import('@/components/import/ArticlesEditStep'), { loading: ImportStepLoading })
const RegisterResultStep = dynamic(() => import('@/components/import/RegisterResultStep'), { loading: ImportStepLoading })
const SIEUploadStep = dynamic(() => import('@/components/import/SIEUploadStep'), { loading: ImportStepLoading })
const SIEPreviewStep = dynamic(() => import('@/components/import/SIEPreviewStep'), { loading: ImportStepLoading })
const AccountMappingStep = dynamic(() => import('@/components/import/AccountMappingStep'), { loading: ImportStepLoading })
const ImportReviewStep = dynamic(() => import('@/components/import/ImportReviewStep'), { loading: ImportStepLoading })
const ImportResultStep = dynamic(() => import('@/components/import/ImportResultStep'), { loading: ImportStepLoading })
// ============================================================
// Bank File Import Wizard Steps
// ============================================================
type BankFileStep = 'upload' | 'preview' | 'column_mapping' | 'confirm' | 'result'
const BANK_STEPS: BankFileStep[] = ['upload', 'preview', 'confirm', 'result']
const BANK_STEPS_WITH_MAPPING: BankFileStep[] = ['upload', 'column_mapping', 'confirm', 'result']
const BANK_STEP_LABELS: Record<BankFileStep, string> = {
upload: 'Ladda upp',
preview: 'Förhandsgranskning',
column_mapping: 'Kolumnmappning',
confirm: 'Bekräfta',
result: 'Resultat',
}
function BankFileImportWizard() {
const { toast } = useToast()
const tTx = useTranslations('transactions')
const { company } = useCompany()
const [bankStep, setBankStep] = useState<BankFileStep>('upload')
const [bankIsLoading, setBankIsLoading] = useState(false)
const [bankError, setBankError] = useState<string | null>(null)
const [bankErrorTitle, setBankErrorTitle] = useState<string | null>(null)
// Parse results
const [parseResult, setParseResult] = useState<BankFileParseResult | null>(null)
const [detectedFormat, setDetectedFormat] = useState<string | null>(null)
const [detectedFormatName, setDetectedFormatName] = useState<string | null>(null)
const [fileHash, setFileHash] = useState<string>('')
const [filename, setFilename] = useState<string>('')
const [rawFileContent, setRawFileContent] = useState<string>('')
// Import result
const [ingestResult, setIngestResult] = useState<IngestResult | null>(null)
// Active PSD2 connections: drives an overlap warning so users don't
// accidentally upload a CSV covering periods we already sync nightly.
const [activePsd2Banks, setActivePsd2Banks] = useState<string[]>([])
useEffect(() => {
if (!company?.id) return
let cancelled = false
const supabase = createClient()
supabase
.from('bank_connections')
.select('bank_name')
.eq('company_id', company.id)
.eq('status', 'active')
.then(({ data }) => {
if (cancelled) return
const names = Array.from(new Set((data ?? []).map((r) => r.bank_name).filter(Boolean)))
setActivePsd2Banks(names)
})
return () => {
cancelled = true
}
}, [company?.id])
const steps = parseResult?.format === 'generic_csv' ? BANK_STEPS_WITH_MAPPING : BANK_STEPS
const currentStepIndex = steps.indexOf(bankStep)
const progress = ((currentStepIndex + 1) / steps.length) * 100
const handleFileSelect = useCallback(async (file: File, formatOverride?: BankFileFormatId) => {
setBankError(null)
setBankErrorTitle(null)
setBankIsLoading(true)
try {
const formData = new FormData()
formData.append('file', file)
if (formatOverride) {
formData.append('format', formatOverride)
}
const res = await fetch('/api/import/bank-file/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
// Structured error envelope: { error: { code, message, message_en, details } }
const err = data?.error
if (err && typeof err === 'object') {
if (err.code === 'BANK_FILE_DUPLICATE') {
const importedAt = err.details?.importedAt ? formatDate(err.details.importedAt) : null
const count = typeof err.details?.importedCount === 'number' ? err.details.importedCount : null
const when = importedAt
? ` ${importedAt}${count !== null ? ` (${count} transaktioner)` : ''}`
: ''
setBankErrorTitle('Filen är redan importerad')
setBankError(
`Den här filen är redan importerad${when}. Transaktionerna finns redan under Transaktioner. ` +
'Exportera en ny fil från banken om du vill lägga till fler transaktioner.'
)
} else {
setBankError(getErrorMessage(err) || 'Kunde inte läsa filen')
}
} else {
setBankError(typeof err === 'string' ? err : 'Kunde inte läsa filen')
}
return
}
setParseResult(data.data.parse_result)
setDetectedFormat(data.data.detected_format)
setDetectedFormatName(data.data.detected_format_name)
setFileHash(data.data.file_hash)
setFilename(data.data.filename)
// Read raw file content for CSV preview
const text = await file.text()
setRawFileContent(text)
const txCount = data.data.parse_result.transactions.length
if (data.data.parse_result.format === 'generic_csv') {
// Auto-detect failed or user picked "Annan CSV": always route to manual column mapping.
// Default mapping rarely matches, so advance regardless of tx count.
setBankStep('column_mapping')
} else if (txCount > 0) {
setBankStep('preview')
toast({
title: 'Fil analyserad',
description: `${txCount} transaktioner hittades`,
})
} else {
// Format detected but no transactions parsed: parser couldn't extract rows
setBankError('Filen kunde läsas men inga transaktioner hittades. Kontrollera att filen innehåller transaktionsdata och inte bara rubriker.')
}
} catch (err) {
setBankError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setBankIsLoading(false)
}
}, [toast])
const handleColumnMappingConfirm = useCallback(async (mapping: GenericCSVColumnMapping) => {
// Re-parse with mapping via the generic CSV parser
const { parseGenericCSV } = await import('@/lib/import/bank-file/formats/generic-csv')
const result = parseGenericCSV(rawFileContent, mapping)
setParseResult(result)
setBankStep('confirm')
}, [rawFileContent])
const handleExecuteImport = useCallback(async (options: { skip_duplicates: boolean; auto_categorize: boolean; settlement_account?: string }) => {
if (!parseResult) return
setBankIsLoading(true)
setBankError(null)
try {
const res = await fetch('/api/import/bank-file/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transactions: parseResult.transactions,
format: parseResult.format,
filename,
file_hash: fileHash,
...options,
}),
})
const data = await res.json()
if (!res.ok) {
// Map the parsed body plus the status: on the thrown-error path
// `data.error` is the canonical envelope OBJECT (withRouteContext),
// which must never be stored into a rendered string state.
setBankError(getErrorMessage(data, { statusCode: res.status }))
return
}
setIngestResult(data.data)
setBankStep('result')
toast({
title: 'Import genomförd',
description: `${data.data.imported} transaktioner importerades`,
})
} catch (err) {
setBankError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setBankIsLoading(false)
}
}, [parseResult, filename, fileHash, toast])
const handleNewImport = () => {
setBankStep('upload')
setParseResult(null)
setDetectedFormat(null)
setDetectedFormatName(null)
setFileHash('')
setFilename('')
setIngestResult(null)
setBankError(null)
setBankErrorTitle(null)
setRawFileContent('')
}
return (
<div className="space-y-6">
{/* Status chip for at-a-glance "auto-sync is healthy / stale / needs attention" */}
<BankSyncStatusChip />
{/* Overlap warning: active PSD2 means file import will likely create
duplicates of transactions the nightly sync already covers. */}
{activePsd2Banks.length > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
<div className="flex-1 text-sm">
<p className="font-medium">
{tTx('import_psd2_active_warning_title', { bankName: activePsd2Banks.join(', ') })}
</p>
<p className="mt-1 text-muted-foreground">
{tTx('import_psd2_active_warning_body')}
</p>
</div>
</div>
)}
{/* Progress */}
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="sm:hidden text-primary font-medium">
Steg {currentStepIndex + 1}/{steps.length}: {BANK_STEP_LABELS[bankStep]}
</span>
{steps.map((s, i) => (
<span
key={s}
className={cn(
'hidden sm:inline',
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground'
)}
>
{BANK_STEP_LABELS[s]}
</span>
))}
</div>
<Progress value={progress} className="h-2" />
</div>
</CardContent>
</Card>
{/* Step content */}
{bankStep === 'upload' && (
<BankFileUploadStep
onFileSelect={handleFileSelect}
isLoading={bankIsLoading}
error={bankError}
errorTitle={bankErrorTitle}
detectedFormat={detectedFormat}
detectedFormatName={detectedFormatName}
/>
)}
{bankStep === 'preview' && parseResult && (
<BankFilePreviewStep
parseResult={parseResult}
onContinue={() => {
if (parseResult.format === 'generic_csv') {
setBankStep('column_mapping')
} else {
setBankStep('confirm')
}
}}
onBack={() => setBankStep('upload')}
/>
)}
{bankStep === 'column_mapping' && (
<BankFileColumnMappingStep
rawFileContent={rawFileContent}
onConfirm={handleColumnMappingConfirm}
onBack={() => setBankStep('upload')}
/>
)}
{bankStep === 'confirm' && parseResult && (
<BankFileConfirmStep
parseResult={parseResult}
onExecute={handleExecuteImport}
onBack={() => {
if (parseResult.format === 'generic_csv') {
setBankStep('column_mapping')
} else {
setBankStep('preview')
}
}}
isLoading={bankIsLoading}
/>
)}
{bankStep === 'result' && ingestResult && (
<BankFileResultStep
result={ingestResult}
onNewImport={handleNewImport}
/>
)}
</div>
)
}
// ============================================================
// SIE Import Wizard (unchanged, extracted into component)
// ============================================================
const SIE_STEP_LABELS: Record<ImportWizardStep, string> = {
upload: 'Ladda upp',
preview: 'Förhandsgranskning',
mapping: 'Kontomappning',
review: 'Bekräfta',
result: 'Resultat',
}
function SIEImportWizard() {
const { toast } = useToast()
const [step, setStep] = useState<ImportWizardStep>('upload')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [errorType, setErrorType] = useState<'duplicate' | 'duplicate_period' | 'validation' | 'parse' | 'network' | undefined>()
const [validationErrors, setValidationErrors] = useState<string[]>([])
const [validationWarnings, setValidationWarnings] = useState<string[]>([])
const [duplicateImportId, setDuplicateImportId] = useState<string | null>(null)
const [isReplacing, setIsReplacing] = useState(false)
const [file, setFile] = useState<File | null>(null)
const [, setParsed] = useState<ParsedSIEFile | null>(null)
const [mappings, setMappings] = useState<AccountMapping[]>([])
const [basAccounts, setBasAccounts] = useState<BASAccount[]>([])
const [preview, setPreview] = useState<ImportPreview | null>(null)
const [issues, setIssues] = useState<ParseIssue[]>([])
const [importResult, setImportResult] = useState<ImportResult | null>(null)
const [, setSieAccounts] = useState<{ number: string; name: string }[]>([])
const [isCreatingAccounts, setIsCreatingAccounts] = useState(false)
// Skip the mapping step when all accounts are already mapped
const hasUnmapped = mappings.some((m) => !m.targetAccount)
const sieSteps: ImportWizardStep[] = hasUnmapped
? ['upload', 'preview', 'mapping', 'review', 'result']
: ['upload', 'preview', 'review', 'result']
const currentStepIndex = sieSteps.indexOf(step)
const progress = ((currentStepIndex + 1) / sieSteps.length) * 100
const handleFileSelect = useCallback(async (selectedFile: File) => {
setFile(selectedFile)
setError(null)
setErrorType(undefined)
setValidationErrors([])
setValidationWarnings([])
setIsLoading(true)
try {
const formData = new FormData()
formData.append('file', selectedFile)
const res = await fetch('/api/import/sie/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
const code = data?.error?.code as string | undefined
const message = getErrorMessage(data)
const details = (data?.error?.details ?? {}) as {
importId?: string
errors?: string[]
warnings?: string[]
}
if (code === 'SIE_DUPLICATE_FILE' || code === 'SIE_DUPLICATE_PERIOD') {
const isPeriod = code === 'SIE_DUPLICATE_PERIOD'
setErrorType(isPeriod ? 'duplicate_period' : 'duplicate')
setError(message)
if (details.importId) {
setDuplicateImportId(details.importId)
}
toast({
title: isPeriod ? 'Överlappande räkenskapsår' : 'Filen har redan importerats',
description: message,
variant: 'destructive',
})
} else if (code === 'SIE_PARSE_VALIDATION_FAILED') {
setErrorType('validation')
setError(message)
setValidationErrors(details.errors || [])
setValidationWarnings(details.warnings || [])
toast({
title: 'Valideringsfel i SIE-filen',
description: `${(details.errors || []).length} fel hittades som måste åtgärdas.`,
variant: 'destructive',
})
} else {
setErrorType('parse')
setError(message)
toast({ title: 'Kunde inte läsa filen', description: message, variant: 'destructive' })
}
return
}
setParsed({
header: data.parsed.header,
accounts: data.parsed.accounts,
openingBalances: [],
closingBalances: [],
resultBalances: [],
vouchers: [],
dimensions: [],
dimensionValues: [],
issues: data.parsed.issues,
stats: data.parsed.stats,
})
setMappings(data.mappings)
setPreview(data.preview)
setIssues(data.parsed.issues)
setSieAccounts(data.parsed.accounts)
const accountsRes = await fetch('/api/bookkeeping/accounts')
if (accountsRes.ok) {
const accountsData = await accountsRes.json()
setBasAccounts(accountsData.data || [])
}
setStep('preview')
toast({
title: 'Fil analyserad',
description: `${data.parsed.stats.totalAccounts} konton och ${data.parsed.stats.totalVouchers} verifikationer hittades`,
})
} catch (err) {
const isNetworkError = err instanceof TypeError && (err.message === 'Failed to fetch' || err.message.includes('NetworkError'))
const message = isNetworkError
? 'Kunde inte nå servern. Kontrollera din internetanslutning och försök igen.'
: getErrorMessage(err)
setErrorType(isNetworkError ? 'network' : 'parse')
setError(message)
toast({ title: isNetworkError ? 'Anslutningsfel' : 'Ett fel uppstod', description: message, variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
const handleUndo = useCallback(async (importId: string) => {
setIsLoading(true)
try {
const res = await fetch(`/api/import/sie/${importId}/undo`, { method: 'DELETE' })
const data = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte ångra import', description: getErrorMessage(data), variant: 'destructive' })
return
}
toast({
title: 'Import ångrad',
description: `${data.deletedEntries} verifikation${data.deletedEntries === 1 ? '' : 'er'} raderades.`,
})
// Reset wizard to upload step so the user can re-import a corrected file
setStep('upload')
setFile(null)
setParsed(null)
setMappings([])
setPreview(null)
setIssues([])
setImportResult(null)
setError(null)
setErrorType(undefined)
setValidationErrors([])
setValidationWarnings([])
setDuplicateImportId(null)
setSieAccounts([])
} catch {
toast({ title: 'Anslutningsfel', description: 'Kunde inte nå servern.', variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
const handleReplace = useCallback(async (importId: string) => {
if (!file) return
setIsReplacing(true)
try {
const res = await fetch(`/api/import/sie/${importId}/replace`, { method: 'POST' })
const data = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte ersätta import', description: getErrorMessage(data), variant: 'destructive' })
return
}
toast({
title: 'Import ersatt',
description: `${data.deletedEntries} verifikation${data.deletedEntries === 1 ? '' : 'er'} raderades. Importerar ny fil...`,
})
// Clear error state and re-trigger the file upload
setError(null)
setErrorType(undefined)
setDuplicateImportId(null)
// Small delay so the user sees the success toast before re-upload starts
await new Promise(resolve => setTimeout(resolve, 500))
handleFileSelect(file)
} catch {
toast({ title: 'Anslutningsfel', description: 'Kunde inte nå servern.', variant: 'destructive' })
} finally {
setIsReplacing(false)
}
}, [file, handleFileSelect, toast])
const handleMappingChange = useCallback((sourceAccount: string, targetAccount: string, targetName: string) => {
setMappings((prev) => applyMappingOverride(prev, sourceAccount, targetAccount, targetName))
setPreview((prev) => {
if (!prev) return prev
const updatedMappings = applyMappingOverride(mappings, sourceAccount, targetAccount, targetName)
const mapped = updatedMappings.filter((m) => m.targetAccount).length
const unmapped = updatedMappings.length - mapped
const lowConfidence = updatedMappings.filter((m) => m.targetAccount && m.confidence < 0.7).length
return {
...prev,
mappingStatus: {
...prev.mappingStatus,
mapped,
unmapped,
lowConfidence,
},
}
})
}, [mappings])
const missingAccounts = mappings
.filter((m) => !m.targetAccount)
.map((m) => ({ number: m.sourceAccount, name: m.sourceName }))
const handleCreateAccounts = useCallback(async () => {
if (missingAccounts.length === 0) return
setIsCreatingAccounts(true)
try {
const res = await fetch('/api/import/sie/create-accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accounts: missingAccounts }),
})
const data = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte skapa konton', description: getErrorMessage(data), variant: 'destructive' })
return
}
toast({ title: 'Konton skapade', description: `${data.created} nya konton har lagts till i din kontoplan` })
// Optimistically update mappings: mark created accounts as self-mapped
const createdSet = new Set(missingAccounts.map(a => a.number))
setMappings(prev => prev.map(m =>
!m.targetAccount && createdSet.has(m.sourceAccount)
? { ...m, targetAccount: m.sourceAccount, targetName: m.sourceName, confidence: 1.0 }
: m
))
setPreview(prev => {
if (!prev) return prev
const newMapped = prev.mappingStatus.mapped + createdSet.size
return {
...prev,
mappingStatus: {
...prev.mappingStatus,
mapped: newMapped,
unmapped: Math.max(0, prev.mappingStatus.unmapped - createdSet.size),
},
}
})
// Also refresh BAS accounts list
const accountsRes = await fetch('/api/bookkeeping/accounts')
if (accountsRes.ok) {
const accountsData = await accountsRes.json()
setBasAccounts(accountsData.data || [])
}
} catch (err) {
toast({ title: 'Kunde inte skapa konton', description: err instanceof Error ? getErrorMessage(err) : 'Försök igen.', variant: 'destructive' })
} finally {
setIsCreatingAccounts(false)
}
}, [missingAccounts, toast])
const handleExecuteImport = useCallback(async (options: ImportExecuteOptions) => {
if (!file) { setError('No file selected'); return }
setIsLoading(true)
setError(null)
try {
const formData = new FormData()
formData.append('file', file)
formData.append('mappings', JSON.stringify(mappings))
formData.append('options', JSON.stringify(options))
const res = await fetch('/api/import/sie/execute', { method: 'POST', body: formData })
const data = await res.json()
if (!res.ok) {
const code = data?.error?.code as string | undefined
const message = getErrorMessage(data)
const failedResult = data?.error?.details?.result as typeof data.result | undefined
if (code === 'SIE_DUPLICATE_FILE' || code === 'SIE_DUPLICATE_PERIOD') {
setError(message)
toast({ title: 'Filen har redan importerats', description: message, variant: 'destructive' })
return
}
if (failedResult) {
setImportResult(failedResult)
} else {
setError(message)
toast({ title: 'Import misslyckades', description: message, variant: 'destructive' })
return
}
} else {
setImportResult(data.result)
}
setStep('result')
if (data.result?.success) {
const created = data.result.journalEntriesCreated
const skipped = data.result.details?.skippedVouchers?.total || 0
toast({
title: 'Import genomförd',
description: `${created} verifikationer skapades${skipped > 0 ? ` (${skipped} hoppades över)` : ''}`,
})
} else if (data.result && !data.result.success) {
toast({
title: 'Import slutförd med problem',
description: `${data.result.errors?.length || 0} fel uppstod under importen. Se resultatet för detaljer.`,
variant: 'destructive',
})
}
} catch (err) {
const isNetworkError = err instanceof TypeError && (err.message === 'Failed to fetch' || err.message.includes('NetworkError'))
const msg = isNetworkError
? 'Tappade anslutningen till servern under importen. Kontrollera din internetanslutning och se om importen genomfördes under Bokföring.'
: getErrorMessage(err)
setError(msg)
toast({ title: 'Import avbröts', description: msg, variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [file, mappings, toast])
const goToStep = (targetStep: ImportWizardStep) => { setStep(targetStep); setError(null); setValidationErrors([]); setValidationWarnings([]) }
const goBack = () => { const i = sieSteps.indexOf(step); if (i > 0) setStep(sieSteps[i - 1]) }
const handleNewImport = () => {
setStep('upload'); setFile(null); setParsed(null); setMappings([])
setPreview(null); setIssues([]); setImportResult(null); setError(null); setErrorType(undefined)
setValidationErrors([]); setValidationWarnings([]); setDuplicateImportId(null)
setSieAccounts([]); setIsCreatingAccounts(false)
}
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="sm:hidden text-primary font-medium">
Steg {currentStepIndex + 1}/{sieSteps.length}: {SIE_STEP_LABELS[step]}
</span>
{sieSteps.map((s, i) => (
<span key={s} className={cn(
'hidden sm:inline',
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground'
)}>
{SIE_STEP_LABELS[s]}
</span>
))}
</div>
<Progress value={progress} className="h-2" />
</div>
</CardContent>
</Card>
{step === 'upload' && <SIEUploadStep onFileSelect={handleFileSelect} isLoading={isLoading} error={error} errorType={errorType} validationErrors={validationErrors} validationWarnings={validationWarnings} duplicateImportId={duplicateImportId} onReplace={handleReplace} isReplacing={isReplacing} />}
{step === 'preview' && preview && (
<SIEPreviewStep preview={preview} issues={issues} missingAccounts={missingAccounts}
onCreateAccounts={handleCreateAccounts} isCreatingAccounts={isCreatingAccounts}
onContinue={() => goToStep(hasUnmapped ? 'mapping' : 'review')} onBack={goBack} />
)}
{step === 'mapping' && (
<AccountMappingStep mappings={mappings} basAccounts={basAccounts}
onMappingChange={handleMappingChange} onContinue={() => goToStep('review')} onBack={goBack} />
)}
{step === 'review' && preview && (
<ImportReviewStep preview={preview} mappings={mappings}
onExecute={handleExecuteImport} onBack={goBack} isLoading={isLoading} />
)}
{step === 'result' && importResult && <ImportResultStep result={importResult} onNewImport={handleNewImport} onUndo={handleUndo} />}
</div>
)
}
// ============================================================
// Opening Balance Flow (entity = "opening_balance" inside CSVDataImportWizard)
// ============================================================
type OpeningBalanceStep = 'upload' | 'column_mapping' | 'edit' | 'period' | 'result'
const OB_STEP_LABELS: Record<OpeningBalanceStep, string> = {
upload: 'Ladda upp',
column_mapping: 'Kolumnmappning',
edit: 'Granska',
period: 'Period',
result: 'Resultat',
}
function OpeningBalanceFlow() {
const { toast } = useToast()
const { dialogProps, confirm } = useDestructiveConfirm()
const router = useRouter()
const [obStep, setObStep] = useState<OpeningBalanceStep>('upload')
const [obIsLoading, setObIsLoading] = useState(false)
const [obError, setObError] = useState<string | null>(null)
const [obBankFormatHint, setObBankFormatHint] = useState<string | null>(null)
const [obFile, setObFile] = useState<File | null>(null)
const [parseResult, setParseResult] = useState<OpeningBalanceParseResult | null>(null)
const [editedRows, setEditedRows] = useState<{
id: string; account_number: string; account_name: string
debit_amount: number; credit_amount: number
}[]>([])
const [executeResult, setExecuteResult] = useState<OpeningBalanceExecuteResult | null>(null)
// Determine steps: skip column mapping if confidence >= 0.8
const needsMapping = parseResult && parseResult.detected_columns.confidence < 0.8
const steps: OpeningBalanceStep[] = needsMapping
? ['upload', 'column_mapping', 'edit', 'period', 'result']
: ['upload', 'edit', 'period', 'result']
const currentStepIndex = steps.indexOf(obStep)
const progress = ((currentStepIndex + 1) / steps.length) * 100
const handleFileSelect = useCallback(async (file: File) => {
setObError(null)
setObBankFormatHint(null)
setObIsLoading(true)
setObFile(file)
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/import/opening-balance/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
// Map the parsed body plus the status, never render `data.error` raw:
// the thrown-error path answers with the canonical envelope OBJECT.
setObError(getErrorMessage(data, { statusCode: res.status }))
return
}
const result: OpeningBalanceParseResult = data.data
setParseResult(result)
if (result.rows.length === 0) {
if (result.detected_bank_format) {
// The file is a bank statement uploaded to the wrong importer (#918)
setObBankFormatHint(result.detected_bank_format)
setObError(`Filen ser ut som ett kontoutdrag från ${result.detected_bank_format}, inte ingående balanser. Kontoutdrag importeras under "Banktransaktioner".`)
} else {
setObError('Inga konton med belopp hittades i filen. Kontrollera att filen innehåller kontonummer och belopp.')
}
return
}
toast({
title: 'Fil analyserad',
description: `${result.rows.length} konton hittades`,
})
// Skip column mapping if confidence >= 0.8
if (result.detected_columns.confidence < 0.8) {
setObStep('column_mapping')
} else {
setObStep('edit')
}
} catch (err) {
setObError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setObIsLoading(false)
}
}, [toast])
const handleColumnMappingConfirm = useCallback(async (columns: DetectedColumns) => {
if (!obFile) return
setObIsLoading(true)
setObError(null)
try {
const formData = new FormData()
formData.append('file', obFile)
formData.append('column_overrides', JSON.stringify(columns))
const res = await fetch('/api/import/opening-balance/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
setObError(getErrorMessage(data, { statusCode: res.status }))
return
}
setParseResult(data.data)
setObStep('edit')
} catch (err) {
setObError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setObIsLoading(false)
}
}, [obFile])
const handleEditContinue = useCallback((rows: typeof editedRows) => {
setEditedRows(rows)
setObStep('period')
}, [])
const handleExecute = useCallback(async (fiscalPeriodId: string, replace: boolean) => {
if (replace) {
const ok = await confirm({
title: 'Ersätt ingående balanser?',
description:
'Den befintliga IB-verifikationen makuleras (stornas) och en ny bokförs med beloppen du angett. Detta går inte att ångra automatiskt.',
confirmLabel: 'Ersätt',
variant: 'warning',
})
if (!ok) return
}
setObIsLoading(true)
setObError(null)
const endpoint = replace
? '/api/import/opening-balance/correct'
: '/api/import/opening-balance/execute'
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fiscal_period_id: fiscalPeriodId,
lines: editedRows.map((r) => ({
account_number: r.account_number,
debit_amount: r.debit_amount,
credit_amount: r.credit_amount,
})),
}),
})
const data = await res.json()
if (!res.ok) {
setObError(getErrorMessage(data))
return
}
setExecuteResult(data.data)
setObStep('result')
if (data.data.success) {
toast({
title: replace ? 'Ingående balanser korrigerade' : 'Ingående balanser bokförda',
description: `${data.data.lines_created} kontorader skapades`,
})
}
} catch (err) {
setObError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setObIsLoading(false)
}
}, [editedRows, toast, confirm])
const handleNewImport = () => {
setObStep('upload')
setObFile(null)
setParseResult(null)
setEditedRows([])
setExecuteResult(null)
setObError(null)
setObBankFormatHint(null)
}
return (
<div className="space-y-6">
{/* Progress */}
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="sm:hidden text-primary font-medium">
Steg {currentStepIndex + 1}/{steps.length}: {OB_STEP_LABELS[obStep]}
</span>
{steps.map((s, i) => (
<span
key={s}
className={cn(
'hidden sm:inline',
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground',
)}
>
{OB_STEP_LABELS[s]}
</span>
))}
</div>
<Progress value={progress} className="h-2" />
</div>
</CardContent>
</Card>
{/* Step content */}
{obStep === 'upload' && (
<OpeningBalanceUploadStep
onFileSelect={handleFileSelect}
isLoading={obIsLoading}
error={obError}
errorAction={
obBankFormatHint
? { label: 'Importera banktransaktioner', onClick: () => router.push('/import?mode=bank') }
: undefined
}
/>
)}
{obStep === 'column_mapping' && parseResult && (
<OpeningBalanceColumnMappingStep
headers={parseResult.headers}
previewRows={parseResult.preview_rows}
detectedColumns={parseResult.detected_columns}
onConfirm={handleColumnMappingConfirm}
onBack={() => setObStep('upload')}
/>
)}
{obStep === 'edit' && parseResult && (
<OpeningBalanceEditStep
rows={parseResult.rows}
onContinue={handleEditContinue}
onBack={() => {
if (needsMapping) {
setObStep('column_mapping')
} else {
setObStep('upload')
}
}}
/>
)}
{obStep === 'period' && (
<OpeningBalancePeriodStep
rows={editedRows}
onExecute={handleExecute}
onBack={() => setObStep('edit')}
isLoading={obIsLoading}
error={obError}
/>
)}
{obStep === 'result' && executeResult && (
<OpeningBalanceResultStep
result={executeResult}
onNewImport={handleNewImport}
/>
)}
<DestructiveConfirmDialog {...dialogProps} />
</div>
)
}
// ============================================================
// Customers Flow (entity = "customers" inside CSVDataImportWizard)
// ============================================================
type RegisterStep = 'upload' | 'column_mapping' | 'edit' | 'result'
const REGISTER_STEP_LABELS: Record<RegisterStep, string> = {
upload: 'Ladda upp',
column_mapping: 'Kolumnmappning',
edit: 'Granska',
result: 'Resultat',
}
const CUSTOMER_COLUMN_SPECS: RegisterColumnSpec<keyof DetectedCustomerColumns>[] = [
{ key: 'name_col', label: 'Namn', required: true },
{ key: 'org_number_col', label: 'Org-/personnummer', required: false },
{ key: 'customer_type_col', label: 'Kundtyp', required: false },
{ key: 'email_col', label: 'E-post', required: false },
{ key: 'phone_col', label: 'Telefon', required: false },
{ key: 'address_line1_col', label: 'Adress', required: false },
{ key: 'address_line2_col', label: 'Adress rad 2', required: false },
{ key: 'postal_code_col', label: 'Postnummer', required: false },
{ key: 'city_col', label: 'Ort', required: false },
{ key: 'country_col', label: 'Land', required: false },
{ key: 'vat_number_col', label: 'VAT-nummer', required: false },
{ key: 'payment_terms_col', label: 'Betalningsvillkor (dagar)', required: false },
{ key: 'notes_col', label: 'Anteckning', required: false },
]
function columnsToMapping<K extends string>(
cols: { readonly [key: string]: unknown },
specs: RegisterColumnSpec<K>[],
): Record<K, number | null> {
const out = {} as Record<K, number | null>
for (const spec of specs) {
const v = cols[spec.key as string]
out[spec.key] = typeof v === 'number' ? v : null
}
return out
}
function CustomersFlow() {
const { toast } = useToast()
const [step, setStep] = useState<RegisterStep>('upload')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [file, setFile] = useState<File | null>(null)
const [parseResult, setParseResult] = useState<CustomerImportParseResult | null>(null)
const [executeResult, setExecuteResult] = useState<RegisterResult | null>(null)
const needsMapping = parseResult && parseResult.detected_columns.confidence < 0.8
const steps: RegisterStep[] = needsMapping
? ['upload', 'column_mapping', 'edit', 'result']
: ['upload', 'edit', 'result']
const currentStepIndex = steps.indexOf(step)
const progress = ((currentStepIndex + 1) / steps.length) * 100
const handleFileSelect = useCallback(async (selectedFile: File) => {
setError(null)
setIsLoading(true)
setFile(selectedFile)
try {
const formData = new FormData()
formData.append('file', selectedFile)
const res = await fetch('/api/import/customers/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || getErrorMessage(data.error) || data.error || 'Kunde inte läsa filen')
return
}
const result = data.data as CustomerImportParseResult
setParseResult(result)
if (result.rows.length === 0) {
setError('Inga giltiga kundrader hittades. Kontrollera att filen innehåller en namnkolumn.')
return
}
toast({
title: 'Fil analyserad',
description: `${result.rows.length} kunder hittades${result.duplicate_count > 0 ? ` (${result.duplicate_count} matchar befintliga)` : ''}`,
})
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
} catch (err) {
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
}, [toast])
const handleColumnMappingConfirm = useCallback(async (
mapping: Record<keyof DetectedCustomerColumns, number | null>,
) => {
if (!file) return
setIsLoading(true)
setError(null)
try {
const overrides: DetectedCustomerColumns = {
name_col: mapping.name_col ?? 0,
org_number_col: mapping.org_number_col,
customer_type_col: mapping.customer_type_col,
email_col: mapping.email_col,
phone_col: mapping.phone_col,
address_line1_col: mapping.address_line1_col,
address_line2_col: mapping.address_line2_col,
postal_code_col: mapping.postal_code_col,
city_col: mapping.city_col,
country_col: mapping.country_col,
vat_number_col: mapping.vat_number_col,
payment_terms_col: mapping.payment_terms_col,
notes_col: mapping.notes_col,
confidence: 1,
}
const formData = new FormData()
formData.append('file', file)
formData.append('column_overrides', JSON.stringify(overrides))
const res = await fetch('/api/import/customers/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Kunde inte tolka filen med de valda kolumnerna')
return
}
setParseResult(data.data)
setStep('edit')
} catch (err) {
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
}, [file])
const handleExecute = useCallback(async (
rows: AnnotatedCustomerRow[],
updateDuplicates: boolean,
) => {
setIsLoading(true)
setError(null)
try {
const res = await fetch('/api/import/customers/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rows: rows.map(({ duplicate_match: _dup, is_valid: _v, validation_errors: _ve, ...rest }) => rest),
update_duplicates: updateDuplicates,
}),
})
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Importen misslyckades')
return
}
setExecuteResult(data.data as RegisterResult)
setStep('result')
const r = data.data as RegisterResult
toast({
title: r.success ? 'Kunder importerade' : 'Importen slutfördes med fel',
description: `${r.created} skapade, ${r.updated} uppdaterade, ${r.skipped} hoppade över${r.failed > 0 ? `, ${r.failed} misslyckades` : ''}`,
variant: r.success ? 'default' : 'destructive',
})
} catch (err) {
setError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setIsLoading(false)
}
}, [toast])
const handleNewImport = () => {
setStep('upload')
setFile(null)
setParseResult(null)
setExecuteResult(null)
setError(null)
}
const initialMapping = parseResult
? columnsToMapping<keyof DetectedCustomerColumns>(parseResult.detected_columns as unknown as { [key: string]: unknown }, CUSTOMER_COLUMN_SPECS)
: null
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="sm:hidden text-primary font-medium">
Steg {currentStepIndex + 1}/{steps.length}: {REGISTER_STEP_LABELS[step]}
</span>
{steps.map((s, i) => (
<span
key={s}
className={cn(
'hidden sm:inline',
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground',
)}
>
{REGISTER_STEP_LABELS[s]}
</span>
))}
</div>
<Progress value={progress} className="h-2" />
</div>
</CardContent>
</Card>
{step === 'upload' && (
<RegisterUploadStep
entity="customers"
onFileSelect={handleFileSelect}
isLoading={isLoading}
error={error}
/>
)}
{step === 'column_mapping' && parseResult && initialMapping && (
<RegisterColumnMappingStep<keyof DetectedCustomerColumns>
headers={parseResult.headers}
previewRows={parseResult.preview_rows}
specs={CUSTOMER_COLUMN_SPECS}
initial={initialMapping}
onConfirm={handleColumnMappingConfirm}
onBack={() => setStep('upload')}
/>
)}
{step === 'edit' && parseResult && (
<CustomersEditStep
rows={parseResult.rows}
onExecute={handleExecute}
onBack={() => setStep(needsMapping ? 'column_mapping' : 'upload')}
isLoading={isLoading}
error={error}
/>
)}
{step === 'result' && executeResult && (
<RegisterResultStep
entity="customers"
result={executeResult}
onNewImport={handleNewImport}
/>
)}
</div>
)
}
// ============================================================
// Suppliers Flow (entity = "suppliers" inside CSVDataImportWizard)
// ============================================================
const SUPPLIER_COLUMN_SPECS: RegisterColumnSpec<keyof DetectedSupplierColumns>[] = [
{ key: 'name_col', label: 'Namn', required: true },
{ key: 'org_number_col', label: 'Org-/personnummer', required: false },
{ key: 'supplier_type_col', label: 'Leverantörstyp', required: false },
{ key: 'email_col', label: 'E-post', required: false },
{ key: 'phone_col', label: 'Telefon', required: false },
{ key: 'address_line1_col', label: 'Adress', required: false },
{ key: 'address_line2_col', label: 'Adress rad 2', required: false },
{ key: 'postal_code_col', label: 'Postnummer', required: false },
{ key: 'city_col', label: 'Ort', required: false },
{ key: 'country_col', label: 'Land', required: false },
{ key: 'vat_number_col', label: 'VAT-nummer', required: false },
{ key: 'bankgiro_col', label: 'Bankgiro', required: false },
{ key: 'plusgiro_col', label: 'Plusgiro', required: false },
{ key: 'bank_account_col', label: 'Bankkonto', required: false },
{ key: 'iban_col', label: 'IBAN', required: false },
{ key: 'bic_col', label: 'BIC/SWIFT', required: false },
{ key: 'payment_terms_col', label: 'Betalningsvillkor (dagar)', required: false },
{ key: 'default_currency_col', label: 'Valuta', required: false },
{ key: 'notes_col', label: 'Anteckning', required: false },
]
function SuppliersFlow() {
const { toast } = useToast()
const [step, setStep] = useState<RegisterStep>('upload')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [file, setFile] = useState<File | null>(null)
const [parseResult, setParseResult] = useState<SupplierImportParseResult | null>(null)
const [executeResult, setExecuteResult] = useState<RegisterResult | null>(null)
const needsMapping = parseResult && parseResult.detected_columns.confidence < 0.8
const steps: RegisterStep[] = needsMapping
? ['upload', 'column_mapping', 'edit', 'result']
: ['upload', 'edit', 'result']
const currentStepIndex = steps.indexOf(step)
const progress = ((currentStepIndex + 1) / steps.length) * 100
const handleFileSelect = useCallback(async (selectedFile: File) => {
setError(null)
setIsLoading(true)
setFile(selectedFile)
try {
const formData = new FormData()
formData.append('file', selectedFile)
const res = await fetch('/api/import/suppliers/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || getErrorMessage(data.error) || data.error || 'Kunde inte läsa filen')
return
}
const result = data.data as SupplierImportParseResult
setParseResult(result)
if (result.rows.length === 0) {
setError('Inga giltiga leverantörsrader hittades. Kontrollera att filen innehåller en namnkolumn.')
return
}
toast({
title: 'Fil analyserad',
description: `${result.rows.length} leverantörer hittades${result.duplicate_count > 0 ? ` (${result.duplicate_count} matchar befintliga)` : ''}`,
})
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
} catch (err) {
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
}, [toast])
const handleColumnMappingConfirm = useCallback(async (
mapping: Record<keyof DetectedSupplierColumns, number | null>,
) => {
if (!file) return
setIsLoading(true)
setError(null)
try {
const overrides: DetectedSupplierColumns = {
name_col: mapping.name_col ?? 0,
org_number_col: mapping.org_number_col,
supplier_type_col: mapping.supplier_type_col,
email_col: mapping.email_col,
phone_col: mapping.phone_col,
address_line1_col: mapping.address_line1_col,
address_line2_col: mapping.address_line2_col,
postal_code_col: mapping.postal_code_col,
city_col: mapping.city_col,
country_col: mapping.country_col,
vat_number_col: mapping.vat_number_col,
bankgiro_col: mapping.bankgiro_col,
plusgiro_col: mapping.plusgiro_col,
bank_account_col: mapping.bank_account_col,
iban_col: mapping.iban_col,
bic_col: mapping.bic_col,
payment_terms_col: mapping.payment_terms_col,
default_currency_col: mapping.default_currency_col,
notes_col: mapping.notes_col,
confidence: 1,
}
const formData = new FormData()
formData.append('file', file)
formData.append('column_overrides', JSON.stringify(overrides))
const res = await fetch('/api/import/suppliers/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Kunde inte tolka filen')
return
}
setParseResult(data.data)
setStep('edit')
} catch (err) {
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
}, [file])
const handleExecute = useCallback(async (
rows: AnnotatedSupplierRow[],
updateDuplicates: boolean,
) => {
setIsLoading(true)
setError(null)
try {
const res = await fetch('/api/import/suppliers/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rows: rows.map(({ duplicate_match: _dup, is_valid: _v, validation_errors: _ve, ...rest }) => rest),
update_duplicates: updateDuplicates,
}),
})
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Importen misslyckades')
return
}
setExecuteResult(data.data as RegisterResult)
setStep('result')
const r = data.data as RegisterResult
toast({
title: r.success ? 'Leverantörer importerade' : 'Importen slutfördes med fel',
description: `${r.created} skapade, ${r.updated} uppdaterade, ${r.skipped} hoppade över${r.failed > 0 ? `, ${r.failed} misslyckades` : ''}`,
variant: r.success ? 'default' : 'destructive',
})
} catch (err) {
setError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setIsLoading(false)
}
}, [toast])
const handleNewImport = () => {
setStep('upload')
setFile(null)
setParseResult(null)
setExecuteResult(null)
setError(null)
}
const initialMapping = parseResult
? columnsToMapping<keyof DetectedSupplierColumns>(parseResult.detected_columns as unknown as { [key: string]: unknown }, SUPPLIER_COLUMN_SPECS)
: null
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="sm:hidden text-primary font-medium">
Steg {currentStepIndex + 1}/{steps.length}: {REGISTER_STEP_LABELS[step]}
</span>
{steps.map((s, i) => (
<span
key={s}
className={cn(
'hidden sm:inline',
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground',
)}
>
{REGISTER_STEP_LABELS[s]}
</span>
))}
</div>
<Progress value={progress} className="h-2" />
</div>
</CardContent>
</Card>
{step === 'upload' && (
<RegisterUploadStep
entity="suppliers"
onFileSelect={handleFileSelect}
isLoading={isLoading}
error={error}
/>
)}
{step === 'column_mapping' && parseResult && initialMapping && (
<RegisterColumnMappingStep<keyof DetectedSupplierColumns>
headers={parseResult.headers}
previewRows={parseResult.preview_rows}
specs={SUPPLIER_COLUMN_SPECS}
initial={initialMapping}
onConfirm={handleColumnMappingConfirm}
onBack={() => setStep('upload')}
/>
)}
{step === 'edit' && parseResult && (
<SuppliersEditStep
rows={parseResult.rows}
onExecute={handleExecute}
onBack={() => setStep(needsMapping ? 'column_mapping' : 'upload')}
isLoading={isLoading}
error={error}
/>
)}
{step === 'result' && executeResult && (
<RegisterResultStep
entity="suppliers"
result={executeResult}
onNewImport={handleNewImport}
/>
)}
</div>
)
}
// ============================================================
// Articles Flow (entity = "articles" inside CSVDataImportWizard)
// ============================================================
const ARTICLE_COLUMN_SPECS: RegisterColumnSpec<keyof DetectedArticleColumns>[] = [
{ key: 'name_col', label: 'Benämning', required: true },
{ key: 'article_number_col', label: 'Artikelnummer', required: false },
{ key: 'type_col', label: 'Typ (vara/tjänst)', required: false },
{ key: 'unit_col', label: 'Enhet', required: false },
{ key: 'price_col', label: 'Pris exkl moms', required: false },
{ key: 'currency_col', label: 'Valuta', required: false },
{ key: 'vat_rate_col', label: 'Moms (%)', required: false },
{ key: 'revenue_account_col', label: 'Försäljningskonto', required: false },
{ key: 'cost_price_col', label: 'Inköpspris', required: false },
{ key: 'ean_col', label: 'EAN', required: false },
{ key: 'housework_type_col', label: 'ROT/RUT-arbetstyp', required: false },
{ key: 'name_en_col', label: 'Benämning (engelska)', required: false },
{ key: 'notes_col', label: 'Anteckning', required: false },
]
function ArticlesFlow() {
const { toast } = useToast()
const [step, setStep] = useState<RegisterStep>('upload')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [file, setFile] = useState<File | null>(null)
const [parseResult, setParseResult] = useState<ArticleImportParseResult | null>(null)
const [executeResult, setExecuteResult] = useState<RegisterResult | null>(null)
const needsMapping = parseResult && parseResult.detected_columns.confidence < 0.8
const steps: RegisterStep[] = needsMapping
? ['upload', 'column_mapping', 'edit', 'result']
: ['upload', 'edit', 'result']
const currentStepIndex = steps.indexOf(step)
const progress = ((currentStepIndex + 1) / steps.length) * 100
const handleFileSelect = useCallback(async (selectedFile: File) => {
setError(null)
setIsLoading(true)
setFile(selectedFile)
try {
const formData = new FormData()
formData.append('file', selectedFile)
const res = await fetch('/api/import/articles/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || getErrorMessage(data.error) || data.error || 'Kunde inte läsa filen')
return
}
const result = data.data as ArticleImportParseResult
setParseResult(result)
if (result.rows.length === 0) {
setError('Inga giltiga artiklar hittades. Kontrollera att filen innehåller en benämningskolumn.')
return
}
toast({
title: 'Fil analyserad',
description: `${result.rows.length} artiklar hittades${result.duplicate_count > 0 ? ` (${result.duplicate_count} matchar befintliga)` : ''}`,
})
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
} catch (err) {
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
}, [toast])
const handleColumnMappingConfirm = useCallback(async (
mapping: Record<keyof DetectedArticleColumns, number | null>,
) => {
if (!file) return
setIsLoading(true)
setError(null)
try {
const overrides: DetectedArticleColumns = {
name_col: mapping.name_col ?? 0,
article_number_col: mapping.article_number_col,
name_en_col: mapping.name_en_col,
type_col: mapping.type_col,
unit_col: mapping.unit_col,
price_col: mapping.price_col,
currency_col: mapping.currency_col,
vat_rate_col: mapping.vat_rate_col,
revenue_account_col: mapping.revenue_account_col,
cost_price_col: mapping.cost_price_col,
ean_col: mapping.ean_col,
housework_type_col: mapping.housework_type_col,
notes_col: mapping.notes_col,
confidence: 1,
}
const formData = new FormData()
formData.append('file', file)
formData.append('column_overrides', JSON.stringify(overrides))
const res = await fetch('/api/import/articles/parse', {
method: 'POST',
body: formData,
})
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Kunde inte tolka filen med de valda kolumnerna')
return
}
setParseResult(data.data)
setStep('edit')
} catch (err) {
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
} finally {
setIsLoading(false)
}
}, [file])
const handleExecute = useCallback(async (
rows: AnnotatedArticleRow[],
updateDuplicates: boolean,
) => {
setIsLoading(true)
setError(null)
try {
const res = await fetch('/api/import/articles/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rows: rows.map(({ duplicate_match: _dup, is_valid: _v, validation_errors: _ve, vat_rate_adjusted: _vra, ...rest }) => rest),
update_duplicates: updateDuplicates,
}),
})
const data = await res.json()
if (!res.ok) {
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Importen misslyckades')
return
}
setExecuteResult(data.data as RegisterResult)
setStep('result')
const r = data.data as RegisterResult
toast({
title: r.success ? 'Artiklar importerade' : 'Importen slutfördes med fel',
description: `${r.created} skapade, ${r.updated} uppdaterade, ${r.skipped} hoppade över${r.failed > 0 ? `, ${r.failed} misslyckades` : ''}`,
variant: r.success ? 'default' : 'destructive',
})
} catch (err) {
setError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
} finally {
setIsLoading(false)
}
}, [toast])
const handleNewImport = () => {
setStep('upload')
setFile(null)
setParseResult(null)
setExecuteResult(null)
setError(null)
}
const initialMapping = parseResult
? columnsToMapping<keyof DetectedArticleColumns>(parseResult.detected_columns as unknown as { [key: string]: unknown }, ARTICLE_COLUMN_SPECS)
: null
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="sm:hidden text-primary font-medium">
Steg {currentStepIndex + 1}/{steps.length}: {REGISTER_STEP_LABELS[step]}
</span>
{steps.map((s, i) => (
<span
key={s}
className={cn(
'hidden sm:inline',
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground',
)}
>
{REGISTER_STEP_LABELS[s]}
</span>
))}
</div>
<Progress value={progress} className="h-2" />
</div>
</CardContent>
</Card>
{step === 'upload' && (
<RegisterUploadStep
entity="articles"
onFileSelect={handleFileSelect}
isLoading={isLoading}
error={error}
/>
)}
{step === 'column_mapping' && parseResult && initialMapping && (
<RegisterColumnMappingStep<keyof DetectedArticleColumns>
headers={parseResult.headers}
previewRows={parseResult.preview_rows}
specs={ARTICLE_COLUMN_SPECS}
initial={initialMapping}
onConfirm={handleColumnMappingConfirm}
onBack={() => setStep('upload')}
/>
)}
{step === 'edit' && parseResult && (
<ArticlesEditStep
rows={parseResult.rows}
onExecute={handleExecute}
onBack={() => setStep(needsMapping ? 'column_mapping' : 'upload')}
isLoading={isLoading}
error={error}
/>
)}
{step === 'result' && executeResult && (
<RegisterResultStep
entity="articles"
result={executeResult}
onNewImport={handleNewImport}
/>
)}
</div>
)
}
// ============================================================
// CSV/Excel Data Import Wizard, entity selector + sub-flow
// ============================================================
type CSVDataEntity = 'opening_balance' | 'customers' | 'suppliers' | 'articles'
const ENTITY_OPTIONS: { value: CSVDataEntity; label: string }[] = [
{ value: 'opening_balance', label: 'Ingående balanser' },
{ value: 'customers', label: 'Kunder' },
{ value: 'suppliers', label: 'Leverantörer' },
{ value: 'articles', label: 'Artiklar' },
]
function CSVDataImportWizard() {
const [entity, setEntity] = useState<CSVDataEntity | null>('opening_balance')
return (
<div className="space-y-6">
<div className="flex flex-wrap gap-3">
{ENTITY_OPTIONS.map((opt) => {
const selected = entity === opt.value
return (
<div key={opt.value} className="relative">
{selected && (
<svg
aria-hidden
className="pointer-events-none absolute -inset-[3px] h-[calc(100%+6px)] w-[calc(100%+6px)] overflow-visible"
>
<rect
x="1"
y="1"
width="calc(100% - 2px)"
height="calc(100% - 2px)"
rx="8"
ry="8"
fill="none"
stroke="currentColor"
strokeWidth="1.25"
strokeDasharray="3 4"
className="animate-marching-ants text-foreground/45"
/>
</svg>
)}
<button
type="button"
onClick={() => setEntity(opt.value)}
aria-pressed={selected}
className={cn(
'relative h-9 rounded-md border px-4 text-sm font-medium transition-colors',
selected
? 'border-foreground bg-foreground text-background'
: 'border-border bg-card text-foreground hover:border-foreground/30 hover:bg-muted',
)}
>
{opt.label}
</button>
</div>
)
})}
</div>
{entity === 'opening_balance' && <OpeningBalanceFlow key="ob-flow" />}
{entity === 'customers' && <CustomersFlow key="cust-flow" />}
{entity === 'suppliers' && <SuppliersFlow key="supp-flow" />}
{entity === 'articles' && <ArticlesFlow key="art-flow" />}
</div>
)
}
// ============================================================
// Banking (PSD2) connect UI
// ============================================================
// Provided by the enable-banking extension and loaded through the settings
// panel registry (dynamic import), so this core page never imports from
// @/extensions directly. The shared panel renders every connection state
// (pending account selection, active, expiring, and expired/error with the
// reconnect entry point), which the old inline wizard here did not.
const BankingPanel = getSettingsPanel('enable-banking')
// Same registry mechanism for the Stripe connect/sync surface: the feed of
// payments, fees and payouts is an import source in the same category as the
// PSD2 bank connection above.
const StripePanel = getSettingsPanel('stripe')
// ============================================================
// Import Page with Selection Cards
// ============================================================
type ImportMode = null | 'psd2' | 'stripe' | 'bank' | 'sie' | 'csv_data' | 'migration'
export default function ImportPage() {
const { isSandbox } = useCompany()
const [mode, setMode] = useState<ImportMode>(null)
const [view, setView] = useState<'import' | 'export'>('import')
const [sieDialogOpen, setSieDialogOpen] = useState(false)
const [cloudOpen, setCloudOpen] = useState(false)
const [userId, setUserId] = useState('')
const [exportPeriodId, setExportPeriodId] = useState<string | null>(null)
const [exportExcludeClosing, setExportExcludeClosing] = useState(true)
const t = useTranslations('import')
const router = useRouter()
const hasCloudBackup = ENABLED_EXTENSION_IDS.has('cloud-backup')
const hasBankSync = useCapability(CAPABILITY.bank_sync)
// Fetch authenticated user ID (used by the migration wizard)
useEffect(() => {
const supabase = createClient()
supabase.auth.getUser().then(({ data: { user } }) => {
if (user) setUserId(user.id)
})
}, [])
// Sync mode + view from URL search params (reacts to client-side navigation changes)
const searchParams = useSearchParams()
useEffect(() => {
// External imports (provider migration, PSD2 bank connection) need live
// third-party credentials, so their deep links are ignored in the sandbox.
// Manual file-import modes (bank file, CSV/Excel, SIE) stay reachable.
const allowedModes = isSandbox
? ['bank', 'sie', 'csv_data']
: ['psd2', 'stripe', 'bank', 'sie', 'csv_data', 'migration']
if (!isSandbox && searchParams.get('migration')) {
setMode('migration')
} else {
const modeParam = searchParams.get('mode')
if (modeParam && allowedModes.includes(modeParam)) {
setMode(modeParam as ImportMode)
}
}
const viewParam = searchParams.get('view')
if (viewParam === 'export' || viewParam === 'import') {
setView(viewParam)
}
}, [isSandbox, searchParams])
// Hash-based deep links: both live on the export tab; #sie-export opens
// the SIE dialog, #cloud-backup expands the cloud panel and scrolls to it.
useEffect(() => {
if (typeof window === 'undefined') return
const hash = window.location.hash
if (hash === '#sie-export') {
setView('export')
setSieDialogOpen(true)
} else if (hash === '#cloud-backup') {
setView('export')
setCloudOpen(true)
setTimeout(() => {
document.querySelector(hash)?.scrollIntoView({ block: 'start', behavior: 'smooth' })
}, 80)
}
}, [])
const handleViewChange = (next: 'import' | 'export') => {
setView(next)
const params = new URLSearchParams(searchParams.toString())
if (next === 'export') params.set('view', 'export')
else params.delete('view')
const qs = params.toString()
router.replace(qs ? `/import?${qs}` : '/import', { scroll: false })
}
// Extensions are active if compiled in: no runtime toggle check needed
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasMigrationExtension = ENABLED_EXTENSION_IDS.has('arcim-migration')
const hasStripeExtension = ENABLED_EXTENSION_IDS.has('stripe')
// Stripe is enabled everywhere (hosted + self-hosted); only the sandbox blocks it.
const stripeDisabled = isSandbox
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
help={
<HelpPopover>
<p>{t('help_text')}</p>
</HelpPopover>
}
/>
{mode === null && (
<>
{isSandbox && <AttnLine>{t('sandbox_disabled')}</AttnLine>}
{/* Importera / Exportera as separate tabs (house seg), like before */}
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
{(
[
{ key: 'import', label: t('tab_import') },
{ key: 'export', label: t('tab_export') },
] as const
).map(({ key, label }) => (
<button
key={key}
type="button"
role="tab"
aria-selected={view === key}
onClick={() => handleViewChange(key)}
className={`rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150 ${
view === key
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
{view === 'import' ? (
<div>
<div className="stagger-enter">
{hasBankingExtension && (
<ImportRow
title={t('psd2_title')}
sub={t('psd2_description')}
chip={
hasBankSync ? (
<Badge variant="success" className="font-normal">{t('psd2_recommended')}</Badge>
) : (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium leading-none text-muted-foreground">
{t('psd2_requires_subscription')}
</span>
)
}
chips={<LogoChip src="/logos/enable-banking-icon.png" name="Enable Banking" mono />}
disabled={isSandbox}
onClick={() => setMode('psd2')}
/>
)}
{hasStripeExtension && (
<ImportRow
title={t('stripe_title')}
sub={t('stripe_description')}
chips={<LogoChip src="/logos/stripeicon.jpeg" name="Stripe" />}
disabled={stripeDisabled}
onClick={() => setMode('stripe')}
/>
)}
{hasMigrationExtension && (
<ImportRow
title={t('migration_title')}
sub={t('migration_description')}
chips={
<>
<LogoChip src="/logos/fortnox.svg" name="Fortnox" />
<LogoChip src="/logos/visma.jpeg" name="Visma" />
<LogoChip src="/logos/bokio.png" name="Bokio" />
<LogoChip src="/logos/bjornlunden.png" name="Björn Lundén" />
<LogoChip src="/logos/Briox_logo.png" name="Briox" />
</>
}
disabled={isSandbox}
onClick={() => setMode('migration')}
/>
)}
<ImportRow
title={t('bankfile_title')}
sub={t('bankfile_description')}
onClick={() => setMode('bank')}
/>
<ImportRow
title={t('csv_data_title')}
sub={t('csv_data_description')}
onClick={() => setMode('csv_data')}
/>
<ImportRow
title={t('sie_title')}
sub={t('sie_description')}
onClick={() => setMode('sie')}
/>
</div>
<p className="mt-4 px-1 text-xs leading-5 text-muted-foreground">{t('pgnote')}</p>
</div>
) : (
<div>
<div className="stagger-enter">
<ImportRow
id="sie-export"
title={t('export_sie_title')}
sub={t('export_sie_description')}
onClick={() => setSieDialogOpen(true)}
/>
{hasCloudBackup && (
<ImportRow
title={t('cloud_row_title')}
sub={t('cloud_row_description')}
expanded={cloudOpen}
onClick={() => setCloudOpen((v) => !v)}
/>
)}
</div>
{hasCloudBackup && cloudOpen && (
<div id="cloud-backup" className="mt-6 scroll-mt-24">
<CloudBackupCard />
</div>
)}
</div>
)}
{/* SIE export as a small centered dialog (concept overlay convention) */}
<Dialog open={sieDialogOpen} onOpenChange={setSieDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-display text-lg tracking-tight">
{t('export_sie_title')}
</DialogTitle>
<DialogDescription className="text-[13px] leading-relaxed">
{t('export_sie_dialog_description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<FiscalYearSelector
value={exportPeriodId}
onChange={setExportPeriodId}
includeAllOption={false}
hideFuturePeriods
label={t('export_sie_period_label')}
/>
<label className="flex cursor-pointer items-start gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-border"
checked={exportExcludeClosing}
onChange={(e) => setExportExcludeClosing(e.target.checked)}
/>
<span>{t('export_sie_exclude_closing_label')}</span>
</label>
</div>
<DialogFooter>
<Button
onClick={() => {
if (exportPeriodId) {
const params = new URLSearchParams({ period_id: exportPeriodId })
if (exportExcludeClosing) params.set('exclude_closing', 'true')
window.open(`/api/reports/sie-export?${params.toString()}`, '_blank')
}
}}
disabled={!exportPeriodId}
>
<Download className="mr-2 h-4 w-4" />
{t('export_sie_button')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
{mode !== null && (
<Button variant="ghost" size="sm" onClick={() => setMode(null)}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('back_to_choices')}
</Button>
)}
{mode === 'psd2' && (
hasBankingExtension && BankingPanel ? (
<BankingPanel />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Landmark className="mb-4 h-10 w-10 text-muted-foreground/40" />
<p className="mb-1 font-medium">Bankintegration (PSD2) är inte aktiverad</p>
<p className="mb-4 max-w-md text-sm text-muted-foreground">
Aktivera tillägget Enable Banking för att koppla ditt bankkonto, eller importera
transaktioner manuellt via bankfil.
</p>
<Button variant="outline" onClick={() => setMode('bank')}>
Importera bankfil istället
</Button>
</CardContent>
</Card>
)
)}
{mode === 'stripe' && (
hasStripeExtension && StripePanel ? (
<StripePanel />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<CreditCard className="mb-4 h-10 w-10 text-muted-foreground/40" />
<p className="mb-1 font-medium">{t('stripe_not_enabled_title')}</p>
<p className="max-w-md text-sm text-muted-foreground">
{t('stripe_not_enabled_description')}
</p>
</CardContent>
</Card>
)
)}
{mode === 'bank' && <BankFileImportWizard />}
{mode === 'sie' && <SIEImportWizard />}
{mode === 'csv_data' && <CSVDataImportWizard />}
{mode === 'migration' && <MigrationWizard userId={userId} />}
</div>
)
}
// Quiet action row (concept scene 32): borderless list row with title, muted
// sub-line and a chevron; chips only for the recommended/gated exceptions.
function ImportRow({
title,
sub,
chip,
chips,
disabled = false,
expanded,
onClick,
id,
}: {
title: string
sub: string
chip?: React.ReactNode
/** Logo chips under the sub line (provider marks, as on the live page). */
chips?: React.ReactNode
disabled?: boolean
/** For rows that fold a panel open below the grid (cloud backup). */
expanded?: boolean
onClick: () => void
id?: string
}) {
return (
<button
type="button"
id={id}
onClick={onClick}
disabled={disabled}
aria-expanded={expanded}
className={cn(
'group flex w-full items-center justify-between gap-4 border-b border-border px-1 py-3 text-left',
'transition-colors duration-150 hover:bg-secondary/35',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent',
)}
>
<span className="min-w-0">
<span className="flex flex-wrap items-center gap-2 text-sm font-medium">
{title}
{chip}
</span>
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{sub}</span>
{chips && <span className="mt-2 flex flex-wrap gap-2">{chips}</span>}
</span>
<ChevronRight
className={cn(
'h-4 w-4 shrink-0 text-muted-foreground/40 transition-transform duration-150',
'group-hover:translate-x-0.5 group-hover:text-muted-foreground',
expanded && 'rotate-90',
)}
aria-hidden="true"
/>
</button>
)
}
// Provider mark chip (same recipe as the pre-migration live page): tiny logo
// on a quiet bordered chip, so integrations read as first-class brands.
// `mono` is for light-on-transparent marks (Enable Banking): the marketing
// site's grayscale+brightness treatment makes them read on a light ground,
// with the inverse lift in dark mode.
function LogoChip({ src, name, mono = false }: { src: string; name: string; mono?: boolean }) {
return (
<span className="flex items-center gap-2 rounded border border-border bg-muted/30 px-2 py-1">
<img
src={src}
alt=""
className={cn(
'h-4 w-4 shrink-0 rounded-sm object-contain',
mono &&
'opacity-90 [filter:grayscale(100%)_brightness(0.18)] dark:[filter:grayscale(100%)_brightness(1.5)]',
)}
/>
<span className="text-[11px] font-medium text-muted-foreground">{name}</span>
</span>
)
}