The SIE preview's "Ingående balanser balanserar inte" warning was a dead end: it said the diff would be booked to 2099 and offered an acknowledge checkbox, nothing else. A new user parsed her SpeedLedger file five times, never reached execute, and emailed support to ask whether IB can be entered by hand (it can, the flow just never said so). The warning now names the usual cause in plain Swedish (föregående års resultat never transferred to eget kapital; SpeedLedger parks it on 9030 /9031), spells out the two ways forward, and offers a button straight into the manual "Ingående balanser" wizard (the CSV/Excel wizard's default entity). The acknowledgement path is unchanged. Copy stays hardcoded Swedish like the rest of the import wizard. Not done: letting the SIE import skip IB when a period already has them (the import refuses such a period today), and running findUntransferredResults at parse time so the preview can name the amount. Closes #2082 Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2855 lines
106 KiB
TypeScript
2855 lines
106 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useCallback, useEffect } from 'react'
|
|
import { fetchAccounts } from '@/lib/reference-data/fetchers'
|
|
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
|
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 { SegmentedControl } from '@/components/ui/segmented-control'
|
|
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, ShoppingBag, ShoppingCart } 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 { decodeFileContent } from '@/lib/import/shared/encoding'
|
|
import type { BankFileParseResult, BankFileFormatId, BankFileDuplicateInfo, GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
|
|
import type { SkattekontoFileParseResult } from '@/lib/import/skattekonto-file/types'
|
|
import type { SkattekontoFileImportResult } from '@/components/import/SkattekontoFileResultStep'
|
|
import type { IngestResult } from '@/types'
|
|
import type {
|
|
ImportWizardStep,
|
|
ParsedSIEFile,
|
|
AccountMapping,
|
|
ImportPreview,
|
|
ImportResult,
|
|
ParseIssue,
|
|
} from '@/lib/import/types'
|
|
import {
|
|
applyVatTreatmentReview,
|
|
applyVatTreatmentReviewAll,
|
|
enrichChangedAccountMappingWithVat,
|
|
enrichAccountMappingsWithVat,
|
|
} from '@/lib/import/account-vat-treatment'
|
|
import type { AccountVatTreatment } from '@/lib/vat/account-vat-treatment'
|
|
import type { TheaterModel } from '@/lib/import/theater-model'
|
|
|
|
/** Above this size the client-side theater parse is skipped (main-thread
|
|
* parse of very large SIE files would jank the animation it exists for). */
|
|
const THEATER_MAX_FILE_BYTES = 8 * 1024 * 1024
|
|
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 { FullArchiveDialog } from '@/components/import/FullArchiveDialog'
|
|
import CloudBackupCard from '@/extensions/general/cloud-backup/components/CloudBackupCard'
|
|
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
|
|
|
|
const MigrationWizard = dynamic(
|
|
() => import('@/components/extensions/general/ArcimMigrationWorkspace'),
|
|
{ ssr: false, loading: () => <div className="flex items-center gap-3 text-muted-foreground p-6"><Loader2 className="h-5 w-5 animate-spin" />Laddar migreringsverktyg...</div> }
|
|
)
|
|
|
|
function ImportStepLoading() {
|
|
return (
|
|
<div className="flex min-h-48 items-center justify-center" role="status">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const BankFileUploadStep = dynamic(() => import('@/components/import/BankFileUploadStep'), { loading: ImportStepLoading })
|
|
const BankFilePreviewStep = dynamic(() => import('@/components/import/BankFilePreviewStep'), { loading: ImportStepLoading })
|
|
const BankFileColumnMappingStep = dynamic(() => import('@/components/import/BankFileColumnMappingStep'), { loading: ImportStepLoading })
|
|
const BankFileConfirmStep = dynamic(() => import('@/components/import/BankFileConfirmStep'), { loading: ImportStepLoading })
|
|
const BankFileResultStep = dynamic(() => import('@/components/import/BankFileResultStep'), { loading: ImportStepLoading })
|
|
const OpeningBalanceUploadStep = dynamic(() => import('@/components/import/OpeningBalanceUploadStep'), { loading: ImportStepLoading })
|
|
const OpeningBalanceColumnMappingStep = dynamic(() => import('@/components/import/OpeningBalanceColumnMappingStep'), { loading: ImportStepLoading })
|
|
const OpeningBalanceEditStep = dynamic(() => import('@/components/import/OpeningBalanceEditStep'), { loading: ImportStepLoading })
|
|
const OpeningBalancePeriodStep = dynamic(() => import('@/components/import/OpeningBalancePeriodStep'), { loading: ImportStepLoading })
|
|
const OpeningBalanceResultStep = dynamic(() => import('@/components/import/OpeningBalanceResultStep'), { loading: ImportStepLoading })
|
|
const RegisterUploadStep = dynamic(() => import('@/components/import/RegisterUploadStep'), { loading: ImportStepLoading })
|
|
const CustomersEditStep = dynamic(() => import('@/components/import/CustomersEditStep'), { loading: ImportStepLoading })
|
|
const SuppliersEditStep = dynamic(() => import('@/components/import/SuppliersEditStep'), { loading: ImportStepLoading })
|
|
const ArticlesEditStep = dynamic(() => import('@/components/import/ArticlesEditStep'), { loading: ImportStepLoading })
|
|
const RegisterResultStep = dynamic(() => import('@/components/import/RegisterResultStep'), { loading: ImportStepLoading })
|
|
const SkattekontoFileUploadStep = dynamic(() => import('@/components/import/SkattekontoFileUploadStep'), { loading: ImportStepLoading })
|
|
const SkattekontoFilePreviewStep = dynamic(() => import('@/components/import/SkattekontoFilePreviewStep'), { loading: ImportStepLoading })
|
|
const SkattekontoFileResultStep = dynamic(() => import('@/components/import/SkattekontoFileResultStep'), { 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 })
|
|
const SIEImportHistory = dynamic(() => import('@/components/import/SIEImportHistory'), { loading: ImportStepLoading })
|
|
const BankFileImportHistory = dynamic(() => import('@/components/import/BankFileImportHistory'), { loading: ImportStepLoading })
|
|
const UnderlagImportWizard = dynamic(() => import('@/components/import/UnderlagImportWizard'), { loading: ImportStepLoading })
|
|
|
|
// ============================================================
|
|
// Bank File Import Wizard Steps
|
|
// ============================================================
|
|
|
|
type BankFileStep = 'upload' | 'preview' | 'column_mapping' | 'confirm' | 'result'
|
|
|
|
const BANK_STEPS: BankFileStep[] = ['upload', 'preview', 'confirm', 'result']
|
|
const BANK_STEPS_WITH_MAPPING: BankFileStep[] = ['upload', 'column_mapping', 'confirm', 'result']
|
|
|
|
const BANK_STEP_LABELS: Record<BankFileStep, string> = {
|
|
upload: 'Ladda upp',
|
|
preview: 'Förhandsgranskning',
|
|
column_mapping: 'Kolumnmappning',
|
|
confirm: 'Bekräfta',
|
|
result: 'Resultat',
|
|
}
|
|
|
|
function BankFileImportWizard() {
|
|
const { toast } = useToast()
|
|
const tTx = useTranslations('transactions')
|
|
const { company } = useCompany()
|
|
|
|
const [bankStep, setBankStep] = useState<BankFileStep>('upload')
|
|
const [bankIsLoading, setBankIsLoading] = useState(false)
|
|
const [bankError, setBankError] = useState<string | null>(null)
|
|
const [bankErrorTitle, setBankErrorTitle] = useState<string | null>(null)
|
|
// The uploaded file was recognized as a skattekontoutdrag: the upload step
|
|
// renders a pointer to the dedicated importer instead of a parse error.
|
|
const [skattekontoDetected, setSkattekontoDetected] = useState(false)
|
|
|
|
// 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)
|
|
|
|
// Advisory duplicate preview: which parsed rows ingest will skip because
|
|
// they already exist. Fetched from check-duplicates after every (re-)parse;
|
|
// a failed check leaves this null and the wizard proceeds without warning
|
|
// (execute-side dedup still skips, and the result step reports the count).
|
|
const [duplicateInfo, setDuplicateInfo] = useState<BankFileDuplicateInfo | null>(null)
|
|
|
|
const checkDuplicates = useCallback(async (transactions: BankFileParseResult['transactions'], format: BankFileFormatId) => {
|
|
setDuplicateInfo(null)
|
|
if (transactions.length === 0) return
|
|
try {
|
|
const res = await fetch('/api/import/bank-file/check-duplicates', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ transactions, format }),
|
|
})
|
|
if (!res.ok) return
|
|
const data = await res.json()
|
|
const preview = data?.data
|
|
if (preview && Array.isArray(preview.duplicate_row_indexes)) {
|
|
setDuplicateInfo({
|
|
duplicate_row_indexes: preview.duplicate_row_indexes,
|
|
duplicate_count: typeof preview.duplicate_count === 'number'
|
|
? preview.duplicate_count
|
|
: preview.duplicate_row_indexes.length,
|
|
})
|
|
}
|
|
} catch {
|
|
// Advisory only: never block the wizard on a failed preview.
|
|
}
|
|
}, [])
|
|
|
|
// Active PSD2 connections: drives an overlap warning so users don't
|
|
// accidentally upload a CSV covering periods we already sync nightly.
|
|
const [activePsd2Banks, setActivePsd2Banks] = useState<string[]>([])
|
|
useEffect(() => {
|
|
if (!company?.id) return
|
|
let cancelled = false
|
|
const supabase = createClient()
|
|
supabase
|
|
.from('bank_connections')
|
|
.select('bank_name')
|
|
.eq('company_id', company.id)
|
|
.eq('status', 'active')
|
|
.then(({ data }) => {
|
|
if (cancelled) return
|
|
const names = Array.from(new Set((data ?? []).map((r) => r.bank_name).filter(Boolean)))
|
|
setActivePsd2Banks(names)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [company?.id])
|
|
|
|
const steps = parseResult?.format === 'generic_csv' ? BANK_STEPS_WITH_MAPPING : BANK_STEPS
|
|
const currentStepIndex = steps.indexOf(bankStep)
|
|
const progress = ((currentStepIndex + 1) / steps.length) * 100
|
|
|
|
const handleFileSelect = useCallback(async (file: File, formatOverride?: BankFileFormatId) => {
|
|
setBankError(null)
|
|
setBankErrorTitle(null)
|
|
setSkattekontoDetected(false)
|
|
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 if (err.code === 'BANK_FILE_SKATTEKONTO_DETECTED') {
|
|
setSkattekontoDetected(true)
|
|
} 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. Decode from bytes, not
|
|
// file.text(): that is UTF-8-only and turns Windows-1252 åäö into
|
|
// U+FFFD (Handelsbanken exports Windows-1252), corrupting both the
|
|
// column-mapping preview and the re-parse on confirm.
|
|
const text = decodeFileContent(await file.arrayBuffer())
|
|
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.
|
|
// (The duplicate check runs after column mapping instead, on the
|
|
// client-side re-parse.)
|
|
setDuplicateInfo(null)
|
|
setBankStep('column_mapping')
|
|
} else if (txCount > 0) {
|
|
// Fire-and-forget: the warning card/badges appear when the answer
|
|
// lands; the wizard never waits for it.
|
|
void checkDuplicates(data.data.parse_result.transactions, data.data.parse_result.format)
|
|
setBankStep('preview')
|
|
toast({
|
|
title: 'Fil analyserad',
|
|
description: `${txCount} transaktioner hittades`,
|
|
})
|
|
} else {
|
|
// Format detected but no transactions parsed: surface the parser's
|
|
// real issues when it produced any, so the user sees WHY (wrong
|
|
// columns, invalid dates, ...) instead of only a generic hint.
|
|
const parseIssues: BankFileParseResult['issues'] = data.data.parse_result.issues ?? []
|
|
if (parseIssues.length > 0) {
|
|
setBankError(
|
|
parseIssues
|
|
.slice(0, 5)
|
|
.map((issue) => (issue.row > 0 ? `Rad ${issue.row}: ${issue.message}` : issue.message))
|
|
.join(' ')
|
|
)
|
|
} else {
|
|
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, checkDuplicates])
|
|
|
|
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)
|
|
// The generic path never re-hits the parse route, so this is its only
|
|
// duplicate check. Fire-and-forget: the confirm step renders immediately
|
|
// and the warning card appears when the answer lands.
|
|
void checkDuplicates(result.transactions, 'generic_csv')
|
|
setBankStep('confirm')
|
|
}, [rawFileContent, checkDuplicates])
|
|
|
|
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)
|
|
setSkattekontoDetected(false)
|
|
setRawFileContent('')
|
|
setDuplicateInfo(null)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Status chip for at-a-glance "auto-sync is healthy / stale / needs attention" */}
|
|
<BankSyncStatusChip />
|
|
|
|
{/* Overlap warning: active PSD2 means file import will likely create
|
|
duplicates of transactions the nightly sync already covers. */}
|
|
{activePsd2Banks.length > 0 && (
|
|
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-4">
|
|
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
|
|
<div className="flex-1 text-sm">
|
|
<p className="font-medium">
|
|
{tTx('import_psd2_active_warning_title', { bankName: activePsd2Banks.join(', ') })}
|
|
</p>
|
|
<p className="mt-1 text-muted-foreground">
|
|
{tTx('import_psd2_active_warning_body')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Progress */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="sm:hidden text-primary font-medium">
|
|
Steg {currentStepIndex + 1}/{steps.length}: {BANK_STEP_LABELS[bankStep]}
|
|
</span>
|
|
{steps.map((s, i) => (
|
|
<span
|
|
key={s}
|
|
className={cn(
|
|
'hidden sm:inline',
|
|
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground'
|
|
)}
|
|
>
|
|
{BANK_STEP_LABELS[s]}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<Progress value={progress} className="h-2" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Step content */}
|
|
{bankStep === 'upload' && (
|
|
<BankFileUploadStep
|
|
onFileSelect={handleFileSelect}
|
|
isLoading={bankIsLoading}
|
|
error={bankError}
|
|
errorTitle={bankErrorTitle}
|
|
detectedFormat={detectedFormat}
|
|
detectedFormatName={detectedFormatName}
|
|
skattekontoDetected={skattekontoDetected}
|
|
/>
|
|
)}
|
|
|
|
{bankStep === 'preview' && parseResult && (
|
|
<BankFilePreviewStep
|
|
parseResult={parseResult}
|
|
duplicateInfo={duplicateInfo}
|
|
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}
|
|
duplicateInfo={duplicateInfo}
|
|
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>
|
|
)
|
|
}
|
|
|
|
// ============================================================
|
|
// Skattekonto File Import Wizard
|
|
// ============================================================
|
|
|
|
type SkattekontoFileStep = 'upload' | 'preview' | 'result'
|
|
|
|
const SKATTEKONTO_STEPS: SkattekontoFileStep[] = ['upload', 'preview', 'result']
|
|
|
|
const SKATTEKONTO_STEP_LABELS: Record<SkattekontoFileStep, string> = {
|
|
upload: 'Ladda upp',
|
|
preview: 'Förhandsgranskning',
|
|
result: 'Resultat',
|
|
}
|
|
|
|
function SkattekontoImportWizard() {
|
|
const { toast } = useToast()
|
|
const t = useTranslations('import')
|
|
|
|
const [step, setStep] = useState<SkattekontoFileStep>('upload')
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [errorTitle, setErrorTitle] = useState<string | null>(null)
|
|
|
|
const [parseResult, setParseResult] = useState<SkattekontoFileParseResult | null>(null)
|
|
const [fileHash, setFileHash] = useState('')
|
|
const [filename, setFilename] = useState('')
|
|
const [duplicateIndexes, setDuplicateIndexes] = useState<number[]>([])
|
|
const [promotionIndexes, setPromotionIndexes] = useState<number[]>([])
|
|
const [orgNumberMismatch, setOrgNumberMismatch] = useState(false)
|
|
const [importResult, setImportResult] = useState<SkattekontoFileImportResult | null>(null)
|
|
|
|
const currentStepIndex = SKATTEKONTO_STEPS.indexOf(step)
|
|
const progress = ((currentStepIndex + 1) / SKATTEKONTO_STEPS.length) * 100
|
|
|
|
const handleFileSelect = useCallback(
|
|
async (file: File) => {
|
|
setError(null)
|
|
setErrorTitle(null)
|
|
setIsLoading(true)
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
const res = await fetch('/api/import/skattekonto-file/parse', {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
const err = data?.error
|
|
if (err?.code === 'SKATTEKONTO_FILE_DUPLICATE') {
|
|
setErrorTitle(t('skattekonto_duplicate_file_title'))
|
|
}
|
|
setError(getErrorMessage(data, { statusCode: res.status }))
|
|
return
|
|
}
|
|
|
|
setParseResult(data.data.parse_result)
|
|
setFileHash(data.data.file_hash)
|
|
setFilename(data.data.filename)
|
|
setDuplicateIndexes(data.data.duplicate_row_indexes ?? [])
|
|
setPromotionIndexes(data.data.promotion_row_indexes ?? [])
|
|
setOrgNumberMismatch(Boolean(data.data.org_number_mismatch))
|
|
setStep('preview')
|
|
} catch (err) {
|
|
setError(err instanceof Error ? getErrorMessage(err) : t('skattekonto_error_title'))
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
},
|
|
[t],
|
|
)
|
|
|
|
const handleExecute = useCallback(async () => {
|
|
if (!parseResult) return
|
|
setIsLoading(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch('/api/import/skattekonto-file/execute', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
rows: parseResult.rows.map((row) => ({
|
|
transaktionsdatum: row.transaktionsdatum,
|
|
transaktionstext: row.transaktionstext,
|
|
belopp: row.belopp,
|
|
})),
|
|
filename,
|
|
file_hash: fileHash,
|
|
variant: parseResult.variant,
|
|
closing_saldo: parseResult.closing_saldo,
|
|
}),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
setError(getErrorMessage(data, { statusCode: res.status }))
|
|
return
|
|
}
|
|
setImportResult(data.data)
|
|
setStep('result')
|
|
toast({
|
|
title: t('skattekonto_result_title'),
|
|
description: t('skattekonto_result_summary', {
|
|
imported: data.data.imported,
|
|
duplicates: data.data.duplicates,
|
|
}),
|
|
})
|
|
} catch (err) {
|
|
setError(err instanceof Error ? getErrorMessage(err) : t('skattekonto_error_title'))
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [parseResult, filename, fileHash, toast, t])
|
|
|
|
const handleNewImport = () => {
|
|
setStep('upload')
|
|
setParseResult(null)
|
|
setFileHash('')
|
|
setFilename('')
|
|
setDuplicateIndexes([])
|
|
setPromotionIndexes([])
|
|
setOrgNumberMismatch(false)
|
|
setImportResult(null)
|
|
setError(null)
|
|
setErrorTitle(null)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="sm:hidden text-primary font-medium">
|
|
Steg {currentStepIndex + 1}/{SKATTEKONTO_STEPS.length}:{' '}
|
|
{SKATTEKONTO_STEP_LABELS[step]}
|
|
</span>
|
|
{SKATTEKONTO_STEPS.map((s, i) => (
|
|
<span
|
|
key={s}
|
|
className={cn(
|
|
'hidden sm:inline',
|
|
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground'
|
|
)}
|
|
>
|
|
{SKATTEKONTO_STEP_LABELS[s]}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<Progress value={progress} className="h-2" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{step === 'upload' && (
|
|
<SkattekontoFileUploadStep
|
|
onFileSelect={handleFileSelect}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
errorTitle={errorTitle}
|
|
/>
|
|
)}
|
|
|
|
{step === 'preview' && parseResult && (
|
|
<SkattekontoFilePreviewStep
|
|
parseResult={parseResult}
|
|
duplicateIndexes={duplicateIndexes}
|
|
promotionIndexes={promotionIndexes}
|
|
orgNumberMismatch={orgNumberMismatch}
|
|
isLoading={isLoading}
|
|
onExecute={handleExecute}
|
|
onBack={handleNewImport}
|
|
/>
|
|
)}
|
|
|
|
{step === 'preview' && error && (
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
)}
|
|
|
|
{step === 'result' && importResult && (
|
|
<SkattekontoFileResultStep result={importResult} 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({
|
|
onOpenManualOpeningBalances,
|
|
}: {
|
|
/** Switch to the manual "Ingående balanser" wizard (SIE preview, issue #2082). */
|
|
onOpenManualOpeningBalances?: () => void
|
|
}) {
|
|
const { toast } = useToast()
|
|
|
|
const [step, setStep] = useState<ImportWizardStep>('upload')
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [errorType, setErrorType] = useState<'duplicate' | 'duplicate_period' | 'validation' | 'parse' | 'network' | undefined>()
|
|
const [validationErrors, setValidationErrors] = useState<string[]>([])
|
|
const [validationWarnings, setValidationWarnings] = useState<string[]>([])
|
|
const [duplicateImportId, setDuplicateImportId] = useState<string | null>(null)
|
|
const [isReplacing, setIsReplacing] = useState(false)
|
|
|
|
const [file, setFile] = useState<File | null>(null)
|
|
const [, setParsed] = useState<ParsedSIEFile | null>(null)
|
|
const [mappings, setMappings] = useState<AccountMapping[]>([])
|
|
const [basAccounts, setBasAccounts] = useState<BASAccount[]>([])
|
|
const [preview, setPreview] = useState<ImportPreview | null>(null)
|
|
const [issues, setIssues] = useState<ParseIssue[]>([])
|
|
const [importResult, setImportResult] = useState<ImportResult | null>(null)
|
|
const [theaterModel, setTheaterModel] = useState<TheaterModel | 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 needsVatReview = mappings.some((m) =>
|
|
m.requiresVatTreatmentReview && !m.vatTreatmentReviewed
|
|
)
|
|
const showMappingStep = hasUnmapped || needsVatReview
|
|
const sieSteps: ImportWizardStep[] = showMappingStep
|
|
? ['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,
|
|
})
|
|
setPreview(data.preview)
|
|
setIssues(data.parsed.issues)
|
|
setSieAccounts(data.parsed.accounts)
|
|
|
|
const accounts = await fetchAccounts(false).catch(() => {
|
|
throw new Error('Kunde inte hämta kontoplanen för momsgranskning.')
|
|
})
|
|
setBasAccounts(accounts)
|
|
setMappings(enrichAccountMappingsWithVat(data.mappings, accounts))
|
|
|
|
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) => enrichChangedAccountMappingWithVat(
|
|
applyMappingOverride(prev, sourceAccount, targetAccount, targetName),
|
|
sourceAccount,
|
|
basAccounts,
|
|
))
|
|
|
|
setPreview((prev) => {
|
|
if (!prev) return prev
|
|
const updatedMappings = enrichChangedAccountMappingWithVat(
|
|
applyMappingOverride(mappings, sourceAccount, targetAccount, targetName),
|
|
sourceAccount,
|
|
basAccounts,
|
|
)
|
|
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,
|
|
},
|
|
}
|
|
})
|
|
}, [basAccounts, mappings])
|
|
|
|
const handleVatTreatmentChange = useCallback((
|
|
sourceAccount: string,
|
|
treatment: AccountVatTreatment | null,
|
|
rate: number | null,
|
|
) => {
|
|
setMappings((prev) => applyVatTreatmentReview(prev, sourceAccount, treatment, rate))
|
|
}, [])
|
|
|
|
const handleConfirmAllVatTreatments = useCallback(() => {
|
|
setMappings((prev) => applyVatTreatmentReviewAll(prev))
|
|
}, [])
|
|
|
|
const confirmVatReview = useCallback(() => {
|
|
if (mappings.some((mapping) =>
|
|
mapping.requiresVatTreatmentReview && !mapping.vatTreatmentReviewed
|
|
)) {
|
|
setError('Granska momshanteringen för alla markerade konton innan du fortsätter.')
|
|
return
|
|
}
|
|
setStep('review')
|
|
setError(null)
|
|
setValidationErrors([])
|
|
setValidationWarnings([])
|
|
}, [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` })
|
|
|
|
const createdSet = new Set(missingAccounts.map(a => a.number))
|
|
// New accounts exist now: refresh every cached chart (pickers app-wide)
|
|
// and re-read the full chart for the VAT review below.
|
|
await invalidateReferenceData('ref:accounts')
|
|
const accounts = await fetchAccounts(false).catch(() => {
|
|
throw new Error('Kunde inte hämta kontoplanen för momsgranskning.')
|
|
})
|
|
setBasAccounts(accounts)
|
|
setMappings(prev => {
|
|
let updated = prev.map(m =>
|
|
!m.targetAccount && createdSet.has(m.sourceAccount)
|
|
? { ...m, targetAccount: m.sourceAccount, targetName: m.sourceName, confidence: 1.0 }
|
|
: m
|
|
)
|
|
for (const sourceAccount of createdSet) {
|
|
updated = enrichChangedAccountMappingWithVat(updated, sourceAccount, accounts)
|
|
}
|
|
return updated
|
|
})
|
|
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),
|
|
},
|
|
}
|
|
})
|
|
} 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)
|
|
|
|
// Import theater: parse the file client-side (the parser is browser-clean)
|
|
// so the graph can build itself while the server writes. Best-effort with
|
|
// a size cap: any failure just leaves the plain spinner takeover.
|
|
if (file.size <= THEATER_MAX_FILE_BYTES) {
|
|
void (async () => {
|
|
try {
|
|
const [{ parseSIEFile, detectEncoding, decodeBuffer }, { buildTheaterModel }] =
|
|
await Promise.all([import('@/lib/import/sie-parser'), import('@/lib/import/theater-model')])
|
|
const buffer = await file.arrayBuffer()
|
|
const parsed = parseSIEFile(decodeBuffer(buffer, detectEncoding(buffer)))
|
|
setTheaterModel(buildTheaterModel(parsed))
|
|
} catch {
|
|
// Theater is a nicety; the import itself is unaffected.
|
|
}
|
|
})()
|
|
}
|
|
|
|
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')
|
|
// An import creates periods and accounts: refresh the session caches so
|
|
// every picker in the app sees them without a reload.
|
|
void invalidateReferenceData(['ref:accounts', 'ref:fiscal-periods'])
|
|
|
|
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); setTheaterModel(null)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="sm:hidden text-primary font-medium">
|
|
Steg {currentStepIndex + 1}/{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(showMappingStep ? 'mapping' : 'review')} onBack={goBack}
|
|
onOpenManualOpeningBalances={onOpenManualOpeningBalances} />
|
|
)}
|
|
{step === 'mapping' && (
|
|
<AccountMappingStep mappings={mappings} basAccounts={basAccounts}
|
|
onMappingChange={handleMappingChange} onVatTreatmentChange={handleVatTreatmentChange}
|
|
onConfirmAllVatTreatments={handleConfirmAllVatTreatments}
|
|
onContinue={confirmVatReview} onBack={goBack} />
|
|
)}
|
|
{step === 'review' && preview && (
|
|
<ImportReviewStep preview={preview} mappings={mappings}
|
|
onExecute={handleExecuteImport} onBack={goBack} isLoading={isLoading}
|
|
theaterModel={theaterModel} />
|
|
)}
|
|
{step === 'result' && importResult && (
|
|
<ImportResultStep result={importResult} onNewImport={handleNewImport} onUndo={handleUndo}
|
|
preview={preview} theaterModel={theaterModel}
|
|
unresolvedVatAccountCount={mappings.filter((mapping) =>
|
|
mapping.sourceAccount === mapping.targetAccount &&
|
|
['3', '4'].includes(mapping.sourceAccount.charAt(0)) &&
|
|
!mapping.defaultVatTreatment
|
|
).length} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================
|
|
// Opening Balance Flow (entity = "opening_balance" inside CSVDataImportWizard)
|
|
// ============================================================
|
|
|
|
type OpeningBalanceStep = 'upload' | 'column_mapping' | 'edit' | 'period' | 'result'
|
|
|
|
const OB_STEP_LABELS: Record<OpeningBalanceStep, string> = {
|
|
upload: 'Ladda upp',
|
|
column_mapping: 'Kolumnmappning',
|
|
edit: 'Granska',
|
|
period: 'Period',
|
|
result: 'Resultat',
|
|
}
|
|
|
|
function OpeningBalanceFlow() {
|
|
const { toast } = useToast()
|
|
const { dialogProps, confirm } = useDestructiveConfirm()
|
|
const router = useRouter()
|
|
|
|
const [obStep, setObStep] = useState<OpeningBalanceStep>('upload')
|
|
const [obIsLoading, setObIsLoading] = useState(false)
|
|
const [obError, setObError] = useState<string | null>(null)
|
|
const [obBankFormatHint, setObBankFormatHint] = useState<string | null>(null)
|
|
const [obFile, setObFile] = useState<File | null>(null)
|
|
const [parseResult, setParseResult] = useState<OpeningBalanceParseResult | null>(null)
|
|
const [editedRows, setEditedRows] = useState<{
|
|
id: string; account_number: string; account_name: string
|
|
debit_amount: number; credit_amount: number
|
|
}[]>([])
|
|
const [executeResult, setExecuteResult] = useState<OpeningBalanceExecuteResult | null>(null)
|
|
|
|
// Determine steps: skip column mapping if confidence >= 0.8
|
|
const needsMapping = parseResult && parseResult.detected_columns.confidence < 0.8
|
|
const steps: OpeningBalanceStep[] = needsMapping
|
|
? ['upload', 'column_mapping', 'edit', 'period', 'result']
|
|
: ['upload', 'edit', 'period', 'result']
|
|
const currentStepIndex = steps.indexOf(obStep)
|
|
const progress = ((currentStepIndex + 1) / steps.length) * 100
|
|
|
|
const handleFileSelect = useCallback(async (file: File) => {
|
|
setObError(null)
|
|
setObBankFormatHint(null)
|
|
setObIsLoading(true)
|
|
setObFile(file)
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
|
|
const res = await fetch('/api/import/opening-balance/parse', {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
// Map the parsed body plus the status, never render `data.error` raw:
|
|
// the thrown-error path answers with the canonical envelope OBJECT.
|
|
setObError(getErrorMessage(data, { statusCode: res.status }))
|
|
return
|
|
}
|
|
|
|
const result: OpeningBalanceParseResult = data.data
|
|
setParseResult(result)
|
|
|
|
if (result.rows.length === 0) {
|
|
if (result.detected_bank_format) {
|
|
// The file is a bank statement uploaded to the wrong importer (#918)
|
|
setObBankFormatHint(result.detected_bank_format)
|
|
setObError(`Filen ser ut som ett kontoutdrag från ${result.detected_bank_format}, inte ingående balanser. Kontoutdrag importeras under "Banktransaktioner".`)
|
|
} else {
|
|
setObError('Inga konton med belopp hittades i filen. Kontrollera att filen innehåller kontonummer och belopp.')
|
|
}
|
|
return
|
|
}
|
|
|
|
toast({
|
|
title: 'Fil analyserad',
|
|
description: `${result.rows.length} konton hittades`,
|
|
})
|
|
|
|
// Skip column mapping if confidence >= 0.8
|
|
if (result.detected_columns.confidence < 0.8) {
|
|
setObStep('column_mapping')
|
|
} else {
|
|
setObStep('edit')
|
|
}
|
|
} catch (err) {
|
|
setObError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
|
|
} finally {
|
|
setObIsLoading(false)
|
|
}
|
|
}, [toast])
|
|
|
|
const handleColumnMappingConfirm = useCallback(async (columns: DetectedColumns) => {
|
|
if (!obFile) return
|
|
|
|
setObIsLoading(true)
|
|
setObError(null)
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', obFile)
|
|
formData.append('column_overrides', JSON.stringify(columns))
|
|
|
|
const res = await fetch('/api/import/opening-balance/parse', {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
setObError(getErrorMessage(data, { statusCode: res.status }))
|
|
return
|
|
}
|
|
|
|
setParseResult(data.data)
|
|
setObStep('edit')
|
|
} catch (err) {
|
|
setObError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
|
|
} finally {
|
|
setObIsLoading(false)
|
|
}
|
|
}, [obFile])
|
|
|
|
const handleEditContinue = useCallback((rows: typeof editedRows) => {
|
|
setEditedRows(rows)
|
|
setObStep('period')
|
|
}, [])
|
|
|
|
const handleExecute = useCallback(async (fiscalPeriodId: string, replace: boolean) => {
|
|
if (replace) {
|
|
const ok = await confirm({
|
|
title: 'Ersätt ingående balanser?',
|
|
description:
|
|
'Den befintliga IB-verifikationen makuleras (stornas) och en ny bokförs med beloppen du angett. Detta går inte att ångra automatiskt.',
|
|
confirmLabel: 'Ersätt',
|
|
variant: 'warning',
|
|
})
|
|
if (!ok) return
|
|
}
|
|
|
|
setObIsLoading(true)
|
|
setObError(null)
|
|
|
|
const endpoint = replace
|
|
? '/api/import/opening-balance/correct'
|
|
: '/api/import/opening-balance/execute'
|
|
|
|
try {
|
|
const res = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
fiscal_period_id: fiscalPeriodId,
|
|
lines: editedRows.map((r) => ({
|
|
account_number: r.account_number,
|
|
debit_amount: r.debit_amount,
|
|
credit_amount: r.credit_amount,
|
|
})),
|
|
}),
|
|
})
|
|
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
setObError(getErrorMessage(data))
|
|
return
|
|
}
|
|
|
|
setExecuteResult(data.data)
|
|
setObStep('result')
|
|
|
|
if (data.data.success) {
|
|
toast({
|
|
title: replace ? 'Ingående balanser korrigerade' : 'Ingående balanser bokförda',
|
|
description: `${data.data.lines_created} kontorader skapades`,
|
|
})
|
|
}
|
|
} catch (err) {
|
|
setObError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
|
|
} finally {
|
|
setObIsLoading(false)
|
|
}
|
|
}, [editedRows, toast, confirm])
|
|
|
|
const handleNewImport = () => {
|
|
setObStep('upload')
|
|
setObFile(null)
|
|
setParseResult(null)
|
|
setEditedRows([])
|
|
setExecuteResult(null)
|
|
setObError(null)
|
|
setObBankFormatHint(null)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Progress */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="sm:hidden text-primary font-medium">
|
|
Steg {currentStepIndex + 1}/{steps.length}: {OB_STEP_LABELS[obStep]}
|
|
</span>
|
|
{steps.map((s, i) => (
|
|
<span
|
|
key={s}
|
|
className={cn(
|
|
'hidden sm:inline',
|
|
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{OB_STEP_LABELS[s]}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<Progress value={progress} className="h-2" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Step content */}
|
|
{obStep === 'upload' && (
|
|
<OpeningBalanceUploadStep
|
|
onFileSelect={handleFileSelect}
|
|
isLoading={obIsLoading}
|
|
error={obError}
|
|
errorAction={
|
|
obBankFormatHint
|
|
? { label: 'Importera banktransaktioner', onClick: () => router.push('/import?mode=bank') }
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{obStep === 'column_mapping' && parseResult && (
|
|
<OpeningBalanceColumnMappingStep
|
|
headers={parseResult.headers}
|
|
previewRows={parseResult.preview_rows}
|
|
detectedColumns={parseResult.detected_columns}
|
|
onConfirm={handleColumnMappingConfirm}
|
|
onBack={() => setObStep('upload')}
|
|
/>
|
|
)}
|
|
|
|
{obStep === 'edit' && parseResult && (
|
|
<OpeningBalanceEditStep
|
|
rows={parseResult.rows}
|
|
onContinue={handleEditContinue}
|
|
onBack={() => {
|
|
if (needsMapping) {
|
|
setObStep('column_mapping')
|
|
} else {
|
|
setObStep('upload')
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{obStep === 'period' && (
|
|
<OpeningBalancePeriodStep
|
|
rows={editedRows}
|
|
onExecute={handleExecute}
|
|
onBack={() => setObStep('edit')}
|
|
isLoading={obIsLoading}
|
|
error={obError}
|
|
/>
|
|
)}
|
|
|
|
{obStep === 'result' && executeResult && (
|
|
<OpeningBalanceResultStep
|
|
result={executeResult}
|
|
onNewImport={handleNewImport}
|
|
/>
|
|
)}
|
|
|
|
<DestructiveConfirmDialog {...dialogProps} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================
|
|
// Customers Flow (entity = "customers" inside CSVDataImportWizard)
|
|
// ============================================================
|
|
|
|
type RegisterStep = 'upload' | 'column_mapping' | 'edit' | 'result'
|
|
|
|
const REGISTER_STEP_LABELS: Record<RegisterStep, string> = {
|
|
upload: 'Ladda upp',
|
|
column_mapping: 'Kolumnmappning',
|
|
edit: 'Granska',
|
|
result: 'Resultat',
|
|
}
|
|
|
|
const CUSTOMER_COLUMN_SPECS: RegisterColumnSpec<keyof DetectedCustomerColumns>[] = [
|
|
{ key: 'name_col', label: 'Namn', required: true },
|
|
{ key: 'org_number_col', label: 'Org-/personnummer', required: false },
|
|
{ key: 'customer_type_col', label: 'Kundtyp', required: false },
|
|
{ key: 'email_col', label: 'E-post', required: false },
|
|
{ key: 'phone_col', label: 'Telefon', required: false },
|
|
{ key: 'address_line1_col', label: 'Adress', required: false },
|
|
{ key: 'address_line2_col', label: 'Adress rad 2', required: false },
|
|
{ key: 'postal_code_col', label: 'Postnummer', required: false },
|
|
{ key: 'city_col', label: 'Ort', required: false },
|
|
{ key: 'country_col', label: 'Land', required: false },
|
|
{ key: 'vat_number_col', label: 'VAT-nummer', required: false },
|
|
{ key: 'payment_terms_col', label: 'Betalningsvillkor (dagar)', required: false },
|
|
{ key: 'notes_col', label: 'Anteckning', required: false },
|
|
]
|
|
|
|
function columnsToMapping<K extends string>(
|
|
cols: { readonly [key: string]: unknown },
|
|
specs: RegisterColumnSpec<K>[],
|
|
): Record<K, number | null> {
|
|
const out = {} as Record<K, number | null>
|
|
for (const spec of specs) {
|
|
const v = cols[spec.key as string]
|
|
out[spec.key] = typeof v === 'number' ? v : null
|
|
}
|
|
return out
|
|
}
|
|
|
|
function CustomersFlow() {
|
|
const { toast } = useToast()
|
|
|
|
const [step, setStep] = useState<RegisterStep>('upload')
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [file, setFile] = useState<File | null>(null)
|
|
const [parseResult, setParseResult] = useState<CustomerImportParseResult | null>(null)
|
|
const [executeResult, setExecuteResult] = useState<RegisterResult | null>(null)
|
|
|
|
const needsMapping = parseResult && parseResult.detected_columns.confidence < 0.8
|
|
const steps: RegisterStep[] = needsMapping
|
|
? ['upload', 'column_mapping', 'edit', 'result']
|
|
: ['upload', 'edit', 'result']
|
|
const currentStepIndex = steps.indexOf(step)
|
|
const progress = ((currentStepIndex + 1) / steps.length) * 100
|
|
|
|
const handleFileSelect = useCallback(async (selectedFile: File) => {
|
|
setError(null)
|
|
setIsLoading(true)
|
|
setFile(selectedFile)
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', selectedFile)
|
|
|
|
const res = await fetch('/api/import/customers/parse', {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
setError(data.error?.message_sv || getErrorMessage(data.error) || data.error || 'Kunde inte läsa filen')
|
|
return
|
|
}
|
|
|
|
const result = data.data as CustomerImportParseResult
|
|
setParseResult(result)
|
|
|
|
if (result.rows.length === 0) {
|
|
setError('Inga giltiga kundrader hittades. Kontrollera att filen innehåller en namnkolumn.')
|
|
return
|
|
}
|
|
|
|
toast({
|
|
title: 'Fil analyserad',
|
|
description: `${result.rows.length} kunder hittades${result.duplicate_count > 0 ? ` (${result.duplicate_count} matchar befintliga)` : ''}`,
|
|
})
|
|
|
|
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
|
|
} catch (err) {
|
|
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [toast])
|
|
|
|
const handleColumnMappingConfirm = useCallback(async (
|
|
mapping: Record<keyof DetectedCustomerColumns, number | null>,
|
|
) => {
|
|
if (!file) return
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const overrides: DetectedCustomerColumns = {
|
|
name_col: mapping.name_col ?? 0,
|
|
org_number_col: mapping.org_number_col,
|
|
customer_type_col: mapping.customer_type_col,
|
|
email_col: mapping.email_col,
|
|
phone_col: mapping.phone_col,
|
|
address_line1_col: mapping.address_line1_col,
|
|
address_line2_col: mapping.address_line2_col,
|
|
postal_code_col: mapping.postal_code_col,
|
|
city_col: mapping.city_col,
|
|
country_col: mapping.country_col,
|
|
vat_number_col: mapping.vat_number_col,
|
|
payment_terms_col: mapping.payment_terms_col,
|
|
notes_col: mapping.notes_col,
|
|
confidence: 1,
|
|
}
|
|
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
formData.append('column_overrides', JSON.stringify(overrides))
|
|
|
|
const res = await fetch('/api/import/customers/parse', {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Kunde inte tolka filen med de valda kolumnerna')
|
|
return
|
|
}
|
|
|
|
setParseResult(data.data)
|
|
setStep('edit')
|
|
} catch (err) {
|
|
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [file])
|
|
|
|
const handleExecute = useCallback(async (
|
|
rows: AnnotatedCustomerRow[],
|
|
updateDuplicates: boolean,
|
|
) => {
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const res = await fetch('/api/import/customers/execute', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
rows: rows.map(({ duplicate_match: _dup, is_valid: _v, validation_errors: _ve, ...rest }) => rest),
|
|
update_duplicates: updateDuplicates,
|
|
}),
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Importen misslyckades')
|
|
return
|
|
}
|
|
|
|
setExecuteResult(data.data as RegisterResult)
|
|
setStep('result')
|
|
|
|
const r = data.data as RegisterResult
|
|
toast({
|
|
title: r.success ? 'Kunder importerade' : 'Importen slutfördes med fel',
|
|
description: `${r.created} skapade, ${r.updated} uppdaterade, ${r.skipped} hoppade över${r.failed > 0 ? `, ${r.failed} misslyckades` : ''}`,
|
|
variant: r.success ? 'default' : 'destructive',
|
|
})
|
|
} catch (err) {
|
|
setError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [toast])
|
|
|
|
const handleNewImport = () => {
|
|
setStep('upload')
|
|
setFile(null)
|
|
setParseResult(null)
|
|
setExecuteResult(null)
|
|
setError(null)
|
|
}
|
|
|
|
const initialMapping = parseResult
|
|
? columnsToMapping<keyof DetectedCustomerColumns>(parseResult.detected_columns as unknown as { [key: string]: unknown }, CUSTOMER_COLUMN_SPECS)
|
|
: null
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="sm:hidden text-primary font-medium">
|
|
Steg {currentStepIndex + 1}/{steps.length}: {REGISTER_STEP_LABELS[step]}
|
|
</span>
|
|
{steps.map((s, i) => (
|
|
<span
|
|
key={s}
|
|
className={cn(
|
|
'hidden sm:inline',
|
|
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{REGISTER_STEP_LABELS[s]}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<Progress value={progress} className="h-2" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{step === 'upload' && (
|
|
<RegisterUploadStep
|
|
entity="customers"
|
|
onFileSelect={handleFileSelect}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
/>
|
|
)}
|
|
|
|
{step === 'column_mapping' && parseResult && initialMapping && (
|
|
<RegisterColumnMappingStep<keyof DetectedCustomerColumns>
|
|
headers={parseResult.headers}
|
|
previewRows={parseResult.preview_rows}
|
|
specs={CUSTOMER_COLUMN_SPECS}
|
|
initial={initialMapping}
|
|
onConfirm={handleColumnMappingConfirm}
|
|
onBack={() => setStep('upload')}
|
|
/>
|
|
)}
|
|
|
|
{step === 'edit' && parseResult && (
|
|
<CustomersEditStep
|
|
rows={parseResult.rows}
|
|
onExecute={handleExecute}
|
|
onBack={() => setStep(needsMapping ? 'column_mapping' : 'upload')}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
/>
|
|
)}
|
|
|
|
{step === 'result' && executeResult && (
|
|
<RegisterResultStep
|
|
entity="customers"
|
|
result={executeResult}
|
|
onNewImport={handleNewImport}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================
|
|
// Suppliers Flow (entity = "suppliers" inside CSVDataImportWizard)
|
|
// ============================================================
|
|
|
|
const SUPPLIER_COLUMN_SPECS: RegisterColumnSpec<keyof DetectedSupplierColumns>[] = [
|
|
{ key: 'name_col', label: 'Namn', required: true },
|
|
{ key: 'org_number_col', label: 'Org-/personnummer', required: false },
|
|
{ key: 'supplier_type_col', label: 'Leverantörstyp', required: false },
|
|
{ key: 'email_col', label: 'E-post', required: false },
|
|
{ key: 'phone_col', label: 'Telefon', required: false },
|
|
{ key: 'address_line1_col', label: 'Adress', required: false },
|
|
{ key: 'address_line2_col', label: 'Adress rad 2', required: false },
|
|
{ key: 'postal_code_col', label: 'Postnummer', required: false },
|
|
{ key: 'city_col', label: 'Ort', required: false },
|
|
{ key: 'country_col', label: 'Land', required: false },
|
|
{ key: 'vat_number_col', label: 'VAT-nummer', required: false },
|
|
{ key: 'bankgiro_col', label: 'Bankgiro', required: false },
|
|
{ key: 'plusgiro_col', label: 'Plusgiro', required: false },
|
|
{ key: 'bank_account_col', label: 'Bankkonto', required: false },
|
|
{ key: 'iban_col', label: 'IBAN', required: false },
|
|
{ key: 'bic_col', label: 'BIC/SWIFT', required: false },
|
|
{ key: 'payment_terms_col', label: 'Betalningsvillkor (dagar)', required: false },
|
|
{ key: 'default_currency_col', label: 'Valuta', required: false },
|
|
{ key: 'notes_col', label: 'Anteckning', required: false },
|
|
]
|
|
|
|
function SuppliersFlow() {
|
|
const { toast } = useToast()
|
|
|
|
const [step, setStep] = useState<RegisterStep>('upload')
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [file, setFile] = useState<File | null>(null)
|
|
const [parseResult, setParseResult] = useState<SupplierImportParseResult | null>(null)
|
|
const [executeResult, setExecuteResult] = useState<RegisterResult | null>(null)
|
|
|
|
const needsMapping = parseResult && parseResult.detected_columns.confidence < 0.8
|
|
const steps: RegisterStep[] = needsMapping
|
|
? ['upload', 'column_mapping', 'edit', 'result']
|
|
: ['upload', 'edit', 'result']
|
|
const currentStepIndex = steps.indexOf(step)
|
|
const progress = ((currentStepIndex + 1) / steps.length) * 100
|
|
|
|
const handleFileSelect = useCallback(async (selectedFile: File) => {
|
|
setError(null)
|
|
setIsLoading(true)
|
|
setFile(selectedFile)
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', selectedFile)
|
|
|
|
const res = await fetch('/api/import/suppliers/parse', {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
setError(data.error?.message_sv || getErrorMessage(data.error) || data.error || 'Kunde inte läsa filen')
|
|
return
|
|
}
|
|
|
|
const result = data.data as SupplierImportParseResult
|
|
setParseResult(result)
|
|
|
|
if (result.rows.length === 0) {
|
|
setError('Inga giltiga leverantörsrader hittades. Kontrollera att filen innehåller en namnkolumn.')
|
|
return
|
|
}
|
|
|
|
toast({
|
|
title: 'Fil analyserad',
|
|
description: `${result.rows.length} leverantörer hittades${result.duplicate_count > 0 ? ` (${result.duplicate_count} matchar befintliga)` : ''}`,
|
|
})
|
|
|
|
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
|
|
} catch (err) {
|
|
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [toast])
|
|
|
|
const handleColumnMappingConfirm = useCallback(async (
|
|
mapping: Record<keyof DetectedSupplierColumns, number | null>,
|
|
) => {
|
|
if (!file) return
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const overrides: DetectedSupplierColumns = {
|
|
name_col: mapping.name_col ?? 0,
|
|
org_number_col: mapping.org_number_col,
|
|
supplier_type_col: mapping.supplier_type_col,
|
|
email_col: mapping.email_col,
|
|
phone_col: mapping.phone_col,
|
|
address_line1_col: mapping.address_line1_col,
|
|
address_line2_col: mapping.address_line2_col,
|
|
postal_code_col: mapping.postal_code_col,
|
|
city_col: mapping.city_col,
|
|
country_col: mapping.country_col,
|
|
vat_number_col: mapping.vat_number_col,
|
|
bankgiro_col: mapping.bankgiro_col,
|
|
plusgiro_col: mapping.plusgiro_col,
|
|
bank_account_col: mapping.bank_account_col,
|
|
iban_col: mapping.iban_col,
|
|
bic_col: mapping.bic_col,
|
|
payment_terms_col: mapping.payment_terms_col,
|
|
default_currency_col: mapping.default_currency_col,
|
|
notes_col: mapping.notes_col,
|
|
confidence: 1,
|
|
}
|
|
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
formData.append('column_overrides', JSON.stringify(overrides))
|
|
|
|
const res = await fetch('/api/import/suppliers/parse', {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Kunde inte tolka filen')
|
|
return
|
|
}
|
|
|
|
setParseResult(data.data)
|
|
setStep('edit')
|
|
} catch (err) {
|
|
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [file])
|
|
|
|
const handleExecute = useCallback(async (
|
|
rows: AnnotatedSupplierRow[],
|
|
updateDuplicates: boolean,
|
|
) => {
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const res = await fetch('/api/import/suppliers/execute', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
rows: rows.map(({ duplicate_match: _dup, is_valid: _v, validation_errors: _ve, ...rest }) => rest),
|
|
update_duplicates: updateDuplicates,
|
|
}),
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Importen misslyckades')
|
|
return
|
|
}
|
|
|
|
setExecuteResult(data.data as RegisterResult)
|
|
setStep('result')
|
|
|
|
const r = data.data as RegisterResult
|
|
toast({
|
|
title: r.success ? 'Leverantörer importerade' : 'Importen slutfördes med fel',
|
|
description: `${r.created} skapade, ${r.updated} uppdaterade, ${r.skipped} hoppade över${r.failed > 0 ? `, ${r.failed} misslyckades` : ''}`,
|
|
variant: r.success ? 'default' : 'destructive',
|
|
})
|
|
} catch (err) {
|
|
setError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [toast])
|
|
|
|
const handleNewImport = () => {
|
|
setStep('upload')
|
|
setFile(null)
|
|
setParseResult(null)
|
|
setExecuteResult(null)
|
|
setError(null)
|
|
}
|
|
|
|
const initialMapping = parseResult
|
|
? columnsToMapping<keyof DetectedSupplierColumns>(parseResult.detected_columns as unknown as { [key: string]: unknown }, SUPPLIER_COLUMN_SPECS)
|
|
: null
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="sm:hidden text-primary font-medium">
|
|
Steg {currentStepIndex + 1}/{steps.length}: {REGISTER_STEP_LABELS[step]}
|
|
</span>
|
|
{steps.map((s, i) => (
|
|
<span
|
|
key={s}
|
|
className={cn(
|
|
'hidden sm:inline',
|
|
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{REGISTER_STEP_LABELS[s]}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<Progress value={progress} className="h-2" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{step === 'upload' && (
|
|
<RegisterUploadStep
|
|
entity="suppliers"
|
|
onFileSelect={handleFileSelect}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
/>
|
|
)}
|
|
|
|
{step === 'column_mapping' && parseResult && initialMapping && (
|
|
<RegisterColumnMappingStep<keyof DetectedSupplierColumns>
|
|
headers={parseResult.headers}
|
|
previewRows={parseResult.preview_rows}
|
|
specs={SUPPLIER_COLUMN_SPECS}
|
|
initial={initialMapping}
|
|
onConfirm={handleColumnMappingConfirm}
|
|
onBack={() => setStep('upload')}
|
|
/>
|
|
)}
|
|
|
|
{step === 'edit' && parseResult && (
|
|
<SuppliersEditStep
|
|
rows={parseResult.rows}
|
|
onExecute={handleExecute}
|
|
onBack={() => setStep(needsMapping ? 'column_mapping' : 'upload')}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
/>
|
|
)}
|
|
|
|
{step === 'result' && executeResult && (
|
|
<RegisterResultStep
|
|
entity="suppliers"
|
|
result={executeResult}
|
|
onNewImport={handleNewImport}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================
|
|
// Articles Flow (entity = "articles" inside CSVDataImportWizard)
|
|
// ============================================================
|
|
|
|
const ARTICLE_COLUMN_SPECS: RegisterColumnSpec<keyof DetectedArticleColumns>[] = [
|
|
{ key: 'name_col', label: 'Benämning', required: true },
|
|
{ key: 'article_number_col', label: 'Artikelnummer', required: false },
|
|
{ key: 'type_col', label: 'Typ (vara/tjänst)', required: false },
|
|
{ key: 'unit_col', label: 'Enhet', required: false },
|
|
{ key: 'price_col', label: 'Pris exkl moms', required: false },
|
|
{ key: 'currency_col', label: 'Valuta', required: false },
|
|
{ key: 'vat_rate_col', label: 'Moms (%)', required: false },
|
|
{ key: 'revenue_account_col', label: 'Försäljningskonto', required: false },
|
|
{ key: 'cost_price_col', label: 'Inköpspris', required: false },
|
|
{ key: 'ean_col', label: 'EAN', required: false },
|
|
{ key: 'housework_type_col', label: 'ROT/RUT-arbetstyp', required: false },
|
|
{ key: 'name_en_col', label: 'Benämning (engelska)', required: false },
|
|
{ key: 'notes_col', label: 'Anteckning', required: false },
|
|
]
|
|
|
|
function ArticlesFlow() {
|
|
const { toast } = useToast()
|
|
|
|
const [step, setStep] = useState<RegisterStep>('upload')
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [file, setFile] = useState<File | null>(null)
|
|
const [parseResult, setParseResult] = useState<ArticleImportParseResult | null>(null)
|
|
const [executeResult, setExecuteResult] = useState<RegisterResult | null>(null)
|
|
|
|
const needsMapping = parseResult && parseResult.detected_columns.confidence < 0.8
|
|
const steps: RegisterStep[] = needsMapping
|
|
? ['upload', 'column_mapping', 'edit', 'result']
|
|
: ['upload', 'edit', 'result']
|
|
const currentStepIndex = steps.indexOf(step)
|
|
const progress = ((currentStepIndex + 1) / steps.length) * 100
|
|
|
|
const handleFileSelect = useCallback(async (selectedFile: File) => {
|
|
setError(null)
|
|
setIsLoading(true)
|
|
setFile(selectedFile)
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', selectedFile)
|
|
|
|
const res = await fetch('/api/import/articles/parse', {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
setError(data.error?.message_sv || getErrorMessage(data.error) || data.error || 'Kunde inte läsa filen')
|
|
return
|
|
}
|
|
|
|
const result = data.data as ArticleImportParseResult
|
|
setParseResult(result)
|
|
|
|
if (result.rows.length === 0) {
|
|
setError('Inga giltiga artiklar hittades. Kontrollera att filen innehåller en benämningskolumn.')
|
|
return
|
|
}
|
|
|
|
toast({
|
|
title: 'Fil analyserad',
|
|
description: `${result.rows.length} artiklar hittades${result.duplicate_count > 0 ? ` (${result.duplicate_count} matchar befintliga)` : ''}`,
|
|
})
|
|
|
|
setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit')
|
|
} catch (err) {
|
|
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [toast])
|
|
|
|
const handleColumnMappingConfirm = useCallback(async (
|
|
mapping: Record<keyof DetectedArticleColumns, number | null>,
|
|
) => {
|
|
if (!file) return
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const overrides: DetectedArticleColumns = {
|
|
name_col: mapping.name_col ?? 0,
|
|
article_number_col: mapping.article_number_col,
|
|
name_en_col: mapping.name_en_col,
|
|
type_col: mapping.type_col,
|
|
unit_col: mapping.unit_col,
|
|
price_col: mapping.price_col,
|
|
currency_col: mapping.currency_col,
|
|
vat_rate_col: mapping.vat_rate_col,
|
|
revenue_account_col: mapping.revenue_account_col,
|
|
cost_price_col: mapping.cost_price_col,
|
|
ean_col: mapping.ean_col,
|
|
housework_type_col: mapping.housework_type_col,
|
|
notes_col: mapping.notes_col,
|
|
confidence: 1,
|
|
}
|
|
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
formData.append('column_overrides', JSON.stringify(overrides))
|
|
|
|
const res = await fetch('/api/import/articles/parse', {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Kunde inte tolka filen med de valda kolumnerna')
|
|
return
|
|
}
|
|
|
|
setParseResult(data.data)
|
|
setStep('edit')
|
|
} catch (err) {
|
|
setError(err instanceof Error ? getErrorMessage(err) : 'Kunde inte läsa filen')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [file])
|
|
|
|
const handleExecute = useCallback(async (
|
|
rows: AnnotatedArticleRow[],
|
|
updateDuplicates: boolean,
|
|
) => {
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const res = await fetch('/api/import/articles/execute', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
rows: rows.map(({ duplicate_match: _dup, is_valid: _v, validation_errors: _ve, vat_rate_adjusted: _vra, ...rest }) => rest),
|
|
update_duplicates: updateDuplicates,
|
|
}),
|
|
})
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
setError(data.error?.message_sv || getErrorMessage(data.error) || 'Importen misslyckades')
|
|
return
|
|
}
|
|
|
|
setExecuteResult(data.data as RegisterResult)
|
|
setStep('result')
|
|
|
|
const r = data.data as RegisterResult
|
|
toast({
|
|
title: r.success ? 'Artiklar importerade' : 'Importen slutfördes med fel',
|
|
description: `${r.created} skapade, ${r.updated} uppdaterade, ${r.skipped} hoppade över${r.failed > 0 ? `, ${r.failed} misslyckades` : ''}`,
|
|
variant: r.success ? 'default' : 'destructive',
|
|
})
|
|
} catch (err) {
|
|
setError(err instanceof Error ? getErrorMessage(err) : 'Importen misslyckades')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [toast])
|
|
|
|
const handleNewImport = () => {
|
|
setStep('upload')
|
|
setFile(null)
|
|
setParseResult(null)
|
|
setExecuteResult(null)
|
|
setError(null)
|
|
}
|
|
|
|
const initialMapping = parseResult
|
|
? columnsToMapping<keyof DetectedArticleColumns>(parseResult.detected_columns as unknown as { [key: string]: unknown }, ARTICLE_COLUMN_SPECS)
|
|
: null
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="sm:hidden text-primary font-medium">
|
|
Steg {currentStepIndex + 1}/{steps.length}: {REGISTER_STEP_LABELS[step]}
|
|
</span>
|
|
{steps.map((s, i) => (
|
|
<span
|
|
key={s}
|
|
className={cn(
|
|
'hidden sm:inline',
|
|
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{REGISTER_STEP_LABELS[s]}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<Progress value={progress} className="h-2" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{step === 'upload' && (
|
|
<RegisterUploadStep
|
|
entity="articles"
|
|
onFileSelect={handleFileSelect}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
/>
|
|
)}
|
|
|
|
{step === 'column_mapping' && parseResult && initialMapping && (
|
|
<RegisterColumnMappingStep<keyof DetectedArticleColumns>
|
|
headers={parseResult.headers}
|
|
previewRows={parseResult.preview_rows}
|
|
specs={ARTICLE_COLUMN_SPECS}
|
|
initial={initialMapping}
|
|
onConfirm={handleColumnMappingConfirm}
|
|
onBack={() => setStep('upload')}
|
|
/>
|
|
)}
|
|
|
|
{step === 'edit' && parseResult && (
|
|
<ArticlesEditStep
|
|
rows={parseResult.rows}
|
|
onExecute={handleExecute}
|
|
onBack={() => setStep(needsMapping ? 'column_mapping' : 'upload')}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
/>
|
|
)}
|
|
|
|
{step === 'result' && executeResult && (
|
|
<RegisterResultStep
|
|
entity="articles"
|
|
result={executeResult}
|
|
onNewImport={handleNewImport}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================
|
|
// CSV/Excel Data Import Wizard, entity selector + sub-flow
|
|
// ============================================================
|
|
|
|
type CSVDataEntity = 'opening_balance' | 'customers' | 'suppliers' | 'articles'
|
|
|
|
const ENTITY_OPTIONS: { value: CSVDataEntity; label: string }[] = [
|
|
{ value: 'opening_balance', label: 'Ingående balanser' },
|
|
{ value: 'customers', label: 'Kunder' },
|
|
{ value: 'suppliers', label: 'Leverantörer' },
|
|
{ value: 'articles', label: 'Artiklar' },
|
|
]
|
|
|
|
function CSVDataImportWizard() {
|
|
const [entity, setEntity] = useState<CSVDataEntity | null>('opening_balance')
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-wrap gap-3">
|
|
{ENTITY_OPTIONS.map((opt) => {
|
|
const selected = entity === opt.value
|
|
return (
|
|
<div key={opt.value} className="relative">
|
|
{selected && (
|
|
<svg
|
|
aria-hidden
|
|
className="pointer-events-none absolute -inset-[3px] h-[calc(100%+6px)] w-[calc(100%+6px)] overflow-visible"
|
|
>
|
|
<rect
|
|
x="1"
|
|
y="1"
|
|
width="calc(100% - 2px)"
|
|
height="calc(100% - 2px)"
|
|
rx="8"
|
|
ry="8"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.25"
|
|
strokeDasharray="3 4"
|
|
className="animate-marching-ants text-foreground/45"
|
|
/>
|
|
</svg>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => setEntity(opt.value)}
|
|
aria-pressed={selected}
|
|
className={cn(
|
|
'relative h-9 rounded-lg border px-4 text-sm font-medium transition-colors',
|
|
selected
|
|
? 'border-foreground bg-foreground text-background'
|
|
: 'border-border bg-card text-foreground hover:border-foreground/30 hover:bg-muted',
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{entity === 'opening_balance' && <OpeningBalanceFlow key="ob-flow" />}
|
|
{entity === 'customers' && <CustomersFlow key="cust-flow" />}
|
|
{entity === 'suppliers' && <SuppliersFlow key="supp-flow" />}
|
|
{entity === 'articles' && <ArticlesFlow key="art-flow" />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================
|
|
// Banking (PSD2) connect UI
|
|
// ============================================================
|
|
// Provided by the enable-banking extension and loaded through the settings
|
|
// panel registry (dynamic import), so this core page never imports from
|
|
// @/extensions directly. The shared panel renders every connection state
|
|
// (pending account selection, active, expiring, and expired/error with the
|
|
// reconnect entry point), which the old inline wizard here did not.
|
|
const BankingPanel = getSettingsPanel('enable-banking')
|
|
|
|
// Same registry mechanism for the Stripe connect/sync surface: the feed of
|
|
// payments, fees and payouts is an import source in the same category as the
|
|
// PSD2 bank connection above.
|
|
const StripePanel = getSettingsPanel('stripe')
|
|
|
|
// And for the WooCommerce order feed: the store's paid orders and refunds are
|
|
// an import source in the same category as the Stripe feed above.
|
|
const WooCommercePanel = getSettingsPanel('woocommerce')
|
|
|
|
// And for the Shopify order feed: same category as the WooCommerce feed above.
|
|
const ShopifyPanel = getSettingsPanel('shopify')
|
|
|
|
// ============================================================
|
|
// Import Page with Selection Cards
|
|
// ============================================================
|
|
|
|
type ImportMode = null | 'psd2' | 'stripe' | 'woocommerce' | 'shopify' | 'bank' | 'skattekonto' | 'sie' | 'underlag' | 'csv_data' | 'migration'
|
|
|
|
export default function ImportPage() {
|
|
const { isSandbox, role } = useCompany()
|
|
const [mode, setMode] = useState<ImportMode>(null)
|
|
const [initialProvider, setInitialProvider] = useState<string | null>(null)
|
|
const [view, setView] = useState<'import' | 'export'>('import')
|
|
const [sieDialogOpen, setSieDialogOpen] = useState(false)
|
|
const [archiveDialogOpen, setArchiveDialogOpen] = useState(false)
|
|
const [cloudOpen, setCloudOpen] = useState(false)
|
|
const [sieHistoryOpen, setSieHistoryOpen] = useState(false)
|
|
const [bankFileHistoryOpen, setBankFileHistoryOpen] = useState(false)
|
|
const [userId, setUserId] = useState('')
|
|
const [exportPeriodId, setExportPeriodId] = useState<string | null>(null)
|
|
const [exportExcludeClosing, setExportExcludeClosing] = useState(true)
|
|
const t = useTranslations('import')
|
|
const router = useRouter()
|
|
const hasCloudBackup = ENABLED_EXTENSION_IDS.has('cloud-backup')
|
|
const hasBankSync = useCapability(CAPABILITY.bank_sync)
|
|
|
|
// Fetch authenticated user ID (used by the migration wizard)
|
|
useEffect(() => {
|
|
const supabase = createClient()
|
|
supabase.auth.getUser().then(({ data: { user } }) => {
|
|
if (user) setUserId(user.id)
|
|
})
|
|
}, [])
|
|
|
|
// Sync mode + view from URL search params (reacts to client-side navigation changes)
|
|
const searchParams = useSearchParams()
|
|
useEffect(() => {
|
|
// External imports (provider migration, PSD2 bank connection) need live
|
|
// third-party credentials, so their deep links are ignored in the sandbox.
|
|
// Manual file-import modes (bank file, CSV/Excel, SIE) stay reachable.
|
|
const allowedModes = isSandbox
|
|
? ['bank', 'skattekonto', 'sie', 'underlag', 'csv_data']
|
|
: ['psd2', 'stripe', 'woocommerce', 'shopify', 'bank', 'skattekonto', 'sie', 'underlag', '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)
|
|
}
|
|
// Deep link from the onboarding branch question: preselect the old
|
|
// system so the wizard can jump straight to its connect step. Cleared
|
|
// for every other mode so a stale preselect can't survive re-entry.
|
|
setInitialProvider(
|
|
modeParam === 'migration' && !isSandbox ? searchParams.get('provider') : null
|
|
)
|
|
}
|
|
const viewParam = searchParams.get('view')
|
|
if (viewParam === 'export' || viewParam === 'import') {
|
|
setView(viewParam)
|
|
}
|
|
// Deep link from surfaces where a bad import is discovered (e.g. the
|
|
// voucher list): /import?history=sie lands directly on the fold-open
|
|
// SIE import history so "Ångra import" is one click away.
|
|
if (searchParams.get('history') === 'sie') {
|
|
setView('import')
|
|
setSieHistoryOpen(true)
|
|
// The history table renders below ~8 import rows: scroll it into view
|
|
// (same pattern as the #cloud-backup hash deep link below).
|
|
setTimeout(() => {
|
|
document
|
|
.getElementById('sie-import-history')
|
|
?.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
|
}, 80)
|
|
}
|
|
}, [isSandbox, searchParams])
|
|
|
|
// Hash-based deep links: all live on the export tab; #sie-export opens
|
|
// the SIE dialog, #full-archive opens the archive download dialog, and
|
|
// #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 === '#full-archive') {
|
|
setView('export')
|
|
setArchiveDialogOpen(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
|
|
const hasWooCommerceExtension = ENABLED_EXTENSION_IDS.has('woocommerce')
|
|
// Same doctrine as Stripe: external credentials never leave the sandbox.
|
|
const woocommerceDisabled = isSandbox
|
|
const hasShopifyExtension = ENABLED_EXTENSION_IDS.has('shopify')
|
|
const shopifyDisabled = isSandbox
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<PageHeader
|
|
title={t('title')}
|
|
help={
|
|
<HelpPopover>
|
|
<p>{t('help_text')}</p>
|
|
</HelpPopover>
|
|
}
|
|
/>
|
|
|
|
{mode === null && (
|
|
<>
|
|
{isSandbox && <AttnLine>{t('sandbox_disabled')}</AttnLine>}
|
|
|
|
{/* Importera / Exportera as separate tabs (house seg), like before */}
|
|
<SegmentedControl
|
|
value={view}
|
|
onChange={handleViewChange}
|
|
options={[
|
|
{ value: 'import', label: t('tab_import') },
|
|
{ value: 'export', label: t('tab_export') },
|
|
]}
|
|
/>
|
|
|
|
{view === 'import' ? (
|
|
<div>
|
|
<div className="stagger-enter">
|
|
{hasBankingExtension && (
|
|
<ImportRow
|
|
title={t('psd2_title')}
|
|
sub={t('psd2_description')}
|
|
chip={
|
|
hasBankSync ? (
|
|
<Badge variant="success" className="font-normal">{t('psd2_recommended')}</Badge>
|
|
) : (
|
|
<span className="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium leading-none text-muted-foreground">
|
|
{t('psd2_requires_subscription')}
|
|
</span>
|
|
)
|
|
}
|
|
chips={<LogoChip src="/logos/enable-banking-icon.png" name="Enable Banking" mono />}
|
|
disabled={isSandbox}
|
|
onClick={() => setMode('psd2')}
|
|
/>
|
|
)}
|
|
{hasStripeExtension && (
|
|
<ImportRow
|
|
title={t('stripe_title')}
|
|
sub={t('stripe_description')}
|
|
chips={<LogoChip src="/logos/stripeicon.jpeg" name="Stripe" />}
|
|
disabled={stripeDisabled}
|
|
onClick={() => setMode('stripe')}
|
|
/>
|
|
)}
|
|
{hasWooCommerceExtension && (
|
|
<ImportRow
|
|
title={t('woocommerce_title')}
|
|
sub={t('woocommerce_description')}
|
|
chip={<BetaChip label={t('badge_beta')} />}
|
|
chips={<LogoChip src="/logos/woocommerce.svg" name="WooCommerce" />}
|
|
disabled={woocommerceDisabled}
|
|
onClick={() => setMode('woocommerce')}
|
|
/>
|
|
)}
|
|
{hasShopifyExtension && (
|
|
<ImportRow
|
|
title={t('shopify_title')}
|
|
sub={t('shopify_description')}
|
|
chip={<BetaChip label={t('badge_beta')} />}
|
|
chips={<LogoChip src="/logos/shopify.svg" name="Shopify" />}
|
|
disabled={shopifyDisabled}
|
|
onClick={() => setMode('shopify')}
|
|
/>
|
|
)}
|
|
{hasMigrationExtension && (
|
|
<ImportRow
|
|
title={t('migration_title')}
|
|
sub={t('migration_description')}
|
|
chips={
|
|
<>
|
|
<LogoChip src="/logos/fortnox.svg" name="Fortnox" />
|
|
<LogoChip src="/logos/visma.jpeg" name="Visma" />
|
|
<LogoChip src="/logos/bokio.png" name="Bokio" />
|
|
<LogoChip src="/logos/bjornlunden.png" name="Björn Lundén" />
|
|
<LogoChip src="/logos/Briox_logo.png" name="Briox" />
|
|
<LogoChip src="/logos/wint.png" name="WINT" />
|
|
</>
|
|
}
|
|
disabled={isSandbox}
|
|
onClick={() => setMode('migration')}
|
|
/>
|
|
)}
|
|
<ImportRow
|
|
title={t('bankfile_title')}
|
|
sub={t('bankfile_description')}
|
|
onClick={() => setMode('bank')}
|
|
/>
|
|
<ImportRow
|
|
title={t('skattekonto_title')}
|
|
sub={t('skattekonto_description')}
|
|
onClick={() => setMode('skattekonto')}
|
|
/>
|
|
<ImportRow
|
|
title={t('csv_data_title')}
|
|
sub={t('csv_data_description')}
|
|
onClick={() => setMode('csv_data')}
|
|
/>
|
|
<ImportRow
|
|
title={t('sie_title')}
|
|
sub={t('sie_description')}
|
|
onClick={() => setMode('sie')}
|
|
/>
|
|
{/* Optional follow-up to a SIE import, never a step inside it:
|
|
the receipts usually arrive later and from another export. */}
|
|
<ImportRow
|
|
title={t('underlag_title')}
|
|
sub={t('underlag_description')}
|
|
onClick={() => setMode('underlag')}
|
|
/>
|
|
<ImportRow
|
|
title={t('sie_history_title')}
|
|
sub={t('sie_history_description')}
|
|
expanded={sieHistoryOpen}
|
|
onClick={() => setSieHistoryOpen((v) => !v)}
|
|
/>
|
|
<ImportRow
|
|
title={t('bankfile_history_title')}
|
|
sub={t('bankfile_history_description')}
|
|
expanded={bankFileHistoryOpen}
|
|
onClick={() => setBankFileHistoryOpen((v) => !v)}
|
|
/>
|
|
</div>
|
|
{sieHistoryOpen && (
|
|
<div className="mt-6" id="sie-import-history">
|
|
<SIEImportHistory />
|
|
</div>
|
|
)}
|
|
{bankFileHistoryOpen && (
|
|
<div className="mt-6">
|
|
<BankFileImportHistory />
|
|
</div>
|
|
)}
|
|
<p className="mt-4 px-1 text-xs leading-5 text-muted-foreground">{t('pgnote')}</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<div className="stagger-enter">
|
|
<ImportRow
|
|
id="sie-export"
|
|
title={t('export_sie_title')}
|
|
sub={t('export_sie_description')}
|
|
onClick={() => setSieDialogOpen(true)}
|
|
/>
|
|
{/* The full-archive route is owner/admin-only (double-checked
|
|
server-side), so the row hides for members and viewers
|
|
instead of offering a download that can only 403. */}
|
|
{(role === 'owner' || role === 'admin') && (
|
|
<ImportRow
|
|
id="full-archive"
|
|
title={t('export_archive_title')}
|
|
sub={t('export_archive_description')}
|
|
onClick={() => setArchiveDialogOpen(true)}
|
|
/>
|
|
)}
|
|
{hasCloudBackup && (
|
|
<ImportRow
|
|
title={t('cloud_row_title')}
|
|
sub={t('cloud_row_description')}
|
|
expanded={cloudOpen}
|
|
onClick={() => setCloudOpen((v) => !v)}
|
|
/>
|
|
)}
|
|
</div>
|
|
{hasCloudBackup && cloudOpen && (
|
|
<div id="cloud-backup" className="mt-6 scroll-mt-24">
|
|
<CloudBackupCard />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Full archive (SIE + reports + all documents) as the same kind of
|
|
small centered dialog as the SIE export below. */}
|
|
<FullArchiveDialog open={archiveDialogOpen} onOpenChange={setArchiveDialogOpen} />
|
|
|
|
{/* SIE export as a small centered dialog (concept overlay convention) */}
|
|
<Dialog open={sieDialogOpen} onOpenChange={setSieDialogOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="font-display text-lg tracking-tight">
|
|
{t('export_sie_title')}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-[13px] leading-relaxed">
|
|
{t('export_sie_dialog_description')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<FiscalYearSelector
|
|
value={exportPeriodId}
|
|
onChange={setExportPeriodId}
|
|
includeAllOption={false}
|
|
hideFuturePeriods
|
|
label={t('export_sie_period_label')}
|
|
/>
|
|
<label className="flex cursor-pointer items-start gap-2 text-sm text-muted-foreground">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-0.5 h-4 w-4 rounded-sm border-border"
|
|
checked={exportExcludeClosing}
|
|
onChange={(e) => setExportExcludeClosing(e.target.checked)}
|
|
/>
|
|
<span>{t('export_sie_exclude_closing_label')}</span>
|
|
</label>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
onClick={() => {
|
|
if (exportPeriodId) {
|
|
const params = new URLSearchParams({ period_id: exportPeriodId })
|
|
if (exportExcludeClosing) params.set('exclude_closing', 'true')
|
|
window.open(`/api/reports/sie-export?${params.toString()}`, '_blank')
|
|
}
|
|
}}
|
|
disabled={!exportPeriodId}
|
|
>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
{t('export_sie_button')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)}
|
|
|
|
{mode !== null && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setMode(null)
|
|
// Mode is client state (not URL-synced), so the deep-linked
|
|
// preselect must be cleared here too or a re-entered migration
|
|
// mode would auto-jump again.
|
|
setInitialProvider(null)
|
|
}}
|
|
>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
{t('back_to_choices')}
|
|
</Button>
|
|
)}
|
|
|
|
{mode === 'psd2' && (
|
|
hasBankingExtension && BankingPanel ? (
|
|
<BankingPanel />
|
|
) : (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
<Landmark className="mb-4 h-10 w-10 text-muted-foreground/40" />
|
|
<p className="mb-1 font-medium">Bankintegration (PSD2) är inte aktiverad</p>
|
|
<p className="mb-4 max-w-md text-sm text-muted-foreground">
|
|
Aktivera tillägget Enable Banking för att koppla ditt bankkonto, eller importera
|
|
transaktioner manuellt via bankfil.
|
|
</p>
|
|
<Button variant="outline" onClick={() => setMode('bank')}>
|
|
Importera bankfil istället
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
)}
|
|
{mode === 'stripe' && (
|
|
hasStripeExtension && StripePanel ? (
|
|
<StripePanel />
|
|
) : (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
<CreditCard className="mb-4 h-10 w-10 text-muted-foreground/40" />
|
|
<p className="mb-1 font-medium">{t('stripe_not_enabled_title')}</p>
|
|
<p className="max-w-md text-sm text-muted-foreground">
|
|
{t('stripe_not_enabled_description')}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
)}
|
|
{mode === 'woocommerce' && (
|
|
hasWooCommerceExtension && WooCommercePanel ? (
|
|
<WooCommercePanel />
|
|
) : (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
<ShoppingCart className="mb-4 h-10 w-10 text-muted-foreground/40" />
|
|
<p className="mb-1 font-medium">{t('woocommerce_not_enabled_title')}</p>
|
|
<p className="max-w-md text-sm text-muted-foreground">
|
|
{t('woocommerce_not_enabled_description')}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
)}
|
|
{mode === 'shopify' && (
|
|
hasShopifyExtension && ShopifyPanel ? (
|
|
<ShopifyPanel />
|
|
) : (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
<ShoppingBag className="mb-4 h-10 w-10 text-muted-foreground/40" />
|
|
<p className="mb-1 font-medium">{t('shopify_not_enabled_title')}</p>
|
|
<p className="max-w-md text-sm text-muted-foreground">
|
|
{t('shopify_not_enabled_description')}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
)}
|
|
{mode === 'bank' && <BankFileImportWizard />}
|
|
{mode === 'skattekonto' && <SkattekontoImportWizard />}
|
|
{/* The CSV/Excel wizard opens on "Ingående balanser" by default, which is
|
|
exactly the manual path the SIE preview offers on an IB imbalance. */}
|
|
{mode === 'sie' && <SIEImportWizard onOpenManualOpeningBalances={() => setMode('csv_data')} />}
|
|
{mode === 'underlag' && <UnderlagImportWizard />}
|
|
{mode === 'csv_data' && <CSVDataImportWizard />}
|
|
{mode === 'migration' && (
|
|
<MigrationWizard userId={userId} initialProvider={initialProvider ?? undefined} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Quiet action row (concept scene 32): borderless list row with title, muted
|
|
// sub-line and a chevron; chips only for the recommended/gated exceptions.
|
|
function ImportRow({
|
|
title,
|
|
sub,
|
|
chip,
|
|
chips,
|
|
disabled = false,
|
|
expanded,
|
|
onClick,
|
|
id,
|
|
}: {
|
|
title: string
|
|
sub: string
|
|
chip?: React.ReactNode
|
|
/** Logo chips under the sub line (provider marks, as on the live page). */
|
|
chips?: React.ReactNode
|
|
disabled?: boolean
|
|
/** For rows that fold a panel open below the grid (cloud backup). */
|
|
expanded?: boolean
|
|
onClick: () => void
|
|
id?: string
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
id={id}
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
aria-expanded={expanded}
|
|
className={cn(
|
|
'group flex w-full items-center justify-between gap-4 border-b border-border px-1 py-3 text-left',
|
|
'transition-colors duration-150 hover:bg-secondary/35',
|
|
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent',
|
|
)}
|
|
>
|
|
<span className="min-w-0">
|
|
<span className="flex flex-wrap items-center gap-2 text-sm font-medium">
|
|
{title}
|
|
{chip}
|
|
</span>
|
|
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{sub}</span>
|
|
{chips && <span className="mt-2 flex flex-wrap gap-2">{chips}</span>}
|
|
</span>
|
|
<ChevronRight
|
|
className={cn(
|
|
'h-4 w-4 shrink-0 text-muted-foreground/40 transition-transform duration-150',
|
|
'group-hover:translate-x-0.5 group-hover:text-muted-foreground',
|
|
expanded && 'rotate-90',
|
|
)}
|
|
aria-hidden="true"
|
|
/>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
// Same quiet beta-badge recipe as the sidebar nav (DashboardNav renderBadge),
|
|
// so "Beta" reads identically wherever it appears.
|
|
function BetaChip({ label }: { label: string }) {
|
|
return (
|
|
<span className="rounded-full bg-muted/60 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-muted-foreground/70">
|
|
{label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// Provider mark chip (same recipe as the pre-migration live page): tiny logo
|
|
// on a quiet bordered chip, so integrations read as first-class brands.
|
|
// `mono` is for light-on-transparent marks (Enable Banking): the marketing
|
|
// site's grayscale+brightness treatment makes them read on a light ground,
|
|
// with the inverse lift in dark mode.
|
|
function LogoChip({ src, name, mono = false }: { src: string; name: string; mono?: boolean }) {
|
|
return (
|
|
<span className="flex items-center gap-2 rounded-sm border border-border bg-muted/30 px-2 py-1">
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
className={cn(
|
|
'h-4 w-4 shrink-0 rounded-sm object-contain',
|
|
mono &&
|
|
'opacity-90 [filter:grayscale(100%)_brightness(0.18)] dark:[filter:grayscale(100%)_brightness(1.5)]',
|
|
)}
|
|
/>
|
|
<span className="text-[11px] font-medium text-muted-foreground">{name}</span>
|
|
</span>
|
|
)
|
|
}
|