d4ef4f8bc4
* 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>
146 lines
5.3 KiB
TypeScript
146 lines
5.3 KiB
TypeScript
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,
|
|
}
|
|
}
|