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
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useBookingTemplates } from '@/lib/reference-data/hooks'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { BookOpen, Search, Building2, Users, Globe } from 'lucide-react'
|
||||
import { TEMPLATE_CATEGORY_LABELS, SCOPE_LABELS, getTemplateScope, applyTemplate } from '@/lib/bookkeeping/template-library'
|
||||
import type { BookingTemplateLibrary, BookingTemplateCategory, EntityType } from '@/types'
|
||||
import type { BookingTemplateCategory, EntityType } from '@/types'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
|
||||
interface Props {
|
||||
@@ -33,8 +34,10 @@ const SCOPE_ICONS = {
|
||||
export default function BookingTemplatePicker({ onApply, entityType, defaultAmount }: Props) {
|
||||
const { toast } = useToast()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
// Session-cached (lib/reference-data): the list is there on the first
|
||||
// open and reopening costs no request; writes in the settings panel
|
||||
// invalidate it.
|
||||
const { templates, isLoading, error: templatesError } = useBookingTemplates()
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<BookingTemplateCategory | 'all'>('all')
|
||||
const [amount, setAmount] = useState('')
|
||||
@@ -49,26 +52,9 @@ export default function BookingTemplatePicker({ onApply, entityType, defaultAmou
|
||||
}
|
||||
}, [open, defaultAmount])
|
||||
|
||||
const fetchTemplates = useCallback(async (signal?: AbortSignal) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const r = await fetch('/api/settings/booking-templates', { signal })
|
||||
const { data } = await r.json()
|
||||
setTemplates(data || [])
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const controller = new AbortController()
|
||||
fetchTemplates(controller.signal)
|
||||
return () => { controller.abort() }
|
||||
}, [open, fetchTemplates])
|
||||
if (open && templatesError) toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
|
||||
}, [open, templatesError, toast])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = templates
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Loader2 } from 'lucide-react'
|
||||
import { computeSuggestedPeriod } from '@/lib/bookkeeping/suggest-fiscal-period'
|
||||
import { fiscalPeriodAdvisoryText } from '@/lib/bookkeeping/fiscal-period-warnings'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -108,6 +109,11 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri
|
||||
return
|
||||
}
|
||||
|
||||
// Every picker and form reads periods from the session cache
|
||||
// (lib/reference-data): refresh it now so the new year is selectable
|
||||
// everywhere at once, not only in the caller that gets onCreated.
|
||||
void invalidateReferenceData('ref:fiscal-periods')
|
||||
|
||||
// The 200 may carry non-blocking advisories (today: a prior räkenskapsår
|
||||
// still open, which is the normal state while the bokslut runs). The
|
||||
// period WAS created, so this is information, never a failure: show the
|
||||
|
||||
@@ -26,6 +26,8 @@ import { deriveTemplateLinesFromBooking } from '@/lib/bookkeeping/template-libra
|
||||
import { sourceTypeForTemplateCategory } from '@/lib/bookkeeping/template-source-type'
|
||||
import { TemplateForm } from '@/components/settings/TemplateForm'
|
||||
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
|
||||
import { useAccounts, useCompanySettings, useFiscalPeriods } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
|
||||
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
|
||||
import { splitCreateAccountPrefill } from '@/lib/bookkeeping/create-account-prefill'
|
||||
@@ -48,7 +50,7 @@ import { resolveFxLineSlot } from '@/lib/bookkeeping/fx-line-slot'
|
||||
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency, BookingTemplateLibrary, BookingTemplateCategory } from '@/types'
|
||||
import type { CreateJournalEntryLineInput, FiscalPeriod, JournalEntrySourceType, Currency, BookingTemplateLibrary, BookingTemplateCategory } from '@/types'
|
||||
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
|
||||
|
||||
const CURRENCIES: { value: Currency; label: string }[] = [
|
||||
@@ -150,7 +152,12 @@ export default function JournalEntryForm({
|
||||
// copy from this namespace.
|
||||
const tTpl = useTranslations('settings_booking_templates')
|
||||
const locale = useLocale()
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
// Session-cached reference data (lib/reference-data): seeded by the
|
||||
// dashboard layout, so the period select, the account picker and the
|
||||
// settings-driven defaults render populated on the first paint instead of
|
||||
// after four round trips, and re-opening the dialog costs no requests.
|
||||
const { periods } = useFiscalPeriods()
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('')
|
||||
const [entryDate, setEntryDate] = useState(initialDate ?? new Date().toISOString().split('T')[0])
|
||||
const [description, setDescription] = useState(initialDescription ?? '')
|
||||
@@ -160,7 +167,7 @@ export default function JournalEntryForm({
|
||||
// when company_settings.dimensions_enabled: a UI-visibility gate; lines
|
||||
// that already carry dimensions (e.g. a draft being edited) still round-trip
|
||||
// untouched when the toggle is off.
|
||||
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
|
||||
const dimensionsEnabled = companySettings?.dimensions_enabled === true
|
||||
const [showDims, setShowDims] = useState(false)
|
||||
// Header-level default dims ("gäller alla rader"). The per-row maps on
|
||||
// `lines` are the ONE source of truth: this state only drives the header
|
||||
@@ -209,7 +216,7 @@ export default function JournalEntryForm({
|
||||
// enabled so the attempt can happen; the handlers gate on validity.
|
||||
const [showValidationHints, setShowValidationHints] = useState(false)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const { accounts } = useAccounts()
|
||||
// Full BAS catalogue (static reference data, fetched once per session). Lets
|
||||
// the account picker surface standard accounts the company hasn't activated
|
||||
// yet; picking one activates it at commit via the existing rail.
|
||||
@@ -258,31 +265,6 @@ export default function JournalEntryForm({
|
||||
uploadedFiles.length > 0
|
||||
useUnsavedChanges(hasContent)
|
||||
|
||||
async function fetchPeriods() {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
const { data } = await res.json()
|
||||
const fetched: FiscalPeriod[] = data || []
|
||||
setPeriods(fetched)
|
||||
|
||||
// Auto-select period matching the current entry date
|
||||
const match = fetched.find(
|
||||
(p) => entryDate >= p.period_start && entryDate <= p.period_end
|
||||
)
|
||||
if (match) {
|
||||
setSelectedPeriod(match.id)
|
||||
setPeriodMismatch(null)
|
||||
} else if (fetched.length > 0) {
|
||||
setSelectedPeriod(fetched[0].id)
|
||||
setPeriodMismatch('no_period')
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAccounts() {
|
||||
const res = await fetch('/api/bookkeeping/accounts')
|
||||
const { data } = await res.json()
|
||||
setAccounts(data || [])
|
||||
}
|
||||
|
||||
// Resolve + set the default voucher series for a source type from the cached
|
||||
// company config: prefer the per-source-type mapping, fall back to the legacy
|
||||
// default_voucher_series, then to 'A'. Reads refs (stable), so it can run both
|
||||
@@ -293,28 +275,28 @@ export default function JournalEntryForm({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPeriods()
|
||||
fetchAccounts()
|
||||
loadBasCatalog().then(setCatalog).catch(() => {/* search degrades to the active chart */})
|
||||
// Company settings power two things here: dimensions_enabled gates the
|
||||
// tagging affordances (all modes, incl. the TransactionBookingDialog
|
||||
// embed), and the default voucher series seeds the standalone form:
|
||||
// prefer the per-source-type mapping when present; fall back to the legacy
|
||||
// default_voucher_series, then to 'A'. In edit mode the draft's own series
|
||||
// is pre-filled: never override it from the company defaults.
|
||||
fetch('/api/settings').then(r => r.json()).then(({ data }) => {
|
||||
if (!data) return
|
||||
setDimensionsEnabled(data.dimensions_enabled === true)
|
||||
seriesMapRef.current =
|
||||
(data.default_voucher_series_per_source_type as Record<string, string> | null) ?? null
|
||||
defaultSeriesRef.current = data.default_voucher_series || 'A'
|
||||
if (!embedded && !editEntryId) {
|
||||
applySeriesForSourceType(effectiveSourceTypeRef.current)
|
||||
}
|
||||
}).catch(() => {/* keep 'A' + hidden dimension affordances */})
|
||||
}, [embedded, sourceType, editEntryId, applySeriesForSourceType])
|
||||
}, [])
|
||||
|
||||
// Auto-select period when entry date changes
|
||||
// Company settings seed the default voucher series for the standalone form:
|
||||
// prefer the per-source-type mapping when present; fall back to the legacy
|
||||
// default_voucher_series, then to 'A'. In edit mode the draft's own series
|
||||
// is pre-filled: never override it from the company defaults. (dimensions_
|
||||
// enabled, which gates the tagging affordances in all modes incl. the
|
||||
// TransactionBookingDialog embed, is derived directly above.)
|
||||
useEffect(() => {
|
||||
if (!companySettings) return
|
||||
seriesMapRef.current =
|
||||
(companySettings.default_voucher_series_per_source_type as Record<string, string> | null) ?? null
|
||||
defaultSeriesRef.current = companySettings.default_voucher_series || 'A'
|
||||
if (!embedded && !editEntryId) {
|
||||
applySeriesForSourceType(effectiveSourceTypeRef.current)
|
||||
}
|
||||
}, [companySettings, embedded, sourceType, editEntryId, applySeriesForSourceType])
|
||||
|
||||
// Auto-select the period matching the entry date (on load and whenever the
|
||||
// date changes). With no match, fall back to the newest period only when
|
||||
// nothing is selected yet, and flag the mismatch either way.
|
||||
useEffect(() => {
|
||||
if (periods.length === 0) return
|
||||
const match = periods.find(
|
||||
@@ -324,6 +306,7 @@ export default function JournalEntryForm({
|
||||
setSelectedPeriod(match.id)
|
||||
setPeriodMismatch(null)
|
||||
} else {
|
||||
setSelectedPeriod((current) => current || periods[0].id)
|
||||
setPeriodMismatch('no_period')
|
||||
}
|
||||
}, [entryDate, periods])
|
||||
@@ -349,12 +332,15 @@ export default function JournalEntryForm({
|
||||
// Read-only hint; the actual number is reserved atomically at commit time,
|
||||
// so this may shift by one if another entry lands first.
|
||||
useEffect(() => {
|
||||
if (embedded || !selectedPeriod || !voucherSeries) {
|
||||
if (embedded || !entryDate || !voucherSeries) {
|
||||
setNextVoucherNumber(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const qs = new URLSearchParams({ period_id: selectedPeriod, series: voucherSeries })
|
||||
// Keyed on the entry date rather than the resolved period so the preview
|
||||
// fires as soon as the series is known: the route resolves the period
|
||||
// from the date itself, which is exactly how selectedPeriod is derived.
|
||||
const qs = new URLSearchParams({ date: entryDate, series: voucherSeries })
|
||||
fetch(`/api/bookkeeping/voucher-sequences/next?${qs}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((body) => {
|
||||
@@ -368,7 +354,7 @@ export default function JournalEntryForm({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [embedded, selectedPeriod, voucherSeries])
|
||||
}, [embedded, entryDate, voucherSeries])
|
||||
|
||||
// Fetch exchange rate from Riksbanken when currency changes
|
||||
const fetchRate = useCallback(async (currency: Currency) => {
|
||||
@@ -821,7 +807,7 @@ export default function JournalEntryForm({
|
||||
// reactivating an existing account, where the rest of the row is whatever
|
||||
// the company already had stored and is picked up by fetchAccounts.
|
||||
const handleAccountCreated = async (account: { account_number: string }) => {
|
||||
await fetchAccounts()
|
||||
await invalidateReferenceData('ref:accounts')
|
||||
if (creatingAccountForLine != null) {
|
||||
updateLine(creatingAccountForLine, 'account_number', account.account_number)
|
||||
}
|
||||
@@ -2136,7 +2122,7 @@ export default function JournalEntryForm({
|
||||
onOpenChange={setShowCreatePeriod}
|
||||
entryDate={entryDate}
|
||||
periods={periods}
|
||||
onCreated={fetchPeriods}
|
||||
onCreated={() => void invalidateReferenceData('ref:fiscal-periods')}
|
||||
/>
|
||||
|
||||
{/* Clear-all confirmation */}
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { ArrowLeft, Check, ChevronRight, Loader2, Search } from 'lucide-react'
|
||||
import type { BookingTemplateLibrary, FiscalPeriod } from '@/types'
|
||||
import type { BookingTemplateLibrary } from '@/types'
|
||||
import { useBookingTemplates, useFiscalPeriods } from '@/lib/reference-data/hooks'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
|
||||
interface Props {
|
||||
@@ -49,40 +50,17 @@ export default function TemplateBookDialog({ open, onOpenChange, onCreated }: Pr
|
||||
const t = useTranslations('bookkeeping')
|
||||
const { toast } = useToast()
|
||||
|
||||
const [templates, setTemplates] = useState<BookingTemplateLibrary[] | null>(null)
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
// Session-cached (lib/reference-data): opening the dialog costs no
|
||||
// requests once the lists are in the cache. null = still loading.
|
||||
const { templates: cachedTemplates, isLoading: templatesLoading } = useBookingTemplates()
|
||||
const templates: BookingTemplateLibrary[] | null = templatesLoading ? null : cachedTemplates
|
||||
const { periods } = useFiscalPeriods()
|
||||
const [search, setSearch] = useState('')
|
||||
const [selected, setSelected] = useState<BookingTemplateLibrary | null>(null)
|
||||
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().split('T')[0])
|
||||
const [amountInput, setAmountInput] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Load templates + fiscal periods when the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const [tplRes, periodRes] = await Promise.all([
|
||||
fetch('/api/settings/booking-templates'),
|
||||
fetch('/api/bookkeeping/fiscal-periods'),
|
||||
])
|
||||
if (cancelled) return
|
||||
if (tplRes.ok) {
|
||||
const { data } = await tplRes.json()
|
||||
if (!cancelled) setTemplates(data ?? [])
|
||||
} else {
|
||||
setTemplates([])
|
||||
}
|
||||
if (periodRes.ok) {
|
||||
const { data } = await periodRes.json()
|
||||
if (!cancelled) setPeriods(data ?? [])
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Reset per open so yesterday's half-typed amount never leaks into today.
|
||||
useEffect(() => {
|
||||
if (open) return
|
||||
|
||||
Reference in New Issue
Block a user