'use client' import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useRouter } from 'next/navigation' import { useTranslations, useLocale } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' import { ToastAction } from '@/components/ui/toast' import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus, Eraser, Tags, BookmarkPlus } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { useCanWrite } from '@/lib/hooks/use-can-write' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent' import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import LineDimensionFields from '@/components/dimensions/LineDimensionFields' import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client' import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker' import { deriveTemplateLinesFromBooking } from '@/lib/bookkeeping/template-library' import { sourceTypeForTemplateCategory } from '@/lib/bookkeeping/template-source-type' import { TemplateForm } from '@/components/settings/TemplateForm' import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog' import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' import { splitCreateAccountPrefill } from '@/lib/bookkeeping/create-account-prefill' import DuplicateBookingDialog, { type DuplicateMatchTransaction } from '@/components/transactions/DuplicateBookingDialog' import { Skeleton } from '@/components/ui/skeleton' import { useSubmitWithAccountActivation, throwOnStructuredError, } from '@/lib/hooks/use-submit-with-account-activation' import { getErrorMessage } from '@/lib/errors/get-error-message' import { linkDocuments, formatFailedDocumentNames, type DocumentLinkFailure, } from '@/lib/documents/link-documents' import { formatCurrency } from '@/lib/utils' import { roundOre } from '@/lib/money' import { formatVoucher, resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver' import { resolveFxLineSlot } from '@/lib/bookkeeping/fx-line-slot' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import { useCompany } from '@/contexts/CompanyContext' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency, BookingTemplateLibrary, BookingTemplateCategory } from '@/types' import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection' const CURRENCIES: { value: Currency; label: string }[] = [ { value: 'SEK', label: 'SEK' }, { value: 'EUR', label: 'EUR' }, { value: 'USD', label: 'USD' }, { value: 'GBP', label: 'GBP' }, { value: 'NOK', label: 'NOK' }, { value: 'DKK', label: 'DKK' }, ] export interface FormLine { account_number: string debit_amount: string credit_amount: string line_description: string currency?: string amount_in_currency?: number exchange_rate?: number /** Pass-through only (set via API/MCP, never edited in this form): edit * mode replaces all lines, so dropping it would strip the stored code. */ tax_code?: string | null /** SIE dimension map {sie_dim_no: object_code}, e.g. {"1":"KS01","6":"P001"}. */ dimensions?: Record } interface Props { onCreated?: () => void onEntryCreated?: (entryId: string) => void initialLines?: FormLine[] initialDate?: string initialDescription?: string initialNotes?: string initialVoucherSeries?: string sourceType?: JournalEntrySourceType sourceId?: string submitUrl?: string embedded?: boolean /** Render without the Card chrome (e.g. inside a dialog) but keep the full * non-embedded field set (series, notes, documents, voucher hint). */ bare?: boolean /** Edit an existing DRAFT in place: the form PATCHes this entry instead of * creating a new one. Only the draft's header + lines are updated. */ editEntryId?: string /** Edit mode: hydrate the currency picker + FX fields from the stored draft * so a foreign-currency draft is not displayed (and resaved) as SEK. */ initialCurrency?: Currency initialExchangeRate?: number initialForeignAmount?: number /** Fired after a successful draft edit (editEntryId path). */ onUpdated?: () => void /** The bank transaction being booked (set by TransactionBookingDialog). * Enables the duplicate guard's "Matcha mot verifikatet" action for * ledger-only voucher candidates. */ duplicateMatchTransaction?: DuplicateMatchTransaction /** Fired after the duplicate guard's match action links the transaction to * the existing voucher (no new entry was created). */ onDuplicateMatched?: (journalEntryId: string) => void } const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '', line_description: '' } export default function JournalEntryForm({ onCreated, onEntryCreated, initialLines, initialDate, initialDescription, initialNotes, initialVoucherSeries, sourceType, sourceId, submitUrl, embedded, bare, editEntryId, initialCurrency, initialExchangeRate, initialForeignAmount, onUpdated, duplicateMatchTransaction, onDuplicateMatched, }: Props) { const { canWrite } = useCanWrite() const { toast } = useToast() const router = useRouter() const { company } = useCompany() const t = useTranslations('journal_form') // Reused only for the bilingual entity-type labels the shared TemplateForm // expects (matches BookingTemplatesPanel); the form itself already pulls its // copy from this namespace. const tTpl = useTranslations('settings_booking_templates') const locale = useLocale() const [periods, setPeriods] = useState([]) const [selectedPeriod, setSelectedPeriod] = useState('') const [entryDate, setEntryDate] = useState(initialDate ?? new Date().toISOString().split('T')[0]) const [description, setDescription] = useState(initialDescription ?? '') const [notes, setNotes] = useState(initialNotes ?? '') const [showNotes, setShowNotes] = useState(false) // Dimension tagging (kostnadsställe/projekt). The affordances render only // when company_settings.dimensions_enabled: a UI-visibility gate; lines // that already carry dimensions (e.g. a draft being edited) still round-trip // untouched when the toggle is off. const [dimensionsEnabled, setDimensionsEnabled] = useState(false) const [showDims, setShowDims] = useState(false) // Header-level default dims ("gäller alla rader"). The per-row maps on // `lines` are the ONE source of truth: this state only drives the header // comboboxes; setHeaderDimension writes the default through to the rows. const [headerDims, setHeaderDims] = useState>({}) // Which row's dimension popover is open (desktop table), and its container // for the outside-click close. const [dimPopoverRow, setDimPopoverRow] = useState(null) const dimPopoverRef = useRef(null) const [lines, setLines] = useState( initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }] ) const [voucherSeries, setVoucherSeries] = useState(initialVoucherSeries ?? 'A') // The source_type the entry will be committed with. Seeded from the prop // (undefined -> 'manual' for the standalone form). Applying a booking template // whose category maps to a dedicated source type (e.g. VAT -> vat_settlement) // flips this so the entry lands in that type's configured voucher series. const [effectiveSourceType, setEffectiveSourceType] = useState( sourceType ?? 'manual', ) // Cache of the company's series config so template routing can re-resolve the // default series without re-fetching /api/settings. Populated by the settings // effect below. const seriesMapRef = useRef | null>(null) const defaultSeriesRef = useRef('A') // Mirror of effectiveSourceType for the settings-fetch callback: if a template // routed the source type before /api/settings resolved, the late callback must // re-apply the series for the ROUTED type, not the mount-time base (otherwise // the entry submits as vat_settlement in the manual series). const effectiveSourceTypeRef = useRef(sourceType ?? 'manual') const [nextVoucherNumber, setNextVoucherNumber] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) // Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): the // /book endpoint flags an already-booked sibling sharing date+amount+account. // Surface it and let the user book anyway. The override is bound to the // reviewed candidate via a ref the next submit reads: force is sent ONLY on // that retry, never on a normal submit or to the manual journal-entry endpoint. const [duplicateCandidate, setDuplicateCandidate] = useState(null) const forceDuplicateRef = useRef<{ force: true; expected_duplicate_journal_entry_id: string } | null>(null) const [showReview, setShowReview] = useState(false) const [isSavingDraft, setIsSavingDraft] = useState(false) const saveAsDraftRef = useRef(false) const [showNoDocWarning, setShowNoDocWarning] = useState(false) const [uploadedFiles, setUploadedFiles] = useState([]) const [accounts, setAccounts] = useState([]) // Full BAS catalogue (static reference data, fetched once per session). Lets // the account picker surface standard accounts the company hasn't activated // yet; picking one activates it at commit via the existing rail. const [catalog, setCatalog] = useState([]) const [entryCurrency, setEntryCurrency] = useState(initialCurrency ?? 'SEK') const [exchangeRate, setExchangeRate] = useState(initialExchangeRate != null ? String(initialExchangeRate) : '') const [isFetchingRate, setIsFetchingRate] = useState(false) const [foreignAmount, setForeignAmount] = useState(initialForeignAmount != null ? String(initialForeignAmount) : '') const [periodMismatch, setPeriodMismatch] = useState<'no_period' | 'wrong_period' | null>(null) const [showCreatePeriod, setShowCreatePeriod] = useState(false) const [showClearConfirm, setShowClearConfirm] = useState(false) // "Spara som mall": derive a reusable template from the current kontering and // hand it to the shared TemplateForm (create mode). Amounts become ratios/VAT // rates so the mall re-computes when applied to a fresh amount later. const [showSaveTemplate, setShowSaveTemplate] = useState(false) // Month (YYYY-MM) of the most recently posted voucher this session. Used to // flag, at the review step, when the user is about to book into a different // month: guards against accidentally posting to the wrong month. const [lastPostedMonth, setLastPostedMonth] = useState(null) // Per-account saldo as of entryDate, keyed by account_number. // undefined = not fetched, null = fetch in flight. const [accountBalances, setAccountBalances] = useState>({}) // Inline account-creation: which line triggered the dialog, and what the // user typed in the combobox so we can prefill the dialog. const [creatingAccountForLine, setCreatingAccountForLine] = useState(null) const [createAccountPrefill, setCreateAccountPrefill] = useState('') // Per-row refs to the account/debit/credit inputs so the keyboard flow can // advance focus with Enter: konto → debet → kredit → nästa rads konto. Two // layouts render simultaneously (mobile cards + desktop table); we focus // whichever one is actually visible. const desktopAccountRefs = useRef<(HTMLInputElement | null)[]>([]) const mobileAccountRefs = useRef<(HTMLInputElement | null)[]>([]) const desktopDebitRefs = useRef<(HTMLInputElement | null)[]>([]) const mobileDebitRefs = useRef<(HTMLInputElement | null)[]>([]) const desktopCreditRefs = useRef<(HTMLInputElement | null)[]>([]) const mobileCreditRefs = useRef<(HTMLInputElement | null)[]>([]) // Confirm button in the inline (bare) review, focused on open so Enter posts. const bareConfirmRef = useRef(null) const isForeign = entryCurrency !== 'SEK' const isUploading = uploadedFiles.some((f) => f.status === 'uploading') const hasContent = description !== '' || notes !== '' || lines.some(l => l.account_number !== '' || l.debit_amount !== '' || l.credit_amount !== '') || uploadedFiles.length > 0 useUnsavedChanges(hasContent) async function fetchPeriods() { const res = await fetch('/api/bookkeeping/fiscal-periods') const { data } = await res.json() const fetched: FiscalPeriod[] = data || [] setPeriods(fetched) // Auto-select period matching the current entry date const match = fetched.find( (p) => entryDate >= p.period_start && entryDate <= p.period_end ) if (match) { setSelectedPeriod(match.id) setPeriodMismatch(null) } else if (fetched.length > 0) { setSelectedPeriod(fetched[0].id) setPeriodMismatch('no_period') } } async function fetchAccounts() { const res = await fetch('/api/bookkeeping/accounts') const { data } = await res.json() setAccounts(data || []) } // Resolve + set the default voucher series for a source type from the cached // company config: prefer the per-source-type mapping, fall back to the legacy // default_voucher_series, then to 'A'. Reads refs (stable), so it can run both // on load and when template application changes the source type. const applySeriesForSourceType = useCallback((st: JournalEntrySourceType) => { const perSource = resolveDefaultSeriesForSource(seriesMapRef.current, st) setVoucherSeries(perSource !== 'A' ? perSource : defaultSeriesRef.current || 'A') }, []) useEffect(() => { fetchPeriods() fetchAccounts() loadBasCatalog().then(setCatalog).catch(() => {/* search degrades to the active chart */}) // Company settings power two things here: dimensions_enabled gates the // tagging affordances (all modes, incl. the TransactionBookingDialog // embed), and the default voucher series seeds the standalone form: // prefer the per-source-type mapping when present; fall back to the legacy // default_voucher_series, then to 'A'. In edit mode the draft's own series // is pre-filled: never override it from the company defaults. fetch('/api/settings').then(r => r.json()).then(({ data }) => { if (!data) return setDimensionsEnabled(data.dimensions_enabled === true) seriesMapRef.current = (data.default_voucher_series_per_source_type as Record | null) ?? null defaultSeriesRef.current = data.default_voucher_series || 'A' if (!embedded && !editEntryId) { applySeriesForSourceType(effectiveSourceTypeRef.current) } }).catch(() => {/* keep 'A' + hidden dimension affordances */}) }, [embedded, sourceType, editEntryId, applySeriesForSourceType]) // Auto-select period when entry date changes useEffect(() => { if (periods.length === 0) return const match = periods.find( (p) => entryDate >= p.period_start && entryDate <= p.period_end ) if (match) { setSelectedPeriod(match.id) setPeriodMismatch(null) } else { setPeriodMismatch('no_period') } }, [entryDate, periods]) // Preview the upcoming voucher number for the selected period + series. // Read-only hint; the actual number is reserved atomically at commit time, // so this may shift by one if another entry lands first. useEffect(() => { if (embedded || !selectedPeriod || !voucherSeries) { setNextVoucherNumber(null) return } let cancelled = false const qs = new URLSearchParams({ period_id: selectedPeriod, series: voucherSeries }) fetch(`/api/bookkeeping/voucher-sequences/next?${qs}`) .then((r) => (r.ok ? r.json() : null)) .then((body) => { if (cancelled) return const next = body?.data?.next setNextVoucherNumber(typeof next === 'number' ? next : null) }) .catch(() => { if (!cancelled) setNextVoucherNumber(null) }) return () => { cancelled = true } }, [embedded, selectedPeriod, voucherSeries]) // Fetch exchange rate from Riksbanken when currency changes const fetchRate = useCallback(async (currency: Currency) => { if (currency === 'SEK') return setIsFetchingRate(true) try { const res = await fetch(`/api/currency/rate?currency=${currency}&date=${entryDate}`) if (res.ok) { const { data } = await res.json() if (data?.rate) { setExchangeRate(String(data.rate)) } } } catch { // Non-critical: user can enter rate manually } finally { setIsFetchingRate(false) } }, [entryDate]) // Edit mode hydrates the draft's STORED rate: the mount-time fetch must not // replace it with today's rate, or a text-only edit would silently save a // different FX rate. Fetch only after the user changes currency (or date). const skipInitialRateFetch = useRef(initialExchangeRate != null) useEffect(() => { if (entryCurrency !== 'SEK') { if (skipInitialRateFetch.current) { skipInitialRateFetch.current = false return } fetchRate(entryCurrency) } }, [entryCurrency, fetchRate]) // Stable key of selected account numbers across all lines, sorted + deduped. // Only valid 4-digit BAS account numbers are included. const accountsKey = useMemo( () => Array.from( new Set(lines.map((l) => l.account_number).filter((a) => /^\d{4}$/.test(a))) ) .sort() .join(','), [lines] ) // Fetch per-account saldo as of entryDate for the accounts currently on the // form. The fetched value is always "saldo before this entry"; the render // layer adds the typed draft amounts on top (draftDeltas) so the column // shows where the account is heading. useEffect(() => { if (!accountsKey) { setAccountBalances({}) return } const accountList = accountsKey.split(',') // Carry forward any previously-known balances for these accounts so the // value doesn't blank out on re-fetch; mark genuinely new accounts as // loading (null). setAccountBalances((prev) => { const next: Record = {} for (const a of accountList) next[a] = a in prev ? prev[a] : null return next }) let cancelled = false const handle = setTimeout(async () => { try { const qs = new URLSearchParams({ accounts: accountsKey, as_of: entryDate }) const res = await fetch(`/api/bookkeeping/account-balances?${qs}`) if (!res.ok) { // 4xx (e.g. future entryDate rejected by Zod) or 5xx: collapse the // loading skeleton so the column doesn't get stuck. Saldo is a // reference value, not authoritative: showing 0 here is preferable // to an indefinite spinner. if (cancelled) return setAccountBalances((prev) => { const next = { ...prev } for (const a of accountList) { if (next[a] == null) next[a] = 0 } return next }) return } const body = (await res.json()) as { data: Array<{ account_number: string; balance: number }> } if (cancelled) return setAccountBalances((prev) => { const next = { ...prev } for (const row of body.data) next[row.account_number] = row.balance return next }) } catch { // Reference value: failure is non-fatal, just leave previous state. } }, 150) return () => { cancelled = true clearTimeout(handle) } }, [accountsKey, entryDate]) // What the typed-but-unposted rows would do to each account's saldo. The // /account-balances convention is debit-positive for every class, so // delta = debit - credit encodes direction without needing the account type: // rendering "before -> after" gives instant feedback on whether the chosen // side increases or decreases the account. const draftDeltas = useMemo(() => { const deltas: Record = {} for (const l of lines) { if (!/^\d{4}$/.test(l.account_number)) continue const delta = (parseFloat(l.debit_amount) || 0) - (parseFloat(l.credit_amount) || 0) if (delta === 0) continue deltas[l.account_number] = roundOre((deltas[l.account_number] ?? 0) + delta) } return deltas }, [lines]) // New rows inherit the current header default (a row without a per-row // override follows the header (see setHeaderDimension). const makeBlankLine = useCallback( (): FormLine => Object.keys(headerDims).length > 0 ? { ...BLANK_LINE, dimensions: { ...headerDims } } : { ...BLANK_LINE }, [headerDims] ) const addLine = () => { setLines([...lines, makeBlankLine()]) } const removeLine = (index: number) => { if (lines.length <= 2) return setLines(lines.filter((_, i) => i !== index)) // Keep the open dimension popover attached to the same row after the splice. setDimPopoverRow((r) => (r === null ? r : r === index ? null : r > index ? r - 1 : r)) } /** * Header default write-through. Inheritance rule: a row inherits dimension * `dimNo` iff its current value equals the previous header default (unset * counts as equal to an unset default). Inheriting rows follow the change * (including clearing); rows whose value differs are per-row overrides and * are left untouched. A row explicitly set to the same code as the header is * indistinguishable from an inherited one and follows later header changes * by design: the per-row maps stay the single source of truth. */ const setHeaderDimension = (dimNo: string, code: string | null) => { const prev = headerDims[dimNo] const next = code?.trim() || undefined setHeaderDims((h) => { const out = { ...h } if (next) out[dimNo] = next else delete out[dimNo] return out }) setLines((ls) => ls.map((l) => { if (l.dimensions?.[dimNo] !== prev) return l // per-row override: keep const dims = { ...(l.dimensions ?? {}) } if (next) dims[dimNo] = next else delete dims[dimNo] return { ...l, dimensions: Object.keys(dims).length > 0 ? dims : undefined } }) ) } const updateLineDimension = (index: number, dimNo: string, code: string | null) => { setLines((ls) => ls.map((l, i) => { if (i !== index) return l const dims = { ...(l.dimensions ?? {}) } const trimmed = code?.trim() if (trimmed) dims[dimNo] = trimmed else delete dims[dimNo] return { ...l, dimensions: Object.keys(dims).length > 0 ? dims : undefined } }) ) } // Compact per-row display, e.g. "KS01 · P001" (dim number order). const compactDims = (dims: Record) => Object.entries(dims) .filter(([, v]) => v) .sort(([a], [b]) => Number(a) - Number(b)) .map(([, v]) => v) .join(' · ') // Close the row dimension popover on outside click (same pattern as the // comboboxes' own dropdowns; their option clicks preventDefault so a // selection never counts as outside). useEffect(() => { if (dimPopoverRow === null) return function handlePointerDown(e: MouseEvent | TouchEvent) { if (dimPopoverRef.current && !dimPopoverRef.current.contains(e.target as Node)) { setDimPopoverRow(null) } } document.addEventListener('mousedown', handlePointerDown) document.addEventListener('touchstart', handlePointerDown) return () => { document.removeEventListener('mousedown', handlePointerDown) document.removeEventListener('touchstart', handlePointerDown) } }, [dimPopoverRow]) const updateLine = (index: number, field: keyof FormLine, value: string) => { const updated = [...lines] updated[index] = { ...updated[index], [field]: value } // If entering debit, clear credit and vice versa if (field === 'debit_amount' && value) { updated[index].credit_amount = '' } else if (field === 'credit_amount' && value) { updated[index].debit_amount = '' } // Auto-fill line description from account name when selecting an account. // NOTE: we intentionally do NOT auto-fill a balancing amount here: that was // surprising when splitting across several lines. The balancing amount is // now opt-in via double-clicking a debit/credit field (handleFillBalance). if (field === 'account_number' && value) { // Fall back to the BAS catalogue so the description still auto-fills when // the chosen account isn't in the active chart yet. const account = accounts.find((a) => a.account_number === value) ?? catalog.find((a) => a.account_number === value) if (account) { updated[index].line_description = account.account_name // Fortnox-style: seed the verifikationstext from the first row's account // when the user hasn't typed one yet. Non-destructive: never overwrites. if (index === 0 && !description.trim()) { setDescription(account.account_name) } } } setLines(updated) } // Outstanding imbalance from every line except `excludeIndex`. // Positive => debit side is short (a debit on the target row balances it); // negative => credit side is short. const computeBalancingDiff = useCallback( (excludeIndex: number) => { const others = lines.filter((_, i) => i !== excludeIndex) const d = others.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0) const c = others.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0) return Math.round((c - d) * 100) / 100 }, [lines] ) // Opt-in balancing: double-click a debit/credit field to fill the amount that // makes the voucher balance. No-op if already balanced or if the balancing // entry belongs on the other side. const handleFillBalance = (index: number, side: 'debit' | 'credit') => { const diff = computeBalancingDiff(index) const fill = side === 'debit' ? diff : -diff if (fill <= 0) return updateLine(index, side === 'debit' ? 'debit_amount' : 'credit_amount', fill.toFixed(2)) } // Move focus to a row's input. Deferred a frame so it runs after any // re-render (e.g. the auto-appended trailing row). offsetParent is null for // display:none elements, so this picks whichever layout is currently visible. const focusRowInput = useCallback( ( desktop: React.RefObject<(HTMLInputElement | null)[]>, mobile: React.RefObject<(HTMLInputElement | null)[]>, index: number ) => { requestAnimationFrame(() => { const d = desktop.current?.[index] const m = mobile.current?.[index] const target = d && d.offsetParent !== null ? d : m && m.offsetParent !== null ? m : (d ?? m) target?.focus() target?.select?.() }) }, [] ) const focusAccount = useCallback( (index: number) => focusRowInput(desktopAccountRefs, mobileAccountRefs, index), [focusRowInput] ) const focusDebit = useCallback( (index: number) => focusRowInput(desktopDebitRefs, mobileDebitRefs, index), [focusRowInput] ) const focusCredit = useCallback( (index: number) => focusRowInput(desktopCreditRefs, mobileCreditRefs, index), [focusRowInput] ) // Keep exactly one trailing blank row so the user never has to click "Lägg // till rad": once the last row is started (account or amount), append a fresh // blank below it. Applies uniformly to typed, templated and copied lines. // The guard lives inside the functional updater so chained updates see each // other's result, making it idempotent and safe under StrictMode's dev-only // double-invoke (no runaway append, no double blank row). useEffect(() => { setLines((prev) => { const last = prev[prev.length - 1] if (!last) return prev const trailingBlank = last.account_number === '' && last.debit_amount === '' && last.credit_amount === '' return trailingBlank ? prev : [...prev, makeBlankLine()] }) }, [lines, makeBlankLine]) // Inline (bare) review: move focus to the confirm button when it opens so // Enter posts: parity with the ConfirmationDialog's autoFocusConfirm. useEffect(() => { if (bare && showReview) { requestAnimationFrame(() => bareConfirmRef.current?.focus()) } }, [bare, showReview]) // Only lines with both an account and a non-zero amount end up in the submit // payload (see the filter in handleConfirm). Compute totals and balance from // those same lines so the enable-gate matches what the API will actually see. const submittableLines = lines.filter((l) => { const d = parseFloat(l.debit_amount) || 0 const c = parseFloat(l.credit_amount) || 0 return !!l.account_number && (d > 0 || c > 0) }) const incompleteLineCount = lines.filter((l) => { const d = parseFloat(l.debit_amount) || 0 const c = parseFloat(l.credit_amount) || 0 const hasAmount = d > 0 || c > 0 const hasAccount = !!l.account_number // Row counts as incomplete if exactly one of (account, amount) is present. return hasAccount !== hasAmount }).length const totalDebit = submittableLines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0) const totalCredit = submittableLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0) const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0 && totalDebit > 0 && submittableLines.length >= 2 && incompleteLineCount === 0 // Account number → BAS name, so derived template lines get meaningful labels. const accountNameMap = useMemo( () => Object.fromEntries(catalog.map((a) => [a.account_number, a.account_name])), [catalog], ) // Template lines derived from the current kontering. Fewer than two usable // lines (a 4-digit account + an amount) disables "Spara som mall". const derivedTemplateLines = useMemo( () => deriveTemplateLinesFromBooking(lines, accountNameMap), [lines, accountNameMap], ) const rate = parseFloat(exchangeRate) || 0 // If user has manually entered a foreign amount, use that; otherwise derive from SEK total const parsedForeignInput = parseFloat(foreignAmount) || 0 const computedForeignAmount = isForeign && rate > 0 ? (parsedForeignInput > 0 ? parsedForeignInput : (totalDebit > 0 ? Math.round(totalDebit / rate * 100) / 100 : 0)) : 0 // The expected SEK equivalent based on foreign amount × rate const computedSekAmount = isForeign && rate > 0 && computedForeignAmount > 0 ? Math.round(computedForeignAmount * rate * 100) / 100 : 0 // Month/period safety signals surfaced at the review step (not as a blocking // dialog on every date change (that would add friction to routine entry). const monthLabel = useCallback( (ym: string) => { const [y, m] = ym.split('-').map(Number) if (!y || !m) return ym return new Date(y, m - 1, 1).toLocaleDateString(locale === 'en' ? 'en-GB' : 'sv-SE', { month: 'long', year: 'numeric', }) }, [locale] ) const entryMonth = entryDate.slice(0, 7) const monthChanged = lastPostedMonth != null && entryMonth !== lastPostedMonth const selectedPeriodObj = periods.find((p) => p.id === selectedPeriod) const selectedPeriodLocked = !!(selectedPeriodObj?.locked_at || selectedPeriodObj?.is_closed) const handleTemplateApply = ( templateLines: FormLine[], templateDescription: string, category?: BookingTemplateCategory, ) => { setLines(templateLines) if (!description) setDescription(templateDescription) // Route templates whose category maps to a dedicated source type (VAT -> // vat_settlement) so the entry books into that type's configured series. // Create mode only: embedded/edit keep their caller-provided source type. // Non-mapped categories fall back to the form's base source type, which // also reverts a prior VAT routing if the user swaps templates. if (!embedded && !editEntryId) { const base = sourceType ?? 'manual' const routed = sourceTypeForTemplateCategory(category) ?? base if (routed !== effectiveSourceType) { setEffectiveSourceType(routed) effectiveSourceTypeRef.current = routed applySeriesForSourceType(routed) } } } // Wipe the form back to a blank entry. Mirrors the post-submit reset: it // clears the data the user typed (lines, description, note, attachments, // currency) but keeps the contextual defaults (period, date) so the form is // immediately ready for the next entry. Template-routed source type does NOT // survive a clear: the next entry is hand-typed, and a sticky vat_settlement // would tag it into the moms series and skip the manual-entry underlag // tracking. Posted entries are immutable, so that mistag is storno-only. const handleClearAll = () => { setDescription('') setNotes('') setUploadedFiles([]) setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }]) setHeaderDims({}) setEntryCurrency('SEK') setExchangeRate('') setForeignAmount('') if (!embedded && !editEntryId) { const base = sourceType ?? 'manual' if (base !== effectiveSourceType) { setEffectiveSourceType(base) effectiveSourceTypeRef.current = base applySeriesForSourceType(base) } } } const handleOpenCreateAccount = (lineIndex: number, prefill: string) => { setCreatingAccountForLine(lineIndex) setCreateAccountPrefill(prefill) } // After a new account is created, refresh the chart, auto-select it on the // line that initiated the create, and close the dialog. All other form // state is preserved: we never navigate away from the form. // // Only the number is required: the dialog also reaches here after // reactivating an existing account, where the rest of the row is whatever // the company already had stored and is picked up by fetchAccounts. const handleAccountCreated = async (account: { account_number: string }) => { await fetchAccounts() if (creatingAccountForLine != null) { updateLine(creatingAccountForLine, 'account_number', account.account_number) } setCreatingAccountForLine(null) setCreateAccountPrefill('') } const handleReview = () => { if (!selectedPeriod || !description || !isBalanced || periodMismatch) return const hasDocuments = uploadedFiles.some((f) => f.status === 'uploaded') if (!embedded && !bare && !hasDocuments) { setShowNoDocWarning(true) return } setShowReview(true) } // Whether an Enter should open the review: mirrors the review button's // enable gate exactly, so Enter never submits something the button wouldn't. const canSubmitReview = () => isBalanced && !!description && !!selectedPeriod && !periodMismatch && !isUploading && canWrite && !isSubmitting && !isSavingDraft // Enter anywhere in the form = "Granska & skapa": opens the review exactly as // the button does, from any field. Navigation is Tab's job. Two Enter // exceptions stay intact: the account combobox (it calls preventDefault to // select the highlighted account (we skip when defaultPrevented) and the // internal-note textarea (newlines). The inline review owns its own Enter. const handleFormKeyDown = (e: React.KeyboardEvent) => { if (e.key !== 'Enter') return if (e.defaultPrevented || showReview) return if ((e.target as HTMLElement).tagName === 'TEXTAREA') return e.preventDefault() if (canSubmitReview()) handleReview() } // Enter-to-advance inside the konteringsrader: konto → debet → kredit → // nästa rads konto. Navigation only fires while the entry is NOT // submittable: once the voucher balances, Enter falls through to the // form-level handler above and opens the review instead, so a single Enter // never both moves focus and submits. const handleAmountKeyDown = (index: number, side: 'debit' | 'credit') => (e: React.KeyboardEvent) => { if (e.key !== 'Enter' || canSubmitReview()) return e.preventDefault() // An amount on this side finishes the row (debit clears credit and vice // versa) → jump to the next row's account. An empty debit means the row // books on the credit side → hop across first. if (side === 'debit' && !(parseFloat(lines[index].debit_amount) > 0)) { focusCredit(index) } else { focusAccount(index + 1) } } // Enter in a radbeskrivning continues to that row's amount. const handleLineDescKeyDown = (index: number) => (e: React.KeyboardEvent) => { if (e.key !== 'Enter' || canSubmitReview()) return e.preventDefault() focusDebit(index) } // Enter in the verifikationstext drops into the first row still missing an // account, so the top-to-bottom keyboard flow never needs the mouse. const handleHeaderDescKeyDown = (e: React.KeyboardEvent) => { if (e.key !== 'Enter' || canSubmitReview()) return const idx = lines.findIndex((l) => !l.account_number) if (idx === -1) return e.preventDefault() focusAccount(idx) } // Inner submit: builds payload, POSTs, throws a structured error on failure // (so the activation hook can intercept ACCOUNTS_NOT_IN_CHART). const postJournalEntry = useCallback(async () => { const submittableLines = lines.filter( (l) => l.account_number && (l.debit_amount || l.credit_amount) ) // PRE-PASS: decide which single line carries the entry's FX metadata BEFORE // any line is built, so the answer cannot depend on line order. Deciding // inside the map meant the "already applied" latch only closed once the // loop had reached a hydrated FX line, so an agent-created EUR draft edited // here could end up with TWO lines claiming the same foreign amount; and // the old test (first account starting with '19') picked the SEK leg of a // EUR/SEK växling and dropped the metadata entirely on entries with no 19xx // line at all. lib/bookkeeping/fx-line-slot.ts states the rule. const fxSlot = resolveFxLineSlot( submittableLines.map((l) => ({ account_number: l.account_number, debit_amount: parseFloat(l.debit_amount) || 0, credit_amount: parseFloat(l.credit_amount) || 0, currency: l.currency, })), { entryCurrency, exchangeRate: rate, foreignAmount: computedForeignAmount } ) if (fxSlot.kind === 'unplaceable') { // `journal_entries` has no currency or exchange_rate column: a rate that // is not written onto a line is unrecoverable, and the verifikat would // post looking as if it had always been in SEK. Refuse instead, before // anything is sent, and say which rader are involved. const messageKey = fxSlot.reason === 'ambiguous' ? 'fx_unplaceable_ambiguous' : fxSlot.reason === 'currency_conflict' ? 'fx_unplaceable_currency_conflict' : 'fx_unplaceable_no_carrier' const message = t(messageKey, { currency: entryCurrency, amount: computedForeignAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2 }), rate: rate.toLocaleString('sv-SE', { minimumFractionDigits: 4 }), accounts: fxSlot.accounts.join(', '), lineCurrency: fxSlot.lineCurrency ?? '', }) throw Object.assign(new Error(message), { body: { error: { message } }, status: 400 }) } const entryLines: CreateJournalEntryLineInput[] = submittableLines.map((l, index) => { const base: CreateJournalEntryLineInput = { account_number: l.account_number, debit_amount: parseFloat(l.debit_amount) || 0, credit_amount: parseFloat(l.credit_amount) || 0, line_description: l.line_description || undefined, } if (l.dimensions) { const dims = Object.fromEntries( Object.entries(l.dimensions).filter(([, v]) => typeof v === 'string' && v.trim() !== '') ) if (Object.keys(dims).length > 0) base.dimensions = dims } if (l.tax_code) base.tax_code = l.tax_code if (l.currency) { // The line speaks for itself (hydrated draft, agent-created entry). base.currency = l.currency if (l.amount_in_currency != null) base.amount_in_currency = l.amount_in_currency if (l.exchange_rate != null) base.exchange_rate = l.exchange_rate } else if (fxSlot.kind === 'slot' && fxSlot.index === index) { base.currency = entryCurrency base.amount_in_currency = computedForeignAmount base.exchange_rate = rate } return base }) const baseUrl = submitUrl ?? '/api/bookkeeping/journal-entries' // Edit mode PATCHes the draft in place; create mode POSTs (with ?as_draft // when saving a draft rather than posting). const url = editEntryId ? `${baseUrl}/${editEntryId}` : saveAsDraftRef.current ? `${baseUrl}?as_draft=true` : baseUrl const res = await fetch(url, { method: editEntryId ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fiscal_period_id: selectedPeriod, entry_date: entryDate, description, source_type: effectiveSourceType, source_id: sourceId, voucher_series: voucherSeries || 'A', notes: notes || undefined, lines: entryLines, // Set only when retrying past the booking-time duplicate guard (see // handleBookAnyway). Stripped by schemas that don't declare it, so a // stray value never reaches the manual journal-entry endpoint. ...(forceDuplicateRef.current ?? {}), }), }) return (await throwOnStructuredError(res)) as { data?: { id?: string; voucher_series?: string; voucher_number?: number }; journal_entry_id?: string } }, [lines, rate, entryCurrency, computedForeignAmount, t, submitUrl, editEntryId, selectedPeriod, entryDate, description, effectiveSourceType, sourceId, voucherSeries, notes]) const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } = useSubmitWithAccountActivation(postJournalEntry) // Attach the uploaded underlag to the entry that was just written. BFL 5 kap // 7 § requires the verifikation to reference its underlag and BFL 7 kap // requires that underlag to be archived with it, but the entry is already // committed when this runs: a failed link cannot be rolled back, only // reported. Files that did not attach are KEPT in the upload zone (clearing // them would erase the user's only pointer to the underlag they believed // was filed); the ones that attached are dropped. const linkUploadedDocuments = async ( journalEntryId: string | undefined, ): Promise => { const targets = uploadedFiles .filter((f) => f.status === 'uploaded' && f.id) .map((f) => ({ documentId: f.id as string, fileName: f.fileName })) if (targets.length === 0) { setUploadedFiles([]) return [] } if (!journalEntryId) { // No id came back, so there is nothing to link to: report it as a // failure rather than clearing the files behind a success toast. return targets.map((target) => ({ documentId: target.documentId, fileName: target.fileName ?? null, status: 0, code: null, reason: null, })) } const { failed } = await linkDocuments(targets, journalEntryId) if (failed.length === 0) { setUploadedFiles([]) return [] } const failedIds = new Set(failed.map((f) => f.documentId)) setUploadedFiles((prev) => prev.filter((f) => f.id != null && failedIds.has(f.id))) return failed } const handleConfirm = async () => { setIsSubmitting(true) saveAsDraftRef.current = false try { const result = await runSubmit() const journalEntryId = result.data?.id ?? result.journal_entry_id const voucher = formatVoucher(result.data ?? {}) const linkFailures = await linkUploadedDocuments(journalEntryId) if (linkFailures.length > 0) { // The verifikat exists but its underlag does not: say exactly that. // Never the plain "Verifikation skapad", which would leave the user // believing the receipt is on the books. toast({ title: t('toast_created_missing_docs_title'), description: t('toast_created_missing_docs_description', { voucher, count: linkFailures.length, files: formatFailedDocumentNames(linkFailures), }), variant: 'destructive', action: journalEntryId ? ( router.push(`/bookkeeping/${journalEntryId}`)} > {t('toast_open_entry')} ) : undefined, }) } else { toast({ title: t('toast_created_title'), description: t('toast_created_description', { voucher }), }) } setLastPostedMonth(entryDate.slice(0, 7)) setShowReview(false) setDescription('') setNotes('') setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }]) setHeaderDims({}) setEntryCurrency('SEK') setExchangeRate('') setForeignAmount('') onCreated?.() if (journalEntryId) { onEntryCreated?.(journalEntryId) } } catch (err) { if (err instanceof Error && err.message === 'cancelled') { // User dismissed the activation dialog: no toast needed } else { const anyErr = err as { body?: { error?: { code?: string; details?: { candidate?: BookedDuplicateCandidate } } } status?: number } const candidate = anyErr.body?.error?.details?.candidate if (anyErr.body?.error?.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE' && candidate) { // Soft duplicate guard fired: don't dead-end on a toast that merely // says "book anyway". Open the dialog so the user can review the // existing verifikat or confirm. handleBookAnyway re-submits with // force bound to this candidate. setDuplicateCandidate(candidate) } else { toast({ title: t('toast_create_failed'), description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }), variant: 'destructive', }) } } } finally { setIsSubmitting(false) } } // Retry the booking past the duplicate guard. force is bound to the reviewed // candidate via the ref; cleared afterwards so a later normal submit can't // inherit it. handleConfirm runs its full success path (toast, reset, // onEntryCreated) exactly as a first-try booking would. const handleBookAnyway = async () => { const candidate = duplicateCandidate if (!candidate) return forceDuplicateRef.current = { force: true, // Bind on the voucher id: present on both a sibling-transaction candidate // and a ledger-only voucher candidate (which has no transaction_id). expected_duplicate_journal_entry_id: candidate.journal_entry_id, } setDuplicateCandidate(null) try { await handleConfirm() } finally { forceDuplicateRef.current = null } } const handleSaveDraft = async () => { if (!selectedPeriod || !description || !isBalanced || periodMismatch) return setIsSavingDraft(true) saveAsDraftRef.current = true try { const result = await runSubmit() const journalEntryId = result.data?.id ?? result.journal_entry_id const linkFailures = await linkUploadedDocuments(journalEntryId) if (linkFailures.length > 0) { // Same rule as the posted path: the draft was saved, the underlag was // not attached, and the user is told which files are missing. toast({ title: t('toast_draft_missing_docs_title'), description: t('toast_draft_missing_docs_description', { count: linkFailures.length, files: formatFailedDocumentNames(linkFailures), }), variant: 'destructive', action: journalEntryId ? ( router.push(`/bookkeeping/${journalEntryId}`)} > {t('toast_open_entry')} ) : undefined, }) } else { toast({ title: t('toast_draft_saved_title'), description: t('toast_draft_saved_description'), }) } setDescription('') setNotes('') setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }]) setHeaderDims({}) setEntryCurrency('SEK') setExchangeRate('') setForeignAmount('') onCreated?.() if (journalEntryId) { onEntryCreated?.(journalEntryId) } } catch (err) { if (err instanceof Error && err.message === 'cancelled') { // Activation dialog dismissed: silent } else { const anyErr = err as { body?: unknown; status?: number } toast({ title: t('toast_save_draft_failed'), description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }), variant: 'destructive', }) } } finally { saveAsDraftRef.current = false setIsSavingDraft(false) } } // Edit an existing draft: PATCH in place (postJournalEntry routes to the // editEntryId URL) and keep it a draft. No field reset: the host dialog // closes on success via onUpdated. const handleSaveEdit = async () => { if (!selectedPeriod || !description || !isBalanced || periodMismatch) return setIsSavingDraft(true) try { await runSubmit() toast({ title: t('toast_updated_title'), description: t('toast_updated_description'), }) onUpdated?.() } catch (err) { if (err instanceof Error && err.message === 'cancelled') { // Activation dialog dismissed: silent } else { const anyErr = err as { body?: unknown; status?: number } toast({ title: t('toast_update_failed'), description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }), variant: 'destructive', }) } } finally { setIsSavingDraft(false) } } // Inline review for the modal (bare): swap the form body to a read-only // summary instead of stacking a second dialog over the form dialog. The // no-underlag caveat folds in here so there's a single confirm step. const reviewPanel = (
{ if (e.key === 'Escape' && !isSubmitting) { e.stopPropagation() setShowReview(false) } }} >
{nextVoucherNumber != null ? t('review_title_with_voucher', { voucher: formatVoucher({ voucher_series: voucherSeries, voucher_number: nextVoucherNumber }) }) : t('review_title')}
{(monthChanged || selectedPeriodLocked) && (
{monthChanged && (

{t('review_month_changed', { prev: monthLabel(lastPostedMonth as string), current: monthLabel(entryMonth) })}

)} {selectedPeriodLocked &&

{t('review_period_locked')}

}
)} {uploadedFiles.filter((f) => f.status === 'uploaded').length === 0 && (

{t('no_doc_body')}

)} p.id === selectedPeriod)?.name || ''} entryDate={entryDate} description={description} notes={notes || undefined} voucherSeries={voucherSeries} lines={lines} totalDebit={totalDebit} totalCredit={totalCredit} attachmentCount={uploadedFiles.filter((f) => f.status === 'uploaded').length} showBalanceBadge hideDate={false} />
) const formContent = (
{bare && showReview ? reviewPanel : ( <> {/* Verifikat metadata: compact bar on top (Fortnox-style). Date, series and period are pre-filled; the period derives from the date. The konteringsrader below are the focus. */}
{!(embedded && initialDate) && (
setEntryDate(e.target.value)} className="mt-1 h-8" />
)}
setDescription(e.target.value)} onKeyDown={handleHeaderDescKeyDown} placeholder={t('description_placeholder')} className="mt-1 h-8" />
{!embedded && (
{ const v = e.target.value.toUpperCase().replace(/[^A-Z]/g, '').slice(-1) setVoucherSeries(v) }} onFocus={(e) => { const target = e.target setTimeout(() => target.select(), 0) }} onBlur={() => { if (!voucherSeries) setVoucherSeries('A') }} className="mt-1 h-8 text-center font-mono" maxLength={1} />
)}
{embedded ? (
) : ( selectedPeriodObj && ( {t('fiscal_year')}:{' '} {selectedPeriodObj.name} {nextVoucherNumber != null && ( {voucherSeries}{nextVoucherNumber} )} ) )}
{!embedded && !showNotes && !notes && ( )} {dimensionsEnabled && !showDims && ( )}
{isForeign && (
setExchangeRate(e.target.value)} placeholder="0,0000" className="h-8 pr-8" step="0.0001" min="0" /> {isFetchingRate && ( )}
0 && !parsedForeignInput ? computedForeignAmount.toFixed(2) : '')} onChange={(e) => setForeignAmount(e.target.value)} placeholder="0,00" className="mt-1 h-8" step="0.01" min="0" />
{rate > 0 && computedForeignAmount > 0 && (

{computedForeignAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {entryCurrency} × {rate.toLocaleString('sv-SE', { minimumFractionDigits: 4 })} = {computedSekAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK

)}
)} {!embedded && (showNotes || notes) && (