Fix/import data (#200)
* fix: enhance import data handling and consent management across components * feat: Enhance SIE import functionality with validation and error handling improvements - Added validation errors and warnings state management in SIEImportWizard. - Improved error handling for duplicate, validation, and parsing errors during SIE file import. - Enhanced user feedback with actionable guidance for common import errors. - Updated SIEUploadStep to display validation errors and warnings. - Improved error messages in API routes for better clarity and user experience. - Added file size and type validation in the SIE parse route. - Enhanced parsing logic to provide more detailed error messages for unbalanced vouchers and missing amounts. - Created a new storage bucket for SIE file archival in Supabase with appropriate policies for user access. - Updated tests to reflect changes in error messages and validation logic. * fix: Improve type assertion for response in getPage method * Update extensions/general/arcim-migration/lib/migration-orchestrator.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update supabase/migrations/20260408130000_sie_files_storage_bucket.sql Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: Add company ID verification for consent handling in accept and disconnect endpoints --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
greptile-apps[bot]
parent
6c79f21679
commit
211033410c
@@ -1,6 +1,7 @@
|
||||
'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'
|
||||
@@ -323,6 +324,8 @@ function SIEImportWizard() {
|
||||
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 [file, setFile] = useState<File | null>(null)
|
||||
const [, setParsed] = useState<ParsedSIEFile | null>(null)
|
||||
@@ -347,6 +350,8 @@ function SIEImportWizard() {
|
||||
setFile(selectedFile)
|
||||
setError(null)
|
||||
setErrorType(undefined)
|
||||
setValidationErrors([])
|
||||
setValidationWarnings([])
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
@@ -361,15 +366,21 @@ function SIEImportWizard() {
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
if (data.error === 'duplicate' || data.error === 'duplicate_period') {
|
||||
setErrorType(data.error)
|
||||
const type = data.error as typeof errorType
|
||||
if (type === 'duplicate' || type === 'duplicate_period') {
|
||||
setErrorType(type)
|
||||
setError(data.message)
|
||||
} else if (data.error === 'validation') {
|
||||
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}: ${data.errors?.join(', ') || 'Unknown validation error'}`)
|
||||
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.error || 'Failed to parse file')
|
||||
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
|
||||
}
|
||||
@@ -402,7 +413,13 @@ function SIEImportWizard() {
|
||||
description: `${data.parsed.stats.totalAccounts} konton och ${data.parsed.stats.totalVouchers} verifikationer hittades`,
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to parse file')
|
||||
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)
|
||||
}
|
||||
@@ -506,9 +523,17 @@ function SIEImportWizard() {
|
||||
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
|
||||
}
|
||||
if (data.result) { setImportResult(data.result) } else { setError(data.error || 'Import failed'); return }
|
||||
} else {
|
||||
setImportResult(data.result)
|
||||
}
|
||||
@@ -516,21 +541,38 @@ function SIEImportWizard() {
|
||||
setStep('result')
|
||||
|
||||
if (data.result?.success) {
|
||||
toast({ title: 'Import genomförd', description: `${data.result.journalEntriesCreated} verifikationer skapades` })
|
||||
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) {
|
||||
setError(err instanceof Error ? err.message : 'Import failed')
|
||||
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) }
|
||||
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([])
|
||||
setSieAccounts([]); setIsCreatingAccounts(false)
|
||||
}
|
||||
|
||||
@@ -557,7 +599,7 @@ function SIEImportWizard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{step === 'upload' && <SIEUploadStep onFileSelect={handleFileSelect} isLoading={isLoading} error={error} errorType={errorType} />}
|
||||
{step === 'upload' && <SIEUploadStep onFileSelect={handleFileSelect} isLoading={isLoading} error={error} errorType={errorType} validationErrors={validationErrors} validationWarnings={validationWarnings} />}
|
||||
{step === 'preview' && preview && (
|
||||
<SIEPreviewStep preview={preview} issues={issues} missingAccounts={missingAccounts}
|
||||
onCreateAccounts={handleCreateAccounts} isCreatingAccounts={isCreatingAccounts}
|
||||
@@ -794,19 +836,19 @@ export default function ImportPage() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Auto-detect OAuth callback or deep-link mode from query params (disabled in sandbox)
|
||||
// Sync mode from URL search params (reacts to client-side navigation changes)
|
||||
const searchParams = useSearchParams()
|
||||
useEffect(() => {
|
||||
if (isSandbox) return
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('migration')) {
|
||||
if (searchParams.get('migration')) {
|
||||
setMode('migration')
|
||||
} else {
|
||||
const modeParam = params.get('mode')
|
||||
const modeParam = searchParams.get('mode')
|
||||
if (modeParam && ['psd2', 'bank', 'sie', 'migration'].includes(modeParam)) {
|
||||
setMode(modeParam as ImportMode)
|
||||
}
|
||||
}
|
||||
}, [isSandbox])
|
||||
}, [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')
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function POST(request: Request) {
|
||||
const accounts: SIEAccount[] = body.accounts
|
||||
|
||||
if (!accounts || !Array.isArray(accounts) || accounts.length === 0) {
|
||||
return NextResponse.json({ error: 'No accounts provided' }, { status: 400 })
|
||||
return NextResponse.json({ error: 'Inga konton att skapa.' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Prepare accounts for upsert (idempotent — safe to retry)
|
||||
@@ -117,7 +117,7 @@ export async function POST(request: Request) {
|
||||
if (error) {
|
||||
console.error('Error upserting accounts batch:', error)
|
||||
return NextResponse.json({
|
||||
error: `Failed to create accounts: ${error.message}`,
|
||||
error: `Kunde inte skapa konton (batch ${Math.floor(i / batchSize) + 1}): ${error.message}. ${totalCreated} konton skapades innan felet.`,
|
||||
created: totalCreated,
|
||||
}, { status: 500 })
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export async function POST(request: Request) {
|
||||
} catch (error) {
|
||||
console.error('Create accounts error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create accounts' },
|
||||
{ error: `Kunde inte skapa konton: ${error instanceof Error ? error.message : 'Okänt fel'}. Försök igen.` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function POST(request: Request) {
|
||||
const optionsJson = formData.get('options') as string | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
return NextResponse.json({ error: 'Ingen fil bifogad. Gå tillbaka och ladda upp filen igen.' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Parse options
|
||||
@@ -87,9 +87,11 @@ export async function POST(request: Request) {
|
||||
// Validate all accounts are mapped
|
||||
const unmapped = mappings.filter((m) => !m.targetAccount)
|
||||
if (unmapped.length > 0) {
|
||||
const accountList = unmapped.slice(0, 5).map((m) => `${m.sourceAccount} (${m.sourceName})`).join(', ')
|
||||
const remaining = unmapped.length > 5 ? ` och ${unmapped.length - 5} till` : ''
|
||||
return NextResponse.json({
|
||||
error: 'validation',
|
||||
message: `${unmapped.length} account(s) are not mapped`,
|
||||
message: `${unmapped.length} konto(n) saknar mappning: ${accountList}${remaining}. Gå tillbaka till kontomappningssteget och koppla alla konton.`,
|
||||
unmappedAccounts: unmapped.map((m) => ({
|
||||
account: m.sourceAccount,
|
||||
name: m.sourceName,
|
||||
@@ -183,7 +185,7 @@ export async function POST(request: Request) {
|
||||
|
||||
if (activateError) {
|
||||
return NextResponse.json({
|
||||
error: `Failed to activate accounts: ${activateError.message}`,
|
||||
error: `Kunde inte aktivera konton i kontoplanen: ${activateError.message}. Kontrollera att kontona inte redan finns med andra inställningar.`,
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -208,7 +210,7 @@ export async function POST(request: Request) {
|
||||
if (!result.success) {
|
||||
return NextResponse.json({
|
||||
error: 'import',
|
||||
message: 'Import completed with errors',
|
||||
message: 'Importen slutfördes med fel. Se detaljerna nedan för att förstå vad som gick snett.',
|
||||
result,
|
||||
}, { status: 400 })
|
||||
}
|
||||
@@ -219,8 +221,11 @@ export async function POST(request: Request) {
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('SIE import error:', error)
|
||||
const detail = error instanceof Error ? error.message : ''
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to import SIE file' },
|
||||
{
|
||||
error: `Importen avbröts oväntat. Ingen data har sparats.${detail ? ` (${detail})` : ''} Försök igen — om felet kvarstår, kontakta support.`,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,14 +36,31 @@ export async function POST(request: Request) {
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
return NextResponse.json({ error: 'parse', message: 'Ingen fil bifogad i förfrågan.' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const filename = file.name.toLowerCase()
|
||||
if (!filename.endsWith('.sie') && !filename.endsWith('.se')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid file type. Please upload a .sie file' },
|
||||
{ error: 'parse', message: 'Filtypen stöds inte. Ladda upp en fil med ändelsen .sie eller .se.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate file size (max 50 MB)
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: 'parse', message: `Filen är för stor (${(file.size / 1024 / 1024).toFixed(1)} MB). Maxstorlek är 50 MB.` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate file is not empty
|
||||
if (file.size === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'parse', message: 'Filen är tom (0 bytes). Kontrollera att exporten från bokföringsprogrammet genomfördes korrekt.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
@@ -92,7 +109,7 @@ export async function POST(request: Request) {
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json({
|
||||
error: 'validation',
|
||||
message: 'SIE file has validation errors',
|
||||
message: 'SIE-filen innehåller valideringsfel som måste åtgärdas innan import.',
|
||||
errors: validation.errors,
|
||||
warnings: validation.warnings,
|
||||
}, { status: 400 })
|
||||
@@ -151,8 +168,12 @@ export async function POST(request: Request) {
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('SIE parse error:', error)
|
||||
const detail = error instanceof Error ? error.message : ''
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to parse SIE file' },
|
||||
{
|
||||
error: 'parse',
|
||||
message: `Kunde inte tolka SIE-filen. Filen kan vara skadad eller i ett format som inte stöds.${detail ? ` (${detail})` : ''}`,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user