c62321988b
* 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>
503 lines
21 KiB
TypeScript
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 & 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 på 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 på 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>
|
|
)
|
|
}
|