# PRD: My Leads Module — Fix 33 Failed Test Cases

## Context

Thirty-three QA test cases in the My Leads module are failing. Root causes have been traced to:

- `app/Http/Controllers/Leads/LeadController.php` — wrong search class, missing filter setup, missing offset default
- `app/Models/Leads/Lead.php` — missing `master_category_id` and date-range filters, wrong tab label
- `resources/views/admin/components/header.blade.php` — missing notification bell, incomplete profile menu
- `resources/views/admin/components/form-header-fields.blade.php` — missing column sort + tooltips
- `resources/views/admin/leads/partials/tabs.blade.php` — missing card tooltips
- `resources/views/admin/leads/partials/table_view.blade.php` — missing filter panel, no search results box
- `resources/views/admin/leads/partials/action_dropdown.blade.php` — pointer-events override defeats selection gating
- `public/assets/js/leads.js` — no table column sort handler, no pointer-events fix, no disabled-link guard

---

## Fix 1 — ML-006, ML-029, ML-031: Table Search Field Not Wired

### Root Cause
`LeadController::index()` builds the `headingOptions` search field (line 283) with class `module-search`. `leads.js` `bindSearch()` (line 109) listens only on `.group-search`, and `bindSearchAutocomplete()` (line 286) listens only on `.leads-search`. Neither matches `module-search`, so typing in the table search field never triggers a fetch. Additionally, `bindSearchAutocomplete()` expects a `data-autocomplete-url` attribute on the element (line 287) and looks for `#leadsAcResults` (line 274) which is not in the DOM — so autocomplete suggestions never appear.

Clearing search (ML-031) also silently fails because the clear-icon click is not handled when the input class is wrong.

### Changes

**`app/Http/Controllers/Leads/LeadController.php` — lines 282–285 (headingOptions search div)**

```php
// BEFORE
'<div class="search-field">
    <input type="text" class="form-control pr-45 module-search" placeholder="Search" value="'. e($request->input('search_text')) .'">
    '. ($request->filled('search_text') ? '<i class="'. \Config::get('settings.icon_cross') .' search-clear-icon"></i>' : '<i class="'. \Config::get('settings.icon_search') . '"></i>') .'
</div>',

// AFTER
'<div class="search-field position-relative">
    <input type="text"
        id="tableSearchInput"
        class="form-control pr-45 leads-search"
        placeholder="Search"
        value="'. e($request->input('search_text')) .'"
        data-autocomplete-url="'. route('leads.autocomplete') .'">
    '. ($request->filled('search_text') ? '<i class="'. \Config::get('settings.icon_cross') .' search-clear-icon" style="cursor:pointer;"></i>' : '<i class="'. \Config::get('settings.icon_search') . '"></i>') .'
    <div id="leadsAcResults" class="autocomplete-results" style="display:none;position:absolute;top:100%;left:0;right:0;z-index:1050;background:#fff;border:1px solid #ddd;border-radius:4px;max-height:280px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,.1);"></div>
</div>',
```

**`public/assets/js/leads.js` — inside `bindFilterPanel()`, after the reset-button handler (after line ~254)**

Add a clear-icon click handler:
```javascript
// Clear icon in table search
document.addEventListener('click', function(e) {
    if (e.target && e.target.classList.contains('search-clear-icon')) {
        var inp = document.querySelector('.leads-search');
        if (inp) { inp.value = ''; }
        _currentSearchText = '';
        fetchContent(gatherFilterPanelParams());
    }
});
```

---

## Fix 2 — ML-008: "Search By" Dropdown Missing

### Root Cause
The table view search area in `headingOptions` has no "Search By" (`searchByField`) select. `leads.js bindSearch()` (line 115) reads `document.getElementById('searchByField')` and falls back to `'dba'`, so users cannot change what field to search by. The dropdown that should show "All / DBA / Email / Phone / Contact Name" is absent, causing the test expectation of selectable filter scope options to fail.

### Changes

**`app/Http/Controllers/Leads/LeadController.php` — add a second element to headingOptions before the existing search div, OR replace the search div with a compound search+select block**

Replace the search string in `headingOptions` (the first element, lines 282–285) with:

```php
'<div class="d-flex align-items-center gap-2">
    <select id="searchByField" class="form-select form-select-sm no-search" style="width:auto;min-width:110px;">
        <option value="dba">DBA</option>
        '. collect($searchableFields)->map(fn($f) =>
            '<option value="'. e($f->slug) .'"'. ($request->input('search_by')===$f->slug?' selected':'') .'>'. e($f->label) .'</option>'
        )->implode('') .'
    </select>
    <div class="search-field position-relative">
        <input type="text"
            id="tableSearchInput"
            class="form-control pr-45 leads-search"
            placeholder="Search"
            value="'. e($request->input('search_text')) .'"
            data-autocomplete-url="'. route('leads.autocomplete') .'">
        '. ($request->filled('search_text') ? '<i class="'. \Config::get('settings.icon_cross') .' search-clear-icon" style="cursor:pointer;"></i>' : '<i class="'. \Config::get('settings.icon_search') . '"></i>') .'
        <div id="leadsAcResults" class="autocomplete-results" style="display:none;position:absolute;top:100%;left:0;right:0;z-index:1050;background:#fff;border:1px solid #ddd;border-radius:4px;max-height:280px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,.1);"></div>
    </div>
</div>',
```

> Note: This merges Fix 1 and Fix 2 into one headingOptions element. Implement both fixes as one combined replacement.

---

## Fix 3 — ML-009: Notification Bell Missing from Header

### Root Cause
`resources/views/admin/components/header.blade.php` (lines 56–77) has a right-side icon area but contains NO notification bell/badge button. The notification offcanvas panel (`#notification-panel`) is defined in `layout.blade.php` (lines 69–88) but there is no trigger to open it, so the notification area is completely invisible to users.

### Changes

**`resources/views/admin/components/header.blade.php` — inside `<div class="d-flex align-items-center gap-18">` (after line 61, before closing `</div>` at line 62)**

```html
{{-- Notification Bell --}}
<div class="dropdown d-inline-block">
    <button type="button" class="btn header-item noti-icon waves-effect"
        data-bs-toggle="offcanvas" data-bs-target="#notification-panel"
        aria-controls="notification-panel">
        <i class="bx bx-bell bx-tada"></i>
        <span class="badge bg-danger rounded-pill" id="notification-badge"
              style="position:absolute;top:8px;right:4px;font-size:10px;padding:2px 5px;display:none;">0</span>
    </button>
</div>
```

> The badge count is populated by the existing notification JS. Ensure `common.js` (or the notification polling script) updates `#notification-badge` text and toggles its `display` when count > 0.

---

## Fix 4 — ML-010: User Profile Dropdown Incomplete

### Root Cause
The dropdown in `header.blade.php` (lines 71–75) contains only "Profile" and "Logout". The original site had additional items (Notification Settings, User Settings). The incomplete menu causes the test to fail on "all items are not there as original site."

### Changes

**`resources/views/admin/components/header.blade.php` — replace the dropdown-menu div (lines 71–75)**

```html
// BEFORE
<div class="dropdown-menu dropdown-menu-end">
    <a class="dropdown-item" href="{{ route('user-settings.profile-settings' ) }}"><i class="bx bx-user font-size-16 align-middle mr-1"></i> <span key="t-profile">Profile</span></a>
        <div class="dropdown-divider"></div>
        <a class="dropdown-item text-danger" href="{{route('admin.logout')}}"><i class="bx bx-power-off font-size-16 align-middle mr-1 text-danger"></i> <span key="t-logout">Logout</span></a>
</div>

// AFTER
<div class="dropdown-menu dropdown-menu-end">
    <a class="dropdown-item" href="{{ route('user-settings.profile-settings') }}">
        <i class="bx bx-user font-size-16 align-middle me-1"></i> Profile
    </a>
    <a class="dropdown-item" href="{{ route('user-settings.notification-settings') }}">
        <i class="bx bx-bell font-size-16 align-middle me-1"></i> Notification Settings
    </a>
    <a class="dropdown-item" href="{{ route('user-settings.calendar-settings') }}">
        <i class="bx bx-calendar font-size-16 align-middle me-1"></i> Calendar Settings
    </a>
    <div class="dropdown-divider"></div>
    <a class="dropdown-item text-danger" href="{{ route('admin.logout') }}">
        <i class="bx bx-power-off font-size-16 align-middle me-1 text-danger"></i> Logout
    </a>
</div>
```

---

## Fix 5 — ML-012: Tab/Card Tooltips Missing

### Root Cause
`resources/views/admin/leads/partials/tabs.blade.php` renders each tab button with only a label and count badge — no `data-bs-toggle="tooltip"` or `title` attributes. Hovering over a tab card shows nothing.

### Changes

**`resources/views/admin/leads/partials/tabs.blade.php` — replace the button element (lines 4–6)**

```blade
// BEFORE
<button class="nav-link {{ $t['active'] ? 'active' : '' }}" data-tab-key="{{ $t['key'] }}" type="button" role="tab" aria-selected="{{ $t['active'] ? 'true' : 'false' }}">
    {{ $t['label'] }}
    <span>{{ $t['count'] }}</span>
</button>

// AFTER
<button class="nav-link {{ $t['active'] ? 'active' : '' }}"
    data-tab-key="{{ $t['key'] }}"
    type="button" role="tab"
    aria-selected="{{ $t['active'] ? 'true' : 'false' }}"
    data-bs-toggle="tooltip"
    data-bs-placement="bottom"
    title="{{ $t['label'] }}: {{ number_format($t['count']) }} leads">
    {{ $t['label'] }}
    <span>{{ $t['count'] }}</span>
</button>
```

**`public/assets/js/leads.js` — add tooltip initialization inside `fetchContent()`, after the content replacement block (after line ~36)**

```javascript
// Re-initialize Bootstrap tooltips on dynamically loaded tabs
function initTabTooltips() {
    document.querySelectorAll('#leadTabs [data-bs-toggle="tooltip"]').forEach(function(el) {
        if (!el._bsTooltip) {
            new bootstrap.Tooltip(el, { trigger: 'hover' });
        }
    });
}
```

Call `initTabTooltips()` at the bottom of `fetchContent().then()` block and once on page load (after line 155 where `bindViewModeToggle()` etc. are called).

---

## Fix 6 — ML-015: Table Header Tooltips Missing

### Root Cause
`resources/views/admin/components/form-header-fields.blade.php` line 18 renders `<th>` elements showing only `{{ $val->display_field_name }}` with no tooltip. Hovering over "Total Leads", "Unassigned Leads", or any dynamic column header shows nothing.

### Changes

**`resources/views/admin/components/form-header-fields.blade.php` — line 18**

```blade
// BEFORE
<th data-col="{{ $val->db_column_name }}" data-id="{{ $val->id }}" class="...">{{ $val->display_field_name }} </th>

// AFTER
<th data-col="{{ $val->db_column_name }}"
    data-id="{{ $val->id }}"
    class="{{ !$key ? 'sticky-col-header-first' : '' }} {{ in_array($val->field_type, [1, 8, 9]) ? 'text-right' : (in_array($val->field_type, [2, 6, 7]) ? 'text-center' : '') }} align-middle sortable-col"
    style="{{ !$key ? ($val->db_column_name != 'id' ? 'width: 15%;' : '' ) : ''}} cursor:pointer;"
    data-sort-col="{{ $val->db_column_name }}"
    data-bs-toggle="tooltip"
    data-bs-placement="top"
    title="{{ $val->description ?? $val->display_field_name }}">
    {{ $val->display_field_name }}
    <span class="sort-icon ms-1 text-muted" style="font-size:11px;">⇅</span>
</th>
```

**`public/assets/js/leads.js` — add tooltip init for table headers inside `fetchContent().then()` block**

```javascript
// Re-initialize tooltips on table headers
document.querySelectorAll('th[data-bs-toggle="tooltip"]').forEach(function(el) {
    new bootstrap.Tooltip(el, { trigger: 'hover' });
});
```

---

## Fix 7 — ML-024, ML-025, ML-026, ML-027, ML-028: Column Sorting Has No Handler

### Root Cause
Table headers have no click handlers for sorting. `leads.js` has no `bindTableSort()` function. While `LeadController::index()` already converts `order_by` + `order_dir` params to the `orderBy` format for `getListing()` (lines 274–276), there is no way for the user to trigger these params from the table view.

### Changes

**`public/assets/js/leads.js` — add after `bindPaginationDelegates()` call (after line 213)**

```javascript
// ---- TABLE COLUMN SORTING ----
var _sortCol = '';
var _sortDir = 'DESC';

function bindTableSort() {
    document.addEventListener('click', function(e) {
        var th = e.target.closest('th.sortable-col');
        if (!th) return;
        var col = th.getAttribute('data-sort-col');
        if (!col) return;

        if (_sortCol === col) {
            _sortDir = _sortDir === 'ASC' ? 'DESC' : 'ASC';
        } else {
            _sortCol = col;
            _sortDir = 'ASC';
        }

        // Update all sort icons
        document.querySelectorAll('th.sortable-col .sort-icon').forEach(function(icon) {
            icon.textContent = '⇅';
            icon.classList.remove('text-primary');
        });
        var icon = th.querySelector('.sort-icon');
        if (icon) {
            icon.textContent = _sortDir === 'ASC' ? '↑' : '↓';
            icon.classList.add('text-primary');
        }

        fetchContent(Object.assign(gatherFilterPanelParams(), {
            order_by: _sortCol,
            order_dir: _sortDir
        }));
    });
}
bindTableSort();
```

> Fix 6 adds the `.sortable-col` class and `data-sort-col` attribute to `<th>` elements, which this handler relies on.

---

## Fix 8 — ML-018: E-Signature Activity Tab Label Mismatch

### Root Cause
`Lead::getTabLabels()` returns `'esign_activity' => 'E-Sign Activity'`. The test expects "E-Signature Activity". The label mismatch causes the test to fail when QA verifies the tab label.

### Changes

**`app/Models/Leads/Lead.php` — inside `getTabLabels()` static method**

```php
// BEFORE
'esign_activity'   => 'E-Sign Activity',

// AFTER
'esign_activity'   => 'E-Signature Activity',
```

---

## Fix 9 — ML-023: Default Records Per Page Is 30, Test Expects 25

### Root Cause
`table_footer.blade.php` (line 17) and `LeadController::index()` derive the page size from `config('settings.per_page_record', 30)` when no explicit `offset` request param or user `listing_table_size` preference is set. The test expects 25 records on the first page ("Footer: 1–25 of 1,781").

### Changes

**`app/Http/Controllers/Leads/LeadController.php` — inside `index()`, in the table-view branch (near line 130, where `$srch_params['offset'] = $this->_offset`)**

```php
// BEFORE
$srch_params['offset'] = $this->_offset;

// AFTER
// Default to 25 records for My Leads table view when no user preference or request param is set
if (!$request->filled('offset') && empty($this->_userListingTableSize)) {
    $this->_offset = 25;
}
$srch_params['offset'] = $this->_offset;
```

**`resources/views/admin/leads/partials/table_footer.blade.php` — line 17**

```blade
// BEFORE
$tblSelectedPerPage = (int) request('offset', $userListingTableSize ?? config('settings.per_page_record', 30));

// AFTER
$tblSelectedPerPage = (int) request('offset', $userListingTableSize ?? 25);
```

---

## Fix 10 — ML-032–ML-039: Filter Panel Missing from Table View

### Root Cause
`LeadController::headingOptions` (lines 281–297) has no "Filters" button. The table view has no filter panel — only the list view has one (right column of `list_view.blade.php`). Without a filter panel, none of the filter test cases (status, status category, sales rep, assigned user, date range, reset, no-results) can function.

Additionally, `Lead::getListing()` has no `master_category_id` (status category) filter and no `date_from`/`date_to` date-range filter, which ML-034 and ML-037 require.

### Changes

#### 10a — Create filter panel partial

**New file: `resources/views/admin/leads/partials/table_filter_panel.blade.php`**

```blade
<div id="tableFilterPanel" class="leads-filter-panel card mt-2 p-3" style="display:none;">
    <div class="row g-2 align-items-end">
        <div class="col-md-2">
            <label class="form-label small mb-1">Status</label>
            <select id="status_filter" class="form-select form-select-sm no-search">
                <option value="">All Statuses</option>
                @foreach($statuses ?? [] as $s)
                    <option value="{{ $s->id }}" {{ request('status') == $s->id ? 'selected' : '' }}>{{ $s->title }}</option>
                @endforeach
            </select>
        </div>
        <div class="col-md-2">
            <label class="form-label small mb-1">Status Category</label>
            <select id="master_category_id_filter" class="form-select form-select-sm no-search">
                <option value="">All Categories</option>
                @foreach($statusCategories ?? [] as $cat)
                    <option value="{{ $cat->id }}" {{ request('master_category_id') == $cat->id ? 'selected' : '' }}>{{ $cat->title }}</option>
                @endforeach
            </select>
        </div>
        <div class="col-md-2">
            <label class="form-label small mb-1">Assigned User</label>
            <select id="owner_user_id_filter" class="form-select form-select-sm no-search">
                <option value="">All Users</option>
                @foreach($companyUsers ?? [] as $u)
                    <option value="{{ $u->id }}" {{ request('owner_user_id') == $u->id ? 'selected' : '' }}>{{ $u->first_name }} {{ $u->last_name }}</option>
                @endforeach
            </select>
        </div>
        <div class="col-md-2">
            <label class="form-label small mb-1">Sales Rep #</label>
            <select id="sales_rep_number_filter" class="form-select form-select-sm no-search">
                <option value="">All</option>
                @foreach($salesRepNumbers ?? [] as $rep)
                    <option value="{{ $rep }}" {{ request('sales_rep_number') == $rep ? 'selected' : '' }}>{{ $rep }}</option>
                @endforeach
            </select>
        </div>
        <div class="col-md-2">
            <label class="form-label small mb-1">Created From</label>
            <input type="date" id="date_from_filter" class="form-control form-control-sm" value="{{ request('date_from') }}">
        </div>
        <div class="col-md-2">
            <label class="form-label small mb-1">Created To</label>
            <input type="date" id="date_to_filter" class="form-control form-control-sm" value="{{ request('date_to') }}">
        </div>
    </div>
    <div class="mt-2 d-flex justify-content-end">
        <button type="button" class="btn btn-sm btn-outline-secondary btn-reset-filters">
            <i class="bx bx-x me-1"></i> Reset Filters
        </button>
    </div>
</div>
```

#### 10b — Include filter panel in table_view

**`resources/views/admin/leads/partials/table_view.blade.php` — add after the heading section (after `</div>` at line 15, before `<div class="table-responsive ...">` at line 16)**

```blade
@include('admin.leads.partials.table_filter_panel')
```

#### 10c — Add "Filters" button to headingOptions

**`app/Http/Controllers/Leads/LeadController.php` — inside `$this->_data['headingOptions']` array (lines 281–297), add a Filters button as one of the elements**

Add before the Export dropdown element:

```php
'<button type="button" class="secondary-button" id="btnToggleFilters">
    <i class="bx bx-filter-alt me-1"></i> Filters
    <span id="filterActiveCount" class="badge bg-primary ms-1" style="display:none;">0</span>
</button>',
```

#### 10d — Pass statusCategories to view

**`app/Http/Controllers/Leads/LeadController.php` — after the `$this->_data['statuses']` line (around line 141)**

```php
$this->_data['statusCategories'] = \App\Models\Masters\MasterLeadCategory::where('company_id', $companyId)
    ->where('status', 1)
    ->orderBy('title')
    ->get(['id', 'title']);
```

> Use the correct model name for lead categories (likely `MasterLeadCategory` or `MasterCategory`). Confirm by checking `app/Models/Masters/` for a model with `master_lead_categories` or `master_categories` table.

#### 10e — Add filter panel toggle and wiring in leads.js

**`public/assets/js/leads.js` — add after `bindFilterPanel()` call (after line 261)**

```javascript
// Toggle filter panel visibility
document.addEventListener('click', function(e) {
    var btn = e.target.closest('#btnToggleFilters');
    if (!btn) return;
    var panel = document.getElementById('tableFilterPanel');
    if (panel) {
        var isVisible = panel.style.display !== 'none';
        panel.style.display = isVisible ? 'none' : 'block';
    }
});
```

#### 10f — Register new filter IDs in leads.js

**`public/assets/js/leads.js` — extend `_filterSelectIds` array (lines 220–222)**

```javascript
// BEFORE
var _filterSelectIds = [
    'owner_user_id_filter', 'status_filter', 'group_filter', 'campaign_filter',
    'source_filter', 'sales_rep_number_filter', 'lead_type_filter',
    'order_by_filter', 'order_dir_filter',
    'searchByField',
];
var _filterParamMap = {
    ...
};

// AFTER — add to _filterSelectIds
var _filterSelectIds = [
    'owner_user_id_filter', 'status_filter', 'master_category_id_filter',
    'group_filter', 'campaign_filter',
    'source_filter', 'sales_rep_number_filter', 'lead_type_filter',
    'order_by_filter', 'order_dir_filter',
    'searchByField',
];
var _filterParamMap = {
    'owner_user_id_filter': 'owner_user_id',
    'status_filter': 'status',
    'master_category_id_filter': 'master_category_id',   // <-- ADD
    'group_filter': 'master_group_id',
    'campaign_filter': 'master_campaign_id',
    'source_filter': 'master_source_id',
    'sales_rep_number_filter': 'sales_rep_number',
    'lead_type_filter': 'lead_type',
    'order_by_filter': 'order_by',
    'order_dir_filter': 'order_dir',
    'searchByField': 'search_by',
};
```

Wire date-range inputs (non-select) in `gatherFilterPanelParams()` (lines 234–238):

```javascript
// AFTER: params['exclude_unassigned'] = ...
var dateFrom = document.getElementById('date_from_filter');
params['date_from'] = dateFrom ? dateFrom.value : '';
var dateTo = document.getElementById('date_to_filter');
params['date_to'] = dateTo ? dateTo.value : '';
```

Also reset date inputs in the reset-button handler (inside `bindFilterPanel()`, lines 248–252):

```javascript
var dateFrom = document.getElementById('date_from_filter');
if (dateFrom) dateFrom.value = '';
var dateTo = document.getElementById('date_to_filter');
if (dateTo) dateTo.value = '';
```

Also update the active-filter badge count after fetch:

```javascript
// At the end of gatherFilterPanelParams():
var activeCount = Object.values(params).filter(function(v){ return v && v !== '' && v !== '0'; }).length;
var badge = document.getElementById('filterActiveCount');
if (badge) {
    badge.textContent = activeCount;
    badge.style.display = activeCount > 0 ? 'inline-block' : 'none';
}
```

#### 10g — Add master_category_id filter to Lead::getListing()

**`app/Models/Leads/Lead.php` — after the `status` filter block (after line 173)**

```php
// Status category filter
if (!empty($srch_params['master_category_id'])) {
    $q->where($this->table . '.master_category_id', (int)$srch_params['master_category_id']);
}
```

#### 10h — Add date range filters to Lead::getListing()

**`app/Models/Leads/Lead.php` — after the `master_category_id` block just added**

```php
// Created date-from filter
if (!empty($srch_params['date_from'])) {
    $q->whereDate($this->table . '.created_at', '>=', $srch_params['date_from']);
}

// Created date-to filter
if (!empty($srch_params['date_to'])) {
    $q->whereDate($this->table . '.created_at', '<=', $srch_params['date_to']);
}
```

---

## Fix 11 — ML-042, ML-043, ML-045: Actions Button Not Blocked When No Leads Selected

### Root Cause
`action_dropdown.blade.php` line 3 sets `style="pointer-events:auto;"` on the Actions dropdown toggle. When `leads.js updateSelectionCounter()` (lines 183–186) adds the `disabled` class to the element, Bootstrap's CSS sets `pointer-events: none` for disabled state — but the hardcoded inline `pointer-events:auto` takes precedence over the class-based rule, so the dropdown still opens even with zero leads selected.

### Changes

**`public/assets/js/leads.js` — inside `updateSelectionCounter()`, in the actionsBtn block (lines 183–186)**

```javascript
// BEFORE
if(actionsBtn){
    if(n > 0){
        actionsBtn.classList.remove('disabled');
        actionsBtn.removeAttribute('disabled');
    } else {
        actionsBtn.classList.add('disabled');
    }
}

// AFTER
if(actionsBtn){
    if(n > 0){
        actionsBtn.classList.remove('disabled');
        actionsBtn.style.pointerEvents = 'auto';
        actionsBtn.removeAttribute('tabindex');
    } else {
        actionsBtn.classList.add('disabled');
        actionsBtn.style.pointerEvents = 'none';
        actionsBtn.setAttribute('tabindex', '-1');
    }
}
```

---

## Fix 12 — ML-056: Disabled Previous Arrow Still Intercepted by Pagination JS

### Root Cause
`leads.js bindPaginationDelegates()` (lines 200–211) intercepts ALL `a[href]` clicks within `.pagination-links`. Laravel renders the "Previous" button on page 1 as a `<span>` (not `<a>`), so it's not intercepted. However, if Bootstrap renders it as an `<a href="...?page=0">` with just a `disabled` class on the wrapping `<li>`, the JS WILL intercept the click and attempt `fetchContent({page: 0})`, which may return unexpected results. The guard is also needed for next-page arrows on the last page.

### Changes

**`public/assets/js/leads.js` — inside `bindPaginationDelegates()`, add a disabled-link guard after line ~203**

```javascript
// BEFORE
if(!a.closest('.pagination-wrapper, .pagination-links, .group-pagination-links')) return;

// AFTER
if(!a.closest('.pagination-wrapper, .pagination-links, .group-pagination-links')) return;
// Skip disabled pagination links
if(a.closest('li.disabled') || a.classList.contains('disabled')) return;
```

---

## Fix 13 — ML-054, ML-055: Pagination Next/Prev AJAX Navigation

### Root Cause
`bindPaginationDelegates()` intercepts pagination clicks and calls `fetchContent()`. These should work once the other fixes (search/filter) are in place and records load correctly. However, there is one edge case: `fetchContent()` builds the URL from `window.location.href` (line 10) and merges params. If the current URL already has stale `search_text` or `offset` from a prior non-AJAX visit, these persist. This can cause next-page requests to use wrong params.

### Changes

**`public/assets/js/leads.js` — inside `fetchContent()`, ensure filter panel params are merged when paginating**

```javascript
// BEFORE (lines 9–14)
function fetchContent(params={}){
    const url = new URL(window.location.href);
    url.searchParams.set('tab', activeTab);
    url.searchParams.set('view', viewMode);
    url.searchParams.set('partial', '1');
    Object.entries(params).forEach(([k,v])=>{ if(v!==undefined && v!==null) url.searchParams.set(k,v); });

// AFTER — merge current filter state into all fetches so page changes respect active filters
function fetchContent(params={}){
    const url = new URL(window.location.href);
    url.searchParams.set('tab', activeTab);
    url.searchParams.set('view', viewMode);
    url.searchParams.set('partial', '1');
    // Merge current filter panel state first so pagination inherits active filters
    var filterState = (typeof gatherFilterPanelParams === 'function') ? gatherFilterPanelParams() : {};
    Object.entries(filterState).forEach(([k,v])=>{ if(v!==undefined && v!==null && v!=='') url.searchParams.set(k,v); });
    // Then merge explicit params (page, offset, etc.) which may override filter state
    Object.entries(params).forEach(([k,v])=>{ if(v!==undefined && v!==null) url.searchParams.set(k,v); });
```

> This ensures that when the user goes to page 2, any active search/filter is preserved.

---

## Fix 14 — ML-059: Vertical Scrolling Broken

### Root Cause
The `.cards` wrapper in `layout.blade.php` (line 52) renders with `animate__fadeInUp` animation. On some browsers, animated elements can create a stacking context that interferes with `overflow: visible`. Additionally, the `.leads-table` div uses `table-responsive` which sets `overflow-x: auto` but may clip tall tables on certain screen sizes if a parent has `overflow: hidden`.

### Changes

**`resources/views/admin/leads/partials/table_view.blade.php` — add CSS to allow vertical scroll**

Add at the top of the file (before `<div class="project-box">`):

```html
@push('page_css')
<style>
    #leads-index-root { overflow: visible !important; }
    .leads-table { overflow-x: auto; overflow-y: visible; }
    .project-box { overflow: visible; }
</style>
@endpush
```

---

## Fix 15 — ML-076: Deselect All Columns Breaks Table

### Root Cause
`Helper::configureColumnFilter('LeadController', 'index')` generates a column visibility dropdown. When a user deselects all checkboxes, `dynamicHeaderColumns()` returns an empty `$headers` array. `form-header-fields.blade.php` then renders a `<thead>` with only the checkbox and action columns but no data columns, and `form-data-fields.blade.php` renders rows with no data cells, producing a visually broken table with mismatched columns.

### Changes

**`resources/views/admin/components/form-header-fields.blade.php` — add a guard before the `@foreach` (before line 17)**

```blade
@if(empty($headers) || count($headers) === 0)
    {{-- Fallback: always show at least the primary identifier column --}}
    <th class="sticky-col-header-first">DBA / Lead</th>
@else
    @foreach ($headers as $key => $val)
        ... existing th markup ...
    @endforeach
@endif
```

**`resources/views/admin/components/form-data-fields.blade.php` — add matching guard**

Wrap the dynamic column cells in the same `@if(empty($headers))` check and render a fallback `<td>{{ $row->dba ?? $row->id }}</td>` when headers are empty.

---

## Fix 16 — ML-081: Responsive Layout Issues

### Root Cause
The `.heading-btn-sec.ipad_column` in `table_view.blade.php` renders the heading buttons in a row but has no `flex-wrap` for small screens, causing overflow on tablet and mobile. The search+select compound control from Fix 2 also needs responsive handling.

### Changes

**`resources/views/admin/leads/partials/table_view.blade.php` — add responsive CSS**

```html
@push('page_css')
<style>
    .heading-btn-sec { flex-wrap: wrap; gap: 8px; }
    .heading-btn { flex-wrap: wrap; gap: 6px; }
    @media (max-width: 768px) {
        .heading-btn-sec h3 { font-size: 16px; }
        .heading-btn .search-field input { width: 120px; }
        #tableFilterPanel .col-md-2 { flex: 0 0 50%; max-width: 50%; }
    }
    @media (max-width: 480px) {
        #tableFilterPanel .col-md-2 { flex: 0 0 100%; max-width: 100%; }
    }
</style>
@endpush
```

---

## Files Modified

| File | Fixes |
|------|-------|
| `app/Http/Controllers/Leads/LeadController.php` | ML-006, ML-008, ML-023, ML-032, ML-033, ML-034, ML-035, ML-036, ML-037 |
| `app/Models/Leads/Lead.php` | ML-018, ML-034, ML-037 |
| `resources/views/admin/components/header.blade.php` | ML-009, ML-010 |
| `resources/views/admin/components/form-header-fields.blade.php` | ML-015, ML-024–028, ML-076 |
| `resources/views/admin/components/form-data-fields.blade.php` | ML-076 |
| `resources/views/admin/leads/partials/tabs.blade.php` | ML-012 |
| `resources/views/admin/leads/partials/table_view.blade.php` | ML-059, ML-081 |
| `resources/views/admin/leads/partials/table_filter_panel.blade.php` | ML-032–039 (new file) |
| `public/assets/js/leads.js` | ML-006, ML-029, ML-031, ML-024–028, ML-042, ML-043, ML-045, ML-054–056 |

---

## Verification

1. **ML-006** — Type 3+ chars into the table search field → results narrow; autocomplete dropdown appears below the input.
2. **ML-008** — Click the "DBA" dropdown next to the search → options appear (DBA, Email, Phone, Contact Name, etc.); selecting one changes what field is searched.
3. **ML-009** — Bell icon appears in the top navigation; badge shows unread count; clicking opens the Notifications offcanvas panel.
4. **ML-010** — Click username/avatar → dropdown shows Profile, Notification Settings, Calendar Settings, and Logout.
5. **ML-012** — Hover over any tab card (Total Leads, Unassigned Leads, E-Signature Activity, etc.) → Bootstrap tooltip appears showing label and count.
6. **ML-015** — Hover over any table column header → tooltip with the column description appears.
7. **ML-018** — Click "E-Signature Activity" tab → table re-fetches and shows only leads with e-signature activity.
8. **ML-019** — Click "Recently Boarded" tab → table re-fetches and shows only recently boarded leads.
9. **ML-023** — On first load (no user preference set), table shows 25 records; footer shows "1–25 of N".
10. **ML-024** — Click "Created" column header → rows sort ascending; click again → descending; ↑/↓ icon appears in header.
11. **ML-025** — Click "DBA" column header → rows sort A–Z then Z–A.
12. **ML-026** — Click "Status" column header → rows group by status alphabetically.
13. **ML-027** — Click "Status Category" column header → rows sort by category name.
14. **ML-028** — Click "Status Age" column header → rows sort numerically (e.g., 1 < 2 < 10, not alphabetically).
15. **ML-029** — Type a DBA name in the table search field → only matching records show.
16. **ML-031** — Clear the search field (click ✕ icon or delete text) → full list restores; filter count returns to 0.
17. **ML-032** — Click "Filters" button → filter panel slides open/appears below heading.
18. **ML-033** — In filter panel, select a Status → table updates to show only leads with that status.
19. **ML-034** — Select a Status Category → table shows only leads in that category.
20. **ML-035** — Select a Sales Rep # → table shows only leads with that sales rep number.
21. **ML-036** — Select an Assigned User → table shows only leads owned by that user.
22. **ML-037** — Set Created From and Created To dates → table shows only leads created in that range.
23. **ML-038** — Click "Reset Filters" → all filter dropdowns and date inputs clear; full unfiltered list restores.
24. **ML-039** — Apply a combination of filters that matches no records → table shows 0 rows with an empty-state message; no broken layout.
25. **ML-042** — With no rows selected, click Actions → dropdown does NOT open (pointer-events:none blocks it).
26. **ML-043** — Select one or more leads, click Actions → dropdown opens; click "Send Mass SMS" → SMS compose modal opens with correct recipient count.
27. **ML-045** — Select one or more leads, click Actions → click "Send Mass Email" → email compose modal opens.
28. **ML-054** — On page 1 with 25 records, click next-page arrow → page advances; footer shows "26–50 of N".
29. **ML-055** — On page 2+, click previous-page arrow → page returns; footer range updates correctly.
30. **ML-056** — On page 1, the previous-page arrow is visually greyed/disabled; clicking it has no effect.
31. **ML-059** — With many rows in the table, scrolling the page vertically is smooth; no content is clipped.
32. **ML-076** — When all column checkboxes in the column manager are deselected, the table shows a fallback column instead of collapsing entirely.
33. **ML-081** — On a 768px-wide screen (iPad), the heading buttons wrap correctly; no horizontal overflow; filter panel columns stack in two-per-row.
