'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: () =>
Laddar migreringsverktyg...
} ) function ImportStepLoading() { return (
) } 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 = { 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('upload') const [bankIsLoading, setBankIsLoading] = useState(false) const [bankError, setBankError] = useState(null) const [bankErrorTitle, setBankErrorTitle] = useState(null) // Parse results const [parseResult, setParseResult] = useState(null) const [detectedFormat, setDetectedFormat] = useState(null) const [detectedFormatName, setDetectedFormatName] = useState(null) const [fileHash, setFileHash] = useState('') const [filename, setFilename] = useState('') const [rawFileContent, setRawFileContent] = useState('') // Import result const [ingestResult, setIngestResult] = useState(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([]) 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 (
{/* Status chip for at-a-glance "auto-sync is healthy / stale / needs attention" */} {/* Overlap warning: active PSD2 means file import will likely create duplicates of transactions the nightly sync already covers. */} {activePsd2Banks.length > 0 && (

{tTx('import_psd2_active_warning_title', { bankName: activePsd2Banks.join(', ') })}

{tTx('import_psd2_active_warning_body')}

)} {/* Progress */}
Steg {currentStepIndex + 1}/{steps.length}: {BANK_STEP_LABELS[bankStep]} {steps.map((s, i) => ( {BANK_STEP_LABELS[s]} ))}
{/* Step content */} {bankStep === 'upload' && ( )} {bankStep === 'preview' && parseResult && ( { if (parseResult.format === 'generic_csv') { setBankStep('column_mapping') } else { setBankStep('confirm') } }} onBack={() => setBankStep('upload')} /> )} {bankStep === 'column_mapping' && ( setBankStep('upload')} /> )} {bankStep === 'confirm' && parseResult && ( { if (parseResult.format === 'generic_csv') { setBankStep('column_mapping') } else { setBankStep('preview') } }} isLoading={bankIsLoading} /> )} {bankStep === 'result' && ingestResult && ( )}
) } // ============================================================ // SIE Import Wizard (unchanged, extracted into component) // ============================================================ const SIE_STEP_LABELS: Record = { upload: 'Ladda upp', preview: 'Förhandsgranskning', mapping: 'Kontomappning', review: 'Bekräfta', result: 'Resultat', } function SIEImportWizard() { const { toast } = useToast() const [step, setStep] = useState('upload') const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [errorType, setErrorType] = useState<'duplicate' | 'duplicate_period' | 'validation' | 'parse' | 'network' | undefined>() const [validationErrors, setValidationErrors] = useState([]) const [validationWarnings, setValidationWarnings] = useState([]) const [duplicateImportId, setDuplicateImportId] = useState(null) const [isReplacing, setIsReplacing] = useState(false) const [file, setFile] = useState(null) const [, setParsed] = useState(null) const [mappings, setMappings] = useState([]) const [basAccounts, setBasAccounts] = useState([]) const [preview, setPreview] = useState(null) const [issues, setIssues] = useState([]) const [importResult, setImportResult] = useState(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 (
Steg {currentStepIndex + 1}/{sieSteps.length}: {SIE_STEP_LABELS[step]} {sieSteps.map((s, i) => ( {SIE_STEP_LABELS[s]} ))}
{step === 'upload' && } {step === 'preview' && preview && ( goToStep(hasUnmapped ? 'mapping' : 'review')} onBack={goBack} /> )} {step === 'mapping' && ( goToStep('review')} onBack={goBack} /> )} {step === 'review' && preview && ( )} {step === 'result' && importResult && }
) } // ============================================================ // Opening Balance Flow (entity = "opening_balance" inside CSVDataImportWizard) // ============================================================ type OpeningBalanceStep = 'upload' | 'column_mapping' | 'edit' | 'period' | 'result' const OB_STEP_LABELS: Record = { 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('upload') const [obIsLoading, setObIsLoading] = useState(false) const [obError, setObError] = useState(null) const [obBankFormatHint, setObBankFormatHint] = useState(null) const [obFile, setObFile] = useState(null) const [parseResult, setParseResult] = useState(null) const [editedRows, setEditedRows] = useState<{ id: string; account_number: string; account_name: string debit_amount: number; credit_amount: number }[]>([]) const [executeResult, setExecuteResult] = useState(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 (
{/* Progress */}
Steg {currentStepIndex + 1}/{steps.length}: {OB_STEP_LABELS[obStep]} {steps.map((s, i) => ( {OB_STEP_LABELS[s]} ))}
{/* Step content */} {obStep === 'upload' && ( router.push('/import?mode=bank') } : undefined } /> )} {obStep === 'column_mapping' && parseResult && ( setObStep('upload')} /> )} {obStep === 'edit' && parseResult && ( { if (needsMapping) { setObStep('column_mapping') } else { setObStep('upload') } }} /> )} {obStep === 'period' && ( setObStep('edit')} isLoading={obIsLoading} error={obError} /> )} {obStep === 'result' && executeResult && ( )}
) } // ============================================================ // Customers Flow (entity = "customers" inside CSVDataImportWizard) // ============================================================ type RegisterStep = 'upload' | 'column_mapping' | 'edit' | 'result' const REGISTER_STEP_LABELS: Record = { upload: 'Ladda upp', column_mapping: 'Kolumnmappning', edit: 'Granska', result: 'Resultat', } const CUSTOMER_COLUMN_SPECS: RegisterColumnSpec[] = [ { 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( cols: { readonly [key: string]: unknown }, specs: RegisterColumnSpec[], ): Record { const out = {} as Record 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('upload') const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [file, setFile] = useState(null) const [parseResult, setParseResult] = useState(null) const [executeResult, setExecuteResult] = useState(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, ) => { 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(parseResult.detected_columns as unknown as { [key: string]: unknown }, CUSTOMER_COLUMN_SPECS) : null return (
Steg {currentStepIndex + 1}/{steps.length}: {REGISTER_STEP_LABELS[step]} {steps.map((s, i) => ( {REGISTER_STEP_LABELS[s]} ))}
{step === 'upload' && ( )} {step === 'column_mapping' && parseResult && initialMapping && ( headers={parseResult.headers} previewRows={parseResult.preview_rows} specs={CUSTOMER_COLUMN_SPECS} initial={initialMapping} onConfirm={handleColumnMappingConfirm} onBack={() => setStep('upload')} /> )} {step === 'edit' && parseResult && ( setStep(needsMapping ? 'column_mapping' : 'upload')} isLoading={isLoading} error={error} /> )} {step === 'result' && executeResult && ( )}
) } // ============================================================ // Suppliers Flow (entity = "suppliers" inside CSVDataImportWizard) // ============================================================ const SUPPLIER_COLUMN_SPECS: RegisterColumnSpec[] = [ { 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('upload') const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [file, setFile] = useState(null) const [parseResult, setParseResult] = useState(null) const [executeResult, setExecuteResult] = useState(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, ) => { 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(parseResult.detected_columns as unknown as { [key: string]: unknown }, SUPPLIER_COLUMN_SPECS) : null return (
Steg {currentStepIndex + 1}/{steps.length}: {REGISTER_STEP_LABELS[step]} {steps.map((s, i) => ( {REGISTER_STEP_LABELS[s]} ))}
{step === 'upload' && ( )} {step === 'column_mapping' && parseResult && initialMapping && ( headers={parseResult.headers} previewRows={parseResult.preview_rows} specs={SUPPLIER_COLUMN_SPECS} initial={initialMapping} onConfirm={handleColumnMappingConfirm} onBack={() => setStep('upload')} /> )} {step === 'edit' && parseResult && ( setStep(needsMapping ? 'column_mapping' : 'upload')} isLoading={isLoading} error={error} /> )} {step === 'result' && executeResult && ( )}
) } // ============================================================ // Articles Flow (entity = "articles" inside CSVDataImportWizard) // ============================================================ const ARTICLE_COLUMN_SPECS: RegisterColumnSpec[] = [ { 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('upload') const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [file, setFile] = useState(null) const [parseResult, setParseResult] = useState(null) const [executeResult, setExecuteResult] = useState(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, ) => { 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(parseResult.detected_columns as unknown as { [key: string]: unknown }, ARTICLE_COLUMN_SPECS) : null return (
Steg {currentStepIndex + 1}/{steps.length}: {REGISTER_STEP_LABELS[step]} {steps.map((s, i) => ( {REGISTER_STEP_LABELS[s]} ))}
{step === 'upload' && ( )} {step === 'column_mapping' && parseResult && initialMapping && ( headers={parseResult.headers} previewRows={parseResult.preview_rows} specs={ARTICLE_COLUMN_SPECS} initial={initialMapping} onConfirm={handleColumnMappingConfirm} onBack={() => setStep('upload')} /> )} {step === 'edit' && parseResult && ( setStep(needsMapping ? 'column_mapping' : 'upload')} isLoading={isLoading} error={error} /> )} {step === 'result' && executeResult && ( )}
) } // ============================================================ // 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('opening_balance') return (
{ENTITY_OPTIONS.map((opt) => { const selected = entity === opt.value return (
{selected && ( )}
) })}
{entity === 'opening_balance' && } {entity === 'customers' && } {entity === 'suppliers' && } {entity === 'articles' && }
) } // ============================================================ // 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(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(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 (

{t('help_text')}

} /> {mode === null && ( <> {isSandbox && {t('sandbox_disabled')}} {/* Importera / Exportera as separate tabs (house seg), like before */}
{( [ { key: 'import', label: t('tab_import') }, { key: 'export', label: t('tab_export') }, ] as const ).map(({ key, label }) => ( ))}
{view === 'import' ? (
{hasBankingExtension && ( {t('psd2_recommended')} ) : ( {t('psd2_requires_subscription')} ) } chips={} disabled={isSandbox} onClick={() => setMode('psd2')} /> )} {hasStripeExtension && ( } disabled={stripeDisabled} onClick={() => setMode('stripe')} /> )} {hasMigrationExtension && ( } disabled={isSandbox} onClick={() => setMode('migration')} /> )} setMode('bank')} /> setMode('csv_data')} /> setMode('sie')} />

{t('pgnote')}

) : (
setSieDialogOpen(true)} /> {hasCloudBackup && ( setCloudOpen((v) => !v)} /> )}
{hasCloudBackup && cloudOpen && (
)}
)} {/* SIE export as a small centered dialog (concept overlay convention) */} {t('export_sie_title')} {t('export_sie_dialog_description')}
)} {mode !== null && ( )} {mode === 'psd2' && ( hasBankingExtension && BankingPanel ? ( ) : (

Bankintegration (PSD2) är inte aktiverad

Aktivera tillägget Enable Banking för att koppla ditt bankkonto, eller importera transaktioner manuellt via bankfil.

) )} {mode === 'stripe' && ( hasStripeExtension && StripePanel ? ( ) : (

{t('stripe_not_enabled_title')}

{t('stripe_not_enabled_description')}

) )} {mode === 'bank' && } {mode === 'sie' && } {mode === 'csv_data' && } {mode === 'migration' && }
) } // 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 ( ) } // 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 ( {name} ) }