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:
@@ -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 }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useAccounts } from '@/lib/reference-data/hooks'
|
||||
import { useAccounts, useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import { ChevronDown, Loader2, Lock } from 'lucide-react'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
|
||||
@@ -72,7 +71,6 @@ export default function ArticleForm({
|
||||
onCancel,
|
||||
}: ArticleFormProps) {
|
||||
const { canWrite } = useCanWrite()
|
||||
const { company } = useCompany()
|
||||
const supabase = createClient()
|
||||
const t = useTranslations('form_article')
|
||||
const tCommon = useTranslations('common')
|
||||
@@ -92,7 +90,11 @@ export default function ArticleForm({
|
||||
const [createAccountPrefill, setCreateAccountPrefill] = useState<string | null>(null)
|
||||
// Momsregistrerad? A non-VAT-registered company never charges moms, so the
|
||||
// VAT field is hidden and the rate forced to 0: mirrors the invoice editor.
|
||||
const [vatRegistered, setVatRegistered] = useState(true)
|
||||
// Icke momsregistrerad verksamhet: VAT controls hidden and lines at 0 %.
|
||||
// Derived from the session-cached settings row (lib/reference-data); a
|
||||
// missing column keeps the registered-company behaviour, as before.
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
const vatRegistered = typeof companySettings?.vat_registered === 'boolean' ? companySettings.vat_registered : true
|
||||
// Supported currencies, fetched from the currencies reference table rather
|
||||
// than hard-coded. Falls back to the article's own currency (or SEK) if the
|
||||
// fetch fails so the Select is never empty.
|
||||
@@ -116,25 +118,6 @@ export default function ArticleForm({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!company?.id) return
|
||||
let cancelled = false
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('vat_registered')
|
||||
.eq('company_id', company.id)
|
||||
.single()
|
||||
.then(({ data }) => {
|
||||
if (!cancelled && typeof data?.vat_registered === 'boolean') {
|
||||
setVatRegistered(data.vat_registered)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [company?.id])
|
||||
|
||||
// Open "Fler fält" by default when it already holds data, so an edit never
|
||||
// hides a value the user previously set. Currency and posting account are no
|
||||
// longer in here: both are permanent rows.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useAccounts } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -21,24 +23,13 @@ const CLASS_LABELS: Record<number, string> = {
|
||||
}
|
||||
|
||||
export default function ChartOfAccounts() {
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
// Session-cached chart (lib/reference-data), shared with every picker.
|
||||
const { accounts, isLoading: loading } = useAccounts()
|
||||
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [editingSRU, setEditingSRU] = useState<string | null>(null)
|
||||
const [sruValue, setSruValue] = useState('')
|
||||
|
||||
async function fetchAccounts() {
|
||||
const res = await fetch('/api/bookkeeping/accounts')
|
||||
const { data } = await res.json()
|
||||
setAccounts(data || [])
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
}, [])
|
||||
|
||||
async function updateSRUCode(accountId: string, newSruCode: string) {
|
||||
const supabase = createClient()
|
||||
const trimmed = newSruCode.trim() || null
|
||||
@@ -47,9 +38,8 @@ export default function ChartOfAccounts() {
|
||||
.update({ sru_code: trimmed })
|
||||
.eq('id', accountId)
|
||||
|
||||
setAccounts((prev) =>
|
||||
prev.map((a) => (a.id === accountId ? { ...a, sru_code: trimmed } : a))
|
||||
)
|
||||
// Refresh the shared chart so this list and every picker show the code.
|
||||
await invalidateReferenceData('ref:accounts')
|
||||
setEditingSRU(null)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { BASAccount } from '@/types'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { BAS_REFERENCE, isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
@@ -75,6 +76,7 @@ export default function ChartOfAccountsManager() {
|
||||
const [collapsedMyClasses, setCollapsedMyClasses] = useState<Set<number>>(new Set())
|
||||
const [expandedCatalogClasses, setExpandedCatalogClasses] = useState<Set<number>>(new Set())
|
||||
const [hideK2Excluded, setHideK2Excluded] = useState<boolean | null>(null)
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
// Off by default: the list endpoint's `?active=false` means "no filter", so
|
||||
// leaving it off keeps first paint on the smaller active-only payload.
|
||||
// A deactivated account is otherwise invisible everywhere and unrecoverable.
|
||||
@@ -205,25 +207,18 @@ export default function ChartOfAccountsManager() {
|
||||
fetchReference(),
|
||||
(async () => {
|
||||
if (hideK2Excluded !== null) return
|
||||
try {
|
||||
const res = await fetch('/api/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
// Default to hiding K2-excluded accounts if the company uses K2 (plan_type === 'k1')
|
||||
setHideK2Excluded(data?.plan_type === 'k1')
|
||||
} else {
|
||||
setHideK2Excluded(false)
|
||||
}
|
||||
} catch {
|
||||
setHideK2Excluded(false)
|
||||
}
|
||||
// Default to hiding K2-excluded accounts if the company uses K2
|
||||
// (plan_type === 'k1'). Settings come from the session cache.
|
||||
setHideK2Excluded(
|
||||
(companySettings as { plan_type?: string } | null)?.plan_type === 'k1',
|
||||
)
|
||||
})(),
|
||||
])
|
||||
setReferenceLoaded(true)
|
||||
} finally {
|
||||
setReferenceLoading(false)
|
||||
}
|
||||
}, [referenceLoaded, referenceLoading, fetchReference, hideK2Excluded])
|
||||
}, [referenceLoaded, referenceLoading, fetchReference, hideK2Excluded, companySettings])
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
await Promise.all([
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from '@/components/bookkeeping/correction-entry-description'
|
||||
import { nextLineDescriptionForAccountChange } from '@/components/bookkeeping/correction-line-description'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useAccounts } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
@@ -33,7 +35,7 @@ import {
|
||||
} from '@/lib/bookkeeping/correction-line-account'
|
||||
import { splitCreateAccountPrefill } from '@/lib/bookkeeping/create-account-prefill'
|
||||
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
||||
import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types'
|
||||
import type { JournalEntry, JournalEntryLine } from '@/types'
|
||||
|
||||
interface CorrectionLine {
|
||||
account_number: string
|
||||
@@ -53,9 +55,18 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const t = useTranslations('journal_detail')
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
// The full chart (deactivated rows included) comes from the session cache
|
||||
// (lib/reference-data); only the static BAS catalogue is loaded per open,
|
||||
// and it is module-cached after the first time.
|
||||
const { accounts, isLoading: accountsLoading, error: accountsError, refresh: refreshAccounts } = useAccounts(false)
|
||||
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
|
||||
const [accountsStatus, setAccountsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [catalogStatus, setCatalogStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const accountsStatus: 'loading' | 'ready' | 'error' =
|
||||
accountsLoading || catalogStatus === 'loading'
|
||||
? 'loading'
|
||||
: accountsError || catalogStatus === 'error'
|
||||
? 'error'
|
||||
: 'ready'
|
||||
const [lines, setLines] = useState<CorrectionLine[]>([])
|
||||
const [description, setDescription] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -98,26 +109,18 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
// Pre-fill the verifikationstext with the same auto text the server
|
||||
// would generate; only a user edit is sent along (see handleSubmit).
|
||||
setDescription(autoCorrectionDescription(entry.description))
|
||||
void fetchAccounts()
|
||||
void loadCatalog()
|
||||
}
|
||||
}, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function fetchAccounts() {
|
||||
setAccountsStatus('loading')
|
||||
async function loadCatalog() {
|
||||
setCatalogStatus('loading')
|
||||
try {
|
||||
const [res, basCatalog] = await Promise.all([
|
||||
fetch('/api/bookkeeping/accounts?active=false'),
|
||||
loadBasCatalog(),
|
||||
])
|
||||
if (!res.ok) throw new Error(`accounts ${res.status}`)
|
||||
const { data } = await res.json()
|
||||
setAccounts(data || [])
|
||||
setCatalog(basCatalog)
|
||||
setAccountsStatus('ready')
|
||||
setCatalog(await loadBasCatalog())
|
||||
setCatalogStatus('ready')
|
||||
} catch {
|
||||
setAccounts([])
|
||||
setCatalog([])
|
||||
setAccountsStatus('error')
|
||||
setCatalogStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,9 +172,9 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
// a dead end here: the rättelse can only post to accounts that exist in the
|
||||
// chart. Creating it inline keeps the half-finished rättelse intact.
|
||||
const handleAccountCreated = async (account: { account_number: string; account_name?: string }) => {
|
||||
await fetchAccounts()
|
||||
await invalidateReferenceData('ref:accounts')
|
||||
if (creatingAccountForLine != null) {
|
||||
// fetchAccounts' state update is not visible in this closure, so the
|
||||
// The refreshed cache is not visible in this closure, so the
|
||||
// fresh account's own name is passed alongside the stale sources. The
|
||||
// reactivate path reports no name, but that account is already in
|
||||
// `accounts` (the fetch includes deactivated rows).
|
||||
@@ -339,7 +342,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
|
||||
{accountsStatus === 'loading' ? t('accounts_loading') : t('accounts_load_failed')}
|
||||
</span>
|
||||
{accountsStatus === 'error' && (
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchAccounts()}>
|
||||
<Button variant="outline" size="sm" onClick={() => void refreshAccounts()}>
|
||||
{t('accounts_retry')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -26,12 +26,14 @@ import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { Loader2, Plus, X } from 'lucide-react'
|
||||
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
|
||||
import {
|
||||
fetchDimensions,
|
||||
type AccountDimensionRuleDto,
|
||||
type DimensionDto,
|
||||
type DimensionRuleType,
|
||||
} from '@/components/dimensions/types'
|
||||
import type { BASAccount } from '@/types'
|
||||
import { useCompanySettings, useDimensions } from '@/lib/reference-data/hooks'
|
||||
import { fetchDimensions } from '@/lib/reference-data/fetchers'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect'
|
||||
import {
|
||||
defaultRateForVatTreatment,
|
||||
@@ -81,8 +83,11 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
|
||||
// dimensions enabled (same /api/settings gate as JournalEntryForm). Rule
|
||||
// mutations apply immediately via their own fetches + toasts; they are
|
||||
// deliberately independent of the account PUT below.
|
||||
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
|
||||
const [dims, setDims] = useState<DimensionDto[]>([])
|
||||
// Settings and the dimension registry come from the session cache
|
||||
// (lib/reference-data), so the section and its pickers are ready on open.
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
const dimensionsEnabled = companySettings?.dimensions_enabled === true
|
||||
const { dimensions: dims } = useDimensions()
|
||||
const [rules, setRules] = useState<AccountDimensionRuleDto[]>([])
|
||||
const [rulesLoading, setRulesLoading] = useState(false)
|
||||
const [addRuleOpen, setAddRuleOpen] = useState(false)
|
||||
@@ -91,33 +96,15 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
|
||||
const [newRuleValueCode, setNewRuleValueCode] = useState<string | null>(null)
|
||||
const [isAddingRule, setIsAddingRule] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then(({ data }) => {
|
||||
if (!cancelled && data?.dimensions_enabled === true) setDimensionsEnabled(true)
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep the section hidden */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dimensionsEnabled) return
|
||||
let cancelled = false
|
||||
setRulesLoading(true)
|
||||
Promise.all([
|
||||
fetchDimensions().catch(() => [] as DimensionDto[]),
|
||||
fetch(`/api/dimensions/rules?account_number=${account.account_number}`)
|
||||
.then(async (r) => ({ ok: r.ok, json: await r.json().catch(() => null) }))
|
||||
.catch(() => ({ ok: false, json: null })),
|
||||
]).then(([fetchedDims, rulesRes]) => {
|
||||
fetch(`/api/dimensions/rules?account_number=${account.account_number}`)
|
||||
.then(async (r) => ({ ok: r.ok, json: await r.json().catch(() => null) }))
|
||||
.catch(() => ({ ok: false, json: null }))
|
||||
.then((rulesRes) => {
|
||||
if (cancelled) return
|
||||
setDims(fetchedDims)
|
||||
if (rulesRes.ok) {
|
||||
setRules((rulesRes.json?.data?.rules ?? []) as AccountDimensionRuleDto[])
|
||||
}
|
||||
@@ -158,7 +145,8 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
|
||||
if (!valueId) {
|
||||
const refreshed = await fetchDimensions().catch(() => null)
|
||||
if (refreshed) {
|
||||
setDims(refreshed)
|
||||
// Hand the fresh registry to the shared cache as well.
|
||||
void invalidateReferenceData('ref:dimensions')
|
||||
valueId = findValueId(refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,14 @@ import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer'
|
||||
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useAccounts } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { changeCorrectionLineAccount, getSelectableCorrectionCatalog } from '@/lib/bookkeeping/correction-line-account'
|
||||
import { splitCreateAccountPrefill } from '@/lib/bookkeeping/create-account-prefill'
|
||||
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
||||
import { Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types'
|
||||
import type { JournalEntry, JournalEntryLine } from '@/types'
|
||||
|
||||
interface NewLine {
|
||||
account_number: string
|
||||
@@ -49,9 +51,18 @@ interface Props {
|
||||
export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrected }: Props) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('journal_detail')
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
// The full chart (deactivated rows included) comes from the session cache
|
||||
// (lib/reference-data); only the static BAS catalogue is loaded per open,
|
||||
// and it is module-cached after the first time.
|
||||
const { accounts, isLoading: accountsLoading, error: accountsError, refresh: refreshAccounts } = useAccounts(false)
|
||||
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
|
||||
const [accountsStatus, setAccountsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [catalogStatus, setCatalogStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const accountsStatus: 'loading' | 'ready' | 'error' =
|
||||
accountsLoading || catalogStatus === 'loading'
|
||||
? 'loading'
|
||||
: accountsError || catalogStatus === 'error'
|
||||
? 'error'
|
||||
: 'ready'
|
||||
const [strikeIds, setStrikeIds] = useState<Set<string>>(new Set())
|
||||
const [newLines, setNewLines] = useState<NewLine[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -77,26 +88,18 @@ export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrect
|
||||
if (open) {
|
||||
setStrikeIds(new Set())
|
||||
setNewLines([])
|
||||
void fetchAccounts()
|
||||
void loadCatalog()
|
||||
}
|
||||
}, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [open, entry.id])
|
||||
|
||||
async function fetchAccounts() {
|
||||
setAccountsStatus('loading')
|
||||
async function loadCatalog() {
|
||||
setCatalogStatus('loading')
|
||||
try {
|
||||
const [res, basCatalog] = await Promise.all([
|
||||
fetch('/api/bookkeeping/accounts?active=false'),
|
||||
loadBasCatalog(),
|
||||
])
|
||||
if (!res.ok) throw new Error(`accounts ${res.status}`)
|
||||
const { data } = await res.json()
|
||||
setAccounts(data || [])
|
||||
setCatalog(basCatalog)
|
||||
setAccountsStatus('ready')
|
||||
setCatalog(await loadBasCatalog())
|
||||
setCatalogStatus('ready')
|
||||
} catch {
|
||||
setAccounts([])
|
||||
setCatalog([])
|
||||
setAccountsStatus('error')
|
||||
setCatalogStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +142,9 @@ export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrect
|
||||
// a dead end here: the rättelse can only post to accounts that exist in the
|
||||
// chart. Creating it inline keeps the half-finished rättelse intact.
|
||||
const handleAccountCreated = async (account: { account_number: string; account_name?: string }) => {
|
||||
await fetchAccounts()
|
||||
await invalidateReferenceData('ref:accounts')
|
||||
if (creatingAccountForLine != null) {
|
||||
// fetchAccounts' state update is not visible in this closure, so the
|
||||
// The refreshed cache is not visible in this closure, so the
|
||||
// fresh account's own name is passed alongside the stale sources. The
|
||||
// reactivate path reports no name, but that account is already in
|
||||
// `accounts` (the fetch includes deactivated rows).
|
||||
@@ -300,7 +303,7 @@ export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrect
|
||||
{accountsStatus === 'loading' ? t('accounts_loading') : t('accounts_load_failed')}
|
||||
</span>
|
||||
{accountsStatus === 'error' && (
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchAccounts()}>
|
||||
<Button variant="outline" size="sm" onClick={() => void refreshAccounts()}>
|
||||
{t('accounts_retry')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useCashAccounts } from '@/lib/reference-data/hooks'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { CashAccount } from '@/types'
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'Accounted:cash-account:'
|
||||
|
||||
@@ -56,57 +56,44 @@ export function CashAccountSelector({
|
||||
className,
|
||||
}: Props) {
|
||||
const { company } = useCompany()
|
||||
const [accounts, setAccounts] = useState<CashAccount[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
// Session-cached and seeded by the dashboard layout (lib/reference-data):
|
||||
// on a normal visit the list is here on the first render, so the restore
|
||||
// below runs in the first effect tick and onReady fires without a round
|
||||
// trip. Restore once per company load, not on every background refresh.
|
||||
const { cashAccounts: accounts, isLoading } = useCashAccounts()
|
||||
const loaded = !isLoading
|
||||
const restoredForRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!company?.id) {
|
||||
onReady?.()
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const res = await fetch('/api/cash-accounts')
|
||||
if (!res.ok) {
|
||||
if (!cancelled) {
|
||||
setLoaded(true)
|
||||
onReady?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
const { data } = await res.json()
|
||||
if (cancelled) return
|
||||
if (!loaded || restoredForRef.current === company.id) return
|
||||
restoredForRef.current = company.id
|
||||
|
||||
const fetched: CashAccount[] = data || []
|
||||
// is_primary first (already ordered on the server), then by ledger code.
|
||||
setAccounts(fetched)
|
||||
setLoaded(true)
|
||||
// Restore last selection or pick the primary as default.
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = window.sessionStorage.getItem(STORAGE_KEY_PREFIX + company.id)
|
||||
const inFetched = (ledger: string) =>
|
||||
accounts.some(a => a.ledger_account === ledger)
|
||||
|
||||
// Restore last selection or pick the primary as default.
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = window.sessionStorage.getItem(STORAGE_KEY_PREFIX + company.id)
|
||||
const inFetched = (ledger: string) =>
|
||||
fetched.some(a => a.ledger_account === ledger)
|
||||
|
||||
if (stored && inFetched(stored)) {
|
||||
if (stored !== value) onChange(stored)
|
||||
} else {
|
||||
const primary = fetched.find(a => a.is_primary)
|
||||
const fallback = primary ?? fetched[0]
|
||||
if (fallback && fallback.ledger_account !== value) {
|
||||
onChange(fallback.ledger_account)
|
||||
}
|
||||
if (stored && inFetched(stored)) {
|
||||
if (stored !== value) onChange(stored)
|
||||
} else {
|
||||
const primary = accounts.find(a => a.is_primary)
|
||||
const fallback = primary ?? accounts[0]
|
||||
if (fallback && fallback.ledger_account !== value) {
|
||||
onChange(fallback.ledger_account)
|
||||
}
|
||||
}
|
||||
|
||||
onReady?.()
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// onReady excluded: lifecycle callback, shouldn't retrigger on parent renders.
|
||||
|
||||
onReady?.()
|
||||
// onReady/onChange are lifecycle callbacks: fire once per load, not on
|
||||
// parent re-renders that re-create them. `value` is read once at restore.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [company?.id])
|
||||
}, [company?.id, loaded, accounts])
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
if (company?.id && typeof window !== 'undefined') {
|
||||
|
||||
@@ -6,9 +6,10 @@ import { Input } from '@/components/ui/input'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
DIMENSION_CODE_PATTERN,
|
||||
fetchDimensions,
|
||||
type DimensionValueDto,
|
||||
} from '@/components/dimensions/types'
|
||||
import { useDimensions } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
|
||||
interface DimensionComboboxProps {
|
||||
/** SIE dimension number as a string ('1' = kostnadsställe, '6' = projekt). */
|
||||
@@ -47,9 +48,23 @@ export default function DimensionCombobox({
|
||||
const [search, setSearch] = useState(value ?? '')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
||||
const [loadState, setLoadState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle')
|
||||
const [dimensionId, setDimensionId] = useState<string | null>(null)
|
||||
const [values, setValues] = useState<DimensionValueDto[]>([])
|
||||
// Registry from the session cache (lib/reference-data): every combobox on
|
||||
// the page shares one entry, so opening a picker costs no request.
|
||||
const { dimensions, isLoading: registryLoading, error: registryError } = useDimensions()
|
||||
const loadState: 'loading' | 'loaded' | 'error' = registryLoading
|
||||
? 'loading'
|
||||
: registryError
|
||||
? 'error'
|
||||
: 'loaded'
|
||||
const dimension = useMemo(
|
||||
() => dimensions.find((d) => String(d.sie_dim_no) === sieDimNo) ?? null,
|
||||
[dimensions, sieDimNo],
|
||||
)
|
||||
const dimensionId = dimension?.id ?? null
|
||||
const values = useMemo(
|
||||
() => dimension?.values.filter((v) => v.is_active) ?? [],
|
||||
[dimension],
|
||||
)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -72,24 +87,10 @@ export default function DimensionCombobox({
|
||||
valuesRef.current = values
|
||||
}, [values])
|
||||
|
||||
const loadValues = useCallback(async () => {
|
||||
setLoadState('loading')
|
||||
try {
|
||||
const dims = await fetchDimensions()
|
||||
const dim = dims.find((d) => String(d.sie_dim_no) === sieDimNo)
|
||||
setDimensionId(dim?.id ?? null)
|
||||
setValues(dim?.values.filter((v) => v.is_active) ?? [])
|
||||
setLoadState('loaded')
|
||||
} catch {
|
||||
setLoadState('error')
|
||||
}
|
||||
}, [sieDimNo])
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
setIsOpen(true)
|
||||
setCreateError(null)
|
||||
if (loadState === 'idle') void loadValues()
|
||||
}, [loadState, loadValues])
|
||||
}, [])
|
||||
|
||||
const filteredValues = useMemo(() => {
|
||||
const term = search.trim().toLowerCase()
|
||||
@@ -169,7 +170,8 @@ export default function DimensionCombobox({
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
}
|
||||
setValues((prev) => [...prev, created].sort((a, b) => a.code.localeCompare(b.code, 'sv')))
|
||||
// Refresh the shared registry so every picker offers the new value.
|
||||
await invalidateReferenceData('ref:dimensions')
|
||||
selectValue(created.code)
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { useDimensions } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -44,7 +46,6 @@ import DimensionValueForm, {
|
||||
type DimensionValueFormInput,
|
||||
} from '@/components/dimensions/DimensionValueForm'
|
||||
import {
|
||||
fetchDimensions,
|
||||
PROJECT_DIM_NO,
|
||||
type DimensionDto,
|
||||
type DimensionValueDto,
|
||||
@@ -76,10 +77,21 @@ export default function DimensionsManager() {
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
|
||||
const [dimensions, setDimensions] = useState<DimensionDto[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const [activeDimId, setActiveDimId] = useState<string | null>(null)
|
||||
// The register renders the same session-cached registry the pickers use
|
||||
// (lib/reference-data); every write below invalidates it.
|
||||
const { dimensions: registry, isLoading, error: loadError, refresh: refreshDimensions } = useDimensions()
|
||||
const dimensions = useMemo(
|
||||
() => [...registry].sort((a, b) => a.sort_order - b.sort_order || a.sie_dim_no - b.sie_dim_no),
|
||||
[registry],
|
||||
)
|
||||
const loadFailed = !!loadError
|
||||
// The requested tab, validated against the current registry: a dimension
|
||||
// that disappears falls back to the first one.
|
||||
const [requestedDimId, setActiveDimId] = useState<string | null>(null)
|
||||
const activeDimId =
|
||||
requestedDimId && dimensions.some((d) => d.id === requestedDimId)
|
||||
? requestedDimId
|
||||
: (dimensions[0]?.id ?? null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('code')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
@@ -88,37 +100,17 @@ export default function DimensionsManager() {
|
||||
const [newDimDialogOpen, setNewDimDialogOpen] = useState(false)
|
||||
const [isCreatingDimension, setIsCreatingDimension] = useState(false)
|
||||
|
||||
const loadDimensions = useCallback(
|
||||
async (showSpinner: boolean) => {
|
||||
if (showSpinner) setIsLoading(true)
|
||||
try {
|
||||
const dims = await fetchDimensions()
|
||||
const sorted = [...dims].sort(
|
||||
(a, b) => a.sort_order - b.sort_order || a.sie_dim_no - b.sie_dim_no,
|
||||
)
|
||||
setDimensions(sorted)
|
||||
setLoadFailed(false)
|
||||
setActiveDimId((prev) =>
|
||||
prev && sorted.some((d) => d.id === prev) ? prev : (sorted[0]?.id ?? null),
|
||||
)
|
||||
} catch (err) {
|
||||
setLoadFailed(true)
|
||||
toast({
|
||||
title: t('load_failed_title'),
|
||||
description: getErrorMessage(err, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[toast, t, errorLocale],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void loadDimensions(true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
if (!loadError) return
|
||||
toast({
|
||||
title: t('load_failed_title'),
|
||||
description: getErrorMessage(loadError, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}, [loadError, toast, t, errorLocale])
|
||||
|
||||
/** After a write: refresh the shared registry (this list and every picker). */
|
||||
const loadDimensions = useCallback(() => invalidateReferenceData('ref:dimensions'), [])
|
||||
|
||||
const activeDim = useMemo(
|
||||
() => dimensions.find((d) => d.id === activeDimId) ?? null,
|
||||
@@ -227,7 +219,7 @@ export default function DimensionsManager() {
|
||||
})
|
||||
}
|
||||
setDialog(null)
|
||||
await loadDimensions(false)
|
||||
await loadDimensions()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('save_failed_title'),
|
||||
@@ -262,7 +254,7 @@ export default function DimensionsManager() {
|
||||
const createdId = (json?.data?.dimension as { id?: string } | undefined)?.id
|
||||
toast({ title: t('dim_created_title') })
|
||||
setNewDimDialogOpen(false)
|
||||
await loadDimensions(false)
|
||||
await loadDimensions()
|
||||
if (createdId) {
|
||||
setActiveDimId(createdId)
|
||||
setSearchTerm('')
|
||||
@@ -300,7 +292,7 @@ export default function DimensionsManager() {
|
||||
}
|
||||
toast({ title: t('deleted_title') })
|
||||
setDialog(null)
|
||||
await loadDimensions(false)
|
||||
await loadDimensions()
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -359,7 +351,7 @@ export default function DimensionsManager() {
|
||||
title={t('load_failed_title')}
|
||||
description={t('load_failed_description')}
|
||||
actionLabel={t('retry')}
|
||||
onAction={() => void loadDimensions(true)}
|
||||
onAction={() => void refreshDimensions()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
|
||||
import {
|
||||
fetchDimensionsCached,
|
||||
type DimensionDto,
|
||||
} from '@/components/dimensions/types'
|
||||
import { useDimensions } from '@/lib/reference-data/hooks'
|
||||
|
||||
interface LineDimensionFieldsProps {
|
||||
/** Current dimensions map ({sie_dim_no: object_code}), a line's map or the header default. */
|
||||
@@ -46,24 +43,13 @@ export default function LineDimensionFields({
|
||||
stacked,
|
||||
inputClassName,
|
||||
}: LineDimensionFieldsProps) {
|
||||
const [registry, setRegistry] = useState<DimensionDto[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetchDimensionsCached()
|
||||
.then((dims) => {
|
||||
if (!cancelled) setRegistry(dims)
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep the hardcoded 1/6 fallback */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
// Registry from the session cache (lib/reference-data): one entry shared
|
||||
// by every line picker on the page; empty while loading or on failure,
|
||||
// which keeps the hardcoded 1/6 fallback below.
|
||||
const { dimensions: registry } = useDimensions()
|
||||
|
||||
const fields = useMemo(() => {
|
||||
const active = registry?.filter((d) => d.is_active) ?? []
|
||||
const active = registry.filter((d) => d.is_active)
|
||||
if (active.length === 0) return FALLBACK_FIELDS
|
||||
return [...active]
|
||||
.sort((a, b) => a.sort_order - b.sort_order || a.sie_dim_no - b.sie_dim_no)
|
||||
|
||||
@@ -61,36 +61,3 @@ export const PROJECT_DIM_NO = 6
|
||||
*/
|
||||
export const DIMENSION_CODE_PATTERN = /^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$/
|
||||
|
||||
/**
|
||||
* Load the company's dimension registry. The handler lazily seeds system dims
|
||||
* 1/6 via ensure_company_dimensions, so the result always contains at least
|
||||
* Kostnadsställe + Projekt. Throws the parsed error envelope on failure so
|
||||
* callers can hand it straight to getErrorMessage().
|
||||
*/
|
||||
export async function fetchDimensions(): Promise<DimensionDto[]> {
|
||||
const res = await fetch('/api/dimensions')
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw json ?? new Error('Failed to load dimensions')
|
||||
}
|
||||
return (json?.dimensions ?? []) as DimensionDto[]
|
||||
}
|
||||
|
||||
let cachedDimensionsPromise: Promise<DimensionDto[]> | null = null
|
||||
|
||||
/**
|
||||
* Module-level cached variant of fetchDimensions for high-mount-count
|
||||
* consumers (one registry fetch per page load instead of one per line
|
||||
* picker). A failed fetch clears the cache so the next mount retries.
|
||||
* Registry mutations are rare enough that staleness within a page visit
|
||||
* is acceptable — the register UI uses the uncached fetch.
|
||||
*/
|
||||
export function fetchDimensionsCached(): Promise<DimensionDto[]> {
|
||||
if (!cachedDimensionsPromise) {
|
||||
cachedDimensionsPromise = fetchDimensions().catch((err) => {
|
||||
cachedDimensionsPromise = null
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return cachedDimensionsPromise
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect, useReducer, useRef } from 'react'
|
||||
import { useAccounts } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
@@ -2039,6 +2041,10 @@ export default function ArcimMigrationWorkspace({
|
||||
// SIE data state (held between mapping and execution steps)
|
||||
const [sieData, setSieData] = useState<SIEData | null>(null)
|
||||
const companyAccountsForVatRef = useRef<BASAccount[]>([])
|
||||
// Chart of accounts incl. inactive, from the session cache
|
||||
// (lib/reference-data). Re-read when the mapping step opens because the
|
||||
// preview may have created accounts; the SIE import invalidates it too.
|
||||
const { refresh: refreshCompanyAccounts } = useAccounts(false)
|
||||
|
||||
// Options state
|
||||
const [migrationOptions, setMigrationOptions] = useState<MigrationOptions>(DEFAULT_OPTIONS)
|
||||
@@ -2619,16 +2625,10 @@ export default function ArcimMigrationWorkspace({
|
||||
}
|
||||
|
||||
const data = await res.json() as SIEData
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts?active=false')
|
||||
const accountsBody = await accountsRes.json().catch(() => ({})) as {
|
||||
data?: BASAccount[]
|
||||
error?: unknown
|
||||
}
|
||||
if (!accountsRes.ok) {
|
||||
throw apiError(accountsBody, `HTTP ${accountsRes.status}`)
|
||||
}
|
||||
companyAccountsForVatRef.current = accountsBody.data ?? []
|
||||
const enrichedMappings = enrichAccountMappingsWithVat(data.mappings, accountsBody.data ?? [])
|
||||
// Bound SWR mutate resolves with the revalidated list.
|
||||
const companyAccounts = ((await refreshCompanyAccounts()) ?? []) as BASAccount[]
|
||||
companyAccountsForVatRef.current = companyAccounts
|
||||
const enrichedMappings = enrichAccountMappingsWithVat(data.mappings, companyAccounts)
|
||||
setSieData({ ...data, mappings: enrichedMappings })
|
||||
|
||||
// If all SIE files are already imported, disable SIE import by default
|
||||
@@ -2648,7 +2648,7 @@ export default function ArcimMigrationWorkspace({
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [consentId])
|
||||
}, [consentId, refreshCompanyAccounts])
|
||||
|
||||
const handlePreviewContinue = useCallback(() => {
|
||||
if (preview?.sieAvailable) {
|
||||
@@ -2781,6 +2781,9 @@ export default function ArcimMigrationWorkspace({
|
||||
|
||||
const result = await res.json() as ImportResult
|
||||
setSieImportResults(prev => [...prev, result])
|
||||
// The import creates accounts and a räkenskapsår: every cached
|
||||
// picker must see them.
|
||||
void invalidateReferenceData(['ref:accounts', 'ref:fiscal-periods'])
|
||||
|
||||
// The endpoint returns HTTP 200 with success:false when the import
|
||||
// itself failed (e.g. räkenskapsår mismatch). Stop here: continuing
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -391,7 +392,11 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
// Cash method users see "Bokför direkt" as the primary CTA; accrual users
|
||||
// see "Skapa leverantörsfaktura". Defaults to 'accrual' until we've read
|
||||
// the company settings so we don't flicker the CTA order on first paint.
|
||||
const [accountingMethod, setAccountingMethod] = useState<AccountingMethod>('accrual')
|
||||
// The company's bookkeeping method drives the CTA hierarchy; read from the
|
||||
// session-cached settings row (lib/reference-data), no request of its own.
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
const accountingMethod: AccountingMethod =
|
||||
companySettings?.accounting_method === 'cash' ? 'cash' : 'accrual'
|
||||
|
||||
// ── Data loading ───────────────────────────────────────────
|
||||
|
||||
@@ -451,16 +456,6 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
useEffect(() => {
|
||||
fetchItems()
|
||||
fetchInboxAddress()
|
||||
// Resolve the company's bookkeeping method: drives CTA hierarchy.
|
||||
fetch('/api/settings')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((body) => {
|
||||
const method = body?.data?.accounting_method
|
||||
if (method === 'cash' || method === 'accrual') {
|
||||
setAccountingMethod(method)
|
||||
}
|
||||
})
|
||||
.catch(() => { /* keep 'accrual' default */ })
|
||||
}, [fetchItems, fetchInboxAddress])
|
||||
|
||||
// Realtime: refetch when any invoice_inbox_items row changes for this
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -188,6 +189,9 @@ function ProfileSkeleton() {
|
||||
export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
const { getByKey, save, isLoading: isDataLoading } = useExtensionData('general', 'tic')
|
||||
const { toast } = useToast()
|
||||
// org_number from the session-cached settings row (lib/reference-data):
|
||||
// the profile fetch no longer pays a /api/settings round trip first.
|
||||
const { settings: companySettings, error: settingsError } = useCompanySettings()
|
||||
const t = useTranslations('tic_workspace')
|
||||
const [profile, setProfile] = useState<TICCompanyProfile | null>(null)
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
@@ -216,14 +220,13 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
setFetchFailed(false)
|
||||
|
||||
try {
|
||||
// Get org_number from company settings
|
||||
const settingsRes = await fetch('/api/settings')
|
||||
if (!settingsRes.ok) {
|
||||
// Get org_number from company settings (settled without a row = the
|
||||
// same failure the old fetch reported).
|
||||
if (settingsError) {
|
||||
toast({ title: t('toast_settings_failed'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
const { data: settings } = await settingsRes.json()
|
||||
const orgNumber = settings?.org_number
|
||||
const orgNumber = companySettings?.org_number
|
||||
|
||||
if (!orgNumber) {
|
||||
setNoOrgNumber(true)
|
||||
@@ -255,7 +258,7 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}, [save, toast, t])
|
||||
}, [save, toast, t, companySettings, settingsError])
|
||||
|
||||
// Auto-fetch on first visit when no cached data
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useAccounts } from '@/lib/reference-data/hooks'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -18,8 +19,6 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { summarizeByCurrency } from '@/lib/import/bank-file/currency-summary'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { BankFileParseResult, BankFileDuplicateInfo } from '@/lib/import/bank-file/types'
|
||||
|
||||
interface BankAccount {
|
||||
@@ -54,32 +53,26 @@ export default function BankFileConfirmStep({
|
||||
// automatically rather than promising an exact final number.
|
||||
const duplicateCount = Math.min(Math.max(duplicateInfo?.duplicate_count ?? 0, 0), stats.parsed_rows)
|
||||
|
||||
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([])
|
||||
const [selectedAccount, setSelectedAccount] = useState('1930')
|
||||
const { company } = useCompany()
|
||||
|
||||
// Active 19xx accounts from the session-cached chart (lib/reference-data):
|
||||
// the account select is populated on the first paint.
|
||||
const { accounts } = useAccounts()
|
||||
const bankAccounts = useMemo<BankAccount[]>(
|
||||
() =>
|
||||
accounts
|
||||
.filter((a) => a.account_number >= '1900' && a.account_number <= '1999')
|
||||
.sort((a, b) => a.account_number.localeCompare(b.account_number))
|
||||
.map((a) => ({ account_number: a.account_number, account_name: a.account_name })),
|
||||
[accounts],
|
||||
)
|
||||
// Default to 1930 if available, otherwise the first account (once).
|
||||
const defaultedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!company?.id) return
|
||||
async function fetchBankAccounts(companyId: string) {
|
||||
const supabase = createClient()
|
||||
const { data } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.gte('account_number', '1900')
|
||||
.lte('account_number', '1999')
|
||||
.order('account_number')
|
||||
|
||||
if (data && data.length > 0) {
|
||||
setBankAccounts(data)
|
||||
// Default to 1930 if available, otherwise first account
|
||||
const has1930 = data.some(a => a.account_number === '1930')
|
||||
if (!has1930) setSelectedAccount(data[0].account_number)
|
||||
}
|
||||
}
|
||||
fetchBankAccounts(company.id)
|
||||
}, [company?.id])
|
||||
if (defaultedRef.current || bankAccounts.length === 0) return
|
||||
defaultedRef.current = true
|
||||
const has1930 = bankAccounts.some((a) => a.account_number === '1930')
|
||||
if (!has1930) setSelectedAccount(bankAccounts[0].account_number)
|
||||
}, [bankAccounts])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useFiscalPeriods } from '@/lib/reference-data/hooks'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
@@ -16,24 +17,10 @@ import { findFiscalYearGaps, type FiscalYearGap, type PeriodLike } from '@/lib/b
|
||||
*/
|
||||
export function FiscalYearGapNotice() {
|
||||
const t = useTranslations('fiscal_year_gaps')
|
||||
const [gaps, setGaps] = useState<FiscalYearGap[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/bookkeeping/fiscal-periods')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return
|
||||
const json = await res.json()
|
||||
const periods = (Array.isArray(json) ? json : (json.data ?? [])) as PeriodLike[]
|
||||
if (!cancelled) setGaps(findFiscalYearGaps(periods))
|
||||
})
|
||||
.catch(() => {
|
||||
// Advisory only.
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
// Session-cached period list (lib/reference-data): refreshed by the import
|
||||
// flow's invalidation, so the notice reflects the years just imported.
|
||||
const { periods } = useFiscalPeriods()
|
||||
const gaps = useMemo<FiscalYearGap[]>(() => findFiscalYearGaps(periods as PeriodLike[]), [periods])
|
||||
|
||||
if (gaps.length === 0) return null
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -72,6 +73,8 @@ export default function ImportReviewStep({
|
||||
}: ImportReviewStepProps) {
|
||||
const { canWrite } = useCanWrite()
|
||||
const { company } = useCompany()
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
const companyDefaultVoucherSeries = companySettings?.default_voucher_series || null
|
||||
const t = useTranslations('import')
|
||||
const [options, setOptions] = useState<ImportExecuteOptions>({
|
||||
createFiscalPeriod: true,
|
||||
@@ -116,15 +119,9 @@ export default function ImportReviewStep({
|
||||
: Promise.resolve({ count: 0, error: null })
|
||||
|
||||
const [
|
||||
{ data: settingsData, error: settingsError },
|
||||
{ data: sequencesData, error: sequencesError },
|
||||
{ count: ibCount, error: ibCountError },
|
||||
] = await Promise.all([
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('default_voucher_series')
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('voucher_sequences')
|
||||
.select('voucher_series')
|
||||
@@ -134,9 +131,6 @@ export default function ImportReviewStep({
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (settingsError) {
|
||||
console.error('Failed to load company settings for voucher series', settingsError)
|
||||
}
|
||||
if (sequencesError) {
|
||||
console.error('Failed to load voucher sequences', sequencesError)
|
||||
}
|
||||
@@ -144,7 +138,8 @@ export default function ImportReviewStep({
|
||||
console.error('Failed to check for existing opening-balance vouchers', ibCountError)
|
||||
}
|
||||
|
||||
const companyDefault = settingsData?.default_voucher_series || null
|
||||
// From the session-cached settings row (lib/reference-data).
|
||||
const companyDefault = companyDefaultVoucherSeries
|
||||
const sequences = new Set<string>((sequencesData || []).map((row) => row.voucher_series))
|
||||
const existingIb = ibCountError ? 0 : (ibCount ?? 0)
|
||||
|
||||
@@ -176,7 +171,7 @@ export default function ImportReviewStep({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [company?.id, preview.fiscalYearStart, preview.fiscalYearEnd, preview.openingBalanceTotal])
|
||||
}, [company?.id, companyDefaultVoucherSeries, preview.fiscalYearStart, preview.fiscalYearEnd, preview.openingBalanceTotal])
|
||||
|
||||
// Block browser close/refresh during import
|
||||
useUnsavedChanges(isLoading)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useFiscalPeriods } from '@/lib/reference-data/hooks'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { AlertCircle, Loader2, CheckCircle2 } from 'lucide-react'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
|
||||
interface EditableRow {
|
||||
id: string
|
||||
@@ -33,9 +33,12 @@ export default function OpeningBalancePeriodStep({
|
||||
isLoading,
|
||||
error,
|
||||
}: OpeningBalancePeriodStepProps) {
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
// Session-cached period list (lib/reference-data).
|
||||
const { periods, isLoading: loadingPeriods } = useFiscalPeriods()
|
||||
const [selectedPeriodId, setSelectedPeriodId] = useState<string>('')
|
||||
const [loadingPeriods, setLoadingPeriods] = useState(true)
|
||||
// Auto-select the first open period without OB once per load, never again
|
||||
// on a background refresh of the list (that would override a user pick).
|
||||
const autoSelectedRef = useRef(false)
|
||||
|
||||
// Compute totals
|
||||
let totalDebit = 0
|
||||
@@ -51,31 +54,13 @@ export default function OpeningBalancePeriodStep({
|
||||
const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchPeriods() {
|
||||
setLoadingPeriods(true)
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const allPeriods: FiscalPeriod[] = data.data || []
|
||||
setPeriods(allPeriods)
|
||||
|
||||
// Auto-select first open period without OB
|
||||
const openPeriod = allPeriods.find(
|
||||
(p) => !p.is_closed && !p.locked_at && !p.opening_balances_set,
|
||||
)
|
||||
if (openPeriod) {
|
||||
setSelectedPeriodId(openPeriod.id)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silent, user can still select period
|
||||
} finally {
|
||||
setLoadingPeriods(false)
|
||||
}
|
||||
}
|
||||
fetchPeriods()
|
||||
}, [])
|
||||
if (loadingPeriods || autoSelectedRef.current) return
|
||||
autoSelectedRef.current = true
|
||||
const openPeriod = periods.find(
|
||||
(p) => !p.is_closed && !p.locked_at && !p.opening_balances_set,
|
||||
)
|
||||
if (openPeriod) setSelectedPeriodId(openPeriod.id)
|
||||
}, [periods, loadingPeriods])
|
||||
|
||||
const selectedPeriod = periods.find((p) => p.id === selectedPeriodId)
|
||||
const periodHasOB = !!selectedPeriod?.opening_balances_set
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useAccounts, useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
@@ -22,11 +23,10 @@ import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
|
||||
import { proposePaymentLines, resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import type { Invoice, InvoiceItem, Customer, BASAccount, EntityType } from '@/types'
|
||||
import type { Invoice, InvoiceItem, Customer, EntityType } from '@/types'
|
||||
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
||||
|
||||
type DuplicateMatchReason = 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only'
|
||||
@@ -67,7 +67,6 @@ export default function PaymentBookingDialog({
|
||||
}: PaymentBookingDialogProps) {
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
const { company } = useCompany()
|
||||
const t = useTranslations('invoice_payment_dialog')
|
||||
|
||||
@@ -77,7 +76,16 @@ export default function PaymentBookingDialog({
|
||||
amount_only: t('match_reason_amount_only'),
|
||||
}
|
||||
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
// Session-cached reference data (lib/reference-data), seeded by the
|
||||
// dashboard layout: the chart and the settings are known on the first
|
||||
// paint, so the proposed lines and the voucher preview resolve as soon as
|
||||
// the dialog opens instead of after two sequential requests.
|
||||
const { accounts, isLoading: accountsLoading, error: accountsError } = useAccounts()
|
||||
const {
|
||||
settings: companySettings,
|
||||
isLoading: settingsLoading,
|
||||
error: settingsError,
|
||||
} = useCompanySettings()
|
||||
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
|
||||
const [lines, setLines] = useState<FormLine[]>([])
|
||||
const accountNameByNumber = useMemo(() => {
|
||||
@@ -92,7 +100,8 @@ export default function PaymentBookingDialog({
|
||||
const [tab, setTab] = useState<'new' | 'existing'>('new')
|
||||
// Drives the "Befintlig verifikation" picker copy: cash links against a 19xx
|
||||
// debit, accrual against a 1510 credit.
|
||||
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
|
||||
const accountingMethod: 'accrual' | 'cash' =
|
||||
companySettings?.accounting_method === 'cash' ? 'cash' : 'accrual'
|
||||
// source_type the booking will use: drives the voucher-series preview so the
|
||||
// number shown matches what mark-paid will actually create.
|
||||
const [sourceType, setSourceType] =
|
||||
@@ -110,38 +119,30 @@ export default function PaymentBookingDialog({
|
||||
return
|
||||
}
|
||||
|
||||
// Reference data still loading (no seed, first mount of the session):
|
||||
// the effect re-runs once it lands.
|
||||
if (accountsLoading || settingsLoading) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
// Fetch accounts
|
||||
const [accountsRes, fetchedCatalog] = await Promise.all([
|
||||
fetch('/api/bookkeeping/accounts'),
|
||||
loadBasCatalog(),
|
||||
])
|
||||
if (!accountsRes.ok) throw new Error(t('load_chart_failed'))
|
||||
const accountsData = await accountsRes.json()
|
||||
const fetchedAccounts: BASAccount[] = accountsData.data || []
|
||||
|
||||
if (accountsError) throw new Error(t('load_chart_failed'))
|
||||
if (!company?.id) throw new Error(t('no_active_company'))
|
||||
|
||||
// Fetch company settings
|
||||
const { data: settings, error: settingsError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type, ore_rounding')
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (settingsError) throw new Error(t('load_settings_failed'))
|
||||
|
||||
const fetchedCatalog = await loadBasCatalog()
|
||||
if (cancelled) return
|
||||
|
||||
setAccounts(fetchedAccounts)
|
||||
setCatalog(fetchedCatalog)
|
||||
|
||||
const accountingMethod = (settings?.accounting_method || 'accrual') as 'accrual' | 'cash'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
setAccountingMethod(accountingMethod)
|
||||
const settings = companySettings
|
||||
// /api/settings used to fall back to the company row's entity type
|
||||
// when company_settings.entity_type is null; the cached row does not.
|
||||
const entityType: EntityType =
|
||||
(settings?.entity_type as EntityType | null | undefined) ??
|
||||
company.entity_type ??
|
||||
'enskild_firma'
|
||||
|
||||
setSourceType(
|
||||
resolveInvoicePaymentSourceType({
|
||||
@@ -193,7 +194,11 @@ export default function PaymentBookingDialog({
|
||||
|
||||
init()
|
||||
return () => { cancelled = true }
|
||||
}, [open, invoice.id, company?.id])
|
||||
// companySettings and accountingMethod are read at init time on purpose: a
|
||||
// background revalidation of the settings row must not re-run init()
|
||||
// (and reset the user's lines) mid-dialog.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, invoice.id, company?.id, accountsLoading, settingsLoading, accountsError, settingsError])
|
||||
|
||||
// Voucher-series preview: resolve the upcoming serie + nummer the same way the
|
||||
// booking engine will, so a misconfigured series is visible before confirming.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useAccounts, useCompanySettings, useFiscalPeriods } from '@/lib/reference-data/hooks'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -29,7 +30,7 @@ import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
|
||||
import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
import { Loader2, Mail, Plus, Send, Trash2 } from 'lucide-react'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import type { Invoice, InvoiceItem, Customer, EntityType, BASAccount } from '@/types'
|
||||
import type { Invoice, InvoiceItem, Customer, EntityType } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
||||
import {
|
||||
@@ -72,12 +73,39 @@ export default function SendInvoiceDialog({
|
||||
const isCreditRepair = isCreditNote && invoice.status === 'sent'
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
|
||||
const [periodName, setPeriodName] = useState('')
|
||||
// Session-cached reference data (lib/reference-data), seeded by the
|
||||
// dashboard layout: settings, the period containing the invoice date and
|
||||
// the chart are known on the first paint, so opening the dialog costs no
|
||||
// reference requests. Only the credit-note original lookup and the BAS
|
||||
// catalogue (module-cached) are still loaded in init().
|
||||
const {
|
||||
settings: companySettings,
|
||||
isLoading: settingsLoading,
|
||||
error: settingsError,
|
||||
} = useCompanySettings()
|
||||
const {
|
||||
periods: fiscalPeriods,
|
||||
isLoading: periodsLoading,
|
||||
error: periodsError,
|
||||
} = useFiscalPeriods()
|
||||
const { accounts } = useAccounts()
|
||||
// /api/settings used to fall back to the company row's entity type when
|
||||
// company_settings.entity_type is null; the cached row does not, so the
|
||||
// fallback is explicit here (same rule as deriveSupplierInvoiceDefaults).
|
||||
const entityType: EntityType =
|
||||
(companySettings?.entity_type as EntityType | null | undefined) ??
|
||||
company?.entity_type ??
|
||||
'enskild_firma'
|
||||
const periodName = useMemo(
|
||||
() =>
|
||||
fiscalPeriods.find(
|
||||
(p) => p.period_start <= invoice.invoice_date && invoice.invoice_date <= p.period_end,
|
||||
)?.name ?? '',
|
||||
[fiscalPeriods, invoice.invoice_date],
|
||||
)
|
||||
const deferBooking = !!companySettings?.defer_invoice_booking
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [shouldBookOnIssue, setShouldBookOnIssue] = useState(true)
|
||||
const [deferBooking, setDeferBooking] = useState(false)
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
|
||||
const [editLines, setEditLines] = useState<FormLine[]>([])
|
||||
const [hasEdited, setHasEdited] = useState(false)
|
||||
@@ -110,25 +138,19 @@ export default function SendInvoiceDialog({
|
||||
return
|
||||
}
|
||||
|
||||
// Reference data still loading (no seed, first mount of the session):
|
||||
// the effect re-runs once it lands.
|
||||
if (settingsLoading || periodsLoading) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
if (!company?.id) throw new Error(t('no_active_company'))
|
||||
if (settingsError) throw new Error(t('company_settings_failed'))
|
||||
if (periodsError) throw new Error(t('fiscal_period_failed'))
|
||||
|
||||
const [settingsResult, periodResult, originalResult, authResult] = await Promise.all([
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type, defer_invoice_booking, email, invoice_email_cc_addresses, invoice_email_bcc_addresses')
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('fiscal_periods')
|
||||
.select('name')
|
||||
.eq('company_id', company.id)
|
||||
.lte('period_start', invoice.invoice_date)
|
||||
.gte('period_end', invoice.invoice_date)
|
||||
.maybeSingle(),
|
||||
const [originalResult, sessionResult] = await Promise.all([
|
||||
invoice.credited_invoice_id
|
||||
? supabase
|
||||
.from('invoices')
|
||||
@@ -137,51 +159,40 @@ export default function SendInvoiceDialog({
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle()
|
||||
: Promise.resolve({ data: null, error: null }),
|
||||
supabase.auth.getUser(),
|
||||
// Local session read (no network): only the signed-in address is
|
||||
// needed, as the legacy CC fallback.
|
||||
supabase.auth.getSession(),
|
||||
])
|
||||
|
||||
if (settingsResult.error) throw new Error(t('company_settings_failed'))
|
||||
if (periodResult.error) throw new Error(t('fiscal_period_failed'))
|
||||
if (originalResult.error) throw new Error(t('original_invoice_failed'))
|
||||
if (authResult.error || !authResult.data.user) throw new Error(t('load_failed_title'))
|
||||
const sessionUser = sessionResult.data.session?.user
|
||||
if (sessionResult.error || !sessionUser) throw new Error(t('load_failed_title'))
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
const method = (settingsResult.data?.accounting_method || 'accrual') as 'accrual' | 'cash'
|
||||
const method = (companySettings?.accounting_method || 'accrual') as 'accrual' | 'cash'
|
||||
// #967: deferred companies mark-sent WITHOUT booking; ekonomi books
|
||||
// later via a separate step, so neither preview nor editor applies.
|
||||
const bookOnIssue = invoice.credited_invoice_id && originalResult.data
|
||||
? creditNoteNeedsJournalEntry(method, originalResult.data)
|
||||
: method === 'accrual' && !settingsResult.data?.defer_invoice_booking
|
||||
: method === 'accrual' && !companySettings?.defer_invoice_booking
|
||||
|
||||
// Line editing needs the chart of accounts; only the accrual
|
||||
// book-at-issue path renders the editor, so skip the fetch elsewhere.
|
||||
let fetchedAccounts: BASAccount[] = []
|
||||
// Line editing needs the BAS catalogue; only the accrual
|
||||
// book-at-issue path renders the editor, so skip the load elsewhere.
|
||||
let fetchedCatalog: CatalogAccount[] = []
|
||||
if (!invoice.credited_invoice_id && bookOnIssue && !hasAccrualItems) {
|
||||
const [accountsRes, catalogResult] = await Promise.all([
|
||||
fetch('/api/bookkeeping/accounts'),
|
||||
loadBasCatalog(),
|
||||
])
|
||||
if (!accountsRes.ok) throw new Error(t('load_chart_failed'))
|
||||
const accountsData = await accountsRes.json()
|
||||
fetchedAccounts = accountsData.data || []
|
||||
fetchedCatalog = catalogResult
|
||||
fetchedCatalog = await loadBasCatalog()
|
||||
}
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
setAccounts(fetchedAccounts)
|
||||
setCatalog(fetchedCatalog)
|
||||
setEntityType((settingsResult.data?.entity_type as EntityType) || 'enskild_firma')
|
||||
const legacyCc = settingsResult.data?.email || authResult.data.user.email
|
||||
const legacyCc = companySettings?.email || sessionUser.email
|
||||
setFixedCc(
|
||||
settingsResult.data?.invoice_email_cc_addresses
|
||||
companySettings?.invoice_email_cc_addresses
|
||||
?? (legacyCc ? [legacyCc] : []),
|
||||
)
|
||||
setFixedBcc(settingsResult.data?.invoice_email_bcc_addresses ?? [])
|
||||
setPeriodName(periodResult.data?.name || '')
|
||||
setDeferBooking(!!settingsResult.data?.defer_invoice_booking)
|
||||
setFixedBcc(companySettings?.invoice_email_bcc_addresses ?? [])
|
||||
setShouldBookOnIssue(bookOnIssue)
|
||||
setIsInitialized(true)
|
||||
} catch (err) {
|
||||
@@ -197,7 +208,10 @@ export default function SendInvoiceDialog({
|
||||
|
||||
init()
|
||||
return () => { cancelled = true }
|
||||
}, [open, invoice.id, invoice.invoice_date, company?.id, canCustomizeRecipients])
|
||||
// companySettings is read at init time on purpose: a background
|
||||
// revalidation of the settings row must not re-run init() mid-dialog.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, invoice.id, invoice.invoice_date, company?.id, canCustomizeRecipients, settingsLoading, periodsLoading, settingsError, periodsError])
|
||||
|
||||
const proposedLines = useMemo(() => {
|
||||
if (!isInitialized || !shouldBookOnIssue) return []
|
||||
|
||||
@@ -1,39 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useAccounts } from '@/lib/reference-data/hooks'
|
||||
|
||||
/**
|
||||
* Account number -> account name for the proposal previews, fetched once per
|
||||
* mount for the active company. Provide the result through
|
||||
* AccountNamesContext (OperationPreview) so every preview surface (the
|
||||
* /pending queue, the chat ApprovalCard) shows "6110 Kontorsmateriel" and
|
||||
* not just the number or the bank's raw text. Display-only: a failed fetch
|
||||
* leaves the map empty and previews fall back to the number.
|
||||
* Account number -> account name for the proposal previews. Provide the
|
||||
* result through AccountNamesContext (OperationPreview) so every preview
|
||||
* surface (the /pending queue, the chat ApprovalCard) shows "6110
|
||||
* Kontorsmateriel" and not just the number or the bank's raw text.
|
||||
* Display-only: derived from the session-cached chart (lib/reference-data),
|
||||
* so it costs no request of its own; while the chart is unavailable the map
|
||||
* is empty and previews fall back to the number.
|
||||
*/
|
||||
export function useAccountNamesSource(): Record<string, string> {
|
||||
const [names, setNames] = useState<Record<string, string>>({})
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
void fetch('/api/bookkeeping/accounts')
|
||||
.then((r) => r.json())
|
||||
.then(({ data }) => {
|
||||
if (!alive) return
|
||||
setNames(
|
||||
Object.fromEntries(
|
||||
((data ?? []) as Array<{ account_number: string; account_name: string }>).map((a) => [
|
||||
a.account_number,
|
||||
a.account_name,
|
||||
]),
|
||||
),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
// Display-only: the number still shows, so a failure is not worth
|
||||
// surfacing as an error the user cannot act on.
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
return names
|
||||
const { accounts } = useAccounts()
|
||||
return useMemo(
|
||||
() => Object.fromEntries(accounts.map((a) => [a.account_number, a.account_name])),
|
||||
[accounts],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import DimensionCombobox from '@/components/dimensions/DimensionCombobox'
|
||||
import { useCompanySettings } from '@/components/settings/useSettings'
|
||||
import { fetchDimensions, type DimensionDto } from '@/components/dimensions/types'
|
||||
import { useDimensions } from '@/lib/reference-data/hooks'
|
||||
|
||||
export type DimensionFilterValue = {
|
||||
/** SIE dimension number as a string ('1' kostnadsställe, '6' projekt). */
|
||||
@@ -43,27 +43,13 @@ interface Props {
|
||||
*/
|
||||
export function DimensionFilter({ value, onChange }: Props) {
|
||||
const { settings } = useCompanySettings()
|
||||
const [dims, setDims] = useState<DimensionDto[]>([])
|
||||
// Registry from the session cache (lib/reference-data).
|
||||
const { dimensions: dims } = useDimensions()
|
||||
// Which dimension the picker targets while no value is selected yet;
|
||||
// once a value is picked, `value.dimNo` is the source of truth.
|
||||
const [pendingDimNo, setPendingDimNo] = useState('6')
|
||||
const enabled = settings?.dimensions_enabled === true
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
let cancelled = false
|
||||
fetchDimensions()
|
||||
.then((rows) => {
|
||||
if (!cancelled) setDims(rows)
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort: without the registry the filter simply doesn't render.
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
if (!enabled || dims.length === 0) return null
|
||||
|
||||
const activeDimNo = value?.dimNo ?? pendingDimNo
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import type { VatPeriodType } from '@/types'
|
||||
import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
@@ -367,13 +368,15 @@ function SkatteverketPanelInner({
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get redovisare from settings
|
||||
// Redovisare from the session-cached settings row (lib/reference-data).
|
||||
// entity_type falls back to the company row, as /api/settings did.
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
const companyEntityType = useCompanyOptional()?.company?.entity_type ?? null
|
||||
const getRedovisare = useCallback(async (): Promise<string> => {
|
||||
const res = await fetch('/api/settings')
|
||||
const { data } = await res.json()
|
||||
if (!data?.org_number) throw new Error('Organisationsnummer saknas')
|
||||
return formatRedovisare(data.org_number, data.entity_type)
|
||||
}, [])
|
||||
const orgNumber = companySettings?.org_number
|
||||
if (!orgNumber) throw new Error('Organisationsnummer saknas')
|
||||
return formatRedovisare(orgNumber, companySettings?.entity_type ?? companyEntityType)
|
||||
}, [companySettings, companyEntityType])
|
||||
|
||||
const getRedovisningsperiod = useCallback((): string => {
|
||||
return formatRedovisningsperiod(periodType, year, period, fiscalYearEnd)
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// Rendered by the focused /reports/[slug] route (see components/reports/FocusedReport.tsx).
|
||||
// The regulated table/figure rendering is unchanged from the original monolith.
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useDimensions } from '@/lib/reference-data/hooks'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -3426,22 +3427,17 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da
|
||||
data: DimensionPnlReport | null
|
||||
error: string | null
|
||||
} | null>(null)
|
||||
const [dims, setDims] = useState<{ sie_dim_no: number; name: string }[]>([])
|
||||
const [dimNo, setDimNo] = useState('6')
|
||||
const reportQs = `${reportQuery(periodId, dateRange)}&dim_no=${encodeURIComponent(dimNo)}`
|
||||
|
||||
// Registered dimensions for the pivot picker (best-effort; the report
|
||||
// defaults to projekt if the registry read fails).
|
||||
useEffect(() => {
|
||||
fetch('/api/dimensions')
|
||||
.then((res) => res.json())
|
||||
.then((payload) => {
|
||||
if (Array.isArray(payload.data)) {
|
||||
setDims(payload.data.map((d: { sie_dim_no: number; name: string }) => ({ sie_dim_no: d.sie_dim_no, name: d.name })))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
// Registered dimensions for the pivot picker, from the session cache
|
||||
// (lib/reference-data); best-effort, the report defaults to projekt while
|
||||
// the registry is unavailable.
|
||||
const { dimensions } = useDimensions()
|
||||
const dims = useMemo(
|
||||
() => dimensions.map((d) => ({ sie_dim_no: d.sie_dim_no, name: d.name })),
|
||||
[dimensions],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
@@ -23,6 +23,7 @@ import type { ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { useBookingTemplates } from '@/lib/reference-data/hooks'
|
||||
|
||||
export function BookingTemplatesPanel() {
|
||||
const t = useTranslations('settings_booking_templates')
|
||||
@@ -36,8 +37,10 @@ export function BookingTemplatesPanel() {
|
||||
aktiebolag: t('entity_aktiebolag'),
|
||||
}
|
||||
|
||||
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
// The panel renders the same session-cached list the pickers use
|
||||
// (lib/reference-data); every write below invalidates it so registry and
|
||||
// pickers can never disagree.
|
||||
const { templates, isLoading, error: templatesError } = useBookingTemplates()
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
@@ -47,19 +50,13 @@ export function BookingTemplatesPanel() {
|
||||
const [activeTemplate, setActiveTemplate] = useState<BookingTemplateLibrary | null>(null)
|
||||
const importRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings/booking-templates')
|
||||
const json = await res.json()
|
||||
if (json.data) setTemplates(json.data)
|
||||
} catch {
|
||||
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [toast, t])
|
||||
useEffect(() => {
|
||||
if (templatesError) toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
|
||||
}, [templatesError, toast, t])
|
||||
|
||||
useEffect(() => { fetchTemplates() }, [fetchTemplates])
|
||||
const refreshTemplates = () => {
|
||||
void invalidateReferenceData('ref:booking-templates')
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeletingId(id)
|
||||
@@ -73,9 +70,7 @@ export function BookingTemplatesPanel() {
|
||||
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
setTemplates((prev) => prev.filter((tt) => tt.id !== id))
|
||||
// The pickers read the session cache (lib/reference-data): drop the
|
||||
// deleted template there too, not just from this panel's own list.
|
||||
// This list and every picker read the session cache: refresh it.
|
||||
void invalidateReferenceData('ref:booking-templates')
|
||||
toast({ title: t('toast_deleted') })
|
||||
} finally {
|
||||
@@ -128,8 +123,7 @@ export function BookingTemplatesPanel() {
|
||||
return
|
||||
}
|
||||
toast({ title: t('toast_import_done'), description: t('toast_import_count', { count: json.imported }) })
|
||||
fetchTemplates()
|
||||
void invalidateReferenceData('ref:booking-templates')
|
||||
refreshTemplates()
|
||||
} catch {
|
||||
toast({ title: t('toast_import_error'), description: t('toast_invalid_file'), variant: 'destructive' })
|
||||
} finally {
|
||||
@@ -207,7 +201,7 @@ export function BookingTemplatesPanel() {
|
||||
duplicateNamePool={companyTemplateNames}
|
||||
onSaved={() => {
|
||||
setShowCreate(false)
|
||||
fetchTemplates()
|
||||
refreshTemplates()
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
@@ -300,7 +294,7 @@ export function BookingTemplatesPanel() {
|
||||
duplicateNamePool={companyTemplateNames}
|
||||
onSaved={() => {
|
||||
setActiveTemplate(null)
|
||||
fetchTemplates()
|
||||
refreshTemplates()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useFiscalPeriods } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
@@ -54,17 +56,29 @@ export function FiscalPeriodEditor() {
|
||||
const isEF = company?.entity_type === 'enskild_firma'
|
||||
const canEdit = role === 'owner' || role === 'admin'
|
||||
|
||||
// Periods come from the session cache (lib/reference-data). The editor
|
||||
// snapshots the FIRST period once per company (a background revalidation
|
||||
// must not reset the dates the user is editing), then loads that period's
|
||||
// posted-entry count, which is what gates editing.
|
||||
const { periods, isLoading: periodsLoading, error: periodsError } = useFiscalPeriods()
|
||||
const periodsRef = useRef(periods)
|
||||
useEffect(() => {
|
||||
if (!company) return
|
||||
periodsRef.current = periods
|
||||
}, [periods])
|
||||
const initialisedForRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!company || periodsLoading) return
|
||||
if (initialisedForRef.current === company.id) return
|
||||
initialisedForRef.current = company.id
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
if (!res.ok) throw new Error(t('fp_load_error_periods'))
|
||||
const { data } = (await res.json()) as { data: FiscalPeriod[] }
|
||||
if (periodsError) throw new Error(t('fp_load_error_periods'))
|
||||
const data = periodsRef.current
|
||||
if (!data || data.length === 0) {
|
||||
if (!cancelled) {
|
||||
setPeriod(null)
|
||||
@@ -97,7 +111,7 @@ export function FiscalPeriodEditor() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [company, t])
|
||||
}, [company, periodsLoading, periodsError, t])
|
||||
|
||||
const validation = validateFirstPeriod(
|
||||
startDate,
|
||||
@@ -153,6 +167,8 @@ export function FiscalPeriodEditor() {
|
||||
throw new Error(body.error || t('fp_update_failed_title'))
|
||||
}
|
||||
setPeriod(body.data as FiscalPeriod)
|
||||
// Every picker reads the shared list: refresh it with the new dates.
|
||||
void invalidateReferenceData('ref:fiscal-periods')
|
||||
toast({
|
||||
title: t('fp_updated_title'),
|
||||
description: `${formatSwedishDate(body.data.period_start)}: ${formatSwedishDate(body.data.period_end)}`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { SettingsGroup } from '@/components/settings/SettingsRows'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useFiscalPeriods } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { Plus, Lock, Unlock, Loader2, Eraser } from 'lucide-react'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
@@ -39,9 +41,11 @@ export function FiscalYearsManager() {
|
||||
const { toast } = useToast()
|
||||
const { role } = useCompany()
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
// Session-cached registry (lib/reference-data): the same list every
|
||||
// picker renders, so a lock/unlock/reset here is visible everywhere the
|
||||
// moment the cache is invalidated below.
|
||||
const { periods, isLoading, error: periodsError } = useFiscalPeriods()
|
||||
const hasError = !!periodsError && periods.length === 0
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [mutatingId, setMutatingId] = useState<string | null>(null)
|
||||
const [resetTarget, setResetTarget] = useState<FiscalPeriod | null>(null)
|
||||
@@ -50,21 +54,7 @@ export function FiscalYearsManager() {
|
||||
// too (requireWrite); this just hides controls a viewer/member can't use.
|
||||
const canManage = role === 'owner' || role === 'admin'
|
||||
|
||||
const fetchPeriods = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
if (!res.ok) throw new Error('fetch failed')
|
||||
const { data } = await res.json()
|
||||
setPeriods((data as FiscalPeriod[]) || [])
|
||||
setHasError(false)
|
||||
} catch {
|
||||
setHasError(true)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchPeriods() }, [fetchPeriods])
|
||||
const refreshPeriods = useCallback(() => invalidateReferenceData('ref:fiscal-periods'), [])
|
||||
|
||||
// Newest first: matches the API's ordering and reads most-recent-at-top.
|
||||
const sorted = [...periods].sort((a, b) => b.period_start.localeCompare(a.period_start))
|
||||
@@ -82,7 +72,7 @@ export function FiscalYearsManager() {
|
||||
throw new Error(body?.error?.message || t('fy_action_error'))
|
||||
}
|
||||
toast({ title: action === 'lock' ? t('fy_lock_success') : t('fy_unlock_success') })
|
||||
await fetchPeriods()
|
||||
await refreshPeriods()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('fy_action_error'),
|
||||
@@ -219,7 +209,7 @@ export function FiscalYearsManager() {
|
||||
onOpenChange={setDialogOpen}
|
||||
entryDate={suggestSeedDate(periods, new Date().toISOString().split('T')[0])}
|
||||
periods={periods}
|
||||
onCreated={fetchPeriods}
|
||||
onCreated={refreshPeriods}
|
||||
/>
|
||||
|
||||
{resetTarget && (
|
||||
@@ -230,7 +220,7 @@ export function FiscalYearsManager() {
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setResetTarget(null)
|
||||
}}
|
||||
onReset={fetchPeriods}
|
||||
onReset={refreshPeriods}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCashAccounts } from '@/lib/reference-data/hooks'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { formatIbanGroups, uniqueConnectionIban } from '@/lib/company/connection-iban'
|
||||
import { bankgiroFromTicSnapshot } from '@/lib/company/snapshot-bank'
|
||||
@@ -98,7 +99,16 @@ export function InvoicePaymentAccountsSettings({
|
||||
// rows (bank_connection_id nulled) and the connect picker mirrors deselected
|
||||
// accounts with enabled=false, so an unfiltered read could offer a closed or
|
||||
// third-party account. Only offered when the remaining rows agree on one IBAN.
|
||||
const [connectionIban, setConnectionIban] = useState<string | null>(null)
|
||||
const { cashAccounts } = useCashAccounts()
|
||||
const connectionIban = useMemo(
|
||||
() =>
|
||||
uniqueConnectionIban(
|
||||
cashAccounts.filter(
|
||||
(a) => a.enabled && a.currency === 'SEK' && a.bank_connection_id && a.iban,
|
||||
),
|
||||
),
|
||||
[cashAccounts],
|
||||
)
|
||||
const legacySekAccount = useMemo(
|
||||
() => legacySekInvoicePaymentAccount({
|
||||
bank_name: settings.bank_name,
|
||||
@@ -166,18 +176,6 @@ export function InvoicePaymentAccountsSettings({
|
||||
if (cancelled) return
|
||||
setSnapshotBankgiro(bankgiroFromTicSnapshot(data?.tic_snapshot, data?.org_number))
|
||||
})
|
||||
supabase
|
||||
.from('cash_accounts')
|
||||
.select('iban')
|
||||
.eq('company_id', company.id)
|
||||
.eq('enabled', true)
|
||||
.eq('currency', 'SEK')
|
||||
.not('bank_connection_id', 'is', null)
|
||||
.not('iban', 'is', null)
|
||||
.then(({ data }) => {
|
||||
if (cancelled) return
|
||||
setConnectionIban(uniqueConnectionIban(data))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
buildActiveAccountIndex,
|
||||
searchAccounts,
|
||||
type AccountSearchItem,
|
||||
type ChartAccountLike,
|
||||
} from '@/lib/bookkeeping/account-search'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
|
||||
@@ -25,7 +24,7 @@ import { convertLibraryToBookingTemplate, LIBRARY_TEMPLATE_PREFIX, isLibraryTemp
|
||||
import { getAccountName } from '@/lib/bookkeeping/client-account-names'
|
||||
import type { BookingTemplateLibrary, EntityType } from '@/types'
|
||||
import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { useBookingTemplates } from '@/lib/reference-data/hooks'
|
||||
import { useAccounts, useBookingTemplates } from '@/lib/reference-data/hooks'
|
||||
|
||||
// Cap on the "Konton" search-result group: enough to cover sibling accounts
|
||||
// on a number-prefix query without drowning the template results.
|
||||
@@ -275,7 +274,11 @@ export default function TemplatePicker({
|
||||
() => libraryTemplates.filter((tt) => !tt.is_system && tt.is_active),
|
||||
[libraryTemplates],
|
||||
)
|
||||
const [chartAccounts, setChartAccounts] = useState<ChartAccountLike[]>([])
|
||||
// The company's active chart, from the session cache (lib/reference-data),
|
||||
// so the search field can surface real accounts (issue #1877) without a
|
||||
// request per mount. buildActiveAccountIndex re-filters is_active as
|
||||
// defense in depth. Only consulted when the consumer routes account picks.
|
||||
const { accounts: chartAccounts } = useAccounts()
|
||||
// Boolean gate rather than the callback itself: the parent recreates the
|
||||
// handler every render, and depending on its identity would refetch the
|
||||
// chart on each keystroke of the page underneath.
|
||||
@@ -286,27 +289,6 @@ export default function TemplatePicker({
|
||||
// and users know what they made).
|
||||
const templateDirection = direction === 'income' ? 'income' : 'expense'
|
||||
|
||||
// Fetch the company's active chart so the search field can surface real
|
||||
// accounts (issue #1877: an active konto like 5460 was unfindable because
|
||||
// only template metadata was searched). The endpoint returns active
|
||||
// accounts by default; buildActiveAccountIndex re-filters as defense in
|
||||
// depth. Gated on the consumer actually routing account picks somewhere.
|
||||
useEffect(() => {
|
||||
if (!accountSearchEnabled) return
|
||||
const controller = new AbortController()
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/accounts', { signal: controller.signal })
|
||||
if (!res.ok) return
|
||||
const { data } = await res.json() as { data?: ChartAccountLike[] }
|
||||
if (data) setChartAccounts(data)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
}
|
||||
})()
|
||||
return () => { controller.abort() }
|
||||
}, [accountSearchEnabled])
|
||||
|
||||
const accountIndex = useMemo(() => buildActiveAccountIndex(chartAccounts), [chartAccounts])
|
||||
|
||||
// Lazy convertibility map. A template is "convertible" if it fits the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useAccounts, useCompanySettings, useFiscalPeriods } from '@/lib/reference-data/hooks'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -33,7 +34,6 @@ import {
|
||||
resolveFiscalYearStart,
|
||||
resolveGapFillStart,
|
||||
} from '../lib/date-suggestions'
|
||||
import type { CompanySettings } from '@/types'
|
||||
import type { StoredAccount } from '../types'
|
||||
import {
|
||||
BankSyncProgressDialog,
|
||||
@@ -97,12 +97,41 @@ export function AccountPickerDialog({
|
||||
const [lastBookedDate, setLastBookedDate] = useState<string | null>(null)
|
||||
// Earliest completed SIE import coverage start: present = migrator flow.
|
||||
const [sieCoverageStart, setSieCoverageStart] = useState<string | null>(null)
|
||||
const [chartAccounts, setChartAccounts] = useState<ChartAccount[]>([])
|
||||
const [chartError, setChartError] = useState(false)
|
||||
// Reference data from the session cache (lib/reference-data), seeded by
|
||||
// the dashboard layout: settings, the period containing today and the
|
||||
// chart are known when the dialog opens, no requests of its own. A failed
|
||||
// load keeps settingsLoaded false so the calendar-year fallback is never
|
||||
// presented as the authoritative fiscal-year start (issue #917).
|
||||
const {
|
||||
settings: companySettings,
|
||||
isLoading: settingsLoading,
|
||||
error: settingsError,
|
||||
} = useCompanySettings()
|
||||
const { periods, isLoading: periodsLoading, error: periodsError } = useFiscalPeriods()
|
||||
// Inactive accounts included: the old chart query did not filter on is_active.
|
||||
const { accounts: allAccounts, error: chartLoadError } = useAccounts(false)
|
||||
const settingsLoaded = !settingsLoading && !periodsLoading && !settingsError && !periodsError
|
||||
const currentPeriodStart = useMemo(() => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const containing = periods
|
||||
.filter((p) => p.period_start <= today && today <= p.period_end)
|
||||
.sort((a, b) => b.period_start.localeCompare(a.period_start))
|
||||
return containing[0]?.period_start || null
|
||||
}, [periods])
|
||||
// 19xx accounts for the per-account ledger combobox. Class 19 = bank/cash
|
||||
// on the BAS chart.
|
||||
const chartAccounts = useMemo<ChartAccount[]>(
|
||||
() =>
|
||||
allAccounts
|
||||
.filter((a) => a.account_number.startsWith('19'))
|
||||
.sort((a, b) => a.account_number.localeCompare(b.account_number))
|
||||
.map((a) => ({ account_number: a.account_number, account_name: a.account_name })),
|
||||
[allAccounts],
|
||||
)
|
||||
// Surface the failure: without the 19xx chart the ledger picker is
|
||||
// silently empty, which reads as "no bank accounts exist".
|
||||
const chartError = Boolean(chartLoadError)
|
||||
const [ledgerByUid, setLedgerByUid] = useState<Record<string, string>>({})
|
||||
const [companySettings, setCompanySettings] = useState<Pick<CompanySettings, 'fiscal_year_start_month' | 'entity_type'> | null>(null)
|
||||
const [currentPeriodStart, setCurrentPeriodStart] = useState<string | null>(null)
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false)
|
||||
|
||||
const [lookbackMode, setLookbackMode] = useState<LookbackMode>('fiscal-year')
|
||||
const [customSubMode, setCustomSubMode] = useState<CustomSubMode>('date')
|
||||
@@ -127,9 +156,6 @@ export function AccountPickerDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Re-arm the "settings loaded" gate each open so the fiscal-year label
|
||||
// doesn't flash last-open's resolved date before this open's fetch lands.
|
||||
setSettingsLoaded(false)
|
||||
const initial = new Set<string>(
|
||||
accounts.filter(a => a.enabled !== false).map(a => a.uid)
|
||||
)
|
||||
@@ -159,45 +185,11 @@ export function AccountPickerDialog({
|
||||
}
|
||||
}, [open, accounts])
|
||||
|
||||
// Load fiscal_year_start_month + entity_type so "Sedan räkenskapsårets början"
|
||||
// resolves to the right date for non-calendar fiscal years, plus the actual
|
||||
// fiscal_periods row containing today: the recurring setting cannot represent
|
||||
// an extended or shortened first year, so the period row wins when it exists.
|
||||
useEffect(() => {
|
||||
if (!open || !company?.id) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const [settingsRes, periodRes] = await Promise.all([
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('fiscal_year_start_month, entity_type')
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_start')
|
||||
.eq('company_id', company.id)
|
||||
.lte('period_start', today)
|
||||
.gte('period_end', today)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle(),
|
||||
])
|
||||
if (cancelled) return
|
||||
if (settingsRes.error || periodRes.error) {
|
||||
// A failed fetch must not present the calendar-year fallback as the
|
||||
// authoritative fiscal-year start (issue #917). Leave settingsLoaded
|
||||
// false so the date stays masked; if the user proceeds anyway the
|
||||
// request falls back to the recurring-setting derivation.
|
||||
return
|
||||
}
|
||||
setCompanySettings((settingsRes.data as { fiscal_year_start_month?: number; entity_type?: CompanySettings['entity_type'] } | null) as Pick<CompanySettings, 'fiscal_year_start_month' | 'entity_type'> | null)
|
||||
setCurrentPeriodStart((periodRes.data as { period_start?: string } | null)?.period_start || null)
|
||||
setSettingsLoaded(true)
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [open, company?.id, supabase])
|
||||
// fiscal_year_start_month + entity_type make "Sedan räkenskapsårets början"
|
||||
// resolve to the right date for non-calendar fiscal years, and the actual
|
||||
// fiscal_periods row containing today wins when it exists: the recurring
|
||||
// setting cannot represent an extended or shortened first year. Both are
|
||||
// derived above from the session cache.
|
||||
|
||||
// Fetch the latest posted verifikat date so we can offer "day after the last
|
||||
// booked entry" as a one-click escape from the default fiscal-year start.
|
||||
@@ -303,31 +295,6 @@ export function AccountPickerDialog({
|
||||
return () => { cancelled = true }
|
||||
}, [open, isInitialSelection, company?.id, connectionId, supabase, accounts])
|
||||
|
||||
// Load 19xx accounts from the chart for the per-account ledger combobox.
|
||||
// Class 19 = bank/cash on the BAS chart.
|
||||
useEffect(() => {
|
||||
if (!open || !company?.id) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('company_id', company.id)
|
||||
.like('account_number', '19%')
|
||||
.order('account_number', { ascending: true })
|
||||
if (cancelled) return
|
||||
if (error) {
|
||||
// Surface the failure: without the 19xx chart the ledger picker is
|
||||
// silently empty, which reads as "no bank accounts exist".
|
||||
setChartError(true)
|
||||
return
|
||||
}
|
||||
setChartError(false)
|
||||
setChartAccounts((data as ChartAccount[] | null) || [])
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [open, company?.id, supabase])
|
||||
|
||||
const allSelected = accounts.length > 0 && selected.size === accounts.length
|
||||
const noneSelected = selected.size === 0
|
||||
|
||||
|
||||
@@ -34,43 +34,7 @@
|
||||
]
|
||||
},
|
||||
"rawReferenceFetch": {
|
||||
"count": 35,
|
||||
"files": [
|
||||
"app/(dashboard)/assets/[id]/dispose/page.tsx",
|
||||
"app/(dashboard)/bookkeeping/year-end/page.tsx",
|
||||
"app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx",
|
||||
"app/(dashboard)/customers/page.tsx",
|
||||
"app/(dashboard)/import/page.tsx",
|
||||
"app/(dashboard)/invoices/[id]/page.tsx",
|
||||
"app/(dashboard)/invoices/page.tsx",
|
||||
"app/(dashboard)/pending/page.tsx",
|
||||
"app/(dashboard)/salary/employees/[id]/page.tsx",
|
||||
"components/articles/ArticleForm.tsx",
|
||||
"components/bookkeeping/ChartOfAccounts.tsx",
|
||||
"components/bookkeeping/ChartOfAccountsManager.tsx",
|
||||
"components/bookkeeping/CorrectionEntryDialog.tsx",
|
||||
"components/bookkeeping/EditAccountDialog.tsx",
|
||||
"components/bookkeeping/StrikeLinesDialog.tsx",
|
||||
"components/common/CashAccountSelector.tsx",
|
||||
"components/dimensions/types.ts",
|
||||
"components/extensions/general/ArcimMigrationWorkspace.tsx",
|
||||
"components/extensions/general/InvoiceInboxWorkspace.tsx",
|
||||
"components/extensions/general/TicWorkspace.tsx",
|
||||
"components/import/BankFileConfirmStep.tsx",
|
||||
"components/import/FiscalYearGapNotice.tsx",
|
||||
"components/import/ImportReviewStep.tsx",
|
||||
"components/import/OpeningBalancePeriodStep.tsx",
|
||||
"components/invoices/PaymentBookingDialog.tsx",
|
||||
"components/invoices/SendInvoiceDialog.tsx",
|
||||
"components/pending-operations/use-account-names.ts",
|
||||
"components/reports/SkatteverketPanel.tsx",
|
||||
"components/reports/views/index.tsx",
|
||||
"components/settings/BookingTemplatesPanel.tsx",
|
||||
"components/settings/FiscalPeriodEditor.tsx",
|
||||
"components/settings/FiscalYearsManager.tsx",
|
||||
"components/settings/InvoicePaymentAccountsSettings.tsx",
|
||||
"components/transactions/TemplatePicker.tsx",
|
||||
"extensions/general/enable-banking/components/AccountPickerDialog.tsx"
|
||||
]
|
||||
"count": 0,
|
||||
"files": []
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user