diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index 02846d85..384e7958 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -162,12 +162,12 @@ export default function BookkeepingPage() { {t('tab_accounts')} - + - + {isLoadingCopy ? (
@@ -202,7 +202,7 @@ export default function BookkeepingPage() { )} - + diff --git a/components/bookkeeping/AccountCombobox.tsx b/components/bookkeeping/AccountCombobox.tsx index a0ae2296..d0899e0a 100644 --- a/components/bookkeeping/AccountCombobox.tsx +++ b/components/bookkeeping/AccountCombobox.tsx @@ -10,6 +10,11 @@ interface AccountComboboxProps { value: string accounts: BASAccount[] onChange: (accountNumber: string) => void + // Fired when the user definitively commits an account: selecting from the + // dropdown (Enter or click) or typing a full 4-digit number. Distinct from + // onChange, which also fires on intermediate edits. Callers use this to + // auto-advance focus (e.g. to the amount field). + onCommit?: (accountNumber: string) => void // When provided, an inline "Skapa nytt konto" affordance appears in the // dropdown's empty state. The current search string is passed so the caller // can prefill the create dialog. @@ -21,7 +26,7 @@ interface AccountComboboxProps { const MAX_RESULTS = 50 -export default function AccountCombobox({ value, accounts, onChange, onCreateAccount, className }: AccountComboboxProps) { +export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, className }: AccountComboboxProps) { const [search, setSearch] = useState(value) const [isOpen, setIsOpen] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState(0) @@ -112,8 +117,9 @@ export default function AccountCombobox({ value, accounts, onChange, onCreateAcc onChange(accountNumber) setSearch(accountNumber) setIsOpen(false) + onCommit?.(accountNumber) }, - [onChange] + [onChange, onCommit] ) const handleKeyDown = (e: React.KeyboardEvent) => { @@ -152,9 +158,13 @@ export default function AccountCombobox({ value, accounts, onChange, onCreateAcc setSearch(newValue) // Emit any 4-digit numeric value to the parent. Unknown BAS numbers are // accepted optimistically — the submit-time ActivateAccountsDialog lets - // the user activate missing accounts without leaving the form. + // the user activate missing accounts without leaving the form. A complete + // 4-digit number is treated as a commit so focus can advance to the amount. if (/^\d{4}$/.test(newValue)) { onChange(newValue) + // Only treat as a commit when the value newly becomes this account, so + // editing an already-committed number doesn't keep stealing focus. + if (newValue !== value) onCommit?.(newValue) } if (!isOpen) { setIsOpen(true) diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index cb0ed0f5..c95db446 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { useTranslations } from 'next-intl' +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' @@ -82,6 +82,7 @@ export default function JournalEntryForm({ 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]) @@ -105,6 +106,10 @@ export default function JournalEntryForm({ const [foreignAmount, setForeignAmount] = useState('') const [periodMismatch, setPeriodMismatch] = useState<'no_period' | 'wrong_period' | null>(null) const [showCreatePeriod, setShowCreatePeriod] = 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>({}) @@ -112,6 +117,11 @@ export default function JournalEntryForm({ // 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' @@ -321,30 +331,72 @@ export default function JournalEntryForm({ updated[index].debit_amount = '' } - // Auto-fill line description from account name when selecting an account + // 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 } - - // Auto-fill balancing amount when both amount fields are empty - if (!updated[index].debit_amount && !updated[index].credit_amount) { - const otherLines = updated.filter((_, i) => i !== index) - const otherDebit = otherLines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0) - const otherCredit = otherLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0) - const diff = Math.round((otherCredit - otherDebit) * 100) / 100 - if (diff > 0) { - updated[index].debit_amount = diff.toFixed(2) - } else if (diff < 0) { - updated[index].credit_amount = Math.abs(diff).toFixed(2) - } - } } 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. @@ -382,6 +434,24 @@ export default function JournalEntryForm({ ? 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) @@ -498,6 +568,7 @@ export default function JournalEntryForm({ title: t('toast_created_title'), description: t('toast_created_description', { voucher: formatVoucher(result.data ?? {}) }), }) + setLastPostedMonth(entryDate.slice(0, 7)) setShowReview(false) setDescription('') setNotes('') @@ -752,6 +823,7 @@ export default function JournalEntryForm({ value={line.account_number} accounts={accounts} onChange={(num) => updateLine(index, 'account_number', num)} + onCommit={() => focusDebit(index)} onCreateAccount={(prefill) => handleOpenCreateAccount(index, prefill)} />
@@ -774,9 +846,12 @@ export default function JournalEntryForm({
{ mobileDebitRefs.current[index] = el }} type="number" value={line.debit_amount} onChange={(e) => updateLine(index, 'debit_amount', e.target.value)} + onDoubleClick={() => handleFillBalance(index, 'debit')} + title={t('fill_balance_tooltip')} placeholder="0,00" className="text-right" inputMode="decimal" @@ -790,6 +865,8 @@ export default function JournalEntryForm({ type="number" value={line.credit_amount} onChange={(e) => updateLine(index, 'credit_amount', e.target.value)} + onDoubleClick={() => handleFillBalance(index, 'credit')} + title={t('fill_balance_tooltip')} placeholder="0,00" className="text-right" inputMode="decimal" @@ -863,6 +940,7 @@ export default function JournalEntryForm({ value={line.account_number} accounts={accounts} onChange={(num) => updateLine(index, 'account_number', num)} + onCommit={() => focusDebit(index)} onCreateAccount={(prefill) => handleOpenCreateAccount(index, prefill)} className="h-8" /> @@ -877,9 +955,12 @@ export default function JournalEntryForm({ { desktopDebitRefs.current[index] = el }} type="number" value={line.debit_amount} onChange={(e) => updateLine(index, 'debit_amount', e.target.value)} + onDoubleClick={() => handleFillBalance(index, 'debit')} + title={t('fill_balance_tooltip')} placeholder="0,00" className="text-right h-8" inputMode="decimal" @@ -892,6 +973,8 @@ export default function JournalEntryForm({ type="number" value={line.credit_amount} onChange={(e) => updateLine(index, 'credit_amount', e.target.value)} + onDoubleClick={() => handleFillBalance(index, 'credit')} + title={t('fill_balance_tooltip')} placeholder="0,00" className="text-right h-8" inputMode="decimal" @@ -962,6 +1045,7 @@ export default function JournalEntryForm({ entityType={company?.entity_type} />
+

{t('fill_balance_hint')}

{/* Document attachments */} @@ -1055,6 +1139,22 @@ export default function JournalEntryForm({ } warningText={embedded ? '' : t('review_warning')} > + {(monthChanged || selectedPeriodLocked) && ( +
+ +
+ {monthChanged && ( +

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

+ )} + {selectedPeriodLocked &&

{t('review_period_locked')}

} +
+
+ )} p.id === selectedPeriod)?.name || ''} entryDate={entryDate} diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx index 448706e1..85af7056 100644 --- a/components/ui/tabs.tsx +++ b/components/ui/tabs.tsx @@ -44,6 +44,10 @@ const TabsContent = React.forwardRef< ref={ref} className={cn( "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + // When a consumer passes `forceMount`, Radix keeps inactive panels in the + // DOM (present is always true) but does NOT hide them itself — hide them + // here. No-op for non-forceMount tabs, which unmount inactive content. + "data-[state=inactive]:hidden", className )} {...props} diff --git a/messages/en.json b/messages/en.json index dc6fab82..3b63b2b0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3040,7 +3040,11 @@ "toast_draft_saved_description": "The draft can be posted from the bookkeeping page.", "toast_save_draft_failed": "Could not save draft", "toast_attach_failed_title": "Documents could not be attached", - "toast_attach_failed_description": "{count} file(s) could not be linked to the journal entry. Try again from the bookkeeping page." + "toast_attach_failed_description": "{count} file(s) could not be linked to the journal entry. Try again from the bookkeeping page.", + "fill_balance_tooltip": "Double-click to fill the balancing amount", + "fill_balance_hint": "Tip: double-click debit or credit to fill the remaining difference.", + "review_month_changed": "Note: different month than the previous voucher ({prev} → {current}).", + "review_period_locked": "This period is closed or locked — posting may be rejected." }, "chart_of_accounts": { "class_1": "Assets", diff --git a/messages/sv.json b/messages/sv.json index 6069a9f6..7f05d50b 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3040,7 +3040,11 @@ "toast_draft_saved_description": "Utkastet kan bokföras från bokföringssidan.", "toast_save_draft_failed": "Kunde inte spara utkast", "toast_attach_failed_title": "Underlag kunde inte bifogas", - "toast_attach_failed_description": "{count} fil(er) kunde inte länkas till verifikationen. Försök igen via bokföringssidan." + "toast_attach_failed_description": "{count} fil(er) kunde inte länkas till verifikationen. Försök igen via bokföringssidan.", + "fill_balance_tooltip": "Dubbelklicka för att fylla i balanserande belopp", + "fill_balance_hint": "Tips: dubbelklicka på debet eller kredit för att fylla i differensen.", + "review_month_changed": "Obs: annan månad än föregående verifikat ({prev} → {current}).", + "review_period_locked": "Perioden är stängd eller låst — bokföring kan nekas." }, "chart_of_accounts": { "class_1": "Tillgångar",