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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
4560ccbfc9
commit
3ee3565d6d
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { use, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useFiscalPeriods } from '@/lib/reference-data/hooks'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
@@ -24,7 +25,7 @@ import { assessJamkning, assessJamkningEligibility } from '@/lib/bokslut/assets/
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import type { Asset, AssetDisposalType, FiscalPeriod, VatTreatment } from '@/types'
|
||||
import type { Asset, AssetDisposalType, VatTreatment } from '@/types'
|
||||
|
||||
interface PeriodOption {
|
||||
id: string
|
||||
@@ -61,7 +62,21 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str
|
||||
const { canWrite } = useCanWrite()
|
||||
|
||||
const [asset, setAsset] = useState<Asset | null>(null)
|
||||
const [periods, setPeriods] = useState<PeriodOption[]>([])
|
||||
// Periods from the session cache (lib/reference-data), narrowed to the
|
||||
// fields the picker needs.
|
||||
const { periods: fiscalPeriods } = useFiscalPeriods()
|
||||
const periods = useMemo<PeriodOption[]>(
|
||||
() =>
|
||||
fiscalPeriods.map((period) => ({
|
||||
id: period.id,
|
||||
name: period.name,
|
||||
period_start: period.period_start,
|
||||
period_end: period.period_end,
|
||||
is_closed: period.is_closed,
|
||||
locked_at: period.locked_at,
|
||||
})),
|
||||
[fiscalPeriods],
|
||||
)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [disposalType, setDisposalType] = useState<AssetDisposalType>('sale')
|
||||
@@ -79,23 +94,11 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
Promise.all([
|
||||
fetch(`/api/assets/${id}`).then((response) => response.json()),
|
||||
fetch('/api/bookkeeping/fiscal-periods').then((response) => response.json()),
|
||||
])
|
||||
.then(([assetResponse, periodsResponse]) => {
|
||||
fetch(`/api/assets/${id}`)
|
||||
.then((response) => response.json())
|
||||
.then((assetResponse) => {
|
||||
if (cancelled) return
|
||||
setAsset(assetResponse.data ?? null)
|
||||
setPeriods(
|
||||
(periodsResponse.data ?? []).map((period: FiscalPeriod) => ({
|
||||
id: period.id,
|
||||
name: period.name,
|
||||
period_start: period.period_start,
|
||||
period_end: period.period_end,
|
||||
is_closed: period.is_closed,
|
||||
locked_at: period.locked_at,
|
||||
})),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
|
||||
@@ -54,7 +54,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { fetchDimensions, type DimensionDto } from '@/components/dimensions/types'
|
||||
import { useDimensions } from '@/lib/reference-data/hooks'
|
||||
import { DetailPager } from '@/components/common/DetailPager'
|
||||
import { listContextKey } from '@/lib/navigation/list-context'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
@@ -159,7 +159,10 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
// Dimension registry, fetched once when any line carries a dimensions map:
|
||||
// used to resolve display names for the per-line dimension text ('KS: Butik');
|
||||
// it falls back to raw codes when the fetch fails or a code is unregistered.
|
||||
const [registryDims, setRegistryDims] = useState<DimensionDto[] | null>(null)
|
||||
// Dimension names for tagged lines, from the session-cached registry
|
||||
// (lib/reference-data); null until it is there, raw codes render meanwhile.
|
||||
const { dimensions } = useDimensions()
|
||||
const registryDims = dimensions.length > 0 ? dimensions : null
|
||||
// Tier-2 retro-tagging (dimensions plan PR6): pencil on posted lines opens
|
||||
// the audited retag dialog; the log renders as a history section below.
|
||||
// Both render only when dimensions are enabled for the company.
|
||||
@@ -170,21 +173,6 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
{ id: string; line_id: string; old_dimensions: Record<string, string>; new_dimensions: Record<string, string>; reason: string; created_at: string }[]
|
||||
>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (registryDims !== null) return
|
||||
const entryLines = (entry?.lines || []) as JournalEntryLine[]
|
||||
if (!entryLines.some((l) => l.dimensions && Object.keys(l.dimensions).length > 0)) return
|
||||
let cancelled = false
|
||||
fetchDimensions()
|
||||
.then((dims) => {
|
||||
if (!cancelled) setRegistryDims(dims)
|
||||
})
|
||||
.catch(() => {/* display-only, raw codes are fine */})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [entry, registryDims])
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useFiscalPeriods } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
@@ -74,57 +76,47 @@ export default function YearEndPage() {
|
||||
const [result, setResult] = useState<YearEndResult | null>(null)
|
||||
const [navigationBlocked, setNavigationBlocked] = useState(false)
|
||||
|
||||
// ---- Load eligible periods ----
|
||||
// ---- Eligible periods, from the session-cached list (lib/reference-data) ----
|
||||
// Recomputed whenever the cached list changes: the close actions below
|
||||
// invalidate it, which is what drops a just-closed year out of the picker.
|
||||
const { periods: allPeriods, isLoading: periodsLoading, error: periodsFetchError } = useFiscalPeriods()
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setPeriodsError('Kunde inte hämta perioder')
|
||||
return
|
||||
}
|
||||
const { data } = (await res.json()) as { data: FiscalPeriod[] }
|
||||
const all = data ?? []
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const eligible: PeriodOption[] = all
|
||||
.filter((p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today)
|
||||
.map((p) => ({ ...p, eligible: true }))
|
||||
// Oldest first: accountants close in order.
|
||||
eligible.sort((a, b) => a.period_start.localeCompare(b.period_start))
|
||||
if (cancelled) return
|
||||
setHasAnyPeriods(all.length > 0)
|
||||
if (periodsLoading) return
|
||||
if (periodsFetchError) {
|
||||
setPeriodsError('Kunde inte hämta perioder')
|
||||
return
|
||||
}
|
||||
const all: FiscalPeriod[] = allPeriods
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const eligible: PeriodOption[] = all
|
||||
.filter((p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today)
|
||||
.map((p) => ({ ...p, eligible: true }))
|
||||
// Oldest first: accountants close in order.
|
||||
eligible.sort((a, b) => a.period_start.localeCompare(b.period_start))
|
||||
setHasAnyPeriods(all.length > 0)
|
||||
|
||||
// The URL ?period= param may point anywhere: at an ineligible period
|
||||
// (e.g. a year that has not ended) or, after a company switch, at a
|
||||
// period that does not exist in this company at all. An unknown id is
|
||||
// reset to the first eligible period; a known-but-ineligible one is
|
||||
// kept selectable in the dropdown so the user can navigate away from
|
||||
// it instead of being stuck (the readiness step explains why it
|
||||
// cannot be closed).
|
||||
let options = eligible
|
||||
if (selectedPeriodId) {
|
||||
const known = all.find((p) => p.id === selectedPeriodId)
|
||||
if (!known) {
|
||||
setSelectedPeriodId(eligible.length > 0 ? eligible[0].id : null)
|
||||
} else if (!eligible.some((p) => p.id === selectedPeriodId)) {
|
||||
options = [...eligible, { ...known, eligible: false }].sort((a, b) =>
|
||||
a.period_start.localeCompare(b.period_start),
|
||||
)
|
||||
}
|
||||
} else if (eligible.length > 0) {
|
||||
setSelectedPeriodId(eligible[0].id)
|
||||
}
|
||||
setPeriods(options)
|
||||
} catch {
|
||||
if (!cancelled) setPeriodsError('Kunde inte hämta perioder')
|
||||
// The URL ?period= param may point anywhere: at an ineligible period
|
||||
// (e.g. a year that has not ended) or, after a company switch, at a
|
||||
// period that does not exist in this company at all. An unknown id is
|
||||
// reset to the first eligible period; a known-but-ineligible one is
|
||||
// kept selectable in the dropdown so the user can navigate away from
|
||||
// it instead of being stuck (the readiness step explains why it
|
||||
// cannot be closed).
|
||||
let options = eligible
|
||||
if (selectedPeriodId) {
|
||||
const known = all.find((p) => p.id === selectedPeriodId)
|
||||
if (!known) {
|
||||
setSelectedPeriodId(eligible.length > 0 ? eligible[0].id : null)
|
||||
} else if (!eligible.some((p) => p.id === selectedPeriodId)) {
|
||||
options = [...eligible, { ...known, eligible: false }].sort((a, b) =>
|
||||
a.period_start.localeCompare(b.period_start),
|
||||
)
|
||||
}
|
||||
} else if (eligible.length > 0) {
|
||||
setSelectedPeriodId(eligible[0].id)
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selectedPeriodId])
|
||||
setPeriods(options)
|
||||
}, [selectedPeriodId, allPeriods, periodsLoading, periodsFetchError])
|
||||
|
||||
// ---- Sync selected period to URL so users can bookmark / share ----
|
||||
useEffect(() => {
|
||||
@@ -202,6 +194,8 @@ export default function YearEndPage() {
|
||||
}
|
||||
setResult(body.data as YearEndResult)
|
||||
setStep('result')
|
||||
// The period is now closed: every picker and form reads the cached list.
|
||||
void invalidateReferenceData('ref:fiscal-periods')
|
||||
toast({
|
||||
title: 'Bokslut verkställt',
|
||||
description: `${report?.period.name ?? 'Perioden'} är stängd.`,
|
||||
@@ -247,11 +241,13 @@ export default function YearEndPage() {
|
||||
title: 'Perioden klarmarkerad',
|
||||
description: `${selectedOption?.name ?? 'Perioden'} är nu markerad som avslutad i tidigare program.`,
|
||||
})
|
||||
// Drop back to "no selection": the load effect refetches and picks the
|
||||
// next eligible period (the marked one no longer qualifies).
|
||||
// Drop back to "no selection" and refresh the cached list: the effect
|
||||
// above picks the next eligible period (the marked one no longer
|
||||
// qualifies).
|
||||
setPeriods(null)
|
||||
setSelectedPeriodId(null)
|
||||
setStep('preflight')
|
||||
await invalidateReferenceData('ref:fiscal-periods')
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte klarmarkera perioden',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useFiscalPeriods } from '@/lib/reference-data/hooks'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { ArrowLeft, ArrowRight, Loader2, Lock, Plus, Trash2 } from 'lucide-react'
|
||||
@@ -26,7 +27,6 @@ import type {
|
||||
PeriodiseringSuggestion,
|
||||
PeriodiseringConfidence,
|
||||
} from '@/lib/bokslut/accruals/auto-detect'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
type Step = 'vacation' | 'audit' | 'auto' | 'manual' | 'review'
|
||||
@@ -139,36 +139,24 @@ export default function PeriodiseringWizardPage() {
|
||||
const [postError, setPostError] = useState<string | null>(null)
|
||||
const [postSummary, setPostSummary] = useState<{ created: number; skipped: number } | null>(null)
|
||||
|
||||
// ---- Load eligible periods ----
|
||||
// ---- Eligible periods, from the session-cached list (lib/reference-data) ----
|
||||
const { periods: allPeriods, isLoading: periodsLoading, error: periodsFetchError } = useFiscalPeriods()
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setPeriodsError('Kunde inte hämta perioder')
|
||||
return
|
||||
}
|
||||
const { data } = (await res.json()) as { data: FiscalPeriod[] }
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const eligible = (data ?? []).filter(
|
||||
(p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today,
|
||||
)
|
||||
eligible.sort((a, b) => a.period_start.localeCompare(b.period_start))
|
||||
if (cancelled) return
|
||||
setPeriods(eligible)
|
||||
if (!selectedPeriodId && eligible.length > 0) {
|
||||
setSelectedPeriodId(eligible[0].id)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setPeriodsError('Kunde inte hämta perioder')
|
||||
}
|
||||
if (periodsLoading) return
|
||||
if (periodsFetchError) {
|
||||
setPeriodsError('Kunde inte hämta perioder')
|
||||
return
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const eligible = allPeriods.filter(
|
||||
(p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today,
|
||||
)
|
||||
eligible.sort((a, b) => a.period_start.localeCompare(b.period_start))
|
||||
setPeriods(eligible)
|
||||
if (!selectedPeriodId && eligible.length > 0) {
|
||||
setSelectedPeriodId(eligible[0].id)
|
||||
}
|
||||
}, [selectedPeriodId])
|
||||
}, [selectedPeriodId, allPeriods, periodsLoading, periodsFetchError])
|
||||
|
||||
// ---- Fetch accruals snapshot once period chosen ----
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, Suspense } from 'react'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { useCompanySettings, useCustomers } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
|
||||
@@ -68,8 +69,12 @@ function compareStrings(a: string, b: string): number {
|
||||
|
||||
function CustomersPageInner() {
|
||||
const { canWrite } = useCanWrite()
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
// The roster from the session cache (lib/reference-data): /api/customers
|
||||
// masks the personnummer column server-side (see the note that used to sit
|
||||
// on fetchCustomers), the list is shared with every customer picker, and
|
||||
// a revisit renders from cache. Skeleton only on the very first load.
|
||||
const { customers, isLoading: customersLoading, error: customersError } = useCustomers()
|
||||
const isLoading = customersLoading && customers.length === 0
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
@@ -115,44 +120,14 @@ function CustomersPageInner() {
|
||||
[searchParams, sortColumn, sortDir, router, pathname]
|
||||
)
|
||||
|
||||
/**
|
||||
* Read the roster through the API, not straight from Supabase.
|
||||
*
|
||||
* personal_number holds AES-256-GCM ciphertext (migration 20260726110000).
|
||||
* A browser-side select('*') handed this page 76 to 82 hex characters and
|
||||
* getIdentifier() rendered them into the nowrap identifier cell, which is
|
||||
* what shredded the table layout for companies with private customers.
|
||||
* GET /api/customers maps every row through maskCustomerRow, so the
|
||||
* ciphertext now never leaves the server and the column shows the same
|
||||
* '********-1234' the detail view does.
|
||||
*
|
||||
* No `company` guard: the route resolves the active company server-side, so
|
||||
* the fetch no longer has to wait for CompanyContext to hydrate. The old
|
||||
* guard could leave the list empty on a slow context load, because the
|
||||
* effect below runs once and never retries.
|
||||
*/
|
||||
async function fetchCustomers() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/customers')
|
||||
if (!response.ok) throw new Error('Failed to load customers')
|
||||
const { data } = await response.json()
|
||||
setCustomers(data || [])
|
||||
} catch {
|
||||
toast({
|
||||
title: t('load_failed_title'),
|
||||
description: t('load_failed_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
if (!customersError) return
|
||||
toast({
|
||||
title: t('load_failed_title'),
|
||||
description: t('load_failed_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}, [customersError, toast, t])
|
||||
|
||||
async function handleCreateCustomer(data: CreateCustomerInput) {
|
||||
setIsCreating(true)
|
||||
@@ -176,7 +151,9 @@ function CustomersPageInner() {
|
||||
title: t('created_title'),
|
||||
description: t('created_description', { name: data.name }),
|
||||
})
|
||||
setCustomers([...customers, result.data])
|
||||
// Every picker shares the cached list: refresh it instead of patching
|
||||
// this page's copy.
|
||||
await invalidateReferenceData('ref:customers')
|
||||
setIsDialogOpen(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { fetchAccounts } from '@/lib/reference-data/fetchers'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
@@ -807,12 +809,9 @@ function SIEImportWizard() {
|
||||
setIssues(data.parsed.issues)
|
||||
setSieAccounts(data.parsed.accounts)
|
||||
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts?active=false')
|
||||
if (!accountsRes.ok) {
|
||||
const accounts = await fetchAccounts(false).catch(() => {
|
||||
throw new Error('Kunde inte hämta kontoplanen för momsgranskning.')
|
||||
}
|
||||
const accountsData = await accountsRes.json()
|
||||
const accounts = accountsData.data || []
|
||||
})
|
||||
setBasAccounts(accounts)
|
||||
setMappings(enrichAccountMappingsWithVat(data.mappings, accounts))
|
||||
|
||||
@@ -986,12 +985,12 @@ function SIEImportWizard() {
|
||||
toast({ title: 'Konton skapade', description: `${data.created} nya konton har lagts till i din kontoplan` })
|
||||
|
||||
const createdSet = new Set(missingAccounts.map(a => a.number))
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts?active=false')
|
||||
if (!accountsRes.ok) {
|
||||
// New accounts exist now: refresh every cached chart (pickers app-wide)
|
||||
// and re-read the full chart for the VAT review below.
|
||||
await invalidateReferenceData('ref:accounts')
|
||||
const accounts = await fetchAccounts(false).catch(() => {
|
||||
throw new Error('Kunde inte hämta kontoplanen för momsgranskning.')
|
||||
}
|
||||
const accountsData = await accountsRes.json()
|
||||
const accounts = accountsData.data || []
|
||||
})
|
||||
setBasAccounts(accounts)
|
||||
setMappings(prev => {
|
||||
let updated = prev.map(m =>
|
||||
@@ -1077,6 +1076,9 @@ function SIEImportWizard() {
|
||||
}
|
||||
|
||||
setStep('result')
|
||||
// An import creates periods and accounts: refresh the session caches so
|
||||
// every picker in the app sees them without a reload.
|
||||
void invalidateReferenceData(['ref:accounts', 'ref:fiscal-periods'])
|
||||
|
||||
if (data.result?.success) {
|
||||
const created = data.result.journalEntriesCreated
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, use } from 'react'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
@@ -246,6 +247,25 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
|
||||
// #967: register/send without booking; ekonomi books in a separate step.
|
||||
const [deferInvoiceBooking, setDeferInvoiceBooking] = useState(false)
|
||||
// Company settings from the session cache (lib/reference-data): applied
|
||||
// whenever the cached row (re)loads, no request per invoice visit.
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
useEffect(() => {
|
||||
const settings = companySettings
|
||||
if (!settings) return
|
||||
setOreRounding(settings.ore_rounding ?? true)
|
||||
if (typeof settings.vat_registered === 'boolean') {
|
||||
setVatRegistered(settings.vat_registered)
|
||||
}
|
||||
setAccountingMethod(settings.accounting_method === 'cash' ? 'cash' : 'accrual')
|
||||
setDeferInvoiceBooking(!!settings.defer_invoice_booking)
|
||||
setReminderDays([
|
||||
settings.reminder_days_level_1 ?? 15,
|
||||
settings.reminder_days_level_2 ?? 30,
|
||||
settings.reminder_days_level_3 ?? 45,
|
||||
])
|
||||
setAutoRemindersEnabled(settings.send_invoice_reminders ?? true)
|
||||
}, [companySettings])
|
||||
const [showBookConfirm, setShowBookConfirm] = useState(false)
|
||||
const [bookVoucherPreview, setBookVoucherPreview] = useState<string | null>(null)
|
||||
const [reminderDays, setReminderDays] = useState<[number, number, number]>([15, 30, 45])
|
||||
@@ -331,16 +351,6 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
setDeductionPersonnummerMasked(undefined)
|
||||
}
|
||||
|
||||
// Settings depend only on the active company, so start them with the main
|
||||
// invoice batch instead of waiting for the invoice row first.
|
||||
const settingsPromise = company?.id
|
||||
? supabase
|
||||
.from('company_settings')
|
||||
.select('ore_rounding, vat_registered, accounting_method, defer_invoice_booking, reminder_days_level_1, reminder_days_level_2, reminder_days_level_3, send_invoice_reminders')
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle()
|
||||
: Promise.resolve(null)
|
||||
|
||||
const deliveriesPromise = loadDeliveries()
|
||||
|
||||
// Invoice, reminders, payments, and deliveries all key on the route id: one
|
||||
@@ -460,25 +470,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
}),
|
||||
)
|
||||
|
||||
const settingsRes = await settingsPromise
|
||||
if (seq !== fetchSeqRef.current) return
|
||||
if (settingsRes) {
|
||||
const settings = settingsRes.data
|
||||
setOreRounding(settings?.ore_rounding ?? true)
|
||||
if (typeof settings?.vat_registered === 'boolean') {
|
||||
setVatRegistered(settings.vat_registered)
|
||||
}
|
||||
setAccountingMethod(settings?.accounting_method === 'cash' ? 'cash' : 'accrual')
|
||||
setDeferInvoiceBooking(!!settings?.defer_invoice_booking)
|
||||
setReminderDays([
|
||||
settings?.reminder_days_level_1 ?? 15,
|
||||
settings?.reminder_days_level_2 ?? 30,
|
||||
settings?.reminder_days_level_3 ?? 45,
|
||||
])
|
||||
if (settings) {
|
||||
setAutoRemindersEnabled(settings.send_invoice_reminders ?? true)
|
||||
}
|
||||
}
|
||||
|
||||
// Related documents need the invoice row but do not gate the main detail
|
||||
// view. Resolve them together after first paint and fill their links in.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
@@ -194,11 +195,14 @@ export default function InvoicesPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [oreRounding, setOreRounding] = useState<boolean>(true)
|
||||
const [rotRutEnabled, setRotRutEnabled] = useState<boolean>(false)
|
||||
// Settings-driven gates from the session-cached settings row
|
||||
// (lib/reference-data), derived instead of copied into state.
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
const oreRounding: boolean = companySettings?.ore_rounding ?? true
|
||||
const rotRutEnabled: boolean = companySettings?.rot_rut_enabled ?? false
|
||||
// Booking mode drives which rows are bulk-bookable (kontantmetoden: none).
|
||||
const [accountingMethod, setAccountingMethod] = useState<string>('accrual')
|
||||
const [deferInvoiceBooking, setDeferInvoiceBooking] = useState<boolean>(false)
|
||||
const accountingMethod: string = companySettings?.accounting_method ?? 'accrual'
|
||||
const deferInvoiceBooking: boolean = companySettings?.defer_invoice_booking ?? false
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [showBulkBookConfirm, setShowBulkBookConfirm] = useState(false)
|
||||
const [isBulkBooking, setIsBulkBooking] = useState(false)
|
||||
@@ -285,7 +289,7 @@ export default function InvoicesPage() {
|
||||
// hundreds of rows to 3 skeleton stubs and replaying the stagger-enter
|
||||
// entrance for a row-scoped action was the "booking feels glitchy" jump.
|
||||
if (invoices.length === 0) setIsLoading(true)
|
||||
const [invoicesResult, settingsResult] = await Promise.allSettled([
|
||||
const [invoicesResult] = await Promise.allSettled([
|
||||
fetchAllRows<Invoice>(
|
||||
({ from, to }) =>
|
||||
supabase
|
||||
@@ -297,11 +301,6 @@ export default function InvoicesPage() {
|
||||
.range(from, to),
|
||||
{ dedupeBy: (invoice) => invoice.id },
|
||||
),
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('ore_rounding, rot_rut_enabled, accounting_method, defer_invoice_booking')
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle(),
|
||||
])
|
||||
|
||||
if (invoicesResult.status === 'rejected') {
|
||||
@@ -313,26 +312,6 @@ export default function InvoicesPage() {
|
||||
} else {
|
||||
setInvoices(invoicesResult.value)
|
||||
}
|
||||
setOreRounding(
|
||||
settingsResult.status === 'fulfilled'
|
||||
? (settingsResult.value.data?.ore_rounding ?? true)
|
||||
: true,
|
||||
)
|
||||
setRotRutEnabled(
|
||||
settingsResult.status === 'fulfilled'
|
||||
? (settingsResult.value.data?.rot_rut_enabled ?? false)
|
||||
: false,
|
||||
)
|
||||
setAccountingMethod(
|
||||
settingsResult.status === 'fulfilled'
|
||||
? (settingsResult.value.data?.accounting_method ?? 'accrual')
|
||||
: 'accrual',
|
||||
)
|
||||
setDeferInvoiceBooking(
|
||||
settingsResult.status === 'fulfilled'
|
||||
? (settingsResult.value.data?.defer_invoice_booking ?? false)
|
||||
: false,
|
||||
)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -33,7 +34,6 @@ import { ToastAction } from '@/components/ui/toast'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
|
||||
import {
|
||||
ClipboardCheck,
|
||||
@@ -287,30 +287,15 @@ export default function PendingOperationsPage() {
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
const [isRejecting, setIsRejecting] = useState(false)
|
||||
const { toast } = useToast()
|
||||
const company = useCompanyOptional()?.company ?? null
|
||||
// Whether the "Bokför utkasten" toast CTA leads anywhere: bulk Bokför on
|
||||
// /invoices only selects drafts when the company books at issue. Under
|
||||
// kontantmetoden or deferred booking (#967) the CTA would be a dead end,
|
||||
// so it stays suppressed (false until settings load: suppressing is the
|
||||
// safe direction, the neutral hint sentence still shows).
|
||||
const [invoiceDraftsCtaUseful, setInvoiceDraftsCtaUseful] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!company) return
|
||||
let cancelled = false
|
||||
const supabase = createClient()
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, defer_invoice_booking')
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle()
|
||||
.then(({ data }) => {
|
||||
if (!cancelled) setInvoiceDraftsCtaUseful(booksInvoicesOnIssue(data))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [company])
|
||||
// Derived from the session-cached settings row (lib/reference-data); false
|
||||
// until the row is available, suppressing being the safe direction.
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
const invoiceDraftsCtaUseful = companySettings ? booksInvoicesOnIssue(companySettings) : false
|
||||
|
||||
// Read ?conversation= once on mount so deep-links from the agent context
|
||||
// strip filter the list automatically.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, use } from 'react'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
@@ -87,7 +88,8 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
// Default dimensions bag ({sie_dim_no: object_code}) proposed on the
|
||||
// employee's salary-cost lines at booking. The fields render only when
|
||||
// company_settings.dimensions_enabled: same UI gate as the voucher form.
|
||||
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
const dimensionsEnabled = companySettings?.dimensions_enabled === true
|
||||
const [dimensions, setDimensions] = useState<Record<string, string>>({})
|
||||
|
||||
// The controlled form fields mirror the saved row. Called on load and again
|
||||
@@ -116,13 +118,6 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
load()
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then(({ data }) => setDimensionsEnabled(data?.dimensions_enabled === true))
|
||||
.catch(() => {/* keep the dimension fields hidden */})
|
||||
}, [])
|
||||
|
||||
function setDimension(dimNo: string, code: string | null) {
|
||||
setDimensions((prev) => {
|
||||
const next = { ...prev }
|
||||
|
||||
Reference in New Issue
Block a user