Files
Mattsson f33628f005 feat(import): say in the SIE wizard that the chart and fiscal year come along (#2307)
* feat(import): say in the SIE wizard that the chart and fiscal year come along

The preview scored the file's accounts against the BAS reference and said
"matchas mot din kontoplan", so a consultant with a 41-account seeded company
read "150 mappade" as "the file's chart replaces mine". A fiscal-year overlap
with a non-empty period was only refused after the mapping step.

- Parse route adds preview.chart (accounts new to THIS company vs already
  present, with a sample) via planChartChanges, and preview.fiscalYear from
  precheckFiscalPeriod: the containment/overlap verdict extracted out of
  ensureFiscalPeriod, which now consumes it, so preview and import cannot drift.
- Preview card renamed to Kontoplan with the counts and the fiscal-year
  verdict (match / create / conflict with the import's own refusal text).
- Review step lists the chart among "Vad händer när du importerar?".
- executeSIEImport reports accountsCreated from the account sync; the result
  grid gets a Konton skapade card.

No import logic changed; no migration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MgxEaU52nJgDQA41svdtC

* fix(import): preview refuses what the import refuses, and the chart card survives "Skapa saknade konton"

Skeptic pass on #2307 refuted the first cut twice:

- "Skapas vid import" was shown for a #RAR the import then refuses under
  BFL 3 kap. (19 months, non-month-end finish, mid-month start after an
  earlier year). The shape rules move into precheckFiscalPeriod as a fourth
  verdict 'invalid' with the same refusal text; ensureFiscalPeriod stays a
  consumer of one verdict, same query order.
- The Kontoplan card counted unmapped sources under "Läggs till" and kept
  listing them after the create button, while the result said 0 created.
  planChartChanges (now client-safe in lib/import/chart-plan.ts) counts
  mapped targets only; the create button moves those accounts from
  "Ej mappade" to "Finns redan" in place.
- The review line claimed existing accounts keep their name unless you opt
  in; the switch defaults to on. Reworded to match.
- Sample names follow the file for identity mappings, as the sync does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MgxEaU52nJgDQA41svdtC

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 13:56:54 +02:00

512 lines
22 KiB
TypeScript

'use client'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
CheckCircle,
XCircle,
AlertCircle,
FileText,
ExternalLink,
RotateCcw,
Info,
Undo2,
} from 'lucide-react'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { formatCurrency } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import TheaterCanvas from '@/components/import/TheaterCanvas'
import { FiscalYearGapNotice } from '@/components/import/FiscalYearGapNotice'
import type { ImportPreview, ImportResult } from '@/lib/import/types'
import type { TheaterModel } from '@/lib/import/theater-model'
interface ImportResultStepProps {
result: ImportResult
onNewImport: () => void
onUndo?: (importId: string) => Promise<void> | void
/** When the theater ran, the reveal replaces the plain success header:
* the settled constellation beside the personalized story + the bank
* bridge. Absent (failure, oversized file, parse miss) = plain header. */
preview?: ImportPreview | null
theaterModel?: TheaterModel | null
unresolvedVatAccountCount?: number
}
export default function ImportResultStep({
result,
onNewImport,
onUndo,
preview = null,
theaterModel = null,
unresolvedVatAccountCount = 0,
}: ImportResultStepProps) {
const t = useTranslations('import')
const { dialogProps, confirm } = useDestructiveConfirm()
const { isSandbox } = useCompany()
const hasBanking = ENABLED_EXTENSION_IDS.has('enable-banking')
// The reveal only tells a story that is true: it needs the theater model,
// the preview, and actual imported entries (an opening-balances-only run
// must not claim "history in place" over an untouched constellation).
const showReveal =
result.success && !!theaterModel && !!preview && result.journalEntriesCreated > 0
const handleUndoClick = async () => {
if (!result.importId || !onUndo) return
const ok = await confirm({
title: 'Ångra hela importen?',
description: `Detta raderar ${result.journalEntriesCreated} verifikation${result.journalEntriesCreated === 1 ? '' : 'er'} och rensar ingående balanser från den här importen. Bifogade dokument blir okopplade men finns kvar.`,
confirmLabel: 'Ångra import',
})
if (!ok) return
await onUndo(result.importId)
}
const hasErrors = result.errors.length > 0
const skipped = result.details?.skippedVouchers
const untransferred = result.details?.untransferredResults
// Filter out raw warnings when we have structured data for them
let otherWarnings = skipped && skipped.total > 0
? result.warnings.filter((w) => !w.includes('hoppades över'))
: result.warnings
if (untransferred && untransferred.length > 0) {
otherWarnings = otherWarnings.filter((w) => !w.includes('förts om till eget kapital'))
}
return (
<div className="space-y-6">
{/* "Din historia" reveal (theater ran) or the plain success/failure header */}
{showReveal && theaterModel && preview ? (
<Card>
<CardContent className="pt-6">
<div className="grid items-center gap-6 md:grid-cols-[minmax(280px,380px)_1fr]">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('reveal_eyebrow')}
</p>
<h2 className="mt-2 font-display text-2xl leading-8 tracking-tight text-balance">
{t('reveal_title', { years: Math.max(theaterModel.years.length, 1) })}
</h2>
<p className="mt-3 text-[13px] text-muted-foreground tabular-nums">
{t('reveal_stats', {
vouchers: result.journalEntriesCreated,
accounts: preview.accountCount,
counterparties: theaterModel.totalCounterparties,
})}
</p>
{/* The balance claim comes from the pre-import file check; it
stays honest only when no unbalanced voucher was skipped. */}
{preview.trialBalance.isBalanced && !(skipped && skipped.unbalanced > 0) && (
<p className="mt-1 text-[13px] tabular-nums text-success">{t('reveal_tie_ok')}</p>
)}
{skipped && skipped.total > 0 && (
<p className="mt-1 text-[13px] text-warning">
{t('reveal_skipped', { count: skipped.total })}
</p>
)}
<div className="mt-6 border-t border-border pt-4">
{/* Sandbox strips live bank connections from /import, so the
bridge quiets down to the plain way onward there. */}
{!isSandbox && <p className="font-display text-base">{t('reveal_bridge')}</p>}
<div className="mt-3 flex flex-wrap items-center gap-3">
{/* Both migrator paths: PSD2 covers recent history (banks
cap the window around 90 days), CSV upload reaches the
older period the SIE file covers. Either way the
overlap is matched against the imported verifikat.
Sandbox hides only the live bank connection; file-based
import works there and its CTA stays. */}
{!isSandbox && hasBanking && (
<Button asChild size="sm">
<Link href="/import?mode=psd2">{t('reveal_cta_bank')}</Link>
</Button>
)}
<Button
asChild
size="sm"
variant={!isSandbox && hasBanking ? 'outline' : 'default'}
>
<Link href="/import?mode=bank">{t('reveal_cta_csv')}</Link>
</Button>
<Link
href="/"
className="text-xs text-muted-foreground underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
>
{t('reveal_cta_open')}
</Link>
</div>
</div>
</div>
<div className="relative hidden min-h-[360px] md:block">
<TheaterCanvas model={theaterModel} settled />
</div>
</div>
</CardContent>
</Card>
) : (
<Card className={result.success ? 'border-border' : 'border-destructive/50'}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{result.success ? (
<>
<CheckCircle className="h-6 w-6 text-success" />
Import genomförd
</>
) : (
<>
<XCircle className="h-6 w-6 text-destructive" />
Import misslyckades
</>
)}
</CardTitle>
<CardDescription>
{result.success
? skipped && skipped.total > 0
? `Din bokföring har importerats. ${result.journalEntriesCreated} verifikationer skapades, ${skipped.total} hoppades över: se detaljer nedan.`
: 'Din bokföring har importerats framgångsrikt.'
: 'Det uppstod fel under importen. Läs felmeddelanden nedan för att förstå vad som gick snett och hur du kan åtgärda det.'}
</CardDescription>
</CardHeader>
</Card>
)}
{/* A year missing between the imported ones: say so here, where the next file is one click away. */}
{result.success && <FiscalYearGapNotice />}
{/* IB resync notice (prior-year backfill) */}
{result.success && result.nextPeriodIBResync && (
<Card className="border-success/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CheckCircle className="h-5 w-5 text-success" />
Ingående balanser synkades om
</CardTitle>
<CardDescription>
Eftersom du importerade ett tidigare räkenskapsår uppdaterades ingående balanser för{' '}
<span className="font-medium">{result.nextPeriodIBResync.nextPeriodName}</span>{' '}
automatiskt (gammal IB makulerad, ny IB skapad från utgående balans).
</CardDescription>
</CardHeader>
</Card>
)}
{result.success && result.nextPeriodIBResyncSkipped && (
<Card className="border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base text-warning">
<AlertCircle className="h-5 w-5" />
Ingående balanser för {result.nextPeriodIBResyncSkipped.nextPeriodName} kunde inte synkas
</CardTitle>
<CardDescription>
Nästa räkenskapsår är låst eller stängt. Lås upp perioden och kör importen igen om du
vill att ingående balanser ska uppdateras automatiskt.
</CardDescription>
</CardHeader>
</Card>
)}
{result.success && unresolvedVatAccountCount > 0 && (
<Card className="border-warning/40">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertCircle className="h-5 w-5 text-warning" />
{t('vat_review_title', { count: unresolvedVatAccountCount })}
</CardTitle>
<CardDescription>{t('vat_review_description')}</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link href="/chart-of-accounts">{t('vat_review_action')}</Link>
</Button>
</CardContent>
</Card>
)}
{/* Dimensions detected (lossless SIE round-trip, dimensions plan PR5) */}
{result.success && result.dimensionsImported && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Info className="h-5 w-5 text-muted-foreground" />
Dimensioner följde med importen
</CardTitle>
<CardDescription>
Filen innehöll kostnadsställen/projekt: {result.dimensionsImported.taggedLines}{' '}
taggade rader importerades
{result.dimensionsImported.values > 0 && (
<> och {result.dimensionsImported.values} nya värden lades till i registret</>
)}
.{' '}
{result.dimensionsImported.toggleEnabled && (
<>Dimensioner aktiverades automatiskt för företaget: du hittar registret under{' '}
<Link href="/dimensions" className="underline underline-offset-4">
Kostnadsställen &amp; projekt
</Link>
.</>
)}
</CardDescription>
</CardHeader>
</Card>
)}
{/* Statistics */}
{result.success && !showReveal && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">Verifikationer skapade</span>
</div>
<p className="text-2xl font-display tabular-nums">{result.journalEntriesCreated}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-sm">{t('result_accounts_created')}</span>
</div>
<p className="text-2xl font-display tabular-nums">{result.accountsCreated ?? 0}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-sm">Räkenskapsår</span>
</div>
<div className="text-2xl font-display">
{result.fiscalPeriodId ? (
<Badge variant="success">Skapat</Badge>
) : (
<Badge variant="secondary">Befintligt</Badge>
)}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-sm">Ingående balanser</span>
</div>
<div className="text-2xl font-display">
{result.openingBalanceEntryId ? (
<Badge variant="success">Importerade</Badge>
) : (
<Badge variant="secondary">Inga</Badge>
)}
</div>
</CardContent>
</Card>
</div>
)}
{/* Errors */}
{hasErrors && (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<XCircle className="h-5 w-5" />
Fel ({result.errors.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2 max-h-48 overflow-y-auto">
{result.errors.map((error, i) => (
<div key={i} className="text-sm flex gap-2">
<XCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
<span>{error}</span>
</div>
))}
</div>
{!result.success && (
<div className="text-sm text-muted-foreground border-t pt-3 space-y-1">
<p className="font-medium">Vad kan du göra?</p>
<ul className="list-disc list-inside space-y-0.5 text-muted-foreground">
<li>Kontrollera att SIE-filen exporterades korrekt från källsystemet</li>
<li>Prova att exportera filen igen och ladda upp nytt</li>
<li>Om felet kvarstår, kontakta support med felmeddelandet ovan</li>
</ul>
</div>
)}
</CardContent>
</Card>
)}
{/* Skipped vouchers: structured breakdown */}
{skipped && skipped.total > 0 && (
<Card className="border-muted-foreground/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-muted-foreground">
<Info className="h-5 w-5" />
Hoppade över {skipped.total} verifikationer
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{skipped.empty > 0 && (
<div className="text-sm">
<p className="font-medium">{skipped.empty} tomma verifikationer</p>
<p className="text-muted-foreground">
Platshållare utan bokföringsrader: vanligt i Fortnox och Visma. Påverkar inte din bokföring.
</p>
</div>
)}
{skipped.unbalanced > 0 && (
<div className="text-sm">
<p className="font-medium">{skipped.unbalanced} obalanserade verifikationer</p>
<p className="text-muted-foreground">
Debet och kredit stämmer inte överens i källsystemet. Saldon har justerats automatiskt.
</p>
</div>
)}
{skipped.singleLine > 0 && (
<div className="text-sm">
<p className="font-medium">{skipped.singleLine} enradsverifikationer</p>
<p className="text-muted-foreground">
Verifikationer med bara en rad (t.ex. periodiseringar). Kräver minst två rader för dubbelbokning.
</p>
</div>
)}
{skipped.unmapped > 0 && (
<div className="text-sm">
<p className="font-medium">{skipped.unmapped} verifikationer med ej kopplade konton</p>
<p className="text-muted-foreground">
Innehåller konton som inte kunde kopplas till din kontoplan.
</p>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Untransferred prior-year results — the year-end omföring is missing */}
{untransferred && untransferred.length > 0 && (
<Card className="border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-warning">
<AlertCircle className="h-5 w-5" />
Årets resultat är inte omfört
</CardTitle>
<CardDescription>
Följande räkenskapsår saknar omföring av årets resultat till eget kapital.
Senare års balansräkning visar en differens beloppet tills omföringen
bokförs (konto 8999 mot eget kapital, t.ex. 2099) i respektive år.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{untransferred.map((u) => (
<div key={u.fiscal_period_id} className="text-sm flex justify-between gap-4">
<span className="font-medium">{u.period_name}</span>
<span className="tabular-nums">
{formatCurrency(u.pl_net, 'SEK', { minimumFractionDigits: 2 })}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Other warnings (filtered) */}
{otherWarnings.length > 0 && (
<Card className="border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-warning">
<AlertCircle className="h-5 w-5" />
Varningar ({otherWarnings.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-48 overflow-y-auto">
{otherWarnings.map((warning, i) => (
<div key={i} className="text-sm flex gap-2">
<AlertCircle className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
<span>{warning}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Next steps: the migrator bridge. Not instructions to read, an action
to take: fetch the bank history so it can be matched against the
verifikat that were just imported, instead of landing as anonymous
"Att bokföra" rows. */}
{result.success && !showReveal && (
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-base">{t('next_steps_title')}</CardTitle>
<CardDescription>{t('next_steps_match_copy')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
{/* Sandbox hides only the live bank connection; file-based
import works there and its CTA stays. */}
{!isSandbox && hasBanking && (
<Button asChild size="sm">
<Link href="/import?mode=psd2">{t('next_steps_cta_bank')}</Link>
</Button>
)}
<Button
asChild
size="sm"
variant={!isSandbox && hasBanking ? 'outline' : 'default'}
>
<Link href="/import?mode=bank">{t('next_steps_cta_csv')}</Link>
</Button>
</div>
<p className="text-sm text-muted-foreground">{t('next_steps_window_hint')}</p>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="outline" className="min-h-11" onClick={onNewImport}>
<RotateCcw className="mr-2 h-4 w-4" />
Ny import
</Button>
{result.success && result.importId && onUndo && (
<Button variant="outline" className="min-h-11 text-destructive hover:text-destructive" onClick={handleUndoClick}>
<Undo2 className="mr-2 h-4 w-4" />
Ångra import
</Button>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{result.success && (
<>
<Button variant="outline" className="min-h-11" asChild>
<Link href="/bookkeeping">
Visa bokföring
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button className="min-h-11" asChild>
<Link href="/reports">
Visa rapporter
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
</>
)}
</div>
</div>
<DestructiveConfirmDialog {...dialogProps} />
</div>
)
}