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,7 @@
|
||||
'use client'
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
@@ -23,6 +23,7 @@ import type { ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { useBookingTemplates } from '@/lib/reference-data/hooks'
|
||||
|
||||
export function BookingTemplatesPanel() {
|
||||
const t = useTranslations('settings_booking_templates')
|
||||
@@ -36,8 +37,10 @@ export function BookingTemplatesPanel() {
|
||||
aktiebolag: t('entity_aktiebolag'),
|
||||
}
|
||||
|
||||
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
// The panel renders the same session-cached list the pickers use
|
||||
// (lib/reference-data); every write below invalidates it so registry and
|
||||
// pickers can never disagree.
|
||||
const { templates, isLoading, error: templatesError } = useBookingTemplates()
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
@@ -47,19 +50,13 @@ export function BookingTemplatesPanel() {
|
||||
const [activeTemplate, setActiveTemplate] = useState<BookingTemplateLibrary | null>(null)
|
||||
const importRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings/booking-templates')
|
||||
const json = await res.json()
|
||||
if (json.data) setTemplates(json.data)
|
||||
} catch {
|
||||
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [toast, t])
|
||||
useEffect(() => {
|
||||
if (templatesError) toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
|
||||
}, [templatesError, toast, t])
|
||||
|
||||
useEffect(() => { fetchTemplates() }, [fetchTemplates])
|
||||
const refreshTemplates = () => {
|
||||
void invalidateReferenceData('ref:booking-templates')
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeletingId(id)
|
||||
@@ -73,9 +70,7 @@ export function BookingTemplatesPanel() {
|
||||
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
setTemplates((prev) => prev.filter((tt) => tt.id !== id))
|
||||
// The pickers read the session cache (lib/reference-data): drop the
|
||||
// deleted template there too, not just from this panel's own list.
|
||||
// This list and every picker read the session cache: refresh it.
|
||||
void invalidateReferenceData('ref:booking-templates')
|
||||
toast({ title: t('toast_deleted') })
|
||||
} finally {
|
||||
@@ -128,8 +123,7 @@ export function BookingTemplatesPanel() {
|
||||
return
|
||||
}
|
||||
toast({ title: t('toast_import_done'), description: t('toast_import_count', { count: json.imported }) })
|
||||
fetchTemplates()
|
||||
void invalidateReferenceData('ref:booking-templates')
|
||||
refreshTemplates()
|
||||
} catch {
|
||||
toast({ title: t('toast_import_error'), description: t('toast_invalid_file'), variant: 'destructive' })
|
||||
} finally {
|
||||
@@ -207,7 +201,7 @@ export function BookingTemplatesPanel() {
|
||||
duplicateNamePool={companyTemplateNames}
|
||||
onSaved={() => {
|
||||
setShowCreate(false)
|
||||
fetchTemplates()
|
||||
refreshTemplates()
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
@@ -300,7 +294,7 @@ export function BookingTemplatesPanel() {
|
||||
duplicateNamePool={companyTemplateNames}
|
||||
onSaved={() => {
|
||||
setActiveTemplate(null)
|
||||
fetchTemplates()
|
||||
refreshTemplates()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useFiscalPeriods } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
@@ -54,17 +56,29 @@ export function FiscalPeriodEditor() {
|
||||
const isEF = company?.entity_type === 'enskild_firma'
|
||||
const canEdit = role === 'owner' || role === 'admin'
|
||||
|
||||
// Periods come from the session cache (lib/reference-data). The editor
|
||||
// snapshots the FIRST period once per company (a background revalidation
|
||||
// must not reset the dates the user is editing), then loads that period's
|
||||
// posted-entry count, which is what gates editing.
|
||||
const { periods, isLoading: periodsLoading, error: periodsError } = useFiscalPeriods()
|
||||
const periodsRef = useRef(periods)
|
||||
useEffect(() => {
|
||||
if (!company) return
|
||||
periodsRef.current = periods
|
||||
}, [periods])
|
||||
const initialisedForRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!company || periodsLoading) return
|
||||
if (initialisedForRef.current === company.id) return
|
||||
initialisedForRef.current = company.id
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
if (!res.ok) throw new Error(t('fp_load_error_periods'))
|
||||
const { data } = (await res.json()) as { data: FiscalPeriod[] }
|
||||
if (periodsError) throw new Error(t('fp_load_error_periods'))
|
||||
const data = periodsRef.current
|
||||
if (!data || data.length === 0) {
|
||||
if (!cancelled) {
|
||||
setPeriod(null)
|
||||
@@ -97,7 +111,7 @@ export function FiscalPeriodEditor() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [company, t])
|
||||
}, [company, periodsLoading, periodsError, t])
|
||||
|
||||
const validation = validateFirstPeriod(
|
||||
startDate,
|
||||
@@ -153,6 +167,8 @@ export function FiscalPeriodEditor() {
|
||||
throw new Error(body.error || t('fp_update_failed_title'))
|
||||
}
|
||||
setPeriod(body.data as FiscalPeriod)
|
||||
// Every picker reads the shared list: refresh it with the new dates.
|
||||
void invalidateReferenceData('ref:fiscal-periods')
|
||||
toast({
|
||||
title: t('fp_updated_title'),
|
||||
description: `${formatSwedishDate(body.data.period_start)}: ${formatSwedishDate(body.data.period_end)}`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { SettingsGroup } from '@/components/settings/SettingsRows'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useFiscalPeriods } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { Plus, Lock, Unlock, Loader2, Eraser } from 'lucide-react'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
@@ -39,9 +41,11 @@ export function FiscalYearsManager() {
|
||||
const { toast } = useToast()
|
||||
const { role } = useCompany()
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
// Session-cached registry (lib/reference-data): the same list every
|
||||
// picker renders, so a lock/unlock/reset here is visible everywhere the
|
||||
// moment the cache is invalidated below.
|
||||
const { periods, isLoading, error: periodsError } = useFiscalPeriods()
|
||||
const hasError = !!periodsError && periods.length === 0
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [mutatingId, setMutatingId] = useState<string | null>(null)
|
||||
const [resetTarget, setResetTarget] = useState<FiscalPeriod | null>(null)
|
||||
@@ -50,21 +54,7 @@ export function FiscalYearsManager() {
|
||||
// too (requireWrite); this just hides controls a viewer/member can't use.
|
||||
const canManage = role === 'owner' || role === 'admin'
|
||||
|
||||
const fetchPeriods = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
if (!res.ok) throw new Error('fetch failed')
|
||||
const { data } = await res.json()
|
||||
setPeriods((data as FiscalPeriod[]) || [])
|
||||
setHasError(false)
|
||||
} catch {
|
||||
setHasError(true)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchPeriods() }, [fetchPeriods])
|
||||
const refreshPeriods = useCallback(() => invalidateReferenceData('ref:fiscal-periods'), [])
|
||||
|
||||
// Newest first: matches the API's ordering and reads most-recent-at-top.
|
||||
const sorted = [...periods].sort((a, b) => b.period_start.localeCompare(a.period_start))
|
||||
@@ -82,7 +72,7 @@ export function FiscalYearsManager() {
|
||||
throw new Error(body?.error?.message || t('fy_action_error'))
|
||||
}
|
||||
toast({ title: action === 'lock' ? t('fy_lock_success') : t('fy_unlock_success') })
|
||||
await fetchPeriods()
|
||||
await refreshPeriods()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('fy_action_error'),
|
||||
@@ -219,7 +209,7 @@ export function FiscalYearsManager() {
|
||||
onOpenChange={setDialogOpen}
|
||||
entryDate={suggestSeedDate(periods, new Date().toISOString().split('T')[0])}
|
||||
periods={periods}
|
||||
onCreated={fetchPeriods}
|
||||
onCreated={refreshPeriods}
|
||||
/>
|
||||
|
||||
{resetTarget && (
|
||||
@@ -230,7 +220,7 @@ export function FiscalYearsManager() {
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setResetTarget(null)
|
||||
}}
|
||||
onReset={fetchPeriods}
|
||||
onReset={refreshPeriods}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCashAccounts } from '@/lib/reference-data/hooks'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { formatIbanGroups, uniqueConnectionIban } from '@/lib/company/connection-iban'
|
||||
import { bankgiroFromTicSnapshot } from '@/lib/company/snapshot-bank'
|
||||
@@ -98,7 +99,16 @@ export function InvoicePaymentAccountsSettings({
|
||||
// rows (bank_connection_id nulled) and the connect picker mirrors deselected
|
||||
// accounts with enabled=false, so an unfiltered read could offer a closed or
|
||||
// third-party account. Only offered when the remaining rows agree on one IBAN.
|
||||
const [connectionIban, setConnectionIban] = useState<string | null>(null)
|
||||
const { cashAccounts } = useCashAccounts()
|
||||
const connectionIban = useMemo(
|
||||
() =>
|
||||
uniqueConnectionIban(
|
||||
cashAccounts.filter(
|
||||
(a) => a.enabled && a.currency === 'SEK' && a.bank_connection_id && a.iban,
|
||||
),
|
||||
),
|
||||
[cashAccounts],
|
||||
)
|
||||
const legacySekAccount = useMemo(
|
||||
() => legacySekInvoicePaymentAccount({
|
||||
bank_name: settings.bank_name,
|
||||
@@ -166,18 +176,6 @@ export function InvoicePaymentAccountsSettings({
|
||||
if (cancelled) return
|
||||
setSnapshotBankgiro(bankgiroFromTicSnapshot(data?.tic_snapshot, data?.org_number))
|
||||
})
|
||||
supabase
|
||||
.from('cash_accounts')
|
||||
.select('iban')
|
||||
.eq('company_id', company.id)
|
||||
.eq('enabled', true)
|
||||
.eq('currency', 'SEK')
|
||||
.not('bank_connection_id', 'is', null)
|
||||
.not('iban', 'is', null)
|
||||
.then(({ data }) => {
|
||||
if (cancelled) return
|
||||
setConnectionIban(uniqueConnectionIban(data))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user