perf(reference-data): sweep the remaining raw reads onto the session cache, ratchet to 0 (#1941)

Final consumer migration of the responsiveness plan: the 35 files still
fetching fiscal periods, settings, accounts, cash accounts, dimensions or
templates on their own now read lib/reference-data, and every client
write site invalidates the shared cache instead of refetching locally.

Settings and registries: FiscalYearsManager, FiscalPeriodEditor (period
snapshotted once per company so a revalidation cannot reset dates being
edited), BookingTemplatesPanel, ChartOfAccounts, ChartOfAccountsManager,
EditAccountDialog, CorrectionEntryDialog, StrikeLinesDialog,
InvoicePaymentAccountsSettings; the dimensions registry (DimensionsManager,
DimensionCombobox, LineDimensionFields, DimensionFilter, bookkeeping/[id])
reads useDimensions and the ad-hoc fetchDimensions/fetchDimensionsCached
helpers are deleted.

Pages and pickers: CashAccountSelector (FyPicker-shaped restore, once per
company load), use-account-names, FiscalYearGapNotice,
OpeningBalancePeriodStep, BankFileConfirmStep, ImportReviewStep, the import
page (invalidates accounts + periods after a SIE execute), customers list,
invoices list + detail, pending, salary employee, asset dispose, year-end
and periodisering pages (invalidate periods after closing), reports
DimensionPnlView (its pivot picker read the wrong payload key and was
always empty; it now populates), SkatteverketPanel, TemplatePicker,
ArticleForm (vat_registered).

Invoice dialogs and extensions: SendInvoiceDialog, PaymentBookingDialog
(init reduced to the credit-note lookup + catalogue, proposal and voucher
preview fire on open when cached; a local getSession replaces the network
getUser for the fallback CC), InvoiceInboxWorkspace, TicWorkspace,
ArcimMigrationWorkspace (invalidates after each SIE import step),
enable-banking AccountPickerDialog.

raw-reference-fetch ratchet: 35 -> 0 files.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 14:56:37 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 4560ccbfc9
commit 3ee3565d6d
41 changed files with 636 additions and 977 deletions
+20 -17
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { use, useCallback, useEffect, useMemo, useState } from 'react' import { use, useCallback, useEffect, useMemo, useState } from 'react'
import { useFiscalPeriods } from '@/lib/reference-data/hooks'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' 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 { getErrorMessage } from '@/lib/errors/get-error-message'
import { useCanWrite } from '@/lib/hooks/use-can-write' import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency, formatDate } from '@/lib/utils' import { formatCurrency, formatDate } from '@/lib/utils'
import type { Asset, AssetDisposalType, FiscalPeriod, VatTreatment } from '@/types' import type { Asset, AssetDisposalType, VatTreatment } from '@/types'
interface PeriodOption { interface PeriodOption {
id: string id: string
@@ -61,7 +62,21 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str
const { canWrite } = useCanWrite() const { canWrite } = useCanWrite()
const [asset, setAsset] = useState<Asset | null>(null) 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 [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [disposalType, setDisposalType] = useState<AssetDisposalType>('sale') const [disposalType, setDisposalType] = useState<AssetDisposalType>('sale')
@@ -79,23 +94,11 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
Promise.all([ fetch(`/api/assets/${id}`)
fetch(`/api/assets/${id}`).then((response) => response.json()), .then((response) => response.json())
fetch('/api/bookkeeping/fiscal-periods').then((response) => response.json()), .then((assetResponse) => {
])
.then(([assetResponse, periodsResponse]) => {
if (cancelled) return if (cancelled) return
setAsset(assetResponse.data ?? null) 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(() => { .catch(() => {
if (!cancelled) { if (!cancelled) {
+5 -17
View File
@@ -54,7 +54,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message' 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 { DetailPager } from '@/components/common/DetailPager'
import { listContextKey } from '@/lib/navigation/list-context' import { listContextKey } from '@/lib/navigation/list-context'
import { useCompanyOptional } from '@/contexts/CompanyContext' 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: // Dimension registry, fetched once when any line carries a dimensions map:
// used to resolve display names for the per-line dimension text ('KS: Butik'); // 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. // 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 // Tier-2 retro-tagging (dimensions plan PR6): pencil on posted lines opens
// the audited retag dialog; the log renders as a history section below. // the audited retag dialog; the log renders as a history section below.
// Both render only when dimensions are enabled for the company. // 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 }[] { 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 () => { const fetchData = useCallback(async () => {
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
+45 -49
View File
@@ -1,6 +1,8 @@
'use client' 'use client'
import React, { useCallback, useEffect, useMemo, useState } from 'react' 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 { useRouter, useSearchParams } from 'next/navigation'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { EmptyState } from '@/components/ui/empty-state' import { EmptyState } from '@/components/ui/empty-state'
@@ -74,57 +76,47 @@ export default function YearEndPage() {
const [result, setResult] = useState<YearEndResult | null>(null) const [result, setResult] = useState<YearEndResult | null>(null)
const [navigationBlocked, setNavigationBlocked] = useState(false) 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(() => { useEffect(() => {
let cancelled = false if (periodsLoading) return
const load = async () => { if (periodsFetchError) {
try { setPeriodsError('Kunde inte hämta perioder')
const res = await fetch('/api/bookkeeping/fiscal-periods') return
if (!res.ok) { }
if (!cancelled) setPeriodsError('Kunde inte hämta perioder') const all: FiscalPeriod[] = allPeriods
return const today = new Date().toISOString().split('T')[0]
} const eligible: PeriodOption[] = all
const { data } = (await res.json()) as { data: FiscalPeriod[] } .filter((p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today)
const all = data ?? [] .map((p) => ({ ...p, eligible: true }))
const today = new Date().toISOString().split('T')[0] // Oldest first: accountants close in order.
const eligible: PeriodOption[] = all eligible.sort((a, b) => a.period_start.localeCompare(b.period_start))
.filter((p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today) setHasAnyPeriods(all.length > 0)
.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)
// The URL ?period= param may point anywhere: at an ineligible period // 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 // (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 // 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 // reset to the first eligible period; a known-but-ineligible one is
// kept selectable in the dropdown so the user can navigate away from // kept selectable in the dropdown so the user can navigate away from
// it instead of being stuck (the readiness step explains why it // it instead of being stuck (the readiness step explains why it
// cannot be closed). // cannot be closed).
let options = eligible let options = eligible
if (selectedPeriodId) { if (selectedPeriodId) {
const known = all.find((p) => p.id === selectedPeriodId) const known = all.find((p) => p.id === selectedPeriodId)
if (!known) { if (!known) {
setSelectedPeriodId(eligible.length > 0 ? eligible[0].id : null) setSelectedPeriodId(eligible.length > 0 ? eligible[0].id : null)
} else if (!eligible.some((p) => p.id === selectedPeriodId)) { } else if (!eligible.some((p) => p.id === selectedPeriodId)) {
options = [...eligible, { ...known, eligible: false }].sort((a, b) => options = [...eligible, { ...known, eligible: false }].sort((a, b) =>
a.period_start.localeCompare(b.period_start), 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')
} }
} else if (eligible.length > 0) {
setSelectedPeriodId(eligible[0].id)
} }
void load() setPeriods(options)
return () => { }, [selectedPeriodId, allPeriods, periodsLoading, periodsFetchError])
cancelled = true
}
}, [selectedPeriodId])
// ---- Sync selected period to URL so users can bookmark / share ---- // ---- Sync selected period to URL so users can bookmark / share ----
useEffect(() => { useEffect(() => {
@@ -202,6 +194,8 @@ export default function YearEndPage() {
} }
setResult(body.data as YearEndResult) setResult(body.data as YearEndResult)
setStep('result') setStep('result')
// The period is now closed: every picker and form reads the cached list.
void invalidateReferenceData('ref:fiscal-periods')
toast({ toast({
title: 'Bokslut verkställt', title: 'Bokslut verkställt',
description: `${report?.period.name ?? 'Perioden'} är stängd.`, description: `${report?.period.name ?? 'Perioden'} är stängd.`,
@@ -247,11 +241,13 @@ export default function YearEndPage() {
title: 'Perioden klarmarkerad', title: 'Perioden klarmarkerad',
description: `${selectedOption?.name ?? 'Perioden'} är nu markerad som avslutad i tidigare program.`, description: `${selectedOption?.name ?? 'Perioden'} är nu markerad som avslutad i tidigare program.`,
}) })
// Drop back to "no selection": the load effect refetches and picks the // Drop back to "no selection" and refresh the cached list: the effect
// next eligible period (the marked one no longer qualifies). // above picks the next eligible period (the marked one no longer
// qualifies).
setPeriods(null) setPeriods(null)
setSelectedPeriodId(null) setSelectedPeriodId(null)
setStep('preflight') setStep('preflight')
await invalidateReferenceData('ref:fiscal-periods')
} catch (err) { } catch (err) {
toast({ toast({
title: 'Kunde inte klarmarkera perioden', title: 'Kunde inte klarmarkera perioden',
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useFiscalPeriods } from '@/lib/reference-data/hooks'
import Link from 'next/link' import Link from 'next/link'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { ArrowLeft, ArrowRight, Loader2, Lock, Plus, Trash2 } from 'lucide-react' import { ArrowLeft, ArrowRight, Loader2, Lock, Plus, Trash2 } from 'lucide-react'
@@ -26,7 +27,6 @@ import type {
PeriodiseringSuggestion, PeriodiseringSuggestion,
PeriodiseringConfidence, PeriodiseringConfidence,
} from '@/lib/bokslut/accruals/auto-detect' } from '@/lib/bokslut/accruals/auto-detect'
import type { FiscalPeriod } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
type Step = 'vacation' | 'audit' | 'auto' | 'manual' | 'review' type Step = 'vacation' | 'audit' | 'auto' | 'manual' | 'review'
@@ -139,36 +139,24 @@ export default function PeriodiseringWizardPage() {
const [postError, setPostError] = useState<string | null>(null) const [postError, setPostError] = useState<string | null>(null)
const [postSummary, setPostSummary] = useState<{ created: number; skipped: number } | 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(() => { useEffect(() => {
let cancelled = false if (periodsLoading) return
const load = async () => { if (periodsFetchError) {
try { setPeriodsError('Kunde inte hämta perioder')
const res = await fetch('/api/bookkeeping/fiscal-periods') return
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')
}
} }
void load() const today = new Date().toISOString().split('T')[0]
return () => { const eligible = allPeriods.filter(
cancelled = true (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 ---- // ---- Fetch accruals snapshot once period chosen ----
useEffect(() => { useEffect(() => {
+18 -41
View File
@@ -1,7 +1,8 @@
'use client' 'use client'
import { useState, useEffect, useMemo, useCallback, Suspense } from 'react' 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 dynamic from 'next/dynamic'
import { useLocale, useTranslations } from 'next-intl' import { useLocale, useTranslations } from 'next-intl'
import { useSearchParams, useRouter, usePathname } from 'next/navigation' import { useSearchParams, useRouter, usePathname } from 'next/navigation'
@@ -68,8 +69,12 @@ function compareStrings(a: string, b: string): number {
function CustomersPageInner() { function CustomersPageInner() {
const { canWrite } = useCanWrite() const { canWrite } = useCanWrite()
const [customers, setCustomers] = useState<Customer[]>([]) // The roster from the session cache (lib/reference-data): /api/customers
const [isLoading, setIsLoading] = useState(true) // 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 [searchTerm, setSearchTerm] = useState('')
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS) const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDialogOpen, setIsDialogOpen] = useState(false)
@@ -115,44 +120,14 @@ function CustomersPageInner() {
[searchParams, sortColumn, sortDir, router, pathname] [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(() => { useEffect(() => {
fetchCustomers() if (!customersError) return
// eslint-disable-next-line react-hooks/exhaustive-deps toast({
}, []) title: t('load_failed_title'),
description: t('load_failed_description'),
variant: 'destructive',
})
}, [customersError, toast, t])
async function handleCreateCustomer(data: CreateCustomerInput) { async function handleCreateCustomer(data: CreateCustomerInput) {
setIsCreating(true) setIsCreating(true)
@@ -176,7 +151,9 @@ function CustomersPageInner() {
title: t('created_title'), title: t('created_title'),
description: t('created_description', { name: data.name }), 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) setIsDialogOpen(false)
} }
+12 -10
View File
@@ -1,6 +1,8 @@
'use client' 'use client'
import { useState, useCallback, useEffect } from 'react' 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 { useSearchParams, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
@@ -807,12 +809,9 @@ function SIEImportWizard() {
setIssues(data.parsed.issues) setIssues(data.parsed.issues)
setSieAccounts(data.parsed.accounts) setSieAccounts(data.parsed.accounts)
const accountsRes = await fetch('/api/bookkeeping/accounts?active=false') const accounts = await fetchAccounts(false).catch(() => {
if (!accountsRes.ok) {
throw new Error('Kunde inte hämta kontoplanen för momsgranskning.') throw new Error('Kunde inte hämta kontoplanen för momsgranskning.')
} })
const accountsData = await accountsRes.json()
const accounts = accountsData.data || []
setBasAccounts(accounts) setBasAccounts(accounts)
setMappings(enrichAccountMappingsWithVat(data.mappings, 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` }) 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 createdSet = new Set(missingAccounts.map(a => a.number))
const accountsRes = await fetch('/api/bookkeeping/accounts?active=false') // New accounts exist now: refresh every cached chart (pickers app-wide)
if (!accountsRes.ok) { // 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.') throw new Error('Kunde inte hämta kontoplanen för momsgranskning.')
} })
const accountsData = await accountsRes.json()
const accounts = accountsData.data || []
setBasAccounts(accounts) setBasAccounts(accounts)
setMappings(prev => { setMappings(prev => {
let updated = prev.map(m => let updated = prev.map(m =>
@@ -1077,6 +1076,9 @@ function SIEImportWizard() {
} }
setStep('result') 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) { if (data.result?.success) {
const created = data.result.journalEntriesCreated const created = data.result.journalEntriesCreated
+20 -28
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useRef, use } from 'react' import { useState, useEffect, useRef, use } from 'react'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl' 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') const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
// #967: register/send without booking; ekonomi books in a separate step. // #967: register/send without booking; ekonomi books in a separate step.
const [deferInvoiceBooking, setDeferInvoiceBooking] = useState(false) 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 [showBookConfirm, setShowBookConfirm] = useState(false)
const [bookVoucherPreview, setBookVoucherPreview] = useState<string | null>(null) const [bookVoucherPreview, setBookVoucherPreview] = useState<string | null>(null)
const [reminderDays, setReminderDays] = useState<[number, number, number]>([15, 30, 45]) 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) 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() const deliveriesPromise = loadDeliveries()
// Invoice, reminders, payments, and deliveries all key on the route id: one // 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 (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 // 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. // view. Resolve them together after first paint and fill their links in.
+9 -30
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import Link from 'next/link' import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
@@ -194,11 +195,14 @@ export default function InvoicesPage() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const [invoices, setInvoices] = useState<Invoice[]>([]) const [invoices, setInvoices] = useState<Invoice[]>([])
const [oreRounding, setOreRounding] = useState<boolean>(true) // Settings-driven gates from the session-cached settings row
const [rotRutEnabled, setRotRutEnabled] = useState<boolean>(false) // (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). // Booking mode drives which rows are bulk-bookable (kontantmetoden: none).
const [accountingMethod, setAccountingMethod] = useState<string>('accrual') const accountingMethod: string = companySettings?.accounting_method ?? 'accrual'
const [deferInvoiceBooking, setDeferInvoiceBooking] = useState<boolean>(false) const deferInvoiceBooking: boolean = companySettings?.defer_invoice_booking ?? false
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBulkBookConfirm, setShowBulkBookConfirm] = useState(false) const [showBulkBookConfirm, setShowBulkBookConfirm] = useState(false)
const [isBulkBooking, setIsBulkBooking] = 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 // hundreds of rows to 3 skeleton stubs and replaying the stagger-enter
// entrance for a row-scoped action was the "booking feels glitchy" jump. // entrance for a row-scoped action was the "booking feels glitchy" jump.
if (invoices.length === 0) setIsLoading(true) if (invoices.length === 0) setIsLoading(true)
const [invoicesResult, settingsResult] = await Promise.allSettled([ const [invoicesResult] = await Promise.allSettled([
fetchAllRows<Invoice>( fetchAllRows<Invoice>(
({ from, to }) => ({ from, to }) =>
supabase supabase
@@ -297,11 +301,6 @@ export default function InvoicesPage() {
.range(from, to), .range(from, to),
{ dedupeBy: (invoice) => invoice.id }, { 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') { if (invoicesResult.status === 'rejected') {
@@ -313,26 +312,6 @@ export default function InvoicesPage() {
} else { } else {
setInvoices(invoicesResult.value) 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) setIsLoading(false)
} }
+5 -20
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -33,7 +34,6 @@ import { ToastAction } from '@/components/ui/toast'
import { cn, formatDate } from '@/lib/utils' import { cn, formatDate } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message' import { getErrorMessage } from '@/lib/errors/get-error-message'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode' import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
import { import {
ClipboardCheck, ClipboardCheck,
@@ -287,30 +287,15 @@ export default function PendingOperationsPage() {
const [rejectReason, setRejectReason] = useState('') const [rejectReason, setRejectReason] = useState('')
const [isRejecting, setIsRejecting] = useState(false) const [isRejecting, setIsRejecting] = useState(false)
const { toast } = useToast() const { toast } = useToast()
const company = useCompanyOptional()?.company ?? null
// Whether the "Bokför utkasten" toast CTA leads anywhere: bulk Bokför on // Whether the "Bokför utkasten" toast CTA leads anywhere: bulk Bokför on
// /invoices only selects drafts when the company books at issue. Under // /invoices only selects drafts when the company books at issue. Under
// kontantmetoden or deferred booking (#967) the CTA would be a dead end, // kontantmetoden or deferred booking (#967) the CTA would be a dead end,
// so it stays suppressed (false until settings load: suppressing is the // so it stays suppressed (false until settings load: suppressing is the
// safe direction, the neutral hint sentence still shows). // safe direction, the neutral hint sentence still shows).
const [invoiceDraftsCtaUseful, setInvoiceDraftsCtaUseful] = useState(false) // Derived from the session-cached settings row (lib/reference-data); false
// until the row is available, suppressing being the safe direction.
useEffect(() => { const { settings: companySettings } = useCompanySettings()
if (!company) return const invoiceDraftsCtaUseful = companySettings ? booksInvoicesOnIssue(companySettings) : false
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])
// Read ?conversation= once on mount so deep-links from the agent context // Read ?conversation= once on mount so deep-links from the agent context
// strip filter the list automatically. // strip filter the list automatically.
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, use } from 'react' import { useState, useEffect, use } from 'react'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { useTranslations } from 'next-intl' 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 // Default dimensions bag ({sie_dim_no: object_code}) proposed on the
// employee's salary-cost lines at booking. The fields render only when // employee's salary-cost lines at booking. The fields render only when
// company_settings.dimensions_enabled: same UI gate as the voucher form. // 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>>({}) const [dimensions, setDimensions] = useState<Record<string, string>>({})
// The controlled form fields mirror the saved row. Called on load and again // 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() load()
}, [id]) }, [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) { function setDimension(dimNo: string, code: string | null) {
setDimensions((prev) => { setDimensions((prev) => {
const next = { ...prev } const next = { ...prev }
+6 -23
View File
@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useMemo, useState } from 'react' 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 { invalidateReferenceData } from '@/lib/reference-data/invalidate'
import { useForm, Controller } from 'react-hook-form' import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
@@ -19,7 +19,6 @@ import {
import { ChevronDown, Loader2, Lock } from 'lucide-react' import { ChevronDown, Loader2, Lock } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils' import { cn, formatCurrency } from '@/lib/utils'
import { useCanWrite } from '@/lib/hooks/use-can-write' import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useCompany } from '@/contexts/CompanyContext'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
@@ -72,7 +71,6 @@ export default function ArticleForm({
onCancel, onCancel,
}: ArticleFormProps) { }: ArticleFormProps) {
const { canWrite } = useCanWrite() const { canWrite } = useCanWrite()
const { company } = useCompany()
const supabase = createClient() const supabase = createClient()
const t = useTranslations('form_article') const t = useTranslations('form_article')
const tCommon = useTranslations('common') const tCommon = useTranslations('common')
@@ -92,7 +90,11 @@ export default function ArticleForm({
const [createAccountPrefill, setCreateAccountPrefill] = useState<string | null>(null) const [createAccountPrefill, setCreateAccountPrefill] = useState<string | null>(null)
// Momsregistrerad? A non-VAT-registered company never charges moms, so the // 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. // 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 // Supported currencies, fetched from the currencies reference table rather
// than hard-coded. Falls back to the article's own currency (or SEK) if the // than hard-coded. Falls back to the article's own currency (or SEK) if the
// fetch fails so the Select is never empty. // fetch fails so the Select is never empty.
@@ -116,25 +118,6 @@ export default function ArticleForm({
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 // 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 // hides a value the user previously set. Currency and posting account are no
// longer in here: both are permanent rows. // longer in here: both are permanent rows.
+7 -17
View File
@@ -1,7 +1,9 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState } from 'react'
import { createClient } from '@/lib/supabase/client' 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -21,24 +23,13 @@ const CLASS_LABELS: Record<number, string> = {
} }
export default function ChartOfAccounts() { export default function ChartOfAccounts() {
const [accounts, setAccounts] = useState<BASAccount[]>([]) // Session-cached chart (lib/reference-data), shared with every picker.
const [loading, setLoading] = useState(true) const { accounts, isLoading: loading } = useAccounts()
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set()) const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [editingSRU, setEditingSRU] = useState<string | null>(null) const [editingSRU, setEditingSRU] = useState<string | null>(null)
const [sruValue, setSruValue] = useState('') 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) { async function updateSRUCode(accountId: string, newSruCode: string) {
const supabase = createClient() const supabase = createClient()
const trimmed = newSruCode.trim() || null const trimmed = newSruCode.trim() || null
@@ -47,9 +38,8 @@ export default function ChartOfAccounts() {
.update({ sru_code: trimmed }) .update({ sru_code: trimmed })
.eq('id', accountId) .eq('id', accountId)
setAccounts((prev) => // Refresh the shared chart so this list and every picker show the code.
prev.map((a) => (a.id === accountId ? { ...a, sru_code: trimmed } : a)) await invalidateReferenceData('ref:accounts')
)
setEditingSRU(null) setEditingSRU(null)
} }
@@ -28,6 +28,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { BASAccount } from '@/types' import type { BASAccount } from '@/types'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import { BAS_REFERENCE, isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference' import { BAS_REFERENCE, isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' 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 [collapsedMyClasses, setCollapsedMyClasses] = useState<Set<number>>(new Set())
const [expandedCatalogClasses, setExpandedCatalogClasses] = useState<Set<number>>(new Set()) const [expandedCatalogClasses, setExpandedCatalogClasses] = useState<Set<number>>(new Set())
const [hideK2Excluded, setHideK2Excluded] = useState<boolean | null>(null) const [hideK2Excluded, setHideK2Excluded] = useState<boolean | null>(null)
const { settings: companySettings } = useCompanySettings()
// Off by default: the list endpoint's `?active=false` means "no filter", so // 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. // leaving it off keeps first paint on the smaller active-only payload.
// A deactivated account is otherwise invisible everywhere and unrecoverable. // A deactivated account is otherwise invisible everywhere and unrecoverable.
@@ -205,25 +207,18 @@ export default function ChartOfAccountsManager() {
fetchReference(), fetchReference(),
(async () => { (async () => {
if (hideK2Excluded !== null) return if (hideK2Excluded !== null) return
try { // Default to hiding K2-excluded accounts if the company uses K2
const res = await fetch('/api/settings') // (plan_type === 'k1'). Settings come from the session cache.
if (res.ok) { setHideK2Excluded(
const { data } = await res.json() (companySettings as { plan_type?: string } | null)?.plan_type === 'k1',
// 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)
}
})(), })(),
]) ])
setReferenceLoaded(true) setReferenceLoaded(true)
} finally { } finally {
setReferenceLoading(false) setReferenceLoading(false)
} }
}, [referenceLoaded, referenceLoading, fetchReference, hideK2Excluded]) }, [referenceLoaded, referenceLoading, fetchReference, hideK2Excluded, companySettings])
const refreshAll = useCallback(async () => { const refreshAll = useCallback(async () => {
await Promise.all([ await Promise.all([
@@ -23,6 +23,8 @@ import {
} from '@/components/bookkeeping/correction-entry-description' } from '@/components/bookkeeping/correction-entry-description'
import { nextLineDescriptionForAccountChange } from '@/components/bookkeeping/correction-line-description' import { nextLineDescriptionForAccountChange } from '@/components/bookkeeping/correction-line-description'
import { useToast } from '@/components/ui/use-toast' 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 { getErrorMessage } from '@/lib/errors/get-error-message'
import { Loader2, Plus, Trash2 } from 'lucide-react' import { Loader2, Plus, Trash2 } from 'lucide-react'
import { formatDate } from '@/lib/utils' import { formatDate } from '@/lib/utils'
@@ -33,7 +35,7 @@ import {
} from '@/lib/bookkeeping/correction-line-account' } from '@/lib/bookkeeping/correction-line-account'
import { splitCreateAccountPrefill } from '@/lib/bookkeeping/create-account-prefill' import { splitCreateAccountPrefill } from '@/lib/bookkeeping/create-account-prefill'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client' 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 { interface CorrectionLine {
account_number: string account_number: string
@@ -53,9 +55,18 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
const { toast } = useToast() const { toast } = useToast()
const router = useRouter() const router = useRouter()
const t = useTranslations('journal_detail') 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 [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 [lines, setLines] = useState<CorrectionLine[]>([])
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false) 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 // Pre-fill the verifikationstext with the same auto text the server
// would generate; only a user edit is sent along (see handleSubmit). // would generate; only a user edit is sent along (see handleSubmit).
setDescription(autoCorrectionDescription(entry.description)) setDescription(autoCorrectionDescription(entry.description))
void fetchAccounts() void loadCatalog()
} }
}, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps }, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps
async function fetchAccounts() { async function loadCatalog() {
setAccountsStatus('loading') setCatalogStatus('loading')
try { try {
const [res, basCatalog] = await Promise.all([ setCatalog(await loadBasCatalog())
fetch('/api/bookkeeping/accounts?active=false'), setCatalogStatus('ready')
loadBasCatalog(),
])
if (!res.ok) throw new Error(`accounts ${res.status}`)
const { data } = await res.json()
setAccounts(data || [])
setCatalog(basCatalog)
setAccountsStatus('ready')
} catch { } catch {
setAccounts([])
setCatalog([]) 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 // 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. // chart. Creating it inline keeps the half-finished rättelse intact.
const handleAccountCreated = async (account: { account_number: string; account_name?: string }) => { const handleAccountCreated = async (account: { account_number: string; account_name?: string }) => {
await fetchAccounts() await invalidateReferenceData('ref:accounts')
if (creatingAccountForLine != null) { 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 // fresh account's own name is passed alongside the stale sources. The
// reactivate path reports no name, but that account is already in // reactivate path reports no name, but that account is already in
// `accounts` (the fetch includes deactivated rows). // `accounts` (the fetch includes deactivated rows).
@@ -339,7 +342,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
{accountsStatus === 'loading' ? t('accounts_loading') : t('accounts_load_failed')} {accountsStatus === 'loading' ? t('accounts_loading') : t('accounts_load_failed')}
</span> </span>
{accountsStatus === 'error' && ( {accountsStatus === 'error' && (
<Button variant="outline" size="sm" onClick={() => void fetchAccounts()}> <Button variant="outline" size="sm" onClick={() => void refreshAccounts()}>
{t('accounts_retry')} {t('accounts_retry')}
</Button> </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 { Loader2, Plus, X } from 'lucide-react'
import DimensionCombobox from '@/components/dimensions/DimensionCombobox' import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
import { import {
fetchDimensions,
type AccountDimensionRuleDto, type AccountDimensionRuleDto,
type DimensionDto, type DimensionDto,
type DimensionRuleType, type DimensionRuleType,
} from '@/components/dimensions/types' } from '@/components/dimensions/types'
import type { BASAccount } from '@/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 { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect'
import { import {
defaultRateForVatTreatment, defaultRateForVatTreatment,
@@ -81,8 +83,11 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
// dimensions enabled (same /api/settings gate as JournalEntryForm). Rule // dimensions enabled (same /api/settings gate as JournalEntryForm). Rule
// mutations apply immediately via their own fetches + toasts; they are // mutations apply immediately via their own fetches + toasts; they are
// deliberately independent of the account PUT below. // deliberately independent of the account PUT below.
const [dimensionsEnabled, setDimensionsEnabled] = useState(false) // Settings and the dimension registry come from the session cache
const [dims, setDims] = useState<DimensionDto[]>([]) // (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 [rules, setRules] = useState<AccountDimensionRuleDto[]>([])
const [rulesLoading, setRulesLoading] = useState(false) const [rulesLoading, setRulesLoading] = useState(false)
const [addRuleOpen, setAddRuleOpen] = 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 [newRuleValueCode, setNewRuleValueCode] = useState<string | null>(null)
const [isAddingRule, setIsAddingRule] = useState(false) 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(() => { useEffect(() => {
if (!dimensionsEnabled) return if (!dimensionsEnabled) return
let cancelled = false let cancelled = false
setRulesLoading(true) setRulesLoading(true)
Promise.all([ fetch(`/api/dimensions/rules?account_number=${account.account_number}`)
fetchDimensions().catch(() => [] as DimensionDto[]), .then(async (r) => ({ ok: r.ok, json: await r.json().catch(() => null) }))
fetch(`/api/dimensions/rules?account_number=${account.account_number}`) .catch(() => ({ ok: false, json: null }))
.then(async (r) => ({ ok: r.ok, json: await r.json().catch(() => null) })) .then((rulesRes) => {
.catch(() => ({ ok: false, json: null })),
]).then(([fetchedDims, rulesRes]) => {
if (cancelled) return if (cancelled) return
setDims(fetchedDims)
if (rulesRes.ok) { if (rulesRes.ok) {
setRules((rulesRes.json?.data?.rules ?? []) as AccountDimensionRuleDto[]) setRules((rulesRes.json?.data?.rules ?? []) as AccountDimensionRuleDto[])
} }
@@ -158,7 +145,8 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
if (!valueId) { if (!valueId) {
const refreshed = await fetchDimensions().catch(() => null) const refreshed = await fetchDimensions().catch(() => null)
if (refreshed) { if (refreshed) {
setDims(refreshed) // Hand the fresh registry to the shared cache as well.
void invalidateReferenceData('ref:dimensions')
valueId = findValueId(refreshed) valueId = findValueId(refreshed)
} }
} }
+24 -21
View File
@@ -18,12 +18,14 @@ import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer'
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
import { AccountNumber } from '@/components/ui/account-number' import { AccountNumber } from '@/components/ui/account-number'
import { useToast } from '@/components/ui/use-toast' 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 { getErrorMessage } from '@/lib/errors/get-error-message'
import { changeCorrectionLineAccount, getSelectableCorrectionCatalog } from '@/lib/bookkeeping/correction-line-account' import { changeCorrectionLineAccount, getSelectableCorrectionCatalog } from '@/lib/bookkeeping/correction-line-account'
import { splitCreateAccountPrefill } from '@/lib/bookkeeping/create-account-prefill' import { splitCreateAccountPrefill } from '@/lib/bookkeeping/create-account-prefill'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client' import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
import { Loader2, Plus, Trash2 } from 'lucide-react' import { Loader2, Plus, Trash2 } from 'lucide-react'
import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types' import type { JournalEntry, JournalEntryLine } from '@/types'
interface NewLine { interface NewLine {
account_number: string account_number: string
@@ -49,9 +51,18 @@ interface Props {
export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrected }: Props) { export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrected }: Props) {
const { toast } = useToast() const { toast } = useToast()
const t = useTranslations('journal_detail') 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 [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 [strikeIds, setStrikeIds] = useState<Set<string>>(new Set())
const [newLines, setNewLines] = useState<NewLine[]>([]) const [newLines, setNewLines] = useState<NewLine[]>([])
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@@ -77,26 +88,18 @@ export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrect
if (open) { if (open) {
setStrikeIds(new Set()) setStrikeIds(new Set())
setNewLines([]) setNewLines([])
void fetchAccounts() void loadCatalog()
} }
}, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps }, [open, entry.id])
async function fetchAccounts() { async function loadCatalog() {
setAccountsStatus('loading') setCatalogStatus('loading')
try { try {
const [res, basCatalog] = await Promise.all([ setCatalog(await loadBasCatalog())
fetch('/api/bookkeeping/accounts?active=false'), setCatalogStatus('ready')
loadBasCatalog(),
])
if (!res.ok) throw new Error(`accounts ${res.status}`)
const { data } = await res.json()
setAccounts(data || [])
setCatalog(basCatalog)
setAccountsStatus('ready')
} catch { } catch {
setAccounts([])
setCatalog([]) 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 // 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. // chart. Creating it inline keeps the half-finished rättelse intact.
const handleAccountCreated = async (account: { account_number: string; account_name?: string }) => { const handleAccountCreated = async (account: { account_number: string; account_name?: string }) => {
await fetchAccounts() await invalidateReferenceData('ref:accounts')
if (creatingAccountForLine != null) { 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 // fresh account's own name is passed alongside the stale sources. The
// reactivate path reports no name, but that account is already in // reactivate path reports no name, but that account is already in
// `accounts` (the fetch includes deactivated rows). // `accounts` (the fetch includes deactivated rows).
@@ -300,7 +303,7 @@ export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrect
{accountsStatus === 'loading' ? t('accounts_loading') : t('accounts_load_failed')} {accountsStatus === 'loading' ? t('accounts_loading') : t('accounts_load_failed')}
</span> </span>
{accountsStatus === 'error' && ( {accountsStatus === 'error' && (
<Button variant="outline" size="sm" onClick={() => void fetchAccounts()}> <Button variant="outline" size="sm" onClick={() => void refreshAccounts()}>
{t('accounts_retry')} {t('accounts_retry')}
</Button> </Button>
)} )}
+28 -41
View File
@@ -1,6 +1,7 @@
'use client' '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 { Label } from '@/components/ui/label'
import { import {
Select, Select,
@@ -10,7 +11,6 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { useCompany } from '@/contexts/CompanyContext' import { useCompany } from '@/contexts/CompanyContext'
import type { CashAccount } from '@/types'
const STORAGE_KEY_PREFIX = 'Accounted:cash-account:' const STORAGE_KEY_PREFIX = 'Accounted:cash-account:'
@@ -56,57 +56,44 @@ export function CashAccountSelector({
className, className,
}: Props) { }: Props) {
const { company } = useCompany() const { company } = useCompany()
const [accounts, setAccounts] = useState<CashAccount[]>([]) // Session-cached and seeded by the dashboard layout (lib/reference-data):
const [loaded, setLoaded] = useState(false) // 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(() => { useEffect(() => {
if (!company?.id) { if (!company?.id) {
onReady?.() onReady?.()
return return
} }
let cancelled = false if (!loaded || restoredForRef.current === company.id) return
;(async () => { restoredForRef.current = company.id
const res = await fetch('/api/cash-accounts')
if (!res.ok) {
if (!cancelled) {
setLoaded(true)
onReady?.()
}
return
}
const { data } = await res.json()
if (cancelled) return
const fetched: CashAccount[] = data || [] // Restore last selection or pick the primary as default.
// is_primary first (already ordered on the server), then by ledger code. if (typeof window !== 'undefined') {
setAccounts(fetched) const stored = window.sessionStorage.getItem(STORAGE_KEY_PREFIX + company.id)
setLoaded(true) const inFetched = (ledger: string) =>
accounts.some(a => a.ledger_account === ledger)
// Restore last selection or pick the primary as default. if (stored && inFetched(stored)) {
if (typeof window !== 'undefined') { if (stored !== value) onChange(stored)
const stored = window.sessionStorage.getItem(STORAGE_KEY_PREFIX + company.id) } else {
const inFetched = (ledger: string) => const primary = accounts.find(a => a.is_primary)
fetched.some(a => a.ledger_account === ledger) const fallback = primary ?? accounts[0]
if (fallback && fallback.ledger_account !== value) {
if (stored && inFetched(stored)) { onChange(fallback.ledger_account)
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)
}
} }
} }
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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [company?.id]) }, [company?.id, loaded, accounts])
const handleChange = (next: string) => { const handleChange = (next: string) => {
if (company?.id && typeof window !== 'undefined') { 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 { getErrorMessage } from '@/lib/errors/get-error-message'
import { import {
DIMENSION_CODE_PATTERN, DIMENSION_CODE_PATTERN,
fetchDimensions,
type DimensionValueDto, type DimensionValueDto,
} from '@/components/dimensions/types' } from '@/components/dimensions/types'
import { useDimensions } from '@/lib/reference-data/hooks'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
interface DimensionComboboxProps { interface DimensionComboboxProps {
/** SIE dimension number as a string ('1' = kostnadsställe, '6' = projekt). */ /** 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 [search, setSearch] = useState(value ?? '')
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(0) const [highlightedIndex, setHighlightedIndex] = useState(0)
const [loadState, setLoadState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle') // Registry from the session cache (lib/reference-data): every combobox on
const [dimensionId, setDimensionId] = useState<string | null>(null) // the page shares one entry, so opening a picker costs no request.
const [values, setValues] = useState<DimensionValueDto[]>([]) 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 [isCreating, setIsCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null) const [createError, setCreateError] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@@ -72,24 +87,10 @@ export default function DimensionCombobox({
valuesRef.current = values valuesRef.current = values
}, [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(() => { const openDropdown = useCallback(() => {
setIsOpen(true) setIsOpen(true)
setCreateError(null) setCreateError(null)
if (loadState === 'idle') void loadValues() }, [])
}, [loadState, loadValues])
const filteredValues = useMemo(() => { const filteredValues = useMemo(() => {
const term = search.trim().toLowerCase() const term = search.trim().toLowerCase()
@@ -169,7 +170,8 @@ export default function DimensionCombobox({
start_date: null, start_date: null,
end_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) selectValue(created.code)
} finally { } finally {
setIsCreating(false) setIsCreating(false)
+31 -39
View File
@@ -2,6 +2,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl' 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 { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -44,7 +46,6 @@ import DimensionValueForm, {
type DimensionValueFormInput, type DimensionValueFormInput,
} from '@/components/dimensions/DimensionValueForm' } from '@/components/dimensions/DimensionValueForm'
import { import {
fetchDimensions,
PROJECT_DIM_NO, PROJECT_DIM_NO,
type DimensionDto, type DimensionDto,
type DimensionValueDto, type DimensionValueDto,
@@ -76,10 +77,21 @@ export default function DimensionsManager() {
const { toast } = useToast() const { toast } = useToast()
const { canWrite } = useCanWrite() const { canWrite } = useCanWrite()
const [dimensions, setDimensions] = useState<DimensionDto[]>([]) // The register renders the same session-cached registry the pickers use
const [isLoading, setIsLoading] = useState(true) // (lib/reference-data); every write below invalidates it.
const [loadFailed, setLoadFailed] = useState(false) const { dimensions: registry, isLoading, error: loadError, refresh: refreshDimensions } = useDimensions()
const [activeDimId, setActiveDimId] = useState<string | null>(null) 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 [searchTerm, setSearchTerm] = useState('')
const [sortColumn, setSortColumn] = useState<SortColumn>('code') const [sortColumn, setSortColumn] = useState<SortColumn>('code')
const [sortDir, setSortDir] = useState<SortDir>('asc') const [sortDir, setSortDir] = useState<SortDir>('asc')
@@ -88,37 +100,17 @@ export default function DimensionsManager() {
const [newDimDialogOpen, setNewDimDialogOpen] = useState(false) const [newDimDialogOpen, setNewDimDialogOpen] = useState(false)
const [isCreatingDimension, setIsCreatingDimension] = 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(() => { useEffect(() => {
void loadDimensions(true) if (!loadError) return
// eslint-disable-next-line react-hooks/exhaustive-deps 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( const activeDim = useMemo(
() => dimensions.find((d) => d.id === activeDimId) ?? null, () => dimensions.find((d) => d.id === activeDimId) ?? null,
@@ -227,7 +219,7 @@ export default function DimensionsManager() {
}) })
} }
setDialog(null) setDialog(null)
await loadDimensions(false) await loadDimensions()
} catch (err) { } catch (err) {
toast({ toast({
title: t('save_failed_title'), title: t('save_failed_title'),
@@ -262,7 +254,7 @@ export default function DimensionsManager() {
const createdId = (json?.data?.dimension as { id?: string } | undefined)?.id const createdId = (json?.data?.dimension as { id?: string } | undefined)?.id
toast({ title: t('dim_created_title') }) toast({ title: t('dim_created_title') })
setNewDimDialogOpen(false) setNewDimDialogOpen(false)
await loadDimensions(false) await loadDimensions()
if (createdId) { if (createdId) {
setActiveDimId(createdId) setActiveDimId(createdId)
setSearchTerm('') setSearchTerm('')
@@ -300,7 +292,7 @@ export default function DimensionsManager() {
} }
toast({ title: t('deleted_title') }) toast({ title: t('deleted_title') })
setDialog(null) setDialog(null)
await loadDimensions(false) await loadDimensions()
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }
@@ -359,7 +351,7 @@ export default function DimensionsManager() {
title={t('load_failed_title')} title={t('load_failed_title')}
description={t('load_failed_description')} description={t('load_failed_description')}
actionLabel={t('retry')} actionLabel={t('retry')}
onAction={() => void loadDimensions(true)} onAction={() => void refreshDimensions()}
/> />
) )
} }
+7 -21
View File
@@ -1,12 +1,9 @@
'use client' 'use client'
import { useEffect, useMemo, useState } from 'react' import { useMemo } from 'react'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import DimensionCombobox from '@/components/dimensions/DimensionCombobox' import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
import { import { useDimensions } from '@/lib/reference-data/hooks'
fetchDimensionsCached,
type DimensionDto,
} from '@/components/dimensions/types'
interface LineDimensionFieldsProps { interface LineDimensionFieldsProps {
/** Current dimensions map ({sie_dim_no: object_code}), a line's map or the header default. */ /** 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, stacked,
inputClassName, inputClassName,
}: LineDimensionFieldsProps) { }: LineDimensionFieldsProps) {
const [registry, setRegistry] = useState<DimensionDto[] | null>(null) // Registry from the session cache (lib/reference-data): one entry shared
// by every line picker on the page; empty while loading or on failure,
useEffect(() => { // which keeps the hardcoded 1/6 fallback below.
let cancelled = false const { dimensions: registry } = useDimensions()
fetchDimensionsCached()
.then((dims) => {
if (!cancelled) setRegistry(dims)
})
.catch(() => {
/* keep the hardcoded 1/6 fallback */
})
return () => {
cancelled = true
}
}, [])
const fields = useMemo(() => { 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 if (active.length === 0) return FALLBACK_FIELDS
return [...active] return [...active]
.sort((a, b) => a.sort_order - b.sort_order || a.sie_dim_no - b.sie_dim_no) .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}$/ 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' 'use client'
import { useState, useCallback, useEffect, useReducer, useRef } from 'react' 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 { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
@@ -2039,6 +2041,10 @@ export default function ArcimMigrationWorkspace({
// SIE data state (held between mapping and execution steps) // SIE data state (held between mapping and execution steps)
const [sieData, setSieData] = useState<SIEData | null>(null) const [sieData, setSieData] = useState<SIEData | null>(null)
const companyAccountsForVatRef = useRef<BASAccount[]>([]) 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 // Options state
const [migrationOptions, setMigrationOptions] = useState<MigrationOptions>(DEFAULT_OPTIONS) const [migrationOptions, setMigrationOptions] = useState<MigrationOptions>(DEFAULT_OPTIONS)
@@ -2619,16 +2625,10 @@ export default function ArcimMigrationWorkspace({
} }
const data = await res.json() as SIEData const data = await res.json() as SIEData
const accountsRes = await fetch('/api/bookkeeping/accounts?active=false') // Bound SWR mutate resolves with the revalidated list.
const accountsBody = await accountsRes.json().catch(() => ({})) as { const companyAccounts = ((await refreshCompanyAccounts()) ?? []) as BASAccount[]
data?: BASAccount[] companyAccountsForVatRef.current = companyAccounts
error?: unknown const enrichedMappings = enrichAccountMappingsWithVat(data.mappings, companyAccounts)
}
if (!accountsRes.ok) {
throw apiError(accountsBody, `HTTP ${accountsRes.status}`)
}
companyAccountsForVatRef.current = accountsBody.data ?? []
const enrichedMappings = enrichAccountMappingsWithVat(data.mappings, accountsBody.data ?? [])
setSieData({ ...data, mappings: enrichedMappings }) setSieData({ ...data, mappings: enrichedMappings })
// If all SIE files are already imported, disable SIE import by default // If all SIE files are already imported, disable SIE import by default
@@ -2648,7 +2648,7 @@ export default function ArcimMigrationWorkspace({
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
}, [consentId]) }, [consentId, refreshCompanyAccounts])
const handlePreviewContinue = useCallback(() => { const handlePreviewContinue = useCallback(() => {
if (preview?.sieAvailable) { if (preview?.sieAvailable) {
@@ -2781,6 +2781,9 @@ export default function ArcimMigrationWorkspace({
const result = await res.json() as ImportResult const result = await res.json() as ImportResult
setSieImportResults(prev => [...prev, result]) 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 // The endpoint returns HTTP 200 with success:false when the import
// itself failed (e.g. räkenskapsår mismatch). Stop here: continuing // itself failed (e.g. räkenskapsår mismatch). Stop here: continuing
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' 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 // Cash method users see "Bokför direkt" as the primary CTA; accrual users
// see "Skapa leverantörsfaktura". Defaults to 'accrual' until we've read // 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. // 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 ─────────────────────────────────────────── // ── Data loading ───────────────────────────────────────────
@@ -451,16 +456,6 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
useEffect(() => { useEffect(() => {
fetchItems() fetchItems()
fetchInboxAddress() 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]) }, [fetchItems, fetchInboxAddress])
// Realtime: refetch when any invoice_inbox_items row changes for this // Realtime: refetch when any invoice_inbox_items row changes for this
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect } from 'react'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -188,6 +189,9 @@ function ProfileSkeleton() {
export default function TicWorkspace({ userId }: WorkspaceComponentProps) { export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
const { getByKey, save, isLoading: isDataLoading } = useExtensionData('general', 'tic') const { getByKey, save, isLoading: isDataLoading } = useExtensionData('general', 'tic')
const { toast } = useToast() 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 t = useTranslations('tic_workspace')
const [profile, setProfile] = useState<TICCompanyProfile | null>(null) const [profile, setProfile] = useState<TICCompanyProfile | null>(null)
const [isFetching, setIsFetching] = useState(false) const [isFetching, setIsFetching] = useState(false)
@@ -216,14 +220,13 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
setFetchFailed(false) setFetchFailed(false)
try { try {
// Get org_number from company settings // Get org_number from company settings (settled without a row = the
const settingsRes = await fetch('/api/settings') // same failure the old fetch reported).
if (!settingsRes.ok) { if (settingsError) {
toast({ title: t('toast_settings_failed'), variant: 'destructive' }) toast({ title: t('toast_settings_failed'), variant: 'destructive' })
return return
} }
const { data: settings } = await settingsRes.json() const orgNumber = companySettings?.org_number
const orgNumber = settings?.org_number
if (!orgNumber) { if (!orgNumber) {
setNoOrgNumber(true) setNoOrgNumber(true)
@@ -255,7 +258,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
} finally { } finally {
setIsFetching(false) setIsFetching(false)
} }
}, [save, toast, t]) }, [save, toast, t, companySettings, settingsError])
// Auto-fetch on first visit when no cached data // Auto-fetch on first visit when no cached data
useEffect(() => { useEffect(() => {
+20 -27
View File
@@ -1,6 +1,7 @@
'use client' '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 { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -18,8 +19,6 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
import { summarizeByCurrency } from '@/lib/import/bank-file/currency-summary' 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' import type { BankFileParseResult, BankFileDuplicateInfo } from '@/lib/import/bank-file/types'
interface BankAccount { interface BankAccount {
@@ -54,32 +53,26 @@ export default function BankFileConfirmStep({
// automatically rather than promising an exact final number. // automatically rather than promising an exact final number.
const duplicateCount = Math.min(Math.max(duplicateInfo?.duplicate_count ?? 0, 0), stats.parsed_rows) 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 [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(() => { useEffect(() => {
if (!company?.id) return if (defaultedRef.current || bankAccounts.length === 0) return
async function fetchBankAccounts(companyId: string) { defaultedRef.current = true
const supabase = createClient() const has1930 = bankAccounts.some((a) => a.account_number === '1930')
const { data } = await supabase if (!has1930) setSelectedAccount(bankAccounts[0].account_number)
.from('chart_of_accounts') }, [bankAccounts])
.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 (isLoading) { if (isLoading) {
return ( return (
+6 -19
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useMemo } from 'react'
import { useFiscalPeriods } from '@/lib/reference-data/hooks'
import Link from 'next/link' import Link from 'next/link'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { AlertCircle } from 'lucide-react' import { AlertCircle } from 'lucide-react'
@@ -16,24 +17,10 @@ import { findFiscalYearGaps, type FiscalYearGap, type PeriodLike } from '@/lib/b
*/ */
export function FiscalYearGapNotice() { export function FiscalYearGapNotice() {
const t = useTranslations('fiscal_year_gaps') const t = useTranslations('fiscal_year_gaps')
const [gaps, setGaps] = useState<FiscalYearGap[]>([]) // Session-cached period list (lib/reference-data): refreshed by the import
// flow's invalidation, so the notice reflects the years just imported.
useEffect(() => { const { periods } = useFiscalPeriods()
let cancelled = false const gaps = useMemo<FiscalYearGap[]>(() => findFiscalYearGaps(periods as PeriodLike[]), [periods])
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
}
}, [])
if (gaps.length === 0) return null if (gaps.length === 0) return null
+6 -11
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -72,6 +73,8 @@ export default function ImportReviewStep({
}: ImportReviewStepProps) { }: ImportReviewStepProps) {
const { canWrite } = useCanWrite() const { canWrite } = useCanWrite()
const { company } = useCompany() const { company } = useCompany()
const { settings: companySettings } = useCompanySettings()
const companyDefaultVoucherSeries = companySettings?.default_voucher_series || null
const t = useTranslations('import') const t = useTranslations('import')
const [options, setOptions] = useState<ImportExecuteOptions>({ const [options, setOptions] = useState<ImportExecuteOptions>({
createFiscalPeriod: true, createFiscalPeriod: true,
@@ -116,15 +119,9 @@ export default function ImportReviewStep({
: Promise.resolve({ count: 0, error: null }) : Promise.resolve({ count: 0, error: null })
const [ const [
{ data: settingsData, error: settingsError },
{ data: sequencesData, error: sequencesError }, { data: sequencesData, error: sequencesError },
{ count: ibCount, error: ibCountError }, { count: ibCount, error: ibCountError },
] = await Promise.all([ ] = await Promise.all([
supabase
.from('company_settings')
.select('default_voucher_series')
.eq('company_id', company.id)
.maybeSingle(),
supabase supabase
.from('voucher_sequences') .from('voucher_sequences')
.select('voucher_series') .select('voucher_series')
@@ -134,9 +131,6 @@ export default function ImportReviewStep({
if (cancelled) return if (cancelled) return
if (settingsError) {
console.error('Failed to load company settings for voucher series', settingsError)
}
if (sequencesError) { if (sequencesError) {
console.error('Failed to load voucher sequences', 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) 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 sequences = new Set<string>((sequencesData || []).map((row) => row.voucher_series))
const existingIb = ibCountError ? 0 : (ibCount ?? 0) const existingIb = ibCountError ? 0 : (ibCount ?? 0)
@@ -176,7 +171,7 @@ export default function ImportReviewStep({
return () => { return () => {
cancelled = true 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 // Block browser close/refresh during import
useUnsavedChanges(isLoading) useUnsavedChanges(isLoading)
+14 -29
View File
@@ -1,12 +1,12 @@
'use client' '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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { AlertCircle, Loader2, CheckCircle2 } from 'lucide-react' import { AlertCircle, Loader2, CheckCircle2 } from 'lucide-react'
import type { FiscalPeriod } from '@/types'
interface EditableRow { interface EditableRow {
id: string id: string
@@ -33,9 +33,12 @@ export default function OpeningBalancePeriodStep({
isLoading, isLoading,
error, error,
}: OpeningBalancePeriodStepProps) { }: OpeningBalancePeriodStepProps) {
const [periods, setPeriods] = useState<FiscalPeriod[]>([]) // Session-cached period list (lib/reference-data).
const { periods, isLoading: loadingPeriods } = useFiscalPeriods()
const [selectedPeriodId, setSelectedPeriodId] = useState<string>('') 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 // Compute totals
let totalDebit = 0 let totalDebit = 0
@@ -51,31 +54,13 @@ export default function OpeningBalancePeriodStep({
const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0 const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0
useEffect(() => { useEffect(() => {
async function fetchPeriods() { if (loadingPeriods || autoSelectedRef.current) return
setLoadingPeriods(true) autoSelectedRef.current = true
try { const openPeriod = periods.find(
const res = await fetch('/api/bookkeeping/fiscal-periods') (p) => !p.is_closed && !p.locked_at && !p.opening_balances_set,
if (res.ok) { )
const data = await res.json() if (openPeriod) setSelectedPeriodId(openPeriod.id)
const allPeriods: FiscalPeriod[] = data.data || [] }, [periods, loadingPeriods])
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()
}, [])
const selectedPeriod = periods.find((p) => p.id === selectedPeriodId) const selectedPeriod = periods.find((p) => p.id === selectedPeriodId)
const periodHasOB = !!selectedPeriod?.opening_balances_set const periodHasOB = !!selectedPeriod?.opening_balances_set
+33 -28
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useAccounts, useCompanySettings } from '@/lib/reference-data/hooks'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { import {
@@ -22,11 +23,10 @@ import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
import { proposePaymentLines, resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines' import { proposePaymentLines, resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines'
import { getErrorMessage } from '@/lib/errors/get-error-message' import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils' import { formatCurrency, formatDate } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext' import { useCompany } from '@/contexts/CompanyContext'
import { Plus, Trash2, Loader2 } from 'lucide-react' import { Plus, Trash2, Loader2 } from 'lucide-react'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' 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' import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
type DuplicateMatchReason = 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only' type DuplicateMatchReason = 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only'
@@ -67,7 +67,6 @@ export default function PaymentBookingDialog({
}: PaymentBookingDialogProps) { }: PaymentBookingDialogProps) {
const { toast } = useToast() const { toast } = useToast()
const router = useRouter() const router = useRouter()
const supabase = createClient()
const { company } = useCompany() const { company } = useCompany()
const t = useTranslations('invoice_payment_dialog') const t = useTranslations('invoice_payment_dialog')
@@ -77,7 +76,16 @@ export default function PaymentBookingDialog({
amount_only: t('match_reason_amount_only'), 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 [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [lines, setLines] = useState<FormLine[]>([]) const [lines, setLines] = useState<FormLine[]>([])
const accountNameByNumber = useMemo(() => { const accountNameByNumber = useMemo(() => {
@@ -92,7 +100,8 @@ export default function PaymentBookingDialog({
const [tab, setTab] = useState<'new' | 'existing'>('new') const [tab, setTab] = useState<'new' | 'existing'>('new')
// Drives the "Befintlig verifikation" picker copy: cash links against a 19xx // Drives the "Befintlig verifikation" picker copy: cash links against a 19xx
// debit, accrual against a 1510 credit. // 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 // source_type the booking will use: drives the voucher-series preview so the
// number shown matches what mark-paid will actually create. // number shown matches what mark-paid will actually create.
const [sourceType, setSourceType] = const [sourceType, setSourceType] =
@@ -110,38 +119,30 @@ export default function PaymentBookingDialog({
return 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 let cancelled = false
async function init() { async function init() {
try { try {
// Fetch accounts if (accountsError) throw new Error(t('load_chart_failed'))
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 (!company?.id) throw new Error(t('no_active_company')) 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')) if (settingsError) throw new Error(t('load_settings_failed'))
const fetchedCatalog = await loadBasCatalog()
if (cancelled) return if (cancelled) return
setAccounts(fetchedAccounts)
setCatalog(fetchedCatalog) setCatalog(fetchedCatalog)
const accountingMethod = (settings?.accounting_method || 'accrual') as 'accrual' | 'cash' const settings = companySettings
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' // /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.
setAccountingMethod(accountingMethod) const entityType: EntityType =
(settings?.entity_type as EntityType | null | undefined) ??
company.entity_type ??
'enskild_firma'
setSourceType( setSourceType(
resolveInvoicePaymentSourceType({ resolveInvoicePaymentSourceType({
@@ -193,7 +194,11 @@ export default function PaymentBookingDialog({
init() init()
return () => { cancelled = true } 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 // Voucher-series preview: resolve the upcoming serie + nummer the same way the
// booking engine will, so a misconfigured series is visible before confirming. // booking engine will, so a misconfigured series is visible before confirming.
+57 -43
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useAccounts, useCompanySettings, useFiscalPeriods } from '@/lib/reference-data/hooks'
import { useLocale, useTranslations } from 'next-intl' import { useLocale, useTranslations } from 'next-intl'
import { import {
Dialog, Dialog,
@@ -29,7 +30,7 @@ import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions' import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
import { Loader2, Mail, Plus, Send, Trash2 } from 'lucide-react' import { Loader2, Mail, Plus, Send, Trash2 } from 'lucide-react'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' 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 { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client' import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
import { import {
@@ -72,12 +73,39 @@ export default function SendInvoiceDialog({
const isCreditRepair = isCreditNote && invoice.status === 'sent' const isCreditRepair = isCreditNote && invoice.status === 'sent'
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [entityType, setEntityType] = useState<EntityType>('enskild_firma') // Session-cached reference data (lib/reference-data), seeded by the
const [periodName, setPeriodName] = useState('') // 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 [isInitialized, setIsInitialized] = useState(false)
const [shouldBookOnIssue, setShouldBookOnIssue] = useState(true) const [shouldBookOnIssue, setShouldBookOnIssue] = useState(true)
const [deferBooking, setDeferBooking] = useState(false)
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [catalog, setCatalog] = useState<CatalogAccount[]>([]) const [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [editLines, setEditLines] = useState<FormLine[]>([]) const [editLines, setEditLines] = useState<FormLine[]>([])
const [hasEdited, setHasEdited] = useState(false) const [hasEdited, setHasEdited] = useState(false)
@@ -110,25 +138,19 @@ export default function SendInvoiceDialog({
return 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 let cancelled = false
async function init() { async function init() {
try { try {
if (!company?.id) throw new Error(t('no_active_company')) 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([ const [originalResult, sessionResult] = 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(),
invoice.credited_invoice_id invoice.credited_invoice_id
? supabase ? supabase
.from('invoices') .from('invoices')
@@ -137,51 +159,40 @@ export default function SendInvoiceDialog({
.eq('company_id', company.id) .eq('company_id', company.id)
.maybeSingle() .maybeSingle()
: Promise.resolve({ data: null, error: null }), : 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 (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 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 // #967: deferred companies mark-sent WITHOUT booking; ekonomi books
// later via a separate step, so neither preview nor editor applies. // later via a separate step, so neither preview nor editor applies.
const bookOnIssue = invoice.credited_invoice_id && originalResult.data const bookOnIssue = invoice.credited_invoice_id && originalResult.data
? creditNoteNeedsJournalEntry(method, 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 // Line editing needs the BAS catalogue; only the accrual
// book-at-issue path renders the editor, so skip the fetch elsewhere. // book-at-issue path renders the editor, so skip the load elsewhere.
let fetchedAccounts: BASAccount[] = []
let fetchedCatalog: CatalogAccount[] = [] let fetchedCatalog: CatalogAccount[] = []
if (!invoice.credited_invoice_id && bookOnIssue && !hasAccrualItems) { if (!invoice.credited_invoice_id && bookOnIssue && !hasAccrualItems) {
const [accountsRes, catalogResult] = await Promise.all([ fetchedCatalog = await loadBasCatalog()
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
} }
if (cancelled) return if (cancelled) return
setAccounts(fetchedAccounts)
setCatalog(fetchedCatalog) setCatalog(fetchedCatalog)
setEntityType((settingsResult.data?.entity_type as EntityType) || 'enskild_firma') const legacyCc = companySettings?.email || sessionUser.email
const legacyCc = settingsResult.data?.email || authResult.data.user.email
setFixedCc( setFixedCc(
settingsResult.data?.invoice_email_cc_addresses companySettings?.invoice_email_cc_addresses
?? (legacyCc ? [legacyCc] : []), ?? (legacyCc ? [legacyCc] : []),
) )
setFixedBcc(settingsResult.data?.invoice_email_bcc_addresses ?? []) setFixedBcc(companySettings?.invoice_email_bcc_addresses ?? [])
setPeriodName(periodResult.data?.name || '')
setDeferBooking(!!settingsResult.data?.defer_invoice_booking)
setShouldBookOnIssue(bookOnIssue) setShouldBookOnIssue(bookOnIssue)
setIsInitialized(true) setIsInitialized(true)
} catch (err) { } catch (err) {
@@ -197,7 +208,10 @@ export default function SendInvoiceDialog({
init() init()
return () => { cancelled = true } 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(() => { const proposedLines = useMemo(() => {
if (!isInitialized || !shouldBookOnIssue) return [] if (!isInitialized || !shouldBookOnIssue) return []
@@ -1,39 +1,21 @@
'use client' '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 * Account number -> account name for the proposal previews. Provide the
* mount for the active company. Provide the result through * result through AccountNamesContext (OperationPreview) so every preview
* AccountNamesContext (OperationPreview) so every preview surface (the * surface (the /pending queue, the chat ApprovalCard) shows "6110
* /pending queue, the chat ApprovalCard) shows "6110 Kontorsmateriel" and * Kontorsmateriel" and not just the number or the bank's raw text.
* not just the number or the bank's raw text. Display-only: a failed fetch * Display-only: derived from the session-cached chart (lib/reference-data),
* leaves the map empty and previews fall back to the number. * 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> { export function useAccountNamesSource(): Record<string, string> {
const [names, setNames] = useState<Record<string, string>>({}) const { accounts } = useAccounts()
useEffect(() => { return useMemo(
let alive = true () => Object.fromEntries(accounts.map((a) => [a.account_number, a.account_name])),
void fetch('/api/bookkeeping/accounts') [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
} }
+4 -18
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useState } from 'react'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -14,7 +14,7 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import DimensionCombobox from '@/components/dimensions/DimensionCombobox' import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
import { useCompanySettings } from '@/components/settings/useSettings' import { useCompanySettings } from '@/components/settings/useSettings'
import { fetchDimensions, type DimensionDto } from '@/components/dimensions/types' import { useDimensions } from '@/lib/reference-data/hooks'
export type DimensionFilterValue = { export type DimensionFilterValue = {
/** SIE dimension number as a string ('1' kostnadsställe, '6' projekt). */ /** SIE dimension number as a string ('1' kostnadsställe, '6' projekt). */
@@ -43,27 +43,13 @@ interface Props {
*/ */
export function DimensionFilter({ value, onChange }: Props) { export function DimensionFilter({ value, onChange }: Props) {
const { settings } = useCompanySettings() 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; // Which dimension the picker targets while no value is selected yet;
// once a value is picked, `value.dimNo` is the source of truth. // once a value is picked, `value.dimNo` is the source of truth.
const [pendingDimNo, setPendingDimNo] = useState('6') const [pendingDimNo, setPendingDimNo] = useState('6')
const enabled = settings?.dimensions_enabled === true 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 if (!enabled || dims.length === 0) return null
const activeDimNo = value?.dimNo ?? pendingDimNo const activeDimNo = value?.dimNo ?? pendingDimNo
+10 -7
View File
@@ -2,6 +2,7 @@
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { import {
@@ -31,7 +32,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import type { VatPeriodType } from '@/types' import type { VatPeriodType } from '@/types'
import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format' 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 { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote' import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { InfoTooltip } from '@/components/ui/info-tooltip' 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 getRedovisare = useCallback(async (): Promise<string> => {
const res = await fetch('/api/settings') const orgNumber = companySettings?.org_number
const { data } = await res.json() if (!orgNumber) throw new Error('Organisationsnummer saknas')
if (!data?.org_number) throw new Error('Organisationsnummer saknas') return formatRedovisare(orgNumber, companySettings?.entity_type ?? companyEntityType)
return formatRedovisare(data.org_number, data.entity_type) }, [companySettings, companyEntityType])
}, [])
const getRedovisningsperiod = useCallback((): string => { const getRedovisningsperiod = useCallback((): string => {
return formatRedovisningsperiod(periodType, year, period, fiscalYearEnd) 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). // Rendered by the focused /reports/[slug] route (see components/reports/FocusedReport.tsx).
// The regulated table/figure rendering is unchanged from the original monolith. // 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 Link from 'next/link'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -3426,22 +3427,17 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da
data: DimensionPnlReport | null data: DimensionPnlReport | null
error: string | null error: string | null
} | null>(null) } | null>(null)
const [dims, setDims] = useState<{ sie_dim_no: number; name: string }[]>([])
const [dimNo, setDimNo] = useState('6') const [dimNo, setDimNo] = useState('6')
const reportQs = `${reportQuery(periodId, dateRange)}&dim_no=${encodeURIComponent(dimNo)}` const reportQs = `${reportQuery(periodId, dateRange)}&dim_no=${encodeURIComponent(dimNo)}`
// Registered dimensions for the pivot picker (best-effort; the report // Registered dimensions for the pivot picker, from the session cache
// defaults to projekt if the registry read fails). // (lib/reference-data); best-effort, the report defaults to projekt while
useEffect(() => { // the registry is unavailable.
fetch('/api/dimensions') const { dimensions } = useDimensions()
.then((res) => res.json()) const dims = useMemo(
.then((payload) => { () => dimensions.map((d) => ({ sie_dim_no: d.sie_dim_no, name: d.name })),
if (Array.isArray(payload.data)) { [dimensions],
setDims(payload.data.map((d: { sie_dim_no: number; name: string }) => ({ sie_dim_no: d.sie_dim_no, name: d.name }))) )
}
})
.catch(() => {})
}, [])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
+16 -22
View File
@@ -1,7 +1,7 @@
'use client' 'use client'
import { useLocale, useTranslations } from 'next-intl' 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 { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { HelpPopover } from '@/components/ui/help-popover' 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 { cn } from '@/lib/utils'
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types' import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
import { useBookingTemplates } from '@/lib/reference-data/hooks'
export function BookingTemplatesPanel() { export function BookingTemplatesPanel() {
const t = useTranslations('settings_booking_templates') const t = useTranslations('settings_booking_templates')
@@ -36,8 +37,10 @@ export function BookingTemplatesPanel() {
aktiebolag: t('entity_aktiebolag'), aktiebolag: t('entity_aktiebolag'),
} }
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([]) // The panel renders the same session-cached list the pickers use
const [isLoading, setIsLoading] = useState(true) // (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 [deletingId, setDeletingId] = useState<string | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
@@ -47,19 +50,13 @@ export function BookingTemplatesPanel() {
const [activeTemplate, setActiveTemplate] = useState<BookingTemplateLibrary | null>(null) const [activeTemplate, setActiveTemplate] = useState<BookingTemplateLibrary | null>(null)
const importRef = useRef<HTMLInputElement>(null) const importRef = useRef<HTMLInputElement>(null)
const fetchTemplates = useCallback(async () => { useEffect(() => {
try { if (templatesError) toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
const res = await fetch('/api/settings/booking-templates') }, [templatesError, toast, t])
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(() => { fetchTemplates() }, [fetchTemplates]) const refreshTemplates = () => {
void invalidateReferenceData('ref:booking-templates')
}
async function handleDelete(id: string) { async function handleDelete(id: string) {
setDeletingId(id) setDeletingId(id)
@@ -73,9 +70,7 @@ export function BookingTemplatesPanel() {
toast({ title: t('toast_delete_failed'), variant: 'destructive' }) toast({ title: t('toast_delete_failed'), variant: 'destructive' })
return return
} }
setTemplates((prev) => prev.filter((tt) => tt.id !== id)) // This list and every picker read the session cache: refresh it.
// The pickers read the session cache (lib/reference-data): drop the
// deleted template there too, not just from this panel's own list.
void invalidateReferenceData('ref:booking-templates') void invalidateReferenceData('ref:booking-templates')
toast({ title: t('toast_deleted') }) toast({ title: t('toast_deleted') })
} finally { } finally {
@@ -128,8 +123,7 @@ export function BookingTemplatesPanel() {
return return
} }
toast({ title: t('toast_import_done'), description: t('toast_import_count', { count: json.imported }) }) toast({ title: t('toast_import_done'), description: t('toast_import_count', { count: json.imported }) })
fetchTemplates() refreshTemplates()
void invalidateReferenceData('ref:booking-templates')
} catch { } catch {
toast({ title: t('toast_import_error'), description: t('toast_invalid_file'), variant: 'destructive' }) toast({ title: t('toast_import_error'), description: t('toast_invalid_file'), variant: 'destructive' })
} finally { } finally {
@@ -207,7 +201,7 @@ export function BookingTemplatesPanel() {
duplicateNamePool={companyTemplateNames} duplicateNamePool={companyTemplateNames}
onSaved={() => { onSaved={() => {
setShowCreate(false) setShowCreate(false)
fetchTemplates() refreshTemplates()
}} }}
/> />
</DialogContent> </DialogContent>
@@ -300,7 +294,7 @@ export function BookingTemplatesPanel() {
duplicateNamePool={companyTemplateNames} duplicateNamePool={companyTemplateNames}
onSaved={() => { onSaved={() => {
setActiveTemplate(null) setActiveTemplate(null)
fetchTemplates() refreshTemplates()
}} }}
/> />
)} )}
+22 -6
View File
@@ -1,8 +1,10 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext' 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 { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { import {
@@ -54,17 +56,29 @@ export function FiscalPeriodEditor() {
const isEF = company?.entity_type === 'enskild_firma' const isEF = company?.entity_type === 'enskild_firma'
const canEdit = role === 'owner' || role === 'admin' 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(() => { 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 let cancelled = false
async function load() { async function load() {
setIsLoading(true) setIsLoading(true)
setLoadError(null) setLoadError(null)
try { try {
const res = await fetch('/api/bookkeeping/fiscal-periods') if (periodsError) throw new Error(t('fp_load_error_periods'))
if (!res.ok) throw new Error(t('fp_load_error_periods')) const data = periodsRef.current
const { data } = (await res.json()) as { data: FiscalPeriod[] }
if (!data || data.length === 0) { if (!data || data.length === 0) {
if (!cancelled) { if (!cancelled) {
setPeriod(null) setPeriod(null)
@@ -97,7 +111,7 @@ export function FiscalPeriodEditor() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [company, t]) }, [company, periodsLoading, periodsError, t])
const validation = validateFirstPeriod( const validation = validateFirstPeriod(
startDate, startDate,
@@ -153,6 +167,8 @@ export function FiscalPeriodEditor() {
throw new Error(body.error || t('fp_update_failed_title')) throw new Error(body.error || t('fp_update_failed_title'))
} }
setPeriod(body.data as FiscalPeriod) setPeriod(body.data as FiscalPeriod)
// Every picker reads the shared list: refresh it with the new dates.
void invalidateReferenceData('ref:fiscal-periods')
toast({ toast({
title: t('fp_updated_title'), title: t('fp_updated_title'),
description: `${formatSwedishDate(body.data.period_start)}: ${formatSwedishDate(body.data.period_end)}`, description: `${formatSwedishDate(body.data.period_start)}: ${formatSwedishDate(body.data.period_end)}`,
+12 -22
View File
@@ -1,7 +1,7 @@
'use client' 'use client'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react' import { useState, useCallback } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
@@ -12,6 +12,8 @@ import {
import { SettingsGroup } from '@/components/settings/SettingsRows' import { SettingsGroup } from '@/components/settings/SettingsRows'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext' 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 { Plus, Lock, Unlock, Loader2, Eraser } from 'lucide-react'
import { formatDate } from '@/lib/utils' import { formatDate } from '@/lib/utils'
import type { FiscalPeriod } from '@/types' import type { FiscalPeriod } from '@/types'
@@ -39,9 +41,11 @@ export function FiscalYearsManager() {
const { toast } = useToast() const { toast } = useToast()
const { role } = useCompany() const { role } = useCompany()
const { dialogProps, confirm } = useDestructiveConfirm() const { dialogProps, confirm } = useDestructiveConfirm()
const [periods, setPeriods] = useState<FiscalPeriod[]>([]) // Session-cached registry (lib/reference-data): the same list every
const [isLoading, setIsLoading] = useState(true) // picker renders, so a lock/unlock/reset here is visible everywhere the
const [hasError, setHasError] = useState(false) // moment the cache is invalidated below.
const { periods, isLoading, error: periodsError } = useFiscalPeriods()
const hasError = !!periodsError && periods.length === 0
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [mutatingId, setMutatingId] = useState<string | null>(null) const [mutatingId, setMutatingId] = useState<string | null>(null)
const [resetTarget, setResetTarget] = useState<FiscalPeriod | 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. // too (requireWrite); this just hides controls a viewer/member can't use.
const canManage = role === 'owner' || role === 'admin' const canManage = role === 'owner' || role === 'admin'
const fetchPeriods = useCallback(async () => { const refreshPeriods = useCallback(() => invalidateReferenceData('ref:fiscal-periods'), [])
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])
// Newest first: matches the API's ordering and reads most-recent-at-top. // 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)) 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')) throw new Error(body?.error?.message || t('fy_action_error'))
} }
toast({ title: action === 'lock' ? t('fy_lock_success') : t('fy_unlock_success') }) toast({ title: action === 'lock' ? t('fy_lock_success') : t('fy_unlock_success') })
await fetchPeriods() await refreshPeriods()
} catch (err) { } catch (err) {
toast({ toast({
title: t('fy_action_error'), title: t('fy_action_error'),
@@ -219,7 +209,7 @@ export function FiscalYearsManager() {
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}
entryDate={suggestSeedDate(periods, new Date().toISOString().split('T')[0])} entryDate={suggestSeedDate(periods, new Date().toISOString().split('T')[0])}
periods={periods} periods={periods}
onCreated={fetchPeriods} onCreated={refreshPeriods}
/> />
{resetTarget && ( {resetTarget && (
@@ -230,7 +220,7 @@ export function FiscalYearsManager() {
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setResetTarget(null) if (!open) setResetTarget(null)
}} }}
onReset={fetchPeriods} onReset={refreshPeriods}
/> />
)} )}
@@ -15,6 +15,7 @@ import {
} from '@/components/settings/SettingsRows' } from '@/components/settings/SettingsRows'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext' import { useCompany } from '@/contexts/CompanyContext'
import { useCashAccounts } from '@/lib/reference-data/hooks'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import { formatIbanGroups, uniqueConnectionIban } from '@/lib/company/connection-iban' import { formatIbanGroups, uniqueConnectionIban } from '@/lib/company/connection-iban'
import { bankgiroFromTicSnapshot } from '@/lib/company/snapshot-bank' 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 // rows (bank_connection_id nulled) and the connect picker mirrors deselected
// accounts with enabled=false, so an unfiltered read could offer a closed or // 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. // 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( const legacySekAccount = useMemo(
() => legacySekInvoicePaymentAccount({ () => legacySekInvoicePaymentAccount({
bank_name: settings.bank_name, bank_name: settings.bank_name,
@@ -166,18 +176,6 @@ export function InvoicePaymentAccountsSettings({
if (cancelled) return if (cancelled) return
setSnapshotBankgiro(bankgiroFromTicSnapshot(data?.tic_snapshot, data?.org_number)) 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 () => { return () => {
cancelled = true cancelled = true
} }
+7 -25
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useMemo, useEffect } from 'react' import { useState, useMemo } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -17,7 +17,6 @@ import {
buildActiveAccountIndex, buildActiveAccountIndex,
searchAccounts, searchAccounts,
type AccountSearchItem, type AccountSearchItem,
type ChartAccountLike,
} from '@/lib/bookkeeping/account-search' } from '@/lib/bookkeeping/account-search'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates' 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 { getAccountName } from '@/lib/bookkeeping/client-account-names'
import type { BookingTemplateLibrary, EntityType } from '@/types' import type { BookingTemplateLibrary, EntityType } from '@/types'
import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions' 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 // Cap on the "Konton" search-result group: enough to cover sibling accounts
// on a number-prefix query without drowning the template results. // 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.filter((tt) => !tt.is_system && tt.is_active),
[libraryTemplates], [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 // Boolean gate rather than the callback itself: the parent recreates the
// handler every render, and depending on its identity would refetch the // handler every render, and depending on its identity would refetch the
// chart on each keystroke of the page underneath. // chart on each keystroke of the page underneath.
@@ -286,27 +289,6 @@ export default function TemplatePicker({
// and users know what they made). // and users know what they made).
const templateDirection = direction === 'income' ? 'income' : 'expense' 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]) const accountIndex = useMemo(() => buildActiveAccountIndex(chartAccounts), [chartAccounts])
// Lazy convertibility map. A template is "convertible" if it fits the // Lazy convertibility map. A template is "convertible" if it fits the
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useAccounts, useCompanySettings, useFiscalPeriods } from '@/lib/reference-data/hooks'
import Link from 'next/link' import Link from 'next/link'
import { import {
Dialog, Dialog,
@@ -33,7 +34,6 @@ import {
resolveFiscalYearStart, resolveFiscalYearStart,
resolveGapFillStart, resolveGapFillStart,
} from '../lib/date-suggestions' } from '../lib/date-suggestions'
import type { CompanySettings } from '@/types'
import type { StoredAccount } from '../types' import type { StoredAccount } from '../types'
import { import {
BankSyncProgressDialog, BankSyncProgressDialog,
@@ -97,12 +97,41 @@ export function AccountPickerDialog({
const [lastBookedDate, setLastBookedDate] = useState<string | null>(null) const [lastBookedDate, setLastBookedDate] = useState<string | null>(null)
// Earliest completed SIE import coverage start: present = migrator flow. // Earliest completed SIE import coverage start: present = migrator flow.
const [sieCoverageStart, setSieCoverageStart] = useState<string | null>(null) const [sieCoverageStart, setSieCoverageStart] = useState<string | null>(null)
const [chartAccounts, setChartAccounts] = useState<ChartAccount[]>([]) // Reference data from the session cache (lib/reference-data), seeded by
const [chartError, setChartError] = useState(false) // 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 [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 [lookbackMode, setLookbackMode] = useState<LookbackMode>('fiscal-year')
const [customSubMode, setCustomSubMode] = useState<CustomSubMode>('date') const [customSubMode, setCustomSubMode] = useState<CustomSubMode>('date')
@@ -127,9 +156,6 @@ export function AccountPickerDialog({
useEffect(() => { useEffect(() => {
if (open) { 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>( const initial = new Set<string>(
accounts.filter(a => a.enabled !== false).map(a => a.uid) accounts.filter(a => a.enabled !== false).map(a => a.uid)
) )
@@ -159,45 +185,11 @@ export function AccountPickerDialog({
} }
}, [open, accounts]) }, [open, accounts])
// Load fiscal_year_start_month + entity_type so "Sedan räkenskapsårets början" // fiscal_year_start_month + entity_type make "Sedan räkenskapsårets början"
// resolves to the right date for non-calendar fiscal years, plus the actual // resolve to the right date for non-calendar fiscal years, and the actual
// fiscal_periods row containing today: the recurring setting cannot represent // fiscal_periods row containing today wins when it exists: the recurring
// an extended or shortened first year, so the period row wins when it exists. // setting cannot represent an extended or shortened first year. Both are
useEffect(() => { // derived above from the session cache.
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])
// Fetch the latest posted verifikat date so we can offer "day after the last // 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. // booked entry" as a one-click escape from the default fiscal-year start.
@@ -303,31 +295,6 @@ export function AccountPickerDialog({
return () => { cancelled = true } return () => { cancelled = true }
}, [open, isInitialSelection, company?.id, connectionId, supabase, accounts]) }, [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 allSelected = accounts.length > 0 && selected.size === accounts.length
const noneSelected = selected.size === 0 const noneSelected = selected.size === 0
+2 -38
View File
@@ -34,43 +34,7 @@
] ]
}, },
"rawReferenceFetch": { "rawReferenceFetch": {
"count": 35, "count": 0,
"files": [ "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"
]
} }
} }