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 { useEffect, useMemo, useState } from 'react'
|
||||
import { useCashAccounts, useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
@@ -87,7 +88,11 @@ export default function BulkBookDialog({
|
||||
const [loadingTemplates, setLoadingTemplates] = useState(true)
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
|
||||
// null = fetch pending; array = loaded (may be empty on error: falls back to '1930')
|
||||
const [cashAccounts, setCashAccounts] = useState<CashAccount[] | null>(null)
|
||||
// Session-cached (lib/reference-data); null only while the list is still
|
||||
// loading, which the pre-fill below reads as "not resolved yet".
|
||||
const { cashAccounts: cachedCashAccounts, isLoading: cashAccountsLoading } = useCashAccounts()
|
||||
const cashAccounts: CashAccount[] | null = cashAccountsLoading ? null : cachedCashAccounts
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
const [mode, setMode] = useState<Mode>('one_line_per_tx')
|
||||
const [description, setDescription] = useState('')
|
||||
const [manualLines, setManualLines] = useState<ManualLine[]>([])
|
||||
@@ -102,7 +107,7 @@ export default function BulkBookDialog({
|
||||
// company_settings.dimensions_enabled, same gate as JournalEntryForm. One
|
||||
// header-level default bag applies to both tabs; the server tags the
|
||||
// generated voucher's lines with it.
|
||||
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
|
||||
const dimensionsEnabled = companySettings?.dimensions_enabled === true
|
||||
const [defaultDims, setDefaultDims] = useState<Record<string, string>>({})
|
||||
|
||||
// Documents that will inherit onto the new verifikat. Computed from
|
||||
@@ -173,43 +178,6 @@ export default function BulkBookDialog({
|
||||
}
|
||||
}, [open, company, supabase])
|
||||
|
||||
// Fetch cash accounts once when the dialog opens so the manual bank-leg
|
||||
// pre-fill can resolve the correct ledger account per transaction.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setCashAccounts(null)
|
||||
let cancelled = false
|
||||
fetch('/api/cash-accounts')
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`cash-accounts fetch failed: ${r.status}`)
|
||||
return r.json()
|
||||
})
|
||||
.then((json) => {
|
||||
if (cancelled) return
|
||||
setCashAccounts((json.data ?? []) as CashAccount[])
|
||||
})
|
||||
.catch(() => {
|
||||
// Fall back to empty list: resolveAccount will return '1930'
|
||||
if (!cancelled) setCashAccounts([])
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [open])
|
||||
|
||||
// Company settings gate the dimension affordance (dimensions_enabled).
|
||||
// Fetched once per open; on failure the pair simply stays hidden.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then(({ data }) => {
|
||||
if (!cancelled) setDimensionsEnabled(data?.dimensions_enabled === true)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDimensionsEnabled(false)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [open])
|
||||
|
||||
// Reset state when dialog closes so the next open starts clean.
|
||||
useEffect(() => {
|
||||
@@ -219,7 +187,6 @@ export default function BulkBookDialog({
|
||||
setMode('one_line_per_tx')
|
||||
setDescription('')
|
||||
setManualLines([])
|
||||
setCashAccounts(null)
|
||||
setDefaultDims({})
|
||||
} else if (sharedDate) {
|
||||
// Pre-fill description with a sensible default the user can edit.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useCashAccounts } from '@/lib/reference-data/hooks'
|
||||
import { useTranslations, useLocale } from 'next-intl'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -9,7 +10,6 @@ import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
|
||||
import type { CashAccount } from '@/types'
|
||||
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
|
||||
|
||||
/** The bank transaction being booked, as much as the caller knows about it.
|
||||
@@ -99,6 +99,10 @@ export default function DuplicateBookingDialog({
|
||||
const canMatch = candidate !== null && !!matchTransaction && !!onMatched
|
||||
const canIgnore = isSiblingCandidate && !!matchTransaction && !!onIgnored
|
||||
|
||||
// Session-cached (lib/reference-data); an empty list resolves to 1930 and
|
||||
// the link route re-validates the account either way.
|
||||
const { cashAccounts } = useCashAccounts()
|
||||
|
||||
async function handleMatch() {
|
||||
if (!candidate || !matchTransaction || !onMatched || matching) return
|
||||
setMatching(true)
|
||||
@@ -112,20 +116,11 @@ export default function DuplicateBookingDialog({
|
||||
// this account and that the transaction belongs to it.
|
||||
let account = candidate.account_number ?? '1930'
|
||||
if (!candidate.account_number) {
|
||||
try {
|
||||
const caRes = await fetch('/api/cash-accounts')
|
||||
if (caRes.ok) {
|
||||
const caJson = await caRes.json()
|
||||
const accounts = (caJson.data ?? []) as CashAccount[]
|
||||
account = resolveAccount(
|
||||
accounts,
|
||||
matchTransaction.cash_account_id ?? null,
|
||||
matchTransaction.currency ?? 'SEK',
|
||||
).account
|
||||
}
|
||||
} catch {
|
||||
// Network hiccup: fall back to 1930; the link route re-validates.
|
||||
}
|
||||
account = resolveAccount(
|
||||
cashAccounts,
|
||||
matchTransaction.cash_account_id ?? null,
|
||||
matchTransaction.currency ?? 'SEK',
|
||||
).account
|
||||
}
|
||||
|
||||
const res = await fetch('/api/reconciliation/bank/link', {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useAccounts } from '@/lib/reference-data/hooks'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
} from '@/lib/invoices/matchable-statuses'
|
||||
import { CheckCircle2, AlertTriangle, Trash2, Plus, Pencil } from 'lucide-react'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import type { BASAccount } from '@/types'
|
||||
|
||||
interface DuplicateCandidate {
|
||||
journal_entry_id: string
|
||||
@@ -180,26 +180,7 @@ export default function InvoiceMatchDialog({
|
||||
const [manualRate, setManualRate] = useState<string>('')
|
||||
// BAS accounts power the AccountCombobox suggestions in edit mode. Loaded
|
||||
// once on dialog open; same endpoint that PaymentBookingDialog uses.
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/accounts')
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
if (!cancelled) setAccounts((data?.data as BASAccount[]) ?? [])
|
||||
} catch {
|
||||
// Non-fatal: combobox just shows no suggestions, user can still
|
||||
// type the number manually.
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
const { accounts } = useAccounts()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !transactionId || targetBlocked) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCashAccounts } from '@/lib/reference-data/hooks'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -20,7 +21,6 @@ import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ArrowUpRight, ArrowDownRight, Loader2 } from 'lucide-react'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import type { CashAccount } from '@/types'
|
||||
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
|
||||
|
||||
interface MatchVoucherDialogProps {
|
||||
@@ -61,28 +61,19 @@ export function MatchVoucherDialog({
|
||||
// so several transactions can settle one verifikat (N:1: a salary run paid in
|
||||
// multiple transfers, an invoice paid in instalments).
|
||||
const [includeMatched, setIncludeMatched] = useState(false)
|
||||
// Session-cached (lib/reference-data): resolving the settlement account no
|
||||
// longer costs a /api/cash-accounts round trip per candidate load.
|
||||
const { cashAccounts } = useCashAccounts()
|
||||
|
||||
const loadCandidates = useCallback(
|
||||
async (tx: TransactionWithInvoice, wide: boolean, matched: boolean, signal: { cancelled: boolean }) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Resolve the settlement account from the company's cash accounts.
|
||||
let account = '1930'
|
||||
let fallback = true
|
||||
try {
|
||||
const caRes = await fetch('/api/cash-accounts')
|
||||
if (caRes.ok) {
|
||||
const caJson = await caRes.json()
|
||||
if (!signal.cancelled) {
|
||||
const accounts = (caJson.data ?? []) as CashAccount[]
|
||||
const resolved = resolveAccount(accounts, tx.cash_account_id ?? null, tx.currency ?? 'SEK')
|
||||
account = resolved.account
|
||||
fallback = resolved.fallback
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Network hiccup: fall back to 1930 and let the user see the note.
|
||||
}
|
||||
// Resolve the settlement account from the company's cash accounts
|
||||
// (an empty list resolves to 1930 with the fallback note shown).
|
||||
const resolved = resolveAccount(cashAccounts, tx.cash_account_id ?? null, tx.currency ?? 'SEK')
|
||||
const account = resolved.account
|
||||
const fallback = resolved.fallback
|
||||
if (!signal.cancelled) {
|
||||
setAccountNumber(account)
|
||||
setAccountFallback(fallback)
|
||||
@@ -121,7 +112,7 @@ export function MatchVoucherDialog({
|
||||
if (!signal.cancelled) setLoading(false)
|
||||
}
|
||||
},
|
||||
[],
|
||||
[cashAccounts],
|
||||
)
|
||||
|
||||
// (Re)load whenever the dialog opens for a transaction, the range widens, or
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useAccounts, useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -31,7 +32,7 @@ import VatTreatmentSelect from './VatTreatmentSelect'
|
||||
import AiCategorizeProposal, { type AiProposalMeta } from './AiCategorizeProposal'
|
||||
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import type { TransactionCategory, VatTreatment, BASAccount, EntityType, LinePatternEntry } from '@/types'
|
||||
import type { TransactionCategory, VatTreatment, EntityType, LinePatternEntry } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
interface QuickReviewDialogProps {
|
||||
@@ -101,7 +102,10 @@ export default function QuickReviewDialog({
|
||||
// never throws.
|
||||
const [accountOverride, setAccountOverride] = useState(defaultAccount ?? '')
|
||||
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>(defaultVat)
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
// Session-cached (lib/reference-data): the kontoväljare is populated on
|
||||
// the first open of every row instead of after a request per open.
|
||||
const { accounts } = useAccounts()
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
// The AI proposal shown this session, kept so we can log a calibration sample
|
||||
// (proposed vs actually booked) once the user confirms.
|
||||
const [aiProposal, setAiProposal] = useState<AiProposalMeta | null>(null)
|
||||
@@ -126,7 +130,7 @@ export default function QuickReviewDialog({
|
||||
// company_settings.dimensions_enabled, same gate as BulkBookDialog. Seeded
|
||||
// from the counterparty template's learned bag so the user sees what the
|
||||
// booking will carry and can change it.
|
||||
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
|
||||
const dimensionsEnabled = companySettings?.dimensions_enabled === true
|
||||
const [dims, setDims] = useState<Record<string, string>>(
|
||||
() => ({ ...(counterpartyDefaultDimensions ?? {}) }),
|
||||
)
|
||||
@@ -141,22 +145,6 @@ export default function QuickReviewDialog({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch accounts on mount
|
||||
useEffect(() => {
|
||||
async function fetchAccounts() {
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/accounts')
|
||||
const { data } = await res.json()
|
||||
if (data) {
|
||||
setAccounts(data)
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
fetchAccounts()
|
||||
}, [])
|
||||
|
||||
// Reset local mirror whenever the underlying transaction changes (the parent
|
||||
// reuses the dialog instance across rows).
|
||||
useEffect(() => {
|
||||
@@ -171,22 +159,6 @@ export default function QuickReviewDialog({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [transaction])
|
||||
|
||||
// Company settings gate the dimension affordance (dimensions_enabled).
|
||||
// Fetched once per open; on failure the picker simply stays hidden.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then(({ data }) => {
|
||||
if (!cancelled) setDimensionsEnabled(data?.dimensions_enabled === true)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDimensionsEnabled(false)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [open])
|
||||
|
||||
// Backfill the SEK conversion on demand. resolveSekAmount silently falls
|
||||
// back to the raw foreign amount when amount_sek/exchange_rate are null,
|
||||
// which means the user would see misleading "kr" values in the verifikation
|
||||
|
||||
Reference in New Issue
Block a user