perf(bookkeeping): fiscal-year pickers and cash accounts read the session cache (#1934)
First consumer migration onto lib/reference-data. FyPicker and
FiscalYearSelector (14 consumer surfaces, 47 fiscal-period fetch sites
before this series) now read useFiscalPeriods(); with the layout seed the
restore of the persisted scope runs in the first effect tick and onReady
fires on mount instead of after a round trip. Their restore rules are
extracted into a pure resolveInitialFiscalScope() (lib/reference-data/
fiscal-scope.ts) so the two pickers cannot drift apart again, and the
restore runs once per company load, not on every background revalidation.
- /reports: the static catalog renders immediately; only the "no fiscal
year" empty state waits for the picker (previously six skeleton bars
until /api/bookkeeping/fiscal-periods resolved).
- JournalEntryList (/bookkeeping): resolves its initial scope from the
cached list instead of its own fetch; the saved-scope shortcut still
unblocks the entries fetch first when nothing is cached, and resolution
is guarded to once per company so a revalidation can never snap a
deep-link "all years" visit back to the stored year.
- /transactions: the account chooser reads useCashAccounts({ enabledOnly })
(seeded) instead of fetching /api/cash-accounts on every visit; the bank
sync button invalidates that entry after a sync.
- STORAGE_KEY_PREFIX / ALL_YEARS_VALUE move to a dependency-free
fiscal-year-storage.ts (re-exported from FiscalYearSelector) so lib/ code
can import them without a React component.
raw-reference-fetch ratchet: 55 -> 51 files.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
b436d47b64
commit
9a56b7aff9
@@ -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<string | null>(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
|
||||
|
||||
@@ -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<FiscalPeriod[]>(() =>
|
||||
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<string | null>(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
|
||||
|
||||
@@ -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<FiscalPeriod[]>(() =>
|
||||
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<string | null>(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
|
||||
|
||||
@@ -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__'
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user