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
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 }
+6 -23
View File
@@ -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<string | null>(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.
+7 -17
View File
@@ -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<number, string> = {
}
export default function ChartOfAccounts() {
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [loading, setLoading] = useState(true)
// Session-cached chart (lib/reference-data), shared with every picker.
const { accounts, isLoading: loading } = useAccounts()
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
const [editingSRU, setEditingSRU] = useState<string | null>(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)
}
@@ -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<Set<number>>(new Set())
const [expandedCatalogClasses, setExpandedCatalogClasses] = useState<Set<number>>(new Set())
const [hideK2Excluded, setHideK2Excluded] = useState<boolean | null>(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([
@@ -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<BASAccount[]>([])
// 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<CatalogAccount[]>([])
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<CorrectionLine[]>([])
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')}
</span>
{accountsStatus === 'error' && (
<Button variant="outline" size="sm" onClick={() => void fetchAccounts()}>
<Button variant="outline" size="sm" onClick={() => void refreshAccounts()}>
{t('accounts_retry')}
</Button>
)}
+14 -26
View File
@@ -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<DimensionDto[]>([])
// 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<AccountDimensionRuleDto[]>([])
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<string | null>(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)
}
}
+24 -21
View File
@@ -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<BASAccount[]>([])
// 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<CatalogAccount[]>([])
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<Set<string>>(new Set())
const [newLines, setNewLines] = useState<NewLine[]>([])
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')}
</span>
{accountsStatus === 'error' && (
<Button variant="outline" size="sm" onClick={() => void fetchAccounts()}>
<Button variant="outline" size="sm" onClick={() => void refreshAccounts()}>
{t('accounts_retry')}
</Button>
)}
+28 -41
View File
@@ -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<CashAccount[]>([])
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<string | null>(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') {
+22 -20
View File
@@ -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<string | null>(null)
const [values, setValues] = useState<DimensionValueDto[]>([])
// 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<string | null>(null)
const containerRef = useRef<HTMLDivElement>(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)
+31 -39
View File
@@ -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<DimensionDto[]>([])
const [isLoading, setIsLoading] = useState(true)
const [loadFailed, setLoadFailed] = useState(false)
const [activeDimId, setActiveDimId] = useState<string | null>(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<string | null>(null)
const activeDimId =
requestedDimId && dimensions.some((d) => d.id === requestedDimId)
? requestedDimId
: (dimensions[0]?.id ?? null)
const [searchTerm, setSearchTerm] = useState('')
const [sortColumn, setSortColumn] = useState<SortColumn>('code')
const [sortDir, setSortDir] = useState<SortDir>('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()}
/>
)
}
+7 -21
View File
@@ -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<DimensionDto[] | null>(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)
-33
View File
@@ -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<DimensionDto[]> {
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<DimensionDto[]> | 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<DimensionDto[]> {
if (!cachedDimensionsPromise) {
cachedDimensionsPromise = fetchDimensions().catch((err) => {
cachedDimensionsPromise = null
throw err
})
}
return cachedDimensionsPromise
}
@@ -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<SIEData | null>(null)
const companyAccountsForVatRef = useRef<BASAccount[]>([])
// 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<MigrationOptions>(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
@@ -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<AccountingMethod>('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
@@ -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<TICCompanyProfile | null>(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(() => {
+20 -27
View File
@@ -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<BankAccount[]>([])
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<BankAccount[]>(
() =>
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 (
+6 -19
View File
@@ -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<FiscalYearGap[]>([])
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<FiscalYearGap[]>(() => findFiscalYearGaps(periods as PeriodLike[]), [periods])
if (gaps.length === 0) return null
+6 -11
View File
@@ -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<ImportExecuteOptions>({
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<string>((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)
+14 -29
View File
@@ -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<FiscalPeriod[]>([])
// Session-cached period list (lib/reference-data).
const { periods, isLoading: loadingPeriods } = useFiscalPeriods()
const [selectedPeriodId, setSelectedPeriodId] = useState<string>('')
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
+33 -28
View File
@@ -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<BASAccount[]>([])
// 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<CatalogAccount[]>([])
const [lines, setLines] = useState<FormLine[]>([])
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.
+57 -43
View File
@@ -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<EntityType>('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<BASAccount[]>([])
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [editLines, setEditLines] = useState<FormLine[]>([])
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 []
@@ -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<string, string> {
const [names, setNames] = useState<Record<string, string>>({})
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],
)
}
+4 -18
View File
@@ -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<DimensionDto[]>([])
// 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
+10 -7
View File
@@ -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<string> => {
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)
+10 -14
View File
@@ -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
+16 -22
View File
@@ -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<BookingTemplateLibrary[]>([])
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<string | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
@@ -47,19 +50,13 @@ export function BookingTemplatesPanel() {
const [activeTemplate, setActiveTemplate] = useState<BookingTemplateLibrary | null>(null)
const importRef = useRef<HTMLInputElement>(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()
}}
/>
</DialogContent>
@@ -300,7 +294,7 @@ export function BookingTemplatesPanel() {
duplicateNamePool={companyTemplateNames}
onSaved={() => {
setActiveTemplate(null)
fetchTemplates()
refreshTemplates()
}}
/>
)}
+22 -6
View File
@@ -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<string | null>(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)}`,
+12 -22
View File
@@ -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<FiscalPeriod[]>([])
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<string | null>(null)
const [resetTarget, setResetTarget] = useState<FiscalPeriod | null>(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}
/>
)}
@@ -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<string | null>(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
}
+7 -25
View File
@@ -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<ChartAccountLike[]>([])
// 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
@@ -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<string | null>(null)
// Earliest completed SIE import coverage start: present = migrator flow.
const [sieCoverageStart, setSieCoverageStart] = useState<string | null>(null)
const [chartAccounts, setChartAccounts] = useState<ChartAccount[]>([])
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<ChartAccount[]>(
() =>
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<Record<string, string>>({})
const [companySettings, setCompanySettings] = useState<Pick<CompanySettings, 'fiscal_year_start_month' | 'entity_type'> | null>(null)
const [currentPeriodStart, setCurrentPeriodStart] = useState<string | null>(null)
const [settingsLoaded, setSettingsLoaded] = useState(false)
const [lookbackMode, setLookbackMode] = useState<LookbackMode>('fiscal-year')
const [customSubMode, setCustomSubMode] = useState<CustomSubMode>('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<string>(
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<CompanySettings, 'fiscal_year_start_month' | 'entity_type'> | 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
+2 -38
View File
@@ -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": []
}
}