diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 8eedbbe4..9de890f1 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -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(null) const [issues, setIssues] = useState([]) const [importResult, setImportResult] = useState(null) + const [theaterModel, setTheaterModel] = useState(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 && ( + onExecute={handleExecuteImport} onBack={goBack} isLoading={isLoading} + theaterModel={theaterModel} /> )} {step === 'result' && importResult && } diff --git a/components/import/ImportReviewStep.tsx b/components/import/ImportReviewStep.tsx index 992c18fb..8f77ba43 100644 --- a/components/import/ImportReviewStep.tsx +++ b/components/import/ImportReviewStep.tsx @@ -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 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 (
-
- -
-

Importerar bokföring...

-

- {preview.voucherCount} verifikationer bearbetas + {theaterModel ? ( +

+ +

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

-
- {elapsed}s + ) : ( +
+ +
+

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. +

-

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

-
+ )}
diff --git a/components/import/ImportTheater.tsx b/components/import/ImportTheater.tsx new file mode 100644 index 00000000..f3e72414 --- /dev/null +++ b/components/import/ImportTheater.tsx @@ -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 = { + tillgangar: 0.31, // right, slightly down + skulder: 1.62, // bottom + intakter: -0.92, // upper right + kostnader: 2.75, // left +} +const BUCKET_LABEL: Record = { + 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(null) + const engineRef = useRef(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 ( +
+
+
    + {lines.map((line, i) => { + const visible = lineCount > i + const active = lineCount === i + 1 && !holdingVisible + return ( +
  1. +

    {line.title}

    +

    + {visible ? line.sub : null} +

    +
  2. + ) + })} +
+ {holdingVisible && ( +

+ {t('theater_writing')} {elapsed}s +

+ )} +
+
+
+
+ ) +} diff --git a/lib/import/__tests__/theater-model.test.ts b/lib/import/__tests__/theater-model.test.ts new file mode 100644 index 00000000..003793fb --- /dev/null +++ b/lib/import/__tests__/theater-model.test.ts @@ -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) + }) +}) diff --git a/lib/import/theater-model.ts b/lib/import/theater-model.ts new file mode 100644 index 00000000..dd62890f --- /dev/null +++ b/lib/import/theater-model.ts @@ -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() + 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() + 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 }>() + 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(), + } + 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, + } +} diff --git a/messages/en.json b/messages/en.json index 986bc7ee..75a9bf61 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 985a0c36..44f1141a 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",