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,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.
|
||||
|
||||
@@ -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 []
|
||||
|
||||
Reference in New Issue
Block a user