feat(import): the import theater during SIE execute (#1471)

* feat(onboarding): branch question on the journey done screen

Second slice of the approved activation concept: the moment the company
exists, the done screen asks "Var fanns bokföringen innan?" with
provider chips (real logos), SIE file, and new-business options, plus a
quiet look-around escape. Choices persist initial_setup_path
(fire-and-forget) and deep-link into the existing flows: providers jump
straight to the migration wizard's connect step (sieViaApi providers
only; Visma/Bokio land on the provider list where the SIE-first gate
lives), the SIE chip opens the upload step, new business lands on Hem
with step one checked off.

mode='add' keeps the plain "Öppna Accounted" button: the concept's own
guard, and it avoids writing the path onto the previous company if
setActiveCompany silently failed. Routing lives in a pure helper with
tests; anonymous onboarding_branch_chosen funnel event follows the
guarded capture pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(onboarding): review triage: single-choice latch, preselect reset

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: restore package-lock.json to main (worktree npm install mutated it)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(import): the import theater: a knowledge graph draws itself during SIE execute

Third slice of the activation concept. While the SIE import commits
server-side (one opaque call, up to ~5 min), the client parses the same
file locally (the parser is browser-clean) and a canvas constellation
builds itself: company hub, fiscal years as tree rings, account-class
anchors, top accounts and recognized counterparties, with paced
narration lines alongside. The final line holds with the elapsed counter
until the server answers, so the theater never outruns the truth.

- lib/import/theater-model.ts: pure aggregation of ParsedSIEFile into a
  capped display model (14 accounts, 12 counterparties, >=2 sightings,
  internal accounting texts skipped, counterparty attached to its
  counter account rather than the bank leg). Tested with fixture-string
  SIE per the sie-parser test pattern.
- components/import/ImportTheater.tsx: ink-on-paper canvas + narration,
  tokens read per frame (theme/palette reactive, JourneyOrb idiom),
  reduced motion renders the settled graph and all lines instantly.
- Wizard: client parse kicks off at execute start via dynamic import,
  capped at 8 MB; any failure silently leaves the existing spinner
  takeover, which also remains for oversized files.

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>
This commit is contained in:
Jakob Wennberg
2026-08-09 19:58:33 +02:00
committed by GitHub
parent 622b144a3d
commit d4ef4f8bc4
7 changed files with 788 additions and 15 deletions
+26 -2
View File
@@ -61,6 +61,11 @@ import type {
ImportResult,
ParseIssue,
} from '@/lib/import/types'
import type { TheaterModel } from '@/lib/import/theater-model'
/** Above this size the client-side theater parse is skipped (main-thread
* parse of very large SIE files would jank the animation it exists for). */
const THEATER_MAX_FILE_BYTES = 8 * 1024 * 1024
import type { BASAccount } from '@/types'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import dynamic from 'next/dynamic'
@@ -441,6 +446,7 @@ function SIEImportWizard() {
const [preview, setPreview] = useState<ImportPreview | null>(null)
const [issues, setIssues] = useState<ParseIssue[]>([])
const [importResult, setImportResult] = useState<ImportResult | null>(null)
const [theaterModel, setTheaterModel] = useState<TheaterModel | null>(null)
const [, setSieAccounts] = useState<{ number: string; name: string }[]>([])
const [isCreatingAccounts, setIsCreatingAccounts] = useState(false)
@@ -708,6 +714,23 @@ function SIEImportWizard() {
setIsLoading(true)
setError(null)
// Import theater: parse the file client-side (the parser is browser-clean)
// so the graph can build itself while the server writes. Best-effort with
// a size cap: any failure just leaves the plain spinner takeover.
if (file.size <= THEATER_MAX_FILE_BYTES) {
void (async () => {
try {
const [{ parseSIEFile, detectEncoding, decodeBuffer }, { buildTheaterModel }] =
await Promise.all([import('@/lib/import/sie-parser'), import('@/lib/import/theater-model')])
const buffer = await file.arrayBuffer()
const parsed = parseSIEFile(decodeBuffer(buffer, detectEncoding(buffer)))
setTheaterModel(buildTheaterModel(parsed))
} catch {
// Theater is a nicety; the import itself is unaffected.
}
})()
}
try {
const formData = new FormData()
formData.append('file', file)
@@ -773,7 +796,7 @@ function SIEImportWizard() {
setStep('upload'); setFile(null); setParsed(null); setMappings([])
setPreview(null); setIssues([]); setImportResult(null); setError(null); setErrorType(undefined)
setValidationErrors([]); setValidationWarnings([]); setDuplicateImportId(null)
setSieAccounts([]); setIsCreatingAccounts(false)
setSieAccounts([]); setIsCreatingAccounts(false); setTheaterModel(null)
}
return (
@@ -811,7 +834,8 @@ function SIEImportWizard() {
)}
{step === 'review' && preview && (
<ImportReviewStep preview={preview} mappings={mappings}
onExecute={handleExecuteImport} onBack={goBack} isLoading={isLoading} />
onExecute={handleExecuteImport} onBack={goBack} isLoading={isLoading}
theaterModel={theaterModel} />
)}
{step === 'result' && importResult && <ImportResultStep result={importResult} onNewImport={handleNewImport} onUndo={handleUndo} />}
</div>
+30 -13
View File
@@ -27,7 +27,9 @@ 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 ImportTheater from '@/components/import/ImportTheater'
import type { ImportPreview, AccountMapping } from '@/lib/import/types'
import type { TheaterModel } from '@/lib/import/theater-model'
const SERIES_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
@@ -37,6 +39,9 @@ interface ImportReviewStepProps {
onExecute: (options: ImportExecuteOptions) => Promise<void>
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 {
@@ -54,6 +59,7 @@ export default function ImportReviewStep({
onExecute,
onBack,
isLoading,
theaterModel = null,
}: ImportReviewStepProps) {
const { canWrite } = useCanWrite()
const { company } = useCompany()
@@ -168,27 +174,38 @@ export default function ImportReviewStep({
m.sourceName.trim() !== m.targetName?.trim()
).length
// Full-screen loading takeover during import execution
// 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 (
<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
{theaterModel ? (
<div className="space-y-6">
<ImportTheater model={theaterModel} preview={preview} elapsed={elapsed} />
<p className="text-center text-sm text-muted-foreground">
Stäng inte sidan. Importen kan ta upp till några minuter beroende antalet verifikationer.
</p>
</div>
<div className="text-2xl font-display tabular-nums text-muted-foreground">
{elapsed}s
) : (
<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 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 antalet verifikationer.
</p>
</div>
<p className="text-sm text-muted-foreground max-w-sm">
Stäng inte sidan. Importen kan ta upp till några minuter beroende antalet verifikationer.
</p>
</div>
)}
</CardContent>
</Card>
</div>
+455
View File
@@ -0,0 +1,455 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import type { ImportPreview } from '@/lib/import/types'
import type { TheaterBucket, TheaterModel } from '@/lib/import/theater-model'
/**
* The import theater: while the SIE import commits server-side (one opaque
* call, up to ~5 minutes), the client-parsed file drives a knowledge graph
* that draws itself: the company as hub, fiscal years as tree rings, account
* classes as anchors, top accounts and recognized counterparties as an ink
* constellation. Narration lines pace alongside; the final line holds with
* the elapsed counter until the server answers, so the animation never
* pretends to know more than the import does.
*
* Ink-on-paper: colors come from the app tokens per frame (theme/palette
* reactive, same idiom as JourneyOrb). Reduced motion renders the settled
* graph and all narration instantly.
*/
interface ImportTheaterProps {
model: TheaterModel
preview: ImportPreview
/** Elapsed seconds since execute started (owned by ImportReviewStep). */
elapsed: number
}
const BUCKET_ANGLE: Record<TheaterBucket, number> = {
tillgangar: 0.31, // right, slightly down
skulder: 1.62, // bottom
intakter: -0.92, // upper right
kostnader: 2.75, // left
}
const BUCKET_LABEL: Record<TheaterBucket, string> = {
tillgangar: 'TILLGÅNGAR',
skulder: 'SKULDER',
intakter: 'INTÄKTER',
kostnader: 'KOSTNADER',
}
/** Deterministic [0,1) hash so the constellation is stable per file. */
function rand01(seed: string): number {
let h = 1779033703 ^ seed.length
for (let i = 0; i < seed.length; i++) {
h = Math.imul(h ^ seed.charCodeAt(i), 3432918353)
h = (h << 13) | (h >>> 19)
}
h = Math.imul(h ^ (h >>> 16), 2246822507)
return ((h ^ (h >>> 13)) >>> 0) / 4294967296
}
interface Node {
id: string
kind: 'hub' | 'ring' | 'bucket' | 'account' | 'counterparty'
x: number
y: number
r: number
ring?: number
label?: string
lab?: boolean
bucket?: TheaterBucket
wave: number
born: number | null
}
interface Engine {
nodes: Node[]
edges: { a: string; b: string }[]
stream: { x: number; y: number; target: Node }[]
feedUntil: number
pulse: number
cam: number
}
function buildEngine(model: TheaterModel, reduced: boolean): Engine {
const nodes: Node[] = []
const edges: { a: string; b: string }[] = []
nodes.push({ id: 'hub', kind: 'hub', x: 0, y: 0, r: 7, label: model.companyName, lab: true, wave: 0, born: null })
model.years.forEach((y, i) => {
nodes.push({ id: `ring${i}`, kind: 'ring', x: 0, y: 0, r: 0, ring: 58 + i * 24, label: y.start.slice(0, 4), wave: 0, born: null })
})
const present = new Set(model.buckets.map((b) => b.id))
for (const bucket of present) {
const p = BUCKET_ANGLE[bucket]
nodes.push({ id: bucket, kind: 'bucket', x: Math.cos(p) * 148, y: Math.sin(p) * 148, r: 2.2, label: BUCKET_LABEL[bucket], lab: true, wave: 0, born: null })
edges.push({ a: 'hub', b: bucket })
}
const maxAccountWeight = Math.max(1, ...model.accounts.map((a) => a.weight))
model.accounts.forEach((a, i) => {
const base = BUCKET_ANGLE[a.bucket]
const spread = (rand01(a.number) - 0.5) * 0.9
const rad = 192 + rand01(a.number + 'r') * 40
nodes.push({
id: a.number,
kind: 'account',
x: Math.cos(base + spread) * rad,
y: Math.sin(base + spread) * rad,
r: 2.4 + (a.weight / maxAccountWeight) * 3.4,
label: `${a.number} ${a.name}`.trim(),
lab: i < 8,
bucket: a.bucket,
wave: i % 4,
born: null,
})
edges.push({ a: a.bucket, b: a.number })
})
const maxCpWeight = Math.max(1, ...model.counterparties.map((c) => c.weight))
model.counterparties.forEach((c, i) => {
const anchor = nodes.find((n) => n.id === c.account)
const base = anchor ? Math.atan2(anchor.y, anchor.x) : BUCKET_ANGLE.kostnader
const spread = (rand01(c.name) - 0.5) * 0.5
const rad = 262 + rand01(c.name + 'r') * 38
nodes.push({
id: `cp${i}`,
kind: 'counterparty',
x: Math.cos(base + spread) * rad,
y: Math.sin(base + spread) * rad,
r: 1.8 + (c.weight / maxCpWeight) * 2.4,
label: c.name,
lab: i < 7,
wave: i % 3,
born: null,
})
if (anchor) edges.push({ a: c.account, b: `cp${i}` })
})
const now = typeof performance !== 'undefined' ? performance.now() : 0
if (reduced) for (const n of nodes) n.born = now - 10_000
return { nodes, edges, stream: [], feedUntil: 0, pulse: 0, cam: reduced ? 1 : 1.45 }
}
function tokenColors(canvas: HTMLCanvasElement) {
const root = getComputedStyle(document.documentElement)
const hsl = (name: string, fallback: string) => {
const v = root.getPropertyValue(name).trim()
return v ? `hsl(${v})` : fallback
}
return {
ink: getComputedStyle(canvas).color,
mut: hsl('--muted-foreground', '#8a8378'),
hair: hsl('--border', '#e5e2da'),
sage: hsl('--success', '#5d8a6f'),
paper: hsl('--background', '#ffffff'),
}
}
export default function ImportTheater({ model, preview, elapsed }: ImportTheaterProps) {
const t = useTranslations('import')
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const engineRef = useRef<Engine | null>(null)
const [lineCount, setLineCount] = useState(0)
const [voucherTick, setVoucherTick] = useState(0)
const reduced =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
const lines: { title: string; sub: string | null; ok?: boolean; warn?: boolean }[] = [
{
title: t('theater_reading', { company: model.companyName || preview.companyName || '' }),
sub: t('theater_years', { count: Math.max(model.years.length, 1) }),
ok: true,
},
{
title: t('theater_vouchers'),
sub: `${voucherTick.toLocaleString('sv-SE')} ${t('theater_vouchers_unit')}`,
},
{
title: t('theater_accounts'),
sub: t('theater_accounts_sub', {
mapped: preview.mappingStatus.mapped,
total: preview.mappingStatus.total,
}),
},
...(model.totalCounterparties > 0
? [{
title: t('theater_counterparties'),
sub: t('theater_counterparties_sub', { count: model.totalCounterparties }),
}]
: []),
{
title: t('theater_balance'),
sub: preview.trialBalance.isBalanced ? t('theater_balance_ok') : t('theater_balance_warn'),
ok: preview.trialBalance.isBalanced,
warn: !preview.trialBalance.isBalanced,
},
]
const holdingVisible = lineCount > lines.length
// Narration pacing + graph spawn hooks. Spawn assignments live here so the
// narration and the constellation always agree on what has "happened".
useEffect(() => {
const engine = buildEngine(model, reduced)
engineRef.current = engine
const spawn = (f: (n: Node) => boolean) => {
const now = performance.now()
for (const n of engine.nodes) if (n.born == null && f(n)) n.born = now
}
spawn((n) => n.kind === 'hub')
if (reduced) {
setLineCount(lines.length + 1)
setVoucherTick(preview.voucherCount)
return
}
const timers: number[] = []
const at = (ms: number, fn: () => void) => timers.push(window.setTimeout(fn, ms))
const counterLine = 1
const cpLine = model.totalCounterparties > 0 ? 3 : -1
const balanceLine = model.totalCounterparties > 0 ? 4 : 3
at(300, () => {
setLineCount(1)
spawn((n) => n.kind === 'ring')
})
at(1500, () => {
setLineCount(counterLine + 1)
spawn((n) => n.kind === 'bucket')
engine.feedUntil = performance.now() + 2100
;[0, 1, 2, 3].forEach((w) =>
timers.push(window.setTimeout(() => spawn((n) => n.kind === 'account' && n.wave === w), 200 + w * 420))
)
const t0 = performance.now()
const tick = () => {
const p = Math.min(1, (performance.now() - t0) / 1600)
setVoucherTick(Math.round(preview.voucherCount * p))
if (p < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
})
at(3400, () => setLineCount(3))
if (cpLine > 0) {
at(4600, () => {
setLineCount(cpLine + 1)
;[0, 1, 2].forEach((w) =>
timers.push(window.setTimeout(() => spawn((n) => n.kind === 'counterparty' && n.wave === w), 120 + w * 420))
)
})
}
at(cpLine > 0 ? 6000 : 4600, () => {
setLineCount(balanceLine + 1)
engine.pulse = performance.now()
})
at(cpLine > 0 ? 7100 : 5700, () => setLineCount(lines.length + 1))
return () => timers.forEach((id) => window.clearTimeout(id))
// The theater runs once per mount for one (model, preview) pair.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Canvas render loop (refs only; React never sees per-frame state).
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
let running = true
const draw = () => {
const engine = engineRef.current
if (!canvas.isConnected || !engine) return
const wrap = canvas.parentElement
if (!wrap) return
const W = wrap.clientWidth
const H = wrap.clientHeight
if (!W || !H) {
if (running && !reduced) requestAnimationFrame(draw)
return
}
const dpr = Math.min(window.devicePixelRatio || 1, 2)
if (canvas.width !== W * dpr || canvas.height !== H * dpr) {
canvas.width = W * dpr
canvas.height = H * dpr
}
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, W, H)
const colors = tokenColors(canvas)
const now = performance.now()
const born = engine.nodes.filter((n) => n.born != null).length
const camTarget = reduced ? 1 : 1.45 - 0.45 * Math.min(1, born / (engine.nodes.length * 0.85))
engine.cam += (camTarget - engine.cam) * (reduced ? 1 : 0.05)
const k = (Math.min(W, H) / 640) * engine.cam
const cx = W / 2
const cy = H / 2
const pos = (n: Node) => ({ x: cx + n.x * k, y: cy + n.y * k })
const age = (n: Node) =>
n.born == null ? 0 : Math.min(1, (now - n.born) / (reduced ? 1 : n.kind === 'ring' ? 900 : 420))
const ease = (v: number) => 1 - Math.pow(1 - v, 3)
const byId = new Map(engine.nodes.map((n) => [n.id, n]))
for (const n of engine.nodes) {
if (n.kind !== 'ring' || n.born == null) continue
const p = ease(age(n))
const rad = (n.ring ?? 0) * k
ctx.strokeStyle = colors.hair
ctx.globalAlpha = 0.75 * p
ctx.lineWidth = 0.8
ctx.beginPath()
ctx.arc(cx, cy, rad, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * p)
ctx.stroke()
ctx.globalAlpha = Math.max(0, p * 1.3 - 0.3)
const lx = cx + Math.cos(-1.15) * rad
const ly = cy + Math.sin(-1.15) * rad
ctx.fillStyle = colors.paper
ctx.fillRect(lx - 15, ly - 6, 30, 12)
ctx.fillStyle = colors.mut
ctx.font = '10px system-ui, sans-serif'
ctx.textAlign = 'center'
if (n.label) ctx.fillText(n.label, lx, ly + 3.5)
ctx.globalAlpha = 1
}
for (const e of engine.edges) {
const A = byId.get(e.a)
const B = byId.get(e.b)
if (!A || !B || A.born == null || B.born == null) continue
const p = ease(Math.min(age(A), age(B)))
const pa = pos(A)
const pb = pos(B)
ctx.strokeStyle = colors.hair
ctx.lineWidth = 0.8
ctx.globalAlpha = 0.65
ctx.beginPath()
ctx.moveTo(pa.x, pa.y)
ctx.lineTo(pa.x + (pb.x - pa.x) * p, pa.y + (pb.y - pa.y) * p)
ctx.stroke()
ctx.globalAlpha = 1
}
if (now < engine.feedUntil && !reduced && engine.stream.length < 60) {
const targets = engine.nodes.filter((n) => n.kind === 'account' && n.born != null)
if (targets.length) {
for (let i = 0; i < 2; i++) {
engine.stream.push({
x: 8,
y: cy + (Math.random() - 0.5) * 170,
target: targets[(Math.random() * targets.length) | 0],
})
}
}
}
if (engine.stream.length) {
ctx.fillStyle = colors.mut
engine.stream = engine.stream.filter((p) => {
const tp = pos(p.target)
p.x += (tp.x - p.x) * 0.085
p.y += (tp.y - p.y) * 0.085
if (Math.abs(tp.x - p.x) + Math.abs(tp.y - p.y) < 7) return false
ctx.globalAlpha = 0.5
ctx.fillRect(p.x, p.y, 1.6, 1.6)
return true
})
ctx.globalAlpha = 1
}
if (engine.pulse > 0) {
const p = Math.min(1, (now - engine.pulse) / 900)
if (p < 1) {
ctx.strokeStyle = colors.sage
ctx.globalAlpha = (1 - p) * 0.5
ctx.lineWidth = 1.2
ctx.beginPath()
ctx.arc(cx, cy, 18 + p * 290 * (Math.min(W, H) / 640), 0, Math.PI * 2)
ctx.stroke()
ctx.globalAlpha = 1
}
}
for (const n of engine.nodes) {
if (n.born == null || n.kind === 'ring') continue
const p = ease(age(n))
const q = pos(n)
const breathe = reduced ? 0 : Math.sin(now / 2600 + n.x + n.y) * 0.06
const splat = p < 1 ? 1 + 0.3 * Math.sin(p * Math.PI) : 1
const r = n.r * (1 + breathe) * p * k * 1.55 * splat
ctx.fillStyle = n.kind === 'bucket' ? colors.mut : colors.ink
ctx.beginPath()
ctx.arc(q.x, q.y, r, 0, Math.PI * 2)
ctx.fill()
if (n.label && n.lab) {
ctx.globalAlpha = Math.max(0, p * 1.2 - 0.2)
ctx.fillStyle = n.kind === 'hub' ? colors.ink : colors.mut
ctx.font =
n.kind === 'hub'
? '500 13px system-ui, sans-serif'
: n.kind === 'bucket'
? '600 9px system-ui, sans-serif'
: '10.5px system-ui, sans-serif'
const align = q.x > cx + 8 ? 'left' : q.x < cx - 8 ? 'right' : 'center'
ctx.textAlign = align
const dx = align === 'left' ? r + 5 : align === 'right' ? -r - 5 : 0
const tw = ctx.measureText(n.label).width
let lx = q.x + dx
if (align === 'left') lx = Math.min(lx, W - 6 - tw)
else if (align === 'right') lx = Math.max(lx, 6 + tw)
else lx = Math.min(Math.max(lx, 6 + tw / 2), W - 6 - tw / 2)
ctx.fillText(n.label, lx, q.y + (n.kind === 'hub' ? r + 15 : 3.5))
ctx.globalAlpha = 1
}
}
if (running && !reduced) requestAnimationFrame(draw)
}
requestAnimationFrame(draw)
if (reduced) {
// A couple of extra frames so layout settles, then hold the still.
const id = window.setTimeout(() => draw(), 120)
return () => {
running = false
window.clearTimeout(id)
}
}
return () => {
running = false
}
}, [reduced])
return (
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
<div role="status" aria-live="polite">
<ol className="space-y-0">
{lines.map((line, i) => {
const visible = lineCount > i
const active = lineCount === i + 1 && !holdingVisible
return (
<li
key={line.title}
className={`border-b border-border/60 py-2.5 transition-opacity duration-500 last:border-b-0 ${
visible ? 'opacity-100' : 'opacity-0'
}`}
>
<p className="text-sm">{line.title}</p>
<p
className={`mt-0.5 min-h-4 text-xs tabular-nums ${
visible && line.ok && !active
? 'text-success'
: visible && line.warn
? 'text-warning'
: 'text-muted-foreground'
}`}
>
{visible ? line.sub : null}
</p>
</li>
)
})}
</ol>
{holdingVisible && (
<p className="mt-4 text-xs text-muted-foreground tabular-nums">
{t('theater_writing')} {elapsed}s
</p>
)}
</div>
<div className="relative min-h-[340px] md:min-h-[420px]">
<canvas ref={canvasRef} className="h-full w-full text-foreground" aria-hidden="true" />
</div>
</div>
)
}
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import { parseSIEFile } from '../sie-parser'
import { bucketOfAccount, buildTheaterModel } from '../theater-model'
function sie(lines: string[]): string {
return [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Nordvik Bygg AB"',
'#ORGNR 5566778899',
'#RAR 0 20260101 20261231',
'#RAR -1 20250101 20251231',
'#KONTO 1930 "Företagskonto"',
'#KONTO 3041 "Försäljning tjänster"',
'#KONTO 4010 "Material"',
'#KONTO 2641 "Ingående moms"',
...lines,
].join('\n')
}
function voucher(num: number, description: string, account = '4010', amount = 100): string[] {
return [
`#VER A ${num} 20260115 "${description}"`,
'{',
`#TRANS ${account} {} ${amount}.00`,
`#TRANS 2641 {} ${(amount * 0.25).toFixed(2)}`,
`#TRANS 1930 {} -${(amount * 1.25).toFixed(2)}`,
'}',
]
}
describe('bucketOfAccount', () => {
it('maps class digits to the four display buckets', () => {
expect(bucketOfAccount('1930')).toBe('tillgangar')
expect(bucketOfAccount('2641')).toBe('skulder')
expect(bucketOfAccount('3041')).toBe('intakter')
expect(bucketOfAccount('4010')).toBe('kostnader')
expect(bucketOfAccount('7010')).toBe('kostnader')
expect(bucketOfAccount('8310')).toBe('kostnader')
})
})
describe('buildTheaterModel', () => {
it('aggregates accounts by posting count and years oldest first', () => {
const parsed = parseSIEFile(
sie([...voucher(1, 'Byggmax'), ...voucher(2, 'Byggmax'), ...voucher(3, 'Ahlsell')])
)
const model = buildTheaterModel(parsed)
expect(model.companyName).toBe('Nordvik Bygg AB')
expect(model.years.map((y) => y.start)).toEqual(['2025-01-01', '2026-01-01'])
expect(model.totalVouchers).toBe(3)
// 4010, 2641, 1930 each appear 3 times; heaviest-first with names.
const acc4010 = model.accounts.find((a) => a.number === '4010')
expect(acc4010).toMatchObject({ name: 'Material', bucket: 'kostnader', weight: 3 })
expect(model.buckets.map((b) => b.id)).toEqual(['tillgangar', 'skulder', 'kostnader'])
})
it('groups counterparties by normalized name and needs at least two sightings', () => {
const parsed = parseSIEFile(
sie([
...voucher(1, 'BYGGMAX AB'),
...voucher(2, 'Byggmax'),
...voucher(3, 'Engångsleverantören'),
])
)
const model = buildTheaterModel(parsed)
expect(model.counterparties).toHaveLength(1)
expect(model.counterparties[0]).toMatchObject({ name: 'Byggmax', weight: 2, account: '4010' })
expect(model.totalCounterparties).toBe(1)
})
it('attaches the counterparty to the counter account, not the bank leg', () => {
const parsed = parseSIEFile(
sie([...voucher(1, 'Vasakronan', '5010', 18500), ...voucher(2, 'Vasakronan', '5010', 18500)])
)
const model = buildTheaterModel(parsed)
expect(model.counterparties[0]).toMatchObject({ name: 'Vasakronan', account: '5010' })
})
it('skips internal accounting voucher texts', () => {
const parsed = parseSIEFile(
sie([
...voucher(1, 'Lön juli'),
...voucher(2, 'Lön augusti'),
...voucher(3, 'Momsredovisning Q2'),
...voucher(4, 'Avskrivning inventarier'),
...voucher(5, 'Årets resultat'),
])
)
expect(buildTheaterModel(parsed).counterparties).toHaveLength(0)
})
it('caps accounts and counterparties to readable sizes', () => {
const accounts: string[] = []
const vouchers: string[] = []
for (let i = 0; i < 30; i++) {
const num = String(5000 + i)
accounts.push(`#KONTO ${num} "Konto ${num}"`)
for (let j = 0; j < 3; j++) {
vouchers.push(...voucher(i * 3 + j + 1, `Leverantör ${i} AB`, num))
}
}
const model = buildTheaterModel(parseSIEFile(sie([...accounts, ...vouchers])))
expect(model.accounts.length).toBeLessThanOrEqual(14)
expect(model.counterparties.length).toBeLessThanOrEqual(12)
expect(model.totalCounterparties).toBe(30)
})
})
+145
View File
@@ -0,0 +1,145 @@
import type { ParsedSIEFile } from './types'
import { formatCounterpartyName, normalizeCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
/**
* Aggregated, display-ready model for the import theater: the knowledge graph
* that builds itself while an SIE import runs. Everything is derived from the
* client-parsed file, capped hard so 2 000+ vouchers become a readable
* constellation instead of a hairball.
*/
export interface TheaterModel {
companyName: string
/** Fiscal years present in the file, oldest first (tree rings). */
years: { start: string; end: string }[]
/** Class buckets that exist in the file, with total line weight. */
buckets: { id: TheaterBucket; weight: number }[]
/** Top accounts by posting count, heaviest first. */
accounts: { number: string; name: string; bucket: TheaterBucket; weight: number }[]
/** Top recognized counterparties from voucher texts, heaviest first. */
counterparties: { name: string; account: string; weight: number }[]
totalVouchers: number
totalCounterparties: number
}
export type TheaterBucket = 'tillgangar' | 'skulder' | 'intakter' | 'kostnader'
const MAX_ACCOUNTS = 14
const MAX_COUNTERPARTIES = 12
/** Internal accounting voucher texts that are not counterparties. */
const SKIP_DESCRIPTIONS = [
'lön',
'löner',
'moms',
'momsredovisning',
'avskrivning',
'avskrivningar',
'omföring',
'bokslut',
'årets resultat',
'ingående balans',
'utgående balans',
'öresavrundning',
'rättelse',
]
export function bucketOfAccount(accountNumber: string): TheaterBucket {
switch (accountNumber.charAt(0)) {
case '1':
return 'tillgangar'
case '2':
return 'skulder'
case '3':
return 'intakter'
default:
return 'kostnader'
}
}
function isSkippableDescription(description: string): boolean {
const lower = description.toLowerCase()
return SKIP_DESCRIPTIONS.some((skip) => lower.startsWith(skip))
}
export function buildTheaterModel(parsed: ParsedSIEFile): TheaterModel {
// Account weights = transaction line counts; names from #KONTO.
const accountNames = new Map(parsed.accounts.map((a) => [a.number, a.name]))
const accountWeights = new Map<string, number>()
for (const voucher of parsed.vouchers) {
for (const line of voucher.lines) {
accountWeights.set(line.account, (accountWeights.get(line.account) ?? 0) + 1)
}
}
const accounts = [...accountWeights.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, MAX_ACCOUNTS)
.map(([number, weight]) => ({
number,
name: accountNames.get(number) ?? '',
bucket: bucketOfAccount(number),
weight,
}))
const bucketWeights = new Map<TheaterBucket, number>()
for (const [number, weight] of accountWeights) {
const bucket = bucketOfAccount(number)
bucketWeights.set(bucket, (bucketWeights.get(bucket) ?? 0) + weight)
}
// Counterparties: normalized voucher texts, internal accounting texts
// skipped, grouped by normalized identity. The "dominant account" is the
// most frequent non-1930/2440-style counter account seen with the name;
// for display purposes the heaviest account of the voucher works well.
const counterpartyWeights = new Map<string, { display: string; weight: number; accounts: Map<string, number> }>()
for (const voucher of parsed.vouchers) {
const description = voucher.description.trim()
if (!description || isSkippableDescription(description)) continue
const normalized = normalizeCounterpartyName(description)
if (!normalized || normalized.length < 3) continue
const entry = counterpartyWeights.get(normalized) ?? {
display: formatCounterpartyName(normalized),
weight: 0,
accounts: new Map<string, number>(),
}
entry.weight += 1
// Weight counter accounts by absolute amount so the counterparty attaches
// to its P&L/balance account rather than the bank leg.
let heaviest: { account: string; amount: number } | null = null
for (const line of voucher.lines) {
const isSettlement = line.account.startsWith('19') || line.account.startsWith('24')
if (isSettlement) continue
const amount = Math.abs(line.amount)
if (!heaviest || amount > heaviest.amount) heaviest = { account: line.account, amount }
}
if (heaviest) {
entry.accounts.set(heaviest.account, (entry.accounts.get(heaviest.account) ?? 0) + 1)
}
counterpartyWeights.set(normalized, entry)
}
const allCounterparties = [...counterpartyWeights.values()].filter((c) => c.weight >= 2)
const counterparties = allCounterparties
.sort((a, b) => b.weight - a.weight)
.slice(0, MAX_COUNTERPARTIES)
.map((c) => {
const dominant = [...c.accounts.entries()].sort((a, b) => b[1] - a[1])[0]
return { name: c.display, account: dominant?.[0] ?? '', weight: c.weight }
})
const years = [...parsed.header.fiscalYears]
.sort((a, b) => a.start.localeCompare(b.start))
.map((y) => ({ start: y.start, end: y.end }))
return {
companyName: parsed.header.companyName ?? '',
years,
buckets: (['tillgangar', 'skulder', 'intakter', 'kostnader'] as const)
.filter((id) => (bucketWeights.get(id) ?? 0) > 0)
.map((id) => ({ id, weight: bucketWeights.get(id) ?? 0 })),
accounts,
counterparties,
totalVouchers: parsed.vouchers.length,
totalCounterparties: allCounterparties.length,
}
}
+12
View File
@@ -6486,6 +6486,18 @@
"import": {
"title": "Import / export",
"subtitle": "Import bank transactions or bookkeeping data into your company",
"theater_reading": "Reading {company}",
"theater_years": "{count, plural, one {# fiscal year} other {# fiscal years}}",
"theater_vouchers": "Reading the vouchers",
"theater_vouchers_unit": "vouchers",
"theater_accounts": "Mapping the chart of accounts to BAS 2026",
"theater_accounts_sub": "{mapped} of {total} accounts",
"theater_counterparties": "Recognizing counterparties",
"theater_counterparties_sub": "{count, plural, one {# counterparty} other {# counterparties}}",
"theater_balance": "Checking the balance",
"theater_balance_ok": "Every year balances",
"theater_balance_warn": "Imbalance found, see the warnings afterwards",
"theater_writing": "Writing to the journal ...",
"export_title": "Export",
"export_subtitle": "Download your bookkeeping as a SIE file or back it up to Google Drive",
"tab_import": "Import",
+12
View File
@@ -6486,6 +6486,18 @@
"import": {
"title": "Importera / exportera",
"subtitle": "Importera banktransaktioner eller bokföringsdata till ditt företag",
"theater_reading": "Läser {company}",
"theater_years": "{count, plural, one {# räkenskapsår} other {# räkenskapsår}}",
"theater_vouchers": "Verifikaten läses in",
"theater_vouchers_unit": "verifikat",
"theater_accounts": "Kontoplanen mappas mot BAS 2026",
"theater_accounts_sub": "{mapped} av {total} konton",
"theater_counterparties": "Motparter känns igen",
"theater_counterparties_sub": "{count, plural, one {# motpart} other {# motparter}}",
"theater_balance": "Kontrollerar balansen",
"theater_balance_ok": "Varje år balanserar",
"theater_balance_warn": "Obalans hittad, se varningarna efteråt",
"theater_writing": "Skriver till journalen ...",
"export_title": "Exportera",
"export_subtitle": "Ladda ner bokföringen som SIE-fil eller säkerhetskopia till Google Drive",
"tab_import": "Importera",