perf(bookkeeping): booking dialogs render populated on open from the session cache (#1935)

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>
This commit is contained in:
Jakob Wennberg
2026-08-26 14:37:28 +02:00
committed by GitHub
parent ec9cab24cc
commit 567fae654c
10 changed files with 118 additions and 216 deletions
@@ -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<BookingTemplateLibrary[]>([])
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<BookingTemplateCategory | 'all'>('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
@@ -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
+40 -54
View File
@@ -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<FiscalPeriod[]>([])
// 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<UploadedFile[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
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<string, string> | 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<string, string> | 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 */}
+7 -29
View File
@@ -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<BookingTemplateLibrary[] | null>(null)
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
// 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<BookingTemplateLibrary | null>(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
@@ -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<number | null>(null)
// null = fetch pending; array = loaded (may be empty on error: falls back to '1930')
const [cashAccounts, setCashAccounts] = useState<CashAccount[] | null>(null)
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
// 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 */})
@@ -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 {
+11 -22
View File
@@ -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<BookingTemplateLibrary[]>([])
// 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<ChartAccountLike[]>([])
// 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
@@ -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<UploadedFile[]>([])
const [pickedInboxDocs, setPickedInboxDocs] = useState<AvailableInboxDoc[]>([])
const [inboxPickerOpen, setInboxPickerOpen] = useState(false)
const [bankAccount, setBankAccount] = useState<string | null>(null)
const [bankAccountName, setBankAccountName] = useState<string | null>(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
+3 -2
View File
@@ -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<FiscalPeriod[]> {
const supabase = createClient()
+1 -6
View File
@@ -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"
]
}