From 3ee3565d6d2df3ebde453a4d537299a3415e5dd8 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 26 Aug 2026 14:56:37 +0200 Subject: [PATCH] perf(reference-data): sweep the remaining raw reads onto the session cache, ratchet to 0 (#1941) Final consumer migration of the responsiveness plan: the 35 files still fetching fiscal periods, settings, accounts, cash accounts, dimensions or templates on their own now read lib/reference-data, and every client write site invalidates the shared cache instead of refetching locally. Settings and registries: FiscalYearsManager, FiscalPeriodEditor (period snapshotted once per company so a revalidation cannot reset dates being edited), BookingTemplatesPanel, ChartOfAccounts, ChartOfAccountsManager, EditAccountDialog, CorrectionEntryDialog, StrikeLinesDialog, InvoicePaymentAccountsSettings; the dimensions registry (DimensionsManager, DimensionCombobox, LineDimensionFields, DimensionFilter, bookkeeping/[id]) reads useDimensions and the ad-hoc fetchDimensions/fetchDimensionsCached helpers are deleted. Pages and pickers: CashAccountSelector (FyPicker-shaped restore, once per company load), use-account-names, FiscalYearGapNotice, OpeningBalancePeriodStep, BankFileConfirmStep, ImportReviewStep, the import page (invalidates accounts + periods after a SIE execute), customers list, invoices list + detail, pending, salary employee, asset dispose, year-end and periodisering pages (invalidate periods after closing), reports DimensionPnlView (its pivot picker read the wrong payload key and was always empty; it now populates), SkatteverketPanel, TemplatePicker, ArticleForm (vat_registered). Invoice dialogs and extensions: SendInvoiceDialog, PaymentBookingDialog (init reduced to the credit-note lookup + catalogue, proposal and voucher preview fire on open when cached; a local getSession replaces the network getUser for the fallback CC), InvoiceInboxWorkspace, TicWorkspace, ArcimMigrationWorkspace (invalidates after each SIE import step), enable-banking AccountPickerDialog. raw-reference-fetch ratchet: 35 -> 0 files. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- app/(dashboard)/assets/[id]/dispose/page.tsx | 37 +++--- app/(dashboard)/bookkeeping/[id]/page.tsx | 22 +--- app/(dashboard)/bookkeeping/year-end/page.tsx | 94 +++++++-------- .../year-end/periodisering/page.tsx | 44 +++---- app/(dashboard)/customers/page.tsx | 59 +++------ app/(dashboard)/import/page.tsx | 22 ++-- app/(dashboard)/invoices/[id]/page.tsx | 48 ++++---- app/(dashboard)/invoices/page.tsx | 39 ++---- app/(dashboard)/pending/page.tsx | 25 +--- .../salary/employees/[id]/page.tsx | 11 +- components/articles/ArticleForm.tsx | 29 +---- components/bookkeeping/ChartOfAccounts.tsx | 24 ++-- .../bookkeeping/ChartOfAccountsManager.tsx | 21 ++-- .../bookkeeping/CorrectionEntryDialog.tsx | 43 +++---- components/bookkeeping/EditAccountDialog.tsx | 40 +++---- components/bookkeeping/StrikeLinesDialog.tsx | 45 +++---- components/common/CashAccountSelector.tsx | 69 +++++------ components/dimensions/DimensionCombobox.tsx | 42 +++---- components/dimensions/DimensionsManager.tsx | 70 +++++------ components/dimensions/LineDimensionFields.tsx | 28 ++--- components/dimensions/types.ts | 33 ----- .../general/ArcimMigrationWorkspace.tsx | 25 ++-- .../general/InvoiceInboxWorkspace.tsx | 17 +-- .../extensions/general/TicWorkspace.tsx | 15 ++- components/import/BankFileConfirmStep.tsx | 47 ++++---- components/import/FiscalYearGapNotice.tsx | 25 +--- components/import/ImportReviewStep.tsx | 17 +-- .../import/OpeningBalancePeriodStep.tsx | 43 +++---- components/invoices/PaymentBookingDialog.tsx | 61 +++++----- components/invoices/SendInvoiceDialog.tsx | 100 +++++++++------- .../pending-operations/use-account-names.ts | 46 +++---- components/reports/DimensionFilter.tsx | 22 +--- components/reports/SkatteverketPanel.tsx | 17 +-- components/reports/views/index.tsx | 24 ++-- components/settings/BookingTemplatesPanel.tsx | 38 +++--- components/settings/FiscalPeriodEditor.tsx | 28 ++++- components/settings/FiscalYearsManager.tsx | 34 ++---- .../InvoicePaymentAccountsSettings.tsx | 24 ++-- components/transactions/TemplatePicker.tsx | 32 ++--- .../components/AccountPickerDialog.tsx | 113 +++++++----------- scripts/checks/antipatterns-baseline.json | 40 +------ 41 files changed, 636 insertions(+), 977 deletions(-) diff --git a/app/(dashboard)/assets/[id]/dispose/page.tsx b/app/(dashboard)/assets/[id]/dispose/page.tsx index 95f6f727..e46a5d13 100644 --- a/app/(dashboard)/assets/[id]/dispose/page.tsx +++ b/app/(dashboard)/assets/[id]/dispose/page.tsx @@ -1,6 +1,7 @@ 'use client' import { use, useCallback, useEffect, useMemo, useState } from 'react' +import { useFiscalPeriods } from '@/lib/reference-data/hooks' import { useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' import Link from 'next/link' @@ -24,7 +25,7 @@ import { assessJamkning, assessJamkningEligibility } from '@/lib/bokslut/assets/ import { getErrorMessage } from '@/lib/errors/get-error-message' import { useCanWrite } from '@/lib/hooks/use-can-write' import { formatCurrency, formatDate } from '@/lib/utils' -import type { Asset, AssetDisposalType, FiscalPeriod, VatTreatment } from '@/types' +import type { Asset, AssetDisposalType, VatTreatment } from '@/types' interface PeriodOption { id: string @@ -61,7 +62,21 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str const { canWrite } = useCanWrite() const [asset, setAsset] = useState(null) - const [periods, setPeriods] = useState([]) + // Periods from the session cache (lib/reference-data), narrowed to the + // fields the picker needs. + const { periods: fiscalPeriods } = useFiscalPeriods() + const periods = useMemo( + () => + fiscalPeriods.map((period) => ({ + id: period.id, + name: period.name, + period_start: period.period_start, + period_end: period.period_end, + is_closed: period.is_closed, + locked_at: period.locked_at, + })), + [fiscalPeriods], + ) const [loading, setLoading] = useState(true) const [submitting, setSubmitting] = useState(false) const [disposalType, setDisposalType] = useState('sale') @@ -79,23 +94,11 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str useEffect(() => { let cancelled = false - Promise.all([ - fetch(`/api/assets/${id}`).then((response) => response.json()), - fetch('/api/bookkeeping/fiscal-periods').then((response) => response.json()), - ]) - .then(([assetResponse, periodsResponse]) => { + fetch(`/api/assets/${id}`) + .then((response) => response.json()) + .then((assetResponse) => { if (cancelled) return setAsset(assetResponse.data ?? null) - setPeriods( - (periodsResponse.data ?? []).map((period: FiscalPeriod) => ({ - id: period.id, - name: period.name, - period_start: period.period_start, - period_end: period.period_end, - is_closed: period.is_closed, - locked_at: period.locked_at, - })), - ) }) .catch(() => { if (!cancelled) { diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index c73d8e5e..c255b32d 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -54,7 +54,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { Badge } from '@/components/ui/badge' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' -import { fetchDimensions, type DimensionDto } from '@/components/dimensions/types' +import { useDimensions } from '@/lib/reference-data/hooks' import { DetailPager } from '@/components/common/DetailPager' import { listContextKey } from '@/lib/navigation/list-context' import { useCompanyOptional } from '@/contexts/CompanyContext' @@ -159,7 +159,10 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i // Dimension registry, fetched once when any line carries a dimensions map: // used to resolve display names for the per-line dimension text ('KS: Butik'); // it falls back to raw codes when the fetch fails or a code is unregistered. - const [registryDims, setRegistryDims] = useState(null) + // Dimension names for tagged lines, from the session-cached registry + // (lib/reference-data); null until it is there, raw codes render meanwhile. + const { dimensions } = useDimensions() + const registryDims = dimensions.length > 0 ? dimensions : null // Tier-2 retro-tagging (dimensions plan PR6): pencil on posted lines opens // the audited retag dialog; the log renders as a history section below. // Both render only when dimensions are enabled for the company. @@ -170,21 +173,6 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i { id: string; line_id: string; old_dimensions: Record; new_dimensions: Record; reason: string; created_at: string }[] >([]) - useEffect(() => { - if (registryDims !== null) return - const entryLines = (entry?.lines || []) as JournalEntryLine[] - if (!entryLines.some((l) => l.dimensions && Object.keys(l.dimensions).length > 0)) return - let cancelled = false - fetchDimensions() - .then((dims) => { - if (!cancelled) setRegistryDims(dims) - }) - .catch(() => {/* display-only, raw codes are fine */}) - return () => { - cancelled = true - } - }, [entry, registryDims]) - const fetchData = useCallback(async () => { setIsLoading(true) setError(null) diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx index 66e62980..b1e00a5f 100644 --- a/app/(dashboard)/bookkeeping/year-end/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/page.tsx @@ -1,6 +1,8 @@ 'use client' import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { useFiscalPeriods } from '@/lib/reference-data/hooks' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { useRouter, useSearchParams } from 'next/navigation' import { Card, CardContent } from '@/components/ui/card' import { EmptyState } from '@/components/ui/empty-state' @@ -74,57 +76,47 @@ export default function YearEndPage() { const [result, setResult] = useState(null) const [navigationBlocked, setNavigationBlocked] = useState(false) - // ---- Load eligible periods ---- + // ---- Eligible periods, from the session-cached list (lib/reference-data) ---- + // Recomputed whenever the cached list changes: the close actions below + // invalidate it, which is what drops a just-closed year out of the picker. + const { periods: allPeriods, isLoading: periodsLoading, error: periodsFetchError } = useFiscalPeriods() useEffect(() => { - let cancelled = false - const load = async () => { - try { - const res = await fetch('/api/bookkeeping/fiscal-periods') - if (!res.ok) { - if (!cancelled) setPeriodsError('Kunde inte hämta perioder') - return - } - const { data } = (await res.json()) as { data: FiscalPeriod[] } - const all = data ?? [] - const today = new Date().toISOString().split('T')[0] - const eligible: PeriodOption[] = all - .filter((p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today) - .map((p) => ({ ...p, eligible: true })) - // Oldest first: accountants close in order. - eligible.sort((a, b) => a.period_start.localeCompare(b.period_start)) - if (cancelled) return - setHasAnyPeriods(all.length > 0) + if (periodsLoading) return + if (periodsFetchError) { + setPeriodsError('Kunde inte hämta perioder') + return + } + const all: FiscalPeriod[] = allPeriods + const today = new Date().toISOString().split('T')[0] + const eligible: PeriodOption[] = all + .filter((p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today) + .map((p) => ({ ...p, eligible: true })) + // Oldest first: accountants close in order. + eligible.sort((a, b) => a.period_start.localeCompare(b.period_start)) + setHasAnyPeriods(all.length > 0) - // The URL ?period= param may point anywhere: at an ineligible period - // (e.g. a year that has not ended) or, after a company switch, at a - // period that does not exist in this company at all. An unknown id is - // reset to the first eligible period; a known-but-ineligible one is - // kept selectable in the dropdown so the user can navigate away from - // it instead of being stuck (the readiness step explains why it - // cannot be closed). - let options = eligible - if (selectedPeriodId) { - const known = all.find((p) => p.id === selectedPeriodId) - if (!known) { - setSelectedPeriodId(eligible.length > 0 ? eligible[0].id : null) - } else if (!eligible.some((p) => p.id === selectedPeriodId)) { - options = [...eligible, { ...known, eligible: false }].sort((a, b) => - a.period_start.localeCompare(b.period_start), - ) - } - } else if (eligible.length > 0) { - setSelectedPeriodId(eligible[0].id) - } - setPeriods(options) - } catch { - if (!cancelled) setPeriodsError('Kunde inte hämta perioder') + // The URL ?period= param may point anywhere: at an ineligible period + // (e.g. a year that has not ended) or, after a company switch, at a + // period that does not exist in this company at all. An unknown id is + // reset to the first eligible period; a known-but-ineligible one is + // kept selectable in the dropdown so the user can navigate away from + // it instead of being stuck (the readiness step explains why it + // cannot be closed). + let options = eligible + if (selectedPeriodId) { + const known = all.find((p) => p.id === selectedPeriodId) + if (!known) { + setSelectedPeriodId(eligible.length > 0 ? eligible[0].id : null) + } else if (!eligible.some((p) => p.id === selectedPeriodId)) { + options = [...eligible, { ...known, eligible: false }].sort((a, b) => + a.period_start.localeCompare(b.period_start), + ) } + } else if (eligible.length > 0) { + setSelectedPeriodId(eligible[0].id) } - void load() - return () => { - cancelled = true - } - }, [selectedPeriodId]) + setPeriods(options) + }, [selectedPeriodId, allPeriods, periodsLoading, periodsFetchError]) // ---- Sync selected period to URL so users can bookmark / share ---- useEffect(() => { @@ -202,6 +194,8 @@ export default function YearEndPage() { } setResult(body.data as YearEndResult) setStep('result') + // The period is now closed: every picker and form reads the cached list. + void invalidateReferenceData('ref:fiscal-periods') toast({ title: 'Bokslut verkställt', description: `${report?.period.name ?? 'Perioden'} är stängd.`, @@ -247,11 +241,13 @@ export default function YearEndPage() { title: 'Perioden klarmarkerad', description: `${selectedOption?.name ?? 'Perioden'} är nu markerad som avslutad i tidigare program.`, }) - // Drop back to "no selection": the load effect refetches and picks the - // next eligible period (the marked one no longer qualifies). + // Drop back to "no selection" and refresh the cached list: the effect + // above picks the next eligible period (the marked one no longer + // qualifies). setPeriods(null) setSelectedPeriodId(null) setStep('preflight') + await invalidateReferenceData('ref:fiscal-periods') } catch (err) { toast({ title: 'Kunde inte klarmarkera perioden', diff --git a/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx b/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx index c603e4e5..ed95abc4 100644 --- a/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' +import { useFiscalPeriods } from '@/lib/reference-data/hooks' import Link from 'next/link' import { useSearchParams } from 'next/navigation' import { ArrowLeft, ArrowRight, Loader2, Lock, Plus, Trash2 } from 'lucide-react' @@ -26,7 +27,6 @@ import type { PeriodiseringSuggestion, PeriodiseringConfidence, } from '@/lib/bokslut/accruals/auto-detect' -import type { FiscalPeriod } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' type Step = 'vacation' | 'audit' | 'auto' | 'manual' | 'review' @@ -139,36 +139,24 @@ export default function PeriodiseringWizardPage() { const [postError, setPostError] = useState(null) const [postSummary, setPostSummary] = useState<{ created: number; skipped: number } | null>(null) - // ---- Load eligible periods ---- + // ---- Eligible periods, from the session-cached list (lib/reference-data) ---- + const { periods: allPeriods, isLoading: periodsLoading, error: periodsFetchError } = useFiscalPeriods() useEffect(() => { - let cancelled = false - const load = async () => { - try { - const res = await fetch('/api/bookkeeping/fiscal-periods') - if (!res.ok) { - if (!cancelled) setPeriodsError('Kunde inte hämta perioder') - return - } - const { data } = (await res.json()) as { data: FiscalPeriod[] } - const today = new Date().toISOString().split('T')[0] - const eligible = (data ?? []).filter( - (p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today, - ) - eligible.sort((a, b) => a.period_start.localeCompare(b.period_start)) - if (cancelled) return - setPeriods(eligible) - if (!selectedPeriodId && eligible.length > 0) { - setSelectedPeriodId(eligible[0].id) - } - } catch { - if (!cancelled) setPeriodsError('Kunde inte hämta perioder') - } + if (periodsLoading) return + if (periodsFetchError) { + setPeriodsError('Kunde inte hämta perioder') + return } - void load() - return () => { - cancelled = true + const today = new Date().toISOString().split('T')[0] + const eligible = allPeriods.filter( + (p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today, + ) + eligible.sort((a, b) => a.period_start.localeCompare(b.period_start)) + setPeriods(eligible) + if (!selectedPeriodId && eligible.length > 0) { + setSelectedPeriodId(eligible[0].id) } - }, [selectedPeriodId]) + }, [selectedPeriodId, allPeriods, periodsLoading, periodsFetchError]) // ---- Fetch accruals snapshot once period chosen ---- useEffect(() => { diff --git a/app/(dashboard)/customers/page.tsx b/app/(dashboard)/customers/page.tsx index 3a2dd935..f6f4b18f 100644 --- a/app/(dashboard)/customers/page.tsx +++ b/app/(dashboard)/customers/page.tsx @@ -1,7 +1,8 @@ 'use client' import { useState, useEffect, useMemo, useCallback, Suspense } from 'react' -import { useCompanySettings } from '@/lib/reference-data/hooks' +import { useCompanySettings, useCustomers } from '@/lib/reference-data/hooks' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import dynamic from 'next/dynamic' import { useLocale, useTranslations } from 'next-intl' import { useSearchParams, useRouter, usePathname } from 'next/navigation' @@ -68,8 +69,12 @@ function compareStrings(a: string, b: string): number { function CustomersPageInner() { const { canWrite } = useCanWrite() - const [customers, setCustomers] = useState([]) - const [isLoading, setIsLoading] = useState(true) + // The roster from the session cache (lib/reference-data): /api/customers + // masks the personnummer column server-side (see the note that used to sit + // on fetchCustomers), the list is shared with every customer picker, and + // a revisit renders from cache. Skeleton only on the very first load. + const { customers, isLoading: customersLoading, error: customersError } = useCustomers() + const isLoading = customersLoading && customers.length === 0 const [searchTerm, setSearchTerm] = useState('') const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS) const [isDialogOpen, setIsDialogOpen] = useState(false) @@ -115,44 +120,14 @@ function CustomersPageInner() { [searchParams, sortColumn, sortDir, router, pathname] ) - /** - * Read the roster through the API, not straight from Supabase. - * - * personal_number holds AES-256-GCM ciphertext (migration 20260726110000). - * A browser-side select('*') handed this page 76 to 82 hex characters and - * getIdentifier() rendered them into the nowrap identifier cell, which is - * what shredded the table layout for companies with private customers. - * GET /api/customers maps every row through maskCustomerRow, so the - * ciphertext now never leaves the server and the column shows the same - * '********-1234' the detail view does. - * - * No `company` guard: the route resolves the active company server-side, so - * the fetch no longer has to wait for CompanyContext to hydrate. The old - * guard could leave the list empty on a slow context load, because the - * effect below runs once and never retries. - */ - async function fetchCustomers() { - setIsLoading(true) - try { - const response = await fetch('/api/customers') - if (!response.ok) throw new Error('Failed to load customers') - const { data } = await response.json() - setCustomers(data || []) - } catch { - toast({ - title: t('load_failed_title'), - description: t('load_failed_description'), - variant: 'destructive', - }) - } finally { - setIsLoading(false) - } - } - useEffect(() => { - fetchCustomers() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + if (!customersError) return + toast({ + title: t('load_failed_title'), + description: t('load_failed_description'), + variant: 'destructive', + }) + }, [customersError, toast, t]) async function handleCreateCustomer(data: CreateCustomerInput) { setIsCreating(true) @@ -176,7 +151,9 @@ function CustomersPageInner() { title: t('created_title'), description: t('created_description', { name: data.name }), }) - setCustomers([...customers, result.data]) + // Every picker shares the cached list: refresh it instead of patching + // this page's copy. + await invalidateReferenceData('ref:customers') setIsDialogOpen(false) } diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index c7a21e61..aac59c78 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -1,6 +1,8 @@ 'use client' import { useState, useCallback, useEffect } from 'react' +import { fetchAccounts } from '@/lib/reference-data/fetchers' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { useSearchParams, useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { Card, CardContent } from '@/components/ui/card' @@ -807,12 +809,9 @@ function SIEImportWizard() { setIssues(data.parsed.issues) setSieAccounts(data.parsed.accounts) - const accountsRes = await fetch('/api/bookkeeping/accounts?active=false') - if (!accountsRes.ok) { + const accounts = await fetchAccounts(false).catch(() => { throw new Error('Kunde inte hämta kontoplanen för momsgranskning.') - } - const accountsData = await accountsRes.json() - const accounts = accountsData.data || [] + }) setBasAccounts(accounts) setMappings(enrichAccountMappingsWithVat(data.mappings, accounts)) @@ -986,12 +985,12 @@ function SIEImportWizard() { toast({ title: 'Konton skapade', description: `${data.created} nya konton har lagts till i din kontoplan` }) const createdSet = new Set(missingAccounts.map(a => a.number)) - const accountsRes = await fetch('/api/bookkeeping/accounts?active=false') - if (!accountsRes.ok) { + // New accounts exist now: refresh every cached chart (pickers app-wide) + // and re-read the full chart for the VAT review below. + await invalidateReferenceData('ref:accounts') + const accounts = await fetchAccounts(false).catch(() => { throw new Error('Kunde inte hämta kontoplanen för momsgranskning.') - } - const accountsData = await accountsRes.json() - const accounts = accountsData.data || [] + }) setBasAccounts(accounts) setMappings(prev => { let updated = prev.map(m => @@ -1077,6 +1076,9 @@ function SIEImportWizard() { } setStep('result') + // An import creates periods and accounts: refresh the session caches so + // every picker in the app sees them without a reload. + void invalidateReferenceData(['ref:accounts', 'ref:fiscal-periods']) if (data.result?.success) { const created = data.result.journalEntriesCreated diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index fafd80be..926e0f2a 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useRef, use } from 'react' +import { useCompanySettings } from '@/lib/reference-data/hooks' import { useRouter } from 'next/navigation' import Link from 'next/link' import { useLocale, useTranslations } from 'next-intl' @@ -246,6 +247,25 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual') // #967: register/send without booking; ekonomi books in a separate step. const [deferInvoiceBooking, setDeferInvoiceBooking] = useState(false) + // Company settings from the session cache (lib/reference-data): applied + // whenever the cached row (re)loads, no request per invoice visit. + const { settings: companySettings } = useCompanySettings() + useEffect(() => { + const settings = companySettings + if (!settings) return + setOreRounding(settings.ore_rounding ?? true) + if (typeof settings.vat_registered === 'boolean') { + setVatRegistered(settings.vat_registered) + } + setAccountingMethod(settings.accounting_method === 'cash' ? 'cash' : 'accrual') + setDeferInvoiceBooking(!!settings.defer_invoice_booking) + setReminderDays([ + settings.reminder_days_level_1 ?? 15, + settings.reminder_days_level_2 ?? 30, + settings.reminder_days_level_3 ?? 45, + ]) + setAutoRemindersEnabled(settings.send_invoice_reminders ?? true) + }, [companySettings]) const [showBookConfirm, setShowBookConfirm] = useState(false) const [bookVoucherPreview, setBookVoucherPreview] = useState(null) const [reminderDays, setReminderDays] = useState<[number, number, number]>([15, 30, 45]) @@ -331,16 +351,6 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st setDeductionPersonnummerMasked(undefined) } - // Settings depend only on the active company, so start them with the main - // invoice batch instead of waiting for the invoice row first. - const settingsPromise = company?.id - ? supabase - .from('company_settings') - .select('ore_rounding, vat_registered, accounting_method, defer_invoice_booking, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3, send_invoice_reminders') - .eq('company_id', company.id) - .maybeSingle() - : Promise.resolve(null) - const deliveriesPromise = loadDeliveries() // Invoice, reminders, payments, and deliveries all key on the route id: one @@ -460,25 +470,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st }), ) - const settingsRes = await settingsPromise if (seq !== fetchSeqRef.current) return - if (settingsRes) { - const settings = settingsRes.data - setOreRounding(settings?.ore_rounding ?? true) - if (typeof settings?.vat_registered === 'boolean') { - setVatRegistered(settings.vat_registered) - } - setAccountingMethod(settings?.accounting_method === 'cash' ? 'cash' : 'accrual') - setDeferInvoiceBooking(!!settings?.defer_invoice_booking) - setReminderDays([ - settings?.reminder_days_level_1 ?? 15, - settings?.reminder_days_level_2 ?? 30, - settings?.reminder_days_level_3 ?? 45, - ]) - if (settings) { - setAutoRemindersEnabled(settings.send_invoice_reminders ?? true) - } - } // Related documents need the invoice row but do not gate the main detail // view. Resolve them together after first paint and fill their links in. diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index a32474fe..33484213 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useMemo } from 'react' +import { useCompanySettings } from '@/lib/reference-data/hooks' import dynamic from 'next/dynamic' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' @@ -194,11 +195,14 @@ export default function InvoicesPage() { const router = useRouter() const searchParams = useSearchParams() const [invoices, setInvoices] = useState([]) - const [oreRounding, setOreRounding] = useState(true) - const [rotRutEnabled, setRotRutEnabled] = useState(false) + // Settings-driven gates from the session-cached settings row + // (lib/reference-data), derived instead of copied into state. + const { settings: companySettings } = useCompanySettings() + const oreRounding: boolean = companySettings?.ore_rounding ?? true + const rotRutEnabled: boolean = companySettings?.rot_rut_enabled ?? false // Booking mode drives which rows are bulk-bookable (kontantmetoden: none). - const [accountingMethod, setAccountingMethod] = useState('accrual') - const [deferInvoiceBooking, setDeferInvoiceBooking] = useState(false) + const accountingMethod: string = companySettings?.accounting_method ?? 'accrual' + const deferInvoiceBooking: boolean = companySettings?.defer_invoice_booking ?? false const [selectedIds, setSelectedIds] = useState>(new Set()) const [showBulkBookConfirm, setShowBulkBookConfirm] = useState(false) const [isBulkBooking, setIsBulkBooking] = useState(false) @@ -285,7 +289,7 @@ export default function InvoicesPage() { // hundreds of rows to 3 skeleton stubs and replaying the stagger-enter // entrance for a row-scoped action was the "booking feels glitchy" jump. if (invoices.length === 0) setIsLoading(true) - const [invoicesResult, settingsResult] = await Promise.allSettled([ + const [invoicesResult] = await Promise.allSettled([ fetchAllRows( ({ from, to }) => supabase @@ -297,11 +301,6 @@ export default function InvoicesPage() { .range(from, to), { dedupeBy: (invoice) => invoice.id }, ), - supabase - .from('company_settings') - .select('ore_rounding, rot_rut_enabled, accounting_method, defer_invoice_booking') - .eq('company_id', company.id) - .maybeSingle(), ]) if (invoicesResult.status === 'rejected') { @@ -313,26 +312,6 @@ export default function InvoicesPage() { } else { setInvoices(invoicesResult.value) } - setOreRounding( - settingsResult.status === 'fulfilled' - ? (settingsResult.value.data?.ore_rounding ?? true) - : true, - ) - setRotRutEnabled( - settingsResult.status === 'fulfilled' - ? (settingsResult.value.data?.rot_rut_enabled ?? false) - : false, - ) - setAccountingMethod( - settingsResult.status === 'fulfilled' - ? (settingsResult.value.data?.accounting_method ?? 'accrual') - : 'accrual', - ) - setDeferInvoiceBooking( - settingsResult.status === 'fulfilled' - ? (settingsResult.value.data?.defer_invoice_booking ?? false) - : false, - ) setIsLoading(false) } diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index 148543ca..578d004c 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { useCompanySettings } from '@/lib/reference-data/hooks' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { Badge } from '@/components/ui/badge' @@ -33,7 +34,6 @@ import { ToastAction } from '@/components/ui/toast' import { cn, formatDate } from '@/lib/utils' import { getErrorMessage } from '@/lib/errors/get-error-message' import { createClient } from '@/lib/supabase/client' -import { useCompanyOptional } from '@/contexts/CompanyContext' import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode' import { ClipboardCheck, @@ -287,30 +287,15 @@ export default function PendingOperationsPage() { const [rejectReason, setRejectReason] = useState('') const [isRejecting, setIsRejecting] = useState(false) const { toast } = useToast() - const company = useCompanyOptional()?.company ?? null // Whether the "Bokför utkasten" toast CTA leads anywhere: bulk Bokför on // /invoices only selects drafts when the company books at issue. Under // kontantmetoden or deferred booking (#967) the CTA would be a dead end, // so it stays suppressed (false until settings load: suppressing is the // safe direction, the neutral hint sentence still shows). - const [invoiceDraftsCtaUseful, setInvoiceDraftsCtaUseful] = useState(false) - - useEffect(() => { - if (!company) return - let cancelled = false - const supabase = createClient() - supabase - .from('company_settings') - .select('accounting_method, defer_invoice_booking') - .eq('company_id', company.id) - .maybeSingle() - .then(({ data }) => { - if (!cancelled) setInvoiceDraftsCtaUseful(booksInvoicesOnIssue(data)) - }) - return () => { - cancelled = true - } - }, [company]) + // Derived from the session-cached settings row (lib/reference-data); false + // until the row is available, suppressing being the safe direction. + const { settings: companySettings } = useCompanySettings() + const invoiceDraftsCtaUseful = companySettings ? booksInvoicesOnIssue(companySettings) : false // Read ?conversation= once on mount so deep-links from the agent context // strip filter the list automatically. diff --git a/app/(dashboard)/salary/employees/[id]/page.tsx b/app/(dashboard)/salary/employees/[id]/page.tsx index 3f02f31f..e52f10d5 100644 --- a/app/(dashboard)/salary/employees/[id]/page.tsx +++ b/app/(dashboard)/salary/employees/[id]/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, use } from 'react' +import { useCompanySettings } from '@/lib/reference-data/hooks' import { useRouter } from 'next/navigation' import Link from 'next/link' import { useTranslations } from 'next-intl' @@ -87,7 +88,8 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s // 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>({}) // The controlled form fields mirror the saved row. Called on load and again @@ -116,13 +118,6 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s load() }, [id]) - useEffect(() => { - fetch('/api/settings') - .then((r) => r.json()) - .then(({ data }) => setDimensionsEnabled(data?.dimensions_enabled === true)) - .catch(() => {/* keep the dimension fields hidden */}) - }, []) - function setDimension(dimNo: string, code: string | null) { setDimensions((prev) => { const next = { ...prev } diff --git a/components/articles/ArticleForm.tsx b/components/articles/ArticleForm.tsx index f57c7c4e..4eca43e4 100644 --- a/components/articles/ArticleForm.tsx +++ b/components/articles/ArticleForm.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useMemo, useState } from 'react' -import { useAccounts } from '@/lib/reference-data/hooks' +import { useAccounts, useCompanySettings } 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' @@ -19,7 +19,6 @@ import { import { ChevronDown, Loader2, Lock } from 'lucide-react' import { cn, formatCurrency } from '@/lib/utils' import { useCanWrite } from '@/lib/hooks/use-can-write' -import { useCompany } from '@/contexts/CompanyContext' import { createClient } from '@/lib/supabase/client' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' @@ -72,7 +71,6 @@ export default function ArticleForm({ onCancel, }: ArticleFormProps) { const { canWrite } = useCanWrite() - const { company } = useCompany() const supabase = createClient() const t = useTranslations('form_article') const tCommon = useTranslations('common') @@ -92,7 +90,11 @@ export default function ArticleForm({ const [createAccountPrefill, setCreateAccountPrefill] = useState(null) // Momsregistrerad? A non-VAT-registered company never charges moms, so the // VAT field is hidden and the rate forced to 0: mirrors the invoice editor. - const [vatRegistered, setVatRegistered] = useState(true) + // Icke momsregistrerad verksamhet: VAT controls hidden and lines at 0 %. + // Derived from the session-cached settings row (lib/reference-data); a + // missing column keeps the registered-company behaviour, as before. + const { settings: companySettings } = useCompanySettings() + const vatRegistered = typeof companySettings?.vat_registered === 'boolean' ? companySettings.vat_registered : true // Supported currencies, fetched from the currencies reference table rather // than hard-coded. Falls back to the article's own currency (or SEK) if the // fetch fails so the Select is never empty. @@ -116,25 +118,6 @@ export default function ArticleForm({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - useEffect(() => { - if (!company?.id) return - let cancelled = false - supabase - .from('company_settings') - .select('vat_registered') - .eq('company_id', company.id) - .single() - .then(({ data }) => { - if (!cancelled && typeof data?.vat_registered === 'boolean') { - setVatRegistered(data.vat_registered) - } - }) - return () => { - cancelled = true - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [company?.id]) - // Open "Fler fält" by default when it already holds data, so an edit never // hides a value the user previously set. Currency and posting account are no // longer in here: both are permanent rows. diff --git a/components/bookkeeping/ChartOfAccounts.tsx b/components/bookkeeping/ChartOfAccounts.tsx index c5f2c78c..669f7254 100644 --- a/components/bookkeeping/ChartOfAccounts.tsx +++ b/components/bookkeeping/ChartOfAccounts.tsx @@ -1,7 +1,9 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState } from 'react' import { createClient } from '@/lib/supabase/client' +import { useAccounts } from '@/lib/reference-data/hooks' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -21,24 +23,13 @@ const CLASS_LABELS: Record = { } export default function ChartOfAccounts() { - const [accounts, setAccounts] = useState([]) - const [loading, setLoading] = useState(true) + // Session-cached chart (lib/reference-data), shared with every picker. + const { accounts, isLoading: loading } = useAccounts() const [expandedClasses, setExpandedClasses] = useState>(new Set()) const [searchQuery, setSearchQuery] = useState('') const [editingSRU, setEditingSRU] = useState(null) const [sruValue, setSruValue] = useState('') - async function fetchAccounts() { - const res = await fetch('/api/bookkeeping/accounts') - const { data } = await res.json() - setAccounts(data || []) - setLoading(false) - } - - useEffect(() => { - fetchAccounts() - }, []) - async function updateSRUCode(accountId: string, newSruCode: string) { const supabase = createClient() const trimmed = newSruCode.trim() || null @@ -47,9 +38,8 @@ export default function ChartOfAccounts() { .update({ sru_code: trimmed }) .eq('id', accountId) - setAccounts((prev) => - prev.map((a) => (a.id === accountId ? { ...a, sru_code: trimmed } : a)) - ) + // Refresh the shared chart so this list and every picker show the code. + await invalidateReferenceData('ref:accounts') setEditingSRU(null) } diff --git a/components/bookkeeping/ChartOfAccountsManager.tsx b/components/bookkeeping/ChartOfAccountsManager.tsx index 39ec1302..c2ef7e61 100644 --- a/components/bookkeeping/ChartOfAccountsManager.tsx +++ b/components/bookkeeping/ChartOfAccountsManager.tsx @@ -28,6 +28,7 @@ import { } from 'lucide-react' import { cn } from '@/lib/utils' import type { BASAccount } from '@/types' +import { useCompanySettings } from '@/lib/reference-data/hooks' import { BAS_REFERENCE, isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -75,6 +76,7 @@ export default function ChartOfAccountsManager() { const [collapsedMyClasses, setCollapsedMyClasses] = useState>(new Set()) const [expandedCatalogClasses, setExpandedCatalogClasses] = useState>(new Set()) const [hideK2Excluded, setHideK2Excluded] = useState(null) + const { settings: companySettings } = useCompanySettings() // Off by default: the list endpoint's `?active=false` means "no filter", so // leaving it off keeps first paint on the smaller active-only payload. // A deactivated account is otherwise invisible everywhere and unrecoverable. @@ -205,25 +207,18 @@ export default function ChartOfAccountsManager() { fetchReference(), (async () => { if (hideK2Excluded !== null) return - try { - const res = await fetch('/api/settings') - if (res.ok) { - const { data } = await res.json() - // Default to hiding K2-excluded accounts if the company uses K2 (plan_type === 'k1') - setHideK2Excluded(data?.plan_type === 'k1') - } else { - setHideK2Excluded(false) - } - } catch { - setHideK2Excluded(false) - } + // Default to hiding K2-excluded accounts if the company uses K2 + // (plan_type === 'k1'). Settings come from the session cache. + setHideK2Excluded( + (companySettings as { plan_type?: string } | null)?.plan_type === 'k1', + ) })(), ]) setReferenceLoaded(true) } finally { setReferenceLoading(false) } - }, [referenceLoaded, referenceLoading, fetchReference, hideK2Excluded]) + }, [referenceLoaded, referenceLoading, fetchReference, hideK2Excluded, companySettings]) const refreshAll = useCallback(async () => { await Promise.all([ diff --git a/components/bookkeeping/CorrectionEntryDialog.tsx b/components/bookkeeping/CorrectionEntryDialog.tsx index 68db6266..2c8eca29 100644 --- a/components/bookkeeping/CorrectionEntryDialog.tsx +++ b/components/bookkeeping/CorrectionEntryDialog.tsx @@ -23,6 +23,8 @@ import { } from '@/components/bookkeeping/correction-entry-description' import { nextLineDescriptionForAccountChange } from '@/components/bookkeeping/correction-line-description' import { useToast } from '@/components/ui/use-toast' +import { useAccounts } from '@/lib/reference-data/hooks' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { getErrorMessage } from '@/lib/errors/get-error-message' import { Loader2, Plus, Trash2 } from 'lucide-react' import { formatDate } from '@/lib/utils' @@ -33,7 +35,7 @@ import { } from '@/lib/bookkeeping/correction-line-account' import { splitCreateAccountPrefill } from '@/lib/bookkeeping/create-account-prefill' import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client' -import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types' +import type { JournalEntry, JournalEntryLine } from '@/types' interface CorrectionLine { account_number: string @@ -53,9 +55,18 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor const { toast } = useToast() const router = useRouter() const t = useTranslations('journal_detail') - const [accounts, setAccounts] = useState([]) + // The full chart (deactivated rows included) comes from the session cache + // (lib/reference-data); only the static BAS catalogue is loaded per open, + // and it is module-cached after the first time. + const { accounts, isLoading: accountsLoading, error: accountsError, refresh: refreshAccounts } = useAccounts(false) const [catalog, setCatalog] = useState([]) - const [accountsStatus, setAccountsStatus] = useState<'loading' | 'ready' | 'error'>('loading') + const [catalogStatus, setCatalogStatus] = useState<'loading' | 'ready' | 'error'>('loading') + const accountsStatus: 'loading' | 'ready' | 'error' = + accountsLoading || catalogStatus === 'loading' + ? 'loading' + : accountsError || catalogStatus === 'error' + ? 'error' + : 'ready' const [lines, setLines] = useState([]) const [description, setDescription] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) @@ -98,26 +109,18 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor // Pre-fill the verifikationstext with the same auto text the server // would generate; only a user edit is sent along (see handleSubmit). setDescription(autoCorrectionDescription(entry.description)) - void fetchAccounts() + void loadCatalog() } }, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps - async function fetchAccounts() { - setAccountsStatus('loading') + async function loadCatalog() { + setCatalogStatus('loading') try { - const [res, basCatalog] = await Promise.all([ - fetch('/api/bookkeeping/accounts?active=false'), - loadBasCatalog(), - ]) - if (!res.ok) throw new Error(`accounts ${res.status}`) - const { data } = await res.json() - setAccounts(data || []) - setCatalog(basCatalog) - setAccountsStatus('ready') + setCatalog(await loadBasCatalog()) + setCatalogStatus('ready') } catch { - setAccounts([]) setCatalog([]) - setAccountsStatus('error') + setCatalogStatus('error') } } @@ -169,9 +172,9 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor // a dead end here: the rättelse can only post to accounts that exist in the // chart. Creating it inline keeps the half-finished rättelse intact. const handleAccountCreated = async (account: { account_number: string; account_name?: string }) => { - await fetchAccounts() + await invalidateReferenceData('ref:accounts') if (creatingAccountForLine != null) { - // fetchAccounts' state update is not visible in this closure, so the + // The refreshed cache is not visible in this closure, so the // fresh account's own name is passed alongside the stale sources. The // reactivate path reports no name, but that account is already in // `accounts` (the fetch includes deactivated rows). @@ -339,7 +342,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor {accountsStatus === 'loading' ? t('accounts_loading') : t('accounts_load_failed')} {accountsStatus === 'error' && ( - )} diff --git a/components/bookkeeping/EditAccountDialog.tsx b/components/bookkeeping/EditAccountDialog.tsx index 6a555860..8df66971 100644 --- a/components/bookkeeping/EditAccountDialog.tsx +++ b/components/bookkeeping/EditAccountDialog.tsx @@ -26,12 +26,14 @@ import { getErrorMessage } from '@/lib/errors/get-error-message' import { Loader2, Plus, X } from 'lucide-react' import DimensionCombobox from '@/components/dimensions/DimensionCombobox' import { - fetchDimensions, type AccountDimensionRuleDto, type DimensionDto, type DimensionRuleType, } from '@/components/dimensions/types' import type { BASAccount } from '@/types' +import { useCompanySettings, useDimensions } from '@/lib/reference-data/hooks' +import { fetchDimensions } from '@/lib/reference-data/fetchers' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect' import { defaultRateForVatTreatment, @@ -81,8 +83,11 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit // dimensions enabled (same /api/settings gate as JournalEntryForm). Rule // mutations apply immediately via their own fetches + toasts; they are // deliberately independent of the account PUT below. - const [dimensionsEnabled, setDimensionsEnabled] = useState(false) - const [dims, setDims] = useState([]) + // Settings and the dimension registry come from the session cache + // (lib/reference-data), so the section and its pickers are ready on open. + const { settings: companySettings } = useCompanySettings() + const dimensionsEnabled = companySettings?.dimensions_enabled === true + const { dimensions: dims } = useDimensions() const [rules, setRules] = useState([]) const [rulesLoading, setRulesLoading] = useState(false) const [addRuleOpen, setAddRuleOpen] = useState(false) @@ -91,33 +96,15 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit const [newRuleValueCode, setNewRuleValueCode] = useState(null) const [isAddingRule, setIsAddingRule] = useState(false) - useEffect(() => { - let cancelled = false - fetch('/api/settings') - .then((r) => r.json()) - .then(({ data }) => { - if (!cancelled && data?.dimensions_enabled === true) setDimensionsEnabled(true) - }) - .catch(() => { - /* keep the section hidden */ - }) - return () => { - cancelled = true - } - }, []) - useEffect(() => { if (!dimensionsEnabled) return let cancelled = false setRulesLoading(true) - Promise.all([ - fetchDimensions().catch(() => [] as DimensionDto[]), - fetch(`/api/dimensions/rules?account_number=${account.account_number}`) - .then(async (r) => ({ ok: r.ok, json: await r.json().catch(() => null) })) - .catch(() => ({ ok: false, json: null })), - ]).then(([fetchedDims, rulesRes]) => { + fetch(`/api/dimensions/rules?account_number=${account.account_number}`) + .then(async (r) => ({ ok: r.ok, json: await r.json().catch(() => null) })) + .catch(() => ({ ok: false, json: null })) + .then((rulesRes) => { if (cancelled) return - setDims(fetchedDims) if (rulesRes.ok) { setRules((rulesRes.json?.data?.rules ?? []) as AccountDimensionRuleDto[]) } @@ -158,7 +145,8 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit if (!valueId) { const refreshed = await fetchDimensions().catch(() => null) if (refreshed) { - setDims(refreshed) + // Hand the fresh registry to the shared cache as well. + void invalidateReferenceData('ref:dimensions') valueId = findValueId(refreshed) } } diff --git a/components/bookkeeping/StrikeLinesDialog.tsx b/components/bookkeeping/StrikeLinesDialog.tsx index e7b7185d..aefd5fef 100644 --- a/components/bookkeeping/StrikeLinesDialog.tsx +++ b/components/bookkeeping/StrikeLinesDialog.tsx @@ -18,12 +18,14 @@ import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' import { AccountNumber } from '@/components/ui/account-number' import { useToast } from '@/components/ui/use-toast' +import { useAccounts } from '@/lib/reference-data/hooks' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { getErrorMessage } from '@/lib/errors/get-error-message' import { changeCorrectionLineAccount, getSelectableCorrectionCatalog } from '@/lib/bookkeeping/correction-line-account' import { splitCreateAccountPrefill } from '@/lib/bookkeeping/create-account-prefill' import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client' import { Loader2, Plus, Trash2 } from 'lucide-react' -import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types' +import type { JournalEntry, JournalEntryLine } from '@/types' interface NewLine { account_number: string @@ -49,9 +51,18 @@ interface Props { export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrected }: Props) { const { toast } = useToast() const t = useTranslations('journal_detail') - const [accounts, setAccounts] = useState([]) + // The full chart (deactivated rows included) comes from the session cache + // (lib/reference-data); only the static BAS catalogue is loaded per open, + // and it is module-cached after the first time. + const { accounts, isLoading: accountsLoading, error: accountsError, refresh: refreshAccounts } = useAccounts(false) const [catalog, setCatalog] = useState([]) - const [accountsStatus, setAccountsStatus] = useState<'loading' | 'ready' | 'error'>('loading') + const [catalogStatus, setCatalogStatus] = useState<'loading' | 'ready' | 'error'>('loading') + const accountsStatus: 'loading' | 'ready' | 'error' = + accountsLoading || catalogStatus === 'loading' + ? 'loading' + : accountsError || catalogStatus === 'error' + ? 'error' + : 'ready' const [strikeIds, setStrikeIds] = useState>(new Set()) const [newLines, setNewLines] = useState([]) const [isSubmitting, setIsSubmitting] = useState(false) @@ -77,26 +88,18 @@ export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrect if (open) { setStrikeIds(new Set()) setNewLines([]) - void fetchAccounts() + void loadCatalog() } - }, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps + }, [open, entry.id]) - async function fetchAccounts() { - setAccountsStatus('loading') + async function loadCatalog() { + setCatalogStatus('loading') try { - const [res, basCatalog] = await Promise.all([ - fetch('/api/bookkeeping/accounts?active=false'), - loadBasCatalog(), - ]) - if (!res.ok) throw new Error(`accounts ${res.status}`) - const { data } = await res.json() - setAccounts(data || []) - setCatalog(basCatalog) - setAccountsStatus('ready') + setCatalog(await loadBasCatalog()) + setCatalogStatus('ready') } catch { - setAccounts([]) setCatalog([]) - setAccountsStatus('error') + setCatalogStatus('error') } } @@ -139,9 +142,9 @@ export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrect // a dead end here: the rättelse can only post to accounts that exist in the // chart. Creating it inline keeps the half-finished rättelse intact. const handleAccountCreated = async (account: { account_number: string; account_name?: string }) => { - await fetchAccounts() + await invalidateReferenceData('ref:accounts') if (creatingAccountForLine != null) { - // fetchAccounts' state update is not visible in this closure, so the + // The refreshed cache is not visible in this closure, so the // fresh account's own name is passed alongside the stale sources. The // reactivate path reports no name, but that account is already in // `accounts` (the fetch includes deactivated rows). @@ -300,7 +303,7 @@ export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrect {accountsStatus === 'loading' ? t('accounts_loading') : t('accounts_load_failed')} {accountsStatus === 'error' && ( - )} diff --git a/components/common/CashAccountSelector.tsx b/components/common/CashAccountSelector.tsx index c97b99b5..ca4953d6 100644 --- a/components/common/CashAccountSelector.tsx +++ b/components/common/CashAccountSelector.tsx @@ -1,6 +1,7 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useRef } from 'react' +import { useCashAccounts } from '@/lib/reference-data/hooks' import { Label } from '@/components/ui/label' import { Select, @@ -10,7 +11,6 @@ import { SelectValue, } from '@/components/ui/select' import { useCompany } from '@/contexts/CompanyContext' -import type { CashAccount } from '@/types' const STORAGE_KEY_PREFIX = 'Accounted:cash-account:' @@ -56,57 +56,44 @@ export function CashAccountSelector({ className, }: Props) { const { company } = useCompany() - const [accounts, setAccounts] = useState([]) - const [loaded, setLoaded] = useState(false) + // Session-cached and seeded by the dashboard layout (lib/reference-data): + // on a normal visit the list is here on the first render, so the restore + // below runs in the first effect tick and onReady fires without a round + // trip. Restore once per company load, not on every background refresh. + const { cashAccounts: accounts, isLoading } = useCashAccounts() + const loaded = !isLoading + const restoredForRef = useRef(null) useEffect(() => { if (!company?.id) { onReady?.() return } - let cancelled = false - ;(async () => { - const res = await fetch('/api/cash-accounts') - if (!res.ok) { - if (!cancelled) { - setLoaded(true) - onReady?.() - } - return - } - const { data } = await res.json() - if (cancelled) return + if (!loaded || restoredForRef.current === company.id) return + restoredForRef.current = company.id - const fetched: CashAccount[] = data || [] - // is_primary first (already ordered on the server), then by ledger code. - setAccounts(fetched) - setLoaded(true) + // Restore last selection or pick the primary as default. + if (typeof window !== 'undefined') { + const stored = window.sessionStorage.getItem(STORAGE_KEY_PREFIX + company.id) + const inFetched = (ledger: string) => + accounts.some(a => a.ledger_account === ledger) - // Restore last selection or pick the primary as default. - if (typeof window !== 'undefined') { - const stored = window.sessionStorage.getItem(STORAGE_KEY_PREFIX + company.id) - const inFetched = (ledger: string) => - fetched.some(a => a.ledger_account === ledger) - - if (stored && inFetched(stored)) { - if (stored !== value) onChange(stored) - } else { - const primary = fetched.find(a => a.is_primary) - const fallback = primary ?? fetched[0] - if (fallback && fallback.ledger_account !== value) { - onChange(fallback.ledger_account) - } + if (stored && inFetched(stored)) { + if (stored !== value) onChange(stored) + } else { + const primary = accounts.find(a => a.is_primary) + const fallback = primary ?? accounts[0] + if (fallback && fallback.ledger_account !== value) { + onChange(fallback.ledger_account) } } - - onReady?.() - })() - return () => { - cancelled = true } - // onReady excluded: lifecycle callback, shouldn't retrigger on parent renders. + + onReady?.() + // onReady/onChange are lifecycle callbacks: fire once per load, not on + // parent re-renders that re-create them. `value` is read once at restore. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [company?.id]) + }, [company?.id, loaded, accounts]) const handleChange = (next: string) => { if (company?.id && typeof window !== 'undefined') { diff --git a/components/dimensions/DimensionCombobox.tsx b/components/dimensions/DimensionCombobox.tsx index 651392e6..8e9873ff 100644 --- a/components/dimensions/DimensionCombobox.tsx +++ b/components/dimensions/DimensionCombobox.tsx @@ -6,9 +6,10 @@ import { Input } from '@/components/ui/input' import { getErrorMessage } from '@/lib/errors/get-error-message' import { DIMENSION_CODE_PATTERN, - fetchDimensions, type DimensionValueDto, } from '@/components/dimensions/types' +import { useDimensions } from '@/lib/reference-data/hooks' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' interface DimensionComboboxProps { /** SIE dimension number as a string ('1' = kostnadsställe, '6' = projekt). */ @@ -47,9 +48,23 @@ export default function DimensionCombobox({ const [search, setSearch] = useState(value ?? '') const [isOpen, setIsOpen] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState(0) - const [loadState, setLoadState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle') - const [dimensionId, setDimensionId] = useState(null) - const [values, setValues] = useState([]) + // Registry from the session cache (lib/reference-data): every combobox on + // the page shares one entry, so opening a picker costs no request. + const { dimensions, isLoading: registryLoading, error: registryError } = useDimensions() + const loadState: 'loading' | 'loaded' | 'error' = registryLoading + ? 'loading' + : registryError + ? 'error' + : 'loaded' + const dimension = useMemo( + () => dimensions.find((d) => String(d.sie_dim_no) === sieDimNo) ?? null, + [dimensions, sieDimNo], + ) + const dimensionId = dimension?.id ?? null + const values = useMemo( + () => dimension?.values.filter((v) => v.is_active) ?? [], + [dimension], + ) const [isCreating, setIsCreating] = useState(false) const [createError, setCreateError] = useState(null) const containerRef = useRef(null) @@ -72,24 +87,10 @@ export default function DimensionCombobox({ valuesRef.current = values }, [values]) - const loadValues = useCallback(async () => { - setLoadState('loading') - try { - const dims = await fetchDimensions() - const dim = dims.find((d) => String(d.sie_dim_no) === sieDimNo) - setDimensionId(dim?.id ?? null) - setValues(dim?.values.filter((v) => v.is_active) ?? []) - setLoadState('loaded') - } catch { - setLoadState('error') - } - }, [sieDimNo]) - const openDropdown = useCallback(() => { setIsOpen(true) setCreateError(null) - if (loadState === 'idle') void loadValues() - }, [loadState, loadValues]) + }, []) const filteredValues = useMemo(() => { const term = search.trim().toLowerCase() @@ -169,7 +170,8 @@ export default function DimensionCombobox({ start_date: null, end_date: null, } - setValues((prev) => [...prev, created].sort((a, b) => a.code.localeCompare(b.code, 'sv'))) + // Refresh the shared registry so every picker offers the new value. + await invalidateReferenceData('ref:dimensions') selectValue(created.code) } finally { setIsCreating(false) diff --git a/components/dimensions/DimensionsManager.tsx b/components/dimensions/DimensionsManager.tsx index 0d800c85..62787402 100644 --- a/components/dimensions/DimensionsManager.tsx +++ b/components/dimensions/DimensionsManager.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useLocale, useTranslations } from 'next-intl' +import { useDimensions } from '@/lib/reference-data/hooks' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' @@ -44,7 +46,6 @@ import DimensionValueForm, { type DimensionValueFormInput, } from '@/components/dimensions/DimensionValueForm' import { - fetchDimensions, PROJECT_DIM_NO, type DimensionDto, type DimensionValueDto, @@ -76,10 +77,21 @@ export default function DimensionsManager() { const { toast } = useToast() const { canWrite } = useCanWrite() - const [dimensions, setDimensions] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const [loadFailed, setLoadFailed] = useState(false) - const [activeDimId, setActiveDimId] = useState(null) + // The register renders the same session-cached registry the pickers use + // (lib/reference-data); every write below invalidates it. + const { dimensions: registry, isLoading, error: loadError, refresh: refreshDimensions } = useDimensions() + const dimensions = useMemo( + () => [...registry].sort((a, b) => a.sort_order - b.sort_order || a.sie_dim_no - b.sie_dim_no), + [registry], + ) + const loadFailed = !!loadError + // The requested tab, validated against the current registry: a dimension + // that disappears falls back to the first one. + const [requestedDimId, setActiveDimId] = useState(null) + const activeDimId = + requestedDimId && dimensions.some((d) => d.id === requestedDimId) + ? requestedDimId + : (dimensions[0]?.id ?? null) const [searchTerm, setSearchTerm] = useState('') const [sortColumn, setSortColumn] = useState('code') const [sortDir, setSortDir] = useState('asc') @@ -88,37 +100,17 @@ export default function DimensionsManager() { const [newDimDialogOpen, setNewDimDialogOpen] = useState(false) const [isCreatingDimension, setIsCreatingDimension] = useState(false) - const loadDimensions = useCallback( - async (showSpinner: boolean) => { - if (showSpinner) setIsLoading(true) - try { - const dims = await fetchDimensions() - const sorted = [...dims].sort( - (a, b) => a.sort_order - b.sort_order || a.sie_dim_no - b.sie_dim_no, - ) - setDimensions(sorted) - setLoadFailed(false) - setActiveDimId((prev) => - prev && sorted.some((d) => d.id === prev) ? prev : (sorted[0]?.id ?? null), - ) - } catch (err) { - setLoadFailed(true) - toast({ - title: t('load_failed_title'), - description: getErrorMessage(err, { locale: errorLocale }), - variant: 'destructive', - }) - } finally { - setIsLoading(false) - } - }, - [toast, t, errorLocale], - ) - useEffect(() => { - void loadDimensions(true) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + if (!loadError) return + toast({ + title: t('load_failed_title'), + description: getErrorMessage(loadError, { locale: errorLocale }), + variant: 'destructive', + }) + }, [loadError, toast, t, errorLocale]) + + /** After a write: refresh the shared registry (this list and every picker). */ + const loadDimensions = useCallback(() => invalidateReferenceData('ref:dimensions'), []) const activeDim = useMemo( () => dimensions.find((d) => d.id === activeDimId) ?? null, @@ -227,7 +219,7 @@ export default function DimensionsManager() { }) } setDialog(null) - await loadDimensions(false) + await loadDimensions() } catch (err) { toast({ title: t('save_failed_title'), @@ -262,7 +254,7 @@ export default function DimensionsManager() { const createdId = (json?.data?.dimension as { id?: string } | undefined)?.id toast({ title: t('dim_created_title') }) setNewDimDialogOpen(false) - await loadDimensions(false) + await loadDimensions() if (createdId) { setActiveDimId(createdId) setSearchTerm('') @@ -300,7 +292,7 @@ export default function DimensionsManager() { } toast({ title: t('deleted_title') }) setDialog(null) - await loadDimensions(false) + await loadDimensions() } finally { setIsSaving(false) } @@ -359,7 +351,7 @@ export default function DimensionsManager() { title={t('load_failed_title')} description={t('load_failed_description')} actionLabel={t('retry')} - onAction={() => void loadDimensions(true)} + onAction={() => void refreshDimensions()} /> ) } diff --git a/components/dimensions/LineDimensionFields.tsx b/components/dimensions/LineDimensionFields.tsx index c6627d1e..216fad75 100644 --- a/components/dimensions/LineDimensionFields.tsx +++ b/components/dimensions/LineDimensionFields.tsx @@ -1,12 +1,9 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useMemo } from 'react' import { Label } from '@/components/ui/label' import DimensionCombobox from '@/components/dimensions/DimensionCombobox' -import { - fetchDimensionsCached, - type DimensionDto, -} from '@/components/dimensions/types' +import { useDimensions } from '@/lib/reference-data/hooks' interface LineDimensionFieldsProps { /** Current dimensions map ({sie_dim_no: object_code}), a line's map or the header default. */ @@ -46,24 +43,13 @@ export default function LineDimensionFields({ stacked, inputClassName, }: LineDimensionFieldsProps) { - const [registry, setRegistry] = useState(null) - - useEffect(() => { - let cancelled = false - fetchDimensionsCached() - .then((dims) => { - if (!cancelled) setRegistry(dims) - }) - .catch(() => { - /* keep the hardcoded 1/6 fallback */ - }) - return () => { - cancelled = true - } - }, []) + // Registry from the session cache (lib/reference-data): one entry shared + // by every line picker on the page; empty while loading or on failure, + // which keeps the hardcoded 1/6 fallback below. + const { dimensions: registry } = useDimensions() const fields = useMemo(() => { - const active = registry?.filter((d) => d.is_active) ?? [] + const active = registry.filter((d) => d.is_active) if (active.length === 0) return FALLBACK_FIELDS return [...active] .sort((a, b) => a.sort_order - b.sort_order || a.sie_dim_no - b.sie_dim_no) diff --git a/components/dimensions/types.ts b/components/dimensions/types.ts index 4a35cfb6..750b05e5 100644 --- a/components/dimensions/types.ts +++ b/components/dimensions/types.ts @@ -61,36 +61,3 @@ export const PROJECT_DIM_NO = 6 */ export const DIMENSION_CODE_PATTERN = /^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$/ -/** - * Load the company's dimension registry. The handler lazily seeds system dims - * 1/6 via ensure_company_dimensions, so the result always contains at least - * Kostnadsställe + Projekt. Throws the parsed error envelope on failure so - * callers can hand it straight to getErrorMessage(). - */ -export async function fetchDimensions(): Promise { - const res = await fetch('/api/dimensions') - const json = await res.json().catch(() => null) - if (!res.ok) { - throw json ?? new Error('Failed to load dimensions') - } - return (json?.dimensions ?? []) as DimensionDto[] -} - -let cachedDimensionsPromise: Promise | null = null - -/** - * Module-level cached variant of fetchDimensions for high-mount-count - * consumers (one registry fetch per page load instead of one per line - * picker). A failed fetch clears the cache so the next mount retries. - * Registry mutations are rare enough that staleness within a page visit - * is acceptable — the register UI uses the uncached fetch. - */ -export function fetchDimensionsCached(): Promise { - if (!cachedDimensionsPromise) { - cachedDimensionsPromise = fetchDimensions().catch((err) => { - cachedDimensionsPromise = null - throw err - }) - } - return cachedDimensionsPromise -} diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index 452d2a68..c75c1451 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -1,6 +1,8 @@ 'use client' import { useState, useCallback, useEffect, useReducer, useRef } from 'react' +import { useAccounts } from '@/lib/reference-data/hooks' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { useTranslations } from 'next-intl' import { Badge } from '@/components/ui/badge' import { Progress } from '@/components/ui/progress' @@ -2039,6 +2041,10 @@ export default function ArcimMigrationWorkspace({ // SIE data state (held between mapping and execution steps) const [sieData, setSieData] = useState(null) const companyAccountsForVatRef = useRef([]) + // Chart of accounts incl. inactive, from the session cache + // (lib/reference-data). Re-read when the mapping step opens because the + // preview may have created accounts; the SIE import invalidates it too. + const { refresh: refreshCompanyAccounts } = useAccounts(false) // Options state const [migrationOptions, setMigrationOptions] = useState(DEFAULT_OPTIONS) @@ -2619,16 +2625,10 @@ export default function ArcimMigrationWorkspace({ } const data = await res.json() as SIEData - const accountsRes = await fetch('/api/bookkeeping/accounts?active=false') - const accountsBody = await accountsRes.json().catch(() => ({})) as { - data?: BASAccount[] - error?: unknown - } - if (!accountsRes.ok) { - throw apiError(accountsBody, `HTTP ${accountsRes.status}`) - } - companyAccountsForVatRef.current = accountsBody.data ?? [] - const enrichedMappings = enrichAccountMappingsWithVat(data.mappings, accountsBody.data ?? []) + // Bound SWR mutate resolves with the revalidated list. + const companyAccounts = ((await refreshCompanyAccounts()) ?? []) as BASAccount[] + companyAccountsForVatRef.current = companyAccounts + const enrichedMappings = enrichAccountMappingsWithVat(data.mappings, companyAccounts) setSieData({ ...data, mappings: enrichedMappings }) // If all SIE files are already imported, disable SIE import by default @@ -2648,7 +2648,7 @@ export default function ArcimMigrationWorkspace({ } finally { setIsLoading(false) } - }, [consentId]) + }, [consentId, refreshCompanyAccounts]) const handlePreviewContinue = useCallback(() => { if (preview?.sieAvailable) { @@ -2781,6 +2781,9 @@ export default function ArcimMigrationWorkspace({ const result = await res.json() as ImportResult setSieImportResults(prev => [...prev, result]) + // The import creates accounts and a räkenskapsår: every cached + // picker must see them. + void invalidateReferenceData(['ref:accounts', 'ref:fiscal-periods']) // The endpoint returns HTTP 200 with success:false when the import // itself failed (e.g. räkenskapsår mismatch). Stop here: continuing diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index ac3ad7e9..c3580bda 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useCallback, useEffect, useRef, useMemo } from 'react' +import { useCompanySettings } from '@/lib/reference-data/hooks' import { useTranslations } from 'next-intl' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -391,7 +392,11 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { // Cash method users see "Bokför direkt" as the primary CTA; accrual users // see "Skapa leverantörsfaktura". Defaults to 'accrual' until we've read // the company settings so we don't flicker the CTA order on first paint. - const [accountingMethod, setAccountingMethod] = useState('accrual') + // The company's bookkeeping method drives the CTA hierarchy; read from the + // session-cached settings row (lib/reference-data), no request of its own. + const { settings: companySettings } = useCompanySettings() + const accountingMethod: AccountingMethod = + companySettings?.accounting_method === 'cash' ? 'cash' : 'accrual' // ── Data loading ─────────────────────────────────────────── @@ -451,16 +456,6 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { useEffect(() => { fetchItems() fetchInboxAddress() - // Resolve the company's bookkeeping method: drives CTA hierarchy. - fetch('/api/settings') - .then((r) => (r.ok ? r.json() : null)) - .then((body) => { - const method = body?.data?.accounting_method - if (method === 'cash' || method === 'accrual') { - setAccountingMethod(method) - } - }) - .catch(() => { /* keep 'accrual' default */ }) }, [fetchItems, fetchInboxAddress]) // Realtime: refetch when any invoice_inbox_items row changes for this diff --git a/components/extensions/general/TicWorkspace.tsx b/components/extensions/general/TicWorkspace.tsx index ae8568fc..8b77a28b 100644 --- a/components/extensions/general/TicWorkspace.tsx +++ b/components/extensions/general/TicWorkspace.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useCallback, useEffect } from 'react' +import { useCompanySettings } from '@/lib/reference-data/hooks' import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -188,6 +189,9 @@ function ProfileSkeleton() { export default function TicWorkspace({ userId }: WorkspaceComponentProps) { const { getByKey, save, isLoading: isDataLoading } = useExtensionData('general', 'tic') const { toast } = useToast() + // org_number from the session-cached settings row (lib/reference-data): + // the profile fetch no longer pays a /api/settings round trip first. + const { settings: companySettings, error: settingsError } = useCompanySettings() const t = useTranslations('tic_workspace') const [profile, setProfile] = useState(null) const [isFetching, setIsFetching] = useState(false) @@ -216,14 +220,13 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) { setFetchFailed(false) try { - // Get org_number from company settings - const settingsRes = await fetch('/api/settings') - if (!settingsRes.ok) { + // Get org_number from company settings (settled without a row = the + // same failure the old fetch reported). + if (settingsError) { toast({ title: t('toast_settings_failed'), variant: 'destructive' }) return } - const { data: settings } = await settingsRes.json() - const orgNumber = settings?.org_number + const orgNumber = companySettings?.org_number if (!orgNumber) { setNoOrgNumber(true) @@ -255,7 +258,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) { } finally { setIsFetching(false) } - }, [save, toast, t]) + }, [save, toast, t, companySettings, settingsError]) // Auto-fetch on first visit when no cached data useEffect(() => { diff --git a/components/import/BankFileConfirmStep.tsx b/components/import/BankFileConfirmStep.tsx index 8f887739..4c7ff673 100644 --- a/components/import/BankFileConfirmStep.tsx +++ b/components/import/BankFileConfirmStep.tsx @@ -1,6 +1,7 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' +import { useAccounts } from '@/lib/reference-data/hooks' import { useTranslations } from 'next-intl' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -18,8 +19,6 @@ import { } from 'lucide-react' import { formatCurrency } from '@/lib/utils' import { summarizeByCurrency } from '@/lib/import/bank-file/currency-summary' -import { createClient } from '@/lib/supabase/client' -import { useCompany } from '@/contexts/CompanyContext' import type { BankFileParseResult, BankFileDuplicateInfo } from '@/lib/import/bank-file/types' interface BankAccount { @@ -54,32 +53,26 @@ export default function BankFileConfirmStep({ // automatically rather than promising an exact final number. const duplicateCount = Math.min(Math.max(duplicateInfo?.duplicate_count ?? 0, 0), stats.parsed_rows) - const [bankAccounts, setBankAccounts] = useState([]) const [selectedAccount, setSelectedAccount] = useState('1930') - const { company } = useCompany() - + // Active 19xx accounts from the session-cached chart (lib/reference-data): + // the account select is populated on the first paint. + const { accounts } = useAccounts() + const bankAccounts = useMemo( + () => + accounts + .filter((a) => a.account_number >= '1900' && a.account_number <= '1999') + .sort((a, b) => a.account_number.localeCompare(b.account_number)) + .map((a) => ({ account_number: a.account_number, account_name: a.account_name })), + [accounts], + ) + // Default to 1930 if available, otherwise the first account (once). + const defaultedRef = useRef(false) useEffect(() => { - if (!company?.id) return - async function fetchBankAccounts(companyId: string) { - const supabase = createClient() - const { data } = await supabase - .from('chart_of_accounts') - .select('account_number, account_name') - .eq('company_id', companyId) - .eq('is_active', true) - .gte('account_number', '1900') - .lte('account_number', '1999') - .order('account_number') - - if (data && data.length > 0) { - setBankAccounts(data) - // Default to 1930 if available, otherwise first account - const has1930 = data.some(a => a.account_number === '1930') - if (!has1930) setSelectedAccount(data[0].account_number) - } - } - fetchBankAccounts(company.id) - }, [company?.id]) + if (defaultedRef.current || bankAccounts.length === 0) return + defaultedRef.current = true + const has1930 = bankAccounts.some((a) => a.account_number === '1930') + if (!has1930) setSelectedAccount(bankAccounts[0].account_number) + }, [bankAccounts]) if (isLoading) { return ( diff --git a/components/import/FiscalYearGapNotice.tsx b/components/import/FiscalYearGapNotice.tsx index 76e59cba..9efeb564 100644 --- a/components/import/FiscalYearGapNotice.tsx +++ b/components/import/FiscalYearGapNotice.tsx @@ -1,6 +1,7 @@ 'use client' -import { useEffect, useState } from 'react' +import { useMemo } from 'react' +import { useFiscalPeriods } from '@/lib/reference-data/hooks' import Link from 'next/link' import { useTranslations } from 'next-intl' import { AlertCircle } from 'lucide-react' @@ -16,24 +17,10 @@ import { findFiscalYearGaps, type FiscalYearGap, type PeriodLike } from '@/lib/b */ export function FiscalYearGapNotice() { const t = useTranslations('fiscal_year_gaps') - const [gaps, setGaps] = useState([]) - - useEffect(() => { - let cancelled = false - fetch('/api/bookkeeping/fiscal-periods') - .then(async (res) => { - if (!res.ok) return - const json = await res.json() - const periods = (Array.isArray(json) ? json : (json.data ?? [])) as PeriodLike[] - if (!cancelled) setGaps(findFiscalYearGaps(periods)) - }) - .catch(() => { - // Advisory only. - }) - return () => { - cancelled = true - } - }, []) + // Session-cached period list (lib/reference-data): refreshed by the import + // flow's invalidation, so the notice reflects the years just imported. + const { periods } = useFiscalPeriods() + const gaps = useMemo(() => findFiscalYearGaps(periods as PeriodLike[]), [periods]) if (gaps.length === 0) return null diff --git a/components/import/ImportReviewStep.tsx b/components/import/ImportReviewStep.tsx index 0d9d9a43..2644839a 100644 --- a/components/import/ImportReviewStep.tsx +++ b/components/import/ImportReviewStep.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useRef } from 'react' +import { useCompanySettings } from '@/lib/reference-data/hooks' import { useTranslations } from 'next-intl' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -72,6 +73,8 @@ export default function ImportReviewStep({ }: ImportReviewStepProps) { const { canWrite } = useCanWrite() const { company } = useCompany() + const { settings: companySettings } = useCompanySettings() + const companyDefaultVoucherSeries = companySettings?.default_voucher_series || null const t = useTranslations('import') const [options, setOptions] = useState({ createFiscalPeriod: true, @@ -116,15 +119,9 @@ export default function ImportReviewStep({ : Promise.resolve({ count: 0, error: null }) const [ - { data: settingsData, error: settingsError }, { data: sequencesData, error: sequencesError }, { count: ibCount, error: ibCountError }, ] = await Promise.all([ - supabase - .from('company_settings') - .select('default_voucher_series') - .eq('company_id', company.id) - .maybeSingle(), supabase .from('voucher_sequences') .select('voucher_series') @@ -134,9 +131,6 @@ export default function ImportReviewStep({ if (cancelled) return - if (settingsError) { - console.error('Failed to load company settings for voucher series', settingsError) - } if (sequencesError) { console.error('Failed to load voucher sequences', sequencesError) } @@ -144,7 +138,8 @@ export default function ImportReviewStep({ console.error('Failed to check for existing opening-balance vouchers', ibCountError) } - const companyDefault = settingsData?.default_voucher_series || null + // From the session-cached settings row (lib/reference-data). + const companyDefault = companyDefaultVoucherSeries const sequences = new Set((sequencesData || []).map((row) => row.voucher_series)) const existingIb = ibCountError ? 0 : (ibCount ?? 0) @@ -176,7 +171,7 @@ export default function ImportReviewStep({ return () => { cancelled = true } - }, [company?.id, preview.fiscalYearStart, preview.fiscalYearEnd, preview.openingBalanceTotal]) + }, [company?.id, companyDefaultVoucherSeries, preview.fiscalYearStart, preview.fiscalYearEnd, preview.openingBalanceTotal]) // Block browser close/refresh during import useUnsavedChanges(isLoading) diff --git a/components/import/OpeningBalancePeriodStep.tsx b/components/import/OpeningBalancePeriodStep.tsx index 23073436..0a408c26 100644 --- a/components/import/OpeningBalancePeriodStep.tsx +++ b/components/import/OpeningBalancePeriodStep.tsx @@ -1,12 +1,12 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' +import { useFiscalPeriods } from '@/lib/reference-data/hooks' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Label } from '@/components/ui/label' import { AlertCircle, Loader2, CheckCircle2 } from 'lucide-react' -import type { FiscalPeriod } from '@/types' interface EditableRow { id: string @@ -33,9 +33,12 @@ export default function OpeningBalancePeriodStep({ isLoading, error, }: OpeningBalancePeriodStepProps) { - const [periods, setPeriods] = useState([]) + // Session-cached period list (lib/reference-data). + const { periods, isLoading: loadingPeriods } = useFiscalPeriods() const [selectedPeriodId, setSelectedPeriodId] = useState('') - const [loadingPeriods, setLoadingPeriods] = useState(true) + // Auto-select the first open period without OB once per load, never again + // on a background refresh of the list (that would override a user pick). + const autoSelectedRef = useRef(false) // Compute totals let totalDebit = 0 @@ -51,31 +54,13 @@ export default function OpeningBalancePeriodStep({ const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0 useEffect(() => { - async function fetchPeriods() { - setLoadingPeriods(true) - try { - const res = await fetch('/api/bookkeeping/fiscal-periods') - if (res.ok) { - const data = await res.json() - const allPeriods: FiscalPeriod[] = data.data || [] - setPeriods(allPeriods) - - // Auto-select first open period without OB - const openPeriod = allPeriods.find( - (p) => !p.is_closed && !p.locked_at && !p.opening_balances_set, - ) - if (openPeriod) { - setSelectedPeriodId(openPeriod.id) - } - } - } catch { - // Silent, user can still select period - } finally { - setLoadingPeriods(false) - } - } - fetchPeriods() - }, []) + if (loadingPeriods || autoSelectedRef.current) return + autoSelectedRef.current = true + const openPeriod = periods.find( + (p) => !p.is_closed && !p.locked_at && !p.opening_balances_set, + ) + if (openPeriod) setSelectedPeriodId(openPeriod.id) + }, [periods, loadingPeriods]) const selectedPeriod = periods.find((p) => p.id === selectedPeriodId) const periodHasOB = !!selectedPeriod?.opening_balances_set diff --git a/components/invoices/PaymentBookingDialog.tsx b/components/invoices/PaymentBookingDialog.tsx index 8c87690c..457be8e8 100644 --- a/components/invoices/PaymentBookingDialog.tsx +++ b/components/invoices/PaymentBookingDialog.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useMemo } from 'react' +import { useAccounts, useCompanySettings } from '@/lib/reference-data/hooks' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { @@ -22,11 +23,10 @@ import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker' import { proposePaymentLines, resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines' import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatCurrency, formatDate } from '@/lib/utils' -import { createClient } from '@/lib/supabase/client' import { useCompany } from '@/contexts/CompanyContext' import { Plus, Trash2, Loader2 } from 'lucide-react' import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' -import type { Invoice, InvoiceItem, Customer, BASAccount, EntityType } from '@/types' +import type { Invoice, InvoiceItem, Customer, EntityType } from '@/types' import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client' type DuplicateMatchReason = 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only' @@ -67,7 +67,6 @@ export default function PaymentBookingDialog({ }: PaymentBookingDialogProps) { const { toast } = useToast() const router = useRouter() - const supabase = createClient() const { company } = useCompany() const t = useTranslations('invoice_payment_dialog') @@ -77,7 +76,16 @@ export default function PaymentBookingDialog({ amount_only: t('match_reason_amount_only'), } - const [accounts, setAccounts] = useState([]) + // Session-cached reference data (lib/reference-data), seeded by the + // dashboard layout: the chart and the settings are known on the first + // paint, so the proposed lines and the voucher preview resolve as soon as + // the dialog opens instead of after two sequential requests. + const { accounts, isLoading: accountsLoading, error: accountsError } = useAccounts() + const { + settings: companySettings, + isLoading: settingsLoading, + error: settingsError, + } = useCompanySettings() const [catalog, setCatalog] = useState([]) const [lines, setLines] = useState([]) const accountNameByNumber = useMemo(() => { @@ -92,7 +100,8 @@ export default function PaymentBookingDialog({ const [tab, setTab] = useState<'new' | 'existing'>('new') // Drives the "Befintlig verifikation" picker copy: cash links against a 19xx // debit, accrual against a 1510 credit. - const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual') + const accountingMethod: 'accrual' | 'cash' = + companySettings?.accounting_method === 'cash' ? 'cash' : 'accrual' // source_type the booking will use: drives the voucher-series preview so the // number shown matches what mark-paid will actually create. const [sourceType, setSourceType] = @@ -110,38 +119,30 @@ export default function PaymentBookingDialog({ return } + // Reference data still loading (no seed, first mount of the session): + // the effect re-runs once it lands. + if (accountsLoading || settingsLoading) return + let cancelled = false async function init() { try { - // Fetch accounts - const [accountsRes, fetchedCatalog] = await Promise.all([ - fetch('/api/bookkeeping/accounts'), - loadBasCatalog(), - ]) - if (!accountsRes.ok) throw new Error(t('load_chart_failed')) - const accountsData = await accountsRes.json() - const fetchedAccounts: BASAccount[] = accountsData.data || [] - + if (accountsError) throw new Error(t('load_chart_failed')) if (!company?.id) throw new Error(t('no_active_company')) - - // Fetch company settings - const { data: settings, error: settingsError } = await supabase - .from('company_settings') - .select('accounting_method, entity_type, ore_rounding') - .eq('company_id', company.id) - .maybeSingle() - if (settingsError) throw new Error(t('load_settings_failed')) + + const fetchedCatalog = await loadBasCatalog() if (cancelled) return - setAccounts(fetchedAccounts) setCatalog(fetchedCatalog) - const accountingMethod = (settings?.accounting_method || 'accrual') as 'accrual' | 'cash' - const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' - - setAccountingMethod(accountingMethod) + const settings = companySettings + // /api/settings used to fall back to the company row's entity type + // when company_settings.entity_type is null; the cached row does not. + const entityType: EntityType = + (settings?.entity_type as EntityType | null | undefined) ?? + company.entity_type ?? + 'enskild_firma' setSourceType( resolveInvoicePaymentSourceType({ @@ -193,7 +194,11 @@ export default function PaymentBookingDialog({ init() return () => { cancelled = true } - }, [open, invoice.id, company?.id]) + // companySettings and accountingMethod are read at init time on purpose: a + // background revalidation of the settings row must not re-run init() + // (and reset the user's lines) mid-dialog. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, invoice.id, company?.id, accountsLoading, settingsLoading, accountsError, settingsError]) // Voucher-series preview: resolve the upcoming serie + nummer the same way the // booking engine will, so a misconfigured series is visible before confirming. diff --git a/components/invoices/SendInvoiceDialog.tsx b/components/invoices/SendInvoiceDialog.tsx index 4a501c8e..777c4f48 100644 --- a/components/invoices/SendInvoiceDialog.tsx +++ b/components/invoices/SendInvoiceDialog.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useMemo } from 'react' +import { useAccounts, useCompanySettings, useFiscalPeriods } from '@/lib/reference-data/hooks' import { useLocale, useTranslations } from 'next-intl' import { Dialog, @@ -29,7 +30,7 @@ import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note' import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions' import { Loader2, Mail, Plus, Send, Trash2 } from 'lucide-react' import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' -import type { Invoice, InvoiceItem, Customer, EntityType, BASAccount } from '@/types' +import type { Invoice, InvoiceItem, Customer, EntityType } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client' import { @@ -72,12 +73,39 @@ export default function SendInvoiceDialog({ const isCreditRepair = isCreditNote && invoice.status === 'sent' const [isSubmitting, setIsSubmitting] = useState(false) - const [entityType, setEntityType] = useState('enskild_firma') - const [periodName, setPeriodName] = useState('') + // Session-cached reference data (lib/reference-data), seeded by the + // dashboard layout: settings, the period containing the invoice date and + // the chart are known on the first paint, so opening the dialog costs no + // reference requests. Only the credit-note original lookup and the BAS + // catalogue (module-cached) are still loaded in init(). + const { + settings: companySettings, + isLoading: settingsLoading, + error: settingsError, + } = useCompanySettings() + const { + periods: fiscalPeriods, + isLoading: periodsLoading, + error: periodsError, + } = useFiscalPeriods() + const { accounts } = useAccounts() + // /api/settings used to fall back to the company row's entity type when + // company_settings.entity_type is null; the cached row does not, so the + // fallback is explicit here (same rule as deriveSupplierInvoiceDefaults). + const entityType: EntityType = + (companySettings?.entity_type as EntityType | null | undefined) ?? + company?.entity_type ?? + 'enskild_firma' + const periodName = useMemo( + () => + fiscalPeriods.find( + (p) => p.period_start <= invoice.invoice_date && invoice.invoice_date <= p.period_end, + )?.name ?? '', + [fiscalPeriods, invoice.invoice_date], + ) + const deferBooking = !!companySettings?.defer_invoice_booking const [isInitialized, setIsInitialized] = useState(false) const [shouldBookOnIssue, setShouldBookOnIssue] = useState(true) - const [deferBooking, setDeferBooking] = useState(false) - const [accounts, setAccounts] = useState([]) const [catalog, setCatalog] = useState([]) const [editLines, setEditLines] = useState([]) const [hasEdited, setHasEdited] = useState(false) @@ -110,25 +138,19 @@ export default function SendInvoiceDialog({ return } + // Reference data still loading (no seed, first mount of the session): + // the effect re-runs once it lands. + if (settingsLoading || periodsLoading) return + let cancelled = false async function init() { try { if (!company?.id) throw new Error(t('no_active_company')) + if (settingsError) throw new Error(t('company_settings_failed')) + if (periodsError) throw new Error(t('fiscal_period_failed')) - const [settingsResult, periodResult, originalResult, authResult] = await Promise.all([ - supabase - .from('company_settings') - .select('accounting_method, entity_type, defer_invoice_booking, email, invoice_email_cc_addresses, invoice_email_bcc_addresses') - .eq('company_id', company.id) - .maybeSingle(), - supabase - .from('fiscal_periods') - .select('name') - .eq('company_id', company.id) - .lte('period_start', invoice.invoice_date) - .gte('period_end', invoice.invoice_date) - .maybeSingle(), + const [originalResult, sessionResult] = await Promise.all([ invoice.credited_invoice_id ? supabase .from('invoices') @@ -137,51 +159,40 @@ export default function SendInvoiceDialog({ .eq('company_id', company.id) .maybeSingle() : Promise.resolve({ data: null, error: null }), - supabase.auth.getUser(), + // Local session read (no network): only the signed-in address is + // needed, as the legacy CC fallback. + supabase.auth.getSession(), ]) - if (settingsResult.error) throw new Error(t('company_settings_failed')) - if (periodResult.error) throw new Error(t('fiscal_period_failed')) if (originalResult.error) throw new Error(t('original_invoice_failed')) - if (authResult.error || !authResult.data.user) throw new Error(t('load_failed_title')) + const sessionUser = sessionResult.data.session?.user + if (sessionResult.error || !sessionUser) throw new Error(t('load_failed_title')) if (cancelled) return - const method = (settingsResult.data?.accounting_method || 'accrual') as 'accrual' | 'cash' + const method = (companySettings?.accounting_method || 'accrual') as 'accrual' | 'cash' // #967: deferred companies mark-sent WITHOUT booking; ekonomi books // later via a separate step, so neither preview nor editor applies. const bookOnIssue = invoice.credited_invoice_id && originalResult.data ? creditNoteNeedsJournalEntry(method, originalResult.data) - : method === 'accrual' && !settingsResult.data?.defer_invoice_booking + : method === 'accrual' && !companySettings?.defer_invoice_booking - // Line editing needs the chart of accounts; only the accrual - // book-at-issue path renders the editor, so skip the fetch elsewhere. - let fetchedAccounts: BASAccount[] = [] + // Line editing needs the BAS catalogue; only the accrual + // book-at-issue path renders the editor, so skip the load elsewhere. let fetchedCatalog: CatalogAccount[] = [] if (!invoice.credited_invoice_id && bookOnIssue && !hasAccrualItems) { - const [accountsRes, catalogResult] = await Promise.all([ - fetch('/api/bookkeeping/accounts'), - loadBasCatalog(), - ]) - if (!accountsRes.ok) throw new Error(t('load_chart_failed')) - const accountsData = await accountsRes.json() - fetchedAccounts = accountsData.data || [] - fetchedCatalog = catalogResult + fetchedCatalog = await loadBasCatalog() } if (cancelled) return - setAccounts(fetchedAccounts) setCatalog(fetchedCatalog) - setEntityType((settingsResult.data?.entity_type as EntityType) || 'enskild_firma') - const legacyCc = settingsResult.data?.email || authResult.data.user.email + const legacyCc = companySettings?.email || sessionUser.email setFixedCc( - settingsResult.data?.invoice_email_cc_addresses + companySettings?.invoice_email_cc_addresses ?? (legacyCc ? [legacyCc] : []), ) - setFixedBcc(settingsResult.data?.invoice_email_bcc_addresses ?? []) - setPeriodName(periodResult.data?.name || '') - setDeferBooking(!!settingsResult.data?.defer_invoice_booking) + setFixedBcc(companySettings?.invoice_email_bcc_addresses ?? []) setShouldBookOnIssue(bookOnIssue) setIsInitialized(true) } catch (err) { @@ -197,7 +208,10 @@ export default function SendInvoiceDialog({ init() return () => { cancelled = true } - }, [open, invoice.id, invoice.invoice_date, company?.id, canCustomizeRecipients]) + // companySettings is read at init time on purpose: a background + // revalidation of the settings row must not re-run init() mid-dialog. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, invoice.id, invoice.invoice_date, company?.id, canCustomizeRecipients, settingsLoading, periodsLoading, settingsError, periodsError]) const proposedLines = useMemo(() => { if (!isInitialized || !shouldBookOnIssue) return [] diff --git a/components/pending-operations/use-account-names.ts b/components/pending-operations/use-account-names.ts index 71d8a3d7..c7bd6ca1 100644 --- a/components/pending-operations/use-account-names.ts +++ b/components/pending-operations/use-account-names.ts @@ -1,39 +1,21 @@ 'use client' -import { useEffect, useState } from 'react' +import { useMemo } from 'react' +import { useAccounts } from '@/lib/reference-data/hooks' /** - * Account number -> account name for the proposal previews, fetched once per - * mount for the active company. Provide the result through - * AccountNamesContext (OperationPreview) so every preview surface (the - * /pending queue, the chat ApprovalCard) shows "6110 Kontorsmateriel" and - * not just the number or the bank's raw text. Display-only: a failed fetch - * leaves the map empty and previews fall back to the number. + * Account number -> account name for the proposal previews. Provide the + * result through AccountNamesContext (OperationPreview) so every preview + * surface (the /pending queue, the chat ApprovalCard) shows "6110 + * Kontorsmateriel" and not just the number or the bank's raw text. + * Display-only: derived from the session-cached chart (lib/reference-data), + * so it costs no request of its own; while the chart is unavailable the map + * is empty and previews fall back to the number. */ export function useAccountNamesSource(): Record { - const [names, setNames] = useState>({}) - useEffect(() => { - let alive = true - void fetch('/api/bookkeeping/accounts') - .then((r) => r.json()) - .then(({ data }) => { - if (!alive) return - setNames( - Object.fromEntries( - ((data ?? []) as Array<{ account_number: string; account_name: string }>).map((a) => [ - a.account_number, - a.account_name, - ]), - ), - ) - }) - .catch(() => { - // Display-only: the number still shows, so a failure is not worth - // surfacing as an error the user cannot act on. - }) - return () => { - alive = false - } - }, []) - return names + const { accounts } = useAccounts() + return useMemo( + () => Object.fromEntries(accounts.map((a) => [a.account_number, a.account_name])), + [accounts], + ) } diff --git a/components/reports/DimensionFilter.tsx b/components/reports/DimensionFilter.tsx index 452950a5..fa771351 100644 --- a/components/reports/DimensionFilter.tsx +++ b/components/reports/DimensionFilter.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { X } from 'lucide-react' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' @@ -14,7 +14,7 @@ import { } from '@/components/ui/select' import DimensionCombobox from '@/components/dimensions/DimensionCombobox' import { useCompanySettings } from '@/components/settings/useSettings' -import { fetchDimensions, type DimensionDto } from '@/components/dimensions/types' +import { useDimensions } from '@/lib/reference-data/hooks' export type DimensionFilterValue = { /** SIE dimension number as a string ('1' kostnadsställe, '6' projekt). */ @@ -43,27 +43,13 @@ interface Props { */ export function DimensionFilter({ value, onChange }: Props) { const { settings } = useCompanySettings() - const [dims, setDims] = useState([]) + // Registry from the session cache (lib/reference-data). + const { dimensions: dims } = useDimensions() // Which dimension the picker targets while no value is selected yet; // once a value is picked, `value.dimNo` is the source of truth. const [pendingDimNo, setPendingDimNo] = useState('6') const enabled = settings?.dimensions_enabled === true - useEffect(() => { - if (!enabled) return - let cancelled = false - fetchDimensions() - .then((rows) => { - if (!cancelled) setDims(rows) - }) - .catch(() => { - // Best-effort: without the registry the filter simply doesn't render. - }) - return () => { - cancelled = true - } - }, [enabled]) - if (!enabled || dims.length === 0) return null const activeDimNo = value?.dimNo ?? pendingDimNo diff --git a/components/reports/SkatteverketPanel.tsx b/components/reports/SkatteverketPanel.tsx index dc8b590e..bc8f1435 100644 --- a/components/reports/SkatteverketPanel.tsx +++ b/components/reports/SkatteverketPanel.tsx @@ -2,6 +2,7 @@ import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import React, { useState, useEffect, useCallback } from 'react' +import { useCompanySettings } from '@/lib/reference-data/hooks' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { @@ -31,7 +32,7 @@ import { } from 'lucide-react' import type { VatPeriodType } from '@/types' import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format' -import { useCapability } from '@/contexts/CompanyContext' +import { useCapability, useCompanyOptional } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import { UpgradeNote } from '@/components/billing/UpgradeNote' import { InfoTooltip } from '@/components/ui/info-tooltip' @@ -367,13 +368,15 @@ function SkatteverketPanelInner({ } } - // Helper to get redovisare from settings + // Redovisare from the session-cached settings row (lib/reference-data). + // entity_type falls back to the company row, as /api/settings did. + const { settings: companySettings } = useCompanySettings() + const companyEntityType = useCompanyOptional()?.company?.entity_type ?? null const getRedovisare = useCallback(async (): Promise => { - const res = await fetch('/api/settings') - const { data } = await res.json() - if (!data?.org_number) throw new Error('Organisationsnummer saknas') - return formatRedovisare(data.org_number, data.entity_type) - }, []) + const orgNumber = companySettings?.org_number + if (!orgNumber) throw new Error('Organisationsnummer saknas') + return formatRedovisare(orgNumber, companySettings?.entity_type ?? companyEntityType) + }, [companySettings, companyEntityType]) const getRedovisningsperiod = useCallback((): string => { return formatRedovisningsperiod(periodType, year, period, fiscalYearEnd) diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index a326912c..aaaba646 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -4,7 +4,8 @@ // Rendered by the focused /reports/[slug] route (see components/reports/FocusedReport.tsx). // The regulated table/figure rendering is unchanged from the original monolith. -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState, useEffect, useCallback, useMemo } from 'react' +import { useDimensions } from '@/lib/reference-data/hooks' import Link from 'next/link' import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -3426,22 +3427,17 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da data: DimensionPnlReport | null error: string | null } | null>(null) - const [dims, setDims] = useState<{ sie_dim_no: number; name: string }[]>([]) const [dimNo, setDimNo] = useState('6') const reportQs = `${reportQuery(periodId, dateRange)}&dim_no=${encodeURIComponent(dimNo)}` - // Registered dimensions for the pivot picker (best-effort; the report - // defaults to projekt if the registry read fails). - useEffect(() => { - fetch('/api/dimensions') - .then((res) => res.json()) - .then((payload) => { - if (Array.isArray(payload.data)) { - setDims(payload.data.map((d: { sie_dim_no: number; name: string }) => ({ sie_dim_no: d.sie_dim_no, name: d.name }))) - } - }) - .catch(() => {}) - }, []) + // Registered dimensions for the pivot picker, from the session cache + // (lib/reference-data); best-effort, the report defaults to projekt while + // the registry is unavailable. + const { dimensions } = useDimensions() + const dims = useMemo( + () => dimensions.map((d) => ({ sie_dim_no: d.sie_dim_no, name: d.name })), + [dimensions], + ) useEffect(() => { let cancelled = false diff --git a/components/settings/BookingTemplatesPanel.tsx b/components/settings/BookingTemplatesPanel.tsx index 3e9056c3..c6815e13 100644 --- a/components/settings/BookingTemplatesPanel.tsx +++ b/components/settings/BookingTemplatesPanel.tsx @@ -1,7 +1,7 @@ 'use client' import { useLocale, useTranslations } from 'next-intl' -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useRef } from 'react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { HelpPopover } from '@/components/ui/help-popover' @@ -23,6 +23,7 @@ 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' +import { useBookingTemplates } from '@/lib/reference-data/hooks' export function BookingTemplatesPanel() { const t = useTranslations('settings_booking_templates') @@ -36,8 +37,10 @@ export function BookingTemplatesPanel() { aktiebolag: t('entity_aktiebolag'), } - const [templates, setTemplates] = useState([]) - const [isLoading, setIsLoading] = useState(true) + // The panel renders the same session-cached list the pickers use + // (lib/reference-data); every write below invalidates it so registry and + // pickers can never disagree. + const { templates, isLoading, error: templatesError } = useBookingTemplates() const [deletingId, setDeletingId] = useState(null) const [expandedId, setExpandedId] = useState(null) const [showCreate, setShowCreate] = useState(false) @@ -47,19 +50,13 @@ export function BookingTemplatesPanel() { const [activeTemplate, setActiveTemplate] = useState(null) const importRef = useRef(null) - const fetchTemplates = useCallback(async () => { - try { - const res = await fetch('/api/settings/booking-templates') - const json = await res.json() - if (json.data) setTemplates(json.data) - } catch { - toast({ title: t('toast_fetch_failed'), variant: 'destructive' }) - } finally { - setIsLoading(false) - } - }, [toast, t]) + useEffect(() => { + if (templatesError) toast({ title: t('toast_fetch_failed'), variant: 'destructive' }) + }, [templatesError, toast, t]) - useEffect(() => { fetchTemplates() }, [fetchTemplates]) + const refreshTemplates = () => { + void invalidateReferenceData('ref:booking-templates') + } async function handleDelete(id: string) { setDeletingId(id) @@ -73,9 +70,7 @@ export function BookingTemplatesPanel() { toast({ title: t('toast_delete_failed'), variant: 'destructive' }) 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. + // This list and every picker read the session cache: refresh it. void invalidateReferenceData('ref:booking-templates') toast({ title: t('toast_deleted') }) } finally { @@ -128,8 +123,7 @@ export function BookingTemplatesPanel() { return } toast({ title: t('toast_import_done'), description: t('toast_import_count', { count: json.imported }) }) - fetchTemplates() - void invalidateReferenceData('ref:booking-templates') + refreshTemplates() } catch { toast({ title: t('toast_import_error'), description: t('toast_invalid_file'), variant: 'destructive' }) } finally { @@ -207,7 +201,7 @@ export function BookingTemplatesPanel() { duplicateNamePool={companyTemplateNames} onSaved={() => { setShowCreate(false) - fetchTemplates() + refreshTemplates() }} /> @@ -300,7 +294,7 @@ export function BookingTemplatesPanel() { duplicateNamePool={companyTemplateNames} onSaved={() => { setActiveTemplate(null) - fetchTemplates() + refreshTemplates() }} /> )} diff --git a/components/settings/FiscalPeriodEditor.tsx b/components/settings/FiscalPeriodEditor.tsx index 38072343..51c5bad8 100644 --- a/components/settings/FiscalPeriodEditor.tsx +++ b/components/settings/FiscalPeriodEditor.tsx @@ -1,8 +1,10 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslations } from 'next-intl' import { useCompany } from '@/contexts/CompanyContext' +import { useFiscalPeriods } from '@/lib/reference-data/hooks' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' import { @@ -54,17 +56,29 @@ export function FiscalPeriodEditor() { const isEF = company?.entity_type === 'enskild_firma' const canEdit = role === 'owner' || role === 'admin' + // Periods come from the session cache (lib/reference-data). The editor + // snapshots the FIRST period once per company (a background revalidation + // must not reset the dates the user is editing), then loads that period's + // posted-entry count, which is what gates editing. + const { periods, isLoading: periodsLoading, error: periodsError } = useFiscalPeriods() + const periodsRef = useRef(periods) useEffect(() => { - if (!company) return + periodsRef.current = periods + }, [periods]) + const initialisedForRef = useRef(null) + + useEffect(() => { + if (!company || periodsLoading) return + if (initialisedForRef.current === company.id) return + initialisedForRef.current = company.id let cancelled = false async function load() { setIsLoading(true) setLoadError(null) try { - const res = await fetch('/api/bookkeeping/fiscal-periods') - if (!res.ok) throw new Error(t('fp_load_error_periods')) - const { data } = (await res.json()) as { data: FiscalPeriod[] } + if (periodsError) throw new Error(t('fp_load_error_periods')) + const data = periodsRef.current if (!data || data.length === 0) { if (!cancelled) { setPeriod(null) @@ -97,7 +111,7 @@ export function FiscalPeriodEditor() { return () => { cancelled = true } - }, [company, t]) + }, [company, periodsLoading, periodsError, t]) const validation = validateFirstPeriod( startDate, @@ -153,6 +167,8 @@ export function FiscalPeriodEditor() { throw new Error(body.error || t('fp_update_failed_title')) } setPeriod(body.data as FiscalPeriod) + // Every picker reads the shared list: refresh it with the new dates. + void invalidateReferenceData('ref:fiscal-periods') toast({ title: t('fp_updated_title'), description: `${formatSwedishDate(body.data.period_start)}: ${formatSwedishDate(body.data.period_end)}`, diff --git a/components/settings/FiscalYearsManager.tsx b/components/settings/FiscalYearsManager.tsx index 3213c54e..eb0d014c 100644 --- a/components/settings/FiscalYearsManager.tsx +++ b/components/settings/FiscalYearsManager.tsx @@ -1,7 +1,7 @@ 'use client' import { useTranslations } from 'next-intl' -import { useState, useEffect, useCallback } from 'react' +import { useState, useCallback } from 'react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' @@ -12,6 +12,8 @@ import { import { SettingsGroup } from '@/components/settings/SettingsRows' import { useToast } from '@/components/ui/use-toast' import { useCompany } from '@/contexts/CompanyContext' +import { useFiscalPeriods } from '@/lib/reference-data/hooks' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { Plus, Lock, Unlock, Loader2, Eraser } from 'lucide-react' import { formatDate } from '@/lib/utils' import type { FiscalPeriod } from '@/types' @@ -39,9 +41,11 @@ export function FiscalYearsManager() { const { toast } = useToast() const { role } = useCompany() const { dialogProps, confirm } = useDestructiveConfirm() - const [periods, setPeriods] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const [hasError, setHasError] = useState(false) + // Session-cached registry (lib/reference-data): the same list every + // picker renders, so a lock/unlock/reset here is visible everywhere the + // moment the cache is invalidated below. + const { periods, isLoading, error: periodsError } = useFiscalPeriods() + const hasError = !!periodsError && periods.length === 0 const [dialogOpen, setDialogOpen] = useState(false) const [mutatingId, setMutatingId] = useState(null) const [resetTarget, setResetTarget] = useState(null) @@ -50,21 +54,7 @@ export function FiscalYearsManager() { // too (requireWrite); this just hides controls a viewer/member can't use. const canManage = role === 'owner' || role === 'admin' - const fetchPeriods = useCallback(async () => { - try { - const res = await fetch('/api/bookkeeping/fiscal-periods') - if (!res.ok) throw new Error('fetch failed') - const { data } = await res.json() - setPeriods((data as FiscalPeriod[]) || []) - setHasError(false) - } catch { - setHasError(true) - } finally { - setIsLoading(false) - } - }, []) - - useEffect(() => { fetchPeriods() }, [fetchPeriods]) + const refreshPeriods = useCallback(() => invalidateReferenceData('ref:fiscal-periods'), []) // Newest first: matches the API's ordering and reads most-recent-at-top. const sorted = [...periods].sort((a, b) => b.period_start.localeCompare(a.period_start)) @@ -82,7 +72,7 @@ export function FiscalYearsManager() { throw new Error(body?.error?.message || t('fy_action_error')) } toast({ title: action === 'lock' ? t('fy_lock_success') : t('fy_unlock_success') }) - await fetchPeriods() + await refreshPeriods() } catch (err) { toast({ title: t('fy_action_error'), @@ -219,7 +209,7 @@ export function FiscalYearsManager() { onOpenChange={setDialogOpen} entryDate={suggestSeedDate(periods, new Date().toISOString().split('T')[0])} periods={periods} - onCreated={fetchPeriods} + onCreated={refreshPeriods} /> {resetTarget && ( @@ -230,7 +220,7 @@ export function FiscalYearsManager() { onOpenChange={(open) => { if (!open) setResetTarget(null) }} - onReset={fetchPeriods} + onReset={refreshPeriods} /> )} diff --git a/components/settings/InvoicePaymentAccountsSettings.tsx b/components/settings/InvoicePaymentAccountsSettings.tsx index 966c6d63..71e00450 100644 --- a/components/settings/InvoicePaymentAccountsSettings.tsx +++ b/components/settings/InvoicePaymentAccountsSettings.tsx @@ -15,6 +15,7 @@ import { } from '@/components/settings/SettingsRows' import { useToast } from '@/components/ui/use-toast' import { useCompany } from '@/contexts/CompanyContext' +import { useCashAccounts } from '@/lib/reference-data/hooks' import { createClient } from '@/lib/supabase/client' import { formatIbanGroups, uniqueConnectionIban } from '@/lib/company/connection-iban' import { bankgiroFromTicSnapshot } from '@/lib/company/snapshot-bank' @@ -98,7 +99,16 @@ export function InvoicePaymentAccountsSettings({ // rows (bank_connection_id nulled) and the connect picker mirrors deselected // accounts with enabled=false, so an unfiltered read could offer a closed or // third-party account. Only offered when the remaining rows agree on one IBAN. - const [connectionIban, setConnectionIban] = useState(null) + const { cashAccounts } = useCashAccounts() + const connectionIban = useMemo( + () => + uniqueConnectionIban( + cashAccounts.filter( + (a) => a.enabled && a.currency === 'SEK' && a.bank_connection_id && a.iban, + ), + ), + [cashAccounts], + ) const legacySekAccount = useMemo( () => legacySekInvoicePaymentAccount({ bank_name: settings.bank_name, @@ -166,18 +176,6 @@ export function InvoicePaymentAccountsSettings({ if (cancelled) return setSnapshotBankgiro(bankgiroFromTicSnapshot(data?.tic_snapshot, data?.org_number)) }) - supabase - .from('cash_accounts') - .select('iban') - .eq('company_id', company.id) - .eq('enabled', true) - .eq('currency', 'SEK') - .not('bank_connection_id', 'is', null) - .not('iban', 'is', null) - .then(({ data }) => { - if (cancelled) return - setConnectionIban(uniqueConnectionIban(data)) - }) return () => { cancelled = true } diff --git a/components/transactions/TemplatePicker.tsx b/components/transactions/TemplatePicker.tsx index 61195f8e..650efd45 100644 --- a/components/transactions/TemplatePicker.tsx +++ b/components/transactions/TemplatePicker.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useMemo, useEffect } from 'react' +import { useState, useMemo } from 'react' import { useTranslations } from 'next-intl' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' @@ -17,7 +17,6 @@ import { buildActiveAccountIndex, searchAccounts, type AccountSearchItem, - type ChartAccountLike, } from '@/lib/bookkeeping/account-search' import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates' @@ -25,7 +24,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' +import { useAccounts, 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. @@ -275,7 +274,11 @@ export default function TemplatePicker({ () => libraryTemplates.filter((tt) => !tt.is_system && tt.is_active), [libraryTemplates], ) - const [chartAccounts, setChartAccounts] = useState([]) + // The company's active chart, from the session cache (lib/reference-data), + // so the search field can surface real accounts (issue #1877) without a + // request per mount. buildActiveAccountIndex re-filters is_active as + // defense in depth. Only consulted when the consumer routes account picks. + const { accounts: chartAccounts } = useAccounts() // Boolean gate rather than the callback itself: the parent recreates the // handler every render, and depending on its identity would refetch the // chart on each keystroke of the page underneath. @@ -286,27 +289,6 @@ export default function TemplatePicker({ // and users know what they made). const templateDirection = direction === 'income' ? 'income' : 'expense' - // 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 - // accounts by default; buildActiveAccountIndex re-filters as defense in - // depth. Gated on the consumer actually routing account picks somewhere. - useEffect(() => { - if (!accountSearchEnabled) return - const controller = new AbortController() - ;(async () => { - try { - const res = await fetch('/api/bookkeeping/accounts', { signal: controller.signal }) - if (!res.ok) return - const { data } = await res.json() as { data?: ChartAccountLike[] } - if (data) setChartAccounts(data) - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return - } - })() - return () => { controller.abort() } - }, [accountSearchEnabled]) - const accountIndex = useMemo(() => buildActiveAccountIndex(chartAccounts), [chartAccounts]) // Lazy convertibility map. A template is "convertible" if it fits the diff --git a/extensions/general/enable-banking/components/AccountPickerDialog.tsx b/extensions/general/enable-banking/components/AccountPickerDialog.tsx index de200c9a..df970513 100644 --- a/extensions/general/enable-banking/components/AccountPickerDialog.tsx +++ b/extensions/general/enable-banking/components/AccountPickerDialog.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useMemo, useRef, useState } from 'react' +import { useAccounts, useCompanySettings, useFiscalPeriods } from '@/lib/reference-data/hooks' import Link from 'next/link' import { Dialog, @@ -33,7 +34,6 @@ import { resolveFiscalYearStart, resolveGapFillStart, } from '../lib/date-suggestions' -import type { CompanySettings } from '@/types' import type { StoredAccount } from '../types' import { BankSyncProgressDialog, @@ -97,12 +97,41 @@ export function AccountPickerDialog({ const [lastBookedDate, setLastBookedDate] = useState(null) // Earliest completed SIE import coverage start: present = migrator flow. const [sieCoverageStart, setSieCoverageStart] = useState(null) - const [chartAccounts, setChartAccounts] = useState([]) - const [chartError, setChartError] = useState(false) + // Reference data from the session cache (lib/reference-data), seeded by + // the dashboard layout: settings, the period containing today and the + // chart are known when the dialog opens, no requests of its own. A failed + // load keeps settingsLoaded false so the calendar-year fallback is never + // presented as the authoritative fiscal-year start (issue #917). + const { + settings: companySettings, + isLoading: settingsLoading, + error: settingsError, + } = useCompanySettings() + const { periods, isLoading: periodsLoading, error: periodsError } = useFiscalPeriods() + // Inactive accounts included: the old chart query did not filter on is_active. + const { accounts: allAccounts, error: chartLoadError } = useAccounts(false) + const settingsLoaded = !settingsLoading && !periodsLoading && !settingsError && !periodsError + const currentPeriodStart = useMemo(() => { + const today = new Date().toISOString().split('T')[0] + const containing = periods + .filter((p) => p.period_start <= today && today <= p.period_end) + .sort((a, b) => b.period_start.localeCompare(a.period_start)) + return containing[0]?.period_start || null + }, [periods]) + // 19xx accounts for the per-account ledger combobox. Class 19 = bank/cash + // on the BAS chart. + const chartAccounts = useMemo( + () => + allAccounts + .filter((a) => a.account_number.startsWith('19')) + .sort((a, b) => a.account_number.localeCompare(b.account_number)) + .map((a) => ({ account_number: a.account_number, account_name: a.account_name })), + [allAccounts], + ) + // Surface the failure: without the 19xx chart the ledger picker is + // silently empty, which reads as "no bank accounts exist". + const chartError = Boolean(chartLoadError) const [ledgerByUid, setLedgerByUid] = useState>({}) - const [companySettings, setCompanySettings] = useState | null>(null) - const [currentPeriodStart, setCurrentPeriodStart] = useState(null) - const [settingsLoaded, setSettingsLoaded] = useState(false) const [lookbackMode, setLookbackMode] = useState('fiscal-year') const [customSubMode, setCustomSubMode] = useState('date') @@ -127,9 +156,6 @@ export function AccountPickerDialog({ useEffect(() => { if (open) { - // Re-arm the "settings loaded" gate each open so the fiscal-year label - // doesn't flash last-open's resolved date before this open's fetch lands. - setSettingsLoaded(false) const initial = new Set( accounts.filter(a => a.enabled !== false).map(a => a.uid) ) @@ -159,45 +185,11 @@ export function AccountPickerDialog({ } }, [open, accounts]) - // Load fiscal_year_start_month + entity_type so "Sedan räkenskapsårets början" - // resolves to the right date for non-calendar fiscal years, plus the actual - // fiscal_periods row containing today: the recurring setting cannot represent - // an extended or shortened first year, so the period row wins when it exists. - useEffect(() => { - if (!open || !company?.id) return - let cancelled = false - ;(async () => { - const today = new Date().toISOString().split('T')[0] - const [settingsRes, periodRes] = await Promise.all([ - supabase - .from('company_settings') - .select('fiscal_year_start_month, entity_type') - .eq('company_id', company.id) - .maybeSingle(), - supabase - .from('fiscal_periods') - .select('period_start') - .eq('company_id', company.id) - .lte('period_start', today) - .gte('period_end', today) - .order('period_start', { ascending: false }) - .limit(1) - .maybeSingle(), - ]) - if (cancelled) return - if (settingsRes.error || periodRes.error) { - // A failed fetch must not present the calendar-year fallback as the - // authoritative fiscal-year start (issue #917). Leave settingsLoaded - // false so the date stays masked; if the user proceeds anyway the - // request falls back to the recurring-setting derivation. - return - } - setCompanySettings((settingsRes.data as { fiscal_year_start_month?: number; entity_type?: CompanySettings['entity_type'] } | null) as Pick | null) - setCurrentPeriodStart((periodRes.data as { period_start?: string } | null)?.period_start || null) - setSettingsLoaded(true) - })() - return () => { cancelled = true } - }, [open, company?.id, supabase]) + // fiscal_year_start_month + entity_type make "Sedan räkenskapsårets början" + // resolve to the right date for non-calendar fiscal years, and the actual + // fiscal_periods row containing today wins when it exists: the recurring + // setting cannot represent an extended or shortened first year. Both are + // derived above from the session cache. // Fetch the latest posted verifikat date so we can offer "day after the last // booked entry" as a one-click escape from the default fiscal-year start. @@ -303,31 +295,6 @@ export function AccountPickerDialog({ return () => { cancelled = true } }, [open, isInitialSelection, company?.id, connectionId, supabase, accounts]) - // Load 19xx accounts from the chart for the per-account ledger combobox. - // Class 19 = bank/cash on the BAS chart. - useEffect(() => { - if (!open || !company?.id) return - let cancelled = false - ;(async () => { - const { data, error } = await supabase - .from('chart_of_accounts') - .select('account_number, account_name') - .eq('company_id', company.id) - .like('account_number', '19%') - .order('account_number', { ascending: true }) - if (cancelled) return - if (error) { - // Surface the failure: without the 19xx chart the ledger picker is - // silently empty, which reads as "no bank accounts exist". - setChartError(true) - return - } - setChartError(false) - setChartAccounts((data as ChartAccount[] | null) || []) - })() - return () => { cancelled = true } - }, [open, company?.id, supabase]) - const allSelected = accounts.length > 0 && selected.size === accounts.length const noneSelected = selected.size === 0 diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json index 9a8d637e..29336a5b 100644 --- a/scripts/checks/antipatterns-baseline.json +++ b/scripts/checks/antipatterns-baseline.json @@ -34,43 +34,7 @@ ] }, "rawReferenceFetch": { - "count": 35, - "files": [ - "app/(dashboard)/assets/[id]/dispose/page.tsx", - "app/(dashboard)/bookkeeping/year-end/page.tsx", - "app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx", - "app/(dashboard)/customers/page.tsx", - "app/(dashboard)/import/page.tsx", - "app/(dashboard)/invoices/[id]/page.tsx", - "app/(dashboard)/invoices/page.tsx", - "app/(dashboard)/pending/page.tsx", - "app/(dashboard)/salary/employees/[id]/page.tsx", - "components/articles/ArticleForm.tsx", - "components/bookkeeping/ChartOfAccounts.tsx", - "components/bookkeeping/ChartOfAccountsManager.tsx", - "components/bookkeeping/CorrectionEntryDialog.tsx", - "components/bookkeeping/EditAccountDialog.tsx", - "components/bookkeeping/StrikeLinesDialog.tsx", - "components/common/CashAccountSelector.tsx", - "components/dimensions/types.ts", - "components/extensions/general/ArcimMigrationWorkspace.tsx", - "components/extensions/general/InvoiceInboxWorkspace.tsx", - "components/extensions/general/TicWorkspace.tsx", - "components/import/BankFileConfirmStep.tsx", - "components/import/FiscalYearGapNotice.tsx", - "components/import/ImportReviewStep.tsx", - "components/import/OpeningBalancePeriodStep.tsx", - "components/invoices/PaymentBookingDialog.tsx", - "components/invoices/SendInvoiceDialog.tsx", - "components/pending-operations/use-account-names.ts", - "components/reports/SkatteverketPanel.tsx", - "components/reports/views/index.tsx", - "components/settings/BookingTemplatesPanel.tsx", - "components/settings/FiscalPeriodEditor.tsx", - "components/settings/FiscalYearsManager.tsx", - "components/settings/InvoicePaymentAccountsSettings.tsx", - "components/transactions/TemplatePicker.tsx", - "extensions/general/enable-banking/components/AccountPickerDialog.tsx" - ] + "count": 0, + "files": [] } }