diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 511ab126..099326b6 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -3,7 +3,6 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' -import { Skeleton } from '@/components/ui/skeleton' import { PageHeader } from '@/components/ui/page-header' import { HelpPopover } from '@/components/ui/help-popover' import { EmptyState } from '@/components/ui/empty-state' @@ -25,7 +24,10 @@ import { getReport } from '@/lib/reports/catalog' export default function ReportsPage() { const router = useRouter() const [selectedPeriod, setSelectedPeriod] = useState('') - const [isLoadingInit, setIsLoadingInit] = useState(true) + // The catalog itself is static; only the "no fiscal year" empty state has + // to wait for the picker to finish restoring its scope (one effect tick + // when the periods are seeded, see FyPicker). + const [fyReady, setFyReady] = useState(false) const { company } = useCompany() const { settings } = useCompanySettings() const t = useTranslations('reports') @@ -62,18 +64,12 @@ export default function ReportsPage() { onChange={(id) => setSelectedPeriod(id || '')} includeAllOption={false} hideFuturePeriods - onReady={() => setIsLoadingInit(false)} + onReady={() => setFyReady(true)} /> } /> - {isLoadingInit ? ( -
- {[1, 2, 3, 4, 5, 6].map((i) => ( - - ))} -
- ) : !selectedPeriod ? ( + {fyReady && !selectedPeriod ? ( ([]) + // Registered, enabled cash accounts for the account chooser: session-cached + // and seeded by the dashboard layout (lib/reference-data), so the chooser + // renders populated on the first paint. Bank sync invalidates the entry. + const { cashAccounts } = useCashAccounts({ enabledOnly: true }) const { toast } = useToast() const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm() @@ -882,24 +886,6 @@ export default function TransactionsPage() { const PAGE_SIZE = 200 - // Account chooser rows: the registered, enabled cash accounts. One fetch - // per company; balances refresh with the page (bank sync triggers a - // router.refresh via the sync toast flow). - useEffect(() => { - if (!companyId) return - let cancelled = false - fetch('/api/cash-accounts?enabled_only=true') - .then((res) => (res.ok ? res.json() : { data: [] })) - .then((json: { data?: CashAccount[] }) => { - if (!cancelled) setCashAccounts(json.data ?? []) - }) - .catch(() => { - if (!cancelled) setCashAccounts([]) - }) - return () => { - cancelled = true - } - }, [companyId]) // Same sequence-guard pattern as fetchGenerationRef: only the newest // skattekonto fetch may write rows. A company switch bumps the sequence so a diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 501bfd84..545d8348 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -46,6 +46,7 @@ import { ArrowDown, ArrowUp, ArrowUpDown, ChevronRight, ChevronLeft, ChevronsLef import { cn, formatDate, formatCurrency } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { resolveCurrentPeriodId } from '@/lib/bookkeeping/suggest-fiscal-period' +import { useFiscalPeriods } from '@/lib/reference-data/hooks' import { Input } from '@/components/ui/input' import { AccountNumber } from '@/components/ui/account-number' import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions' @@ -61,7 +62,7 @@ import { getErrorMessage } from '@/lib/errors/get-error-message' import { useCompanyOptional } from '@/contexts/CompanyContext' import { listContextKey, writeListContext } from '@/lib/navigation/list-context' import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/types' -import type { FiscalPeriod, JournalEntry, JournalEntryLine } from '@/types' +import type { JournalEntry, JournalEntryLine } from '@/types' // Shared source of truth (lib/worklist/types.ts) so the per-row chip and // waiver UI can never drift from the worklist count and the SQL predicate @@ -444,7 +445,7 @@ export default function JournalEntryList({ setPageSizeHydrated(true) }, [company?.id]) - // Fetch fiscal periods AND resolve the initial fiscal-year scope in one pass. + // Resolve the initial fiscal-year scope from the session-cached period list. // The list is period-oriented (BFL): verifikationsnummer run as an unbroken // series *per räkenskapsår*, so the same number (e.g. A42) recurs once per // year. Showing every year at once makes those look like duplicates and makes @@ -454,12 +455,22 @@ export default function JournalEntryList({ // Resolving the scope here, not in the dialog's FiscalYearSelector, which // only mounts when opened, keeps the first fetch correct. periodHydrated // gates that first fetch so the list loads already scoped to the resolved year. + // + // The periods come from useFiscalPeriods (seeded by the dashboard layout), + // so on a normal visit this resolves in the first effect tick with no + // round trip; the saved-scope shortcut below still unblocks the entries + // fetch first when the list is not cached yet. Resolution runs once per + // company: a background revalidation of the list must not snap the scope + // back (the deep-link "all years" visit in particular). + const { periods: fiscalPeriods, isLoading: fiscalPeriodsLoading } = useFiscalPeriods() + const periodScopeResolvedForRef = useRef(null) useEffect(() => { if (!company?.id) { setPeriodId(null) setPeriodHydrated(true) return } + if (periodScopeResolvedForRef.current === company.id) return // Deep-link arrival with the missing-underlag filter: scope this visit to // all fiscal years (in memory only) so the list can show the same set the @@ -467,6 +478,7 @@ export default function JournalEntryList({ // FyPicker afterwards works and persists as usual. if (deepLinkAllYearsRef.current) { deepLinkAllYearsRef.current = false + periodScopeResolvedForRef.current = company.id setPeriodId(null) setPeriodHydrated(true) return @@ -477,43 +489,29 @@ export default function JournalEntryList({ ? window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id) : null // Optimistic hydration: a saved scope unblocks the first entries fetch - // immediately instead of serializing it behind the fiscal-periods - // round-trip (the common returning-user case). The fetch below still - // validates a saved period id and re-scopes to the current räkenskapsår - // if it went stale (e.g. the period was deleted), the entries effect - // then refires with the corrected scope. + // immediately instead of waiting for the period list (the common + // returning-user case without a seed). The validation below still + // re-scopes to the current räkenskapsår if the saved id went stale (e.g. + // the period was deleted); the entries effect then refires with the + // corrected scope. if (stored) { // FISCAL_YEAR_ALL_VALUE = user explicitly chose "all years", respect it. setPeriodId(stored === FISCAL_YEAR_ALL_VALUE ? null : stored) setPeriodHydrated(true) } - let cancelled = false - ;(async () => { - let fetched: FiscalPeriod[] = [] - try { - const res = await fetch('/api/bookkeeping/fiscal-periods') - if (res.ok) { - const { data } = await res.json() - fetched = (data || []) as FiscalPeriod[] - } - } catch { - // Non-critical: fall through with an empty list (scope stays "all years"). - } - if (cancelled) return + // Not cached yet and no seed: wait for the list, this effect re-runs. + if (fiscalPeriodsLoading) return + periodScopeResolvedForRef.current = company.id - if (stored === FISCAL_YEAR_ALL_VALUE) return - if (stored && fetched.some((p) => p.id === stored)) return + if (stored === FISCAL_YEAR_ALL_VALUE) return + if (stored && fiscalPeriods.some((p) => p.id === stored)) return - // No (valid) saved scope → default to the current räkenskapsår. - const today = new Date().toISOString().split('T')[0] - setPeriodId(resolveCurrentPeriodId(fetched, today)) - setPeriodHydrated(true) - })() - return () => { - cancelled = true - } - }, [company?.id]) + // No (valid) saved scope → default to the current räkenskapsår. + const today = new Date().toISOString().split('T')[0] + setPeriodId(resolveCurrentPeriodId(fiscalPeriods, today)) + setPeriodHydrated(true) + }, [company?.id, fiscalPeriods, fiscalPeriodsLoading]) // Debounce the free-text search before it reaches the API. Require ≥2 chars: // a single character matches almost every verifikationstext and isn't a useful diff --git a/components/common/FiscalYearSelector.tsx b/components/common/FiscalYearSelector.tsx index c7d36194..1cb615d7 100644 --- a/components/common/FiscalYearSelector.tsx +++ b/components/common/FiscalYearSelector.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { useTranslations } from 'next-intl' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' @@ -13,12 +13,15 @@ import { } from '@/components/ui/select' import { Lock } from 'lucide-react' import { useCompany } from '@/contexts/CompanyContext' +import { STORAGE_KEY_PREFIX, ALL_YEARS_VALUE } from '@/components/common/fiscal-year-storage' +import { useFiscalPeriods } from '@/lib/reference-data/hooks' +import { prepareFiscalPeriods, resolveInitialFiscalScope } from '@/lib/reference-data/fiscal-scope' import type { FiscalPeriod } from '@/types' -// Exported so other surfaces (e.g. JournalEntryList's filter dialog) can read -// and write the same persisted selection without duplicating the magic string. -export const STORAGE_KEY_PREFIX = 'Accounted:fiscal-year:' -export const ALL_YEARS_VALUE = '__all__' +// Re-exported so other surfaces (e.g. JournalEntryList's filter dialog) can +// read and write the same persisted selection without duplicating the magic +// string. The values live in fiscal-year-storage.ts (dependency-free). +export { STORAGE_KEY_PREFIX, ALL_YEARS_VALUE } interface Props { /** @@ -56,13 +59,6 @@ interface Props { initialCompanyId?: string | null } -function preparePeriods(periods: FiscalPeriod[], hideFuturePeriods: boolean): FiscalPeriod[] { - const today = new Date().toISOString().split('T')[0] - return periods - .filter((period) => !hideFuturePeriods || period.period_start <= today) - .sort((a, b) => b.period_start.localeCompare(a.period_start)) -} - /** * Shared fiscal-year (räkenskapsår) selector. * @@ -86,11 +82,16 @@ export function FiscalYearSelector({ }: Props) { const { company } = useCompany() const t = useTranslations('fiscal_year') + // Session-cached and seeded by the dashboard layout (see FyPicker): the + // restore runs in the first effect tick on a normal visit, no round trip. + const { periods: cachedPeriods, isLoading } = useFiscalPeriods() const canUseInitialPeriods = initialCompanyId === company?.id && initialPeriods !== undefined - const [periods, setPeriods] = useState(() => - canUseInitialPeriods ? preparePeriods(initialPeriods, hideFuturePeriods) : [], + const periods = useMemo( + () => prepareFiscalPeriods(canUseInitialPeriods ? initialPeriods : cachedPeriods, hideFuturePeriods), + [canUseInitialPeriods, initialPeriods, cachedPeriods, hideFuturePeriods], ) - const [loaded, setLoaded] = useState(canUseInitialPeriods) + const loaded = canUseInitialPeriods || !isLoading + const restoredForRef = useRef(null) const effectiveLabel = label === null ? null : (label ?? t('label')) useEffect(() => { @@ -100,52 +101,21 @@ export function FiscalYearSelector({ onReady?.() return } - let cancelled = false - ;(async () => { - let fetched: FiscalPeriod[] - if (initialCompanyId === company.id && initialPeriods !== undefined) { - fetched = preparePeriods(initialPeriods, hideFuturePeriods) - } else { - const res = await fetch('/api/bookkeeping/fiscal-periods') - if (!res.ok) { - if (!cancelled) { - setLoaded(true) - onReady?.() - } - return - } - const { data } = await res.json() - fetched = preparePeriods(data || [], hideFuturePeriods) - } - if (cancelled) return + if (!loaded || restoredForRef.current === company.id) return + restoredForRef.current = company.id - setPeriods(fetched) - setLoaded(true) - - // Restore last selection (only if caller hasn't already set a value). - // localStorage access is guarded because this is a 'use client' component - // but still runs during SSR on first render for some setups. - if (value === null && typeof window !== 'undefined') { - const stored = window.localStorage.getItem(STORAGE_KEY_PREFIX + company.id) - if (stored === ALL_YEARS_VALUE) { - if (includeAllOption) onChange(null, null) - else if (fetched.length > 0) onChange(fetched[0].id, fetched[0]) - } else if (stored && fetched.some((p) => p.id === stored)) { - onChange(stored, fetched.find((p) => p.id === stored) ?? null) - } else if (!includeAllOption && fetched.length > 0) { - onChange(fetched[0].id, fetched[0]) - } - } - - onReady?.() - })() - return () => { - cancelled = true + // Restore last selection (only if caller hasn't already set a value). + if (value === null && typeof window !== 'undefined') { + const stored = window.localStorage.getItem(STORAGE_KEY_PREFIX + company.id) + const pick = resolveInitialFiscalScope(periods, stored, { includeAllOption }) + if (pick) onChange(pick.periodId, pick.period) } - // onReady is intentionally excluded from deps: it's a lifecycle callback that - // should fire once per load, not re-trigger if the parent re-creates it. + + onReady?.() + // onReady/onChange are lifecycle callbacks that fire once per load, not + // again when the parent re-creates them. `value` is read once at restore. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [company?.id, hideFuturePeriods, includeAllOption, initialCompanyId, initialPeriods]) + }, [company?.id, loaded, periods]) const handleChange = (next: string) => { const nextPeriodId = next === ALL_YEARS_VALUE ? null : next diff --git a/components/common/FyPicker.tsx b/components/common/FyPicker.tsx index 708bf97f..c6f92268 100644 --- a/components/common/FyPicker.tsx +++ b/components/common/FyPicker.tsx @@ -1,13 +1,12 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { useTranslations } from 'next-intl' import { useCompany } from '@/contexts/CompanyContext' import { ContextPicker } from '@/components/common/ContextPicker' -import { - STORAGE_KEY_PREFIX, - ALL_YEARS_VALUE, -} from '@/components/common/FiscalYearSelector' +import { STORAGE_KEY_PREFIX, ALL_YEARS_VALUE } from '@/components/common/fiscal-year-storage' +import { useFiscalPeriods } from '@/lib/reference-data/hooks' +import { prepareFiscalPeriods, resolveInitialFiscalScope } from '@/lib/reference-data/fiscal-scope' import type { FiscalPeriod } from '@/types' interface FyPickerProps { @@ -73,13 +72,6 @@ interface FyPickerProps { className?: string } -function preparePeriods(periods: FiscalPeriod[], hideFuturePeriods: boolean): FiscalPeriod[] { - const today = new Date().toISOString().split('T')[0] - return periods - .filter((p) => !hideFuturePeriods || p.period_start <= today) - .sort((a, b) => b.period_start.localeCompare(a.period_start)) -} - /** * Fiscal-year context picker (UI-migration plan PR 3): the chip-dropdown * "Räkenskapsår 2026" with a check on the active choice and closed/locked @@ -103,76 +95,48 @@ export function FyPicker({ }: FyPickerProps) { const { company } = useCompany() const t = useTranslations('fiscal_year') + // Session-cached and seeded by the dashboard layout, so on a normal visit + // the list is already here on the first render: the restore below runs in + // the first effect tick and onReady fires without a network round trip. + // initialPeriods remains an explicit override for server-rendered pages. + const { periods: cachedPeriods, isLoading } = useFiscalPeriods() const canUseInitial = initialCompanyId === company?.id && initialPeriods !== undefined - const [periods, setPeriods] = useState(() => - canUseInitial ? preparePeriods(initialPeriods, hideFuturePeriods) : [], + const periods = useMemo( + () => prepareFiscalPeriods(canUseInitial ? initialPeriods : cachedPeriods, hideFuturePeriods), + [canUseInitial, initialPeriods, cachedPeriods, hideFuturePeriods], ) - const [loaded, setLoaded] = useState(canUseInitial) + const loaded = canUseInitial || !isLoading + // Restore once per company load, not on every background revalidation of + // the cached list (which would re-fire onChange/onReady mid-session). + const restoredForRef = useRef(null) useEffect(() => { if (!company?.id) { onReady?.() return } - let cancelled = false - ;(async () => { - let fetched: FiscalPeriod[] - if (initialCompanyId === company.id && initialPeriods !== undefined) { - fetched = preparePeriods(initialPeriods, hideFuturePeriods) - } else { - const res = await fetch('/api/bookkeeping/fiscal-periods') - if (!res.ok) { - if (!cancelled) { - setLoaded(true) - onReady?.() - } - return - } - const { data } = await res.json() - fetched = preparePeriods(data || [], hideFuturePeriods) - } - if (cancelled) return + if (!loaded || restoredForRef.current === company.id) return + restoredForRef.current = company.id - setPeriods(fetched) - setLoaded(true) - - // Restore last selection (same key as FiscalYearSelector so pages keep - // their scope when the picker swaps in). - // - // requireExplicitChoice gates this WHOLE block, not individual branches: - // every path in here ends in an unprompted onChange (restore, the - // ALL_YEARS-stored fallback, newest-period, preferLatestEnded), and a - // per-branch gate already missed one of them once. Nothing auto-fires; - // the picker stays empty until a human picks. - if (value === null && !requireExplicitChoice && !suppressAutoRestore && typeof window !== 'undefined') { - if (preferLatestEnded) { - // Filing surfaces: ignore the shared scope memory and open on the - // most recently ended period (fetched is sorted newest-first). - const today = new Date().toISOString().split('T')[0] - const pick = fetched.find((p) => p.period_end < today) ?? fetched[0] - if (pick) onChange(pick.id, pick) - } else { - const stored = window.localStorage.getItem(storageKeyPrefix + company.id) - if (stored === ALL_YEARS_VALUE) { - if (includeAllOption) onChange(null, null) - else if (fetched.length > 0) onChange(fetched[0].id, fetched[0]) - } else if (stored && fetched.some((p) => p.id === stored)) { - onChange(stored, fetched.find((p) => p.id === stored) ?? null) - } else if (!includeAllOption && fetched.length > 0) { - onChange(fetched[0].id, fetched[0]) - } - } - } - - onReady?.() - })() - return () => { - cancelled = true + // Restore last selection (same key as FiscalYearSelector so pages keep + // their scope when the picker swaps in). + // + // requireExplicitChoice gates this WHOLE block, not individual branches: + // every path in here ends in an unprompted onChange (restore, the + // ALL_YEARS-stored fallback, newest-period, preferLatestEnded), and a + // per-branch gate already missed one of them once. Nothing auto-fires; + // the picker stays empty until a human picks. + if (value === null && !requireExplicitChoice && !suppressAutoRestore && typeof window !== 'undefined') { + const stored = window.localStorage.getItem(storageKeyPrefix + company.id) + const pick = resolveInitialFiscalScope(periods, stored, { includeAllOption, preferLatestEnded }) + if (pick) onChange(pick.periodId, pick.period) } - // onReady is a lifecycle callback: fire once per load, not on parent - // re-renders that re-create it. + + 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, hideFuturePeriods, includeAllOption, preferLatestEnded, requireExplicitChoice, suppressAutoRestore, initialCompanyId, initialPeriods, storageKeyPrefix]) + }, [company?.id, loaded, periods]) const handleChange = (id: string) => { const nextId = id === ALL_YEARS_VALUE ? null : id diff --git a/components/common/fiscal-year-storage.ts b/components/common/fiscal-year-storage.ts new file mode 100644 index 00000000..d6ada222 --- /dev/null +++ b/components/common/fiscal-year-storage.ts @@ -0,0 +1,9 @@ +/** + * Persisted fiscal-year scope: the localStorage key prefix (companyId is + * appended) and the sentinel for an explicit "all years" choice. Lives in + * its own dependency-free module so lib/ code (the reference-data scope + * resolver) can import it without pulling a React component along. + * FiscalYearSelector re-exports both for existing importers. + */ +export const STORAGE_KEY_PREFIX = 'Accounted:fiscal-year:' +export const ALL_YEARS_VALUE = '__all__' diff --git a/components/transactions/BankSyncNowButton.tsx b/components/transactions/BankSyncNowButton.tsx index fd038172..7176a608 100644 --- a/components/transactions/BankSyncNowButton.tsx +++ b/components/transactions/BankSyncNowButton.tsx @@ -15,6 +15,7 @@ import { } from '@/components/ui/dropdown-menu' import { createClient } from '@/lib/supabase/client' import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import { claimConnectionsLoad, clearBusyConnection, @@ -162,6 +163,9 @@ export function useBankSync() { // Tell the neighbouring status chip to refetch so it doesn't keep showing // the pre-sync "synced Nd ago" until a hard reload. notifyBankSyncUpdated() + // The account chooser reads cash accounts from the session cache + // (lib/reference-data); a sync can change balances, so refetch them. + void invalidateReferenceData('ref:cash-accounts') router.refresh() } catch (error) { toast({ diff --git a/lib/reference-data/__tests__/fiscal-scope.test.ts b/lib/reference-data/__tests__/fiscal-scope.test.ts new file mode 100644 index 00000000..338aa4d4 --- /dev/null +++ b/lib/reference-data/__tests__/fiscal-scope.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest' +import { prepareFiscalPeriods, resolveInitialFiscalScope } from '../fiscal-scope' +import { ALL_YEARS_VALUE } from '@/components/common/fiscal-year-storage' +import type { FiscalPeriod } from '@/types' + +const period = (id: string, start: string, end: string) => + ({ id, name: id, period_start: start, period_end: end }) as unknown as FiscalPeriod + +const p2024 = period('p2024', '2024-01-01', '2024-12-31') +const p2025 = period('p2025', '2025-01-01', '2025-12-31') +const p2026 = period('p2026', '2026-01-01', '2026-12-31') +const p2027 = period('p2027', '2027-01-01', '2027-12-31') +const TODAY = '2026-08-26' + +describe('prepareFiscalPeriods', () => { + it('sorts newest first and can hide periods that have not started', () => { + const all = prepareFiscalPeriods([p2024, p2027, p2026, p2025], false, TODAY) + expect(all.map((p) => p.id)).toEqual(['p2027', 'p2026', 'p2025', 'p2024']) + const started = prepareFiscalPeriods([p2024, p2027, p2026, p2025], true, TODAY) + expect(started.map((p) => p.id)).toEqual(['p2026', 'p2025', 'p2024']) + }) + + it('does not mutate the input', () => { + const input = [p2024, p2026] + prepareFiscalPeriods(input, false, TODAY) + expect(input.map((p) => p.id)).toEqual(['p2024', 'p2026']) + }) +}) + +describe('resolveInitialFiscalScope', () => { + const periods = prepareFiscalPeriods([p2024, p2025, p2026], false, TODAY) + + it('restores a persisted period that still exists', () => { + expect(resolveInitialFiscalScope(periods, 'p2025', { includeAllOption: true })).toEqual({ + periodId: 'p2025', + period: p2025, + }) + }) + + it('ignores a stale persisted id and falls back per the surface', () => { + expect(resolveInitialFiscalScope(periods, 'gone', { includeAllOption: true })).toBeNull() + expect(resolveInitialFiscalScope(periods, 'gone', { includeAllOption: false })).toEqual({ + periodId: 'p2026', + period: p2026, + }) + }) + + it('honours an explicit "all years" only where the surface allows it', () => { + expect(resolveInitialFiscalScope(periods, ALL_YEARS_VALUE, { includeAllOption: true })).toEqual({ + periodId: null, + period: null, + }) + expect(resolveInitialFiscalScope(periods, ALL_YEARS_VALUE, { includeAllOption: false })).toEqual({ + periodId: 'p2026', + period: p2026, + }) + }) + + it('with nothing stored: all-years surfaces stay unfiltered, others pick the newest', () => { + expect(resolveInitialFiscalScope(periods, null, { includeAllOption: true })).toBeNull() + expect(resolveInitialFiscalScope(periods, null, { includeAllOption: false })).toEqual({ + periodId: 'p2026', + period: p2026, + }) + }) + + it('preferLatestEnded opens on the most recently ended period and ignores the stored choice', () => { + expect( + resolveInitialFiscalScope(periods, 'p2024', { includeAllOption: false, preferLatestEnded: true, today: TODAY }), + ).toEqual({ periodId: 'p2025', period: p2025 }) + }) + + it('preferLatestEnded falls back to the newest period when none has ended', () => { + const onlyCurrent = prepareFiscalPeriods([p2026], false, TODAY) + expect( + resolveInitialFiscalScope(onlyCurrent, null, { includeAllOption: false, preferLatestEnded: true, today: TODAY }), + ).toEqual({ periodId: 'p2026', period: p2026 }) + }) + + it('returns null for an empty list on every path', () => { + expect(resolveInitialFiscalScope([], null, { includeAllOption: false })).toBeNull() + expect(resolveInitialFiscalScope([], ALL_YEARS_VALUE, { includeAllOption: false })).toBeNull() + expect(resolveInitialFiscalScope([], 'x', { includeAllOption: false, preferLatestEnded: true })).toBeNull() + expect(resolveInitialFiscalScope([], ALL_YEARS_VALUE, { includeAllOption: true })).toEqual({ + periodId: null, + period: null, + }) + }) +}) diff --git a/lib/reference-data/fiscal-scope.ts b/lib/reference-data/fiscal-scope.ts new file mode 100644 index 00000000..acd4c058 --- /dev/null +++ b/lib/reference-data/fiscal-scope.ts @@ -0,0 +1,75 @@ +/** + * Initial fiscal-year scope resolution shared by FyPicker and + * FiscalYearSelector. Pure so the restore rules (persisted choice, "all + * years", newest started period, most recently ended period) can be tested + * without React, and so both pickers cannot drift apart again. + */ + +import type { FiscalPeriod } from '@/types' +import { ALL_YEARS_VALUE } from '@/components/common/fiscal-year-storage' + +export function todayIso(): string { + return new Date().toISOString().split('T')[0] +} + +/** Newest first; optionally drops periods that have not started yet. */ +export function prepareFiscalPeriods( + periods: readonly FiscalPeriod[], + hideFuturePeriods: boolean, + today: string = todayIso(), +): FiscalPeriod[] { + return periods + .filter((p) => !hideFuturePeriods || p.period_start <= today) + .sort((a, b) => b.period_start.localeCompare(a.period_start)) +} + +export interface FiscalScopeOptions { + /** Whether "all years" (null) is a valid selection on this surface. */ + includeAllOption: boolean + /** + * Filing surfaces: ignore the persisted choice and open on the most + * recently ended period (only an ended year can be declared). + */ + preferLatestEnded?: boolean + today?: string +} + +export interface FiscalScopePick { + periodId: string | null + period: FiscalPeriod | null +} + +/** + * What the picker should select on load, or null when nothing should be + * auto-selected. `periods` must already be prepared (newest first); + * `stored` is the raw persisted value for this company (or null). + */ +export function resolveInitialFiscalScope( + periods: readonly FiscalPeriod[], + stored: string | null, + options: FiscalScopeOptions, +): FiscalScopePick | null { + const newest = periods[0] ?? null + + if (options.preferLatestEnded) { + const today = options.today ?? todayIso() + const pick = periods.find((p) => p.period_end < today) ?? newest + return pick ? { periodId: pick.id, period: pick } : null + } + + if (stored === ALL_YEARS_VALUE) { + if (options.includeAllOption) return { periodId: null, period: null } + return newest ? { periodId: newest.id, period: newest } : null + } + + if (stored) { + const match = periods.find((p) => p.id === stored) + if (match) return { periodId: match.id, period: match } + } + + if (!options.includeAllOption && newest) { + return { periodId: newest.id, period: newest } + } + + return null +} diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json index 6690f40d..3dffb7fb 100644 --- a/scripts/checks/antipatterns-baseline.json +++ b/scripts/checks/antipatterns-baseline.json @@ -34,7 +34,7 @@ ] }, "rawReferenceFetch": { - "count": 55, + "count": 51, "files": [ "app/(dashboard)/assets/[id]/dispose/page.tsx", "app/(dashboard)/bookkeeping/year-end/page.tsx", @@ -47,7 +47,6 @@ "app/(dashboard)/salary/employees/[id]/page.tsx", "app/(dashboard)/salary/runs/[id]/page.tsx", "app/(dashboard)/supplier-invoices/[id]/page.tsx", - "app/(dashboard)/transactions/page.tsx", "components/articles/ArticleForm.tsx", "components/bookkeeping/BookingTemplatePicker.tsx", "components/bookkeeping/ChartOfAccounts.tsx", @@ -55,12 +54,9 @@ "components/bookkeeping/CorrectionEntryDialog.tsx", "components/bookkeeping/EditAccountDialog.tsx", "components/bookkeeping/JournalEntryForm.tsx", - "components/bookkeeping/JournalEntryList.tsx", "components/bookkeeping/StrikeLinesDialog.tsx", "components/bookkeeping/TemplateBookDialog.tsx", "components/common/CashAccountSelector.tsx", - "components/common/FiscalYearSelector.tsx", - "components/common/FyPicker.tsx", "components/dimensions/types.ts", "components/extensions/general/ArcimMigrationWorkspace.tsx", "components/extensions/general/BookDirectlyDialog.tsx",