Files
accounted/app/(dashboard)/import/page.tsx
T
Mattsson 11621bb79f Feat/skv integration full (#284)
* feat: add script to import Skatteverket monthly tax tables as fallback TypeScript module

- Implemented a new script `import-tax-tables.ts` to parse fixed-width TXT tax tables from Skatteverket (SKV 434).
- The script generates a TypeScript module for emergency fallback when the Skatteverket open-data API is unavailable.
- Supports command-line argument for specifying the year and handles parsing of B-rows only.
- Outputs a structured TypeScript file containing tax data for specified years.

* feat: gate salary module behind dev-only flag

Temporarily disable the Lön module in production while the feature is
being completed. Sidebar entries ("Löner", "Anställda") still render but
are not clickable and show a "Kommer snart" badge. Middleware redirects
/salary* to / and returns 404 on /api/salary/* so the feature can't be
reached by direct URL. All gates check NODE_ENV === 'development' so
local dev keeps full access for continued development.

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

* feat: refactor bank file import wizard to streamline column mapping and enhance CSV handling

* fix: bump migration timestamp to avoid collision with logos_bucket

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

* feat: enhance AGI generation and salary entry calculations with improved status checks and error handling

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:03:23 +02:00

1358 lines
50 KiB
TypeScript

'use client'
import { useState, useCallback, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, Scale } from 'lucide-react'
import { cn } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector'
import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import type { BankConnection } from '@/types'
// Bank file import components
import BankFileUploadStep from '@/components/import/BankFileUploadStep'
import BankFilePreviewStep from '@/components/import/BankFilePreviewStep'
import BankFileColumnMappingStep from '@/components/import/BankFileColumnMappingStep'
import BankFileConfirmStep from '@/components/import/BankFileConfirmStep'
import BankFileResultStep from '@/components/import/BankFileResultStep'
// Opening balance import components
import OpeningBalanceUploadStep from '@/components/import/OpeningBalanceUploadStep'
import OpeningBalanceColumnMappingStep from '@/components/import/OpeningBalanceColumnMappingStep'
import OpeningBalanceEditStep from '@/components/import/OpeningBalanceEditStep'
import OpeningBalancePeriodStep from '@/components/import/OpeningBalancePeriodStep'
import OpeningBalanceResultStep from '@/components/import/OpeningBalanceResultStep'
import type { OpeningBalanceParseResult, OpeningBalanceExecuteResult, DetectedColumns } from '@/lib/import/opening-balance/types'
// SIE import components
import SIEUploadStep from '@/components/import/SIEUploadStep'
import SIEPreviewStep from '@/components/import/SIEPreviewStep'
import AccountMappingStep from '@/components/import/AccountMappingStep'
import ImportReviewStep, { type ImportExecuteOptions } from '@/components/import/ImportReviewStep'
import ImportResultStep from '@/components/import/ImportResultStep'
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'
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> }
)
// ============================================================
// 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 [bankStep, setBankStep] = useState<BankFileStep>('upload')
const [bankIsLoading, setBankIsLoading] = useState(false)
const [bankError, setBankError] = 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)
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)
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) {
if (data.error === 'duplicate') {
setBankError(data.message)
} else {
setBankError(data.error || '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 ? err.message : '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 ? err.message : '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)
setRawFileContent('')
}
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}: {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}
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' | 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 type = data.error as typeof errorType
if (type === 'duplicate' || type === 'duplicate_period') {
setErrorType(type)
setError(data.message)
if (data.importId) {
setDuplicateImportId(data.importId)
}
toast({ title: type === 'duplicate' ? 'Filen har redan importerats' : 'Överlappande räkenskapsår', description: data.message, variant: 'destructive' })
} else if (type === 'validation') {
setErrorType('validation')
setError(data.message || 'SIE-filen innehåller valideringsfel.')
setValidationErrors(data.errors || [])
setValidationWarnings(data.warnings || [])
toast({ title: 'Valideringsfel i SIE-filen', description: `${(data.errors || []).length} fel hittades som måste åtgärdas.`, variant: 'destructive' })
} else {
setErrorType('parse')
setError(data.message || data.error || 'Kunde inte tolka filen.')
toast({ title: 'Kunde inte läsa filen', description: data.message || data.error || 'Kontrollera att filen är en giltig SIE-fil.', variant: 'destructive' })
}
return
}
setParsed({
header: data.parsed.header,
accounts: data.parsed.accounts,
openingBalances: [],
closingBalances: [],
resultBalances: [],
vouchers: [],
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.'
: err instanceof Error ? err.message : 'Ett oväntat fel uppstod.'
setErrorType('parse')
setError(message)
toast({ title: 'Anslutningsfel', description: message, 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: data.error || 'Ett fel uppstod', variant: 'destructive' })
return
}
toast({
title: 'Import ersatt',
description: `${data.cancelledEntries} verifikation${data.cancelledEntries === 1 ? '' : 'er'} makulerades. 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: data.error || 'Försök igen.', 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 ? err.message : '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) {
if (data.error === 'duplicate') {
setError(data.message || 'Denna fil har redan importerats')
toast({ title: 'Filen har redan importerats', description: data.message, variant: 'destructive' })
return
}
if (data.result) {
setImportResult(data.result)
} else {
const msg = data.message || data.error || 'Importen misslyckades.'
setError(msg)
toast({ title: 'Import misslyckades', description: msg, 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.'
: err instanceof Error ? err.message : 'Ett oväntat fel uppstod.'
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} />}
</div>
)
}
// ============================================================
// Opening Balance Import Wizard
// ============================================================
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 OpeningBalanceImportWizard() {
const { toast } = useToast()
const [obStep, setObStep] = useState<OpeningBalanceStep>('upload')
const [obIsLoading, setObIsLoading] = useState(false)
const [obError, setObError] = 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)
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) {
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 ? err.message : '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 ? err.message : '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) => {
setObIsLoading(true)
setObError(null)
try {
const res = await fetch('/api/import/opening-balance/execute', {
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) {
if (res.status === 409) {
setObError(data.error || 'Perioden har redan ingående balanser')
} else {
setObError(data.error || 'Importen misslyckades')
}
return
}
setExecuteResult(data.data)
setObStep('result')
if (data.data.success) {
toast({
title: 'Ingående balanser bokförda',
description: `${data.data.lines_created} kontorader skapades`,
})
}
} catch (err) {
setObError(err instanceof Error ? err.message : 'Importen misslyckades')
} finally {
setObIsLoading(false)
}
}, [editedRows, toast])
const handleNewImport = () => {
setObStep('upload')
setObFile(null)
setParseResult(null)
setEditedRows([])
setExecuteResult(null)
setObError(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}
/>
)}
{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}
/>
)}
</div>
)
}
// ============================================================
// PSD2 Bank Connection (inline, from Enable Banking extension)
// ============================================================
function PSD2ConnectWizard() {
const { toast } = useToast()
const supabase = createClient()
const { dialogProps, confirm } = useDestructiveConfirm()
const { company } = useCompany()
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null)
const [isConnecting, setIsConnecting] = useState(false)
const [connectingBankName, setConnectingBankName] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
fetchConnections()
}, [])
async function fetchConnections() {
setIsLoading(true)
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
if (!company) return
const { data: connections } = await supabase
.from('bank_connections')
.select('*')
.eq('company_id', company.id)
.order('created_at', { ascending: false })
setBankConnections(connections || [])
setIsLoading(false)
}
async function handleConnectBank(bank: Bank) {
setIsConnecting(true)
setConnectingBankName(bank.name)
try {
const response = await fetch('/api/extensions/ext/enable-banking/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aspsp_name: bank.name, aspsp_country: bank.country }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error)
}
window.location.href = data.authorization_url
} catch (error) {
toast({
title: 'Kunde inte ansluta bank',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
setIsConnecting(false)
setConnectingBankName(null)
}
}
async function handleSyncTransactions(connectionId: string) {
setSyncingConnectionId(connectionId)
try {
const response = await fetch('/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error)
}
toast({
title: 'Synkronisering klar',
description: `${data.imported} nya transaktioner importerade`,
})
fetchConnections()
} catch (error) {
toast({
title: 'Synkronisering misslyckades',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
setSyncingConnectionId(null)
}
async function handleDisconnectBank(connectionId: string) {
const ok = await confirm({
title: 'Koppla bort bank?',
description: 'PSD2-samtycket kommer återkallas. Befintliga transaktioner påverkas inte.',
confirmLabel: 'Koppla bort',
variant: 'warning',
})
if (!ok) return
try {
const response = await fetch('/api/extensions/ext/enable-banking/disconnect', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Disconnect failed')
}
toast({
title: 'Bank bortkopplad',
description: 'Bankanslutningen och PSD2-samtycket har återkallats',
})
fetchConnections()
} catch (error) {
toast({
title: 'Kunde inte koppla bort bank',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
}
const activeConnections = bankConnections.filter((c) => c.status === 'active')
return (
<div className="space-y-6">
<DestructiveConfirmDialog {...dialogProps} />
{/* Connected banks */}
{activeConnections.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Anslutna banker</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{activeConnections.map((connection) => (
<BankConnectionStatus
key={connection.id}
connection={connection}
onSync={handleSyncTransactions}
onDisconnect={handleDisconnectBank}
isSyncing={syncingConnectionId === connection.id}
/>
))}
</CardContent>
</Card>
)}
{/* Connect new bank */}
<Card>
<CardHeader>
<CardTitle>Anslut din bank</CardTitle>
<CardDescription>
Välj din bank nedan för att koppla ditt konto via PSD2. Transaktioner synkas automatiskt varje dag.
</CardDescription>
</CardHeader>
<CardContent>
<BankSelector
onConnect={handleConnectBank}
isConnecting={isConnecting}
connectingBankName={connectingBankName}
/>
</CardContent>
</Card>
</div>
)
}
// ============================================================
// Import Page with Selection Cards
// ============================================================
type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'opening_balance' | 'migration'
export default function ImportPage() {
const { company } = useCompany()
const [mode, setMode] = useState<ImportMode>(null)
const [userId, setUserId] = useState('')
const [isSandbox, setIsSandbox] = useState(false)
// 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 from URL search params (reacts to client-side navigation changes)
const searchParams = useSearchParams()
useEffect(() => {
if (isSandbox) return
if (searchParams.get('migration')) {
setMode('migration')
} else {
const modeParam = searchParams.get('mode')
if (modeParam && ['psd2', 'bank', 'sie', 'opening_balance', 'migration'].includes(modeParam)) {
setMode(modeParam as ImportMode)
}
}
}, [isSandbox, searchParams])
// 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-6">
{/* Header */}
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Importera</h1>
<p className="text-muted-foreground">
Importera banktransaktioner eller bokföringsdata till ditt företag
</p>
</div>
{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">
Import är inte tillgängligt i sandlådemiljön. Skapa ett konto för att importera data.
</p>
</div>
)}
<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-5 transition-all',
isSandbox
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:border-foreground/15 hover:shadow-[var(--shadow-sm)] active:scale-[0.998]'
)}
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.5">
<h3 className="text-[15px] font-semibold leading-tight">Koppla bank</h3>
<span className="text-[11px] font-medium text-success bg-success/10 px-2 py-0.5 rounded-full leading-none">
Rekommenderat
</span>
</div>
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed max-w-lg">
Anslut ditt bankkonto direkt och synka transaktioner automatiskt via PSD2.
</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2.5 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-5 transition-all',
isSandbox
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:border-foreground/15 hover:shadow-[var(--shadow-sm)] active:scale-[0.998]'
)}
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">Hämta från annat system</h3>
<p className="text-sm mt-1.5 leading-relaxed max-w-lg underline decoration-foreground/20 underline-offset-2 text-muted-foreground">
Inget ändras i ditt befintliga system.
</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/40 shrink-0 mt-2.5 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
<div className="flex flex-wrap gap-2 mt-3.5 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-1.5 rounded border border-border/60 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 */}
<div
role="button"
tabIndex={isSandbox ? -1 : 0}
aria-disabled={isSandbox}
className={cn(
'group flex items-start gap-4 rounded-lg border bg-card p-5 transition-all',
isSandbox
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:border-foreground/15 hover:shadow-[var(--shadow-sm)] active:scale-[0.998]'
)}
onClick={() => { if (!isSandbox) setMode('bank') }}
onKeyDown={(e) => { if (!isSandbox && (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">Banktransaktioner</h3>
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed max-w-lg">
Importera kontoutdrag från din bank. Stöder de flesta svenska banker.
</p>
<div className="flex flex-wrap gap-1.5 mt-2.5">
{['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.5 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
{/* 4. Ingående balanser */}
<div
role="button"
tabIndex={isSandbox ? -1 : 0}
aria-disabled={isSandbox}
className={cn(
'group flex items-start gap-4 rounded-lg border bg-card p-5 transition-all',
isSandbox
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:border-foreground/15 hover:shadow-[var(--shadow-sm)] active:scale-[0.998]'
)}
onClick={() => { if (!isSandbox) setMode('opening_balance') }}
onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('opening_balance') } }}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<Scale className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">Ingående balanser</h3>
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed max-w-lg">
Importera ingående balanser från en Excel- eller CSV-fil.
</p>
<div className="flex flex-wrap gap-1.5 mt-2.5">
{['XLSX', 'CSV'].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.5 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
{/* 5. Bokföringsdata (SIE) */}
<div
role="button"
tabIndex={isSandbox ? -1 : 0}
aria-disabled={isSandbox}
className={cn(
'group flex items-start gap-4 rounded-lg border bg-card p-5 transition-all',
isSandbox
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:border-foreground/15 hover:shadow-[var(--shadow-sm)] active:scale-[0.998]'
)}
onClick={() => { if (!isSandbox) setMode('sie') }}
onKeyDown={(e) => { if (!isSandbox && (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">Bokföringsdata (SIE)</h3>
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed max-w-lg">
Importera verifikationer och kontoplan från ett annat bokföringsprogram.
</p>
<div className="flex flex-wrap gap-1.5 mt-2.5">
{['SIE4', '.se', '.si'].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.5 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
</div>
</>
)}
{mode !== null && (
<Button variant="ghost" size="sm" onClick={() => setMode(null)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka till val
</Button>
)}
{mode === 'psd2' && <PSD2ConnectWizard />}
{mode === 'bank' && <BankFileImportWizard />}
{mode === 'sie' && <SIEImportWizard />}
{mode === 'opening_balance' && <OpeningBalanceImportWizard />}
{mode === 'migration' && <MigrationWizard userId={userId} />}
</div>
)
}