feat(bookkeeping): verifikationsserie dropdown, wider Ny verifikat modal, balancing amount on focus (#2120)
Three fixes to the Ny verifikation modal. 1. Verifikationsserie is a closed dropdown, not a one-letter free-text field. Ships Fortnox's table verbatim from their Systemdokumentation (A Redovisning, B Kundfakturor, C Inbetalningar från kunder, D Leverantörsfakturor, E Utbetalningar till leverantörer, F Kassa, G Avskrivning, H Periodisering, I Bokslut, J Revisor, K Lön, L Kontantfaktura, M Momsrapport). A is deliberately Redovisning, not Kundfakturor: it is the general series manual entries land in, and migration 20260526120700 ships every source_type defaulting to 'A', so every existing company's A series already holds everything. Any letter the company already configured is appended so no existing value can fall out of the picker. 2. The modal goes from sm:max-w-3xl to sm:max-w-5xl lg:max-w-6xl. 3. Tabbing or clicking into an untouched amount proposes the outstanding difference, pre-selected so typing replaces it. Only fires when the row has an account, both amounts are empty, and the difference belongs on that side. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DnoyZCbfm2geRbwtUpi6XN
This commit is contained in:
co-authored by
Claude Opus 5
parent
0406e628e1
commit
e5fba9471e
@@ -45,7 +45,7 @@ import {
|
||||
} 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 { formatVoucher, resolveDefaultSeriesForSource, VOUCHER_SERIES_PRESETS } 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'
|
||||
@@ -193,6 +193,11 @@ export default function JournalEntryForm({
|
||||
// effect below.
|
||||
const seriesMapRef = useRef<Record<string, string> | null>(null)
|
||||
const defaultSeriesRef = useRef<string>('A')
|
||||
// Series letters this company has configured beyond the fixed presets. The
|
||||
// dropdown is a closed list, so anything already in use (a legacy letter, an
|
||||
// override in the per-source-type map) has to stay selectable: otherwise the
|
||||
// Select would render blank on a value it does not offer.
|
||||
const [configuredSeries, setConfiguredSeries] = useState<string[]>([])
|
||||
// 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
|
||||
@@ -274,6 +279,18 @@ export default function JournalEntryForm({
|
||||
setVoucherSeries(perSource !== 'A' ? perSource : defaultSeriesRef.current || 'A')
|
||||
}, [])
|
||||
|
||||
// The series picker: the fixed Swedish presets first, then any letter this
|
||||
// company already uses (settings map, global default, or the series a draft
|
||||
// was saved with) so no existing value falls out of the list.
|
||||
const seriesOptions = useMemo(() => {
|
||||
const options = VOUCHER_SERIES_PRESETS.map((p) => ({ letter: p.letter, label: p.label }))
|
||||
const seen = new Set(options.map((o) => o.letter))
|
||||
const extras = [...configuredSeries, voucherSeries]
|
||||
.filter((letter) => /^[A-Z]$/.test(letter) && !seen.has(letter) && seen.add(letter))
|
||||
.sort()
|
||||
return [...options, ...extras.map((letter) => ({ letter, label: '' }))]
|
||||
}, [configuredSeries, voucherSeries])
|
||||
|
||||
useEffect(() => {
|
||||
loadBasCatalog().then(setCatalog).catch(() => {/* search degrades to the active chart */})
|
||||
}, [])
|
||||
@@ -289,6 +306,16 @@ export default function JournalEntryForm({
|
||||
seriesMapRef.current =
|
||||
(companySettings.default_voucher_series_per_source_type as Record<string, string> | null) ?? null
|
||||
defaultSeriesRef.current = companySettings.default_voucher_series || 'A'
|
||||
setConfiguredSeries(
|
||||
Array.from(
|
||||
new Set(
|
||||
[
|
||||
...Object.values(seriesMapRef.current || {}),
|
||||
defaultSeriesRef.current,
|
||||
].filter((v): v is string => typeof v === 'string' && /^[A-Z]$/.test(v)),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (!embedded && !editEntryId) {
|
||||
applySeriesForSourceType(effectiveSourceTypeRef.current)
|
||||
}
|
||||
@@ -622,6 +649,28 @@ export default function JournalEntryForm({
|
||||
updateLine(index, side === 'debit' ? 'debit_amount' : 'credit_amount', fill.toFixed(2))
|
||||
}
|
||||
|
||||
// Tabbing (or clicking) into an untouched amount proposes the outstanding
|
||||
// difference, so the closing row of a moms-split voucher fills itself. The
|
||||
// proposal is pre-selected: typing replaces it, which keeps a multi-row split
|
||||
// exactly as fast as before. Guards: the row must already have an account (so
|
||||
// you can tab through the trailing blank row), both amounts must still be
|
||||
// empty (never overwrite a typed figure), and the difference must belong on
|
||||
// this side.
|
||||
const handleAmountFocus =
|
||||
(index: number, side: 'debit' | 'credit') =>
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
const target = e.currentTarget
|
||||
const line = lines[index]
|
||||
if (!line || !line.account_number) return
|
||||
if (line.debit_amount || line.credit_amount) return
|
||||
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))
|
||||
// Select after the controlled re-render has written the value.
|
||||
requestAnimationFrame(() => target.select())
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -1362,24 +1411,26 @@ export default function JournalEntryForm({
|
||||
/>
|
||||
</div>
|
||||
{!embedded && (
|
||||
<div className="w-16">
|
||||
// Closed list, not free text: the letters carry fixed meanings
|
||||
// (A = redovisning, B = kundfakturor, ...) and a typo here silently
|
||||
// starts a new series with its own number sequence.
|
||||
<div className="w-full sm:w-72">
|
||||
<Label className="text-xs text-muted-foreground">{t('series')}</Label>
|
||||
<Input
|
||||
value={voucherSeries}
|
||||
onChange={(e) => {
|
||||
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}
|
||||
/>
|
||||
<Select value={voucherSeries} onValueChange={(v) => setVoucherSeries(v)}>
|
||||
<SelectTrigger className="mt-1 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{seriesOptions.map((option) => (
|
||||
<SelectItem key={option.letter} value={option.letter}>
|
||||
<span className="font-mono">{option.letter}</span>
|
||||
{option.label && (
|
||||
<span className="ml-2 text-muted-foreground">{option.label}</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1594,6 +1645,7 @@ export default function JournalEntryForm({
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
|
||||
onKeyDown={handleAmountKeyDown(index, 'debit')}
|
||||
onFocus={handleAmountFocus(index, 'debit')}
|
||||
onDoubleClick={() => handleFillBalance(index, 'debit')}
|
||||
title={t('fill_balance_tooltip')}
|
||||
placeholder="0,00"
|
||||
@@ -1611,6 +1663,7 @@ export default function JournalEntryForm({
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
|
||||
onKeyDown={handleAmountKeyDown(index, 'credit')}
|
||||
onFocus={handleAmountFocus(index, 'credit')}
|
||||
onDoubleClick={() => handleFillBalance(index, 'credit')}
|
||||
title={t('fill_balance_tooltip')}
|
||||
placeholder="0,00"
|
||||
@@ -1748,6 +1801,7 @@ export default function JournalEntryForm({
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
|
||||
onKeyDown={handleAmountKeyDown(index, 'debit')}
|
||||
onFocus={handleAmountFocus(index, 'debit')}
|
||||
onDoubleClick={() => handleFillBalance(index, 'debit')}
|
||||
title={t('fill_balance_tooltip')}
|
||||
placeholder="0,00"
|
||||
@@ -1764,6 +1818,7 @@ export default function JournalEntryForm({
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
|
||||
onKeyDown={handleAmountKeyDown(index, 'credit')}
|
||||
onFocus={handleAmountFocus(index, 'credit')}
|
||||
onDoubleClick={() => handleFillBalance(index, 'credit')}
|
||||
title={t('fill_balance_tooltip')}
|
||||
placeholder="0,00"
|
||||
|
||||
@@ -64,7 +64,11 @@ export default function NewJournalEntryDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
||||
// Wide: the konteringsrader table (konto, radtext, debet, kredit,
|
||||
// saldo) is the point of this dialog, and at 3xl the amount columns
|
||||
// were squeezed against the account search. Capped at 6xl so it stays
|
||||
// a dialog on an ultrawide screen.
|
||||
className="sm:max-w-5xl lg:max-w-6xl max-h-[95dvh] sm:max-h-[92vh] overflow-y-auto"
|
||||
// A half-typed verifikat must survive an accidental backdrop click or a
|
||||
// stray Escape (easy to hit across multiple windows/screens, or when you
|
||||
// only meant to dismiss a combobox dropdown). Closing is explicit: the
|
||||
|
||||
Reference in New Issue
Block a user