perf(forms): supplier-invoice form, register forms and review dialogs read the session cache (#1938)
The supplier-invoice editor issued four requests on every mount (suppliers, accounts, settings, fiscal periods) and defaulted vatRegistered=true, entity type and rounding until /api/settings landed, so the moms controls visibly flipped. The register forms fetched the whole chart of accounts to fill one konto combobox, and each transaction review dialog refetched accounts, cash accounts or settings per open. - use-supplier-invoice-data: thin composition of useSuppliers, useAccounts, useCompanySettings and useFiscalPeriods; the settings-driven gates come from a pure deriveSupplierInvoiceDefaults() (tested) instead of state that flips when the fetch returns; the per-invoice öresavrundning toggle is the one local override. Inline supplier create invalidates the shared list instead of patching local state. - SupplierForm, ArticleForm (posting accounts), QuickReviewDialog, InvoiceMatchDialog, supplier-invoices/[id] (payment dialog chart): useAccounts; ArticleForm's inline account create invalidates the chart. - BulkBookDialog, MatchVoucherDialog, DuplicateBookingDialog: cash accounts from useCashAccounts (resolveAccount over the cached list; an empty list still resolves to 1930 with the fallback note). - QuickReviewDialog, BulkBookDialog, NewEmployeeDialog, customers list (default payment terms), salary run page (payment format, bank, IBAN, dimensions): derived from useCompanySettings; the salary page's post-settings-modal refetch becomes a cache invalidation. raw-reference-fetch ratchet: 45 -> 35 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
40e773548c
commit
4560ccbfc9
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, Suspense } from 'react'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
|
||||
@@ -76,7 +77,13 @@ function CustomersPageInner() {
|
||||
// Company default payment terms (Inställningar → Fakturering). Prefilled
|
||||
// into the new-customer form so it opens on the company's own default
|
||||
// instead of a hardcoded 30.
|
||||
const [companyDefaultTerms, setCompanyDefaultTerms] = useState<number | null>(null)
|
||||
// Default payment terms from the session-cached settings row; the dialog
|
||||
// falls back to 30 until (or unless) the company set one.
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
const companyDefaultTerms =
|
||||
typeof companySettings?.invoice_default_days === 'number' && companySettings.invoice_default_days > 0
|
||||
? companySettings.invoice_default_days
|
||||
: null
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('customers')
|
||||
const tCommon = useTranslations('common')
|
||||
@@ -147,22 +154,6 @@ function CustomersPageInner() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Best-effort: the dialog falls back to 30 until (or unless) this lands.
|
||||
let cancelled = false
|
||||
fetch('/api/settings')
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((json) => {
|
||||
if (cancelled) return
|
||||
const days = json?.data?.invoice_default_days
|
||||
if (typeof days === 'number' && days > 0) setCompanyDefaultTerms(days)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleCreateCustomer(data: CreateCustomerInput) {
|
||||
setIsCreating(true)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { use, useEffect, useRef, useState } from 'react'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -110,20 +112,21 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const settingsRes = await fetch('/api/settings')
|
||||
if (!settingsRes.ok) return
|
||||
const { data } = await settingsRes.json()
|
||||
if (data?.preferred_payment_format === 'pain001' || data?.preferred_payment_format === 'bg_lb') {
|
||||
setPreferredPaymentFormat(data.preferred_payment_format)
|
||||
}
|
||||
setDefaultBank(typeof data?.salary_default_bank === 'string' ? data.salary_default_bank : null)
|
||||
// Company settings from the session cache (lib/reference-data): applied
|
||||
// whenever the cached row (re)loads, no request of its own.
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
useEffect(() => {
|
||||
const data = companySettings as unknown as Record<string, unknown> | null
|
||||
if (!data) return
|
||||
const format = data.preferred_payment_format
|
||||
if (format === 'pain001' || format === 'bg_lb') setPreferredPaymentFormat(format)
|
||||
setDefaultBank(typeof data.salary_default_bank === 'string' ? data.salary_default_bank : null)
|
||||
setSenderBankgiro(
|
||||
typeof data?.bankgiro === 'string' && data.bankgiro.trim() ? data.bankgiro : null,
|
||||
typeof data.bankgiro === 'string' && data.bankgiro.trim() ? data.bankgiro : null,
|
||||
)
|
||||
setSenderIban(typeof data?.iban === 'string' && data.iban.trim() ? data.iban : null)
|
||||
setDimensionsEnabled(data?.dimensions_enabled === true)
|
||||
}
|
||||
setSenderIban(typeof data.iban === 'string' && data.iban.trim() ? data.iban : null)
|
||||
setDimensionsEnabled(data.dimensions_enabled === true)
|
||||
}, [companySettings])
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -132,7 +135,6 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
|
||||
const [, empRes] = await Promise.all([
|
||||
loadRun(),
|
||||
fetch('/api/salary/employees'),
|
||||
loadSettings(),
|
||||
])
|
||||
if (empRes.ok) {
|
||||
const { data } = await empRes.json()
|
||||
@@ -154,7 +156,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
|
||||
pathnameSeen.current = true
|
||||
return
|
||||
}
|
||||
if (pathname === `/salary/runs/${id}`) loadSettings()
|
||||
if (pathname === `/salary/runs/${id}`) void invalidateReferenceData('company_settings')
|
||||
}, [pathname, id])
|
||||
|
||||
// Refetch when the tab regains focus. AGI can be generated out-of-band (via
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useAccounts } from '@/lib/reference-data/hooks'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -38,7 +39,7 @@ import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
|
||||
import { DetailPager } from '@/components/common/DetailPager'
|
||||
import { listContextKey } from '@/lib/navigation/list-context'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, BASAccount } from '@/types'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment } from '@/types'
|
||||
|
||||
interface EditableLine {
|
||||
account_number: string
|
||||
@@ -107,8 +108,10 @@ export default function SupplierInvoiceDetailPage() {
|
||||
const [payAmount, setPayAmount] = useState('')
|
||||
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
|
||||
const [paymentAccount, setPaymentAccount] = useState('1930')
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [areAccountsLoading, setAreAccountsLoading] = useState(false)
|
||||
// Chart of accounts for the payment dialog, from the session cache
|
||||
// (lib/reference-data): one request per session at most, shared with
|
||||
// every other picker, instead of a deferred fetch per detail page.
|
||||
const { accounts, isLoading: areAccountsLoading } = useAccounts()
|
||||
// Which action is in flight, not just whether one is: the acting button
|
||||
// shows the spinner while the others only disable. A single boolean put
|
||||
// identical pending feedback (none) on every button at once.
|
||||
@@ -305,28 +308,6 @@ export default function SupplierInvoiceDetailPage() {
|
||||
}
|
||||
}, [isPayDialogOpen, invoice, payAmount, paymentAccount])
|
||||
|
||||
// The chart of accounts is only needed by the payment dialog. Defer the
|
||||
// request until the user opens it instead of blocking the detail page.
|
||||
useEffect(() => {
|
||||
if (!isPayDialogOpen || accounts.length > 0) return
|
||||
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setAreAccountsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/bookkeeping/accounts')
|
||||
if (cancelled || !response.ok) return
|
||||
const { data } = await response.json()
|
||||
if (Array.isArray(data)) setAccounts(data as BASAccount[])
|
||||
} finally {
|
||||
if (!cancelled) setAreAccountsLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [accounts.length, isPayDialogOpen])
|
||||
|
||||
async function handleApprove() {
|
||||
setProcessingAction('approve')
|
||||
// try/catch/finally like handleDelete: a rejected fetch()/res.json()
|
||||
|
||||
Reference in New Issue
Block a user