* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2847 lines
105 KiB
TypeScript
2847 lines
105 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() {
|
|
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} />
|
|
)}
|
|
{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 />}
|
|
{mode === 'sie' && <SIEImportWizard />}
|
|
{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>
|
|
)
|
|
}
|