diff --git a/components/bookkeeping/BookingTemplatePicker.tsx b/components/bookkeeping/BookingTemplatePicker.tsx index 1949c759..60db2692 100644 --- a/components/bookkeeping/BookingTemplatePicker.tsx +++ b/components/bookkeeping/BookingTemplatePicker.tsx @@ -1,6 +1,7 @@ 'use client' -import { useState, useEffect, useMemo, useCallback } from 'react' +import { useState, useEffect, useMemo } from 'react' +import { useBookingTemplates } from '@/lib/reference-data/hooks' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useToast } from '@/components/ui/use-toast' @@ -13,7 +14,7 @@ import { } from '@/components/ui/dialog' import { BookOpen, Search, Building2, Users, Globe } from 'lucide-react' import { TEMPLATE_CATEGORY_LABELS, SCOPE_LABELS, getTemplateScope, applyTemplate } from '@/lib/bookkeeping/template-library' -import type { BookingTemplateLibrary, BookingTemplateCategory, EntityType } from '@/types' +import type { BookingTemplateCategory, EntityType } from '@/types' import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' interface Props { @@ -33,8 +34,10 @@ const SCOPE_ICONS = { export default function BookingTemplatePicker({ onApply, entityType, defaultAmount }: Props) { const { toast } = useToast() const [open, setOpen] = useState(false) - const [templates, setTemplates] = useState([]) - const [isLoading, setIsLoading] = useState(false) + // Session-cached (lib/reference-data): the list is there on the first + // open and reopening costs no request; writes in the settings panel + // invalidate it. + const { templates, isLoading, error: templatesError } = useBookingTemplates() const [search, setSearch] = useState('') const [selectedCategory, setSelectedCategory] = useState('all') const [amount, setAmount] = useState('') @@ -49,26 +52,9 @@ export default function BookingTemplatePicker({ onApply, entityType, defaultAmou } }, [open, defaultAmount]) - const fetchTemplates = useCallback(async (signal?: AbortSignal) => { - setIsLoading(true) - try { - const r = await fetch('/api/settings/booking-templates', { signal }) - const { data } = await r.json() - setTemplates(data || []) - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return - toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' }) - } finally { - setIsLoading(false) - } - }, [toast]) - useEffect(() => { - if (!open) return - const controller = new AbortController() - fetchTemplates(controller.signal) - return () => { controller.abort() } - }, [open, fetchTemplates]) + if (open && templatesError) toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' }) + }, [open, templatesError, toast]) const filtered = useMemo(() => { let result = templates diff --git a/components/bookkeeping/CreatePeriodDialog.tsx b/components/bookkeeping/CreatePeriodDialog.tsx index 48bd5afe..a4ec7235 100644 --- a/components/bookkeeping/CreatePeriodDialog.tsx +++ b/components/bookkeeping/CreatePeriodDialog.tsx @@ -19,6 +19,7 @@ import { Loader2 } from 'lucide-react' import { computeSuggestedPeriod } from '@/lib/bookkeeping/suggest-fiscal-period' import { fiscalPeriodAdvisoryText } from '@/lib/bookkeeping/fiscal-period-warnings' import type { FiscalPeriod } from '@/types' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' interface Props { open: boolean @@ -108,6 +109,11 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri return } + // Every picker and form reads periods from the session cache + // (lib/reference-data): refresh it now so the new year is selectable + // everywhere at once, not only in the caller that gets onCreated. + void invalidateReferenceData('ref:fiscal-periods') + // The 200 may carry non-blocking advisories (today: a prior räkenskapsår // still open, which is the normal state while the bokslut runs). The // period WAS created, so this is information, never a failure: show the diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index 600a53c9..dee72af5 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -26,6 +26,8 @@ import { deriveTemplateLinesFromBooking } from '@/lib/bookkeeping/template-libra import { sourceTypeForTemplateCategory } from '@/lib/bookkeeping/template-source-type' import { TemplateForm } from '@/components/settings/TemplateForm' import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog' +import { useAccounts, useCompanySettings, useFiscalPeriods } from '@/lib/reference-data/hooks' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' import { splitCreateAccountPrefill } from '@/lib/bookkeeping/create-account-prefill' @@ -48,7 +50,7 @@ import { resolveFxLineSlot } from '@/lib/bookkeeping/fx-line-slot' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import { useCompany } from '@/contexts/CompanyContext' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' -import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency, BookingTemplateLibrary, BookingTemplateCategory } from '@/types' +import type { CreateJournalEntryLineInput, FiscalPeriod, JournalEntrySourceType, Currency, BookingTemplateLibrary, BookingTemplateCategory } from '@/types' import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection' const CURRENCIES: { value: Currency; label: string }[] = [ @@ -150,7 +152,12 @@ export default function JournalEntryForm({ // copy from this namespace. const tTpl = useTranslations('settings_booking_templates') const locale = useLocale() - const [periods, setPeriods] = useState([]) + // Session-cached reference data (lib/reference-data): seeded by the + // dashboard layout, so the period select, the account picker and the + // settings-driven defaults render populated on the first paint instead of + // after four round trips, and re-opening the dialog costs no requests. + const { periods } = useFiscalPeriods() + const { settings: companySettings } = useCompanySettings() const [selectedPeriod, setSelectedPeriod] = useState('') const [entryDate, setEntryDate] = useState(initialDate ?? new Date().toISOString().split('T')[0]) const [description, setDescription] = useState(initialDescription ?? '') @@ -160,7 +167,7 @@ export default function JournalEntryForm({ // when company_settings.dimensions_enabled: a UI-visibility gate; lines // that already carry dimensions (e.g. a draft being edited) still round-trip // untouched when the toggle is off. - const [dimensionsEnabled, setDimensionsEnabled] = useState(false) + const dimensionsEnabled = companySettings?.dimensions_enabled === true const [showDims, setShowDims] = useState(false) // Header-level default dims ("gäller alla rader"). The per-row maps on // `lines` are the ONE source of truth: this state only drives the header @@ -209,7 +216,7 @@ export default function JournalEntryForm({ // enabled so the attempt can happen; the handlers gate on validity. const [showValidationHints, setShowValidationHints] = useState(false) const [uploadedFiles, setUploadedFiles] = useState([]) - const [accounts, setAccounts] = useState([]) + const { accounts } = useAccounts() // Full BAS catalogue (static reference data, fetched once per session). Lets // the account picker surface standard accounts the company hasn't activated // yet; picking one activates it at commit via the existing rail. @@ -258,31 +265,6 @@ export default function JournalEntryForm({ uploadedFiles.length > 0 useUnsavedChanges(hasContent) - async function fetchPeriods() { - const res = await fetch('/api/bookkeeping/fiscal-periods') - const { data } = await res.json() - const fetched: FiscalPeriod[] = data || [] - setPeriods(fetched) - - // Auto-select period matching the current entry date - const match = fetched.find( - (p) => entryDate >= p.period_start && entryDate <= p.period_end - ) - if (match) { - setSelectedPeriod(match.id) - setPeriodMismatch(null) - } else if (fetched.length > 0) { - setSelectedPeriod(fetched[0].id) - setPeriodMismatch('no_period') - } - } - - async function fetchAccounts() { - const res = await fetch('/api/bookkeeping/accounts') - const { data } = await res.json() - setAccounts(data || []) - } - // Resolve + set the default voucher series for a source type from the cached // company config: prefer the per-source-type mapping, fall back to the legacy // default_voucher_series, then to 'A'. Reads refs (stable), so it can run both @@ -293,28 +275,28 @@ export default function JournalEntryForm({ }, []) useEffect(() => { - fetchPeriods() - fetchAccounts() loadBasCatalog().then(setCatalog).catch(() => {/* search degrades to the active chart */}) - // Company settings power two things here: dimensions_enabled gates the - // tagging affordances (all modes, incl. the TransactionBookingDialog - // embed), and the default voucher series seeds the standalone form: - // prefer the per-source-type mapping when present; fall back to the legacy - // default_voucher_series, then to 'A'. In edit mode the draft's own series - // is pre-filled: never override it from the company defaults. - fetch('/api/settings').then(r => r.json()).then(({ data }) => { - if (!data) return - setDimensionsEnabled(data.dimensions_enabled === true) - seriesMapRef.current = - (data.default_voucher_series_per_source_type as Record | null) ?? null - defaultSeriesRef.current = data.default_voucher_series || 'A' - if (!embedded && !editEntryId) { - applySeriesForSourceType(effectiveSourceTypeRef.current) - } - }).catch(() => {/* keep 'A' + hidden dimension affordances */}) - }, [embedded, sourceType, editEntryId, applySeriesForSourceType]) + }, []) - // Auto-select period when entry date changes + // Company settings seed the default voucher series for the standalone form: + // prefer the per-source-type mapping when present; fall back to the legacy + // default_voucher_series, then to 'A'. In edit mode the draft's own series + // is pre-filled: never override it from the company defaults. (dimensions_ + // enabled, which gates the tagging affordances in all modes incl. the + // TransactionBookingDialog embed, is derived directly above.) + useEffect(() => { + if (!companySettings) return + seriesMapRef.current = + (companySettings.default_voucher_series_per_source_type as Record | null) ?? null + defaultSeriesRef.current = companySettings.default_voucher_series || 'A' + if (!embedded && !editEntryId) { + applySeriesForSourceType(effectiveSourceTypeRef.current) + } + }, [companySettings, embedded, sourceType, editEntryId, applySeriesForSourceType]) + + // Auto-select the period matching the entry date (on load and whenever the + // date changes). With no match, fall back to the newest period only when + // nothing is selected yet, and flag the mismatch either way. useEffect(() => { if (periods.length === 0) return const match = periods.find( @@ -324,6 +306,7 @@ export default function JournalEntryForm({ setSelectedPeriod(match.id) setPeriodMismatch(null) } else { + setSelectedPeriod((current) => current || periods[0].id) setPeriodMismatch('no_period') } }, [entryDate, periods]) @@ -349,12 +332,15 @@ export default function JournalEntryForm({ // Read-only hint; the actual number is reserved atomically at commit time, // so this may shift by one if another entry lands first. useEffect(() => { - if (embedded || !selectedPeriod || !voucherSeries) { + if (embedded || !entryDate || !voucherSeries) { setNextVoucherNumber(null) return } let cancelled = false - const qs = new URLSearchParams({ period_id: selectedPeriod, series: voucherSeries }) + // Keyed on the entry date rather than the resolved period so the preview + // fires as soon as the series is known: the route resolves the period + // from the date itself, which is exactly how selectedPeriod is derived. + const qs = new URLSearchParams({ date: entryDate, series: voucherSeries }) fetch(`/api/bookkeeping/voucher-sequences/next?${qs}`) .then((r) => (r.ok ? r.json() : null)) .then((body) => { @@ -368,7 +354,7 @@ export default function JournalEntryForm({ return () => { cancelled = true } - }, [embedded, selectedPeriod, voucherSeries]) + }, [embedded, entryDate, voucherSeries]) // Fetch exchange rate from Riksbanken when currency changes const fetchRate = useCallback(async (currency: Currency) => { @@ -821,7 +807,7 @@ export default function JournalEntryForm({ // reactivating an existing account, where the rest of the row is whatever // the company already had stored and is picked up by fetchAccounts. const handleAccountCreated = async (account: { account_number: string }) => { - await fetchAccounts() + await invalidateReferenceData('ref:accounts') if (creatingAccountForLine != null) { updateLine(creatingAccountForLine, 'account_number', account.account_number) } @@ -2136,7 +2122,7 @@ export default function JournalEntryForm({ onOpenChange={setShowCreatePeriod} entryDate={entryDate} periods={periods} - onCreated={fetchPeriods} + onCreated={() => void invalidateReferenceData('ref:fiscal-periods')} /> {/* Clear-all confirmation */} diff --git a/components/bookkeeping/TemplateBookDialog.tsx b/components/bookkeeping/TemplateBookDialog.tsx index 5be8b404..989d89a4 100644 --- a/components/bookkeeping/TemplateBookDialog.tsx +++ b/components/bookkeeping/TemplateBookDialog.tsx @@ -23,7 +23,8 @@ import { import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { roundOre } from '@/lib/money' import { ArrowLeft, Check, ChevronRight, Loader2, Search } from 'lucide-react' -import type { BookingTemplateLibrary, FiscalPeriod } from '@/types' +import type { BookingTemplateLibrary } from '@/types' +import { useBookingTemplates, useFiscalPeriods } from '@/lib/reference-data/hooks' import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' interface Props { @@ -49,40 +50,17 @@ export default function TemplateBookDialog({ open, onOpenChange, onCreated }: Pr const t = useTranslations('bookkeeping') const { toast } = useToast() - const [templates, setTemplates] = useState(null) - const [periods, setPeriods] = useState([]) + // Session-cached (lib/reference-data): opening the dialog costs no + // requests once the lists are in the cache. null = still loading. + const { templates: cachedTemplates, isLoading: templatesLoading } = useBookingTemplates() + const templates: BookingTemplateLibrary[] | null = templatesLoading ? null : cachedTemplates + const { periods } = useFiscalPeriods() const [search, setSearch] = useState('') const [selected, setSelected] = useState(null) const [entryDate, setEntryDate] = useState(() => new Date().toISOString().split('T')[0]) const [amountInput, setAmountInput] = useState('') const [submitting, setSubmitting] = useState(false) - // Load templates + fiscal periods when the dialog opens. - useEffect(() => { - if (!open) return - let cancelled = false - ;(async () => { - const [tplRes, periodRes] = await Promise.all([ - fetch('/api/settings/booking-templates'), - fetch('/api/bookkeeping/fiscal-periods'), - ]) - if (cancelled) return - if (tplRes.ok) { - const { data } = await tplRes.json() - if (!cancelled) setTemplates(data ?? []) - } else { - setTemplates([]) - } - if (periodRes.ok) { - const { data } = await periodRes.json() - if (!cancelled) setPeriods(data ?? []) - } - })() - return () => { - cancelled = true - } - }, [open]) - // Reset per open so yesterday's half-typed amount never leaks into today. useEffect(() => { if (open) return diff --git a/components/extensions/general/BookDirectlyDialog.tsx b/components/extensions/general/BookDirectlyDialog.tsx index bc827b27..3f8423ff 100644 --- a/components/extensions/general/BookDirectlyDialog.tsx +++ b/components/extensions/general/BookDirectlyDialog.tsx @@ -25,6 +25,7 @@ import { TemplateForm } from '@/components/settings/TemplateForm' import { deriveTemplateLinesFromBooking } from '@/lib/bookkeeping/template-library' import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' import { useCompany } from '@/contexts/CompanyContext' +import { useAccounts, useCashAccounts, useFiscalPeriods } from '@/lib/reference-data/hooks' import { useSubmitWithAccountActivation, throwOnStructuredError, @@ -36,7 +37,7 @@ import { resolveAccount } from '@/lib/cash-accounts/resolve-account' import { renderChannelContextNotes } from '@/lib/documents/channel-context-notes' import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates' import { AttnLine } from '@/components/ui/attn-line' -import type { BASAccount, BookingTemplateLibrary, CashAccount, FiscalPeriod, InboxChannelContext, InvoiceExtractionResult } from '@/types' +import type { BookingTemplateLibrary, CashAccount, InboxChannelContext, InvoiceExtractionResult } from '@/types' interface InboxItem { id: string @@ -185,11 +186,15 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = // pending, or unsupported. const [fxRate, setFxRate] = useState(null) - // null = fetch pending; array = loaded (may be empty on error: falls back to '1930') - const [cashAccounts, setCashAccounts] = useState(null) - - const [periods, setPeriods] = useState([]) - const [accounts, setAccounts] = useState([]) + // Session-cached reference data (lib/reference-data), seeded by the + // dashboard layout: the settlement account, the period and the account + // picker are known on the first paint instead of after three round trips + // per open. cashAccounts stays null only while the list is still loading + // (no seed): the prefill effect below reads that as "not resolved yet". + const { cashAccounts: cachedCashAccounts, isLoading: cashAccountsLoading } = useCashAccounts() + const cashAccounts: CashAccount[] | null = cashAccountsLoading ? null : cachedCashAccounts + const { periods } = useFiscalPeriods() + const { accounts } = useAccounts() // Full BAS catalogue (static reference data, fetched once per session). Lets // the account picker surface standard accounts the company hasn't activated // yet; picking one activates it at commit via the existing @@ -316,29 +321,6 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, targetCurrency, item.id]) - // Fetch cash accounts once when the dialog opens so the settlement line can - // be routed to the correct ledger account instead of the hardcoded '1930'. - 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 } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, item.id]) - // SEK-equivalent of the underlag total: the anchor for ranking candidates. const targetSek = useMemo(() => { if (targetAmount == null) return null @@ -403,25 +385,11 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = }) }, [open, item, selectedTransactionAmount, bankAccount]) - // Fetch fiscal periods and accounts on first open + // Load the static BAS catalogue on first open (periods and accounts come + // from the session cache above). useEffect(() => { if (!open) return let cancelled = false - ;(async () => { - try { - const [periodsRes, accountsRes] = await Promise.all([ - fetch('/api/bookkeeping/fiscal-periods'), - fetch('/api/bookkeeping/accounts'), - ]) - const periodsJson = await periodsRes.json() - const accountsJson = await accountsRes.json() - if (cancelled) return - setPeriods(periodsJson.data || []) - setAccounts(accountsJson.data || []) - } catch (err) { - console.error('[book-direct] fetch reference data failed:', err) - } - })() loadBasCatalog().then((data) => { if (!cancelled) setCatalog(data) }).catch(() => {/* search degrades to the active chart */}) diff --git a/components/settings/BookingTemplatesPanel.tsx b/components/settings/BookingTemplatesPanel.tsx index 77f3f96d..3e9056c3 100644 --- a/components/settings/BookingTemplatesPanel.tsx +++ b/components/settings/BookingTemplatesPanel.tsx @@ -22,6 +22,7 @@ import { downloadFile } from '@/lib/browser/download-file' import type { ErrorLocale } from '@/lib/errors/get-error-message' import { cn } from '@/lib/utils' import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' export function BookingTemplatesPanel() { const t = useTranslations('settings_booking_templates') @@ -73,6 +74,9 @@ export function BookingTemplatesPanel() { return } setTemplates((prev) => prev.filter((tt) => tt.id !== id)) + // The pickers read the session cache (lib/reference-data): drop the + // deleted template there too, not just from this panel's own list. + void invalidateReferenceData('ref:booking-templates') toast({ title: t('toast_deleted') }) } finally { setDeletingId(null) @@ -125,6 +129,7 @@ export function BookingTemplatesPanel() { } toast({ title: t('toast_import_done'), description: t('toast_import_count', { count: json.imported }) }) fetchTemplates() + void invalidateReferenceData('ref:booking-templates') } catch { toast({ title: t('toast_import_error'), description: t('toast_invalid_file'), variant: 'destructive' }) } finally { diff --git a/components/transactions/TemplatePicker.tsx b/components/transactions/TemplatePicker.tsx index 6344c137..61195f8e 100644 --- a/components/transactions/TemplatePicker.tsx +++ b/components/transactions/TemplatePicker.tsx @@ -25,6 +25,7 @@ import { convertLibraryToBookingTemplate, LIBRARY_TEMPLATE_PREFIX, isLibraryTemp import { getAccountName } from '@/lib/bookkeeping/client-account-names' import type { BookingTemplateLibrary, EntityType } from '@/types' import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions' +import { useBookingTemplates } from '@/lib/reference-data/hooks' // Cap on the "Konton" search-result group: enough to cover sibling accounts // on a number-prefix query without drowning the template results. @@ -264,7 +265,16 @@ export default function TemplatePicker({ const t = useTranslations('tx_template_picker') const [searchQuery, setSearchQuery] = useState('') const [showAdvanced, setShowAdvanced] = useState(false) - const [libraryRaw, setLibraryRaw] = useState([]) + // The user's library templates (company + team scope), session-cached + // (lib/reference-data). Kept in their raw shape so every template renders, + // even ones that don't fit convertLibraryToBookingTemplate's simple + // 2-account contract: those get routed through the manual booking dialog + // instead of the QuickReview single-account path. + const { templates: libraryTemplates } = useBookingTemplates() + const libraryRaw = useMemo( + () => libraryTemplates.filter((tt) => !tt.is_system && tt.is_active), + [libraryTemplates], + ) const [chartAccounts, setChartAccounts] = useState([]) // Boolean gate rather than the callback itself: the parent recreates the // handler every render, and depending on its identity would refetch the @@ -276,27 +286,6 @@ export default function TemplatePicker({ // and users know what they made). const templateDirection = direction === 'income' ? 'income' : 'expense' - // Fetch the user's library templates (company + team scope). We keep them - // in their raw shape so we can render every template, even ones that don't - // fit convertLibraryToBookingTemplate's simple 2-account contract: those - // get routed through the manual booking dialog instead of the QuickReview - // single-account path. - useEffect(() => { - const controller = new AbortController() - ;(async () => { - try { - const res = await fetch('/api/settings/booking-templates', { signal: controller.signal }) - if (!res.ok) return - const { data } = await res.json() as { data?: BookingTemplateLibrary[] } - if (!data) return - setLibraryRaw(data.filter((tt) => !tt.is_system && tt.is_active)) - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return - } - })() - return () => { controller.abort() } - }, []) - // Fetch the company's active chart so the search field can surface real // accounts (issue #1877: an active konto like 5460 was unfindable because // only template metadata was searched). The endpoint returns active diff --git a/components/transactions/TransactionBookingDialog.tsx b/components/transactions/TransactionBookingDialog.tsx index b16d8e85..185430c3 100644 --- a/components/transactions/TransactionBookingDialog.tsx +++ b/components/transactions/TransactionBookingDialog.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useMemo, useState } from 'react' import { useTranslations } from 'next-intl' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' @@ -18,9 +18,10 @@ import { resolveSekAmount, buildCurrencyMetadata } from '@/lib/bookkeeping/curre import { applyTemplate } from '@/lib/bookkeeping/template-library' import { proposalLinesToFormLines } from '@/lib/bookkeeping/proposal-lines' import type { ProposalLine } from '@/lib/bookkeeping/proposal-lines' -import type { BookingTemplateLibrary, CashAccount } from '@/types' +import type { BookingTemplateLibrary } from '@/types' import type { TransactionWithInvoice } from './transaction-types' import { resolveAccount } from '@/lib/cash-accounts/resolve-account' +import { useCashAccounts } from '@/lib/reference-data/hooks' interface TransactionBookingDialogProps { open: boolean @@ -136,39 +137,26 @@ export default function TransactionBookingDialog({ const [uploadedFiles, setUploadedFiles] = useState([]) const [pickedInboxDocs, setPickedInboxDocs] = useState([]) const [inboxPickerOpen, setInboxPickerOpen] = useState(false) - const [bankAccount, setBankAccount] = useState(null) - const [bankAccountName, setBankAccountName] = useState(null) - - useEffect(() => { - if (!open || !transaction) return - setBankAccount(null) - setBankAccountName(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 - const accounts = (json.data ?? []) as CashAccount[] - const { account } = resolveAccount( - accounts, - transaction.cash_account_id ?? null, - transaction.currency ?? 'SEK', - ) - // Use the matched account's own name instead of a generic label. - const matched = - accounts.find((a) => a.id === transaction.cash_account_id) ?? - accounts.find((a) => a.ledger_account === account) - setBankAccount(account) - setBankAccountName(matched?.name ?? null) - }) - .catch(() => { - if (!cancelled) setBankAccount('1930') - }) - return () => { cancelled = true } - }, [open, transaction?.id]) + // Settlement account for the bank line: resolved from the session-cached + // cash accounts (seeded by the dashboard layout), so the form below mounts + // on the first paint instead of after a /api/cash-accounts round trip on + // every open. null only while the list is genuinely still loading. + const { cashAccounts, isLoading: cashAccountsLoading } = useCashAccounts() + const { bankAccount, bankAccountName } = useMemo(() => { + if (!transaction || cashAccountsLoading) { + return { bankAccount: null as string | null, bankAccountName: null as string | null } + } + const { account } = resolveAccount( + cashAccounts, + transaction.cash_account_id ?? null, + transaction.currency ?? 'SEK', + ) + // Use the matched account's own name instead of a generic label. + const matched = + cashAccounts.find((a) => a.id === transaction.cash_account_id) ?? + cashAccounts.find((a) => a.ledger_account === account) + return { bankAccount: account, bankAccountName: matched?.name ?? null } + }, [transaction, cashAccounts, cashAccountsLoading]) if (!transaction) return null diff --git a/lib/reference-data/fetchers.ts b/lib/reference-data/fetchers.ts index b2db61e4..9b8898e5 100644 --- a/lib/reference-data/fetchers.ts +++ b/lib/reference-data/fetchers.ts @@ -22,13 +22,13 @@ import { createClient } from '@/lib/supabase/client' import type { Article, BASAccount, + BookingTemplateLibrary, CashAccount, Customer, FiscalPeriod, Supplier, } from '@/types' import type { DimensionDto } from '@/components/dimensions/types' -import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates' export class ReferenceFetchError extends Error { readonly status: number @@ -42,7 +42,8 @@ export class ReferenceFetchError extends Error { } } -export type BookingTemplateWithUsage = BookingTemplate & { last_used_at: string | null } +/** 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 { const supabase = createClient() diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json index 3dffb7fb..dfcd200a 100644 --- a/scripts/checks/antipatterns-baseline.json +++ b/scripts/checks/antipatterns-baseline.json @@ -34,7 +34,7 @@ ] }, "rawReferenceFetch": { - "count": 51, + "count": 46, "files": [ "app/(dashboard)/assets/[id]/dispose/page.tsx", "app/(dashboard)/bookkeeping/year-end/page.tsx", @@ -48,18 +48,14 @@ "app/(dashboard)/salary/runs/[id]/page.tsx", "app/(dashboard)/supplier-invoices/[id]/page.tsx", "components/articles/ArticleForm.tsx", - "components/bookkeeping/BookingTemplatePicker.tsx", "components/bookkeeping/ChartOfAccounts.tsx", "components/bookkeeping/ChartOfAccountsManager.tsx", "components/bookkeeping/CorrectionEntryDialog.tsx", "components/bookkeeping/EditAccountDialog.tsx", - "components/bookkeeping/JournalEntryForm.tsx", "components/bookkeeping/StrikeLinesDialog.tsx", - "components/bookkeeping/TemplateBookDialog.tsx", "components/common/CashAccountSelector.tsx", "components/dimensions/types.ts", "components/extensions/general/ArcimMigrationWorkspace.tsx", - "components/extensions/general/BookDirectlyDialog.tsx", "components/extensions/general/InvoiceInboxWorkspace.tsx", "components/extensions/general/TicWorkspace.tsx", "components/import/BankFileConfirmStep.tsx", @@ -85,7 +81,6 @@ "components/transactions/MatchVoucherDialog.tsx", "components/transactions/QuickReviewDialog.tsx", "components/transactions/TemplatePicker.tsx", - "components/transactions/TransactionBookingDialog.tsx", "extensions/general/enable-banking/components/AccountPickerDialog.tsx" ] }