f7cd1b86e7
* feat(import): add syncMappedAccounts helper for account create + rename Single home for the create-missing-accounts logic that exists in three near-identical copies (executeSIEImport, the SIE execute route, and the arcim-migration extension), plus a new rename pass that carries customized SIE #KONTO names into accounts that already exist (e.g. K1-seeded defaults). The file's name applies only to identity mappings (source === target); remapped targets keep their BAS/current name. With updateAccountNames=false the behavior matches the legacy code exactly. Not wired up yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(import): preserve SIE #KONTO account names; add updateAccountNames option Customer report: account names customized in Fortnox did not follow into Accounted via SIE import. The import always used BAS default names for accounts in the BAS reference and never touched accounts that already existed (the K1-seeded chart), so the file's names were silently dropped. executeSIEImport now routes account creation through syncMappedAccounts, which prefers the file's #KONTO name for identity-mapped accounts and renames existing accounts whose name differs (surfaced as a warning). New option updateAccountNames (default true) restores the old behavior when disabled. The duplicated pre-create blocks in the execute route and the arcim-migration extension are removed — executeSIEImport owns account sync on every path now, including the Fortnox re-sync (idempotent renames). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(mcp): expose update_account_names on gnubok_import_sie Optional boolean on the tool schema, staged into the pending operation and threaded through commitImportSie to executeSIEImport. Defaults to true at both stage and commit time — the commit-side default also covers operations staged before the param existed (Boolean(undefined) would have silently flipped it off). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(api): v1 SIE import generated no account mappings The route passed [] as mappings to executeSIEImport, which the mapping-coverage guard (added in #613) rejects for any real file — and before that guard, every voucher was silently skipped as unmapped. The route has never produced a working import for files with vouchers. Generate mappings server-side from the file's #KONTO records plus stored per-company overrides (same as the dashboard execute route), reject unmappable files with a clean 400 before the operation row is created, and expose options.updateAccountNames (default true). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(import): "Använd kontonamn från filen" toggle in import review step New switch (default on) controlling whether the SIE file's #KONTO names are carried into the chart of accounts. Helper text shows how many identity-mapped accounts carry names that differ from the BAS defaults. The page already serializes the whole options object to the execute route, so no further wiring is needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(import): address PR #669 review — parallel renames, rename audit trail - Rename pass now runs UPDATEs concurrently in bounded batches of 25 (greptile P2): a re-sync with many custom names no longer serializes N round trips, and a pathological full-chart rename cannot stampede the API. Per-rename failures stay non-fatal via Promise.allSettled. - Persist the per-account rename detail (number, from, to) into sie_imports.migration_documentation as accountRenames — the behandlingshistorik record per BFNAR 2013:2 (swedish-compliance review); the result warnings only carry the count. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
412 lines
15 KiB
TypeScript
412 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { formatCurrency } from '@/lib/utils'
|
|
import {
|
|
CheckCircle,
|
|
AlertCircle,
|
|
ArrowRight,
|
|
Loader2,
|
|
Calendar,
|
|
FileText,
|
|
Database,
|
|
Lock,
|
|
} from 'lucide-react'
|
|
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
|
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
|
import { createClient } from '@/lib/supabase/client'
|
|
import { useCompany } from '@/contexts/CompanyContext'
|
|
import type { ImportPreview, AccountMapping } from '@/lib/import/types'
|
|
|
|
const SERIES_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
|
|
|
interface ImportReviewStepProps {
|
|
preview: ImportPreview
|
|
mappings: AccountMapping[]
|
|
onExecute: (options: ImportExecuteOptions) => Promise<void>
|
|
onBack: () => void
|
|
isLoading: boolean
|
|
}
|
|
|
|
export interface ImportExecuteOptions {
|
|
createFiscalPeriod: boolean
|
|
importOpeningBalances: boolean
|
|
importTransactions: boolean
|
|
updateAccountNames: boolean
|
|
voucherSeries: string
|
|
}
|
|
|
|
export default function ImportReviewStep({
|
|
preview,
|
|
mappings,
|
|
onExecute,
|
|
onBack,
|
|
isLoading,
|
|
}: ImportReviewStepProps) {
|
|
const { canWrite } = useCanWrite()
|
|
const { company } = useCompany()
|
|
const [options, setOptions] = useState<ImportExecuteOptions>({
|
|
createFiscalPeriod: true,
|
|
importOpeningBalances: true,
|
|
importTransactions: true,
|
|
updateAccountNames: true,
|
|
voucherSeries: 'B',
|
|
})
|
|
const [defaultSeries, setDefaultSeries] = useState<string | null>(null)
|
|
const [existingSeries, setExistingSeries] = useState<Set<string>>(new Set())
|
|
const [seriesLoaded, setSeriesLoaded] = useState(false)
|
|
const [elapsed, setElapsed] = useState(0)
|
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!company?.id) return
|
|
setSeriesLoaded(false)
|
|
const supabase = createClient()
|
|
|
|
let cancelled = false
|
|
;(async () => {
|
|
const [
|
|
{ data: settingsData, error: settingsError },
|
|
{ data: sequencesData, error: sequencesError },
|
|
] = await Promise.all([
|
|
supabase
|
|
.from('company_settings')
|
|
.select('default_voucher_series')
|
|
.eq('company_id', company.id)
|
|
.maybeSingle(),
|
|
supabase
|
|
.from('voucher_sequences')
|
|
.select('voucher_series')
|
|
.eq('company_id', company.id),
|
|
])
|
|
|
|
if (cancelled) return
|
|
|
|
if (settingsError) {
|
|
console.error('Failed to load company settings for voucher series', settingsError)
|
|
}
|
|
if (sequencesError) {
|
|
console.error('Failed to load voucher sequences', sequencesError)
|
|
}
|
|
|
|
const companyDefault = settingsData?.default_voucher_series || null
|
|
const sequences = new Set<string>((sequencesData || []).map((row) => row.voucher_series))
|
|
|
|
setDefaultSeries(companyDefault)
|
|
setExistingSeries(sequences)
|
|
|
|
const initial = companyDefault || (sequences.has('B') ? 'B' : Array.from(sequences).sort()[0]) || 'A'
|
|
setOptions((prev) => ({ ...prev, voucherSeries: initial }))
|
|
setSeriesLoaded(true)
|
|
})()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [company?.id])
|
|
|
|
// Block browser close/refresh during import
|
|
useUnsavedChanges(isLoading)
|
|
|
|
// Elapsed time counter during import
|
|
useEffect(() => {
|
|
if (isLoading) {
|
|
setElapsed(0)
|
|
intervalRef.current = setInterval(() => setElapsed((s) => s + 1), 1000)
|
|
} else {
|
|
if (intervalRef.current) clearInterval(intervalRef.current)
|
|
setElapsed(0)
|
|
}
|
|
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
|
|
}, [isLoading])
|
|
|
|
const handleExecute = () => {
|
|
onExecute(options)
|
|
}
|
|
|
|
const updateOption = <K extends keyof ImportExecuteOptions>(
|
|
key: K,
|
|
value: ImportExecuteOptions[K]
|
|
) => {
|
|
setOptions((prev) => ({ ...prev, [key]: value }))
|
|
}
|
|
|
|
// Calculate what will be imported
|
|
const mappedCount = mappings.filter((m) => m.targetAccount).length
|
|
const hasOpeningBalances = preview.openingBalanceTotal > 0
|
|
const hasTransactions = preview.voucherCount > 0
|
|
// Identity-mapped accounts whose #KONTO name differs from the BAS default —
|
|
// mirrors the filter in syncMappedAccounts, so the count matches what the
|
|
// import would actually rename/create with a custom name.
|
|
const customNameCount = mappings.filter(
|
|
(m) =>
|
|
m.targetAccount &&
|
|
m.sourceAccount === m.targetAccount &&
|
|
m.sourceName?.trim() &&
|
|
m.sourceName.trim() !== m.targetName?.trim()
|
|
).length
|
|
|
|
// Full-screen loading takeover during import execution
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardContent className="pt-8 pb-8">
|
|
<div className="flex flex-col items-center text-center space-y-6">
|
|
<Loader2 className="h-10 w-10 text-primary animate-spin" />
|
|
<div className="space-y-1">
|
|
<p className="font-medium text-lg">Importerar bokföring...</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{preview.voucherCount} verifikationer bearbetas
|
|
</p>
|
|
</div>
|
|
<div className="text-2xl font-display font-medium tabular-nums text-muted-foreground">
|
|
{elapsed}s
|
|
</div>
|
|
<p className="text-sm text-muted-foreground max-w-sm">
|
|
Stäng inte sidan. Importen kan ta upp till några minuter beroende på antalet verifikationer.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Summary */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<CheckCircle className="h-5 w-5 text-success" />
|
|
Redo att importera
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Granska inställningarna nedan och klicka på "Starta import" för att genomföra importen.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
|
|
<FileText className="h-8 w-8 text-muted-foreground" />
|
|
<div>
|
|
<p className="font-medium">{preview.companyName || 'Okänt företag'}</p>
|
|
<p className="text-sm text-muted-foreground">{preview.orgNumber || 'Inget orgnr'}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
|
|
<Calendar className="h-8 w-8 text-muted-foreground" />
|
|
<div>
|
|
<p className="font-medium">Räkenskapsår</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{preview.fiscalYearStart
|
|
? new Date(preview.fiscalYearStart).toLocaleDateString('sv-SE')
|
|
: '?'}{' '}
|
|
-{' '}
|
|
{preview.fiscalYearEnd
|
|
? new Date(preview.fiscalYearEnd).toLocaleDateString('sv-SE')
|
|
: '?'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
|
|
<Database className="h-8 w-8 text-muted-foreground" />
|
|
<div>
|
|
<p className="font-medium">{mappedCount} konton mappade</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{preview.voucherCount} verifikationer
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Import options */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Importinställningar</CardTitle>
|
|
<CardDescription>Välj vad som ska importeras</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Fiscal period */}
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="create-fiscal-period" className="font-medium">
|
|
Skapa räkenskapsår
|
|
</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Skapar automatiskt räkenskapsåret om det inte redan finns
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="create-fiscal-period"
|
|
checked={options.createFiscalPeriod}
|
|
onCheckedChange={(checked) => updateOption('createFiscalPeriod', checked)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Opening balances */}
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="import-opening-balances" className="font-medium">
|
|
Importera ingående balanser
|
|
</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
{hasOpeningBalances
|
|
? `Skapar verifikation för IB på ${formatCurrency(preview.openingBalanceTotal)}`
|
|
: 'Inga ingående balanser i filen'}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="import-opening-balances"
|
|
checked={options.importOpeningBalances}
|
|
onCheckedChange={(checked) => updateOption('importOpeningBalances', checked)}
|
|
disabled={!hasOpeningBalances}
|
|
/>
|
|
</div>
|
|
|
|
{/* Transactions */}
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="import-transactions" className="font-medium">
|
|
Importera verifikationer
|
|
</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
{hasTransactions
|
|
? `Importerar ${preview.voucherCount} verifikationer med ${preview.transactionLineCount} rader`
|
|
: 'Inga verifikationer i filen (SIE1-format?)'}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="import-transactions"
|
|
checked={options.importTransactions}
|
|
onCheckedChange={(checked) => updateOption('importTransactions', checked)}
|
|
disabled={!hasTransactions}
|
|
/>
|
|
</div>
|
|
|
|
{/* Account names from file */}
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="update-account-names" className="font-medium">
|
|
Använd kontonamn från filen
|
|
</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
{customNameCount > 0
|
|
? `${customNameCount} ${customNameCount === 1 ? 'konto' : 'konton'} har egna namn i filen som skiljer sig från BAS-standard`
|
|
: 'Kontonamnen i filen följer BAS-standard'}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="update-account-names"
|
|
checked={options.updateAccountNames}
|
|
onCheckedChange={(checked) => updateOption('updateAccountNames', checked)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Voucher series */}
|
|
{options.importTransactions && hasTransactions && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="voucher-series" className="font-medium">
|
|
Verifikationsserie för importerade transaktioner
|
|
</Label>
|
|
<Select
|
|
value={options.voucherSeries}
|
|
onValueChange={(value) => updateOption('voucherSeries', value)}
|
|
disabled={!seriesLoaded}
|
|
>
|
|
<SelectTrigger id="voucher-series" className="w-48">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SERIES_LETTERS.map((letter) => {
|
|
const isDefault = defaultSeries === letter
|
|
const isExisting = existingSeries.has(letter)
|
|
const suffix = isDefault
|
|
? ' — standard'
|
|
: isExisting
|
|
? ' — används redan'
|
|
: ''
|
|
return (
|
|
<SelectItem key={letter} value={letter}>
|
|
{`Serie ${letter}${suffix}`}
|
|
</SelectItem>
|
|
)
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-sm text-muted-foreground">
|
|
Använd en separat serie för att enkelt kunna skilja importerade från manuella verifikationer
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Warnings */}
|
|
{!preview.trialBalance.isBalanced && (
|
|
<Card className="border-warning/50 bg-warning/5">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-warning">
|
|
<AlertCircle className="h-5 w-5" />
|
|
Observera
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm text-muted-foreground">
|
|
De ingående balanserna i filen balanserar inte helt. En justeringspost kommer
|
|
att skapas automatiskt mot konto 2099 (Årets resultat).
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* What happens next */}
|
|
<Card className="bg-muted/50">
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Vad händer när du importerar?</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="text-sm text-muted-foreground space-y-2">
|
|
<p>1. Räkenskapsåret skapas om det inte finns</p>
|
|
<p>2. En verifikation för ingående balanser skapas</p>
|
|
<p>3. Alla verifikationer importeras med nya verifikationsnummer</p>
|
|
<p>4. Kontomappningarna sparas för framtida importer</p>
|
|
<p className="pt-2 font-medium">
|
|
Importen kan inte ångras automatiskt, men du kan ta bort skapade verifikationer manuellt.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
|
|
<Button variant="outline" className="min-h-11" onClick={onBack}>
|
|
Tillbaka
|
|
</Button>
|
|
<Button
|
|
className="min-h-11"
|
|
onClick={handleExecute}
|
|
disabled={!canWrite || isLoading}
|
|
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
|
>
|
|
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
|
|
Starta import
|
|
{canWrite && <ArrowRight className="ml-2 h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|