'use client' import { useState, useEffect, useCallback, useMemo, useRef } from 'react' 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 { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus, Eraser } from 'lucide-react' 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 BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker' import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog' import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' import DuplicateBookingDialog 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 { formatCurrency } from '@/lib/utils' import { formatVoucher, resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver' 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 } 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 } 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 /** Fired after a successful draft edit (editEntryId path). */ onUpdated?: () => 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, onUpdated, }: Props) { const { canWrite } = useCanWrite() const { toast } = useToast() const { company } = useCompany() const t = useTranslations('journal_form') 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) const [lines, setLines] = useState( initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }] ) const [voucherSeries, setVoucherSeries] = useState(initialVoucherSeries ?? 'A') 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([]) const [entryCurrency, setEntryCurrency] = useState('SEK') const [exchangeRate, setExchangeRate] = useState('') const [isFetchingRate, setIsFetchingRate] = useState(false) const [foreignAmount, setForeignAmount] = useState('') const [periodMismatch, setPeriodMismatch] = useState<'no_period' | 'wrong_period' | null>(null) const [showCreatePeriod, setShowCreatePeriod] = useState(false) const [showClearConfirm, setShowClearConfirm] = 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 debit inputs so we can auto-advance focus there once an // account is committed on a row. Two layouts render simultaneously (mobile // cards + desktop table); we focus whichever one is actually visible. const desktopDebitRefs = useRef<(HTMLInputElement | null)[]>([]) const mobileDebitRefs = useRef<(HTMLInputElement | 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 || []) } useEffect(() => { fetchPeriods() fetchAccounts() // Fetch default voucher series from company settings — 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. if (!embedded && !editEntryId) { fetch('/api/settings').then(r => r.json()).then(({ data }) => { if (!data) return const effectiveSourceType = sourceType ?? 'manual' const perSource = resolveDefaultSeriesForSource( data as { default_voucher_series_per_source_type?: Record | null } | null, effectiveSourceType, ) const fallback = data.default_voucher_series || 'A' setVoucherSeries(perSource !== 'A' ? perSource : fallback) }).catch(() => {/* keep 'A' */}) } }, [embedded, sourceType, editEntryId]) // 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]) useEffect(() => { if (entryCurrency !== 'SEK') { 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. Balances are reference-only ("saldo before this entry") — they ignore // the draft lines the user is typing, by design. 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]) const addLine = () => { setLines([...lines, { ...BLANK_LINE }]) } const removeLine = (index: number) => { if (lines.length <= 2) return setLines(lines.filter((_, i) => i !== index)) } 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) { const account = accounts.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 debit 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 focusDebit = useCallback((index: number) => { requestAnimationFrame(() => { const d = desktopDebitRefs.current[index] const m = mobileDebitRefs.current[index] const target = d && d.offsetParent !== null ? d : m && m.offsetParent !== null ? m : (d ?? m) target?.focus() target?.select?.() }) }, []) // 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, { ...BLANK_LINE }] }) }, [lines]) // 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 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) => { setLines(templateLines) if (!description) setDescription(templateDescription) } // 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, series) so the // form is immediately ready for the next entry. const handleClearAll = () => { setDescription('') setNotes('') setUploadedFiles([]) setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }]) setEntryCurrency('SEK') setExchangeRate('') setForeignAmount('') } 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. const handleAccountCreated = async (account: BASAccount) => { 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) } // 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 () => { let currencyMetaApplied = false const entryLines: CreateJournalEntryLineInput[] = lines .filter((l) => l.account_number && (l.debit_amount || l.credit_amount)) .map((l) => { 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.currency) { 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 (isForeign && rate > 0 && l.account_number.startsWith('19') && !currencyMetaApplied) { base.currency = entryCurrency base.amount_in_currency = computedForeignAmount base.exchange_rate = rate currencyMetaApplied = true } 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: sourceType ?? 'manual', 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, isForeign, rate, entryCurrency, computedForeignAmount, submitUrl, editEntryId, selectedPeriod, entryDate, description, sourceType, sourceId, voucherSeries, notes]) const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } = useSubmitWithAccountActivation(postJournalEntry) const handleConfirm = async () => { setIsSubmitting(true) saveAsDraftRef.current = false try { const result = await runSubmit() const journalEntryId = result.data?.id ?? result.journal_entry_id if (journalEntryId && uploadedFiles.length > 0) { const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id) let linkFailCount = 0 for (const file of filesToLink) { try { await fetch(`/api/documents/${file.id}/link`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ journal_entry_id: journalEntryId }), }) } catch { linkFailCount++ } } if (linkFailCount > 0) { toast({ title: t('toast_attach_failed_title'), description: t('toast_attach_failed_description', { count: linkFailCount }), variant: 'destructive', }) } } toast({ title: t('toast_created_title'), description: t('toast_created_description', { voucher: formatVoucher(result.data ?? {}) }), }) setLastPostedMonth(entryDate.slice(0, 7)) setShowReview(false) setDescription('') setNotes('') setUploadedFiles([]) setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }]) 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 if (journalEntryId && uploadedFiles.length > 0) { const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id) for (const file of filesToLink) { try { await fetch(`/api/documents/${file.id}/link`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ journal_entry_id: journalEntryId }), }) } catch { // Non-blocking: user can attach underlag from the draft view } } } toast({ title: t('toast_draft_saved_title'), description: t('toast_draft_saved_description'), }) setDescription('') setNotes('') setUploadedFiles([]) setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }]) 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 = (
{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)} 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 && ( )}
{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) && (