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:
co-authored by
Claude Opus 4.8
parent
dfd87dd294
commit
05651e6402
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user