'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 = { keys: ['account_number', 'account_name'], threshold: 0.3, includeScore: true, } let fuseInstance: Fuse | null = null function getFuse(): Fuse | null { const chart = getBasLoaded() if (!chart) return null if (!fuseInstance) fuseInstance = new Fuse(chart, FUSE_OPTIONS) return fuseInstance } let balanceFuseInstance: Fuse | null = null function getBalanceFuse(): Fuse | 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() 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(() => dedupeRows(initialRows)) const [activeAutocomplete, setActiveAutocomplete] = useState(null) const [autocompleteQuery, setAutocompleteQuery] = useState('') const autocompleteRef = useRef(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) => { 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 (
{/* Table */}
{rows.map((row) => ( 0 && 'bg-destructive/5', )} > ))} {!totals.isBalanced && ( )}
Konto Kontonamn Debet Kredit
{ 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 && (
{autocompleteResults.map((item) => ( ))}
)}
{row.account_name} {row.validation_errors.length > 0 && ( )}
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} /> 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} />
Summa {totals.debit.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2, })} {totals.credit.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2, })}
Differens {totals.diff.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2, })}{' '} SEK
{/* Actions row */}
{!totals.isBalanced && Math.abs(totals.diff) <= 1 && Math.abs(totals.diff) >= 0.01 && ( )}
{/* Warnings */} {!totals.isBalanced && Math.abs(totals.diff) > 1 && (

Debet och kredit balanserar inte. Differens:{' '} {totals.diff.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK. Kontrollera beloppen innan du fortsätter.

)}
) }