feat(bookkeeping): journal-entry form UX — persistent tabs, focus-advance, opt-in balance fill, period guards (#650)

Captures working-tree changes to the journal-entry form:
- Persist form state across tab switches: forceMount the three bookkeeping tabs (page.tsx) + hide inactive forceMounted panels in the Tabs primitive (tabs.tsx; no-op for non-forceMount tabs).
- AccountCombobox: new onCommit callback fires on a definitive account selection (dropdown pick or full 4-digit entry); the form uses it to auto-advance focus to the debit field (mobile + desktop layouts via refs + visibility check).
- Replace the surprising auto-fill-balancing-amount on account select with opt-in double-click on a debit/credit field (handleFillBalance) — the prior behaviour misfired when splitting across lines.
- Keep exactly one trailing blank row (StrictMode-safe idempotent effect).
- Review-step safety warnings: different-month-than-last-voucher and closed/locked-period notices (paired sv/en strings) — guards against posting to the wrong month/period.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-03 18:02:34 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent dfd87dd294
commit 05651e6402
6 changed files with 145 additions and 23 deletions
+3 -3
View File
@@ -162,12 +162,12 @@ export default function BookkeepingPage() {
<TabsTrigger value="accounts">{t('tab_accounts')}</TabsTrigger>
</TabsList>
<TabsContent value="journal" className="space-y-4">
<TabsContent value="journal" forceMount className="space-y-4">
<FiscalYearSelector value={periodId} onChange={setPeriodId} />
<JournalEntryList key={`${refreshKey}-${periodId ?? 'all'}`} periodId={periodId ?? undefined} />
</TabsContent>
<TabsContent value="new-entry">
<TabsContent value="new-entry" forceMount>
{isLoadingCopy ? (
<div className="flex items-center gap-2 py-12 justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -202,7 +202,7 @@ export default function BookkeepingPage() {
)}
</TabsContent>
<TabsContent value="accounts">
<TabsContent value="accounts" forceMount>
<ChartOfAccountsManager />
</TabsContent>
</Tabs>
+13 -3
View File
@@ -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)
+115 -15
View File
@@ -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<FiscalPeriod[]>([])
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<string | null>(null)
// Per-account saldo as of entryDate, keyed by account_number.
// undefined = not fetched, null = fetch in flight.
const [accountBalances, setAccountBalances] = useState<Record<string, number | null>>({})
@@ -112,6 +117,11 @@ export default function JournalEntryForm({
// user typed in the combobox so we can prefill the dialog.
const [creatingAccountForLine, setCreatingAccountForLine] = useState<number | null>(null)
const [createAccountPrefill, setCreateAccountPrefill] = useState<string>('')
// 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)}
/>
</div>
@@ -774,9 +846,12 @@ export default function JournalEntryForm({
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{t('col_debit')}</Label>
<Input
ref={(el) => { 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({
</td>
<td className="py-1.5 px-1">
<Input
ref={(el) => { 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}
/>
</div>
<p className="mt-1.5 text-xs text-muted-foreground">{t('fill_balance_hint')}</p>
</div>
{/* Document attachments */}
@@ -1055,6 +1139,22 @@ export default function JournalEntryForm({
}
warningText={embedded ? '' : t('review_warning')}
>
{(monthChanged || selectedPeriodLocked) && (
<div className="mb-4 flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3">
<AlertTriangle className="h-5 w-5 text-warning-foreground mt-0.5 shrink-0" />
<div className="flex-1 text-sm text-warning-foreground space-y-0.5">
{monthChanged && (
<p className="font-medium">
{t('review_month_changed', {
prev: monthLabel(lastPostedMonth as string),
current: monthLabel(entryMonth),
})}
</p>
)}
{selectedPeriodLocked && <p>{t('review_period_locked')}</p>}
</div>
</div>
)}
<JournalEntryReviewContent
periodName={periods.find((p) => p.id === selectedPeriod)?.name || ''}
entryDate={entryDate}
+4
View File
@@ -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}
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",