# Backend Work Plan — JotiJournal Admin Panel

> Goal: Build a full admin/backend panel for managing all domain entities of JotiJournal, reusing the existing AdminKit theme (`public/theme/admin/`) and Laravel auth foundation.
>
> Scope: Admin-facing controllers, routes, views, validation, role-based access. Does **not** rewrite the existing user-facing site (`UserController.php`), only complements it.
>
> Stack: Laravel 11+, MySQL, Blade, Bootstrap 5 (AdminKit theme).

---

## 1. Current State Snapshot

**What exists already**
- Laravel skeleton with `app/Models/` covering: `Act`, `Amendement`, `Article`, `ArticleCategory`, `ArticleSubcategory`, `Circular`, `CircularSubject`, `Folder`, `Journal`, `JournalSection`, `ResearchArticle`, `Role`, `Samasya`, `Section`, `SearchHistory`, `User`, `UserRole`, `Wishlist`.
- A single fat controller `app/Http/Controllers/User/UserController.php` (1,558 lines) that powers the entire **public/user-facing** site.
- User auth wired (`/user/login`, session-based) but no role gating in middleware.
- AdminKit **Pro** Bootstrap 5 theme installed at `public/theme/admin/` (41 HTML pages, light + dark themes). Old Basic version preserved at `public/theme/admin_basic_backup_20260517_120544/`. Key pages now available:
  - **Layout source:** `index.html` (1,393-line Pro dashboard)
  - **Auth flow:** `pages-sign-in.html`, `pages-sign-up.html`, `pages-reset-password.html`
  - **List/index template:** `tables-datatables-responsive.html`, `tables-datatables-buttons.html` (DataTables built in via `js/datatables.js`)
  - **Form templates:** `forms-layouts.html`, `forms-basic-inputs.html`, `forms-advanced-inputs.html`, `forms-input-groups.html`, `forms-validation.html`
  - **Rich-text editor:** `forms-editors.html` — ships with **Quill** (already styled into `css/light.css`)
  - **Business pages:** `pages-profile.html`, `pages-settings.html`, `pages-clients.html`, `pages-invoice.html`, `pages-pricing.html`, `pages-projects.html`
  - **Error pages:** `pages-404.html`, `pages-500.html`
  - **Charts:** `charts-chartjs.html` (Chart.js) + `charts-apexcharts.html` (ApexCharts)
  - **UI components:** alerts, buttons, cards, general, grid, modals, offcanvas, placeholders, tabs, typography
- Folders, wishlists, search history, research articles features already built (recent migrations).

**Status (updated 2026-05-17)**

| Area | Status |
|---|---|
| Admin namespace (`app/Http/Controllers/Admin/`) | ✅ created — `AuthController.php`, `DashboardController.php` |
| Admin layout in Blade | ✅ `layouts/admin.blade.php` + `admin/partials/{sidebar,topbar}.blade.php` |
| Role middleware | ✅ `app/Http/Middleware/EnsureUserHasRole.php` + aliased as `role` in `bootstrap/app.php` |
| Admin routes group | ✅ `routes/web.php` — `/admin/login`, `/admin/logout`, `/admin` (gated `auth + role:super-admin`) |
| `User::hasRole()` helper | ✅ added to `app/Models/User.php` |
| `users.remember_token` column | ✅ added via migration `2026_05_17_065704_add_remember_token_to_users_table.php` |
| Admin login page (port of `pages-sign-in.html`) | ✅ `admin/auth/login.blade.php` — Quill social buttons removed, `alert-danger alert-dismissible` for errors |
| Admin dashboard page (port of `index.html`) | ✅ `admin/dashboard.blade.php` — stat cards / charts / map / calendar / projects table |
| Validation approach | ✅ established — inline `Validator::make` in `save()` controller method (NOT FormRequest classes — JSON endpoints don't suit redirect-with-errors). Pattern locked in via Acts. |
| CRUD screens for content models | ⏳ **5 of 8 taxonomies done + Users CRUD (Phase 4 pulled forward)** — Acts ✅, Sections ✅, Article Categories ✅, Article Subcategories ✅, Circular Subjects ✅, Users ✅. Remaining: Journals, Journal Sections, Roles (taxonomy), 6 content models (Phase 3), Phase 5 polish. |
| AJAX-modal CRUD pattern (DataTable + Modal + SweetAlert) | ✅ established in Phase 2.1 — canonical template for every future CRUD resource |
| Helper assets (`admin_custom.js`, `datatable.custom.js`) | ✅ copied from `/opt/lampp/htdocs/enotary/` and wired into the admin layout |
| Theme rebranded (AdminKit → JOTIJournal) | ✅ all visible "AdminKit Pro" branding stripped; CSS copyright + JS localStorage prefix renamed; backups at `*.bak`. Admin brand visible as **JOTIJournal** (renamed from "JotiJournal" on 2026-05-17 in admin views only — public site untouched). |
| `settings.js` relative-path bug | ✅ patched to `/theme/admin/css/{light,dark}.css` (the original used `css/x.css` which broke under Laravel routes) |

**Existing role schema (verified from `jotici (1).sql` dump)**

```sql
roles
├── id          INT PK
├── role        VARCHAR(50)        -- ⚠ column is named `role`, NOT `name`
└── created_at  TIMESTAMP          -- no updated_at

user_roles
├── id       INT PK
├── user_id  INT  → users.id
└── role_id  INT  → roles.id      -- no timestamps
```

Existing rows in `roles`:

| id | role          |
|----|---------------|
| 1  | `Super Admin` |
| 2  | `User`        |

Existing rows in `user_roles` (relevant): `(user_id=1, role_id=1)` — i.e., `mpsjajournal@gmail.com` is the **Super Admin**.

The `User::roles()` relation already correctly points to the `user_roles` pivot. Eloquent infers `user_id` / `role_id` foreign keys — works as-is. **Do not create new migrations for these tables.**

---

## 2. Architecture Decisions

| Decision | Choice | Reason |
|---|---|---|
| Controller layout | One controller per resource under `App\Http\Controllers\Admin\` | Mirrors Laravel resource controller convention; avoids repeating the 1,558-line monolith. |
| Route grouping | `Route::prefix('admin')->name('admin.')->middleware(['auth','role:super-admin'])` | Clean URL space, named routes, single auth gate. Middleware accepts a **slug** to avoid space-parsing issues; slug is mapped to the real role string `Super Admin` inside the middleware. |
| Auth | Reuse existing `users`, `roles`, `user_roles` tables + Laravel session auth | All three tables and their data already exist (verified in §1). No new tables, no new migrations. The `User::roles()` relation already works. We only need a `hasRole()` helper + a middleware that uses it. |
| Layout | New `resources/views/layouts/admin.blade.php` ported from `theme/admin/index.html` | AdminKit is already designed; no need to invent. |
| Theme assets | Reference via `{{ asset('theme/admin/...') }}` | Matches the existing memory rule about asset paths (uses `php artisan serve`). |
| Settings widget | Patched `settings.js` — absolute CSS paths + branding stripped + localStorage prefix renamed to `jj_admin_config_` | The original `settings.js` rewrote the stylesheet `href` to a relative path (`css/light.css`), which 404'd under Laravel routes and made the page render invisibly due to inline `body{opacity:0}`. Fixed once for the whole admin. |
| CSS / JS originals | Backups at `public/theme/admin/css/*.bak` and `js/*.bak` | Allows restoring the pristine vendor files if licensing concerns require it. |
| Validation | `app/Http/Requests/Admin/*Request.php` per resource + Bootstrap `was-validated` styles | Keeps controllers thin; reuses Pro's `forms-validation.html` patterns. |
| Forms | Blade partials in `resources/views/admin/<resource>/_form.blade.php` | Share between create/edit. Use Pro's `forms-layouts.html` as the base layout. |
| Rich text editor | **Quill** (ships with AdminKit Pro, styled in `css/light.css`) | Already integrated — see `forms-editors.html` for usage. No extra dependency. |
| List/index pages | **Server-side DataTables 2.x via CDN** (jQuery 3.7 + DataTables 2.1.8 + Buttons 3.1.2) + `initDatatable()` helper from `public/theme/datatable.custom.js` | Pattern ported from the `enotary` project — see `/opt/lampp/htdocs/enotary/resources/views/admin/state/index.blade.php`. Empty `<tbody>` rendered server-side; rows loaded via POST AJAX to `<resource>.data_table`. Scales to 100k+ rows (each request only pulls one page). AdminKit's bundled `js/datatables.js` is **not used** — it would conflict with the CDN jQuery + DataTables. |
| Add/Edit forms | **Shared `#addNewModal` per resource page** — opens via `data-bs-toggle="modal" data-record-id="..."`, AJAX-loads the form HTML from `<resource>.addForm`, submits via AJAX to `<resource>.save` (JSON in / JSON out), closes on success and reloads the DataTable. | Replaces the old per-route `/create` + `/edit` pages. Single round trip per action, no full-page reloads, search/sort state preserved across saves. |
| Delete confirmation | **SweetAlert2** (`Swal.fire({...})`) via the global `.remove` button handler in `public/theme/admin/js/admin_custom.js`. Buttons declare themselves with `class="remove" data-record-id="…" data-href="<route('…remove')>"`. | Native `confirm()` is ugly and unstyleable; SweetAlert2 looks consistent with the theme and works with promises. Set up once in `admin_custom.js` — every future resource gets it free. |
| Toast notifications | **Bootstrap toasts** rendered into a global `#toastArea` container in the layout. `showToast('success'\|'danger'\|'warning'\|'info', html)` helper appends + shows them. | Replaces the inline-banner flash zone for AJAX flows. Page redirects still use the layout's flash banner; AJAX successes use toasts. |
| AJAX setup | Global `$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') } })` in `admin_custom.js` | Sends Laravel's CSRF token with every AJAX request automatically — no per-call boilerplate. Requires `<meta name="csrf-token">` in the layout `<head>` (already present). |
| Modal AJAX-loader overlay | Inline `<style>` block in `layouts/admin.blade.php` — `.modal_loader_div` is `position:absolute; inset:0; display:none` by default, `display:flex` when `.active`. The `.minheight { min-height: 30vh }` rule on the modal body ensures the spinner is visible even before content loads. | Toggled by per-page AJAX `beforeSend → addClass('active')` and `complete → removeClass('active')`. CSS rules ported from `/opt/lampp/htdocs/enotary/public/theme/admin/css/custom.css` (lines 642–668). |
| Password-field eye toggle | `.password-toggle` button inside a Bootstrap `.input-group`, handled by a delegated click handler in `admin_custom.js`. Flips the sibling `<input>`'s type between `password` ↔ `text` and swaps the icon between `fa-eye` ↔ `fa-eye-slash`. `tabindex="-1"` on the button keeps it out of the Tab order. | Reusable across any form that wants show/hide password support — the User CRUD form and the Change Password page both use it without extra per-page JS. |
| Searchable selects | **Choices.js** (bundled in AdminKit Pro's `app.js`, no extra include). Initialised per-page after the modal's AJAX-loaded form is in the DOM. Previous instance is `.destroy()`ed on `hidden.bs.modal` to avoid leaks. | Used wherever a dropdown has >50 options (e.g., Act dropdown on the Sections form with 600 options). Stays as a plain `<select>` until the form HTML is injected. |
| Scoped + case-insensitive uniqueness | Explicit `Section::where('act_id', $actId)->whereRaw('LOWER(TRIM(section)) = ?', [mb_strtolower($name)])->when($id, fn($q)=>$q->where('id','!=',$id))->exists()` check in the `save()` method, BEFORE the `updateOrCreate`. Run on top of the standard `Validator::make`. | Laravel's `Rule::unique('table','col')->where('act_id', $actId)` would only do case-insensitive matching if the column's MySQL collation happened to be `_ci` — that's fragile. The explicit `LOWER()/TRIM()` query is self-documenting and survives a collation change. Used wherever uniqueness must be scoped to a parent + ignore case + ignore whitespace (Sections under Act, will recur elsewhere). |
| Submit button label & icon | Form partial pattern: `{{ $model?->id ? 'Update' : 'Save' }}` for the text and `fa-pencil-alt` (existing) vs `fa-save` (new) for the icon. Loading text adapts the same way: "Updating..." vs "Saving...". | Same icon vocabulary as the row-level edit button (pencil), so the modal feels visually consistent with the table. |
| Charts | Chart.js (dashboard line) + ApexCharts (richer breakdowns) | Both shipped with Pro. |
| Dark mode | Toggle via `js/settings.js` + `css/dark.css` | Already wired into the theme — admin gets dark mode for free. |
| Error pages | Use `pages-404.html` / `pages-500.html` as Blade templates for `resources/views/errors/` | Standard Laravel error page override. |

---

## 3. Folder Structure (target — reflects the rev-2 AJAX-modal pattern)

```
app/
├── Http/
│   ├── Controllers/
│   │   ├── User/UserController.php             (existing — untouched)
│   │   └── Admin/
│   │       ├── AuthController.php              ✅ done
│   │       ├── DashboardController.php         ✅ done
│   │       ├── ActController.php               ✅ done (Phase 2.1)
│   │       ├── SectionController.php           ✅ done (Phase 2.2)
│   │       ├── ArticleCategoryController.php   ✅ done (Phase 2.3)
│   │       ├── ArticleSubcategoryController.php ✅ done (Phase 2.4)
│   │       ├── CircularSubjectController.php   ✅ done (Phase 2.5)
│   │       ├── JournalController.php           ❌ Phase 2.6
│   │       ├── JournalSectionController.php    ❌ Phase 2.7
│   │       ├── RoleController.php              ❌ Phase 2.8
│   │       ├── ArticleController.php           ✅ done (Phase 3.1 — page-based, not modal)
│   │       ├── ResearchArticleController.php   ❌ Phase 3
│   │       ├── CircularController.php          ❌ Phase 3
│   │       ├── AmendmentController.php         ❌ Phase 3
│   │       ├── SamasyaController.php           ❌ Phase 3
│   │       ├── JudgementController.php         ❌ Phase 3
│   │       └── UserController.php              ✅ done (Phase 4 — pulled forward from later)
│   └── Middleware/
│       └── EnsureUserHasRole.php               ✅ done
├── Helpers/
│   └── dates.php                               ✅ done (Session 11) — `format_date()` global helper; dd-mm-YYYY display convention. Registered via composer.json autoload.files.
└── Models/                                     (existing — see §1 model list)

(NO `app/Http/Requests/Admin/` folder — validation is inline in each controller's `save()`
via `Validator::make()` because endpoints return JSON, not redirect-with-errors.)

resources/views/
├── layouts/
│   └── admin.blade.php                         ✅ done — loads jQuery + DataTables + SweetAlert + helpers
└── admin/
    ├── auth/login.blade.php                    ✅ done
    ├── dashboard.blade.php                     ✅ done (demo data — Phase 2 wires real counts)
    ├── partials/
    │   ├── sidebar.blade.php                   ✅ done
    │   └── topbar.blade.php                    ✅ done
    ├── acts/                                   ✅ done — canonical CRUD template
    │   ├── index.blade.php                     (page shell: empty table + #addNewModal)
    │   └── partials/form.blade.php             (form HTML returned by addForm endpoint)
    ├── articles/                               ✅ done (Phase 3.1) — DEVIATES from canonical (page-based, not modal)
    │   ├── index.blade.php                     (list + DataTable; "Add" is an anchor link to create page — NO modal markup)
    │   ├── create.blade.php                    (full-page create form; extends layouts.admin; @includes form partial)
    │   ├── edit.blade.php                      (full-page edit form; extends layouts.admin; @includes form partial)
    │   └── partials/form.blade.php             (shared Quill-enabled form body; reads `old()` + `@error()`; used by create + edit)
    └── <resource>/                             ❌ canonical structure for every other future CRUD resource
        ├── index.blade.php
        └── partials/form.blade.php

public/theme/
├── admin/js/admin_custom.js                    ✅ done — bs5button, backDropModel, showToast, .remove SweetAlert
└── datatable.custom.js                         ✅ done — initDatatable() helper

routes/web.php                                  — admin group with discrete POST endpoints per resource (see §5)
```

**Canonical CRUD page contract** (mirror this for every Phase 2/3 resource):

| Method on `<Resource>Controller` | Route | Purpose |
|---|---|---|
| `index()` | `GET  /admin/<resource>` | Renders page shell |
| `getDataTable(Request)` | `POST /admin/<resource>/data_table` | JSON for DataTables — `{draw, recordsTotal, recordsFiltered, data:[[col1, col2, ...], ...]}` |
| `getForm(Request)` | `POST /admin/<resource>/addForm` | Returns `{status, html}` — renders `<resource>.partials.form` blade |
| `save(Request)` | `POST /admin/<resource>/save` | Validates via `Validator::make`, `updateOrCreate`, returns `{status:1\|0, message, errors?, id?}` |
| `remove(Request)` | `POST /admin/<resource>/remove` | Deletes by id, returns `{status, message}`. Triggered by the global `.remove` SweetAlert handler. |

> **Articles deviates from this contract** (decided 2026-05-17): page-based create/edit instead of a shared modal. The list page and the delete flow still match the canonical contract — only `getForm` is dropped, and two new GET endpoints (`create`, `edit`) render full pages. See the Articles row in Phase 3 (§4) and the Articles route block in §5.

> **Default sort convention** (Session 11, 2026-05-17): every `getDataTable` falls back to `ORDER BY <table>.id DESC` when the request carries no `order[0][column]` (or maps it to a non-orderable column). Every `index.blade.php` passes `[]` as the `initDatatable()` `aaSorting` arg so the client *doesn't* send an explicit order on first load — the server fallback wins and newest rows surface at the top. Column-click sorting still works because DataTables sends the user's chosen column on subsequent draws, which bypasses the fallback. **Use qualified `<table>.id`** (e.g. `articles.id`, not bare `id`) — every CRUD with a join would otherwise hit MySQL's ambiguous-column error.

> **Date display convention** (Session 11): use the `format_date($value, $format = 'd-m-Y', $placeholder = '—')` global helper from `app/Helpers/dates.php` in every controller method that renders a date into a DataTable row. Accepts Carbon/string/null. Catches parse failures and returns the placeholder. Don't reimplement `optional($x)->format(...)` inline.

---

## 4. Build Sequence (phased)

### Phase 1 — Foundation ✅ COMPLETE (2026-05-17)

**Verified working end-to-end** with Super Admin `mpsjajournal@gmail.com` (dev password: `password`):
- `GET /admin/login` (anonymous) → renders login form (200)
- `POST /admin/login` with valid Super Admin creds → 302 → `/admin` (200, dashboard renders 23.9 KB)
- `POST /admin/login` with bad password or non-Super-Admin → 302 back with `alert-danger alert-dismissible` "Oops! Invalid credentials."
- `GET /admin` (anonymous) → 302 → `/admin/login` (the path-aware redirect in `bootstrap/app.php` handles this — see [Implementation notes](#implementation-notes-phase-1))
- `POST /admin/logout` → 302 → `/admin/login`

#### Implementation notes (Phase 1)

- **Path-aware guest redirect** — added `redirectGuestsTo()` to `bootstrap/app.php` so unauthenticated visits to `/admin/*` go to `/admin/login`, not the user-facing `/login`. Without this, Laravel's default `auth` middleware sends every guest to the named `login` route, which is the wrong page for admins.
- **Theme rebrand** — patched `public/theme/admin/js/settings.js` to (a) use absolute CSS paths so the page doesn't go blank on `/admin/*` routes, (b) strip "AdminKit" / "Get AdminKit PRO" branding from the bottom-right settings widget, (c) rename localStorage prefix `adminkit_config_` → `jj_admin_config_`. CSS copyright headers also stripped from `light.css` / `dark.css`. All originals at `*.bak`.
- **Legacy schema** — the `users` table came from the old CodeIgniter dump (`is_active`, `token`, `expDate`, `dob`, `image`, `city`, `address`) and lacked `remember_token`. Added that column in a migration; did NOT add `email_verified_at` because the `User` model doesn't implement `MustVerifyEmail`.
- **Dashboard data is still demo data** — the stat cards (Sales / Visitors / Earnings / Orders), Recent Movement line chart, Browser Usage pie, Latest Projects table, world map, and Monthly Sales bar all show the AdminKit demo values. Phase 1's deliverable was visual fidelity; **wiring real model counts is the first task of Phase 2**.

**Theme-page → Blade-view mapping**

| Pro page (in `public/theme/admin/`) | Becomes | Used for |
|---|---|---|
| `index.html` | `resources/views/layouts/admin.blade.php` + `admin/dashboard.blade.php` | Master layout + dashboard |
| `pages-sign-in.html` | `admin/auth/login.blade.php` | Admin login |
| `pages-reset-password.html` | `admin/auth/forgot-password.blade.php` | Password reset |
| `pages-sign-up.html` | (skipped — admin accounts are seeded, not self-served) | — |
| `pages-profile.html` | `admin/profile.blade.php` | "My Profile" for logged-in admin |
| `pages-settings.html` | `admin/settings.blade.php` | Site-wide admin settings (optional) |
| `tables-datatables-responsive.html` | reference only — superseded by the rev-2 pattern using `dt_table` class + `initDatatable()` from `public/theme/datatable.custom.js` | — |
| `forms-layouts.html` | reference for input/label markup used inside `admin/<resource>/partials/form.blade.php` | Per-resource form partials, not a shared layout |
| `forms-editors.html` (Quill bits) | reference for the markup pattern when adding rich-text fields in Phase 3 forms | Same modal pattern, just with a Quill `<div>` instead of `<textarea>` |
| `forms-validation.html` | not used — validation errors arrive as JSON `{errors:{field:[...]}}` from the save endpoint and surface as a SweetAlert toast | — |
| `pages-404.html` | `resources/views/errors/404.blade.php` | Override Laravel's 404 |
| `pages-500.html` | `resources/views/errors/500.blade.php` | Override Laravel's 500 |
| `pages-blank.html` | reference only | Empty starting template |

1. **Admin layout** — port `public/theme/admin/index.html` (1,393 lines, Pro) → Blade.
   - **Target file:** `resources/views/layouts/admin.blade.php`.
   - **Outer structure to preserve:** `.wrapper` containing `<nav id="sidebar" class="sidebar js-sidebar">` + `<main class="content">` + `<footer class="footer">`.
   - **Partials to extract** under `resources/views/admin/partials/`:
     - `sidebar.blade.php` — brand + `sidebar-nav` (rewrite menu items, see below).
     - `topbar.blade.php` — hamburger toggle (`.js-sidebar-toggle`), search form, alerts/messages dropdowns, user dropdown.
     - `footer.blade.php` — strip AdminKit copyright; replace with JotiJournal footer.
   - **Sidebar nav rewrite** — delete demo items (Profile, Sign In, Sign Up, Blank, Buttons, Forms, Cards, Typography, Icons, Charts, Maps). Replace with grouped real sections (current shape after 2026-05-17 reorg):
     - **Dashboard** (top-level, no group).
     - **Content:** Articles, Research Articles, Circulars, Amendments, Samasyas, Journals (placeholders pending Phase 3).
     - **CMS:** Acts ✅, Sections ✅, Article Categories ✅, Article Subcategories ✅, Circular Subjects ✅. Group was originally labelled "Taxonomy" — renamed to "CMS" 2026-05-17. Acts + Sections were originally placed under Content; moved to CMS in the same reorg since they're classification/reference data that Content tables join to, not first-class content.
     - **Access:** Users ✅, Roles (placeholder).
     - Use existing `data-feather="..."` icons (file-text, layers, users, shield, etc.).
   - **Delete:** `.sidebar-cta` "Upgrade to Pro" box (lines 117–125 of source).
   - **Asset rewrites** — every `css/app.css`, `js/app.js`, `img/avatars/*.jpg` becomes `{{ asset('theme/admin/css/app.css') }}` etc. Keep `js/app.js` as-is — it initializes Feather icons, Chart.js, sidebar toggle, and Simplebar; don't rewrite.
   - **Topbar avatar & name** — replace hardcoded "Charles Hall" / `avatar.jpg` with `{{ auth()->user()->name }}` and a default avatar (or initials).
   - **Yield/section blocks:** `@yield('title')`, `@yield('page_title')`, `@yield('content')`, `@stack('scripts')`.
2. **Admin login** — **Super Admin only**. Non-Super-Admin users (role=`User` or any future tier) must not be able to access this page or authenticate through it.
   - Port `public/theme/admin/pages-sign-in.html` → `resources/views/admin/auth/login.blade.php`.
   - `Admin\AuthController` methods:

   ```php
   public function showLoginForm()
   {
       if (auth()->check()) {
           return auth()->user()->hasRole('Super Admin')
               ? redirect()->route('admin.dashboard')
               : redirect('/');  // logged-in non-admin → bounce away, never show form
       }
       return view('admin.auth.login');
   }

   public function login(Request $request)
   {
       $data = $request->validate([
           'email'    => 'required|email',
           'password' => 'required|string',
       ]);

       if (!Auth::attempt($data, $request->boolean('remember'))) {
           return back()->withErrors(['email' => 'Invalid credentials.'])->onlyInput('email');
       }

       if (!$request->user()->hasRole('Super Admin')) {
           Auth::logout();                        // never leave non-admin authenticated via this route
           $request->session()->invalidate();
           $request->session()->regenerateToken();
           return back()->withErrors(['email' => 'Invalid credentials.'])->onlyInput('email');
       }

       $request->session()->regenerate();
       return redirect()->intended(route('admin.dashboard'));
   }

   public function logout(Request $request)
   {
       Auth::logout();
       $request->session()->invalidate();
       $request->session()->regenerateToken();
       return redirect()->route('admin.login');
   }
   ```

   **Access-control matrix for `/admin/login`:**

   | Visitor state | GET behavior | POST behavior |
   |---|---|---|
   | Anonymous | Show login form | Auth, then role check; reject non-admin with "Invalid credentials" (no role-leak) |
   | Logged in as Super Admin | Redirect → `/admin` | (same) |
   | Logged in as `User` (or other non-admin) | Redirect → `/` | Same path-not-hit (already redirected on GET); if reached, same rejection as anonymous |

   The same `role:super-admin` middleware guards all other `/admin/*` routes (see §5), so a non-admin who manually visits `/admin/articles` etc. is also bounced.
3. **`hasRole()` helper on the User model**
   - Add to `app/Models/User.php`:
     ```php
     public function hasRole(string $role): bool
     {
         return $this->roles()->where('role', $role)->exists();
     }
     ```
   - Note: queries the `roles.role` column (not `name`). Uses the existing `roles()` belongsToMany relation through `user_roles`.
4. **Role middleware** — gates every `/admin/*` route except `login`/`logout`. A non-Super-Admin reaching any admin URL is bounced before the controller runs.
   - `app/Http/Middleware/EnsureUserHasRole.php`:
     ```php
     public function handle(Request $request, Closure $next, string $slug): Response
     {
         $map  = ['super-admin' => 'Super Admin', 'user' => 'User'];
         $role = $map[$slug] ?? $slug;

         if (!$request->user()) {
             return redirect()->route('admin.login');           // anonymous → login
         }
         if (!$request->user()->hasRole($role)) {
             // Authenticated as wrong role — log them out of admin context
             // and send to a safe page so they can't bounce-loop the admin URL.
             return redirect('/')->with('error', 'You do not have permission to access that area.');
         }
         return $next($request);
     }
     ```
   - Register as `'role'` alias in `bootstrap/app.php` → `$middleware->alias([...])`.
   - The slug→string map exists so route definitions stay clean (`role:super-admin`) and don't break when role names contain spaces.
5. **Seeders**
   - **No `RoleSeeder` needed** — `roles` table already has `Super Admin` (id=1) and `User` (id=2) from the SQL dump.
   - **No admin user seeder needed** — `mpsjajournal@gmail.com` (user_id=1) is already linked to `role_id=1` in `user_roles`.
   - Only add a seeder if §7 Q1 adds new role tiers (e.g., `Editor`, `Viewer`).
5. **Dashboard** — port the dashboard body from `theme/admin/index.html` (lines 293–~560).
   - `Admin\DashboardController@index` returns an array of counts to the view:
     ```php
     return view('admin.dashboard', [
         'actsCount'      => Act::count(),
         'articlesCount'  => Article::count(),
         'circularsCount' => Circular::count(),
         'samasyasCount'  => Samasya::count(),
         'usersCount'     => User::count(),
         'researchCount'  => ResearchArticle::count(),
     ]);
     ```
   - **Replace the 4 demo stat cards** (Sales / Visitors / Earnings / Orders, lines 302–384 of template) with cards for Acts / Articles / Circulars / Samasyas. Keep the same Bootstrap markup (`.card > .card-body > .row > .col mt-0 > h5.card-title` + `h1.mt-1.mb-3`), only change titles, icons, and values.
   - **"Recent Movement" line chart** (template lines ~390–402, uses `<canvas id="chartjs-dashboard-line">`) — repurpose to plot content added per month (e.g., articles created last 12 months), or hide for v1.
   - **"Browser Usage" doughnut chart** — repurpose to show breakdown by content type (Acts vs Articles vs Circulars vs Samasyas) or hide for v1.
   - Other template sections below (projects table, calendar, world map) — **remove for v1**; bring back only if needed.

**Deliverable check**: Admin can log in at `/admin/login` and see a styled dashboard with placeholder counts.

### Phase 2 — Core taxonomy CRUD (2–3 days) — 5 of 8 DONE

**Progress snapshot** (each follows the canonical 5-endpoint AJAX-modal pattern):

| # | Resource | Rows | Status | Special notes |
|---|---|--:|:---:|---|
| 2.1 | Acts | 600 | ✅ | Canonical template established here |
| 2.2 | Sections | 5,479 | ✅ | Per-Act case-insensitive uniqueness + Choices.js searchable parent select |
| 2.3 | Article Categories | 10 | ✅ | Two flag toggles (for_article / for_question) with "at least one" rule; no `updated_at` |
| 2.4 | Article Subcategories | 30 | ✅ | Per-Category case-insensitive uniqueness; mirror of Sections shape |
| 2.5 | Circular Subjects | 3 | ✅ | Simplest schema — top-level, single text column |
| 2.6 | Journals | ? | ❌ | Schema to verify (likely `name, volume, year`) |
| 2.7 | Journal Sections | ? | ❌ | Child of Journal, mirror Sections shape |
| 2.8 | Roles | 2 | ❌ | Reuses existing `roles` table; column is `role`, NOT `name` |

**Open Phase 2 prerequisite** (not yet done):
- Wire real model counts into `Admin\DashboardController::index()` so the dashboard shows live counts of Acts / Articles / Circulars / Samasyas instead of the AdminKit demo values. Touch `app/Http/Controllers/Admin/DashboardController.php` + four `{{ $count }}` substitutions in `resources/views/admin/dashboard.blade.php`.

---

#### Phase 2.1 — Acts CRUD ✅ COMPLETE (2026-05-17, rev 2 — refactored to AJAX-modal pattern)

**Initial implementation** (rev 1, server-rendered): each row rendered server-side, `/create` and `/edit` were full pages, delete used `confirm()`. Worked but didn't scale (570 KB page).

**Refactored implementation** (rev 2, current — matches `/opt/lampp/htdocs/enotary` pattern):
- **Page shell** is a tiny `index.blade.php` (12.7 KB) with an empty `<tbody>` and a single `#addNewModal`.
- **Table rows** load via `POST /admin/acts/data_table` (server-side DataTables — page-of-25 at a time, full-text search across `act` + `description`).
- **Add / Edit** both open the same modal; `POST /admin/acts/addForm` returns the rendered form HTML with the right values pre-filled.
- **Save** posts JSON to `POST /admin/acts/save`. Validation errors come back as `{status:0, errors:{...}}` and are rendered as a SweetAlert-styled toast; success is `{status:1, message, id}` and triggers a toast + modal close + `table.ajax.reload(null, false)`.
- **Delete** uses the global `.remove` SweetAlert handler from `admin_custom.js` — `Swal.fire({title: 'Are you sure?', ...})`. Confirmed → POSTs to `POST /admin/acts/remove`.

**End-to-end verification** (all 8 cases passed):
| Test | Endpoint | Result |
|---|---|---|
| Page shell loads | `GET /admin/acts` | 200, 12.7 KB (45× smaller than rev 1) |
| Table data fetched | `POST /admin/acts/data_table` | `recordsTotal=600`, search "ACT" → `filtered=312`, returns 10 rows |
| Blank form | `POST /admin/acts/addForm` with id=0 | Returns HTML with empty `<input value="">` |
| Pre-filled form | `POST /admin/acts/addForm` with id=2 | Returns HTML containing the existing act name |
| Create | `POST /admin/acts/save` with id="" | `{status:1, message:"Act created successfully.", id:N}` |
| Update | `POST /admin/acts/save` with id=N | `{status:1, message:"Act updated successfully.", id:N}` |
| Validation | `POST /admin/acts/save` with blank act | `{status:0, errors:{act:["The act name field is required."]}}` |
| Delete | `POST /admin/acts/remove` with id=N | `{status:1, message:"Act \"…\" deleted."}`, DB row gone |

**Visible columns** (2026-05-17): **S.No. | Act | Sections | Updated | Action** — Description was dropped from the table per user request (long descriptions clutter the layout). The `description` column is still saved/edited via the modal form, and is still **included in the DataTable search** (you can find an act by text in its description even though the column isn't displayed).

**This becomes the standard pattern for every future CRUD page.** Sections, Articles, Circulars, etc. should mirror the controller method names (`index`, `getDataTable`, `getForm`, `save`, `remove`) and the view structure (`index.blade.php` + `partials/form.blade.php`).


**Real schema** (verified against DB, 600 rows currently)
```
acts
├── id          INT PK
├── act         VARCHAR(250)   -- the act-name column. ⚠ NOT `name`
├── description TEXT NULL
├── created_at  TIMESTAMP NOT NULL
└── updated_at  TIMESTAMP NOT NULL
```

(See the rev-2 verification table above for actual endpoints + tested responses.)

---

#### Remaining Phase 2 resources

The Acts CRUD is the **canonical template** — every entry below follows the exact same 5-endpoint pattern (`index`, `getDataTable`, `getForm`, `save`, `remove`) with `<resource>/index.blade.php` + `<resource>/partials/form.blade.php`.

| # | Resource | Real columns (verified against DB) | Notes |
|---|---|---|---|
| 1 | Acts | `act`, `description` | ✅ done (Phase 2.1) — 600 rows |
| 2 | Sections | `act_id`, `section`, `description` | ✅ done (Phase 2.2) — 5,479 rows. Uniqueness is **per-Act + case-insensitive + trim-aware** (see §2 "Scoped + case-insensitive uniqueness"). Acts dropdown uses **Choices.js** for searchable select with 600 options. |
| 3 | Article Categories | `category`, `for_article`, `for_question`, `description`, `created_at` (**no `updated_at`**) | ✅ done (Phase 2.3) — 10 rows. Two boolean flags (`for_article`, `for_question`) — at least one must be set. Case-insensitive uniqueness on `category` (no parent scope, since categories are top-level). Drives Article + Samasya dropdowns in Phase 3. |
| 4 | Article Subcategories | `article_category_id`, `subcategory` (varchar 100), `description`, `created_at` (**no `updated_at`**) | ✅ done (Phase 2.4) — 30 rows. Uniqueness is **per-Category + case-insensitive + trim-aware** (same shape as Sections-under-Acts). Category dropdown uses Choices.js. Same manual `created_at` pattern as Article Categories. |
| 5 | Circular Subjects | `subject` (varchar 100), `created_at` (**no `updated_at`, no `description`**) | ✅ done (Phase 2.5) — 3 rows (CIVIL / CRIMINAL / OTHERS). Simplest schema in Phase 2: top-level, just one text column. Case-insensitive + trim-aware uniqueness on `subject`. No Choices.js needed anywhere — small enough for plain select. Drives the Circulars subject_id dropdown in Phase 3. |
| 6 | Journals | (verify schema — likely `name, volume, year`) | |
| 7 | Journal Sections | `journal_id`, ... | |
| 8 | Roles | `role` (matches the `roles` table — see §1) | Reuses existing `roles` table; uses `role` column not `name` |

For every resource, **verify the real schema first** (the plan's pre-Acts guesses turned out wrong for `acts`; same risk elsewhere).

### Phase 3 — Content CRUD (3–5 days)

Same 5-endpoint AJAX-modal pattern as Phase 2 for most resources, with two additions: a **rich-text editor** for body fields (CKEditor 5 Super Build — see Session 11 for the journey through Quill → CK5 Classic → CK4 Full → CK4 LTS → final landing on CK5 Super Build), and **file uploads** where needed (Circular attachments). Each entry below assumes the schema needs verification before building (the `acts` schema differed materially from the plan's guesses — same risk here).

> **Exception — Articles is page-based, not modal-based** (decided 2026-05-17). The Articles list lives at `/admin/articles`, but "Add" navigates to a dedicated `/admin/articles/create` page and the row "Edit" button navigates to `/admin/articles/{id}/edit`. Reason: the Article body is long-form rich text — a full page gives the editor room to breathe and matches the editor experience the admin team expects. `save` shifts to the standard Laravel page-form flow (server-side redirect with flash + `withInput()->withErrors()` on failure) instead of returning JSON. Other Phase 3 resources (Research Articles, Circulars, Amendments, Samasyas, Judgements) still default to the modal pattern unless changed later.

| # | Resource | Likely fields (verify against SQL dump) | Notes |
|---|---|---|---|
| 1 | Articles | **Real schema (verified against `jotici (1).sql` line 991):** `category_id` (int, NOT NULL), `subcategory_id` (int, NULL allowed), `title` (varchar 768), `article` (longtext — the rich-text body, ⚠ NOT `body`), `article_strip_tag` (longtext — plaintext mirror of `article`, used for search), `author` (varchar 500), `publish_date` (date NULL — ⚠ NOT `published_at`), `created_at` (timestamp, **no `updated_at`** — but column has `ON UPDATE current_timestamp()` so it doubles as last-touched) | ✅ done (Phase 3.1) — 803 rows. **Page-based CRUD** (not modal). List at `/admin/articles`, create at `/admin/articles/create`, edit at `/admin/articles/{id}/edit`. **CKEditor 5 Super Build** (CDN, pinned to 41.4.2 — last version before mandatory license keys; `removePlugins` list excludes all premium plugins so they don't break init) replaces the `<textarea name="article">`; auto-syncs back on submit. Editable area sized via `.ck-editor__editable_inline { min-height: 500px }` in the form partial (CK5 ignores `config.height`). **Flatpickr datepicker** on `publish_date`: shown to user as `dd-mm-YYYY` via `altInput: true, altFormat: 'd-m-Y'`; underlying hidden input still carries `Y-m-d` so the `date` validation rule is unchanged. **List column rendering** uses `format_date($item->publish_date)`. `save` returns **server-side redirect**: `back()->withInput()->withErrors($v)` on failure, `redirect()->route('admin.articles.index')->with('success', …)` on success. Form reads `old('field', $article->field ?? '')` and renders `@error('field')` blocks. **On every save, writes `article_strip_tag = trim(preg_replace('/\s+/u',' ', strip_tags(...)))`** — the user-side article search joins against this column. Form layout: row 1 = Title (col-12) · row 2 = Author / Category / Subcategory (col-md-4 × 3) · row 3 = Publish date (col-md-3) · row 4 = Article body. Subcategory dropdown filters to selected Category client-side via `data-category` attrs + Choices.js re-init; cross-field check enforced server-side via `$v->after()`. See Sessions 10 + 11 in §11 for full details. |
| 2 | Research Articles | title, author, abstract, body, published_at | Same CKEditor 5 Super Build + Flatpickr setup as Articles. If page-based UX is wanted here too, mirror the Articles pattern (separate create/edit pages, redirect-with-errors). |
| 3 | Circulars | subject_id, number, date, body, attachment | File upload — `save` switches to `multipart/form-data`; validate `mimes:pdf,doc,docx\|max:10240`. |
| 4 | Amendments | act_id, section_id, date, body | Cascading dropdowns: Section dropdown filtered by selected Act. |
| 5 | Samasya (Q&A) | title, question, answer, category | Two Quill instances per form (question, answer). |
| 6 | Judgements | title, court, date, parties, body, act/section tags | Largest form. **For Judgements only, switch to true server-side DataTables** if the table grows past ~10k rows. Existing `judgements` table likely already has the data — verify columns + row count. |

### Phase 4 — User & Access management (1 day)
1. ✅ `Admin\UserController` — list/create/edit/disable users, assign roles via `user_roles` pivot. **Completed early on 2026-05-17.** Includes self-protection (cannot demote/disable/delete own account).
2. `Admin\RoleController` — list/create/edit roles. ❌ pending.
3. ✅ **Change Password** flow for the logged-in admin. Self-service via `/admin/change-password`, linked from the user dropdown in both the sidebar and the topbar. Verifies current password before accepting new, enforces min 8 chars + confirmation match + "must be different" rule, and calls `Auth::logoutOtherDevices()` to invalidate sessions on other devices.

### Phase 5 — Polish (1–2 days)
1. ✅ ~~Flash messages (success/error toasts)~~ — done in Phase 2.1 via `showToast()` + `#toastArea` + session flash zone in layout.
2. ✅ ~~Server-side DataTables search/sort for index pages~~ — done in Phase 2.1 via `initDatatable()` + per-resource `getDataTable` endpoints.
3. Soft-delete + restore for content models (optional, deferred until requested).
4. Activity log (who edited what, when) — use `spatie/laravel-activitylog` if approved (§7 Q5).
5. CSRF, XSS, file-upload validation review — CSRF + general validation already in place; full audit before launch.
6. Replace any remaining native `confirm()` / `alert()` calls with SweetAlert2 / `showToast()` for consistency.
7. Loading states on long-running AJAX (Phase 3 file uploads, Judgements table) — `bs5button('loading')` is already wired for form-submit buttons; extend to filter-change handlers if needed.

---

## 5. Routes Plan (current shape of `routes/web.php`)

The canonical CRUD resource has **5 discrete routes** (not `Route::resource`) because every action returns JSON for the AJAX flow:

```php
Route::prefix('admin')->name('admin.')->group(function () {
    Route::get('/login',   [AdminAuthController::class, 'showLoginForm'])->name('login');
    Route::post('/login',  [AdminAuthController::class, 'login'])->name('login.post');
    Route::post('/logout', [AdminAuthController::class, 'logout'])->name('logout');

    Route::middleware(['auth', 'role:super-admin'])->group(function () {
        Route::get('/', [AdminDashboardController::class, 'index'])->name('dashboard');

        // Canonical CRUD shape — repeat for each resource (Sections, Article Categories, etc.)
        Route::prefix('acts')->name('acts.')->controller(AdminActController::class)->group(function () {
            Route::get('/',           'index')->name('index');
            Route::post('data_table', 'getDataTable')->name('data_table');
            Route::post('addForm',    'getForm')->name('addForm');
            Route::post('save',       'save')->name('save');
            Route::post('remove',     'remove')->name('remove');
        });

        // Phase 2.2 onward — sections, article-categories, article-subcategories,
        // circular-subjects, journals, journal-sections, roles — same 5-route block.
        // Phase 3 — research-articles, circulars, amendments, samasyas, judgements — same 5-route block.
        // Phase 4 — users — same 5-route block.

        // Articles — DEVIATES from the canonical block (page-based create/edit, decided 2026-05-17).
        // List is still AJAX (data_table) + remove still POSTs JSON for the SweetAlert flow,
        // but addForm is replaced by two GET pages (create, edit) and save uses redirect-with-errors.
        Route::prefix('articles')->name('articles.')->controller(AdminArticleController::class)->group(function () {
            Route::get('/',                'index')->name('index');
            Route::post('data_table',      'getDataTable')->name('data_table');
            Route::get('create',           'create')->name('create');
            Route::get('{article}/edit',   'edit')->name('edit');
            Route::post('save',            'save')->name('save');   // handles both create + update via hidden `id`
            Route::post('remove',          'remove')->name('remove');
        });
    });
});
```

**Why not `Route::resource(...)`?** Resource routes are designed for the classic server-rendered REST cycle (GET /create → POST / → GET /{id}/edit → PUT /{id} → DELETE /{id}). Our AJAX-modal flow doesn't use create/edit GET pages or PUT/DELETE methods — every mutation is a POST returning JSON. Discrete routes match what's actually wired up. **Even for Articles** (the page-based exception): the list still uses an AJAX `data_table` endpoint, delete still goes through the JSON `remove` endpoint, and `save` is a single endpoint handling both create + update via a hidden `id` field — none of that matches `Route::resource`'s shape, so discrete routes stay the better fit there too.

---

## 6. Validation Strategy

- **Inline `Validator::make()` inside each controller's `save()` method** — NOT FormRequest classes. Reason: every save endpoint returns JSON `{status:0\|1, errors?, message}`, not a redirect-with-errors. FormRequest's automatic redirect flow doesn't fit JSON APIs.
- Same controller handles both create and update; the `unique` rule uses `unique:<table>,<col>,<id ?: 'NULL'>,id` so the current row is ignored when editing.
- Errors come back as `{status:0, errors:{field:[messages]}}` and are rendered as a SweetAlert-styled toast (list of bullet points). No per-field `is-invalid` rendering — toast is enough for the common case.
- **Articles is the one exception** (page-based, not modal — see §4 Phase 3). `ArticleController::save()` still validates with inline `Validator::make()` but on failure returns `back()->withInput()->withErrors($v)` instead of JSON. The form partial renders `@error('field')` blocks beneath each input and reads `old('field', $article->field ?? '')` for sticky values. Success flashes via `redirect()->route('admin.articles.index')->with('success', …)`, surfaced by the layout's existing session-flash zone. FormRequest classes could replace inline `Validator::make()` here since the redirect flow does fit, but inline keeps controller-shape consistency with the rest of the codebase.

Example (canonical pattern from `ActController::save`):

```php
$id = (int) $request->input('id', 0);

$v = Validator::make($request->all(), [
    'id'          => 'nullable|integer',
    'act'         => 'required|string|max:250|unique:acts,act,' . ($id ?: 'NULL') . ',id',
    'description' => 'nullable|string|max:65535',
]);

if ($v->fails()) {
    return response()->json(['status' => 0, 'errors' => $v->errors()]);
}

$act = Act::updateOrCreate(['id' => $id ?: null], [
    'act'         => $request->input('act'),
    'description' => $request->input('description'),
]);

return response()->json([
    'status'  => 1,
    'message' => $id ? 'Act updated successfully.' : 'Act created successfully.',
    'id'      => $act->id,
]);
```

- **File uploads** (Phase 3 — Circular attachments, etc.) — `mimes:pdf,doc,docx\|max:10240`. The save endpoint switches to accepting `multipart/form-data` (jQuery `FormData` on the client).
- **Mass assignment safety** — `Model::updateOrCreate(['id' => ...], [...explicit fields...])` is safer than passing `$request->all()` as the second argument, since the second array is hand-built from `$request->input('field')` calls. Even with `$fillable` on the model, this is the explicit guarantee.
- **Slug auto-generation** in model `boot()` where needed (none of the Phase 2 resources need slugs; revisit for Articles/Judgements if URL-friendly slugs are wanted).

---

## 7. Open Questions (need user input before/during build)

1. **Role granularity** — `Super Admin` (id=1) and `User` (id=2) already exist in the `roles` table. Should we add an `Editor` (edit content, not users) and/or `Viewer` (read-only) tier, or keep it Super-Admin-only for now? Note: adding rows is a one-time DB insert, no migration needed.
2. ~~**Rich-text editor**~~ — **resolved**: use **Quill** (ships with AdminKit Pro, styled in `css/light.css`, sample in `forms-editors.html`).
3. **File storage** — local `storage/app/public/` or external (S3, Cloudinary)?
4. **Soft deletes** — should deletions be reversible?
5. **Audit log** — required at launch or later?
6. **Image handling for articles** — inline in editor only, or a separate "media library" screen?
7. **Localization** — admin UI in English only or also Hindi (since content is bilingual)?

---

## 8. Risks & Mitigations

| Risk | Mitigation |
|---|---|
| The fat `UserController` collides with admin auth (shared `auth` middleware). | Keep admin login on a separate URL prefix; use the same `users` table but gate via the new `role` middleware. |
| AdminKit HTML uses relative asset paths (`css/app.css`). | Rewrite all `href`/`src` to `{{ asset('theme/admin/...') }}` during port. |
| Migration drift — `jotici_update.sql.zip` in `mydocs/` may contain schema beyond what migrations describe. | Inspect that dump before writing form requests; align migrations to schema, not the other way around. |
| Mass-assignment vulnerabilities. | Every Admin controller uses `FormRequest::validated()`, never `$request->all()` into `Model::create()`. |
| Existing public site breaks if shared assets change. | Admin assets stay in `public/theme/admin/`; user assets stay where they are. No cross-edit. |

---

## 9. Out of Scope

- Refactoring the existing 1,558-line user-facing `UserController` (separate effort).
- Frontend redesign of the public site.
- Public API/JSON endpoints (this plan covers server-rendered admin only).
- Email/notifications system.
- Two-factor auth.

---

## 10. Acceptance Criteria

The backend is "done" when:
1. ✅ `mpsjajournal@gmail.com` (Super Admin, user_id=1, role_id=1 via `user_roles`) can log in at `/admin/login` and reach the dashboard. **Verified 2026-05-17.**
2. ⏳ Dashboard shows live counts of every content type. (Currently shows demo values — first Phase 2 task.)
3. ❌ Every resource listed in §3 has working list / create / edit / delete with validation. (Phase 2 + 3.)
4. ✅ **Non-Super-Admin users cannot access `/admin/*` at all** — including `/admin/login`. **Verified 2026-05-17:**
   - Anonymous → sees login form, but submitting non-Super-Admin credentials returns "Oops! Invalid credentials." in a dismissible danger alert (no role leak).
   - Logged-in regular `User` visiting `/admin/login` → redirected to `/` (form never rendered).
   - Logged-in regular `User` visiting any other `/admin/*` → middleware bounces with a flash error, no view rendered.
5. ⏳ All forms have CSRF tokens and server-side validation; no raw `$request->all()` writes. (Login + 5 Phase-2 CRUDs ✓; rest of Phase 2/3 still to build, canonical `Validator::make` + `updateOrCreate(['id'=>…],[…explicit…])` pattern locked in.)
6. ⏳ Every list page has search and pagination. (5 CRUDs ✓ via server-side DataTables; rest of Phase 2/3 to build using the same `initDatatable()` pattern.)
7. ⏳ Admin layout matches the (rebranded) theme styling on all CRUD screens. (Layout + partials done; 5 CRUDs all use the same canonical modal+table pattern with consistent styling.)

**Legend:** ✅ done | ⏳ partial / in progress | ❌ not started

---

## 11. Implementation Log

### 2026-05-17 — Session 11: Articles form polish + project-wide conventions ✅ COMPLETE

Follow-up to Session 10. Five threads, all interrelated:

**1. Rich-text editor: Quill → CKEditor 5 Super Build (after a detour)**

The editor chain was:
- **Quill 1.3.7** (Session 10 initial ship) — worked but the user asked for CKEditor.
- **CKEditor 5 Classic** — worked, but the user wanted "full plugins" which is CK4 nomenclature.
- **CKEditor 4 Full (4.22.1)** — worked, but showed CKSource's "this version is not secure" nag banner on init.
- **CKEditor 4 LTS (4.25.1-lts)** — secure, but **silently refused to render without a paid `licenseKey`** (the LTS bundle has license-check code that bails when the key is empty — confirmed by grepping the CDN bundle for `licenseKey` / `atob` patterns).
- **CKEditor 5 Super Build (41.4.2)** ✅ — the final landing. Free, supported, includes most plugins via CDN. Pinned to 41.4.2 — versions 44+ introduced mandatory license keys for all features.

Init shape (in `resources/views/admin/articles/partials/form_scripts.blade.php`):
```js
CKEDITOR.ClassicEditor.create(document.getElementById('article-input'), {
    removePlugins: [/* every premium plugin: AIAssistant, TrackChanges, Comments, … */],
    toolbar: { items: [/* free-tier toolbar */], shouldNotGroupWhenFull: true },
    image: { toolbar: [...] }, table: { contentToolbar: [...] },
    language: 'en',
});
```

**The `removePlugins` allowlist is load-bearing.** Without it, premium plugins (AI Assistant, track changes, comments, real-time collaboration, pagination, format painter, etc.) try to initialise, detect the missing license, and take down the entire editor init.

Editor height set via CSS, not config: `.ck-editor__editable_inline { min-height: 500px; }` lives in the form partial.

**2. Form layout reflow** — `resources/views/admin/articles/partials/form.blade.php`:
- Row 1: Title (col-12, full width)
- Row 2: Author / Category / Subcategory (col-md-4 × 3)
- Row 3: Publish date (col-md-3 — compact, alone)
- Row 4: Article body (CKEditor)

**3. Flatpickr datepicker** on `publish_date`. Server contract preserved: visible alt input shows `dd-mm-YYYY`, hidden real input carries `Y-m-d`, and `ArticleController::save()`'s `date` validation rule keeps working unchanged. CDN: `https://cdn.jsdelivr.net/npm/flatpickr@4.6.13`. Init:
```js
flatpickr('#article-publish-date', {
    dateFormat: 'Y-m-d', altInput: true, altFormat: 'd-m-Y', allowInput: true,
});
```

**4. Date display helper** — `app/Helpers/dates.php`:
```php
function format_date(mixed $value, string $format = 'd-m-Y', string $placeholder = '—'): string
```
Accepts Carbon / string / null. Catches parse failures (returns placeholder). Registered via `composer.json` `autoload.files` → `app/Helpers/dates.php`. Used by `ArticleController::getDataTable()` for the Publish column. **Convention going forward** (see §3 "Date display convention"): every DataTable date column uses this helper instead of inline `optional()->format()`.

**5. Default sort: every list page now defaults to `<table>.id DESC`** — see §3 "Default sort convention" for the rule. Implementation:

Server side (every Phase 2 + Phase 3 controller's `getDataTable`):
```php
$orderIdx = (int) $request->input('order.0.column', -1);   // -1 default — never matches a real $columns index
$orderDir = $request->input('order.0.dir', 'asc') === 'desc' ? 'desc' : 'asc';
$orderCol = $columns[$orderIdx] ?? null;
if ($orderCol === null) {
    $orderCol = '<table>.id';   // qualified — every joined query would error on bare `id`
    $orderDir = 'desc';
}
```

Client side (every `index.blade.php`'s `initDatatable()` call): last arg flipped from `[[1, 'asc']]` to `[]` so DataTables doesn't override the server fallback on first load.

| Controller | Qualified id |
|---|---|
| ActController | `acts.id` |
| SectionController | `sections.id` |
| ArticleCategoryController | `article_category.id` |
| ArticleSubcategoryController | `article_subcategory.id` |
| CircularSubjectController | `circular_subject.id` |
| UserController | `users.id` |
| ArticleController | `articles.id` |

Column-click sorting still works — DataTables sends the user's chosen column on subsequent draws, which bypasses the fallback.

**Verified** (`php artisan tinker` calling `getDataTable` directly with no order param): the top row's `data-record-id` matches `<Model>::max('id')` for every list — Articles 852, Acts 655, Sections 5713, ArticleCategory 21, ArticleSubcategory 39, CircularSubject 15, Users 2084.

**Files updated in Session 11**

```
app/Helpers/dates.php                                                  NEW (format_date helper)
composer.json                                                          autoload.files += dates.php
app/Http/Controllers/Admin/{Act,Section,ArticleCategory,
    ArticleSubcategory,CircularSubject,User,Article}Controller.php     order fallback → <table>.id DESC
resources/views/admin/{acts,sections,article_categories,
    article_subcategories,circular_subjects,users,articles}/index.blade.php
                                                                       initDatatable's aaSorting → []
resources/views/admin/articles/partials/form.blade.php                 layout reflow + CK5 textarea + Flatpickr text input + CSS min-height
resources/views/admin/articles/partials/form_scripts.blade.php         CK5 init with removePlugins; Flatpickr init
resources/views/admin/articles/create.blade.php                        Flatpickr CSS + CK5 Super Build CDN
resources/views/admin/articles/edit.blade.php                          same
app/Http/Controllers/Admin/ArticleController.php                       data_table publish col now uses format_date()
```

**Pitfalls noted**

- CKEditor 4 LTS silently fails without a license key — no console error, no UI hint, the textarea just stays a plain textarea. If you ever see CK4 LTS in `<script src>` and the editor isn't rendering, that's the cause.
- CKEditor 5 Super Build is ~4.3 MB minified. Fine for an admin-only form, painful for any public page that loads it.
- CKEditor 5 normalises some legacy markup on load (inline `style="text-align: justify"` → `class=`). The 803 existing articles are author-CK4-style markup. Most round-trip fine, but watch for subtle formatting shifts if you save-without-edits an old article.
- `composer dump-autoload` is required after any change to `composer.json` `autoload.files` (not just `psr-4`).
- DataTables' `aaSorting: []` means "don't send any order on first load." Combined with the server fallback, this is the cleanest way to express "newest first by default, but let the user re-sort." Don't try to do this purely client-side by sorting a hidden id column — DataTables will fight you on subsequent draws.

### 2026-05-17 — Session 10: Articles CRUD ✅ COMPLETE (Phase 3.1 — first page-based CRUD)

> **Note (Session 11 follow-up):** the initial ship used **Quill** for the editor and a native `<input type="date">` for `publish_date`. Both were replaced in Session 11 (CKEditor 5 Super Build + Flatpickr). The schema, route shape, validation strategy, and `article_strip_tag` write-on-save logic described below are unchanged.

**Real schema** (verified against `jotici (1).sql` line 991 — 803 rows):

```
articles   (plural table name — every other content/taxonomy table is singular; articles is the outlier)
├── id                 INT PK
├── category_id        INT NOT NULL                  → article_category.id
├── subcategory_id     INT NULL                      → article_subcategory.id
├── title              VARCHAR(768) utf8mb4
├── article            LONGTEXT utf8                 -- the rich-text body (⚠ NOT `body`)
├── article_strip_tag  LONGTEXT utf8mb4              -- plaintext mirror; user-side search joins against this
├── author             VARCHAR(500) utf8mb4
├── publish_date       DATE NULL                     -- ⚠ NOT `published_at`
└── created_at         TIMESTAMP NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
                                                     -- no updated_at column; created_at auto-touches on UPDATE
```

**Files created**

```
app/Http/Controllers/Admin/ArticleController.php
app/Models/Article.php                              (updated — added $fillable, $casts; was a stub before)
resources/views/admin/articles/index.blade.php      (list + DataTable; "Add" is an anchor to /create — NO modal markup)
resources/views/admin/articles/create.blade.php     (full-page; loads Quill via @push('head') + @push('scripts'))
resources/views/admin/articles/edit.blade.php       (full-page; same Quill load)
resources/views/admin/articles/partials/form.blade.php         (shared form body — old() + @error())
resources/views/admin/articles/partials/form_scripts.blade.php (Quill init, Choices.js, subcategory client-side filter)
```

**Files updated**

```
routes/web.php                                      — added articles route block (6 routes)
resources/views/admin/partials/sidebar.blade.php   — wired Articles link; Content group auto-opens on /admin/articles/*
```

**Route shape** (deviates from the canonical 5-route block — see §3 contract + §5 plan)

| Verb | URI | Method |
|---|---|---|
| GET   | `/admin/articles`            | `index`        |
| POST  | `/admin/articles/data_table` | `getDataTable` |
| GET   | `/admin/articles/create`     | `create`       |
| GET   | `/admin/articles/{id}/edit`  | `edit`         (whereNumber) |
| POST  | `/admin/articles/save`       | `save`         |
| POST  | `/admin/articles/remove`     | `remove`       |

**Key implementation decisions**

- **`save()` uses server-side redirect**, not JSON — only deviation from the project's existing CRUD shape. Validation failure: `back()->withInput()->withErrors($v)` + `@error('field')` blocks under each input + `old('field', $article->field ?? '')` for sticky values. Success: `redirect()->route('admin.articles.index')->with('success', …)`, surfaced by the layout's existing `@if (session('success'))` zone.
- **`article_strip_tag` write-on-save** uses `trim(preg_replace('/\s+/u', ' ', strip_tags($body)))` — `strip_tags()` alone leaves HTML's raw whitespace + `&nbsp;` artifacts that make `LIKE %term%` search noisy. The collapse step produces a clean plaintext mirror. Without this column being populated, new articles silently disappear from the user-facing article search.
- **Cross-field validation** for subcategory-belongs-to-category lives in `$v->after()` (a closure on the Validator) rather than a custom Rule class — runs after field-level `exists` rules, so an invalid subcategory_id short-circuits before the cross-check needlessly hits the DB.
- **Quill** loaded via CDN (`@push('head')` + `@push('scripts')`) only on `create`/`edit` pages — keeps the global layout lean. Toolbar: headings (h1–h3), bold/italic/underline/strike, ordered/bullet lists, indent ±, alignment, blockquote, link, clean. Empty Quill renders `<p><br></p>`; the submit handler normalises that to `''` so the `required` rule on `article` catches a truly empty body.
- **Subcategory filtering** is client-side: `<option data-category="…">` attributes + JS that rebuilds the `<select>` (via `replaceChildren()`) and re-inits Choices.js whenever category changes. Chosen over an AJAX endpoint because there are only 30 subcategories total — preloading is trivial and avoids a round-trip per change. Switch to AJAX if subcategories grow past a few hundred.
- **Category dropdown** filters to `for_article = 1` (currently all 10 rows qualify, but the flag exists to support a future split between Article-only and Samasya-only categories).
- **Quill `dangerouslyPasteHTML`** is the supported way to hydrate existing HTML into Quill's internal model. Trust boundary: only authenticated super-admins reach the route (`role:super-admin` middleware), and they're hydrating content they or other super-admins authored through this same editor — standard CMS posture.

**Verified end-to-end** (controller methods exercised via `php artisan tinker`, pages via authenticated `curl`):

| Check | Result |
|---|---|
| `GET /admin/articles`                 | 200, list page renders (11 KB shell) |
| `GET /admin/articles/create`          | 200, form renders with empty fields (20 KB) |
| `GET /admin/articles/32/edit`         | 200, hydrates the 100KB LIMITATION ACT body into Quill |
| `POST /admin/articles/data_table`     | `{total:803, filtered:803, data:[7 cols per row]}` |
| `save()` with missing title           | 302 → back, error: "The title field is required." |
| `save()` with category=18 + subcat=9 (belongs to 12) | 302 → back, error: "The selected subcategory does not belong to the chosen category." |
| `save()` with valid create payload    | 302 → `/admin/articles`, flash "Article created successfully.", row inserted, `article_strip_tag` populated, `publish_date` round-trips through date cast |
| `remove()` with valid id              | `{status:1, message:"Article \"…\" deleted."}`, DB row gone |
| `remove()` with non-existent id       | `{status:0, message:"Article not found."}` |

**Pitfalls noted for future Phase 3 work**

- Plan's pre-build field guesses for Articles were wrong on three columns (`body` → `article`, `published_at` → `publish_date`, missing `article_strip_tag`). Same risk for every remaining Phase 3 resource — **verify schema against `jotici (1).sql` before writing any controller**.
- `articles` is the only plural table name in the content/taxonomy set. Don't typo it as `article` (singular) in joins or routes.
- The `ON UPDATE current_timestamp()` on `created_at` means it doubles as "last touched". Use it directly for any "Last modified" column — there is no `updated_at` to fall back on.

### 2026-05-17 — Session 9: Sidebar reorg + admin rebrand

Cosmetic / IA tidy-up — no controller or DB work. Three focused changes:

1. **Sidebar group regroup**
   - Moved **Acts** and **Sections** from the *Content* group to the *CMS* group. Rationale: they're classification/reference data (just like Article Categories, Subcategories, Circular Subjects) — content tables (Judgements, Amendments, etc.) join *into* them, they aren't content themselves.
   - Removed the **Journal Sections** placeholder from the group (was an unused `#` link; we'll add it back when we build the Journal Sections CRUD).
   - Updated the `$taxonomyOpen` computed flag to include `$isActs` and `$isSections` so the group auto-expands when on `/admin/acts/*` or `/admin/sections/*`.

2. **Sidebar group rename: Taxonomy → CMS**
   - Visible label switched to "CMS" — the Bootstrap collapse target id (`#taxonomy`) is untouched since it's an internal anchor, not a user-facing label.
   - The plan still uses "Taxonomy" as an *architectural* term elsewhere (§3 Folder Structure, Phase 2 progress table) because the semantic role of those tables is taxonomy regardless of the sidebar's display label.

3. **Brand rename: "Joti" → "JOTI"** in admin views only
   - Sidebar brand: `JotiJournal` → `JOTIJournal`
   - Layout `<title>`, meta description/author, footer.
   - Admin login page title + meta tags.
   - Public-facing site (`resources/views/users/*`, exports, front layout) **deliberately untouched** — the plan's §9 "Out of Scope" lists "Frontend redesign of the public site" so it stays branded as "JotiJournal".

**Files modified**

```
resources/views/admin/partials/sidebar.blade.php  — regroup + rename + brand swap
resources/views/layouts/admin.blade.php           — JotiJournal → JOTIJournal (title, meta, footer)
resources/views/admin/auth/login.blade.php        — JotiJournal → JOTIJournal (title, meta)
```

---

### 2026-05-17 — Session 8: Eye-toggle + Change Password flow

**What was added**

1. **Password-field eye toggle** — a reusable `.password-toggle` button pattern (input-group + delegated jQuery handler in `admin_custom.js`). Applied to the User CRUD form (both Password and Confirm Password inputs) and to all three fields on the Change Password page.
2. **Change Password screen** at `/admin/change-password` — dedicated full-page form (not a modal), reachable from the user dropdown in both sidebar and topbar.

**Files created**

```
resources/views/admin/auth/change_password.blade.php  — full-page form with 3 password fields, all with eye toggle
```

**Files modified**

```
app/Http/Controllers/Admin/AuthController.php       — added showChangePasswordForm() + changePassword()
routes/web.php                                       — GET + POST /admin/change-password inside super-admin middleware group
public/theme/admin/js/admin_custom.js                — global .password-toggle click handler
resources/views/admin/users/partials/form.blade.php  — wrapped Password / Confirm inputs in .input-group with eye buttons
resources/views/admin/partials/topbar.blade.php      — user dropdown: replaced "Profile" with "Change Password" link
resources/views/admin/partials/sidebar.blade.php     — sidebar user dropdown: same change
```

**Validation rules on `/admin/change-password`**
```
current_password : required|string
new_password     : required|string|min:8|confirmed|different:current_password
```
Plus an explicit `Hash::check($currentPassword, $user->password)` BEFORE accepting — defends against session-hijack scenarios where an attacker has the cookie but not the password.

**Security behavior**
- On success, `Auth::logoutOtherDevices($newPassword)` is called. This rotates the `remember_token` for OTHER sessions, kicking them out, while the current session continues uninterrupted.

**8-case verification** (all passed):
- Page loads with 3 password fields + 3 eye toggles
- Wrong current password → "Current password is incorrect."
- Mismatched confirmation → rejected
- New password identical to current → "must be different" error
- New password < 8 chars → rejected
- Successful change: DB hash updates, new password works in `Hash::check`, old password no longer works
- Re-login with new password → 302 → /admin
- Test cleanup: reset back to `password` so the existing test fixtures still work

---

### 2026-05-17 — Session 7: Users CRUD ✅ COMPLETE (Phase 4 pulled forward)

**Schema verified** (2,078 rows in production data):
```
users
├── id              INT PK
├── first_name      VARCHAR(250) NULL   -- legacy: full names stuffed here
├── last_name       VARCHAR(250) NULL   -- legacy: mostly NULL
├── email           VARCHAR(100) NULL
├── password        VARCHAR(200) NULL   -- hashed via Laravel 11 'hashed' cast
├── dob, image, city, address           -- legacy CodeIgniter columns, not used by admin form
├── is_active       INT(1) NOT NULL     -- 0=disabled, 1=active
├── token, expDate                      -- legacy, not used
├── remember_token  VARCHAR(100) NULL   -- added by our migration
├── created_at      TIMESTAMP NOT NULL
└── updated_at      TIMESTAMP NOT NULL
```

Role assignment is via the existing `user_roles` pivot — single role per user (assumption validated against current data: zero users with multiple roles).

**Files created**

```
app/Http/Controllers/Admin/UserController.php       — distinct from User\UserController (front-end user)
resources/views/admin/users/index.blade.php
resources/views/admin/users/partials/form.blade.php
```

**Files modified**

```
app/Models/User.php                               — added `is_active` to $fillable; hasRole() already present
routes/web.php                                    — added users route group (Phase 4 pulled forward)
resources/views/admin/partials/sidebar.blade.php  — Users link wired (Access group, no parent dropdown)
```

**Self-protection guards** (security-critical — these are what makes a "create users" feature safe):
1. **Cannot demote self** — if `id == auth()->id()` and submitted `role_id` ≠ Super Admin, reject with explicit "would lock you out" message.
2. **Cannot disable self** — same guard on `is_active`.
3. **Cannot delete self** — `remove()` short-circuits if `id == auth()->id()`.
4. **UI mirrors backend** — the form partial disables the role radios + active toggle + hides the delete button when editing your own row, with a yellow info notice. The backend re-checks regardless of what the UI sent.

**13-case verification** (all passed):
- Page shell 13.5 KB
- DataTable scales to 2,078 rows server-side (search by role / email both work)
- Create: password bcrypted via `'hashed'` cast (`$2y$12$...` prefix); `Hash::check('secret1234', stored)` returns MATCH
- Duplicate email → rejected
- Password mismatch (confirmation) → rejected
- Empty password on update → existing hash unchanged (confirmed before/after compare)
- **Self-demote** (id=1 → User role) → rejected: "You cannot change your own role…"
- **Self-disable** → rejected
- **Self-delete** → rejected
- Delete another user → succeeds (with `roles()->detach()` so the pivot row goes too)
- After all attempted attacks: `users` table still has 2,078 rows, Super Admin (id=1) still has Super Admin role + is_active=1

**Decisions made**
- **No `groupBy`** on the DataTable join — MySQL's `ONLY_FULL_GROUP_BY` mode (default in modern installs) rejects non-aggregated SELECT columns. Without groupBy the JOIN could theoretically duplicate rows for multi-role users, but current data has zero such users; if it ever becomes a real case, switch to `leftJoinSub` with an aggregate subquery.
- **Single role per user** (sync to one) — even though the `belongsToMany` relation allows multiple, the admin UI treats it as single-role. Switching to multi-role later only requires changing the radio inputs to checkboxes and the controller's `->sync([$id])` to `->sync($ids)`.
- **`'password' => 'hashed'` cast** on the User model (Laravel 11) auto-bcrypts on `$user->password = $plaintext; $user->save()`. No manual `Hash::make()` boilerplate.
- **Phase 4 pulled forward from "after Phase 3"** because the user needed to create new users immediately. Roles CRUD (Phase 2.8) and the password-reset flow (Phase 5) remain in their original phases.

---

### 2026-05-17 — Session 6: Circular Subjects CRUD ✅ COMPLETE

**Schema verified** (3 rows: CIVIL / CRIMINAL / OTHERS — simplest schema in Phase 2):
```
circular_subject     (singular table name)
├── id          INT PK
├── subject     VARCHAR(100)   -- column name is `subject`
└── created_at  TIMESTAMP NOT NULL   -- no updated_at, no description column
```

**Files created**

```
app/Http/Controllers/Admin/CircularSubjectController.php
resources/views/admin/circular_subjects/index.blade.php
resources/views/admin/circular_subjects/partials/form.blade.php
```

**Files modified**

```
app/Models/CircularSubject.php                    — added $fillable + $casts
routes/web.php                                    — added circular-subjects route group
resources/views/admin/partials/sidebar.blade.php  — Circular Subjects link wired; Taxonomy group opens on /admin/circular-subjects/* too
```

**9-case verification** (all passed) — focused on the no-duplicate requirement against live data:
- DataTable: total=3, displays the 3 existing rows
- **Duplicate prevention against live data** (all rejected): `CRIMINAL`, `criminal`, `Criminal`, `  CIVIL  `, `OTHERS`
- New name → created (id=16), `created_at` manually set to `2026-05-17 07:47:43`
- Update keeping same name → succeeds (self-ignore)
- Update renaming to existing `CIVIL` → rejected
- Missing subject → "The subject name field is required."
- Delete works

**Decisions made**
- This is the simplest CRUD in Phase 2 — top-level, single text field, no description. Worth keeping in mind as the **minimal template**: any future top-level lookup table (e.g., the future Journal table once we verify its schema) can be cloned from this with just a column rename.
- No Choices.js anywhere — 3 options doesn't justify the library.

---

### 2026-05-17 — Session 5: Article Subcategories CRUD ✅ COMPLETE

**Schema verified** (30 rows):
```
article_subcategory   (singular table name)
├── id                   INT PK
├── article_category_id  INT NULL  → article_category.id
├── subcategory          VARCHAR(100)  -- column name is `subcategory`, NOT `name`
├── description          TEXT NULL
└── created_at           TIMESTAMP NOT NULL  -- no updated_at (same as article_category)
```

**Files created**

```
app/Http/Controllers/Admin/ArticleSubcategoryController.php
resources/views/admin/article_subcategories/index.blade.php       (Choices.js init for category select)
resources/views/admin/article_subcategories/partials/form.blade.php
```

**Files modified**

```
app/Models/ArticleSubcategory.php                 — added $fillable + $casts (datetime created_at)
routes/web.php                                    — added article-subcategories route group
resources/views/admin/partials/sidebar.blade.php  — Article Subcategories link wired; Taxonomy group opens on /admin/article-subcategories/* too
```

**13-case verification** (all passed):
- Page shell 14.0 KB
- DataTable: total=30, LEFT JOIN `article_category` exposes parent category name in column 2
- addForm renders 11 `<option>`s (10 categories + placeholder)
- Create under category_id=13 → succeeds (id=40)
- **Duplicate prevention scoped to same category**: exact / UPPERCASE / lowercase / whitespace-wrapped — all rejected
- **Same name under DIFFERENT category** (id 12 vs 13) → allowed (scoping correct)
- Update keeping same name → succeeds (self-exclusion correct)
- Missing `article_category_id` → "The selected category is invalid."
- Non-existent `article_category_id=99999` → same error (Laravel `exists:article_category,id` rule)
- Delete removes the row

**Decisions made**
- This CRUD is structurally identical to Sections-under-Acts; only the table / column / route names differ. Worth noting as a **template** for any future child taxonomy: copy ArticleSubcategoryController + the two views, rename `article_category_id → <parent>_id`, `subcategory → <child column>`, swap the model classes, done.
- Choices.js used despite the dropdown having only 10 options — kept for visual consistency with the Sections form. With only 10 options it could be a plain `<select>` without any UX hit; if Choices.js ever becomes a perf concern, dropping it here is the easy win.

---

### 2026-05-17 — Session 4: Article Categories CRUD ✅ COMPLETE

**Schema verified** (10 rows):
```
article_category    (note: singular table name)
├── id             INT PK
├── category       VARCHAR(500)  -- column name is `category`, NOT `name`
├── for_article    INT(1) NOT NULL  -- 1 = visible to Articles dropdown
├── for_question   INT(1) NOT NULL  -- 1 = visible to Samasya (Q&A) dropdown
├── description    TEXT NULL
└── created_at     TIMESTAMP NOT NULL  -- ⚠ NO updated_at column!
```

**Files created**

```
app/Http/Controllers/Admin/ArticleCategoryController.php
resources/views/admin/article_categories/index.blade.php
resources/views/admin/article_categories/partials/form.blade.php
```

**Files modified**

```
app/Models/ArticleCategory.php                    — added $fillable + $casts (datetime for created_at, integer for the two flags)
routes/web.php                                    — added article-categories route group
resources/views/admin/partials/sidebar.blade.php  — Article Categories link wired under Taxonomy; group auto-expands on /admin/article-categories/*
```

**11-case verification** (all passed):
- Page shell 13.4 KB
- DataTable returns 10 rows with `for_article` / `for_question` rendered as **Yes/No badges** (green Yes via `bg-success`, grey No via `bg-secondary`)
- Create with both flags → succeeds, `created_at` manually set by controller to `now()`
- **Duplicate prevention** (against existing live data, all rejected): `"CRIMINAL PROCEDURE CODE"`, `"criminal procedure code"`, `"Criminal Procedure Code"`, `"  CIVIL PROCEDURE CODE  "`, `"SUBSTANTIVE LAWS"`
- **"At least one flag" rule** — submitting with both flags unchecked returns `for_article` error: "Select at least one of 'For Article' or 'For Question'…"
- Only `for_question=1` succeeds
- Update keeping the same name → succeeds (self-exclusion works)
- Missing `category` → "The category name field is required."
- Delete removes the row

**Decisions made**

- **Manual `created_at` on insert** (no `updated_at` to auto-track): `$payload['created_at'] = now()` only on create; updates use `fill()->save()` and don't touch `created_at`. Model keeps `$timestamps = false`.
- **`$casts = ['created_at' => 'datetime', 'for_article' => 'integer', 'for_question' => 'integer']`** — needed because without auto-timestamps, Eloquent doesn't cast `created_at` to Carbon. Without this cast, `optional($model->created_at)->format(...)` silently returns null and the index column shows `—` instead of dates. (Caught and fixed during testing.)
- **Business rule "at least one flag enabled"** lives in the controller, not the validator — it's a cross-field rule that's easier to express with a plain `if (!$forArticle && !$forQuestion)` than with a custom Laravel rule class.
- **`$request->boolean('for_article')`** handles unchecked checkboxes correctly: when a checkbox is unchecked, browsers don't send the field at all, so `boolean()` defaults to false. Cleaner than the manual `($request->input('for_article') == '1' ? 1 : 0)` ternary.
- **Sidebar Taxonomy group** now auto-expands on `/admin/article-categories/*` (same `$taxonomyOpen` pattern that Content uses for `$contentOpen`).

---

### 2026-05-17 — Session 3: Sections CRUD ✅ COMPLETE

**Schema verified** (5,479 rows):
```
sections
├── id           INT PK
├── act_id       INT → acts.id (indexed, no FK constraint)
├── section      VARCHAR(250) (column name is `section`, NOT `name` or `section_no`)
├── description  TEXT NULL
├── created_at   TIMESTAMP NOT NULL
└── updated_at   TIMESTAMP NOT NULL
```

Column collation `utf8_general_ci` (case-insensitive by default), but the controller doesn't rely on collation — it does an explicit `LOWER(TRIM(section))` comparison so the behavior survives a collation change.

**Files created**

```
app/Http/Controllers/Admin/SectionController.php       — index, getDataTable (LEFT JOIN acts), getForm, save, remove
resources/views/admin/sections/index.blade.php         — page shell + #addNewModal + Choices.js init for Act dropdown
resources/views/admin/sections/partials/form.blade.php — act_id <select> (600 options), section name input, description textarea
```

**Files modified**

```
app/Models/Section.php                            — enabled timestamps, added $fillable=['act_id','section','description']
routes/web.php                                    — added Sections route group (same 5-endpoint shape as Acts)
resources/views/admin/partials/sidebar.blade.php  — Sections link wired; Content group expands on /admin/sections/* too
```

**13-case verification** (all passed):
- Page shell 13.7 KB
- DataTable JOIN returns total=5479, joined Act name in column 2
- addForm renders 601 `<option>`s (600 acts + placeholder)
- Create succeeds
- **Duplicate prevention tests** — exact match, UPPERCASE, lowercase, leading/trailing whitespace **all rejected** with the same error message
- Same name under a different Act → succeeds (scoping works)
- Update keeping the same name → succeeds (self-exclusion works)
- Missing act_id → validation error
- Non-existent act_id → `exists:acts,id` rule fails
- Delete removes the row

**Decisions made**
- The uniqueness check is explicit `LOWER(TRIM(section))` — see §2 architecture decision for the why.
- The Acts `<select>` is rendered as plain HTML server-side, then **Choices.js** is initialised on it in the `success` callback of the addForm AJAX. Previous instance is `.destroy()`ed on modal close to avoid orphans.
- Same submit-button pattern as Acts: `{{ $section?->id ? 'Update' : 'Save' }}` with adaptive icon (`fa-pencil-alt` for edit, `fa-save` for create).

---

### 2026-05-17 — Session 2: Acts CRUD (two revisions in one session)

**Rev 1 (rolled back later):** server-rendered `Route::resource('acts')` with separate `/create`, `/edit` views, `_form.blade.php` partial, and `Store/UpdateActRequest` form requests. Worked but the index rendered all 600 rows server-side (570 KB) and used native `confirm()` for delete.

**Rev 2 (current, pattern ported from `/opt/lampp/htdocs/enotary`):** AJAX-driven page shell + modal + SweetAlert. Page size 12.7 KB (45× smaller). Table data, form HTML, save, and delete all happen via AJAX. This is the new standard.

**Files created (rev 2)**

```
app/Http/Controllers/Admin/ActController.php                  — index, getDataTable, getForm, save, remove
public/theme/admin/js/admin_custom.js                          — bs5button, backDropModel, showToast, .remove SweetAlert (copied from enotary)
public/theme/datatable.custom.js                               — initDatatable() helper (copied from enotary)
resources/views/admin/acts/index.blade.php                    — page shell with empty table + #addNewModal
resources/views/admin/acts/partials/form.blade.php            — form HTML returned by addForm endpoint
```

**Files modified**

```
app/Models/Act.php                                — enabled timestamps, added $fillable=['act','description']
routes/web.php                                    — replaced Route::resource('acts') with discrete POST endpoints (acts.data_table, acts.addForm, acts.save, acts.remove)
resources/views/admin/partials/sidebar.blade.php  — Acts link wired to admin.acts.index; auto-expands Content group on /admin/acts/*
resources/views/layouts/admin.blade.php           — added jQuery 3.7.1 + DataTables 2.1.8 + Buttons 3.1.2 + jszip + pdfmake + SweetAlert2 (all CDN), admin_custom.js + datatable.custom.js (local), #toastArea container, and inline modal-loader CSS (.modal_loader_div / .minheight rules ported from enotary)
```

**Later in the session**
- **Description column removed** from the Acts list table (still searchable, still editable in modal). Header reduced to 5 columns: `S.No. | Act | Sections | Updated | Action`. `ActController::getDataTable` updated to return 5-col rows and the `$columns` order-map adjusted accordingly.
- **Modal AJAX-loader fixed** — the `.modal_loader_div` had no CSS, so the `.active` toggle was a no-op (spinner sat at top-left of modal body, never hid). Added the 4 CSS rules from enotary's `custom.css` inline in the admin layout `<head>`. Loader now appears centered with a white overlay while `addForm` fetches, hides on `complete`.

**Files deleted (rev 1 → rev 2 cleanup)**

```
app/Http/Requests/Admin/StoreActRequest.php       (validation now inline in ActController::save via Validator::make)
app/Http/Requests/Admin/UpdateActRequest.php
resources/views/admin/acts/create.blade.php       (replaced by modal)
resources/views/admin/acts/edit.blade.php         (replaced by modal)
resources/views/admin/acts/_form.blade.php        (replaced by partials/form.blade.php)
```

**Decisions made along the way**

- Switched from AdminKit's bundled `js/datatables.js` (which bundles jQuery + DataTables 1.x) to CDN-hosted jQuery 3.7.1 + DataTables 2.1.8 — avoids the conflict of two jQueries on one page and matches the enotary pattern exactly.
- Validation runs **inline in the controller** via `Validator::make()` (not FormRequest classes) because the save endpoint returns JSON, not a redirect — FormRequest's automatic redirect-with-errors flow doesn't suit JSON APIs.
- The `unique:acts,act,{$id},id` rule with `($id ?: 'NULL')` substitution lets one save endpoint handle both create and update — no separate Store/Update request needed.
- `withCount('sections')` on the DataTables query avoids N+1 when rendering the Sections column.
- All AJAX requests inherit the CSRF token via the `$.ajaxSetup` in `admin_custom.js`.

---

### 2026-05-17 — Session 1: Foundation built

**Files created**

```
app/Http/Controllers/Admin/AuthController.php
app/Http/Controllers/Admin/DashboardController.php
app/Http/Middleware/EnsureUserHasRole.php
database/migrations/2026_05_17_065704_add_remember_token_to_users_table.php
resources/views/layouts/admin.blade.php
resources/views/admin/auth/login.blade.php
resources/views/admin/dashboard.blade.php
resources/views/admin/partials/sidebar.blade.php
resources/views/admin/partials/topbar.blade.php
```

**Files modified**

```
app/Models/User.php                     — added hasRole(string $role): bool
bootstrap/app.php                       — 'role' middleware alias + redirectGuestsTo() callback
routes/web.php                          — admin route group
public/theme/admin/js/settings.js       — abs paths, branding strip (backup: settings.js.bak)
public/theme/admin/css/light.css        — copyright header removed (backup: light.css.bak)
public/theme/admin/css/dark.css         — copyright header removed (backup: dark.css.bak)
```

**One-off database actions**

- Migration `add_remember_token_to_users_table` run.
- `mpsjajournal@gmail.com` password reset to `password` via `php artisan tinker`. **⚠ Change before any non-local deployment.**

**Theme/visual state**

- The bottom-right settings widget says **"Theme Settings"** (was "Explore AdminKit Pro"), opens with Color scheme / Sidebar layout / Sidebar position / Layout choices. No "Get AdminKit PRO" CTA at the bottom.
- Sidebar branded as **"JotiJournal Admin"** with placeholder nav for Content / Taxonomy / Access groups (all `#` until Phase 2 wires real routes).
- Dashboard renders all demo widgets faithfully — needs Phase 2 to swap in real counts.
