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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
4560ccbfc9
commit
3ee3565d6d
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user