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:
Jakob Wennberg
2026-08-26 14:50:26 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 40e773548c
commit 4560ccbfc9
16 changed files with 228 additions and 332 deletions
+8 -17
View File
@@ -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)
+16 -14
View File
@@ -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()
+12 -19
View File
@@ -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<BASAccount[]>([])
// 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<string | null>(null)
@@ -90,22 +98,6 @@ export default function ArticleForm({
// fetch fails so the Select is never empty.
const [currencies, setCurrencies] = useState<CurrencyOption[]>([])
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)
}}
+4 -9
View File
@@ -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<Record<string, string>>({})
const [tax, setTax] = useState<EmployeeTaxValue>({
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 }
@@ -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)
@@ -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<string, unknown>) => 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)
})
})
@@ -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,
}
}
@@ -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<Supplier[]>([])
const [suppliersLoaded, setSuppliersLoaded] = useState(false)
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [entityType, setEntityType] = useState<EntityType>('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<boolean>(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<FiscalPeriod[]>([])
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<boolean | null>(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,
}
}
+7 -21
View File
@@ -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<void>
@@ -28,25 +29,10 @@ export default function SupplierForm({
}: SupplierFormProps) {
const { canWrite } = useCanWrite()
const t = useTranslations('form_supplier')
const [accounts, setAccounts] = useState<BASAccount[]>([])
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
+7 -40
View File
@@ -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', {
+2 -21
View File
@@ -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) {
+10 -19
View File
@@ -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
+7 -35
View File
@@ -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
+1 -11
View File
@@ -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"
]