# PRD: Sales Representative Module — QA Issue Resolution

## Context

QA testing of the Sales Representative module surfaced 30+ failures across five categories: validation gaps, broken search/filter, missing column sorting, edit-form data problems, and assigned-sales-rep information not appearing in lead details. This PRD captures every failing test case, maps each to the exact code location that needs changing, and defines acceptance criteria.

---

## Category A — Validation & Data Integrity

### A1. Missing uniqueness checks on email, phone, and sales rep number
**Issues:** SALES_REP_029, SALES_REP_030, SALES_REP_031

Currently `__formPost()` in `SalesRepController` has no `unique:` rule for email, phone, or sales_rep_number. Duplicates are silently created.

**Change — `app/Http/Controllers/SalesReps/SalesRepController.php` → `__formPost()`**

Add to the `$request->validate()` block:
```php
'email'           => 'required|email|max:255|unique:sales_reps,email' . ($id ? ",{$id},id,deleted_at,NULL" : ',NULL,id,deleted_at,NULL'),
'phone'           => 'nullable|string|max:50|unique:sales_reps,phone' . ($id ? ",{$id},id,deleted_at,NULL" : ',NULL,id,deleted_at,NULL'),
'sales_rep_number'=> 'nullable|string|max:50|unique:sales_reps,sales_rep_number' . ($id ? ",{$id},id,deleted_at,NULL" : ',NULL,id,deleted_at,NULL'),
```
Uniqueness is scoped to non-deleted rows by appending `deleted_at,NULL`.

### A2. Max-length validation errors not surfaced to user
**Issues:** SR-013, SR-017, SALES_REP_023, SALES_REP_024, SALES_REP_025, SALES_REP_026

The Laravel rules (`max:150`, `max:255`, etc.) already exist in `__formPost()`, but the AJAX form response on validation failure returns a 500 from the generic catch. Laravel's `ValidationException` must be caught separately and returned as a 422 JSON so the front-end form-validation-init.js can map errors to fields.

**Change — `__formPost()` catch block:**
```php
} catch (\Illuminate\Validation\ValidationException $ve) {
    if ($request->ajax()) {
        return response()->json(['errors' => $ve->errors()], 422);
    }
    throw $ve;
} catch (\Exception $e) { ...
```

Also add `maxlength` HTML attributes to the form fields in `__formUiGeneration()`:
- `first_name`, `last_name` → `maxlength: 150`
- `email` → `maxlength: 255`
- `sales_rep_number` → `maxlength: 50`
- `username` → `maxlength: 50`
- `note` (textarea) → `maxlength: 1000`

### A3. No input trimming
**Issue:** SR-018

In `SalesRep::store()`, wrap string inputs before mass assignment:
```php
foreach (['first_name','last_name','email','phone','sales_rep_number','note','username'] as $f) {
    if (isset($input[$f])) $input[$f] = trim($input[$f]);
}
```

### A4. No Sales Rep Number format validation
**Issue:** SR-070

Business rule: permissive alphanumeric + dash/underscore only.
```php
'sales_rep_number' => ['nullable','string','max:50','regex:/^[A-Za-z0-9\-_]+$/'],
```
Update the placeholder text on the form field to say "e.g. REP-001".

---

## Category B — Edit Form Issues

### B1. Password field is blank in edit form with no explanation
**Issues:** SR-020, SR-069

The password is intentionally not pre-filled (security). QA reads this as broken.

**Change — `__formUiGeneration()` form definition:**
```php
'password' => [
    'type'       => 'password',
    'label'      => 'Password',
    'value'      => '',
    'attributes' => [
        'placeholder' => 'Leave blank to keep current password',
        'class'       => 'form-control',
        'required'    => !$id,
        'autocomplete'=> 'new-password',
    ],
    'help_text' => $id ? 'Leave blank to keep the existing password.' : '',
]
```
Also confirm the form component (`resources/views/admin/components/`) renders `help_text` — add rendering if missing.

### B2. Edit form not showing existing data
**Issues:** SR-069, SMK-04

Root cause: `SalesRep::getListing()` applies `->with()` before the `id` shortcut path, so `with` may be ignored.

**Change — `SalesRep::getListing()` around line 92:**
```php
if (isset($srch_params['id'])) {
    $q = $listing->where($this->table . '.id', $srch_params['id']);
    return isset($srch_params['with']) ? $q->with($srch_params['with'])->first() : $q->first();
}
```

---

## Category C — Search & Filter

### C1. Full-name search returns no results
**Issue:** SR-034

`SalesRep::getListing()` checks `first_name LIKE` and `last_name LIKE` individually. A two-word search like "Rose Luna" matches neither column alone.

**Change — `app/Models/SalesReps/SalesRep.php` → `getListing()`:**
```php
->orWhereRaw("CONCAT({$this->table}.first_name, ' ', {$this->table}.last_name) LIKE ?", ["%{$st}%"]);
```

### C2. Sales rep number filter not working
**Issue:** SR-037

The `sales_rep_number` advanced filter param must pass through from GET request to `getListing()`. In `SalesRepController::index()` the only mapping currently done is `name` → `search_text`. The other advanced filter keys are not explicitly mapped.

**Change — `SalesRepController::index()`:** Strip empty strings so `when(isset(...))` only fires for non-empty values:
```php
foreach (['status','phone','email','sales_rep_number'] as $k) {
    if (array_key_exists($k, $srch_params) && $srch_params[$k] === '') {
        unset($srch_params[$k]);
    }
}
```

### C3. Clearing search does not reset the grid
**Issue:** SR-040

The `.search-clear-icon` renders only when `search_text` is set in the request. JS in `common.js` must redirect to `window.location.pathname` (stripping all query params) on click.

**Change — `public/assets/js/sales_reps.js`:** Ensure the clear icon redirects cleanly:
```js
$(document).on('click', '.search-clear-icon', function() {
    window.location.href = window.location.pathname;
});
```

### C4. Filter count badge not updating
**Issue:** SR-043

The badge `<span class="filter-counter" data-filter-id="primary">(0)</span>` is static. No JS updates it on apply/clear.

**Change — `public/assets/js/sales_reps.js`:**
```js
function updateFilterCounter(filterId) {
    var count = 0;
    $('#offcanvasRightAdvancedPrimary [name]').each(function() {
        var v = $(this).val();
        if (v && v !== '') count++;
    });
    $('[data-filter-id="' + filterId + '"]').text('(' + count + ')');
}
// On page load, reflect active filters from GET params
$(function() { updateFilterCounter('primary'); });
// After filter form submit
$(document).on('submit', '#offcanvasRightAdvancedPrimary form', function() {
    updateFilterCounter('primary');
});
```

---

## Category D — Column Sorting

### D1. No sorting on the grid
**Issues:** SR-055, SR-056

**Change — `resources/views/admin/sales-reps/index.blade.php`:**

Replace static `<th>` text with sort-link helpers for: Sales Rep Name, Sales Rep Number, Email, Status. Add ▲/▼ indicator based on current `orderBy`/`orderDir` request params.

**Change — `SalesRepController::index()`:**
```php
if ($request->input('orderBy')) {
    $srch_params['orderBy'] = $request->input('orderBy') . ':' . $request->input('orderDir', 'asc');
}
```

---

## Category E — Assigned Sales Rep Not Showing in Lead Details

### E1. Sales rep assignment missing from lead details page
**Issues:** SALES_REP_043, SALES_REP_044, SALES_REP_045, SALES_REP_047, SALES_REP_048, SALES_REP_049, SALES_REP_050, SR-085

`SalesRepLeadMap::syncAssignments()` writes to `lead_user_maps`, but the lead details view's `$assignedUsers` either doesn't include role labels or isn't populated at all.

**Change — `app/Http/Controllers/Leads/LeadController.php`:** Build `$assignedUsers` with role label:
```php
$assignedUsers = $lead->assignedUsers->map(function($u) {
    $role = $u->roles->first();
    return [
        'id'    => $u->id,
        'name'  => trim($u->first_name . ' ' . $u->last_name),
        'class' => $role ? $role->title : '',
    ];
})->toArray();
```

**Change — `app/Models/SalesReps/SalesRepLeadMap.php` → `syncAssignments()`:** Guard missing `user_id`:
```php
$salesRep = SalesRep::with('user')->find($salesRepId);
if (!$salesRep || !$salesRep->user_id) {
    return Helper::resp('Sales rep has no linked user account.', 400);
}
```

**Change — `resources/views/admin/leads/partials/list_view.blade.php`:** Show only sales-rep-role users in the "Sales Rep" line (role IDs 4, 7, 8):
```blade
@php($salesRepUsers = $lead->assignedUsers->filter(fn($u) => in_array($u->roles->first()?->id ?? 0, [4,7,8])))
@if($salesRepUsers->count())
    <p><strong>Sales Rep:</strong> {{ $salesRepUsers->map(fn($u) => trim($u->first_name.' '.$u->last_name))->implode(', ') }}</p>
@endif
```

---

## Category F — UX Polish

### F1. Empty state already handled
**Issue:** SR-006 — `index.blade.php` line 79 already shows "No Data Found". No change needed.

### F2. No feedback when saving without changes
**Issue:** SR-022

**Change — `SalesRep::store()`** (update path):
```php
$data->fill($input);
if (!$data->isDirty()) {
    \DB::rollBack();
    return Helper::resp('No changes were made.', 200, $data);
}
$data->save();
```

### F3. Keyboard accessibility
**Issues:** SALES_REP_036, SALES_REP_037, SR-060

**Change — `index.blade.php`:** The "Add Sales Rep" `<a>` already has `href` so it is natively focusable. Ensure the modal sets focus to the first input on open (check the modal JS initializer).

---

## Files to Modify

| File | Changes |
|------|---------|
| `app/Http/Controllers/SalesReps/SalesRepController.php` | Uniqueness rules, ValidationException handler, sort params, maxlength attributes, password help text |
| `app/Models/SalesReps/SalesRep.php` | `getListing()`: full-name LIKE concat, fix `id`+`with` shortcut; `store()`: input trimming, `isDirty()` no-change guard |
| `app/Models/SalesReps/SalesRepLeadMap.php` | Guard against missing `user_id` in `syncAssignments()` |
| `app/Http/Controllers/Leads/LeadController.php` | Build `$assignedUsers` with role label; eager-load roles |
| `resources/views/admin/sales-reps/index.blade.php` | Sortable column headers with ▲/▼ indicators |
| `resources/views/admin/leads/partials/list_view.blade.php` | Filter assigned users to sales-rep roles only |
| `public/assets/js/sales_reps.js` | Filter counter update, search clear redirect |

---

## Verification Plan

### Validation (A1–A4)
- Leave name blank on create → required error shown in modal
- Enter `abc@` → "must be a valid email" error
- Enter 200-char name → max-length error
- Create two reps with same email → duplicate error on second
- Create two reps with same mobile → duplicate error
- Create two reps with same rep number → duplicate error
- Save `"  John  "` → stored as `"John"`

### Edit form (B1–B2)
- Open edit → all fields pre-filled (name, email, phone, rep number, type, username)
- Password shows placeholder "Leave blank to keep current password"
- Save with no changes → toast "No changes were made."
- Change password and save → password updates

### Search & filter (C1–C4)
- Search "Rose" → matching reps appear
- Search "Rose Luna" (full name) → matching rep appears
- Search "Sales-30" → rep with that number appears
- Apply Status = Active → only active reps shown; badge shows `(1)`
- Clear filter → all reps shown; badge resets to `(0)`
- Click × in search box → grid resets to full unfiltered list

### Sorting (D1)
- Click "Sales Rep Name" header → rows sort A→Z; click again → Z→A
- Repeat for Sales Rep Number, Email, Status

### Lead assignment display (E1)
- Assign lead to sales rep → open that lead's details → "Assigned Users" shows rep name with "(Sales Rep)" label
- Refresh → rep remains assigned
- Lead list view → lead row shows "Sales Rep: [name]"
- Deactivate the rep → lead details loads without error
- Assign two leads to same rep → both reflect the rep

### UX (F2–F3)
- Save unchanged record → "No changes were made." toast
- Tab to "Add Sales Rep" button → focusable; Enter opens modal
- Tab through modal fields → logical order
