# Admin backoffice + partner portal (client/src/app/[locale]/(private-routes)/admin + /partner, client/src/components/admin, client/src/layout)

## Current state

The backoffice is in far better shape than "15 hand-rolled tables": there is a real shared primitive layer in client/src/components/admin — AdminDataTable (typed columns, dense, horizontal-scroll container, align defaults to 'inherit' for RTL), AdminPageHeader, AdminPager (prev/next only), AdminEmptyState, AdminErrorState, and a genuinely good ConfirmDialog (required-reason gating, loading-disables-buttons, destructive color) — and every console actually uses them. Every list page follows the same skeleton→error→empty→table/cards branch, filters live in the page-header actions slot or a bordered filter row (tickets/audit use a draft-vs-applied Apply pattern), and status colors flow through the shared StatusChip whose colors resolve from --bal-* semantic tokens (dark scheme covered in src/theme/tokens.css; I found zero hard-coded hexes anywhere in the admin/partner surface). Specialized composites (RefundPanel, DocumentViewer with on-demand signed-URL re-request, AdminMessageBubble with visually distinct internal notes, SupportAlertCard with a severity borderInlineStart accent, ConfigRow, AuditLogRow with an expandable field diff, PartnerSettlementRow with VAT-on-commission PriceBreakdown) cover the domain-heavy screens. Money renders as Toman via shared utils, dates as Shamsi, IBANs/reference codes are dir="ltr"-wrapped, and PII discipline (write-then-masked IBAN, never-echoed credential numbers, non-leaking partner access-denied state) is visible in the UI code itself.

What drags it down is everything around the pages. The shell is the untouched open-source starter: TopBarAndSideBarLayout renders a default MUI AppBar with a centered nowrap title, a logo IconButton that doubles as the sidebar opener with a hard-coded English 'Open Sidebar' tooltip, physical left/right anchor constants in layout/config.ts, physical paddingLeft/Right compensation, and an 8px page gutter with no content max-width — the classic old-MUI-example look the owner already flagged, and the admin console inherits it wholesale. Worse, sidebar navigation is functionally degraded: SideBarNavItem computes selection via pathname.startsWith(path) against a locale-less ROUTES path while next-intl (localePrefix always, confirmed in client/middleware.ts) prefixes every pathname with /fa or /en — so the active console is never highlighted, and every sidebar click first hits a locale-normalization redirect. Above that, admin workflow affordances are thin: no filter/page state in the URL (back/refresh loses queue position), no sorting or text search on queues (verification filters only by status; tickets has no date/assignee/updated column), the pager indicator says only "صفحه {page}" with no total, users/notifications routes are PlaceholderScreens (notifications is a live sidebar item for every admin), config and holidays fetch page 1 forever with no pager, and people are everywhere referred to as raw numeric IDs — role grants, partner-center admin assignment, and nurse-roster assignment are typed into bare number inputs with no lookup. The partner portal is a small, clean, read-only surface (home/nurses/bookings/settlement on the same primitives) whose one glaring defect is rendering raw English snake_case wire codes as booking statuses to Persian-speaking center staff.

## Problems (20)

- **[high]** `client/src/layout/components/SideBarNavItem.tsx` — Active-item highlighting in the admin/partner sidebar never fires: selection compares a locale-less ROUTES path against the locale-prefixed pathname from next/navigation usePathname (localePrefix is always-on per client/middleware.ts), so startsWith is always false. Side effect: sidebar links navigate to locale-less URLs and eat a 307 locale redirect on every click.
  - evidence: line 28: `const selected = propSelected || (path && path.length > 1 && pathname.startsWith(path)) || false;` — pathname is '/fa/admin/…', path is '/admin/…'
- **[high]** `client/src/layout/TopBarAndSideBarLayout.tsx` — The whole backoffice sits in the untouched starter shell: default-blue-shadow MUI AppBar with a centered nowrap title, logo IconButton doubling as the sidebar opener, physical paddingLeft/paddingRight compensation keyed on anchor.includes('left') (works only by grace of stylis-plugin-rtl), and an 8px content gutter with no max-width — a dense worklist page starts 8px from the viewport edge and reads as an old MUI example, not a branded console.
  - evidence: lines 53-60 physical padding keyed on anchor strings; line 71 hard-coded `'Open Sidebar'` tooltip; line 102 `paddingLeft: 1, paddingRight: 1` main gutter
- **[high]** `client/src/app/[locale]/(private-routes)/partner/bookings/page.tsx` — Partner-facing booking statuses render as raw English snake_case wire codes ('pending_payment', 'in_progress') in both the filter menu and the table chip — an untranslated, un-StatusChip'd surface shown to external Persian-speaking center staff, breaking the app-wide localized-status-chip convention.
  - evidence: line 17 comment 'labels are the codes themselves'; line 45 `<Chip … label={b.status} />`; lines 66-69 `<MenuItem …>{s}</MenuItem>`
- **[high]** `client/src/app/[locale]/(private-routes)/admin/roles/page.tsx` — High-stakes audited actions target users by hand-typed numeric ID with no lookup or name echo-back: role grants here, partner-center admin assignment and sponsored-nurse assignment in partners pages. One mistyped digit grants super_admin to the wrong account or links the wrong nurse — and there is no Users console to even look an ID up (users page is a placeholder).
  - evidence: GrantRoleDialog lines 176-182 `<TextField label={t('role_col_user')} type="number" …>`; same pattern in partners/[id]/page.tsx lines 226-245 (assign nurse) and partners/page.tsx lines 238-244 (adminUserId)
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/alerts/page.tsx` — Assign-to-self silently falls back to user ID 1 when the current user isn't hydrated — an alert could be assigned to whoever user #1 is instead of the acting admin.
  - evidence: line 36: `const meId = authState.currentUser?.id ?? 1;`
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/config/page.tsx` — Config list and its history drawer are hard-wired to page 1 with no pager — any platform_configs rows beyond the first page are invisible and uneditable from the UI. Same defect on holidays (useHolidays({}, 1)), where the calendar grows every year and will silently truncate.
  - evidence: line 63 `usePlatformConfigs(1)`; line 182 `useConfigChangeHistory(configKey, 1, …)`; holidays/page.tsx line 38 `useHolidays({}, 1)`
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/tickets/page.tsx` — No admin filter/page state is URL-synced anywhere in the backoffice: applied filters and page live in component state, so browser back from a ticket, a refresh, or sharing a link with a colleague loses the queue position — detail pages even hand-roll 'back' buttons that router.push to the bare list. For a worklist tool this is a daily-use tax.
  - evidence: lines 39-41 `useState<AdminTicketFilters>` + `useState(1)` with no searchParams; tickets/[id]/page.tsx line 90 `router.push(`/${locale}${ROUTES.ADMIN_TICKETS}`)`
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx` — The admin ticket thread has no status controls at all — no close/reopen and no assignee (services/tickets exposes no close mutation), so a resolved case can never leave the 'open' queue from the UI; and the message list has no scroll-to-latest, so long threads open scrolled to the oldest message.
  - evidence: lines 118-127 render only chips for status; hooks dir has useAdminTicket/usePostAdminMessage but no close/assign hook; line 150 plain `maxHeight: 520, overflowY: 'auto'` Box with no scroll anchoring
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx` — The internal-note vs public-reply distinction exists only as a small ToggleButtonGroup above the composer; the composer itself looks identical in both modes and the send button label never changes — an admin can easily post an internal note publicly. The bubbles are well-differentiated after the fact, but the safety cue is needed before send.
  - evidence: lines 163-190: ToggleButtonGroup + plain TextField + single `{t('ticket_send')}` button; only the placeholder string changes with mode
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/verification/page.tsx` — The highest-traffic trust queue has no text search (nurse name/phone), no sorting, no queue counts, and no age/SLA signal — only a 3-value status select. The desk cannot prioritize by 'waiting longest' or find a specific applicant; submittedAt renders but is not sortable.
  - evidence: lines 101-117: the only filter is a status TextField select; AdminDataTable (components/admin/AdminDataTable.tsx) has no sort affordance at all
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/audit/page.tsx` — Every date input across the backoffice is a native Gregorian type="date" field (audit from/to, payout preview window, holiday date, credential issued/expires) while every displayed date is Shamsi — Iranian ops staff must mentally convert calendars to filter or enter data; there is no Jalali picker anywhere.
  - evidence: lines 63-78 two `type="date"` fields; payouts/page.tsx lines 235-250; holidays/page.tsx lines 139-146; verification/[nurseId]/page.tsx lines 415-433
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/payouts/[batchId]/page.tsx` — Detail-page headers follow four different patterns: verification case uses AdminPageHeader + back link, ticket thread has no page header (h6 inside a Paper), payout batch hand-rolls a raw h5, partner-center detail hand-rolls h5 + chips and its back button misuses the 'partners' icon as a back glyph. No shared detail-header/breadcrumb primitive exists.
  - evidence: lines 52-64 raw `Typography variant="h5"`; partners/[id]/page.tsx lines 80-90 `startIcon="partners"` on the back button; tickets/[id]/page.tsx line 110 h6-in-Paper
- **[medium]** `client/src/components/admin/AdminPager.tsx` — The pager shows only the current page with no total ('صفحه ۳'), even though every caller computes pageCount; there is no total-results count, page-size control, or jump — weak for an ops tool paging 20-25 rows at a time through large queues.
  - evidence: client/messages/fa.json line 1194 `"page_indicator": "صفحه {page}"` (the non-admin namespace at line 992 has 'صفحه {page} از {total}' — the admin one lost the total)
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/notifications/page.tsx` — The notifications sidebar item is shown to every admin (show: true in AdminLayout) but leads to a PlaceholderScreen dead-end; the users route is likewise a placeholder — starter-style stub screens shipped inside a production nav.
  - evidence: whole file is `<PlaceholderScreen …/>`; AdminLayout.tsx line 34 `{ title: t('notifications'), …, show: true }`; users/page.tsx same placeholder
- **[medium]** `client/src/components/admin/AuditLogRow.tsx` — Actors and owners render as bare '#42' style numeric IDs across audit rows, alert cards, role grants, and payout previews — investigating 'who did this' requires leaving the tool; there is no name-resolution layer or link-to-user anywhere.
  - evidence: line 48 `{entry.actorUserId != null ? `#${entry.actorUserId}` : '—'}`; SupportAlertCard.tsx line 74 `#${alert.ownerUserId}`; roles/page.tsx line 60 `render: (g) => `#${g.userId}``
- **[low]** `client/src/layout/components/SideBar.tsx` — Hard-coded English strings in the fa-default shell chrome: the logout tooltip and the sidebar-open tooltip are untranslated literals.
  - evidence: line 76 `title="Logout Current User"`; TopBarAndSideBarLayout.tsx line 71 `'Open Sidebar'`
- **[low]** `client/src/components/admin/AuditLogRow.tsx` — The expandable diff row's chevron is static — it never rotates and the clickable header has no aria-expanded/button semantics, so open/closed state is invisible and the row isn't keyboard-toggleable.
  - evidence: line 53 `<AppIcon icon="expand" …/>` with no rotation transform; lines 38-42 onClick on a plain Stack
- **[low]** `client/src/app/[locale]/(private-routes)/admin/config/page.tsx` — Dead ternary on the config edit field type — both branches are 'text'.
  - evidence: line 152 `type={config.dataType === 'int' || config.dataType === 'decimal' ? 'text' : 'text'}`
- **[low]** `client/src/app/[locale]/(private-routes)/admin/holidays/page.tsx` — Misleading constant/comment: TODAY_ISO is an empty string yet the comment claims it is 'seeded below via state default' — a new holiday's date field simply starts blank.
  - evidence: line 106 `const TODAY_ISO = ''; // seeded below via state default so no Date at module load`
- **[low]** `client/src/app/[locale]/(private-routes)/admin/payouts/page.tsx` — The payout window default is computed with toISOString() (UTC), so near local midnight in Tehran the prefilled start/end dates are off by one day from the admin's wall-clock date.
  - evidence: line 56 `const isoDate = (d: Date): string => d.toISOString().slice(0, 10);`

## Opportunities (11)

- **Purpose-built backoffice shell to replace the starter chrome** (impact: high, effort: large) — One shell change fixes the whole area's first impression and daily ergonomics: slim top bar (breadcrumb trail + global search + bell), a collapsible rail sidebar with working active-state (use next-intl's locale-aware pathname/Link), content area with ~1440px max-width and 24px gutters, cream surface with the deep-teal rail. Fix the locale-less sidebar hrefs and the 'Open Sidebar'/'Logout' literals at the same time. Every one of the 21 pages inherits it for free.
- **useAdminListState: URL-synced filters + page as a shared hook** (impact: high, effort: medium) — A small hook that reads/writes filters and page to searchParams (the customer search flow already does this pattern) and is adopted by all nine queue pages. Fixes back-button/refresh/share-a-link wholesale, makes detail-page 'back' a real history back, and costs each page a two-line change since filter state is already isolated.
- **UserPicker/NursePicker autocomplete to kill raw-ID inputs** (impact: high, effort: medium) — One shared async Autocomplete (search by name/phone, renders name + masked phone + id, echoes the resolved name in the confirm dialog) dropped into role grant, partner admin assignment, sponsored-nurse assignment, and alert assignment. Turns the scariest wrong-target failure mode in the backoffice into a non-issue and gives the ConfirmDialog copy a human name instead of '#42'. Needs one small lookup endpoint (or the future users console's list API).
- **Workbench home: queue counts and aging on the console cards** (impact: high, effort: medium) — The admin overview cards are pure links today. Add per-console live counts (pending verifications, open tickets, unresolved alerts, reviews awaiting moderation, next payout window + eligible total) and an 'oldest waiting' age chip. This converts the landing page from a menu into the morning triage screen — the single highest-leverage screen for an ops team, and the card grid already exists.
- **Verification desk redesign (the flagship queue)** (impact: high, effort: large) — This is trust — the product — and worth real design: tab counts per status, name/phone search, sortable age column with SLA coloring, and a split-pane case view (queue rail + case detail) with keyboard next/prev so a reviewer never round-trips to the list between cases. The case page already has the right bones (StepCard, DocumentViewer, credential form); it needs the throughput layout around them, plus side-by-side document/identity comparison for the identity cross-check the docstring promises.
- **Ticket console: status/assignee lifecycle + safer internal mode** (impact: high, effort: medium) — Add close/reopen and assign-to-me on the thread header (needs the small backend mutation), unread/last-activity + assignee columns in the queue, scroll-to-latest on thread open, and make the composer visibly amber (warning-soft background + 'ثبت یادداشت داخلی' send label) whenever internal mode is on. Tickets are where refunds, emergencies, and coordination all converge — second-highest-traffic surface after verification.
- **Shared Jalali date picker** (impact: medium, effort: medium) — One JalaliDatePicker component (input + calendar in Shamsi, emits ISO Gregorian on the wire) replacing every native type="date" across audit filters, payout windows, holidays, and credential forms. Directly reduces data-entry errors for the finance and trust desks and removes the display/input calendar mismatch.
- **Payout run: explicit money-movement summary + typed confirmation** (impact: medium, effort: small) — The preview dialog lists eligible nurses but the final ConfirmDialog is generic copy. Show the batch total (sum to move), count, and processing date in the confirm step and require typing the amount or the word تایید for the run — the standard guard for an action that irreversibly moves money. Also localize the skipped-reason strings (currently raw server text rendered dir="ltr").
- **AdminDataTable v2: sorting, sticky header, total count** (impact: medium, effort: small) — Add optional per-column sort (server param already keyed by filter object), a sticky header for long pages, and a footer line 'نمایش ۱–۲۰ از ۱۲۴' wired to the total every caller already has — plus restore '{page} از {total}' in the admin pager message. Small changes to one component + one i18n line lift all eleven tables at once.
- **Partner portal polish: localized statuses, booking detail, invoice export** (impact: medium, effort: small) — Map the seven booking codes to the existing StatusChip kinds + fa labels (small, fixes the worst partner-facing defect), link a sponsored-booking row to a scoped read-only detail, and add a CSV/Excel export on settlement for the center's accountant — the actual consumer of that screen. Keeps the portal light-touch while making it feel finished.
- **Real Users console replacing the placeholder** (impact: medium, effort: large) — A read-first user directory (search by phone/name, role chips, verification state, links into their nurse/customer profile, tickets, bookings, audit trail) that becomes the hub the numeric IDs all over the backoffice can deep-link to. Pairs with the UserPicker endpoint; also gives the roles grid a place to launch from.

## Keep (do not regress)

- The shared composite layer itself — AdminDataTable/AdminPageHeader/AdminPager/AdminEmptyState/AdminErrorState/ConfirmDialog in client/src/components/admin are used by every console; there is one table, one pager, one empty/error/confirm pattern, all unit-tested. Any redesign should restyle these primitives, not fork per-page markup.
- ConfirmDialog's action-safety contract: every irreversible/audited action (approve/reject verification, moderate review, revoke role, run/retry payout, resolve alert, verify center) goes through it, with required-reason gating for reject/hide/resolve, loading that disables both buttons (double-submit-proof), and error color on destructive confirms.
- Token discipline and dark-mode-by-construction: zero hard-coded hexes in the entire admin/partner surface; StatusChip and all accents resolve from --bal-* semantic tokens defined for both schemes in src/theme/tokens.css, plus palette-aware 'divider'/'action.hover' everywhere else.
- RTL correctness in content code: borderInlineStart severity accents (SupportAlertCard, payout failure block), dir="ltr" wrappers on every IBAN/reference-code/latin-reason string, AdminDataTable's align:'inherit' default, and the config-history Drawer anchoring by locale.
- Trust/PII handling expressed in the UI: DocumentViewer fetches short-lived signed URLs on demand with an expired→re-request affordance; settlement IBAN is write-then-masked (blank field + masked placeholder, never echoed); credential numbers are accepted but never displayed; internal ticket notes are visually unmistakable (dashed warning border + badge) and isolated to admin types.
- Consistent loading/empty/error triad on every list page — skeleton stacks sized to the content, dashed-border empty states with domain icons, and an inline retry error panel; no page dumps a spinner-only or blank state.
- The draft-vs-applied filter pattern on tickets and audit (typing never refetches; Apply commits the query key) — the right behavior for server-keyed caches, worth spreading, not replacing.
- Server-authority posture: capability flags (useAdminCapabilities) only hide controls, money is never recomputed client-side (PriceBreakdown renders server decompositions; payout eligibility/holiday shift come from the server), and the roles console honestly banners its mock-backed status.
- Shamsi-first display formatting via formatShamsiDate/DateTime and Toman via formatIrrToToman across every admin and partner money/date render.
