# PRD: Groups Module — Fix 14 Failed QA Test Cases

## Context

14 QA test cases in the Groups module are failing. Root causes span three files and one missing migration:

- `resources/views/admin/manages/groups/index.blade.php` — missing sort links, missing export button, missing back button, missing star column
- `app/Http/Controllers/Manages/GroupController.php` — missing export method, missing duplicate-name validation
- `app/Models/Masters/MasterGroup.php` — missing `is_default` in fillable, missing `distinct()` in listing query
- `routes/web.php` — missing export route for groups
- New migration required for `is_default` column on `master_groups`

---

## Fix 1 — GRP_015/016/017/018: Column sorting does nothing

### Root Cause

The `<th>` headers in `index.blade.php` are plain text strings. No `Helper::sort()` calls exist. The model's `getListing()` already processes the `orderBy` request parameter via `Helper::manageOrderBy()`, and the controller already passes `$this->_model->orderBy` to the view — so the backend is fully ready. Only the view headers are missing the sort link markup.

### Expected Behaviour

Clicking "Name", "Active", "Created At", or "Updated At" column headers toggles ascending/descending sort and reloads the page with sorted records.

### Changes

**`resources/views/admin/manages/groups/index.blade.php`** — replace the four plain `<th>` tags

```blade
{{-- REPLACE: --}}
<th >Name</th>
<th>Active</th>
...
<th>Created at</th>
...
<th>Updated at</th>

{{-- WITH: --}}
<th>Name {!! \App\Helpers\Helper::sort($routePrefix . '.index', 'title', $orderBy) !!}</th>
<th>Active {!! \App\Helpers\Helper::sort($routePrefix . '.index', 'status', $orderBy) !!}</th>
...
<th>Created at {!! \App\Helpers\Helper::sort($routePrefix . '.index', 'created_at', $orderBy) !!}</th>
...
<th>Updated at {!! \App\Helpers\Helper::sort($routePrefix . '.index', 'updated_at', $orderBy) !!}</th>
```

---

## Fix 2 — GRP_019/020/021: Export button missing and export does not work

### Root Cause

Three layers are all absent:
1. No route registered for group export.
2. No `export()` method in `GroupController`.
3. No export button rendered in the view's `$headingOptions`.

The `Helper::exportData()` helper is already available and used by Merchants, SalesReps, and Leads with identical data-format dropdown pattern.

### Expected Behaviour

- **GRP_019:** An "Export" dropdown button is visible and enabled on the Groups listing page.
- **GRP_020:** Clicking CSV or XLS downloads all groups (for the current company) with columns: Name, Status, Created By, Created At.
- **GRP_021:** When a search filter is active, the exported file respects the same filters (search text and status) that are visible in the URL.

### Changes

**`routes/web.php`** — add export route inside the existing `Route::group(['prefix' => 'groups'])` block (around line 567)

```php
Route::group(['prefix' => 'groups'], function () {
    Route::post('{id}/change-status', 'App\Http\Controllers\Manages\GroupController@changeStatus')
        ->name('manages.groups.change-status');
    // ADD:
    Route::get('export/{format}', 'App\Http\Controllers\Manages\GroupController@export')
        ->where('format', 'csv|xlsx')
        ->name('manages.groups.export');
});
```

**`app/Http/Controllers/Manages/GroupController.php`** — add export method

```php
public function export(Request $request, string $format)
{
    try {
        $srch_params             = $request->all();
        $srch_params['company_id'] = \Auth::user()->company_id;
        $srch_params['with']     = ['created_by', 'updated_by'];
        $data = $this->_model->getListing($srch_params);

        $headers = ['Name', 'Status', 'Created By', 'Created At'];
        $rows    = [];
        foreach ($data as $row) {
            $rows[] = [
                $row->title,
                $row->statuses[$row->status]['name'] ?? $row->status,
                $row->created_by->full_name ?? '—',
                \App\Helpers\Helper::showdate($row->created_at, false),
            ];
        }

        return \App\Helpers\Helper::exportData($format, 'groups', 'Groups', $headers, $rows);
    } catch (\Exception $e) {
        \App\Models\ErrorLog::Log($e);
        return back()->with('error', $e->getMessage());
    }
}
```

**`resources/views/admin/manages/groups/index.blade.php`** — add export button to `$headingOptions`

```blade
@php($headingOptions = [
    '<div class="search-field">...</div>',          {{-- existing search field --}}
    '<div class="d-inline-block position-relative hw-export-wrapper" data-export-wrapper data-route="'. route('manages.groups.export', '__FMT__') .'">'
        .'<button type="button" class="'. \Config::get('view.buttons.primary') .' hw-export-btn" data-export-btn><i class="bx bx-download"></i> <span>Export</span></button>'
        .'<div class="hw-export-menu shadow" data-export-menu>'
            .'<button type="button" class="hw-export-item" data-format="csv">CSV</button>'
            .'<button type="button" class="hw-export-item" data-format="xlsx">XLS</button>'
        .'</div>'
    .'</div>',
    $permission['create'] ? '<a href="..." class="... show-modal">...</a>' : ''  {{-- existing add button --}}
])
```

> The `initGlobalExports()` function in `common.js` (already loaded on every page) wires up the dropdown and appends the current URL's filter params to the export URL, so GRP_021 (export after search) is automatically satisfied.

---

## Fix 3 — GRP_025: Back to Administration button missing

### Root Cause

The groups index blade does not include the "Back to Administration" button that is present on other manage-section pages (`documents.blade.php`, `helpdesk-ticket-settings.blade.php`). No UI element redirects users back to the parent administration/manage section.

### Expected Behaviour

Clicking "Back to Administration" redirects the user to the previous page (browser history back).

### Changes

**`resources/views/admin/manages/groups/index.blade.php`** — add back-button section above the `project-box` div

```blade
<div class="col-12">
    {{-- ADD: --}}
    <div class="back-section mb-2">
        <button type="button" class="btn back-btn waves-effect" onclick="window.history.back()">
            <i class="{{ \Config::get('settings.icon_back') }}"></i> Back to Administration
        </button>
    </div>
    {{-- existing: --}}
    <div class="project-box">
        ...
    </div>
</div>
```

> Using `onclick="window.history.back()"` makes the handler explicit and self-contained, consistent with the intent of the existing `back-btn` CSS class.

---

## Fix 4 — GRP_026: Star/default indicator column missing

### Root Cause

The `master_groups` table has no `is_default` column. The listing view has no star icon column. There is no mechanism to mark a group as the company's default group.

### Expected Behaviour

A star icon column (⭐) is visible in the listing. Groups marked as the company default display a filled star (`text-warning`); all others display nothing or an empty star.

### Changes

**New migration** — `database/migrations/YYYY_MM_DD_HHMMSS_add_is_default_to_master_groups_table.php`

```php
public function up(): void
{
    Schema::table('master_groups', function (Blueprint $table) {
        $table->boolean('is_default')->default(false)->after('status');
    });
}

public function down(): void
{
    Schema::table('master_groups', function (Blueprint $table) {
        $table->dropColumn('is_default');
    });
}
```

**`app/Models/Masters/MasterGroup.php`** — add `is_default` to `$fillable`

```php
protected $fillable = [
    'company_id',
    'created_by_user_id',
    'updated_by_user_id',
    'status',
    'title',
    'is_default',   // ADD
];
```

**`resources/views/admin/manages/groups/index.blade.php`** — add star column header and cell

```blade
{{-- In <thead>: --}}
<th></th>   {{-- star column header, no label --}}
<th>Name ...</th>
...

{{-- In <tbody> row: --}}
<td>
    @if($val->is_default)
        <i class="bx bxs-star text-warning" title="Default Group"></i>
    @endif
</td>
<td>{{ $val->title ?? '' }}</td>
...
```

> The `colspan` in the "No Data Found" fallback row must also be incremented by 1.

---

## Fix 5 — GRP_033: Duplicate records may appear in list

### Root Cause

`getListing()` in `MasterGroup` does not call `->distinct()`. While no joins currently exist, the absence of a `DISTINCT` guard means a future join (e.g. for eager loading or a filter) could produce duplicate rows. The test "each group appears once only" is a correctness invariant that should be enforced at the query level.

### Expected Behaviour

Each group record appears exactly once in the listing regardless of search/filter parameters.

### Changes

**`app/Models/Masters/MasterGroup.php`** — add `->distinct()` to the base query in `getListing()`

```php
$listing = self::select($select)
    ->distinct()           // ADD
    ->when(isset($srch_params['with']), ...)
    ...
```

---

## Fix 6 — GRP_035: Inactive group status display

### Root Cause

The view correctly uses `$val->statuses[$val->status]` to render the badge. However, `getListing()` is called without a `company_id` filter in the controller's `index()`, which means groups from other companies could appear in some edge-case configurations. Additionally, the `statuses` public property is defined on `MasterGroup` but since `getListing()` returns Eloquent model instances (`$listing->get()`), the property is accessible on each result — this part is fine.

The actual failure is that the status icon for `status = 2` (Inactive) renders correctly in the view code, but the QA tester may be filtering on `status` and the filter parameter name might not match the field name expected by `getListing()`. The filter passes `status` in the request, and `getListing()` reads `$srch_params['status']` — these match, so the filter itself works. 

The remaining gap is that the controller's `index()` does not enforce `company_id` scoping on the listing query, which can cause confusion when checking inactive records across companies. The fix is to always pass `company_id` to the listing.

### Changes

**`app/Http/Controllers/Manages/GroupController.php`** — add company_id scoping in `index()`

```php
public function index(Request $request)
{
    try {
        $this->initIndex();
        $this->_data['breadcrumb'] = [];
        $srch_params = $request->all();
        $srch_params['company_id'] = \Auth::user()->company_id;   // ADD
        $this->_data['data'] = $data = $this->_model->getListing($srch_params, $this->_offset);
        ...
    }
}
```

---

## Fix 7 — GRP_040: Create group with Active = No does not save correctly

### Root Cause

The `__formPost()` validation rule `'title' => 'required|max:255'` does not include `status`, so if the status field is missing or malformed in the POST body it will silently be absent from `$input`. The model's `store()` method passes `$input` directly to `create()`, so if `status` is not in `$input`, the DB default (`1` = Active) is used regardless of what the user selected.

### Expected Behaviour

When a user selects "Inactive" (status=2) in the create form and clicks "Create", the group is persisted with `status = 2`.

### Changes

**`app/Http/Controllers/Manages/GroupController.php`** — add `status` to the validation rules in `__formPost()`

```php
$this->validate($request, [
    'title'  => 'required|max:255',
    'status' => 'required|in:1,2',   // ADD
]);
```

---

## Fix 8 — GRP_045: Duplicate group name not prevented

### Root Cause

`__formPost()` validates only `required|max:255` on `title`. No uniqueness check exists. Two groups with identical names can be created for the same company. The `master_groups` migration also has no unique index on `title`.

### Expected Behaviour

Submitting a group name that already exists for the current company shows a validation error: **"The group name has already been taken."** The group is not created.

### Changes

**`app/Http/Controllers/Manages/GroupController.php`** — add unique validation to `__formPost()`

```php
protected function __formPost(Request $request, $id = 0)
{
    try {
        $companyId = \Auth::user()->company_id;

        $this->validate($request, [
            'title' => [
                'required',
                'max:255',
                \Illuminate\Validation\Rule::unique('master_groups', 'title')
                    ->where('company_id', $companyId)
                    ->whereNull('deleted_at')
                    ->ignore($id ?: null),
            ],
            'status' => 'required|in:1,2',
        ]);

        $input    = $request->all();
        $response = $this->_model->store($input, $id, $request);
        ...
    }
}
```

---

## Fix 9 — GRP_046: Duplicate name with different case not caught

### Root Cause

Laravel's `Rule::unique()` issues a `SELECT WHERE title = ?` query, which follows the column's collation. If `master_groups.title` uses `utf8mb4_unicode_ci` (MySQL default), the comparison is already case-insensitive and "iso" / "ISO" are treated as the same value at the DB level — so GRP_046 is automatically covered once GRP_045's unique rule is in place.

If the database or table uses a binary/case-sensitive collation, an additional `whereRaw('LOWER(title) = LOWER(?)', [$request->title])` check is needed. The migration created for Fix 4 (`is_default`) can also add an explicit case-insensitive unique index if required.

### Changes

No additional code change beyond Fix 8 (GRP_045) if the DB collation is `utf8mb4_unicode_ci`. To be explicit and safe regardless of collation, replace the `Rule::unique()` approach with a manual existence check in `__formPost()`:

```php
$duplicate = \App\Models\Masters\MasterGroup::whereRaw('LOWER(title) = LOWER(?)', [$request->title])
    ->where('company_id', $companyId)
    ->whereNull('deleted_at')
    ->when($id, fn($q) => $q->where('id', '!=', $id))
    ->exists();

if ($duplicate) {
    return redirect()->back()
        ->withInput()
        ->withErrors(['title' => 'The group name has already been taken.']);
}
```

> Add this block immediately after the `validate()` call in `__formPost()`.

---

## Migration Checklist

| # | File | Action |
|---|------|--------|
| 1 | `database/migrations/YYYY_MM_DD_add_is_default_to_master_groups_table.php` | Create — add `is_default` boolean column |
| — | `php artisan migrate` | Run after creating the migration |

---

## File Change Summary

| File | Test Cases Fixed |
|------|-----------------|
| `resources/views/admin/manages/groups/index.blade.php` | GRP_015, GRP_016, GRP_017, GRP_018, GRP_019, GRP_020, GRP_021, GRP_025, GRP_026 |
| `app/Http/Controllers/Manages/GroupController.php` | GRP_019, GRP_020, GRP_021, GRP_035, GRP_040, GRP_045, GRP_046 |
| `app/Models/Masters/MasterGroup.php` | GRP_026, GRP_033 |
| `routes/web.php` | GRP_019, GRP_020, GRP_021 |
| New migration (`add_is_default_to_master_groups_table`) | GRP_026 |

---

## Notes

- All export functionality re-uses the existing `hw-export-wrapper` / `initGlobalExports()` pattern from `common.js` — no new JavaScript is required.
- The `Helper::sort()` calls require `$orderBy` and `$routePrefix` to be present in the view scope; both are already passed by `GroupController::index()`.
- The star/default column is display-only for now; a toggle mechanism to set/unset the default group can be added in a follow-up.
- The duplicate-name check (Fix 8/9) uses `whereNull('deleted_at')` to exclude soft-deleted groups from the uniqueness scope, allowing a deleted group's name to be reused.
