Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
3.9 KiB
Admin dashboard data caching (TanStack Query)
The admin dashboard (app/(dashboard)/**) is being migrated to TanStack Query
v5, the same library already used by the consumer app (see
docs/consumer-caching.md). Read that doc first for the shared basics
(lib/queryClient.ts, QueryClientProvider in app/providers.tsx,
unwrapService). This doc only covers where the admin side intentionally
differs.
queries/admin/adminKeys.ts is the admin key factory
Parallel to queries/consumerKeys.ts, scoped under ['admin', ...] instead
of ['consumer', ...]. One hook file per data domain lives in
queries/admin/*.ts, same shape as queries/consumer/*.ts.
PaginatedList owns one useQuery internally -- most admin pages need no changes
17 of the 20 admin sections render data through components/PaginatedList.tsx,
passing only url/itemsKey/columns/urlParams. That component now runs
a single useQuery internally, keyed on adminKeys.list(url, resolvedParams)
where resolvedParams is the exact {page, pageSize, sort, filters, ...}
object sent to the server (from buildListParams). Callers don't need their
own query hook for the list itself -- just keep passing url/columns as
before. refresh() on the PaginatedListHandle ref now does
queryClient.invalidateQueries({ queryKey: adminKeys.listByUrl(url) })
instead of a local re-render counter. The imperative method remains available
for non-row workflows, while row actions use useAdminMutation directly as
described below.
Why useQuery, not useInfiniteQuery: unlike every consumer list
(consumer-caching.md's "always infinite query" rule), PaginatedList is a
page-jump table -- page, pageSize, totalItemsCount, not an accumulating
feed. useInfiniteQuery doesn't fit that shape. placeholderData: keepPreviousData is used instead, so changing page/sort/filter shows the
previous page's rows (not a blank table) while the new page loads.
Why the fetch itself doesn't need a services/*/ServiceResult<T>
wrapper: components/paginated-list/paginatedListApi.ts's
fetchPaginatedList already calls axiosInstance directly and either
resolves { items, pagination } or throws -- exactly what a bare queryFn
wants. Only genuine cancellation (CanceledError/AbortError, from
useQuery's own signal) is rethrown as-is so TanStack's built-in
cancellation handling still applies; every other error is normalized through
handleServiceError(error, { errorMode: 'silent' }) before being thrown, so
query.error.message still gives the same localized text the old
loadError state used to.
hooks/useAdminMutation.ts owns admin row mutations and list invalidation
The identity-verifications, reviews, settlements, user-reports, and
withdrawal-requests sections use one mutation shape. The hook preserves the
existing runAction(id, action, successMessage) call-site and exposes a
per-row pendingId, while its successful mutation callback invalidates
adminKeys.listByUrl(url). Pages therefore do not need to thread a
PaginatedListHandle ref through action callbacks just to refresh the list.
runAction resolves to true on success and false after a handled failure.
Modal actions use that result to close/reset the modal only after the server
accepts the mutation. Axios/service errors are normalized in the hook and
shown once through the shared toast copy.
Raw-axiosInstance mutations don't need a service-layer rewrite first
Most non-CRUD admin actions (approve/reject/hide/restore/etc.) call
axiosInstance directly with no services/*.ts wrapper. Axios already
throws on non-2xx, which is exactly what a bare mutationFn wants -- there's
no need to introduce a ServiceResult<T> service function before writing the
mutation hook. Reserve unwrapService(await X(..., { errorMode: 'parent' }))
for the sections that already have a real service (tags, blog-articles,
event-categories, admin user detail/edit, wallet credit, geography).