Files
accounted/components/import/ImportResultStep.tsx
T
Jakob Wennberg c62321988b feat(reconciliation,bokslut): underlag on a balansdag + persisted closing checklist (Reko bilagor, PR 2 + PR 3) (#1873)
* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2)

A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an
account was reconciled against to (account_key, through_date), before or
after the sign-off, from every account body on the Avstämning page. Rows
live in account_reconciliation_attachments (append-only, removal stamp by
trigger, RLS like account_reconciliations), bytes in the documents bucket
under the company prefix so its RLS applies unchanged, and the full
archive copies them into bilagor/ with a hash manifest.

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

* fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them

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

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867)

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3)

The bokslut checklist is a catalogue in code with one state row per period
(bokslut_checklist_items): the steps the system can judge (sign-offs through
balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are
computed live and a stored row only overrides them; the manual steps are
the konsult's ticks, with who and when. It sits on the wizard's Kontroll
step and is dumped into the full archive.

A hole between fiscal years (one-file SIE migrations) is now named on the
bokslut readiness screen and on the import result screen, where the next
file is one click away. Non-adjacent period links are #1849's fix.

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

* fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:34:05 +02:00

503 lines
21 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-3">
<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">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>
)
}