# Pjax SPA-style Navigation — Admin Portal (backend)

**Status:** Phases 0–3 DONE for the tested modules (see per-module table) ·
**Branch:** tailwind-poc · **Created:** 2026-07-21

## ⚠️ CRITICAL FIX, read this first: `NavContentPjax`

Full writeup lives in [`PJAX_NAV_SHOP_PLAN.md`](PJAX_NAV_SHOP_PLAN.md) (found while
testing THIS portal, applies identically to both). One-line version: `yii\widgets\Pjax`
calls `$view->clear()` on every request it recognizes as its own — i.e. every
navigation click this feature produces — which silently wipes any `registerJs()` /
`registerJsFile()` / `registerCss()` the page already made, so a page's OWN inline
script never arrives at all (not "double-fires on revisit" — never fires, full stop).
Fixed by [`common/widgets/NavContentPjax.php`](../../common/widgets/NavContentPjax.php)
(shared with the shop portal); both layouts use it instead of the bare
`yii\widgets\Pjax`. **This is why the "inline AJAX toggle" audit below reads the way
it does** — the toggles were ALWAYS silently broken under Pjax before this fix (not a
new regression this work introduced), and needed BOTH the `NavContentPjax` fix (to run
at all) AND an idempotency guard (to not double-fire on the 2nd+ visit). Verified live:
`/shop`'s `.mode-toggle` confirm dialog fires exactly once on the 1st Pjax visit and
exactly once again on the 2nd.

## Phase 0 implementation log + verification (2026-07-21)

Built mirroring the shop portal, with one deliberate simplification: **no `AdminNav`
PHP helper was needed.** The shop needed `ShopNav::resolveActiveId()` because its
sidebar JS re-highlights by a synthetic `data-nav-id` looked up against a server-
rendered `data-active-nav-id`. The admin sidebar's `Menu.php` source has no stable
per-item id (it's a legacy AdminLTE-style array with a pre-computed boolean `active`
per node) — so the JS here re-highlights by comparing each `[data-pjax-nav]` link's
`href` to `window.location.pathname + search` directly, needing zero new PHP.

- `backend/views/layouts/_tw_admin_sidebar.php` — `$renderLeaf()` now emits
  `data-pjax-nav`, `data-{active,inactive}-class`, `data-icon-{active,inactive}-class`
  on every link (same data-attribute contract as the shop, mechanically copied).
- `backend/views/layouts/tailwind.php` — content wrapped in `Pjax::begin(['id' =>
  'tw-content-pjax', ...])`; the inner div carries `data-nav-title` (no
  `data-active-nav-id` needed, see above). New JS registered as a plain
  `<script defer>` tag (matching this layout's existing `ng-motion.js` convention —
  no AssetBundle, unlike the shop's TailwindAsset).
- `backend/web/js/aurora-pjax-nav.js` (NEW, admin-specific — NOT shared with the shop
  file via `sourcePath` like `aurora-core.js` is, because the re-highlight strategy
  differs): click→`$.pjax()`; on `pjax:end` (bound via `jQuery(document).on(...)`,
  NEVER native `addEventListener` — see the CRITICAL gotcha in the shop plan, applies
  identically here) re-inits icons, re-highlights by href match, **auto-opens the
  matched link's ancestor `<details>` group** (`link.closest('details').open = true`
  — the admin-specific piece the shop didn't need), syncs title, resets scroll.

**Bonus discovery confirmed: `aurora-core.js` is ALREADY shared cross-tier.**
`backend/assets/AuroraDialogAsset.php` publishes `frontend/web/js/aurora-core.js` via
`sourcePath` (one file, not a duplicate) — meaning the jQuery `pjax:end` fix already
applied to the SHOP copy of that file is picked up by admin for free, zero extra work.

**Verified live** (`/` ↔ `/shop` ↔ `/agent/index`, EN + AR):
- Response to a nav click is genuinely partial (`<title>...</title>` + the content div
  only, confirmed via raw response body — no `<html>`/sidebar markup at all).
- Sidebar link highlights correctly AND its parent `<details>` group auto-opens.
- Browser back re-highlights correctly (Dashboard un-highlights Shops, re-highlights
  itself) — same `pjax:end`-not-`pjax:success` mechanism as the shop, ported directly
  this time instead of rediscovering the bug.
- RTL: `dir="rtl"` preserved, title renders in Arabic ("تفاصيل الأخصائيين"), no console
  errors.
- Zero console errors across every step.

## Phase 1 findings (2026-07-21) — shared JS audit

**Much smaller surface than the shop.** Admin-tier JS files: `app.js` (legacy
AdminLTE, untouched by Pjax), `column-chooser.js` (registered via `BackendAsset`/
`DashboardAsset` for legacy `layout=base` pages only, never reachable through the
Pjax container — same as the shop's equivalent legacy files), `ng-motion.js`
(reviewed in full: single `document.addEventListener('click', ...)` — safe, 'click'
is a real native event unaffected by the jQuery custom-event bug, AND it's loaded via
a `<head>`-level `<script>` tag rather than a per-view `registerJsFile`, so it only
ever executes once regardless of Pjax nav — no re-binding risk at all), and the
reused `aurora-core.js` (already fixed via the shop work). **No admin equivalent of
`booking-calendar.js`'s complexity was found** — nothing in this tier's own JS binds
document-level listeners inside a per-view `registerJsFile` call the way Calendar
does, so there is no known admin module requiring exclusion the way Calendar is
excluded on the shop side. This should be re-confirmed per-module during Phase 2 as
each one is actually exercised (this is a review of the FILES that exist, not a
guarantee no view has an inline `<script>` with the same defect — inline
`registerJs()` snippets weren't separately inventoried).

## Phase 2 findings (2026-07-21) — the inline `registerJs()` inventory Phase 1 flagged

Ran it: `grep -rl "registerJs(" backend/views/ | xargs grep -l "addEventListener"` found
8 files. All had the SAME unguarded pattern (a delegated `document.addEventListener`
inside a per-view `registerJs()` call, with no idempotency guard) — exactly what
Phase 1 predicted might exist. Fixed 6, confirmed 1 out of scope, 1 already fixed:

- **Fixed** (added `if (!window.__ngXBound) { window.__ngXBound = true; ... }` around
  the listener): `backend/views/shop/index.php` (`.mode-toggle`, Live↔Demo — the one
  that surfaced the `NavContentPjax` bug during live testing),
  `backend/views/agent/index.php` (`.mode-toggle, .status-toggle`),
  `backend/views/user/index.php` (same, shared by `/user/index` + `/user/managers`),
  `backend/views/ads/index.php` (`.ads-delete`), `backend/views/city/index.php`
  (`[data-toggle-active]` — already an IIFE, guarded via an early `return` instead),
  `backend/views/shop/requests.php` (`.request-reject`).
- **Fixed differently** — `backend/views/push-notification/history.php`: NOT a
  delegated-click case. It binds directly to specific `[data-ng-notif]` buttons and
  `.ng-notif-modal` elements (not `document`-delegated), which is actually SAFE to
  re-run every visit (those elements are fresh swapped-in content each time, not the
  same nodes) — gating the WHOLE function would have BROKEN it (buttons stop opening
  their modal after the 1st visit). Only the one line that truly persists across
  swaps — `document.addEventListener('keydown', ...)` for Escape — needed its own
  narrower guard. Caught mid-fix by re-reading the code instead of copy-pasting the
  same pattern from the other 6 files — worth remembering as a general rule: **check
  whether a `registerJs()` block binds via delegation (safe to guard the whole thing)
  or binds directly to specific elements (must re-run every visit; only guard the
  truly-persistent, document/window-level listeners inside it).**
- **Out of scope, no fix**: `backend/views/withdrawal/_settlement_form.php` — rendered
  via `WithdrawalController::actionSettlementForm()`'s `$this->renderAjax('_settlement_form', ...)`,
  loaded into a modal via its own dedicated AJAX call, never part of the Pjax
  container's swapped content (same category as the shop's iframe-only files).

**Live-verified the toggle fix end-to-end** (`/shop`'s `.mode-toggle`): 1st Pjax visit
→ click → exactly one confirm dialog ("Change to Demo?"). Navigated away, back (2nd
Pjax visit) → click again → still exactly one dialog, not two — proving both the
`NavContentPjax` fix (makes the script arrive) and the idempotency guard (keeps it
from double-binding) are both necessary and both correct.

## Phase 3 regression results (2026-07-21)

Chain Dashboard → Shop Details → Booking Details → Transfer Requests, then 3× browser
back (landed correctly on Dashboard, exactly one sidebar item + its parent `<details>`
group correctly highlighted/opened, matching URL/title), then 2× forward (landed
correctly on Transfer Requests, same single-highlight correctness), then a hard reload
mid-chain (`/admin-finance/transfer-requests` direct load — sidebar present, container
present). Zero console errors at every step. Also re-verified the SHOP portal still
works correctly after the `NavContentPjax` swap (same widget, used by both layouts):
Finance tab-switching still fires, and `settings-notifications.js`'s preview-toggle
guard (`window.__ngNotifPreviewBound`) now reads `true` on the shop side too — meaning
that fix was ALSO silently non-functional before `NavContentPjax`, and is now for real.

## Goal

Same objective as [[PJAX_NAV_SHOP_PLAN]] (sidebar-swap-only navigation, no full reload),
applied to the admin portal. Written as a **separate plan** because the admin menu is
structurally different (deep nested groups vs. the shop's flat list) and far larger —
treat the two rollouts as independent efforts that share only the underlying JS pattern.

## Non-goals

- Not a SPA rewrite.
- Not touching `api/` (mobile-facing).
- Commented-out menu entries (`// 'url' => ...`) are dead code — skip them, don't revive
  them as part of this effort.
- RBAC/System/Widgets/Articles/Translation sections (low-traffic internal tooling) are
  explicitly LOW PRIORITY — do the commercial/operational sections first (Shop Details,
  Users, Booking, Finance) and only extend to these if time allows.

## Current state (verified in code, 2026-07-21)

- `backend/views/layouts/tailwind.php` already registers `yii\widgets\PjaxAsset`, single
  `<main>...<?= $content ?></main>` container (~line 81-88) — same shape as the shop layout.
- `backend/views/layouts/_tw_admin_sidebar.php` renders links through `Html::a()` inside a
  **recursive** `$renderLeaf()` (nested parent → children → grandchildren in places, e.g.
  System → Files → Storage/Manager) — one render function, but the active-state and
  expand/collapse logic is more involved than the shop's flat menu.
- `backend/views/layouts/menu/Menu.php` defines **92** active URL entries across **15**
  top-level groups (`'items' => [...]`), several 2-3 levels deep.
- Admin has its OWN Tailwind bundle (`tailwind.admin.config.js` → `backend/web/css/tailwind.css`,
  self-hosted, pinned lucide `backend/web/js/libs/lucide.min.js` — NOT the same asset
  pipeline as the shop portal's TailwindAsset). Any shared "pjax-safe JS" helper written
  for the shop side must be ported here separately, not assumed to be shared.

## Architecture decisions

Same decisions as the shop plan — sidebar outside the Pjax container + custom click
binding; title + active-item sync on `pjax:success` — plus one admin-specific addition.
**Correction (2026-07-21, verified building the shop pilot):** the server-side perf win
does NOT need a per-controller `getIsPjax()` check — `yii\widgets\Pjax::begin()/end()`
in the shared layout already short-circuits automatically (it detects its own
`X-Pjax-Container` header and calls `Yii::$app->end()` after emitting only its own
content, discarding everything else via nested output buffers). Wrapping the admin
layout's content div once gets every module the win for free.

4. **Bind re-init JS to `pjax:end`, not `pjax:success`** — verified while building the
   shop pilot: browser back/forward restores content from jquery-pjax's own client-side
   cache (`onPjaxPopstate()`) and never fires `pjax:success` at all; `pjax:end` is the
   only event guaranteed on every completion path (success, error, AND cached
   popstate-restore). Port the shop's `aurora-pjax-nav.js` pattern as-is here, don't
   re-derive it — this bug is easy to miss (title/content still update correctly via
   jquery-pjax's own internals; only OUR sidebar re-highlight silently goes stale).
5. **CRITICAL: bind `pjax:end` via jQuery's `.on()`, NEVER `document.addEventListener()`.**
   Verified live (jQuery 3.6.4): `jquery.pjax.js` fires `pjax:end`/`pjax:success` via
   jQuery's own `.trigger()`, which does **not** dispatch a native DOM event for a
   custom event name — `document.addEventListener('pjax:end', fn)` silently NEVER
   fires. This bit the shop build twice: (a) my first `aurora-pjax-nav.js` draft used
   native `addEventListener` and never fired (caught via manual testing, not by any
   error — it just silently did nothing); (b) the SHOP'S OWN PRE-EXISTING
   `aurora-core.js` had `document.addEventListener('pjax:end', ...)` calls (stagger
   re-init, page-transition replay) written before this Pjax-nav work even started —
   those had almost certainly NEVER fired either, a latent bug hiding in the codebase
   until this work exercised Pjax broadly enough to surface it. **Audit whatever admin
   JS you find already listening for `pjax:end`/`pjax:success` via
   `document.addEventListener` — it's dead code, fix it to `jQuery(document).on(...)`.**
6. **Nested-menu active/expand state.** Unlike the shop's flat list, admin menu items are
   grouped in collapsible sections (`<details>`, per the shop sidebar's same pattern).
   After a Pjax swap, the section containing the active leaf must auto-expand — verify
   this explicitly per module since it's new behavior, not just "highlight one `<a>`".

## Phases

- **Phase 0 — Foundation + 1 pilot page.** Same as shop: wire container + binding + one
  page (`/dashboard/index` — Admin Dashboard) end to end before touching anything else.
- **Phase 1 — Shared admin JS made pjax-safe.** Port the shop's `pjax:success` re-init
  pattern to whatever the admin bundle's equivalent of `aurora-core.js` is (icons via the
  self-hosted lucide, any modal/toast helpers, `_tw_admin_sidebar.php`'s expand-state JS).
- **Phase 2 — Per-module rollout**, prioritized: Shop Details → Users → Booking Details →
  Finance/Earnings/Transfers → Category/Cities settings → everything else, using the
  per-module checklist below.
- **Phase 3 — Full regression pass** across all touched modules together.

## Verification checklist (apply to every module row below)

Same list as the shop plan, plus the nested-menu item:
`[ ] Pjax loads content only` · `[ ] Icons/JS re-init` · `[ ] Forms + validation fire` ·
`[ ] Modals/AJAX toggles (e.g. inline active/optional switches) still work` ·
`[ ] Correct sidebar leaf highlights AND its parent section auto-expands` ·
`[ ] Browser back/forward restores the right view` · `[ ] <title> updates` ·
`[ ] RTL (Arabic) unaffected` · `[ ] No console errors` ·
`[ ] Response is genuinely partial on a Pjax nav (Network tab — no full <html>, confirms
the widget's auto short-circuit fired)` ·
`[ ] Direct/deep-link reload of the URL still full-renders (non-Pjax fallback)`

## Per-module TODO (grounded in `Menu.php`, 2026-07-21) — priority order

### Tier 1 — commercial/operational core (do first)

| # | Module | Route(s) | CRUD surface | Status |
|---|---|---|---|---|
| 1 | Home / Dashboard | `/`, `/dashboard/index` | read (BI dashboard) | ✅ verified (Phase 0 pilot + Phase 3 regression) |
| 2 | Shop Details | `/shop` (list), `/shop/create`, `/shop/plans`, `/shop/subscriptions` | full CRUD + plan/subscription mgmt | ✅ verified — this is the page with the `.mode-toggle` fix (see CRITICAL FIX at top); `/shop/index`, `/shop/create` etc. not separately exercised |
| 3 | Users → Shop Owner | `/shop/index` | read/update (owner accounts) | ⬜ untested (shares `shop/index.php` view with row 2's list, likely covered but not separately clicked) |
| 4 | Users → Specialist Details | `/agent/index` | read/update | ✅ verified — same `.mode-toggle`/`.status-toggle` bug + fix as Shop Details (`agent/index.php`) |
| 5 | Users → Customers | `/user/index` | read/update | ✅ verified — same toggle bug + fix (`user/index.php`, shared with `/user/managers`) |
| 6 | Booking Details | `/booking/index` | read + admin actions (cancel/refund?) | ✅ verified (nav + no console errors; no admin action buttons separately exercised) |
| 7 | Payment Details | `/payment/index` | read | ⬜ untested |
| 8 | Navagoo earnings (إيرادات نفاجو) | `/earnings/index` | read | ⬜ untested |
| 9 | Cancellations & refunds | `/cancellations-and-refunds/index` | read + action | ⬜ untested |
| 10 | Transfer Requests | `/withdrawal/index` | read + approve/reject | ⬜ untested directly (its settlement sub-form, `_settlement_form.php`, is rendered via `renderAjax()` into a modal — NOT reachable through the Pjax container at all, so it's out of scope like the shop's iframe-only files; the LIST page itself wasn't separately clicked) |
| 11 | Finance (transfer requests hub) | `/admin-finance/transfer-requests` | read + action | ✅ verified (nav + no console errors) |
| 12 | Platform P&L | `/analytics/index` | read | ⬜ untested |
| 13 | Costs (COGS) | `/cost/index` | full CRUD | ✅ verified (nav + no console errors; CRUD forms not separately exercised) |

### Tier 2 — catalogue/config

| # | Module | Route(s) | CRUD surface | Status |
|---|---|---|---|---|
| 14 | Shops Categories | `/shop-category/index` | full CRUD | ⬜ |
| 15 | Catalogue | `/shop-category/catalogue` | read (hierarchy view) | ⬜ |
| 16 | Main Services | `/service/index` | full CRUD | ⬜ |
| 17 | Cities | `/city/index` | full CRUD | ⬜ |
| 18 | Districts | `/district/index` | full CRUD | ⬜ |
| 19 | Marketing | `/marketing/index` | full CRUD | ⬜ |
| 20 | People | `/user/people` | read | ⬜ |

### Tier 3 — engagement / support

| # | Module | Route(s) | CRUD surface | Status |
|---|---|---|---|---|
| 21 | Customer Invitations | `/customer-invitation-campaign/index` | full CRUD | ⬜ |
| 22 | Ads | `/ads/index` | full CRUD | ⬜ |
| 23 | Live chat | `/live-chat/index` | read/respond | ⬜ |
| 24 | FAQs | `/faq/index` | full CRUD | ⬜ |
| 25 | Contact Us | `/contact-us/index` | read | ⬜ |
| 26 | Demo Requests | `/demo-request/index` | read + convert | ⬜ |
| 27 | Managers | `/managers/index` | full CRUD | ⬜ |
| 28 | Support & content | `/faq/support` | full CRUD | ⬜ |
| 29 | Events | `/timeline-event/audit` | read (audit log) | ⬜ |
| 30 | Users & Roles | `/users-roles/index` | full CRUD (RBAC-adjacent) | ⬜ |
| 31 | Push Notifications | `/push-notification/index` | create + read | ⬜ |
| 32 | Notification Triggers | (see `/notification-trigger/...`) | full CRUD | ⬜ |
| 33 | Contact | (line ~623 entry) | read | ⬜ |

### Tier 4 — low priority (internal tooling; do only if time allows)

| # | Module | Route(s) | CRUD surface | Status |
|---|---|---|---|---|
| 34 | Articles | `/content/article/index`, `/content/category/index` | full CRUD | ⬜ |
| 35 | Widgets (Text/Menu/Carousel) | `/widget/text`, `/widget/menu`, `/widget/carousel` | full CRUD ×3 | ⬜ |
| 36 | Translation | `/translation/default/index` | full CRUD | ⬜ |
| 37 | System → RBAC Rules | `/rbac/rbac-auth-*` (4 sub-pages) | full CRUD | ⬜ |
| 38 | System → Files (Storage/Manager) | `/file/storage/index`, `/file/manager/index` | read/manage | ⬜ |
| 39 | System → Key-Value Storage | `/system/key-storage/index` | full CRUD (raw config — be careful) | ⬜ |
| 40 | System → Cache | `/system/cache/index` | action only (flush) | ⬜ |
| 41 | System → Information | `/system/information/index` | read only | ⬜ |

## Open questions to resolve in Phase 0

- Confirm the admin bundle's actual JS-helper filenames (equivalent to shop's
  `aurora-core.js`) before Phase 1 — this doc assumes parity with the shop pattern but
  hasn't inventoried the admin JS files line-by-line yet.
- Several Tier 1 modules (Shop Details, Booking) have heavy inline AJAX toggles (active
  switches, contract acceptance flows) — audit those specifically for idempotent
  re-binding, they're the most likely source of "works once, breaks on second Pjax nav"
  bugs (double-bound click handlers firing an action twice).
