perf(reference-data): sweep the remaining raw reads onto the session cache, ratchet to 0 (#1941)

Final consumer migration of the responsiveness plan: the 35 files still
fetching fiscal periods, settings, accounts, cash accounts, dimensions or
templates on their own now read lib/reference-data, and every client
write site invalidates the shared cache instead of refetching locally.

Settings and registries: FiscalYearsManager, FiscalPeriodEditor (period
snapshotted once per company so a revalidation cannot reset dates being
edited), BookingTemplatesPanel, ChartOfAccounts, ChartOfAccountsManager,
EditAccountDialog, CorrectionEntryDialog, StrikeLinesDialog,
InvoicePaymentAccountsSettings; the dimensions registry (DimensionsManager,
DimensionCombobox, LineDimensionFields, DimensionFilter, bookkeeping/[id])
reads useDimensions and the ad-hoc fetchDimensions/fetchDimensionsCached
helpers are deleted.

Pages and pickers: CashAccountSelector (FyPicker-shaped restore, once per
company load), use-account-names, FiscalYearGapNotice,
OpeningBalancePeriodStep, BankFileConfirmStep, ImportReviewStep, the import
page (invalidates accounts + periods after a SIE execute), customers list,
invoices list + detail, pending, salary employee, asset dispose, year-end
and periodisering pages (invalidate periods after closing), reports
DimensionPnlView (its pivot picker read the wrong payload key and was
always empty; it now populates), SkatteverketPanel, TemplatePicker,
ArticleForm (vat_registered).

Invoice dialogs and extensions: SendInvoiceDialog, PaymentBookingDialog
(init reduced to the credit-note lookup + catalogue, proposal and voucher
preview fire on open when cached; a local getSession replaces the network
getUser for the fallback CC), InvoiceInboxWorkspace, TicWorkspace,
ArcimMigrationWorkspace (invalidates after each SIE import step),
enable-banking AccountPickerDialog.

raw-reference-fetch ratchet: 35 -> 0 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:
Jakob Wennberg
2026-08-26 14:56:37 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 4560ccbfc9
commit 3ee3565d6d
41 changed files with 636 additions and 977 deletions
+7 -17
View File
@@ -1,7 +1,9 @@
'use client'
import { useState, useEffect } from 'react'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useAccounts } from '@/lib/reference-data/hooks'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -21,24 +23,13 @@ const CLASS_LABELS: Record<number, string> = {
}
export default function ChartOfAccounts() {
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [loading, setLoading] = useState(true)
// Session-cached chart (lib/reference-data), shared with every picker.
const { accounts, isLoading: loading } = useAccounts()
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
const [editingSRU, setEditingSRU] = useState<string | null>(null)
const [sruValue, setSruValue] = useState('')
async function fetchAccounts() {
const res = await fetch('/api/bookkeeping/accounts')
const { data } = await res.json()
setAccounts(data || [])
setLoading(false)
}
useEffect(() => {
fetchAccounts()
}, [])
async function updateSRUCode(accountId: string, newSruCode: string) {
const supabase = createClient()
const trimmed = newSruCode.trim() || null
@@ -47,9 +38,8 @@ export default function ChartOfAccounts() {
.update({ sru_code: trimmed })
.eq('id', accountId)
setAccounts((prev) =>
prev.map((a) => (a.id === accountId ? { ...a, sru_code: trimmed } : a))
)
// Refresh the shared chart so this list and every picker show the code.
await invalidateReferenceData('ref:accounts')
setEditingSRU(null)
}
@@ -28,6 +28,7 @@ import {
} from 'lucide-react'
import { cn } from '@/lib/utils'
import type { BASAccount } from '@/types'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import { BAS_REFERENCE, isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -75,6 +76,7 @@ export default function ChartOfAccountsManager() {
const [collapsedMyClasses, setCollapsedMyClasses] = useState<Set<number>>(new Set())
const [expandedCatalogClasses, setExpandedCatalogClasses] = useState<Set<number>>(new Set())
const [hideK2Excluded, setHideK2Excluded] = useState<boolean | null>(null)
const { settings: companySettings } = useCompanySettings()
// Off by default: the list endpoint's `?active=false` means "no filter", so
// leaving it off keeps first paint on the smaller active-only payload.
// A deactivated account is otherwise invisible everywhere and unrecoverable.
@@ -205,25 +207,18 @@ export default function ChartOfAccountsManager() {
fetchReference(),
(async () => {
if (hideK2Excluded !== null) return
try {
const res = await fetch('/api/settings')
if (res.ok) {
const { data } = await res.json()
// Default to hiding K2-excluded accounts if the company uses K2 (plan_type === 'k1')
setHideK2Excluded(data?.plan_type === 'k1')
} else {
setHideK2Excluded(false)
}
} catch {
setHideK2Excluded(false)
}
// Default to hiding K2-excluded accounts if the company uses K2
// (plan_type === 'k1'). Settings come from the session cache.
setHideK2Excluded(
(companySettings as { plan_type?: string } | null)?.plan_type === 'k1',
)
})(),
])
setReferenceLoaded(true)
} finally {
setReferenceLoading(false)
}
}, [referenceLoaded, referenceLoading, fetchReference, hideK2Excluded])
}, [referenceLoaded, referenceLoading, fetchReference, hideK2Excluded, companySettings])
const refreshAll = useCallback(async () => {
await Promise.all([
@@ -23,6 +23,8 @@ import {
} from '@/components/bookkeeping/correction-entry-description'
import { nextLineDescriptionForAccountChange } from '@/components/bookkeeping/correction-line-description'
import { useToast } from '@/components/ui/use-toast'
import { useAccounts } from '@/lib/reference-data/hooks'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { Loader2, Plus, Trash2 } from 'lucide-react'
import { formatDate } from '@/lib/utils'
@@ -33,7 +35,7 @@ import {
} from '@/lib/bookkeeping/correction-line-account'
import { splitCreateAccountPrefill } from '@/lib/bookkeeping/create-account-prefill'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types'
import type { JournalEntry, JournalEntryLine } from '@/types'
interface CorrectionLine {
account_number: string
@@ -53,9 +55,18 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
const { toast } = useToast()
const router = useRouter()
const t = useTranslations('journal_detail')
const [accounts, setAccounts] = useState<BASAccount[]>([])
// The full chart (deactivated rows included) comes from the session cache
// (lib/reference-data); only the static BAS catalogue is loaded per open,
// and it is module-cached after the first time.
const { accounts, isLoading: accountsLoading, error: accountsError, refresh: refreshAccounts } = useAccounts(false)
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [accountsStatus, setAccountsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [catalogStatus, setCatalogStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const accountsStatus: 'loading' | 'ready' | 'error' =
accountsLoading || catalogStatus === 'loading'
? 'loading'
: accountsError || catalogStatus === 'error'
? 'error'
: 'ready'
const [lines, setLines] = useState<CorrectionLine[]>([])
const [description, setDescription] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -98,26 +109,18 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
// Pre-fill the verifikationstext with the same auto text the server
// would generate; only a user edit is sent along (see handleSubmit).
setDescription(autoCorrectionDescription(entry.description))
void fetchAccounts()
void loadCatalog()
}
}, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps
async function fetchAccounts() {
setAccountsStatus('loading')
async function loadCatalog() {
setCatalogStatus('loading')
try {
const [res, basCatalog] = await Promise.all([
fetch('/api/bookkeeping/accounts?active=false'),
loadBasCatalog(),
])
if (!res.ok) throw new Error(`accounts ${res.status}`)
const { data } = await res.json()
setAccounts(data || [])
setCatalog(basCatalog)
setAccountsStatus('ready')
setCatalog(await loadBasCatalog())
setCatalogStatus('ready')
} catch {
setAccounts([])
setCatalog([])
setAccountsStatus('error')
setCatalogStatus('error')
}
}
@@ -169,9 +172,9 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
// a dead end here: the rättelse can only post to accounts that exist in the
// chart. Creating it inline keeps the half-finished rättelse intact.
const handleAccountCreated = async (account: { account_number: string; account_name?: string }) => {
await fetchAccounts()
await invalidateReferenceData('ref:accounts')
if (creatingAccountForLine != null) {
// fetchAccounts' state update is not visible in this closure, so the
// The refreshed cache is not visible in this closure, so the
// fresh account's own name is passed alongside the stale sources. The
// reactivate path reports no name, but that account is already in
// `accounts` (the fetch includes deactivated rows).
@@ -339,7 +342,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
{accountsStatus === 'loading' ? t('accounts_loading') : t('accounts_load_failed')}
</span>
{accountsStatus === 'error' && (
<Button variant="outline" size="sm" onClick={() => void fetchAccounts()}>
<Button variant="outline" size="sm" onClick={() => void refreshAccounts()}>
{t('accounts_retry')}
</Button>
)}
+14 -26
View File
@@ -26,12 +26,14 @@ import { getErrorMessage } from '@/lib/errors/get-error-message'
import { Loader2, Plus, X } from 'lucide-react'
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
import {
fetchDimensions,
type AccountDimensionRuleDto,
type DimensionDto,
type DimensionRuleType,
} from '@/components/dimensions/types'
import type { BASAccount } from '@/types'
import { useCompanySettings, useDimensions } from '@/lib/reference-data/hooks'
import { fetchDimensions } from '@/lib/reference-data/fetchers'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect'
import {
defaultRateForVatTreatment,
@@ -81,8 +83,11 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
// dimensions enabled (same /api/settings gate as JournalEntryForm). Rule
// mutations apply immediately via their own fetches + toasts; they are
// deliberately independent of the account PUT below.
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [dims, setDims] = useState<DimensionDto[]>([])
// Settings and the dimension registry come from the session cache
// (lib/reference-data), so the section and its pickers are ready on open.
const { settings: companySettings } = useCompanySettings()
const dimensionsEnabled = companySettings?.dimensions_enabled === true
const { dimensions: dims } = useDimensions()
const [rules, setRules] = useState<AccountDimensionRuleDto[]>([])
const [rulesLoading, setRulesLoading] = useState(false)
const [addRuleOpen, setAddRuleOpen] = useState(false)
@@ -91,33 +96,15 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
const [newRuleValueCode, setNewRuleValueCode] = useState<string | null>(null)
const [isAddingRule, setIsAddingRule] = useState(false)
useEffect(() => {
let cancelled = false
fetch('/api/settings')
.then((r) => r.json())
.then(({ data }) => {
if (!cancelled && data?.dimensions_enabled === true) setDimensionsEnabled(true)
})
.catch(() => {
/* keep the section hidden */
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!dimensionsEnabled) return
let cancelled = false
setRulesLoading(true)
Promise.all([
fetchDimensions().catch(() => [] as DimensionDto[]),
fetch(`/api/dimensions/rules?account_number=${account.account_number}`)
.then(async (r) => ({ ok: r.ok, json: await r.json().catch(() => null) }))
.catch(() => ({ ok: false, json: null })),
]).then(([fetchedDims, rulesRes]) => {
fetch(`/api/dimensions/rules?account_number=${account.account_number}`)
.then(async (r) => ({ ok: r.ok, json: await r.json().catch(() => null) }))
.catch(() => ({ ok: false, json: null }))
.then((rulesRes) => {
if (cancelled) return
setDims(fetchedDims)
if (rulesRes.ok) {
setRules((rulesRes.json?.data?.rules ?? []) as AccountDimensionRuleDto[])
}
@@ -158,7 +145,8 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
if (!valueId) {
const refreshed = await fetchDimensions().catch(() => null)
if (refreshed) {
setDims(refreshed)
// Hand the fresh registry to the shared cache as well.
void invalidateReferenceData('ref:dimensions')
valueId = findValueId(refreshed)
}
}
+24 -21
View File
@@ -18,12 +18,14 @@ import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer'
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
import { AccountNumber } from '@/components/ui/account-number'
import { useToast } from '@/components/ui/use-toast'
import { useAccounts } from '@/lib/reference-data/hooks'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { changeCorrectionLineAccount, getSelectableCorrectionCatalog } from '@/lib/bookkeeping/correction-line-account'
import { splitCreateAccountPrefill } from '@/lib/bookkeeping/create-account-prefill'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
import { Loader2, Plus, Trash2 } from 'lucide-react'
import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types'
import type { JournalEntry, JournalEntryLine } from '@/types'
interface NewLine {
account_number: string
@@ -49,9 +51,18 @@ interface Props {
export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrected }: Props) {
const { toast } = useToast()
const t = useTranslations('journal_detail')
const [accounts, setAccounts] = useState<BASAccount[]>([])
// The full chart (deactivated rows included) comes from the session cache
// (lib/reference-data); only the static BAS catalogue is loaded per open,
// and it is module-cached after the first time.
const { accounts, isLoading: accountsLoading, error: accountsError, refresh: refreshAccounts } = useAccounts(false)
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [accountsStatus, setAccountsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [catalogStatus, setCatalogStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const accountsStatus: 'loading' | 'ready' | 'error' =
accountsLoading || catalogStatus === 'loading'
? 'loading'
: accountsError || catalogStatus === 'error'
? 'error'
: 'ready'
const [strikeIds, setStrikeIds] = useState<Set<string>>(new Set())
const [newLines, setNewLines] = useState<NewLine[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -77,26 +88,18 @@ export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrect
if (open) {
setStrikeIds(new Set())
setNewLines([])
void fetchAccounts()
void loadCatalog()
}
}, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps
}, [open, entry.id])
async function fetchAccounts() {
setAccountsStatus('loading')
async function loadCatalog() {
setCatalogStatus('loading')
try {
const [res, basCatalog] = await Promise.all([
fetch('/api/bookkeeping/accounts?active=false'),
loadBasCatalog(),
])
if (!res.ok) throw new Error(`accounts ${res.status}`)
const { data } = await res.json()
setAccounts(data || [])
setCatalog(basCatalog)
setAccountsStatus('ready')
setCatalog(await loadBasCatalog())
setCatalogStatus('ready')
} catch {
setAccounts([])
setCatalog([])
setAccountsStatus('error')
setCatalogStatus('error')
}
}
@@ -139,9 +142,9 @@ export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrect
// a dead end here: the rättelse can only post to accounts that exist in the
// chart. Creating it inline keeps the half-finished rättelse intact.
const handleAccountCreated = async (account: { account_number: string; account_name?: string }) => {
await fetchAccounts()
await invalidateReferenceData('ref:accounts')
if (creatingAccountForLine != null) {
// fetchAccounts' state update is not visible in this closure, so the
// The refreshed cache is not visible in this closure, so the
// fresh account's own name is passed alongside the stale sources. The
// reactivate path reports no name, but that account is already in
// `accounts` (the fetch includes deactivated rows).
@@ -300,7 +303,7 @@ export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrect
{accountsStatus === 'loading' ? t('accounts_loading') : t('accounts_load_failed')}
</span>
{accountsStatus === 'error' && (
<Button variant="outline" size="sm" onClick={() => void fetchAccounts()}>
<Button variant="outline" size="sm" onClick={() => void refreshAccounts()}>
{t('accounts_retry')}
</Button>
)}