# Dynamic Tables — Final Implementation Plan (v2)

## Confirmed Decisions (From Your Answers)

| Question | Decision |
|---|---|
| Q1 — Pagination scope | ALL listings get server-side pagination, no exceptions |
| Q2 — Search behavior | Option A — full page reload (GET request with `?search=term`) |
| Q3 — Sort behavior | Option A — full page reload with `?sort_col=X&sort_dir=asc` |
| Q4 — Per-page selector | YES — present on ALL listings |
| Q5 — Filter forms on Tier 1 pages | Option A — add name search + status filter bar to every page |
| Q6 — Schemes page | EXCLUDED from this phase (skip) |
| Q7 — PC Forms page | EXCLUDED from this phase (skip) |
| Q8 — Backend pattern | Option A — PHP Trait `HasTableFilters` |

Additional requirement confirmed: All UI states must be handled — loading, error, empty, success, no-results.

---

## Pages In Scope (Final List)

### Group A — Tier 1 → Convert to server-side (currently all-data, static DataTables)

| Page | Controller | View File |
|---|---|---|
| Designations | `DesignationsController` | `designations/index` |
| Sections | `SectionController` | `sections/index` |
| Sectors | `SectorsController` | `sectors/index` |
| Funding Sources | `FundingSourcesController` | `funding-sources/index` |
| MEC Categories | `MecCategoryController` | `mec-categories/index` |
| MEC Regions | `MecRegionController` | `mec-regions/index` |
| MEC Sub-Sectors | `MecSubSectorController` | `mec-sub-sectors/index` |
| MEC SIMS Visits | `MecSimsVisitsController` | `mec-sims/visits/index` |
| MEC SIMS Modifications | `MecSimsModificationController` | `mec-sims/modification/index` |
| MEC SIMS Compliances | `MecSimsCompliancesController` | `mec-sims/compliances/index` |
| MEC SIMS Reports | `MecSimsReportsController` | `mec-sims/reports/index` |
| Chart of Accounts | `ChartOfAccountsController` | `chart-of-accounts/index` |
| Films Historical | `FilmsHistoricalController` | `films/historical/index` |
| SEO All Slugs | `SettingsController` | `seo-settings/all-slugs/index` |
| SEO Redirects | `SettingsController` | `seo-settings/redirects/index` |
| Support Admin Tickets | `SupportAdminController` | `support/admin/tickets/index` |
| Support Tickets | `SupportController` | `support/tickets/index` |
| Reports Compliance Replies | `ReportsComplianceRepliesController` | `reports/compliance-replies/index` |
| User Analytics | `UserAnalyticsController` | `useranalytics/index` |
| Inquiries | `InquiresController` | `inquiries/index` |

### Group B — Tier 2 → Add server search + server sort + per-page (pagination already works)

| Page | Controller | View File | Notes |
|---|---|---|---|
| Users | `UsersController` | `users/index` | Has filter form, add search + server sort |
| Plans | `PlanController` | `plan/index` | Has filter form, add server sort + per-page |
| Project Concept Notes | `ProjectConceptNoteController` | `project-concept-notes/index` | Add search, server sort, per-page |
| Registration Submissions | `RegistrationSubmissionsController` | `registration-submissions/index` | Add server sort, per-page |
| Scheme Plan Changes | `PlanSchemeChangeRequestController` | `scheme-plan-changes/index` | Add server sort, per-page |
| Permissions | `PermissionsController` | `permissions/index` | Add search, server sort, per-page |

### Pages Excluded From This Phase

| Page | Reason |
|---|---|
| Schemes | Too complex, own filter system — excluded by user request |
| PC Forms | 9,139-line file, complex — excluded by user request |
| BOQ / CSR Costing | Separate subsystem |
| Activity Logs | Already fully dynamic AJAX — DO NOT TOUCH |
| Messenger, Notifications | Not listing tables |
| Dashboard, Dynamic Dashboard | Stats pages, no listing |
| Registration (wizard) | Form flow, not listing |
| Reports (builder) | Complex custom system |

---

## New Shared Infrastructure (To Be Created)

### 1. PHP Trait — `app/Traits/HasTableFilters.php`

Provides reusable methods for ALL controllers that serve paginated listings:

```php
namespace App\Traits;

use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;

trait HasTableFilters
{
    /**
     * Resolve sort column and direction from request.
     * Validates direction to prevent SQL injection.
     */
    protected function resolveTableSort(
        Request $request,
        string $defaultCol,
        string $defaultDir = 'desc',
        array $allowedCols = []
    ): array {
        $col = $request->input('sort_col', $defaultCol);
        // Whitelist check if allowed cols provided
        if (!empty($allowedCols) && !in_array($col, $allowedCols, true)) {
            $col = $defaultCol;
        }
        $dir = strtolower($request->input('sort_dir', $defaultDir));
        $dir = in_array($dir, ['asc', 'desc']) ? $dir : $defaultDir;
        return ['col' => $col, 'dir' => $dir];
    }

    /**
     * Resolve per-page from request. Whitelisted to prevent abuse.
     */
    protected function resolvePerPage(Request $request, int $default = 25): int
    {
        $allowed = [10, 25, 50, 100];
        $val = (int) $request->input('per_page', $default);
        return in_array($val, $allowed) ? $val : $default;
    }

    /**
     * Resolve and sanitize search string.
     */
    protected function resolveSearch(Request $request): string
    {
        return trim((string) $request->input('search', ''));
    }

    /**
     * Append all current request params (except page) to paginator links.
     */
    protected function appendFiltersToLinks(
        LengthAwarePaginator $paginator,
        Request $request
    ): LengthAwarePaginator {
        return $paginator->appends($request->except('page'));
    }

    /**
     * Build the array of active filter chips for the view.
     * Returns array of ['label' => 'Name: John', 'remove_url' => '...'] 
     */
    protected function buildActiveFilterChips(Request $request, array $filterMap): array
    {
        $chips = [];
        $baseUrl = $request->url();
        $allParams = $request->except(['page', 'sort_col', 'sort_dir', 'per_page']);
        
        foreach ($filterMap as $param => $label) {
            $value = $request->input($param, '');
            if ($value !== '' && $value !== null) {
                $removeParams = array_diff_key($allParams, [$param => null]);
                $chips[] = [
                    'label'      => $label . ': ' . $value,
                    'remove_url' => $baseUrl . '?' . http_build_query($removeParams),
                ];
            }
        }
        return $chips;
    }
}
```

---

### 2. Shared Filter CSS Partial — `resources/views/partials/filter-card-styles.blade.php`

Centralizes the duplicated `.pms-filter-card`, `.pms-filter-field`, `.pms-filter-actions` styles
that are currently copy-pasted across Users, Plans, Scheme Plan Changes, and other pages.

The class names will be standardized to `pms-filter-*` so they don't conflict with existing
page-specific classes.

---

### 3. Blade Component — `resources/views/components/table-filter-card.blade.php`

A reusable filter form wrapper component:

```blade
@props([
    'action',           // form action URL
    'method' => 'GET',
    'id' => 'tableFilterForm',
    'resetUrl',
])

<div class="card pms-filter-card mb-3">
    <div class="card-body">
        <form method="{{ strtoupper($method) }}" action="{{ $action }}" id="{{ $id }}" class="pms-filter-form">
            {{ $slot }}
            <div class="pms-filter-actions">
                <button type="submit" class="btn btn-primary pms-btn-gradient pms-filter-actions__submit">
                    <i class="fas fa-search mr-1"></i> Filter
                </button>
                <a href="{{ $resetUrl }}" class="pms-filter-actions__reset" title="Reset all filters">
                    <i class="fas fa-sync-alt"></i>
                </a>
            </div>
        </form>
    </div>
</div>
```

---

### 4. Blade Component — `resources/views/components/per-page-selector.blade.php`

```blade
@props(['current' => 25, 'options' => [10, 25, 50, 100]])

<div class="pms-per-page d-inline-flex align-items-center gap-1">
    <span class="pms-per-page__label text-muted small">Per page:</span>
    <select class="pms-per-page__select form-control form-control-sm" 
            onchange="pmsChangePerPage(this.value)"
            aria-label="Records per page">
        @foreach ($options as $opt)
            <option value="{{ $opt }}" @selected((int)$current === $opt)>{{ $opt }}</option>
        @endforeach
    </select>
</div>
```

---

### 5. Blade Component — `resources/views/components/active-filter-chips.blade.php`

Shows active filters as removable pills:

```blade
@props(['chips' => [], 'totalCount' => null])

@if (!empty($chips))
<div class="pms-filter-chips mb-2 d-flex flex-wrap align-items-center gap-2">
    <span class="pms-filter-chips__label text-muted small">Active filters:</span>
    @foreach ($chips as $chip)
        <a href="{{ $chip['remove_url'] }}" class="pms-filter-chip">
            <span>{{ $chip['label'] }}</span>
            <i class="fas fa-times ml-1"></i>
        </a>
    @endforeach
    @if($totalCount !== null)
        <span class="pms-filter-chips__count text-muted small">{{ number_format($totalCount) }} result(s)</span>
    @endif
</div>
@endif
```

---

### 6. Blade Component — `resources/views/components/table-empty-state.blade.php`

Handles: no records (fresh), no results (filtered), loading, error states.

```blade
@props([
    'state' => 'empty',       // 'empty' | 'no-results' | 'loading' | 'error'
    'message' => null,
    'hint' => null,
    'createRoute' => null,
    'createLabel' => 'Add New',
    'resetUrl' => null,
    'errorDetails' => null,
])
```

---

### 7. Enhanced `pms-index-datatable.js`

Add `serverSort` mode to `initLaravelPaginated()`. When enabled:
- Clicking a column header does NOT sort the DOM — instead builds a URL with `?sort_col=X&sort_dir=asc` and redirects.
- Current filters/search params are preserved.
- Visual: shows an arrow indicator on the active sort column header.

```js
// New public method: attach server sort to table headers
PmsIndexDataTable.attachServerSort = function(tableSelector, options) {
    var $table = $(tableSelector);
    var baseUrl = options.baseUrl;
    var currentQuery = options.currentQuery || {};
    var currentSortCol = options.currentSortCol || '';
    var currentSortDir = options.currentSortDir || 'asc';
    var colMap = options.colMap || {};  // { colIndex: 'db_column_name' }

    $table.find('thead th[data-sort-col]').each(function() {
        var $th = $(this);
        var col = $th.data('sort-col');
        var isActive = col === currentSortCol;
        var nextDir = isActive && currentSortDir === 'asc' ? 'desc' : 'asc';

        $th.addClass('pms-sortable-th');
        if (isActive) {
            $th.addClass('pms-sort-active pms-sort-' + currentSortDir);
        }
        $th.css('cursor', 'pointer').on('click', function() {
            var q = $.extend({}, currentQuery, { sort_col: col, sort_dir: nextDir, page: 1 });
            window.location.href = baseUrl + '?' + $.param(q);
        });
    });
};
```

---

## Implementation Steps (Execution Order)

> [!NOTE]
> Each phase is self-contained. We do Phase 1 first, verify nothing breaks, then proceed.

### Phase 1 — Backend Trait + Shared CSS (Foundation)

**Deliverables:**
- `app/Traits/HasTableFilters.php` — new file
- `resources/views/partials/filter-card-styles.blade.php` — new file (centralized CSS)
- `resources/views/components/table-filter-card.blade.php` — new Blade component
- `resources/views/components/per-page-selector.blade.php` — new Blade component
- `resources/views/components/active-filter-chips.blade.php` — new Blade component
- `resources/views/components/table-empty-state.blade.php` — new Blade component
- `public/assets/js/pms-index-datatable.js` — add `attachServerSort` + server sort support

**Risk**: None — new files only, no existing files modified yet.

---

### Phase 2 — Group A: Simple Reference Tables (Designations, Sections, Sectors, MEC*)

For each page in Group A (20 pages), apply the same pattern:

#### Backend (Controller)
1. Add `use HasTableFilters;` trait
2. Replace `new Request(array_merge([...]))` pattern with trait methods
3. Ensure Service `list()` returns `LengthAwarePaginator` not `Collection`
4. Pass `$sort`, `$search`, `$perPage`, `$activeChips` to view

#### Backend (Service / Query)
Update each service's `list()` method to:
```php
->when($search !== '', fn($q) => $q->where('name', 'LIKE', "%{$search}%"))
->orderBy($sortCol, $sortDir)
->paginate($perPage)
```

#### View
1. Remove inline filter CSS → `@include('partials.filter-card-styles')`
2. Add `<x-table-filter-card>` with search input + status dropdown
3. Add `<x-active-filter-chips>` below filter card
4. Switch `PmsIndexDataTable.init()` → `PmsIndexDataTable.initLaravelPaginated()`
5. Add `data-sort-col` attributes to `<th>` elements
6. Add `PmsIndexDataTable.attachServerSort()` call in scripts
7. Add `<x-per-page-selector>` in pagination bar
8. Add proper `<x-table-empty-state>` for all states

---

### Phase 3 — Group B: Tier 2 Pages (Users, Plans, PCN, etc.)

For each page in Group B (6 pages), apply the additional features:

#### Backend
1. Add `use HasTableFilters;` trait to controller
2. Add sort resolution: `$sort = $this->resolveTableSort($request, 'id', 'desc', $allowedCols);`
3. Add per-page resolution: `$perPage = $this->resolvePerPage($request, 25);`
4. Pass sort and per-page to Service `list()` (Services already handle the heavy lifting)
5. Return sort/perPage to view

#### View
1. Add search text input to existing filter form (if missing)
2. Add `data-sort-col` attributes to `<th>` elements
3. Add `PmsIndexDataTable.attachServerSort()` call
4. Add `<x-per-page-selector>` in pagination bar
5. Add `<x-active-filter-chips>` below filter card
6. Add server sort support for JS

---

### Phase 4 — UI States (Loading, Error, Empty, No-Results)

All pages must handle these states gracefully:

#### States Required on Every Listing Page

| State | When It Appears | UI Treatment |
|---|---|---|
| **Loading** | Page is loading (navigation in progress) | Skeleton rows or spinner overlay on table body |
| **Empty** | Table has zero records (fresh, no data yet) | Centered icon + "No records yet" message + Create button |
| **No Results** | Filters/search returned zero rows | "No matching records" message + "Clear filters" link |
| **Error** | Laravel server error (500, etc.) | Error alert with retry link |
| **Success** | CRUD operation completed | Flash toast via existing SweetAlert system (already in place) |
| **Partial** | Last page has fewer rows than per-page | Handled naturally by pagination |

#### Implementation

**Empty state** — Server renders it when `$collection->isEmpty()`:
```blade
@if($records->isEmpty())
    <x-table-empty-state
        :state="request()->anyFilled(['search', 'status', 'name']) ? 'no-results' : 'empty'"
        :createRoute="can('...', 'add') ? route('...create') : null"
        createLabel="Add New"
        :resetUrl="route('...index')"
    />
@endif
```

**Loading state** — CSS-based: `.pms-table-loading` class on wrapper shows skeleton shimmer
during normal page navigation (added/removed via JS `beforeunload`/`DOMContentLoaded`).

**Error state** — Already handled by Laravel's exception handling + existing `session-flash-swal.blade.php`.

**Success/failure flash** — Already handled by `RealRashid\SweetAlert\Facades\Alert` used throughout app.

The `x-table-empty-state` component will render differently per state.

---

## Standardized URL Query Parameter Convention

ALL listings across the app will use these consistent parameter names:

| Parameter | Type | Example | Description |
|---|---|---|---|
| `search` | string | `?search=john` | Free-text search across key columns |
| `sort_col` | string | `?sort_col=name` | DB column name to sort by |
| `sort_dir` | string | `?sort_dir=asc` | Direction: `asc` or `desc` |
| `per_page` | int | `?per_page=25` | Records per page: 10, 25, 50, 100 |
| `page` | int | `?page=2` | Laravel paginator page |
| `status` | string/int | `?status=1` | Active/inactive filter |
| Other filters | varies | `?permission_id=3` | Specific to each page |

---

## Shared CSS Classes (Standardized)

All filter forms will use these class names (defined once in `filter-card-styles.blade.php`):

```css
.pms-filter-card          /* The card wrapper */
.pms-filter-form          /* The <form> element */
.pms-filter-field         /* Each filter input wrapper */
.pms-filter-field label   /* Filter label */
.pms-filter-actions       /* Submit + reset button wrapper */
.pms-filter-actions__submit  /* Submit button */
.pms-filter-actions__reset   /* Reset icon link */
.pms-filter-chip          /* Active filter pill */
.pms-filter-chips         /* Pills container */
.pms-per-page             /* Per-page selector wrapper */
.pms-per-page__select     /* Per-page <select> */
.pms-sortable-th          /* Sortable column header */
.pms-sort-active          /* Active sort column header */
.pms-sort-asc             /* Sort ascending indicator */
.pms-sort-desc            /* Sort descending indicator */
.pms-table-empty-state    /* Empty state container */
.pms-table-loading        /* Loading skeleton overlay */
```

---

## Pages NOT Modified in This Phase

| Page | Status |
|---|---|
| Schemes | Excluded — already complex, user requested skip |
| PC Forms | Excluded — 9,139-line file, user requested skip |
| Activity Logs | Excluded — already has own full AJAX system |
| BOQ / CSR | Excluded — separate subsystem |
| Messenger | Excluded — not a listing table |
| Notifications | Excluded — not a listing table |
| Dashboard | Excluded — stats page |
| Registration | Excluded — wizard/form |
| Reports | Excluded — custom report builder |

---

## Complete File Inventory

### New Files (Create)

| File | Type | Purpose |
|---|---|---|
| `app/Traits/HasTableFilters.php` | PHP Trait | Backend: sort, per-page, search, chip helpers |
| `resources/views/partials/filter-card-styles.blade.php` | Blade Partial | Centralized filter CSS |
| `resources/views/components/table-filter-card.blade.php` | Blade Component | Reusable filter form wrapper |
| `resources/views/components/per-page-selector.blade.php` | Blade Component | Per-page dropdown |
| `resources/views/components/active-filter-chips.blade.php` | Blade Component | Active filter chips |
| `resources/views/components/table-empty-state.blade.php` | Blade Component | Empty/no-results/error state |

### Modified Files (Backend)

| File | Change |
|---|---|
| `app/Http/Controllers/DesignationsController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/SectionController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/SectorsController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/FundingSourcesController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/MecCategoryController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/MecRegionController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/MecSubSectorController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/MecSimsVisitsController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/MecSimsCompliancesController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/MecSimsModificationController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/MecSimsReportsController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/ChartOfAccountsController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/FilmsHistoricalController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/SettingsController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/SupportAdminController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/SupportController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/ReportsComplianceRepliesController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/UserAnalyticsController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/InquiresController.php` | Add trait, server sort/search/perpage |
| `app/Http/Controllers/UsersController.php` | Add trait, add server sort/perpage |
| `app/Http/Controllers/PlanController.php` | Add trait, add server sort/perpage |
| `app/Http/Controllers/ProjectConceptNoteController.php` | Add trait, add server sort/perpage |
| `app/Http/Controllers/RegistrationSubmissionsController.php` | Add trait, add server sort/perpage |
| `app/Http/Controllers/PlanSchemeChangeRequestController.php` | Add trait, add server sort/perpage |
| `app/Http/Controllers/PermissionsController.php` | Add trait, add server sort/perpage |
| Corresponding Service files | Update `list()` to accept sort/search/perpage, return paginator |

### Modified Files (Views — All listing index.blade.php files in scope)

26 view files — all applying the same standardized pattern.

### Modified Files (Shared JS)

| File | Change |
|---|---|
| `public/assets/js/pms-index-datatable.js` | Add `attachServerSort()`, loading state helpers |

---

## Verification Checklist (Per Page)

- [ ] Search box appears in filter card
- [ ] Typing and submitting search loads filtered results from database (not client-side)
- [ ] Search term persists through pagination (page 2 keeps search)
- [ ] Search term persists through sort click
- [ ] Column header click sorts entire dataset (full page reload, not just current page)
- [ ] Sort arrow indicator appears on active column
- [ ] Sort direction toggles asc/desc on repeated click of same column
- [ ] Sort persists through pagination
- [ ] Per-page selector shows 10/25/50/100
- [ ] Changing per-page resets to page 1, keeps filters
- [ ] Per-page persists through pagination
- [ ] Status filter works server-side
- [ ] Active filter chips shown when filters are active
- [ ] Clicking X on chip removes that filter, keeps others
- [ ] Reset button clears ALL filters and returns to page 1
- [ ] Empty state: clean message shown when no records exist at all
- [ ] No-results state: shown when filters return zero rows, with Clear Filters link
- [ ] Loading state: visual feedback during page navigation
- [ ] Existing Create button still works
- [ ] Existing details modal still works
- [ ] Existing edit/delete still works
- [ ] DataTables export buttons (CSV, Excel, PDF, Print, Copy, ColVis) still work
- [ ] Dark mode renders correctly
- [ ] `php artisan test` passes with no regressions
