567fae654c
The bookkeeping dialogs were the customer's "fields load late" in its purest form: Bokför (TransactionBookingDialog + the embedded JournalEntryForm) issued five requests on every open (fiscal periods, accounts, settings, cash accounts, then the voucher preview once the first two had landed), Nytt verifikat the same minus one, BookDirectlyDialog four, and the template dialogs two. Each Radix dialog unmounts on close, so every reopen paid the full price again, and several fields visibly flipped: the bank line seeded '1930' then rewrote itself, the series defaulted to 'A' until settings arrived, the period select was empty. All of them now read lib/reference-data (seeded by the dashboard layout): - JournalEntryForm: periods, accounts and settings from the hooks; dimensionsEnabled derived, not fetched; the voucher-number preview is keyed on the entry date (the route resolves the period from it) so it fires as soon as the series is known instead of after the period fetch; after activating accounts it invalidates the shared accounts cache; the create-period dialog callback invalidates the periods cache. - TransactionBookingDialog: settlement account and its name derived with useMemo from the cached cash accounts; the form mounts on the first paint. - BookDirectlyDialog: cash accounts, periods and accounts from the hooks; the '1930'-then-rewrite disappears because the resolved account is known on the first render. - TemplateBookDialog, BookingTemplatePicker, TemplatePicker: templates (and periods) from the hooks. - BookingTemplatesPanel (delete, import) and CreatePeriodDialog (create) invalidate the corresponding cache entries so every picker sees the change at once. - fetchers.ts: booking templates are booking_templates rows (BookingTemplateLibrary), not the static BookingTemplate shape. Per open: Bokför 5 requests -> 0 blocking (voucher preview is a non-blocking hint), Nytt verifikat 5 -> 1 non-blocking, BookDirectly 4 -> 0, Mall 2 -> 0, template pickers 1 -> 0. raw-reference-fetch ratchet: 51 -> 46 files. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
111 lines
3.8 KiB
TypeScript
111 lines
3.8 KiB
TypeScript
/**
|
|
* Fetchers behind the reference-data hooks. Pure async functions so they can
|
|
* be unit-tested without React and reused by `preload()` warm-ups.
|
|
*
|
|
* Two transports, chosen per data set:
|
|
* - Browser Supabase for the trivial RLS-scoped selects (fiscal periods,
|
|
* cash accounts). These mirror the corresponding API routes exactly
|
|
* (period.list ordering, listForCompany ordering) and save the proxy +
|
|
* route-wrapper round trips the API path pays.
|
|
* - `/api/...` for lists whose route does real work the client must not
|
|
* reimplement: accounts (list_company_accounts RPC with the paged
|
|
* fallback), dimensions (ensure_company_dimensions + pagination),
|
|
* booking templates (team scoping + last-used ordering), customers
|
|
* (personal-number masking), suppliers and articles.
|
|
*
|
|
* Do not import lib/cash-accounts/service.ts here: it pulls lib/logger and
|
|
* the account-sync module into the client bundle. The two order() clauses
|
|
* are mirrored instead and pinned by a test.
|
|
*/
|
|
|
|
import { createClient } from '@/lib/supabase/client'
|
|
import type {
|
|
Article,
|
|
BASAccount,
|
|
BookingTemplateLibrary,
|
|
CashAccount,
|
|
Customer,
|
|
FiscalPeriod,
|
|
Supplier,
|
|
} from '@/types'
|
|
import type { DimensionDto } from '@/components/dimensions/types'
|
|
|
|
export class ReferenceFetchError extends Error {
|
|
readonly status: number
|
|
readonly body: unknown
|
|
|
|
constructor(url: string, status: number, body: unknown) {
|
|
super(`Reference data request failed: ${status} ${url}`)
|
|
this.name = 'ReferenceFetchError'
|
|
this.status = status
|
|
this.body = body
|
|
}
|
|
}
|
|
|
|
/** A booking_templates row as the list route returns it, with its last-used stamp. */
|
|
export type BookingTemplateWithUsage = BookingTemplateLibrary & { last_used_at: string | null }
|
|
|
|
export async function fetchFiscalPeriods(companyId: string): Promise<FiscalPeriod[]> {
|
|
const supabase = createClient()
|
|
const { data, error } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.order('period_start', { ascending: false })
|
|
if (error) throw error
|
|
return (data ?? []) as FiscalPeriod[]
|
|
}
|
|
|
|
export async function fetchCashAccounts(companyId: string): Promise<CashAccount[]> {
|
|
const supabase = createClient()
|
|
const { data, error } = await supabase
|
|
.from('cash_accounts')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.order('is_primary', { ascending: false })
|
|
.order('ledger_account', { ascending: true })
|
|
if (error) throw error
|
|
return (data ?? []) as CashAccount[]
|
|
}
|
|
|
|
async function getJson<T>(url: string, pick: (body: Record<string, unknown>) => unknown): Promise<T> {
|
|
const res = await fetch(url)
|
|
let body: unknown = null
|
|
try {
|
|
body = await res.json()
|
|
} catch {
|
|
body = null
|
|
}
|
|
if (!res.ok) throw new ReferenceFetchError(url, res.status, body)
|
|
const picked = pick((body ?? {}) as Record<string, unknown>)
|
|
return (picked ?? []) as T
|
|
}
|
|
|
|
export function fetchAccounts(activeOnly = true): Promise<BASAccount[]> {
|
|
const url = activeOnly
|
|
? '/api/bookkeeping/accounts'
|
|
: '/api/bookkeeping/accounts?active=false'
|
|
return getJson<BASAccount[]>(url, (b) => b.data)
|
|
}
|
|
|
|
export function fetchDimensions(): Promise<DimensionDto[]> {
|
|
return getJson<DimensionDto[]>('/api/dimensions', (b) => b.dimensions)
|
|
}
|
|
|
|
export function fetchBookingTemplates(): Promise<BookingTemplateWithUsage[]> {
|
|
return getJson<BookingTemplateWithUsage[]>('/api/settings/booking-templates', (b) => b.data)
|
|
}
|
|
|
|
export function fetchCustomers(): Promise<Customer[]> {
|
|
return getJson<Customer[]>('/api/customers', (b) => b.data)
|
|
}
|
|
|
|
export function fetchSuppliers(): Promise<Supplier[]> {
|
|
return getJson<Supplier[]>('/api/suppliers', (b) => b.data)
|
|
}
|
|
|
|
export function fetchArticles(includeInactive = false): Promise<Article[]> {
|
|
const url = includeInactive ? '/api/articles?include_inactive=1' : '/api/articles'
|
|
return getJson<Article[]>(url, (b) => b.data)
|
|
}
|