1a41119682
* perf(bundle): drop the BAS chart and the Node crypto polyfill from the shared client baseline
Two chunks rode along in the first-load JS of almost every dashboard route:
the full BAS 2026 chart (315 KB uncompressed, in 81 route manifests) and
the browser polyfill for Node's crypto/vm/Buffer (327 KB, in 26 routes
incl. login and register). Neither was needed on first paint; both got
there through static imports of helpers that happen to live next to code
that needs the data or the builtin.
Node polyfill (4 pure splits, behaviour unchanged, re-exported from the
original modules for server callers):
- lib/auth/bankid-flags.ts: isBankIdEnabled (login, register, security
settings imported it from bankid.ts, which imports crypto).
- lib/import/bank-file/formats.ts: the format registry + detection (the
import history imported getFormat from parser.ts, which hashes).
- lib/salary/personnummer-format.ts: parsing/validation/formatting (the
employee forms reached the encrypting personnummer.ts via tax-column).
- lib/auth/api-key-scopes.ts: scope catalogue, groups, tool map, helpers
(the API key panel imported STAGING_SCOPES from the key generator).
BAS chart:
- lib/bookkeeping/bas-lazy.ts + use-bas-reference.ts: the chart becomes a
dynamic import, fetched once per session after first paint; components
that show BAS names/descriptions call useBasReference() and re-render
when it lands. Until then (and on the server) only the hardcoded
account-descriptions answer, so SSR and hydration agree.
- lib/bookkeeping/bas-labels.ts: class/group labels out of bas-reference.ts
(account-descriptions needed a label and paid for the whole chart).
- lib/bookkeeping/bas-account-numbers.ts (generated, ~11 KB) +
scripts/generate-bas-account-numbers.ts (--check) + parity test:
isStandardBASAccountNumber for AddAccountDialog/ChartOfAccountsManager.
- lib/bookkeeping/account-classifier-{heuristic,client}.ts: the BAS-aligned
heuristic shared by the server classifier and a client variant that uses
the lazy chart.
- lib/bookkeeping/invoice-accounts.ts: INVOICE_FX_RATE_MISSING,
InvoiceFxRateMissingError, getRevenueAccount, getOutputVatAccount out of
invoice-entries.ts, whose engine import pulled account-backfill and the
chart into SendInvoiceDialog/PaymentBookingDialog.
- CorrectOpeningBalanceDialog re-seeds names when the chart lands;
OpeningBalanceRowEditor builds its Fuse indexes lazily; the
ChartOfAccountsManager BAS-katalog tab awaits the chunk.
Tooling:
- scripts/perf/client-import-closure.mjs: static import closure of every
'use client' module with the shortest chain to a target (file or bare
specifier); found every path above without a build.
- scripts/checks/client-node-builtin.mjs wired into check:guards: a client
module reaching a Node builtin is a hard failure (0 today).
Left as is: invoices/[id], its credit page and SendInvoiceDialog still
reach the chart through lib/invoices/issue-credit-note -> invoice-entries
-> engine -> account-backfill; splitting the engine is out of scope here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(perf): unambiguous import-edge regex in the closure walker (CodeQL js/redos)
One quantifier per span: a greedy [^'"]* up to the specifier quote, which it
cannot cross, so a run of whitespace has a single parse. Same edges as
before (multi-line named imports, re-exports, side-effect imports; type-only
imports still skipped).
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>
436 lines
16 KiB
TypeScript
436 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
|
import Fuse, { type IFuseOptions } from 'fuse.js'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Plus, Trash2, AlertTriangle, Scale } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { getBasLoaded } from '@/lib/bookkeeping/bas-lazy'
|
|
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
|
|
import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
|
|
|
|
export interface EditableRow {
|
|
id: string
|
|
account_number: string
|
|
account_name: string
|
|
debit_amount: number
|
|
credit_amount: number
|
|
validation_errors: string[]
|
|
bas_match: string | null
|
|
}
|
|
|
|
export interface OpeningBalanceEditorState {
|
|
rows: EditableRow[]
|
|
totals: { debit: number; credit: number; diff: number; isBalanced: boolean }
|
|
/** Balanced, no row errors, and at least two lines: safe to book/correct. */
|
|
canSubmit: boolean
|
|
}
|
|
|
|
interface OpeningBalanceRowEditorProps {
|
|
/** Seed rows. Read once on mount (later prop changes are ignored: remount
|
|
* via `key` or a conditional render to reset the grid). */
|
|
initialRows: EditableRow[]
|
|
/** Fires whenever rows / totals / validity change. Held in a ref internally,
|
|
* so it does NOT need to be referentially stable: passing an inline arrow
|
|
* is safe and will not cause an update-loop. */
|
|
onChange: (state: OpeningBalanceEditorState) => void
|
|
}
|
|
|
|
// Balance-sheet accounts (class 1-2) drive the primary suggestions; numeric
|
|
// queries fall back to the full chart. The chart is a lazily loaded chunk
|
|
// (lib/bookkeeping/bas-lazy.ts): the indexes are built on first use after
|
|
// it has arrived, and the suggestion list is empty until then.
|
|
const FUSE_OPTIONS: IFuseOptions<BASReferenceAccount> = {
|
|
keys: ['account_number', 'account_name'],
|
|
threshold: 0.3,
|
|
includeScore: true,
|
|
}
|
|
|
|
let fuseInstance: Fuse<BASReferenceAccount> | null = null
|
|
function getFuse(): Fuse<BASReferenceAccount> | null {
|
|
const chart = getBasLoaded()
|
|
if (!chart) return null
|
|
if (!fuseInstance) fuseInstance = new Fuse(chart, FUSE_OPTIONS)
|
|
return fuseInstance
|
|
}
|
|
|
|
let balanceFuseInstance: Fuse<BASReferenceAccount> | null = null
|
|
function getBalanceFuse(): Fuse<BASReferenceAccount> | null {
|
|
const chart = getBasLoaded()
|
|
if (!chart) return null
|
|
if (!balanceFuseInstance) {
|
|
balanceFuseInstance = new Fuse(
|
|
chart.filter((a) => a.account_class === 1 || a.account_class === 2),
|
|
FUSE_OPTIONS,
|
|
)
|
|
}
|
|
return balanceFuseInstance
|
|
}
|
|
|
|
let idCounter = 0
|
|
function generateId() {
|
|
return `row_${++idCounter}_${Date.now()}`
|
|
}
|
|
|
|
// Defense-in-depth dedup: if a seed source ever leaks duplicate accounts,
|
|
// collapse them before the user sees them. Union validation_errors so a
|
|
// warning surfaced only on the later row isn't silently dropped on merge.
|
|
function dedupeRows(initialRows: EditableRow[]): EditableRow[] {
|
|
const byAccount = new Map<string, EditableRow>()
|
|
for (const r of initialRows) {
|
|
const key = r.account_number.replace(/\D/g, '')
|
|
const existing = byAccount.get(key)
|
|
if (existing) {
|
|
existing.debit_amount = Math.round((existing.debit_amount + r.debit_amount) * 100) / 100
|
|
existing.credit_amount = Math.round((existing.credit_amount + r.credit_amount) * 100) / 100
|
|
if (!existing.account_name && r.account_name) existing.account_name = r.account_name
|
|
if (r.validation_errors?.length) {
|
|
const seen = new Set(existing.validation_errors)
|
|
for (const err of r.validation_errors) {
|
|
if (!seen.has(err)) existing.validation_errors.push(err)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
byAccount.set(key, {
|
|
id: r.id || generateId(),
|
|
account_number: r.account_number,
|
|
account_name: r.account_name,
|
|
debit_amount: r.debit_amount,
|
|
credit_amount: r.credit_amount,
|
|
validation_errors: [...r.validation_errors],
|
|
bas_match: r.bas_match,
|
|
})
|
|
}
|
|
return Array.from(byAccount.values())
|
|
}
|
|
|
|
/**
|
|
* The opening-balance line grid: editable account / debet / kredit rows with
|
|
* BAS autocomplete, a live debit-credit balance check, blocking of resultat-
|
|
* konton (class 3-8), and a "round to 2099" auto-balance for sub-1-SEK drift.
|
|
*
|
|
* Stateless about WHY it's editing: the wizard seeds it from a parsed file,
|
|
* the verifikat correction dialog seeds it from the booked IB's lines. Both
|
|
* read the current rows + validity through `onChange`.
|
|
*/
|
|
export default function OpeningBalanceRowEditor({
|
|
initialRows,
|
|
onChange,
|
|
}: OpeningBalanceRowEditorProps) {
|
|
const [rows, setRows] = useState<EditableRow[]>(() => dedupeRows(initialRows))
|
|
const [activeAutocomplete, setActiveAutocomplete] = useState<string | null>(null)
|
|
const [autocompleteQuery, setAutocompleteQuery] = useState('')
|
|
const autocompleteRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Hold onChange in a ref so an unstable (inline) callback identity can't
|
|
// retrigger the notifying effect below. Synced after each commit rather than
|
|
// during render (react-hooks/refs: refs must not be written while rendering).
|
|
const onChangeRef = useRef(onChange)
|
|
useEffect(() => {
|
|
onChangeRef.current = onChange
|
|
})
|
|
|
|
const totals = useMemo(() => {
|
|
let debit = 0
|
|
let credit = 0
|
|
for (const row of rows) {
|
|
debit = Math.round((debit + row.debit_amount) * 100) / 100
|
|
credit = Math.round((credit + row.credit_amount) * 100) / 100
|
|
}
|
|
const diff = Math.round((debit - credit) * 100) / 100
|
|
return { debit, credit, diff, isBalanced: Math.abs(diff) < 0.01 }
|
|
}, [rows])
|
|
|
|
const hasErrors = useMemo(() => {
|
|
return rows.some((r) => {
|
|
if (!/^\d{4}$/.test(r.account_number)) return true
|
|
if (r.debit_amount === 0 && r.credit_amount === 0) return true
|
|
if (r.validation_errors.length > 0) return true
|
|
return false
|
|
})
|
|
}, [rows])
|
|
|
|
const canSubmit = totals.isBalanced && !hasErrors && rows.length >= 2
|
|
|
|
// Push state up via the ref so an unstable `onChange` identity can't retrigger
|
|
// this effect. Memoised `totals` means it only fires when the user actually
|
|
// edits a row, never in a loop.
|
|
useEffect(() => {
|
|
onChangeRef.current({ rows, totals, canSubmit })
|
|
}, [rows, totals, canSubmit])
|
|
|
|
const basReady = useBasReference()
|
|
const autocompleteResults = useMemo(() => {
|
|
if (!autocompleteQuery || autocompleteQuery.length < 1) return []
|
|
const isNumeric = /^\d+$/.test(autocompleteQuery)
|
|
const fuse = isNumeric ? getFuse() : getBalanceFuse()
|
|
if (!fuse) return []
|
|
return fuse.search(autocompleteQuery, { limit: 8 }).map((r) => r.item)
|
|
// basReady re-runs the search once the chart chunk has arrived.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [autocompleteQuery, basReady])
|
|
|
|
const updateRow = useCallback((id: string, updates: Partial<EditableRow>) => {
|
|
setRows((prev) =>
|
|
prev.map((r) => {
|
|
if (r.id !== id) return r
|
|
const updated = { ...r, ...updates }
|
|
|
|
// Re-validate
|
|
const errors: string[] = []
|
|
if (!/^\d{4}$/.test(updated.account_number)) {
|
|
errors.push('Ogiltigt kontonummer')
|
|
}
|
|
const cls = parseInt(updated.account_number.charAt(0), 10)
|
|
if (cls >= 3 && cls <= 8) {
|
|
errors.push(`Resultatkonto (klass ${cls})`)
|
|
}
|
|
updated.validation_errors = errors
|
|
|
|
return updated
|
|
}),
|
|
)
|
|
}, [])
|
|
|
|
const deleteRow = useCallback((id: string) => {
|
|
setRows((prev) => prev.filter((r) => r.id !== id))
|
|
}, [])
|
|
|
|
const addRow = useCallback(() => {
|
|
setRows((prev) => [
|
|
...prev,
|
|
{
|
|
id: generateId(),
|
|
account_number: '',
|
|
account_name: '',
|
|
debit_amount: 0,
|
|
credit_amount: 0,
|
|
validation_errors: ['Ogiltigt kontonummer'],
|
|
bas_match: null,
|
|
},
|
|
])
|
|
}, [])
|
|
|
|
const selectAutocompleteItem = useCallback(
|
|
(rowId: string, account: BASReferenceAccount) => {
|
|
updateRow(rowId, {
|
|
account_number: account.account_number,
|
|
account_name: account.account_name,
|
|
bas_match: account.account_name,
|
|
})
|
|
setActiveAutocomplete(null)
|
|
setAutocompleteQuery('')
|
|
},
|
|
[updateRow],
|
|
)
|
|
|
|
const handleAutoBalance = useCallback(() => {
|
|
if (Math.abs(totals.diff) > 1) return // Only auto-balance ≤ 1 SEK
|
|
if (totals.isBalanced) return
|
|
|
|
const adjustmentRow: EditableRow = {
|
|
id: generateId(),
|
|
account_number: '2099',
|
|
account_name: 'Årets resultat',
|
|
debit_amount: totals.diff > 0 ? 0 : Math.abs(totals.diff),
|
|
credit_amount: totals.diff > 0 ? totals.diff : 0,
|
|
validation_errors: [],
|
|
bas_match: 'Årets resultat',
|
|
}
|
|
setRows((prev) => [...prev, adjustmentRow])
|
|
}, [totals])
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Table */}
|
|
<div className="overflow-x-auto rounded-lg border">
|
|
<table className="w-full text-sm">
|
|
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
|
<tr className="border-b">
|
|
<th className="px-3 py-2 text-left w-28">Konto</th>
|
|
<th className="px-3 py-2 text-left">Kontonamn</th>
|
|
<th className="px-3 py-2 text-right w-32">Debet</th>
|
|
<th className="px-3 py-2 text-right w-32">Kredit</th>
|
|
<th className="px-3 py-2 w-10" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row) => (
|
|
<tr
|
|
key={row.id}
|
|
className={cn(
|
|
'border-b last:border-0',
|
|
row.validation_errors.length > 0 && 'bg-destructive/5',
|
|
)}
|
|
>
|
|
<td className="px-3 py-1.5 relative">
|
|
<Input
|
|
value={row.account_number}
|
|
onChange={(e) => {
|
|
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 4)
|
|
updateRow(row.id, { account_number: val })
|
|
setActiveAutocomplete(row.id)
|
|
setAutocompleteQuery(val)
|
|
}}
|
|
onFocus={() => {
|
|
setActiveAutocomplete(row.id)
|
|
setAutocompleteQuery(row.account_number)
|
|
}}
|
|
onBlur={() => {
|
|
// Delay to allow click on autocomplete items
|
|
setTimeout(() => setActiveAutocomplete(null), 200)
|
|
}}
|
|
placeholder="1930"
|
|
className="h-8 font-mono tabular-nums w-20"
|
|
maxLength={4}
|
|
/>
|
|
{/* Autocomplete dropdown */}
|
|
{activeAutocomplete === row.id && autocompleteResults.length > 0 && (
|
|
<div
|
|
ref={autocompleteRef}
|
|
className="absolute z-50 top-full left-3 mt-1 w-72 max-h-48 overflow-y-auto rounded-lg border bg-popover shadow-md"
|
|
>
|
|
{autocompleteResults.map((item) => (
|
|
<button
|
|
key={item.account_number}
|
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-left text-sm hover:bg-accent transition-colors"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault()
|
|
selectAutocompleteItem(row.id, item)
|
|
}}
|
|
>
|
|
<span className="font-mono text-muted-foreground tabular-nums">
|
|
{item.account_number}
|
|
</span>
|
|
<span className="truncate">{item.account_name}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm truncate max-w-xs">{row.account_name}</span>
|
|
{row.validation_errors.length > 0 && (
|
|
<span
|
|
className="text-destructive shrink-0"
|
|
title={row.validation_errors.join(', ')}
|
|
>
|
|
<AlertTriangle className="h-3.5 w-3.5" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-1.5">
|
|
<Input
|
|
type="number"
|
|
value={row.debit_amount || ''}
|
|
onChange={(e) =>
|
|
updateRow(row.id, {
|
|
debit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
|
|
})
|
|
}
|
|
placeholder="0,00"
|
|
className="h-8 text-right tabular-nums w-28"
|
|
min={0}
|
|
step={0.01}
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-1.5">
|
|
<Input
|
|
type="number"
|
|
value={row.credit_amount || ''}
|
|
onChange={(e) =>
|
|
updateRow(row.id, {
|
|
credit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
|
|
})
|
|
}
|
|
placeholder="0,00"
|
|
className="h-8 text-right tabular-nums w-28"
|
|
min={0}
|
|
step={0.01}
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-1.5">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => deleteRow(row.id)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr className="border-t-2 font-medium">
|
|
<td className="px-3 py-2" colSpan={2}>
|
|
Summa
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums">
|
|
{totals.debit.toLocaleString('sv-SE', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
})}
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums">
|
|
{totals.credit.toLocaleString('sv-SE', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
})}
|
|
</td>
|
|
<td />
|
|
</tr>
|
|
{!totals.isBalanced && (
|
|
<tr className="text-destructive">
|
|
<td className="px-3 py-1 text-sm" colSpan={2}>
|
|
Differens
|
|
</td>
|
|
<td className="px-3 py-1 text-right tabular-nums text-sm" colSpan={2}>
|
|
{totals.diff.toLocaleString('sv-SE', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
})}{' '}
|
|
SEK
|
|
</td>
|
|
<td />
|
|
</tr>
|
|
)}
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Actions row */}
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={addRow}>
|
|
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
Lägg till rad
|
|
</Button>
|
|
{!totals.isBalanced && Math.abs(totals.diff) <= 1 && Math.abs(totals.diff) >= 0.01 && (
|
|
<Button variant="outline" size="sm" onClick={handleAutoBalance}>
|
|
<Scale className="h-3.5 w-3.5 mr-1.5" />
|
|
Avrunda ({totals.diff > 0 ? '+' : ''}
|
|
{totals.diff.toFixed(2)} till 2099)
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Warnings */}
|
|
{!totals.isBalanced && Math.abs(totals.diff) > 1 && (
|
|
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 px-4 py-3">
|
|
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
|
|
<p className="text-sm text-warning">
|
|
Debet och kredit balanserar inte. Differens:{' '}
|
|
{totals.diff.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK. Kontrollera
|
|
beloppen innan du fortsätter.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|