'use client' import { useState, useEffect, useRef } from 'react' import { useCompanySettings } from '@/lib/reference-data/hooks' import { useTranslations } from 'next-intl' 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, formatDate } 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 { AttnLine } from '@/components/ui/attn-line' import ImportTheater from '@/components/import/ImportTheater' import { defaultImportOpeningBalancesOn, defaultOpeningBalanceSeries, } from '@/lib/import/opening-balance-defaults' import type { ImportPreview, AccountMapping } from '@/lib/import/types' import type { TheaterModel } from '@/lib/import/theater-model' const SERIES_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') interface ImportReviewStepProps { preview: ImportPreview mappings: AccountMapping[] onExecute: (options: ImportExecuteOptions) => Promise onBack: () => void isLoading: boolean /** Client-parsed graph model for the import theater; null falls back to * the plain spinner takeover (parse failed, oversized file, or pending). */ theaterModel?: TheaterModel | null } export interface ImportExecuteOptions { createFiscalPeriod: boolean importOpeningBalances: boolean importTransactions: boolean updateAccountNames: boolean voucherSeries: string /** Series for the Ingående balanser voucher. Defaults to one the file's * own vouchers do not use, so their numbering is never shifted (#1882). */ openingBalanceSeries: string markImportedNoDocRequired: boolean } export default function ImportReviewStep({ preview, mappings, onExecute, onBack, isLoading, theaterModel = null, }: ImportReviewStepProps) { const { canWrite } = useCanWrite() const { company } = useCompany() const { settings: companySettings } = useCompanySettings() const companyDefaultVoucherSeries = companySettings?.default_voucher_series || null const t = useTranslations('import') const [options, setOptions] = useState({ createFiscalPeriod: true, importOpeningBalances: true, importTransactions: true, updateAccountNames: true, voucherSeries: 'B', openingBalanceSeries: defaultOpeningBalanceSeries(preview.voucherSeriesInFile ?? []), markImportedNoDocRequired: false, }) const [defaultSeries, setDefaultSeries] = useState(null) const [existingSeries, setExistingSeries] = useState>(new Set()) const [seriesLoaded, setSeriesLoaded] = useState(false) // Posted opening-balance vouchers already booked in the file's fiscal year. // Non-zero means a re-import: the IB toggle then defaults OFF (issue #1882; // a field report accumulated five IB vouchers from repeated test imports). const [existingIbCount, setExistingIbCount] = useState(0) const [elapsed, setElapsed] = useState(0) const intervalRef = useRef | null>(null) useEffect(() => { if (!company?.id) return setSeriesLoaded(false) const supabase = createClient() let cancelled = false ;(async () => { // Smart IB-toggle default (issue #1882): a posted opening-balance // voucher already booked inside the file's fiscal year means this is // a re-import, and importing IB again would create a duplicate // "Ingående balanser" verifikat. const ibCountQuery = preview.fiscalYearStart && preview.fiscalYearEnd ? supabase .from('journal_entries') .select('id', { count: 'exact', head: true }) .eq('company_id', company.id) .eq('source_type', 'opening_balance') .eq('status', 'posted') .gte('entry_date', preview.fiscalYearStart) .lte('entry_date', preview.fiscalYearEnd) : Promise.resolve({ count: 0, error: null }) const [ { data: sequencesData, error: sequencesError }, { count: ibCount, error: ibCountError }, ] = await Promise.all([ supabase .from('voucher_sequences') .select('voucher_series') .eq('company_id', company.id), ibCountQuery, ]) if (cancelled) return if (sequencesError) { console.error('Failed to load voucher sequences', sequencesError) } if (ibCountError) { console.error('Failed to check for existing opening-balance vouchers', ibCountError) } // From the session-cached settings row (lib/reference-data). const companyDefault = companyDefaultVoucherSeries const sequences = new Set((sequencesData || []).map((row) => row.voucher_series)) const existingIb = ibCountError ? 0 : (ibCount ?? 0) setDefaultSeries(companyDefault) setExistingSeries(sequences) setExistingIbCount(existingIb) const initial = companyDefault || (sequences.has('B') ? 'B' : Array.from(sequences).sort()[0]) || 'A' setOptions((prev) => ({ ...prev, voucherSeries: initial, // Recompute with the effective transaction series excluded: file // vouchers WITHOUT a series land in that series at import time, so // the IB default must avoid it too (issue #1882). Safe to overwrite: // the select is disabled until seriesLoaded, so no user choice can // be clobbered here. openingBalanceSeries: defaultOpeningBalanceSeries([ ...(preview.voucherSeriesInFile ?? []), initial, ]), importOpeningBalances: defaultImportOpeningBalancesOn({ hasOpeningBalances: preview.openingBalanceTotal > 0, existingIbEntryCount: existingIb, }), })) setSeriesLoaded(true) })() return () => { cancelled = true } }, [company?.id, companyDefaultVoucherSeries, preview.fiscalYearStart, preview.fiscalYearEnd, preview.openingBalanceTotal]) // 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 = ( key: K, value: ImportExecuteOptions[K] ) => { setOptions((prev) => ({ ...prev, [key]: value })) } // Series used by the file's own #VER records, uppercased for comparison. // Booking the IB voucher in one of these consumes that series' next // number and shifts the file's numbering by one (issue #1882). const seriesInFile = new Set( (preview.voucherSeriesInFile ?? []).map((s) => s.trim().toUpperCase()) ) // Calculate what will be imported const mappedCount = mappings.filter((m) => m.targetAccount).length const hasOpeningBalances = preview.openingBalanceTotal > 0 const hasTransactions = preview.voucherCount > 0 // An import whose fiscal year already ended in a prior calendar year is a // historical/migration import: the underlag live in the old system, so the // exemption is especially apt. Nudges (does not force) the toggle. const isHistoricalImport = (() => { if (!preview.fiscalYearEnd) return false const end = new Date(preview.fiscalYearEnd) const startOfThisYear = new Date(new Date().getFullYear(), 0, 1) return !isNaN(end.getTime()) && end < startOfThisYear })() // 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. With a client-parsed // model the theater plays (the graph draws itself while the server writes); // without one, the plain spinner takeover remains the fallback. if (isLoading) { return (
{theaterModel ? (

Stäng inte sidan. Importen kan ta upp till några minuter beroende på antalet verifikationer.

) : (

Importerar bokföring...

{preview.voucherCount} verifikationer bearbetas

{elapsed}s

Stäng inte sidan. Importen kan ta upp till några minuter beroende på antalet verifikationer.

)}
) } return (
{/* Summary */} Redo att importera Granska inställningarna nedan och klicka på "Starta import" för att genomföra importen.

{preview.companyName || 'Okänt företag'}

{preview.orgNumber || 'Inget orgnr'}

Räkenskapsår

{preview.fiscalYearStart ? formatDate(preview.fiscalYearStart) : '?'}{' '} -{' '} {preview.fiscalYearEnd ? formatDate(preview.fiscalYearEnd) : '?'}

{mappedCount} konton mappade

{preview.voucherCount} verifikationer

{/* Import options */} Importinställningar Välj vad som ska importeras {/* Fiscal period */}

Skapar automatiskt räkenskapsåret om det inte redan finns

updateOption('createFiscalPeriod', checked)} />
{/* Opening balances */}

{hasOpeningBalances ? `Skapar verifikation för IB på ${formatCurrency(preview.openingBalanceTotal)}` : 'Inga ingående balanser i filen'}

{existingIbCount > 0 && (

{t('ib_exists_hint')}

)}
updateOption('importOpeningBalances', checked)} disabled={!hasOpeningBalances} />
{/* Voucher series for the opening-balance voucher (issue #1882) */} {options.importOpeningBalances && hasOpeningBalances && (
{seriesInFile.has(options.openingBalanceSeries.toUpperCase()) ? ( {t('ib_series_collision')} ) : (

{t('ib_series_hint')}

)}
)} {/* Transactions */}

{hasTransactions ? `Importerar ${preview.voucherCount} verifikationer med ${preview.transactionLineCount} rader` : 'Inga verifikationer i filen (SIE1-format?)'}

updateOption('importTransactions', checked)} disabled={!hasTransactions} />
{/* Account names from file */}

{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'}

updateOption('updateAccountNames', checked)} />
{/* Voucher series */} {options.importTransactions && hasTransactions && (

Använd en separat serie för att enkelt kunna skilja importerade från manuella verifikationer

)} {/* No-underlag exemption: keeps a multi-year migration from flooding "Att hantera: saknade underlag" with thousands of items. */}

Märker alla importerade verifikationer som att de inte behöver något separat underlag: underlagen finns kvar i ditt tidigare system. Annars hamnar de under "Att hantera: saknade underlag". Kan ändras per verifikation efteråt.

updateOption('markImportedNoDocRequired', checked)} disabled={!options.importTransactions || !hasTransactions} />
{/* Warnings */} {!preview.trialBalance.isBalanced && ( Observera

De ingående balanserna i filen balanserar inte helt. En justeringspost kommer att skapas automatiskt mot konto 2099 (Årets resultat).

)} {/* What happens next */} Vad händer när du importerar?

1. Räkenskapsåret skapas om det inte finns

2. En verifikation för ingående balanser skapas

3. Alla verifikationer importeras med nya verifikationsnummer

4. Kontomappningarna sparas för framtida importer

Importen kan inte ångras automatiskt, men du kan ta bort skapade verifikationer manuellt.

{/* Actions */}
) }