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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 14:56:37 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 4560ccbfc9
commit 3ee3565d6d
41 changed files with 636 additions and 977 deletions
+20 -17
View File
@@ -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<Asset | null>(null)
const [periods, setPeriods] = useState<PeriodOption[]>([])
// Periods from the session cache (lib/reference-data), narrowed to the
// fields the picker needs.
const { periods: fiscalPeriods } = useFiscalPeriods()
const periods = useMemo<PeriodOption[]>(
() =>
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<AssetDisposalType>('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) {
+5 -17
View File
@@ -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<DimensionDto[] | null>(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<string, string>; new_dimensions: Record<string, string>; 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)
+45 -49
View File
@@ -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<YearEndResult | null>(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',
@@ -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<string | null>(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(() => {
+18 -41
View File
@@ -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<Customer[]>([])
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)
}
+12 -10
View File
@@ -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
+20 -28
View File
@@ -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<string | null>(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.
+9 -30
View File
@@ -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<Invoice[]>([])
const [oreRounding, setOreRounding] = useState<boolean>(true)
const [rotRutEnabled, setRotRutEnabled] = useState<boolean>(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<string>('accrual')
const [deferInvoiceBooking, setDeferInvoiceBooking] = useState<boolean>(false)
const accountingMethod: string = companySettings?.accounting_method ?? 'accrual'
const deferInvoiceBooking: boolean = companySettings?.defer_invoice_booking ?? false
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<Invoice>(
({ 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)
}
+5 -20
View File
@@ -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.
@@ -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<Record<string, string>>({})
// 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 }