diff --git a/app/(dashboard)/customers/page.tsx b/app/(dashboard)/customers/page.tsx index 612cde86..3a2dd935 100644 --- a/app/(dashboard)/customers/page.tsx +++ b/app/(dashboard)/customers/page.tsx @@ -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(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) diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index 6afafcc7..b6809de1 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -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 | 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 diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index b8c0bb6e..41de5c9d 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -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([]) - 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() diff --git a/components/articles/ArticleForm.tsx b/components/articles/ArticleForm.tsx index f98f2ea8..f57c7c4e 100644 --- a/components/articles/ArticleForm.tsx +++ b/components/articles/ArticleForm.tsx @@ -1,6 +1,8 @@ 'use client' import { useEffect, useMemo, useState } from 'react' +import { useAccounts } from '@/lib/reference-data/hooks' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' @@ -21,7 +23,7 @@ import { useCompany } from '@/contexts/CompanyContext' import { createClient } from '@/lib/supabase/client' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' -import type { BASAccount, CreateArticleInput } from '@/types' +import type { CreateArticleInput } from '@/types' import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account' import { ROT_WORK_TYPES, @@ -78,7 +80,13 @@ export default function ArticleForm({ // unknown 4-digit numbers optimistically: the API answers with // ACCOUNTS_NOT_IN_CHART for activatable BAS accounts, and the host page's // ActivateAccountsDialog flow takes over (same UX as the journal entry form). - const [postingAccounts, setPostingAccounts] = useState([]) + // Balance-sheet and revenue accounts for the posting override, from the + // session cache (lib/reference-data): populated on the first paint. + const { accounts: activeAccounts } = useAccounts() + const postingAccounts = useMemo( + () => activeAccounts.filter((account) => account.account_class >= 1 && account.account_class <= 3), + [activeAccounts], + ) // Inline account creation: what the user typed in the combobox when they hit // "Skapa konto": non-null opens AddAccountDialog prefilled with it. const [createAccountPrefill, setCreateAccountPrefill] = useState(null) @@ -90,22 +98,6 @@ export default function ArticleForm({ // fetch fails so the Select is never empty. const [currencies, setCurrencies] = useState([]) - async function fetchRevenueAccounts() { - try { - const res = await fetch('/api/bookkeeping/accounts') - const body = await res.json() - const accounts = ((body?.data as BASAccount[]) || []) - .filter((account) => account.account_class >= 1 && account.account_class <= 3) - setPostingAccounts(accounts) - } catch { - // Non-fatal: the combobox degrades to free 4-digit entry. - } - } - - useEffect(() => { - fetchRevenueAccounts() - }, []) - // Currency options come from the currencies reference table: one source of // truth, no hard-coded list. useEffect(() => { @@ -604,7 +596,8 @@ export default function ArticleForm({ : undefined } onCreated={async (account) => { - await fetchRevenueAccounts() + // The new account must be in the shared chart before the field points at it. + await invalidateReferenceData('ref:accounts') setValue('revenue_account', account.account_number, { shouldDirty: true }) setCreateAccountPrefill(null) }} diff --git a/components/salary/NewEmployeeDialog.tsx b/components/salary/NewEmployeeDialog.tsx index 6de7048f..f06dd579 100644 --- a/components/salary/NewEmployeeDialog.tsx +++ b/components/salary/NewEmployeeDialog.tsx @@ -1,6 +1,7 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState } from 'react' +import { useCompanySettings } from '@/lib/reference-data/hooks' import { Dialog, DialogContent, @@ -116,7 +117,8 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan // Default dimensions bag ({sie_dim_no: object_code}) proposed on the // employee's salary-cost lines at booking. The fields render only when // company_settings.dimensions_enabled: same UI gate as the voucher form. - const [dimensionsEnabled, setDimensionsEnabled] = useState(false) + const { settings: companySettings } = useCompanySettings() + const dimensionsEnabled = companySettings?.dimensions_enabled === true const [dimensions, setDimensions] = useState>({}) const [tax, setTax] = useState({ f_skatt_status: 'a_skatt', @@ -126,13 +128,6 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan tax_municipality: '', }) - useEffect(() => { - fetch('/api/settings') - .then((r) => r.json()) - .then(({ data }) => setDimensionsEnabled(data?.dimensions_enabled === true)) - .catch(() => {/* keep the dimension fields hidden */}) - }, []) - function setDimension(dimNo: string, code: string | null) { setDimensions((prev) => { const next = { ...prev } diff --git a/components/supplier-invoices/NewSupplierInvoiceForm.tsx b/components/supplier-invoices/NewSupplierInvoiceForm.tsx index 96435f4c..c704abec 100644 --- a/components/supplier-invoices/NewSupplierInvoiceForm.tsx +++ b/components/supplier-invoices/NewSupplierInvoiceForm.tsx @@ -186,7 +186,7 @@ export default function NewSupplierInvoiceForm({ const { suppliers, - setSuppliers, + refreshSuppliers, suppliersLoaded, accounts, entityType, @@ -1117,7 +1117,9 @@ export default function NewSupplierInvoiceForm({ toast({ title: t('create_supplier_failed_title'), description: getErrorMessage(result, { context: 'supplier' }), variant: 'destructive' }) } else { const created = result.data as Supplier - setSuppliers((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name))) + // The shared supplier list feeds every picker: refresh it (awaited, so + // the pending selection below finds the new row). + await refreshSuppliers() setPendingSupplierSelect(created.id) setHasMatchedSupplier(true) setShowNewSupplier(false) diff --git a/components/supplier-invoices/__tests__/supplier-invoice-defaults.test.ts b/components/supplier-invoices/__tests__/supplier-invoice-defaults.test.ts new file mode 100644 index 00000000..51da0c50 --- /dev/null +++ b/components/supplier-invoices/__tests__/supplier-invoice-defaults.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { deriveSupplierInvoiceDefaults } from '../supplier-invoice-defaults' +import type { CompanySettings } from '@/types' + +const settings = (overrides: Record) => overrides as unknown as CompanySettings + +describe('deriveSupplierInvoiceDefaults', () => { + it('uses registered-company defaults while settings are missing', () => { + expect(deriveSupplierInvoiceDefaults(null)).toEqual({ + entityType: 'enskild_firma', + accountingMethod: 'accrual', + oreRounding: true, + dimensionsEnabled: false, + vatRegistered: true, + }) + expect(deriveSupplierInvoiceDefaults(undefined, 'aktiebolag').entityType).toBe('aktiebolag') + }) + + it('reads every gate from the settings row', () => { + expect( + deriveSupplierInvoiceDefaults( + settings({ + entity_type: 'aktiebolag', + accounting_method: 'cash', + ore_rounding: false, + dimensions_enabled: true, + vat_registered: false, + }), + ), + ).toEqual({ + entityType: 'aktiebolag', + accountingMethod: 'cash', + oreRounding: false, + dimensionsEnabled: true, + vatRegistered: false, + }) + }) + + it('only an explicit vat_registered=false gates VAT; null keeps the registered behaviour', () => { + expect(deriveSupplierInvoiceDefaults(settings({ vat_registered: null })).vatRegistered).toBe(true) + expect(deriveSupplierInvoiceDefaults(settings({})).vatRegistered).toBe(true) + }) + + it('falls back to the company entity type only when the settings row has none', () => { + expect(deriveSupplierInvoiceDefaults(settings({ entity_type: null }), 'aktiebolag').entityType).toBe('aktiebolag') + expect(deriveSupplierInvoiceDefaults(settings({ entity_type: 'enskild_firma' }), 'aktiebolag').entityType).toBe( + 'enskild_firma', + ) + }) + + it('treats an unknown accounting method or non-boolean rounding as the defaults', () => { + const d = deriveSupplierInvoiceDefaults(settings({ accounting_method: 'weird', ore_rounding: 'yes' })) + expect(d.accountingMethod).toBe('accrual') + expect(d.oreRounding).toBe(true) + }) +}) diff --git a/components/supplier-invoices/supplier-invoice-defaults.ts b/components/supplier-invoices/supplier-invoice-defaults.ts new file mode 100644 index 00000000..43347ab4 --- /dev/null +++ b/components/supplier-invoices/supplier-invoice-defaults.ts @@ -0,0 +1,38 @@ +import type { CompanySettings, EntityType } from '@/types' + +export interface SupplierInvoiceDefaults { + entityType: EntityType + accountingMethod: 'accrual' | 'cash' + /** Company-wide öresavrundning default; overridable per invoice. */ + oreRounding: boolean + /** UI gate for kostnadsställe/projekt affordances (same as JournalEntryForm). */ + dimensionsEnabled: boolean + /** + * Icke momsregistrerad verksamhet has no right to deduct input VAT: the + * moms controls disappear and every line books at 0 % (the gross amount IS + * the cost). Only an explicit false gates: a missing column keeps the + * registered-company behaviour. + */ + vatRegistered: boolean +} + +/** + * Pure derivation of the supplier-invoice editor's settings-driven defaults. + * `fallbackEntityType` is the company row's entity type: /api/settings used + * to fall back to it when company_settings.entity_type is null, and the + * cached settings row does not, so the caller passes it explicitly. + */ +export function deriveSupplierInvoiceDefaults( + settings: CompanySettings | null | undefined, + fallbackEntityType?: EntityType | null, +): SupplierInvoiceDefaults { + const entityType = + (settings?.entity_type as EntityType | null | undefined) ?? fallbackEntityType ?? 'enskild_firma' + return { + entityType, + accountingMethod: settings?.accounting_method === 'cash' ? 'cash' : 'accrual', + oreRounding: typeof settings?.ore_rounding === 'boolean' ? settings.ore_rounding : true, + dimensionsEnabled: settings?.dimensions_enabled === true, + vatRegistered: settings?.vat_registered !== false, + } +} diff --git a/components/supplier-invoices/use-supplier-invoice-data.ts b/components/supplier-invoices/use-supplier-invoice-data.ts index c6b53b84..3dea36bd 100644 --- a/components/supplier-invoices/use-supplier-invoice-data.ts +++ b/components/supplier-invoices/use-supplier-invoice-data.ts @@ -1,102 +1,58 @@ 'use client' -import { useState, useEffect } from 'react' -import type { Supplier, BASAccount, EntityType, FiscalPeriod } from '@/types' +import { useCallback, useMemo, useState } from 'react' +import { useCompanyOptional } from '@/contexts/CompanyContext' +import { + useAccounts, + useCompanySettings, + useFiscalPeriods, + useSuppliers, +} from '@/lib/reference-data/hooks' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' +import { deriveSupplierInvoiceDefaults } from './supplier-invoice-defaults' /** * Reference data for the supplier-invoice editor: suppliers, the BAS chart, * company settings (entity type, accounting method, öresavrundning default, - * dimensions, VAT registration) and fiscal periods. Extracted verbatim from - * NewSupplierInvoiceForm; the fetch-once-on-mount semantics are unchanged. + * dimensions, VAT registration) and fiscal periods. + * + * All of it comes from the session cache (lib/reference-data), seeded by the + * dashboard layout, so the form renders with its moms controls, period and + * account picker already resolved on the first paint instead of defaulting + * (vatRegistered=true, series, period) and flipping once four fetches land. + * The settings-driven values are derived (deriveSupplierInvoiceDefaults), + * never copied into state, so a background revalidation cannot get them out + * of step; the per-invoice öresavrundning toggle is the one local override. */ export function useSupplierInvoiceData() { - const [suppliers, setSuppliers] = useState([]) - const [suppliersLoaded, setSuppliersLoaded] = useState(false) - const [accounts, setAccounts] = useState([]) - const [entityType, setEntityType] = useState('enskild_firma') - const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual') - // Öresavrundning is display-only; defaults to the company-wide setting and is - // overridable per invoice via the toggle in the totals section. - const [oreRounding, setOreRounding] = useState(true) - // Dimension tagging (kostnadsställe/projekt). Affordances render only when - // company_settings.dimensions_enabled: the same UI-visibility gate as - // JournalEntryForm. - const [dimensionsEnabled, setDimensionsEnabled] = useState(false) - // Icke momsregistrerad verksamhet has no right to deduct input VAT: the - // moms controls disappear and every line books at 0 % (the gross amount IS - // the cost). Defaults true so registered companies keep the 25 % prefill - // while /api/settings is still in flight. - const [vatRegistered, setVatRegistered] = useState(true) - const [periods, setPeriods] = useState([]) - const [periodsLoaded, setPeriodsLoaded] = useState(false) + const company = useCompanyOptional()?.company ?? null + const { suppliers, isLoading: suppliersLoading } = useSuppliers() + const { accounts } = useAccounts() + const { settings } = useCompanySettings() + const { periods, isLoading: periodsLoading } = useFiscalPeriods() - useEffect(() => { - async function fetchSuppliers() { - try { - const res = await fetch('/api/suppliers') - const { data } = await res.json() - setSuppliers(data || []) - } finally { - setSuppliersLoaded(true) - } - } + const defaults = useMemo( + () => deriveSupplierInvoiceDefaults(settings, company?.entity_type ?? null), + [settings, company?.entity_type], + ) + const [oreRoundingOverride, setOreRoundingOverride] = useState(null) + const setOreRounding = useCallback((value: boolean) => setOreRoundingOverride(value), []) - async function fetchAccounts() { - const res = await fetch('/api/bookkeeping/accounts') - const { data } = await res.json() - setAccounts(data || []) - } - - async function fetchEntityType() { - try { - const res = await fetch('/api/settings') - const { data } = await res.json() - if (data?.entity_type) setEntityType(data.entity_type) - // Cash method books at payment, not registration: drives whether the - // out-of-period warning is relevant (see willBookAtRegistration). - if (data?.accounting_method === 'cash' || data?.accounting_method === 'accrual') { - setAccountingMethod(data.accounting_method) - } - if (typeof data?.ore_rounding === 'boolean') setOreRounding(data.ore_rounding) - setDimensionsEnabled(data?.dimensions_enabled === true) - // Only an explicit false gates: a missing column or failed fetch keeps - // the registered-company behavior. - if (data?.vat_registered === false) setVatRegistered(false) - } catch { - // Default to enskild_firma / accrual, dimension affordances hidden - } - } - - async function fetchPeriods() { - try { - const res = await fetch('/api/bookkeeping/fiscal-periods') - const { data } = await res.json() - setPeriods(data || []) - } catch { - // Non-critical: the server still hard-blocks an out-of-period booking. - } finally { - setPeriodsLoaded(true) - } - } - - fetchSuppliers() - fetchAccounts() - fetchEntityType() - fetchPeriods() - }, []) + /** After an inline supplier create: refresh the shared list everywhere. */ + const refreshSuppliers = useCallback(() => invalidateReferenceData('ref:suppliers'), []) return { suppliers, - setSuppliers, - suppliersLoaded, + refreshSuppliers, + suppliersLoaded: !suppliersLoading, accounts, - entityType, - accountingMethod, - oreRounding, + entityType: defaults.entityType, + accountingMethod: defaults.accountingMethod, + oreRounding: oreRoundingOverride ?? defaults.oreRounding, setOreRounding, - dimensionsEnabled, - vatRegistered, + dimensionsEnabled: defaults.dimensionsEnabled, + vatRegistered: defaults.vatRegistered, periods, - periodsLoaded, + periodsLoaded: !periodsLoading, } } diff --git a/components/suppliers/SupplierForm.tsx b/components/suppliers/SupplierForm.tsx index ccaabe7d..d1a09c63 100644 --- a/components/suppliers/SupplierForm.tsx +++ b/components/suppliers/SupplierForm.tsx @@ -1,6 +1,7 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useMemo } from 'react' +import { useAccounts } from '@/lib/reference-data/hooks' import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' @@ -13,7 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Loader2, Lock, X } from 'lucide-react' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { useCanWrite } from '@/lib/hooks/use-can-write' -import type { BASAccount, CreateSupplierInput } from '@/types' +import type { CreateSupplierInput } from '@/types' interface SupplierFormProps { onSubmit: (data: CreateSupplierInput) => Promise @@ -28,25 +29,10 @@ export default function SupplierForm({ }: SupplierFormProps) { const { canWrite } = useCanWrite() const t = useTranslations('form_supplier') - const [accounts, setAccounts] = useState([]) - - useEffect(() => { - let cancelled = false - async function fetchAccounts() { - try { - const res = await fetch('/api/bookkeeping/accounts') - if (!res.ok) return - const { data } = await res.json() - if (!cancelled) setAccounts(data || []) - } catch { - // Without the chart the combobox still accepts a typed 4-digit number. - } - } - fetchAccounts() - return () => { - cancelled = true - } - }, []) + // Chart of accounts from the session cache (lib/reference-data): the + // konto combobox is populated on the first paint; without the chart it + // still accepts a typed 4-digit number. + const { accounts } = useAccounts() // The default account seeds expense lines on supplier invoices, so the // browsable list is cost classes 4-7. Any other 4-digit number can still be diff --git a/components/transactions/BulkBookDialog.tsx b/components/transactions/BulkBookDialog.tsx index b7247060..4de2c5b2 100644 --- a/components/transactions/BulkBookDialog.tsx +++ b/components/transactions/BulkBookDialog.tsx @@ -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(null) // null = fetch pending; array = loaded (may be empty on error: falls back to '1930') - const [cashAccounts, setCashAccounts] = useState(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('one_line_per_tx') const [description, setDescription] = useState('') const [manualLines, setManualLines] = useState([]) @@ -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>({}) // 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. diff --git a/components/transactions/DuplicateBookingDialog.tsx b/components/transactions/DuplicateBookingDialog.tsx index 21f57980..f9889f2d 100644 --- a/components/transactions/DuplicateBookingDialog.tsx +++ b/components/transactions/DuplicateBookingDialog.tsx @@ -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', { diff --git a/components/transactions/InvoiceMatchDialog.tsx b/components/transactions/InvoiceMatchDialog.tsx index 716be894..62e0b5db 100644 --- a/components/transactions/InvoiceMatchDialog.tsx +++ b/components/transactions/InvoiceMatchDialog.tsx @@ -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('') // BAS accounts power the AccountCombobox suggestions in edit mode. Loaded // once on dialog open; same endpoint that PaymentBookingDialog uses. - const [accounts, setAccounts] = useState([]) - - 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) { diff --git a/components/transactions/MatchVoucherDialog.tsx b/components/transactions/MatchVoucherDialog.tsx index 518b4c5b..75b2f1d0 100644 --- a/components/transactions/MatchVoucherDialog.tsx +++ b/components/transactions/MatchVoucherDialog.tsx @@ -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 diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx index 4c8e36f0..80db42f5 100644 --- a/components/transactions/QuickReviewDialog.tsx +++ b/components/transactions/QuickReviewDialog.tsx @@ -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(defaultVat) - const [accounts, setAccounts] = useState([]) + // 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(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>( () => ({ ...(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 diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json index d6999cf6..9a8d637e 100644 --- a/scripts/checks/antipatterns-baseline.json +++ b/scripts/checks/antipatterns-baseline.json @@ -34,7 +34,7 @@ ] }, "rawReferenceFetch": { - "count": 45, + "count": 35, "files": [ "app/(dashboard)/assets/[id]/dispose/page.tsx", "app/(dashboard)/bookkeeping/year-end/page.tsx", @@ -45,8 +45,6 @@ "app/(dashboard)/invoices/page.tsx", "app/(dashboard)/pending/page.tsx", "app/(dashboard)/salary/employees/[id]/page.tsx", - "app/(dashboard)/salary/runs/[id]/page.tsx", - "app/(dashboard)/supplier-invoices/[id]/page.tsx", "components/articles/ArticleForm.tsx", "components/bookkeeping/ChartOfAccounts.tsx", "components/bookkeeping/ChartOfAccountsManager.tsx", @@ -67,18 +65,10 @@ "components/pending-operations/use-account-names.ts", "components/reports/SkatteverketPanel.tsx", "components/reports/views/index.tsx", - "components/salary/NewEmployeeDialog.tsx", "components/settings/BookingTemplatesPanel.tsx", "components/settings/FiscalPeriodEditor.tsx", "components/settings/FiscalYearsManager.tsx", "components/settings/InvoicePaymentAccountsSettings.tsx", - "components/supplier-invoices/use-supplier-invoice-data.ts", - "components/suppliers/SupplierForm.tsx", - "components/transactions/BulkBookDialog.tsx", - "components/transactions/DuplicateBookingDialog.tsx", - "components/transactions/InvoiceMatchDialog.tsx", - "components/transactions/MatchVoucherDialog.tsx", - "components/transactions/QuickReviewDialog.tsx", "components/transactions/TemplatePicker.tsx", "extensions/general/enable-banking/components/AccountPickerDialog.tsx" ]