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
+33 -28
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useAccounts, useCompanySettings } from '@/lib/reference-data/hooks'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import {
@@ -22,11 +23,10 @@ import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
import { proposePaymentLines, resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { Plus, Trash2, Loader2 } from 'lucide-react'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { Invoice, InvoiceItem, Customer, BASAccount, EntityType } from '@/types'
import type { Invoice, InvoiceItem, Customer, EntityType } from '@/types'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
type DuplicateMatchReason = 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only'
@@ -67,7 +67,6 @@ export default function PaymentBookingDialog({
}: PaymentBookingDialogProps) {
const { toast } = useToast()
const router = useRouter()
const supabase = createClient()
const { company } = useCompany()
const t = useTranslations('invoice_payment_dialog')
@@ -77,7 +76,16 @@ export default function PaymentBookingDialog({
amount_only: t('match_reason_amount_only'),
}
const [accounts, setAccounts] = useState<BASAccount[]>([])
// Session-cached reference data (lib/reference-data), seeded by the
// dashboard layout: the chart and the settings are known on the first
// paint, so the proposed lines and the voucher preview resolve as soon as
// the dialog opens instead of after two sequential requests.
const { accounts, isLoading: accountsLoading, error: accountsError } = useAccounts()
const {
settings: companySettings,
isLoading: settingsLoading,
error: settingsError,
} = useCompanySettings()
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [lines, setLines] = useState<FormLine[]>([])
const accountNameByNumber = useMemo(() => {
@@ -92,7 +100,8 @@ export default function PaymentBookingDialog({
const [tab, setTab] = useState<'new' | 'existing'>('new')
// Drives the "Befintlig verifikation" picker copy: cash links against a 19xx
// debit, accrual against a 1510 credit.
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
const accountingMethod: 'accrual' | 'cash' =
companySettings?.accounting_method === 'cash' ? 'cash' : 'accrual'
// source_type the booking will use: drives the voucher-series preview so the
// number shown matches what mark-paid will actually create.
const [sourceType, setSourceType] =
@@ -110,38 +119,30 @@ export default function PaymentBookingDialog({
return
}
// Reference data still loading (no seed, first mount of the session):
// the effect re-runs once it lands.
if (accountsLoading || settingsLoading) return
let cancelled = false
async function init() {
try {
// Fetch accounts
const [accountsRes, fetchedCatalog] = await Promise.all([
fetch('/api/bookkeeping/accounts'),
loadBasCatalog(),
])
if (!accountsRes.ok) throw new Error(t('load_chart_failed'))
const accountsData = await accountsRes.json()
const fetchedAccounts: BASAccount[] = accountsData.data || []
if (accountsError) throw new Error(t('load_chart_failed'))
if (!company?.id) throw new Error(t('no_active_company'))
// Fetch company settings
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('accounting_method, entity_type, ore_rounding')
.eq('company_id', company.id)
.maybeSingle()
if (settingsError) throw new Error(t('load_settings_failed'))
const fetchedCatalog = await loadBasCatalog()
if (cancelled) return
setAccounts(fetchedAccounts)
setCatalog(fetchedCatalog)
const accountingMethod = (settings?.accounting_method || 'accrual') as 'accrual' | 'cash'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
setAccountingMethod(accountingMethod)
const settings = companySettings
// /api/settings used to fall back to the company row's entity type
// when company_settings.entity_type is null; the cached row does not.
const entityType: EntityType =
(settings?.entity_type as EntityType | null | undefined) ??
company.entity_type ??
'enskild_firma'
setSourceType(
resolveInvoicePaymentSourceType({
@@ -193,7 +194,11 @@ export default function PaymentBookingDialog({
init()
return () => { cancelled = true }
}, [open, invoice.id, company?.id])
// companySettings and accountingMethod are read at init time on purpose: a
// background revalidation of the settings row must not re-run init()
// (and reset the user's lines) mid-dialog.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, invoice.id, company?.id, accountsLoading, settingsLoading, accountsError, settingsError])
// Voucher-series preview: resolve the upcoming serie + nummer the same way the
// booking engine will, so a misconfigured series is visible before confirming.
+57 -43
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useAccounts, useCompanySettings, useFiscalPeriods } from '@/lib/reference-data/hooks'
import { useLocale, useTranslations } from 'next-intl'
import {
Dialog,
@@ -29,7 +30,7 @@ import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
import { Loader2, Mail, Plus, Send, Trash2 } from 'lucide-react'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { Invoice, InvoiceItem, Customer, EntityType, BASAccount } from '@/types'
import type { Invoice, InvoiceItem, Customer, EntityType } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
import {
@@ -72,12 +73,39 @@ export default function SendInvoiceDialog({
const isCreditRepair = isCreditNote && invoice.status === 'sent'
const [isSubmitting, setIsSubmitting] = useState(false)
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
const [periodName, setPeriodName] = useState('')
// Session-cached reference data (lib/reference-data), seeded by the
// dashboard layout: settings, the period containing the invoice date and
// the chart are known on the first paint, so opening the dialog costs no
// reference requests. Only the credit-note original lookup and the BAS
// catalogue (module-cached) are still loaded in init().
const {
settings: companySettings,
isLoading: settingsLoading,
error: settingsError,
} = useCompanySettings()
const {
periods: fiscalPeriods,
isLoading: periodsLoading,
error: periodsError,
} = useFiscalPeriods()
const { accounts } = useAccounts()
// /api/settings used to fall back to the company row's entity type when
// company_settings.entity_type is null; the cached row does not, so the
// fallback is explicit here (same rule as deriveSupplierInvoiceDefaults).
const entityType: EntityType =
(companySettings?.entity_type as EntityType | null | undefined) ??
company?.entity_type ??
'enskild_firma'
const periodName = useMemo(
() =>
fiscalPeriods.find(
(p) => p.period_start <= invoice.invoice_date && invoice.invoice_date <= p.period_end,
)?.name ?? '',
[fiscalPeriods, invoice.invoice_date],
)
const deferBooking = !!companySettings?.defer_invoice_booking
const [isInitialized, setIsInitialized] = useState(false)
const [shouldBookOnIssue, setShouldBookOnIssue] = useState(true)
const [deferBooking, setDeferBooking] = useState(false)
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [editLines, setEditLines] = useState<FormLine[]>([])
const [hasEdited, setHasEdited] = useState(false)
@@ -110,25 +138,19 @@ export default function SendInvoiceDialog({
return
}
// Reference data still loading (no seed, first mount of the session):
// the effect re-runs once it lands.
if (settingsLoading || periodsLoading) return
let cancelled = false
async function init() {
try {
if (!company?.id) throw new Error(t('no_active_company'))
if (settingsError) throw new Error(t('company_settings_failed'))
if (periodsError) throw new Error(t('fiscal_period_failed'))
const [settingsResult, periodResult, originalResult, authResult] = await Promise.all([
supabase
.from('company_settings')
.select('accounting_method, entity_type, defer_invoice_booking, email, invoice_email_cc_addresses, invoice_email_bcc_addresses')
.eq('company_id', company.id)
.maybeSingle(),
supabase
.from('fiscal_periods')
.select('name')
.eq('company_id', company.id)
.lte('period_start', invoice.invoice_date)
.gte('period_end', invoice.invoice_date)
.maybeSingle(),
const [originalResult, sessionResult] = await Promise.all([
invoice.credited_invoice_id
? supabase
.from('invoices')
@@ -137,51 +159,40 @@ export default function SendInvoiceDialog({
.eq('company_id', company.id)
.maybeSingle()
: Promise.resolve({ data: null, error: null }),
supabase.auth.getUser(),
// Local session read (no network): only the signed-in address is
// needed, as the legacy CC fallback.
supabase.auth.getSession(),
])
if (settingsResult.error) throw new Error(t('company_settings_failed'))
if (periodResult.error) throw new Error(t('fiscal_period_failed'))
if (originalResult.error) throw new Error(t('original_invoice_failed'))
if (authResult.error || !authResult.data.user) throw new Error(t('load_failed_title'))
const sessionUser = sessionResult.data.session?.user
if (sessionResult.error || !sessionUser) throw new Error(t('load_failed_title'))
if (cancelled) return
const method = (settingsResult.data?.accounting_method || 'accrual') as 'accrual' | 'cash'
const method = (companySettings?.accounting_method || 'accrual') as 'accrual' | 'cash'
// #967: deferred companies mark-sent WITHOUT booking; ekonomi books
// later via a separate step, so neither preview nor editor applies.
const bookOnIssue = invoice.credited_invoice_id && originalResult.data
? creditNoteNeedsJournalEntry(method, originalResult.data)
: method === 'accrual' && !settingsResult.data?.defer_invoice_booking
: method === 'accrual' && !companySettings?.defer_invoice_booking
// Line editing needs the chart of accounts; only the accrual
// book-at-issue path renders the editor, so skip the fetch elsewhere.
let fetchedAccounts: BASAccount[] = []
// Line editing needs the BAS catalogue; only the accrual
// book-at-issue path renders the editor, so skip the load elsewhere.
let fetchedCatalog: CatalogAccount[] = []
if (!invoice.credited_invoice_id && bookOnIssue && !hasAccrualItems) {
const [accountsRes, catalogResult] = await Promise.all([
fetch('/api/bookkeeping/accounts'),
loadBasCatalog(),
])
if (!accountsRes.ok) throw new Error(t('load_chart_failed'))
const accountsData = await accountsRes.json()
fetchedAccounts = accountsData.data || []
fetchedCatalog = catalogResult
fetchedCatalog = await loadBasCatalog()
}
if (cancelled) return
setAccounts(fetchedAccounts)
setCatalog(fetchedCatalog)
setEntityType((settingsResult.data?.entity_type as EntityType) || 'enskild_firma')
const legacyCc = settingsResult.data?.email || authResult.data.user.email
const legacyCc = companySettings?.email || sessionUser.email
setFixedCc(
settingsResult.data?.invoice_email_cc_addresses
companySettings?.invoice_email_cc_addresses
?? (legacyCc ? [legacyCc] : []),
)
setFixedBcc(settingsResult.data?.invoice_email_bcc_addresses ?? [])
setPeriodName(periodResult.data?.name || '')
setDeferBooking(!!settingsResult.data?.defer_invoice_booking)
setFixedBcc(companySettings?.invoice_email_bcc_addresses ?? [])
setShouldBookOnIssue(bookOnIssue)
setIsInitialized(true)
} catch (err) {
@@ -197,7 +208,10 @@ export default function SendInvoiceDialog({
init()
return () => { cancelled = true }
}, [open, invoice.id, invoice.invoice_date, company?.id, canCustomizeRecipients])
// companySettings is read at init time on purpose: a background
// revalidation of the settings row must not re-run init() mid-dialog.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, invoice.id, invoice.invoice_date, company?.id, canCustomizeRecipients, settingsLoading, periodsLoading, settingsError, periodsError])
const proposedLines = useMemo(() => {
if (!isInitialized || !shouldBookOnIssue) return []