Files
accounted/app/(dashboard)/import/page.tsx
T
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00

2331 lines
87 KiB
TypeScript

'use client'
import { useState, useCallback, useEffect } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
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 { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, FileSpreadsheet, 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) {
setBankError(data.error || 'Importen misslyckades')
return
}
setIngestResult(data.data)
setBankStep('result')
toast({
title: 'Import genomförd',
description: `${data.data.imported} transaktioner importerades`,
})
} catch (err) {
setBankError(err instanceof Error ? 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) {
setObError(data.error || 'Kunde inte läsa filen')
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(data.error || 'Kunde inte läsa filen med de valda kolumnerna')
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: '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,
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')
// ============================================================
// Import Page with Selection Cards
// ============================================================
type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'csv_data' | 'migration'
export default function ImportPage() {
const { company } = useCompany()
const [mode, setMode] = useState<ImportMode>(null)
const [view, setView] = useState<'import' | 'export'>('import')
const [userId, setUserId] = useState('')
const [isSandbox, setIsSandbox] = useState(false)
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 and sandbox status
useEffect(() => {
const supabase = createClient()
supabase.auth.getUser().then(({ data: { user } }) => {
if (!user) return
setUserId(user.id)
if (!company) return
supabase
.from('company_settings')
.select('is_sandbox')
.eq('company_id', company.id)
.single()
.then(({ data }) => {
if (data?.is_sandbox) setIsSandbox(true)
})
})
}, [])
// 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', '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 (#cloud-backup, #sie-export) → switch to export tab and scroll
useEffect(() => {
if (typeof window === 'undefined') return
const hash = window.location.hash
if (hash === '#cloud-backup' || hash === '#sie-export') {
setView('export')
setTimeout(() => {
document.querySelector(hash)?.scrollIntoView({ block: 'start', behavior: 'smooth' })
}, 50)
}
}, [])
const handleViewChange = (next: string) => {
if (next !== 'import' && next !== 'export') return
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')
return (
<div className="space-y-8">
<PageHeader
title={view === 'export' ? t('export_title') : t('title')}
description={view === 'export' ? t('export_subtitle') : undefined}
/>
{mode === null && (
<>
{isSandbox && (
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/50 px-4 py-3">
<Info className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-sm text-muted-foreground">
{t('sandbox_disabled')}
</p>
</div>
)}
<Tabs value={view} onValueChange={handleViewChange}>
<TabsList className="grid w-full max-w-xs grid-cols-2">
<TabsTrigger value="import">{t('tab_import')}</TabsTrigger>
<TabsTrigger value="export">{t('tab_export')}</TabsTrigger>
</TabsList>
<TabsContent value="import" className="mt-6">
<div className="space-y-2">
{/* 1. Koppla bank */}
{hasBankingExtension && (
<div
role="button"
tabIndex={isSandbox ? -1 : 0}
aria-disabled={isSandbox}
className={cn(
'group flex items-start gap-4 rounded-lg border bg-card p-6 transition-colors',
isSandbox
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:border-foreground/15'
)}
onClick={() => { if (!isSandbox) setMode('psd2') }}
onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('psd2') } }}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<Landmark className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-[15px] font-semibold leading-tight">{t('psd2_title')}</h3>
{hasBankSync ? (
<Badge variant="success">{t('psd2_recommended')}</Badge>
) : (
<span className="text-[11px] font-medium text-muted-foreground bg-secondary px-2 py-0.5 rounded-full leading-none">
{t('psd2_requires_subscription')}
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed max-w-lg">
{t('psd2_description')}
</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
)}
{/* 2. Hämta från annat system */}
{hasMigrationExtension === true && (
<div
role="button"
tabIndex={isSandbox ? -1 : 0}
aria-disabled={isSandbox}
className={cn(
'group rounded-lg border bg-card p-6 transition-colors',
isSandbox
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:border-foreground/15'
)}
onClick={() => { if (!isSandbox) setMode('migration') }}
onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('migration') } }}
>
<div className="flex items-start gap-4">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<ArrowRightLeft className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">{t('migration_title')}</h3>
<p className="text-sm mt-1 leading-relaxed max-w-lg underline decoration-foreground/20 underline-offset-2 text-muted-foreground">
{t('migration_description')}
</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
<div className="flex flex-wrap gap-2 mt-4 ml-[52px]">
{([
{ name: 'Fortnox', logo: '/logos/fortnox.svg' },
{ name: 'Visma', logo: '/logos/visma.jpeg' },
{ name: 'Bokio', logo: '/logos/bokio.png' },
{ name: 'Björn Lundén', logo: '/logos/bjornlunden.png' },
{ name: 'Briox', logo: '/logos/Briox_logo.png' },
] as const).map(provider => (
<div key={provider.name} className="flex items-center gap-2 rounded border border-border bg-muted/30 px-2 py-1">
<img src={provider.logo} alt={provider.name} className="h-4 w-4 shrink-0 rounded-sm object-contain" />
<span className="text-[11px] font-medium text-muted-foreground">{provider.name}</span>
</div>
))}
</div>
</div>
)}
{/* 3. Banktransaktioner: manual file imports (bank file, CSV/Excel,
SIE) run entirely on uploaded data with no external service, so
they stay available in the sandbox, unlike the API-backed options
above (bank connection, provider migration) which need live
credentials. */}
<div
role="button"
tabIndex={0}
className={cn(
'group flex items-start gap-4 rounded-lg border bg-card p-6 transition-colors',
'cursor-pointer hover:border-foreground/15'
)}
onClick={() => setMode('bank')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('bank') } }}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<ArrowLeftRight className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">{t('bankfile_title')}</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed max-w-lg">
{t('bankfile_description')}
</p>
<div className="flex flex-wrap gap-2 mt-2">
{['CSV', 'OFX', 'SEB', 'Swedbank', 'Nordea'].map(fmt => (
<span key={fmt} className="text-[11px] text-muted-foreground/80 bg-muted/80 px-1.5 py-0.5 rounded leading-none">
{fmt}
</span>
))}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
{/* 4. CSV/Excel-data (ingående balanser, kunder, leverantörer) */}
<div
role="button"
tabIndex={0}
className={cn(
'group flex items-start gap-4 rounded-lg border bg-card p-6 transition-colors',
'cursor-pointer hover:border-foreground/15'
)}
onClick={() => setMode('csv_data')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('csv_data') } }}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<FileSpreadsheet className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">{t('csv_data_title')}</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed max-w-lg">
{t('csv_data_description')}
</p>
<div className="flex flex-wrap gap-2 mt-2">
{[
{ key: 'XLSX', label: 'XLSX' },
{ key: 'CSV', label: 'CSV' },
{ key: 'opening_balances', label: t('csv_chip_opening_balances') },
{ key: 'customers', label: t('csv_chip_customers') },
{ key: 'suppliers', label: t('csv_chip_suppliers') },
{ key: 'articles', label: t('csv_chip_articles') },
].map(chip => (
<span key={chip.key} className="text-[11px] text-muted-foreground/80 bg-muted/80 px-1.5 py-0.5 rounded leading-none">
{chip.label}
</span>
))}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
{/* 5. Bokföringsdata (SIE) */}
<div
role="button"
tabIndex={0}
className={cn(
'group flex items-start gap-4 rounded-lg border bg-card p-6 transition-colors',
'cursor-pointer hover:border-foreground/15'
)}
onClick={() => setMode('sie')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('sie') } }}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<FileText className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">{t('sie_title')}</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed max-w-lg">
{t('sie_description')}
</p>
<div className="flex flex-wrap gap-2 mt-2">
{['SIE4', '.se'].map(fmt => (
<span key={fmt} className="text-[11px] text-muted-foreground/80 bg-muted/80 px-1.5 py-0.5 rounded leading-none">
{fmt}
</span>
))}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
</div>
</TabsContent>
<TabsContent value="export" className="mt-6">
<div className="space-y-4">
{/* SIE-export */}
<div id="sie-export" className="scroll-mt-24 rounded-lg border border-border bg-card p-6">
<div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
{/* Identity */}
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<FileSpreadsheet className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">{t('export_sie_title')}</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{t('export_sie_description')}
</p>
</div>
</div>
{/* Controls */}
<div className="space-y-4">
<FiscalYearSelector
value={exportPeriodId}
onChange={setExportPeriodId}
includeAllOption={false}
hideFuturePeriods
label={t('export_sie_period_label')}
/>
<label className="flex items-start gap-2 text-sm text-muted-foreground cursor-pointer">
<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>
<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}
className="w-full sm:w-auto"
>
<Download className="mr-2 h-4 w-4" />
{t('export_sie_button')}
</Button>
</div>
</div>
</div>
{/* Molnsynkronisering (Google Drive) */}
{hasCloudBackup && (
<div id="cloud-backup" className="scroll-mt-24">
<CloudBackupCard />
</div>
)}
</div>
</TabsContent>
</Tabs>
</>
)}
{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 === 'bank' && <BankFileImportWizard />}
{mode === 'sie' && <SIEImportWizard />}
{mode === 'csv_data' && <CSVDataImportWizard />}
{mode === 'migration' && <MigrationWizard userId={userId} />}
</div>
)
}