perf(bookkeeping): booking dialogs render populated on open from the session cache (#1935)
The bookkeeping dialogs were the customer's "fields load late" in its purest form: Bokför (TransactionBookingDialog + the embedded JournalEntryForm) issued five requests on every open (fiscal periods, accounts, settings, cash accounts, then the voucher preview once the first two had landed), Nytt verifikat the same minus one, BookDirectlyDialog four, and the template dialogs two. Each Radix dialog unmounts on close, so every reopen paid the full price again, and several fields visibly flipped: the bank line seeded '1930' then rewrote itself, the series defaulted to 'A' until settings arrived, the period select was empty. All of them now read lib/reference-data (seeded by the dashboard layout): - JournalEntryForm: periods, accounts and settings from the hooks; dimensionsEnabled derived, not fetched; the voucher-number preview is keyed on the entry date (the route resolves the period from it) so it fires as soon as the series is known instead of after the period fetch; after activating accounts it invalidates the shared accounts cache; the create-period dialog callback invalidates the periods cache. - TransactionBookingDialog: settlement account and its name derived with useMemo from the cached cash accounts; the form mounts on the first paint. - BookDirectlyDialog: cash accounts, periods and accounts from the hooks; the '1930'-then-rewrite disappears because the resolved account is known on the first render. - TemplateBookDialog, BookingTemplatePicker, TemplatePicker: templates (and periods) from the hooks. - BookingTemplatesPanel (delete, import) and CreatePeriodDialog (create) invalidate the corresponding cache entries so every picker sees the change at once. - fetchers.ts: booking templates are booking_templates rows (BookingTemplateLibrary), not the static BookingTemplate shape. Per open: Bokför 5 requests -> 0 blocking (voucher preview is a non-blocking hint), Nytt verifikat 5 -> 1 non-blocking, BookDirectly 4 -> 0, Mall 2 -> 0, template pickers 1 -> 0. raw-reference-fetch ratchet: 51 -> 46 files. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
ec9cab24cc
commit
567fae654c
@@ -25,6 +25,7 @@ import { convertLibraryToBookingTemplate, LIBRARY_TEMPLATE_PREFIX, isLibraryTemp
|
||||
import { getAccountName } from '@/lib/bookkeeping/client-account-names'
|
||||
import type { BookingTemplateLibrary, EntityType } from '@/types'
|
||||
import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { useBookingTemplates } from '@/lib/reference-data/hooks'
|
||||
|
||||
// Cap on the "Konton" search-result group: enough to cover sibling accounts
|
||||
// on a number-prefix query without drowning the template results.
|
||||
@@ -264,7 +265,16 @@ export default function TemplatePicker({
|
||||
const t = useTranslations('tx_template_picker')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [libraryRaw, setLibraryRaw] = useState<BookingTemplateLibrary[]>([])
|
||||
// The user's library templates (company + team scope), session-cached
|
||||
// (lib/reference-data). Kept in their raw shape so every template renders,
|
||||
// even ones that don't fit convertLibraryToBookingTemplate's simple
|
||||
// 2-account contract: those get routed through the manual booking dialog
|
||||
// instead of the QuickReview single-account path.
|
||||
const { templates: libraryTemplates } = useBookingTemplates()
|
||||
const libraryRaw = useMemo(
|
||||
() => libraryTemplates.filter((tt) => !tt.is_system && tt.is_active),
|
||||
[libraryTemplates],
|
||||
)
|
||||
const [chartAccounts, setChartAccounts] = useState<ChartAccountLike[]>([])
|
||||
// Boolean gate rather than the callback itself: the parent recreates the
|
||||
// handler every render, and depending on its identity would refetch the
|
||||
@@ -276,27 +286,6 @@ export default function TemplatePicker({
|
||||
// and users know what they made).
|
||||
const templateDirection = direction === 'income' ? 'income' : 'expense'
|
||||
|
||||
// Fetch the user's library templates (company + team scope). We keep them
|
||||
// in their raw shape so we can render every template, even ones that don't
|
||||
// fit convertLibraryToBookingTemplate's simple 2-account contract: those
|
||||
// get routed through the manual booking dialog instead of the QuickReview
|
||||
// single-account path.
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings/booking-templates', { signal: controller.signal })
|
||||
if (!res.ok) return
|
||||
const { data } = await res.json() as { data?: BookingTemplateLibrary[] }
|
||||
if (!data) return
|
||||
setLibraryRaw(data.filter((tt) => !tt.is_system && tt.is_active))
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
}
|
||||
})()
|
||||
return () => { controller.abort() }
|
||||
}, [])
|
||||
|
||||
// Fetch the company's active chart so the search field can surface real
|
||||
// accounts (issue #1877: an active konto like 5460 was unfindable because
|
||||
// only template metadata was searched). The endpoint returns active
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -18,9 +18,10 @@ import { resolveSekAmount, buildCurrencyMetadata } from '@/lib/bookkeeping/curre
|
||||
import { applyTemplate } from '@/lib/bookkeeping/template-library'
|
||||
import { proposalLinesToFormLines } from '@/lib/bookkeeping/proposal-lines'
|
||||
import type { ProposalLine } from '@/lib/bookkeeping/proposal-lines'
|
||||
import type { BookingTemplateLibrary, CashAccount } from '@/types'
|
||||
import type { BookingTemplateLibrary } from '@/types'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
|
||||
import { useCashAccounts } from '@/lib/reference-data/hooks'
|
||||
|
||||
interface TransactionBookingDialogProps {
|
||||
open: boolean
|
||||
@@ -136,39 +137,26 @@ export default function TransactionBookingDialog({
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [pickedInboxDocs, setPickedInboxDocs] = useState<AvailableInboxDoc[]>([])
|
||||
const [inboxPickerOpen, setInboxPickerOpen] = useState(false)
|
||||
const [bankAccount, setBankAccount] = useState<string | null>(null)
|
||||
const [bankAccountName, setBankAccountName] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !transaction) return
|
||||
setBankAccount(null)
|
||||
setBankAccountName(null)
|
||||
let cancelled = false
|
||||
fetch('/api/cash-accounts')
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`cash-accounts fetch failed: ${r.status}`)
|
||||
return r.json()
|
||||
})
|
||||
.then((json) => {
|
||||
if (cancelled) return
|
||||
const accounts = (json.data ?? []) as CashAccount[]
|
||||
const { account } = resolveAccount(
|
||||
accounts,
|
||||
transaction.cash_account_id ?? null,
|
||||
transaction.currency ?? 'SEK',
|
||||
)
|
||||
// Use the matched account's own name instead of a generic label.
|
||||
const matched =
|
||||
accounts.find((a) => a.id === transaction.cash_account_id) ??
|
||||
accounts.find((a) => a.ledger_account === account)
|
||||
setBankAccount(account)
|
||||
setBankAccountName(matched?.name ?? null)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setBankAccount('1930')
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [open, transaction?.id])
|
||||
// Settlement account for the bank line: resolved from the session-cached
|
||||
// cash accounts (seeded by the dashboard layout), so the form below mounts
|
||||
// on the first paint instead of after a /api/cash-accounts round trip on
|
||||
// every open. null only while the list is genuinely still loading.
|
||||
const { cashAccounts, isLoading: cashAccountsLoading } = useCashAccounts()
|
||||
const { bankAccount, bankAccountName } = useMemo(() => {
|
||||
if (!transaction || cashAccountsLoading) {
|
||||
return { bankAccount: null as string | null, bankAccountName: null as string | null }
|
||||
}
|
||||
const { account } = resolveAccount(
|
||||
cashAccounts,
|
||||
transaction.cash_account_id ?? null,
|
||||
transaction.currency ?? 'SEK',
|
||||
)
|
||||
// Use the matched account's own name instead of a generic label.
|
||||
const matched =
|
||||
cashAccounts.find((a) => a.id === transaction.cash_account_id) ??
|
||||
cashAccounts.find((a) => a.ledger_account === account)
|
||||
return { bankAccount: account, bankAccountName: matched?.name ?? null }
|
||||
}, [transaction, cashAccounts, cashAccountsLoading])
|
||||
|
||||
if (!transaction) return null
|
||||
|
||||
|
||||
Reference in New Issue
Block a user