# Player Management CRUD Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build session-scoped Player Management CRUD for CourtKulture staff and convert app-wide flash/status alerts plus destructive confirmations to SweetAlert2.

**Architecture:** Keep players nested under open-play sessions because the existing schema, queue, matches, and standings are session-local. Use Form Requests for validation, a small `PlayerManagementService` for session ownership and delete safety, Blade/Bootstrap 5 for mobile-first views, and a shared SweetAlert2 bridge in `resources/js/app.js`.

**Tech Stack:** Laravel 12, PHP 8.2+, Blade, Bootstrap 5, Vite, SweetAlert2, PHPUnit feature tests.

---

## Review Roles

- **product_owner:** Confirm the staff flow is clear: session dashboard -> Manage Players -> add/edit/delete.
- **architect:** Confirm the feature stays session-scoped and does not introduce a global player directory.
- **backend_dev:** Implement routes, requests, service, controller behavior, and tests.
- **frontend_dev:** Implement mobile-first Blade views and SweetAlert2 integration.
- **qa_reviewer:** Run targeted tests, full `php artisan test`, and `npm run build`.
- **security_reviewer:** Verify staff middleware, per-session ownership checks, CSRF forms, soft delete behavior, and safe JSON/data attribute output for alert messages.

Git note: this workspace currently returns `fatal: not a git repository`. Commit steps are included for a normal repository, but if Git remains unavailable, record the changed files instead of committing.

## File Structure

- Modify `routes/web.php`: add nested session player CRUD routes.
- Modify `app/Http/Controllers/Staff/PlayerController.php`: add index/create/edit/update/destroy and keep store compatible with quick-add.
- Create `app/Http/Requests/Staff/UpdatePlayerRequest.php`: validate editable player fields.
- Modify `app/Http/Requests/Staff/StorePlayerRequest.php`: add optional `return_to` field so the same store endpoint can redirect to dashboard or player management.
- Create `app/Services/PlayerManagementService.php`: enforce session ownership and guarded soft deletion.
- Create `resources/views/staff/players/index.blade.php`: mobile cards plus desktop table.
- Create `resources/views/staff/players/create.blade.php`: staff create form.
- Create `resources/views/staff/players/edit.blade.php`: staff edit form.
- Modify `resources/views/staff/sessions/show.blade.php`: add Manage Players entry point near standings.
- Modify `resources/views/layouts/staff.blade.php`: expose flash/errors to SweetAlert2 and remove Bootstrap alert blocks.
- Modify `resources/views/auth/login.blade.php`: expose status flash to SweetAlert2 and remove Bootstrap alert block.
- Modify `resources/views/components/auth-session-status.blade.php`: expose auth status to SweetAlert2.
- Modify `resources/js/app.js`: import SweetAlert2, show flash messages, attach confirmation behavior.
- Modify `resources/css/app.css`: add player management and SweetAlert2 CourtKulture styling.
- Modify `package.json` and `package-lock.json`: add `sweetalert2`.
- Create `tests/Feature/StaffPlayerManagementTest.php`: feature coverage for CRUD, ownership, delete safety, and alert markup.

---

### Task 1: Add Player Management Feature Tests

**Files:**
- Create: `tests/Feature/StaffPlayerManagementTest.php`

- [ ] **Step 1: Write failing feature tests**

Create `tests/Feature/StaffPlayerManagementTest.php` with:

```php
<?php

namespace Tests\Feature;

use App\Models\Court;
use App\Models\CourtMatch;
use App\Models\MatchTeam;
use App\Models\OpenPlaySession;
use App\Models\Player;
use App\Models\QueueEntry;
use App\Models\User;
use App\Services\QueueService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class StaffPlayerManagementTest extends TestCase
{
    use RefreshDatabase;

    public function test_staff_can_view_session_player_management_page(): void
    {
        $user = User::factory()->staff()->create();
        $session = OpenPlaySession::factory()->create(['name' => 'Friday Open Play']);
        Player::factory()->for($session)->create([
            'display_name' => 'Ada Rally',
            'email' => 'ada@example.com',
            'skill_level' => 'advanced',
            'gender' => 'female',
            'status' => 'waiting',
        ]);

        $this->actingAs($user)
            ->get(route('staff.sessions.players.index', $session))
            ->assertOk()
            ->assertSee('Manage Players')
            ->assertSee('Friday Open Play')
            ->assertSee('Ada Rally')
            ->assertSee('ada@example.com')
            ->assertSee('Advanced')
            ->assertSee('Female')
            ->assertSeeHtml('ck-player-card')
            ->assertSeeHtml('table');
    }

    public function test_staff_can_open_create_and_edit_player_forms(): void
    {
        $user = User::factory()->staff()->create();
        $session = OpenPlaySession::factory()->create();
        $player = Player::factory()->for($session)->create(['display_name' => 'Ben Drop']);

        $this->actingAs($user)
            ->get(route('staff.sessions.players.create', $session))
            ->assertOk()
            ->assertSee('Add Player')
            ->assertSee('Display Name')
            ->assertSee('Check in to queue');

        $this->actingAs($user)
            ->get(route('staff.sessions.players.edit', [$session, $player]))
            ->assertOk()
            ->assertSee('Edit Player')
            ->assertSee('Ben Drop');
    }

    public function test_staff_can_create_player_from_management_page_and_return_to_index(): void
    {
        $user = User::factory()->staff()->create();
        $session = OpenPlaySession::factory()->create();

        $this->actingAs($user)
            ->post(route('staff.sessions.players.store', $session), [
                'display_name' => 'Mina Slice',
                'email' => 'mina@example.com',
                'skill_level' => 'intermediate',
                'gender' => 'female',
                'check_in' => '1',
                'return_to' => 'players',
            ])
            ->assertRedirect(route('staff.sessions.players.index', $session));

        $this->assertDatabaseHas('players', [
            'open_play_session_id' => $session->id,
            'display_name' => 'Mina Slice',
            'email' => 'mina@example.com',
            'skill_level' => 'intermediate',
            'gender' => 'female',
            'status' => 'waiting',
        ]);

        $this->assertDatabaseHas('queue_entries', [
            'open_play_session_id' => $session->id,
            'status' => 'waiting',
        ]);
    }

    public function test_staff_can_update_player_details(): void
    {
        $user = User::factory()->staff()->create();
        $session = OpenPlaySession::factory()->create();
        $player = Player::factory()->for($session)->create([
            'display_name' => 'Old Name',
            'email' => 'old@example.com',
            'skill_level' => 'beginner',
            'gender' => 'unspecified',
        ]);

        $this->actingAs($user)
            ->patch(route('staff.sessions.players.update', [$session, $player]), [
                'display_name' => 'New Name',
                'email' => 'new@example.com',
                'skill_level' => 'advanced',
                'gender' => 'male',
            ])
            ->assertRedirect(route('staff.sessions.players.index', $session));

        $this->assertDatabaseHas('players', [
            'id' => $player->id,
            'display_name' => 'New Name',
            'email' => 'new@example.com',
            'skill_level' => 'advanced',
            'gender' => 'male',
        ]);
    }

    public function test_staff_cannot_update_player_to_duplicate_name_or_email_in_same_session(): void
    {
        $user = User::factory()->staff()->create();
        $session = OpenPlaySession::factory()->create();
        Player::factory()->for($session)->create([
            'display_name' => 'Existing Player',
            'email' => 'existing@example.com',
        ]);
        $player = Player::factory()->for($session)->create([
            'display_name' => 'Editable Player',
            'email' => 'editable@example.com',
        ]);

        $this->actingAs($user)
            ->patch(route('staff.sessions.players.update', [$session, $player]), [
                'display_name' => 'Existing Player',
                'email' => 'existing@example.com',
                'skill_level' => 'intermediate',
                'gender' => 'unspecified',
            ])
            ->assertSessionHasErrors(['display_name', 'email']);
    }

    public function test_same_name_and_email_are_allowed_in_different_sessions(): void
    {
        $user = User::factory()->staff()->create();
        $firstSession = OpenPlaySession::factory()->create();
        $secondSession = OpenPlaySession::factory()->create();

        Player::factory()->for($firstSession)->create([
            'display_name' => 'Shared Name',
            'email' => 'shared@example.com',
        ]);

        $player = Player::factory()->for($secondSession)->create([
            'display_name' => 'Other Name',
            'email' => 'other@example.com',
        ]);

        $this->actingAs($user)
            ->patch(route('staff.sessions.players.update', [$secondSession, $player]), [
                'display_name' => 'Shared Name',
                'email' => 'shared@example.com',
                'skill_level' => 'intermediate',
                'gender' => 'unspecified',
            ])
            ->assertRedirect(route('staff.sessions.players.index', $secondSession));

        $this->assertDatabaseHas('players', [
            'id' => $player->id,
            'open_play_session_id' => $secondSession->id,
            'display_name' => 'Shared Name',
            'email' => 'shared@example.com',
        ]);
    }

    public function test_non_staff_users_cannot_access_player_management(): void
    {
        $user = User::factory()->create(['is_staff' => false]);
        $session = OpenPlaySession::factory()->create();
        $player = Player::factory()->for($session)->create();

        $this->actingAs($user)->get(route('staff.sessions.players.index', $session))->assertForbidden();
        $this->actingAs($user)->get(route('staff.sessions.players.create', $session))->assertForbidden();
        $this->actingAs($user)->get(route('staff.sessions.players.edit', [$session, $player]))->assertForbidden();
        $this->actingAs($user)->patch(route('staff.sessions.players.update', [$session, $player]), [])->assertForbidden();
        $this->actingAs($user)->delete(route('staff.sessions.players.destroy', [$session, $player]))->assertForbidden();
    }

    public function test_cross_session_player_access_returns_not_found(): void
    {
        $user = User::factory()->staff()->create();
        $firstSession = OpenPlaySession::factory()->create();
        $secondSession = OpenPlaySession::factory()->create();
        $player = Player::factory()->for($firstSession)->create();

        $this->actingAs($user)
            ->get(route('staff.sessions.players.edit', [$secondSession, $player]))
            ->assertNotFound();

        $this->actingAs($user)
            ->patch(route('staff.sessions.players.update', [$secondSession, $player]), [
                'display_name' => 'Wrong Session',
                'email' => null,
                'skill_level' => 'intermediate',
                'gender' => 'unspecified',
            ])
            ->assertNotFound();

        $this->actingAs($user)
            ->delete(route('staff.sessions.players.destroy', [$secondSession, $player]))
            ->assertNotFound();
    }

    public function test_staff_can_delete_waiting_player_and_queue_entry_is_removed(): void
    {
        $user = User::factory()->staff()->create();
        $session = OpenPlaySession::factory()->create();
        $player = Player::factory()->for($session)->create(['display_name' => 'Queue Player']);
        (new QueueService())->addPlayerToQueue($session, $player);

        $this->actingAs($user)
            ->delete(route('staff.sessions.players.destroy', [$session, $player]))
            ->assertRedirect(route('staff.sessions.players.index', $session));

        $this->assertSoftDeleted('players', ['id' => $player->id]);
        $this->assertSoftDeleted('queue_entries', [
            'open_play_session_id' => $session->id,
            'player_id' => $player->id,
        ]);
    }

    public function test_staff_cannot_delete_player_in_in_progress_match(): void
    {
        $user = User::factory()->staff()->create();
        $session = OpenPlaySession::factory()->create();
        $court = Court::factory()->for($session)->create();
        $player = Player::factory()->for($session)->create(['status' => 'playing']);
        $partner = Player::factory()->for($session)->create(['status' => 'playing']);
        $opponentOne = Player::factory()->for($session)->create(['status' => 'playing']);
        $opponentTwo = Player::factory()->for($session)->create(['status' => 'playing']);

        $match = CourtMatch::factory()
            ->for($session)
            ->for($court, 'court')
            ->create(['status' => 'in_progress']);

        MatchTeam::factory()->for($match, 'match')->create([
            'team_number' => 1,
            'player_one_id' => $player->id,
            'player_two_id' => $partner->id,
        ]);

        MatchTeam::factory()->for($match, 'match')->create([
            'team_number' => 2,
            'player_one_id' => $opponentOne->id,
            'player_two_id' => $opponentTwo->id,
        ]);

        $this->actingAs($user)
            ->from(route('staff.sessions.players.index', $session))
            ->delete(route('staff.sessions.players.destroy', [$session, $player]))
            ->assertRedirect(route('staff.sessions.players.index', $session))
            ->assertSessionHasErrors(['player']);

        $this->assertNotSoftDeleted('players', ['id' => $player->id]);
    }

    public function test_session_dashboard_links_to_player_management(): void
    {
        $user = User::factory()->staff()->create();
        $session = OpenPlaySession::factory()->create();

        $this->actingAs($user)
            ->get(route('staff.sessions.show', $session))
            ->assertOk()
            ->assertSee('Manage Players')
            ->assertSee(route('staff.sessions.players.index', $session), false);
    }

    public function test_staff_flash_messages_are_exposed_for_sweetalert_without_bootstrap_alerts(): void
    {
        $user = User::factory()->staff()->create();

        $this->actingAs($user)
            ->withSession(['status' => 'Player updated.'])
            ->get(route('staff.sessions.index'))
            ->assertOk()
            ->assertSeeHtml('data-cq-flash-status="Player updated."')
            ->assertDontSee('alert alert-success', false);
    }

    public function test_player_delete_forms_use_sweetalert_confirmation_attributes(): void
    {
        $user = User::factory()->staff()->create();
        $session = OpenPlaySession::factory()->create();
        Player::factory()->for($session)->create(['display_name' => 'Delete Me']);

        $this->actingAs($user)
            ->get(route('staff.sessions.players.index', $session))
            ->assertOk()
            ->assertSeeHtml('data-cq-confirm')
            ->assertSeeHtml('data-cq-confirm-title="Delete player?"');
    }
}
```

- [ ] **Step 2: Run the new test file to verify it fails**

Run:

```bash
php artisan test tests/Feature/StaffPlayerManagementTest.php
```

Expected: FAIL with missing route names such as `staff.sessions.players.index`.

- [ ] **Step 3: Commit tests if Git is available**

Run:

```bash
git add tests/Feature/StaffPlayerManagementTest.php
git commit -m "test: add staff player management coverage"
```

Expected in a normal repo: commit succeeds. Expected in this workspace if Git remains unavailable: `fatal: not a git repository`; record the file as changed instead.

---

### Task 2: Add Routes, Update Request Validation, And Player Service

**Files:**
- Modify: `routes/web.php`
- Modify: `app/Http/Requests/Staff/StorePlayerRequest.php`
- Create: `app/Http/Requests/Staff/UpdatePlayerRequest.php`
- Create: `app/Services/PlayerManagementService.php`

- [ ] **Step 1: Add nested player routes**

In `routes/web.php`, replace the single player store route with the full ordered group:

```php
        Route::get('sessions/{session}/players', [PlayerController::class, 'index'])
            ->name('sessions.players.index');

        Route::get('sessions/{session}/players/create', [PlayerController::class, 'create'])
            ->name('sessions.players.create');

        Route::post('sessions/{session}/players', [PlayerController::class, 'store'])
            ->name('sessions.players.store');

        Route::get('sessions/{session}/players/{player}/edit', [PlayerController::class, 'edit'])
            ->name('sessions.players.edit');

        Route::patch('sessions/{session}/players/{player}', [PlayerController::class, 'update'])
            ->name('sessions.players.update');

        Route::delete('sessions/{session}/players/{player}', [PlayerController::class, 'destroy'])
            ->name('sessions.players.destroy');
```

Keep the import route after these player routes:

```php
        Route::post('sessions/{session}/players/import', [PlayerImportController::class, 'store'])
            ->name('sessions.players.import');
```

- [ ] **Step 2: Extend store validation for management-page redirects**

In `app/Http/Requests/Staff/StorePlayerRequest.php`, add `return_to` to the rules:

```php
            'check_in' => ['nullable', 'boolean'],
            'return_to' => ['nullable', Rule::in(['session', 'players'])],
```

- [ ] **Step 3: Create update request**

Create `app/Http/Requests/Staff/UpdatePlayerRequest.php`:

```php
<?php

namespace App\Http\Requests\Staff;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class UpdatePlayerRequest extends FormRequest
{
    public function authorize(): bool
    {
        return (bool) $this->user()?->is_staff;
    }

    protected function prepareForValidation(): void
    {
        $this->merge([
            'skill_level' => $this->input('skill_level', 'intermediate'),
            'gender' => $this->input('gender', 'unspecified'),
        ]);
    }

    /**
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        $session = $this->route('session');
        $player = $this->route('player');

        return [
            'display_name' => [
                'required',
                'string',
                'max:255',
                Rule::unique('players', 'display_name')
                    ->where('open_play_session_id', $session?->id)
                    ->ignore($player?->id),
            ],
            'email' => [
                'nullable',
                'email',
                'max:255',
                Rule::unique('players', 'email')
                    ->where('open_play_session_id', $session?->id)
                    ->ignore($player?->id),
            ],
            'skill_level' => ['required', Rule::in(['beginner', 'intermediate', 'advanced'])],
            'gender' => ['required', Rule::in(['male', 'female', 'unspecified'])],
        ];
    }
}
```

- [ ] **Step 4: Create player management service**

Create `app/Services/PlayerManagementService.php`:

```php
<?php

namespace App\Services;

use App\Models\CourtMatch;
use App\Models\OpenPlaySession;
use App\Models\Player;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

class PlayerManagementService
{
    public function __construct(private readonly QueueService $queueService)
    {
    }

    public function ensureBelongsToSession(OpenPlaySession $session, Player $player): void
    {
        abort_if((int) $player->open_play_session_id !== (int) $session->id, 404);
    }

    /**
     * @throws ValidationException
     */
    public function deleteFromSession(OpenPlaySession $session, Player $player): void
    {
        $this->ensureBelongsToSession($session, $player);

        if ($this->isInProgressMatch($session, $player)) {
            throw ValidationException::withMessages([
                'player' => 'Finish this active match before deleting the player.',
            ]);
        }

        DB::transaction(function () use ($session, $player) {
            $this->queueService->removePlayerFromQueue($session, $player);
            $player->delete();
        });
    }

    public function isInProgressMatch(OpenPlaySession $session, Player $player): bool
    {
        return CourtMatch::query()
            ->where('open_play_session_id', $session->id)
            ->where('status', 'in_progress')
            ->whereHas('teams', function ($query) use ($player) {
                $query->where('player_one_id', $player->id)
                    ->orWhere('player_two_id', $player->id);
            })
            ->exists();
    }
}
```

- [ ] **Step 5: Run syntax checks**

Run:

```bash
php -l routes/web.php
php -l app/Http/Requests/Staff/StorePlayerRequest.php
php -l app/Http/Requests/Staff/UpdatePlayerRequest.php
php -l app/Services/PlayerManagementService.php
```

Expected: `No syntax errors detected` for each file.

- [ ] **Step 6: Run targeted tests**

Run:

```bash
php artisan test tests/Feature/StaffPlayerManagementTest.php
```

Expected: still FAIL because controller methods and views are not implemented yet.

- [ ] **Step 7: Commit backend foundations if Git is available**

Run:

```bash
git add routes/web.php app/Http/Requests/Staff/StorePlayerRequest.php app/Http/Requests/Staff/UpdatePlayerRequest.php app/Services/PlayerManagementService.php
git commit -m "feat: add session player management routes and validation"
```

---

### Task 3: Implement Player Controller CRUD

**Files:**
- Modify: `app/Http/Controllers/Staff/PlayerController.php`

- [ ] **Step 1: Replace the controller with CRUD behavior**

Update `app/Http/Controllers/Staff/PlayerController.php` to:

```php
<?php

namespace App\Http\Controllers\Staff;

use App\Http\Controllers\Controller;
use App\Http\Requests\Staff\StorePlayerRequest;
use App\Http\Requests\Staff\UpdatePlayerRequest;
use App\Models\OpenPlaySession;
use App\Models\Player;
use App\Services\PlayerManagementService;
use App\Services\QueueService;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;

class PlayerController extends Controller
{
    public function __construct(
        private readonly QueueService $queueService,
        private readonly PlayerManagementService $playerManagement,
    ) {
    }

    public function index(OpenPlaySession $session): View
    {
        $players = $session->players()
            ->orderBy('display_name')
            ->get();

        return view('staff.players.index', [
            'session' => $session,
            'players' => $players,
        ]);
    }

    public function create(OpenPlaySession $session): View
    {
        return view('staff.players.create', [
            'session' => $session,
        ]);
    }

    public function store(StorePlayerRequest $request, OpenPlaySession $session): RedirectResponse
    {
        $player = Player::query()->create([
            ...$request->safe()->only(['display_name', 'email', 'skill_level', 'gender']),
            'import_source' => 'manual',
            'open_play_session_id' => $session->id,
            'status' => 'active',
        ]);

        if ($request->boolean('check_in')) {
            $this->queueService->addPlayerToQueue($session, $player);
        }

        $route = $request->input('return_to') === 'players'
            ? 'staff.sessions.players.index'
            : 'staff.sessions.show';

        return redirect()
            ->route($route, $session)
            ->with('status', 'Player added.');
    }

    public function edit(OpenPlaySession $session, Player $player): View
    {
        $this->playerManagement->ensureBelongsToSession($session, $player);

        return view('staff.players.edit', [
            'session' => $session,
            'player' => $player,
        ]);
    }

    public function update(UpdatePlayerRequest $request, OpenPlaySession $session, Player $player): RedirectResponse
    {
        $this->playerManagement->ensureBelongsToSession($session, $player);

        $player->update($request->safe()->only(['display_name', 'email', 'skill_level', 'gender']));

        return redirect()
            ->route('staff.sessions.players.index', $session)
            ->with('status', 'Player updated.');
    }

    public function destroy(OpenPlaySession $session, Player $player): RedirectResponse
    {
        $this->playerManagement->deleteFromSession($session, $player);

        return redirect()
            ->route('staff.sessions.players.index', $session)
            ->with('status', 'Player deleted.');
    }
}
```

- [ ] **Step 2: Run syntax check**

Run:

```bash
php -l app/Http/Controllers/Staff/PlayerController.php
```

Expected: `No syntax errors detected`.

- [ ] **Step 3: Run targeted tests**

Run:

```bash
php artisan test tests/Feature/StaffPlayerManagementTest.php
```

Expected: FAIL because the new Blade views do not exist yet.

- [ ] **Step 4: Commit controller if Git is available**

Run:

```bash
git add app/Http/Controllers/Staff/PlayerController.php
git commit -m "feat: implement staff player controller crud"
```

---

### Task 4: Build Player Management Blade Views

**Files:**
- Create: `resources/views/staff/players/index.blade.php`
- Create: `resources/views/staff/players/create.blade.php`
- Create: `resources/views/staff/players/edit.blade.php`
- Modify: `resources/views/staff/sessions/show.blade.php`
- Modify: `resources/css/app.css`

- [ ] **Step 1: Create player index view**

Create `resources/views/staff/players/index.blade.php`:

```blade
@extends('layouts.staff')

@php
    $skillLabels = ['beginner' => 'Beginner', 'intermediate' => 'Intermediate', 'advanced' => 'Advanced'];
    $genderLabels = ['male' => 'Male', 'female' => 'Female', 'unspecified' => 'Unspecified'];
    $sourceLabels = ['manual' => 'Manual', 'reclub_paste' => 'Reclub'];
@endphp

@section('content')
    <div class="d-flex flex-column flex-lg-row align-items-lg-center justify-content-between gap-3 mb-4">
        <div>
            <p class="ck-page-kicker mb-1">Player Management</p>
            <h1 class="h3 ck-heading mb-1">Manage Players</h1>
            <div class="text-secondary small">{{ $session->name }} · {{ $session->venue ?? 'Venue not set' }}</div>
        </div>
        <div class="d-flex flex-column flex-sm-row gap-2">
            <a href="{{ route('staff.sessions.show', $session) }}" class="btn btn-outline-secondary">Back to Dashboard</a>
            <a href="{{ route('staff.sessions.players.create', $session) }}" class="btn btn-cq btn-cq-active">Add Player</a>
        </div>
    </div>

    <section class="cq-card p-3">
        <div class="d-flex flex-column flex-md-row justify-content-between gap-2 mb-3">
            <div>
                <h2 class="cq-section-title mb-1">Session Roster</h2>
                <div class="small text-secondary">{{ $players->count() }} players in this session</div>
            </div>
            <span class="badge ck-badge ck-status-{{ str_replace('_', '-', $session->status) }} align-self-start">{{ ucfirst($session->status) }}</span>
        </div>

        <div class="ck-player-card-list d-md-none">
            @forelse ($players as $player)
                <article class="ck-player-card">
                    <div class="d-flex justify-content-between gap-3">
                        <div>
                            <h3 class="h6 fw-bold mb-1">{{ $player->display_name }}</h3>
                            <div class="small text-secondary">{{ $player->email ?? 'No email' }}</div>
                        </div>
                        <span class="badge ck-badge ck-status-{{ str_replace('_', '-', $player->status) }}">{{ str_replace('_', ' ', $player->status) }}</span>
                    </div>
                    <div class="ck-meta-badges my-3">
                        <span class="badge ck-badge">{{ $skillLabels[$player->skill_level] ?? 'Intermediate' }}</span>
                        <span class="badge ck-badge">{{ $genderLabels[$player->gender] ?? 'Unspecified' }}</span>
                        <span class="badge ck-badge">{{ $sourceLabels[$player->import_source] ?? ucfirst(str_replace('_', ' ', $player->import_source)) }}</span>
                    </div>
                    <div class="row g-2 small text-secondary mb-3">
                        <div class="col-4"><strong class="text-dark">{{ $player->games_played }}</strong><br>Games</div>
                        <div class="col-4"><strong class="text-dark">{{ $player->wins }}</strong><br>Wins</div>
                        <div class="col-4"><strong class="text-dark">{{ $player->losses }}</strong><br>Losses</div>
                    </div>
                    <div class="d-flex gap-2">
                        <a href="{{ route('staff.sessions.players.edit', [$session, $player]) }}" class="btn btn-outline-dark btn-sm flex-fill">Edit</a>
                        <form method="POST" action="{{ route('staff.sessions.players.destroy', [$session, $player]) }}" class="flex-fill" data-cq-confirm data-cq-confirm-title="Delete player?" data-cq-confirm-text="This removes {{ $player->display_name }} from this session and queue.">
                            @csrf
                            @method('DELETE')
                            <button type="submit" class="btn btn-outline-danger btn-sm w-100">Delete</button>
                        </form>
                    </div>
                </article>
            @empty
                <div class="text-secondary small">No players yet.</div>
            @endforelse
        </div>

        <div class="table-responsive d-none d-md-block">
            <table class="table table-sm table-striped align-middle mb-0">
                <thead>
                    <tr>
                        <th>Player</th>
                        <th>Skill</th>
                        <th>Gender</th>
                        <th>Status</th>
                        <th>Source</th>
                        <th>Record</th>
                        <th class="text-end">Actions</th>
                    </tr>
                </thead>
                <tbody>
                    @forelse ($players as $player)
                        <tr>
                            <td>
                                <div class="fw-semibold">{{ $player->display_name }}</div>
                                <div class="small text-secondary">{{ $player->email ?? 'No email' }}</div>
                            </td>
                            <td><span class="badge ck-badge">{{ $skillLabels[$player->skill_level] ?? 'Intermediate' }}</span></td>
                            <td><span class="badge ck-badge">{{ $genderLabels[$player->gender] ?? 'Unspecified' }}</span></td>
                            <td><span class="badge ck-badge ck-status-{{ str_replace('_', '-', $player->status) }}">{{ str_replace('_', ' ', $player->status) }}</span></td>
                            <td><span class="badge ck-badge">{{ $sourceLabels[$player->import_source] ?? ucfirst(str_replace('_', ' ', $player->import_source)) }}</span></td>
                            <td>{{ $player->wins }}-{{ $player->losses }} · {{ $player->games_played }} games</td>
                            <td>
                                <div class="d-flex justify-content-end gap-2">
                                    <a href="{{ route('staff.sessions.players.edit', [$session, $player]) }}" class="btn btn-outline-dark btn-sm">Edit</a>
                                    <form method="POST" action="{{ route('staff.sessions.players.destroy', [$session, $player]) }}" data-cq-confirm data-cq-confirm-title="Delete player?" data-cq-confirm-text="This removes {{ $player->display_name }} from this session and queue.">
                                        @csrf
                                        @method('DELETE')
                                        <button type="submit" class="btn btn-outline-danger btn-sm">Delete</button>
                                    </form>
                                </div>
                            </td>
                        </tr>
                    @empty
                        <tr>
                            <td colspan="7" class="text-secondary">No players yet.</td>
                        </tr>
                    @endforelse
                </tbody>
            </table>
        </div>
    </section>
@endsection
```

- [ ] **Step 2: Create player create view**

Create `resources/views/staff/players/create.blade.php`:

```blade
@extends('layouts.staff')

@php
    $skillLabels = ['beginner' => 'Beginner', 'intermediate' => 'Intermediate', 'advanced' => 'Advanced'];
    $genderLabels = ['male' => 'Male', 'female' => 'Female', 'unspecified' => 'Unspecified'];
@endphp

@section('content')
    <div class="d-flex flex-column flex-lg-row align-items-lg-center justify-content-between gap-3 mb-4">
        <div>
            <p class="ck-page-kicker mb-1">Player Management</p>
            <h1 class="h3 ck-heading mb-1">Add Player</h1>
            <div class="text-secondary small">{{ $session->name }}</div>
        </div>
        <a href="{{ route('staff.sessions.players.index', $session) }}" class="btn btn-outline-secondary align-self-start align-self-lg-center">Back to Players</a>
    </div>

    <section class="cq-card p-3 p-md-4 ck-form-panel">
        <form method="POST" action="{{ route('staff.sessions.players.store', $session) }}">
            @csrf
            <input type="hidden" name="return_to" value="players">

            <div class="mb-3">
                <label for="display_name" class="form-label">Display Name</label>
                <input id="display_name" name="display_name" type="text" value="{{ old('display_name') }}" class="form-control @error('display_name') is-invalid @enderror" required>
                @error('display_name')
                    <div class="invalid-feedback">{{ $message }}</div>
                @enderror
            </div>

            <div class="mb-3">
                <label for="email" class="form-label">Email</label>
                <input id="email" name="email" type="email" value="{{ old('email') }}" class="form-control @error('email') is-invalid @enderror">
                @error('email')
                    <div class="invalid-feedback">{{ $message }}</div>
                @enderror
            </div>

            <div class="row g-2 mb-3">
                <div class="col-12 col-md-6">
                    <label for="skill_level" class="form-label">Skill Level</label>
                    <select id="skill_level" name="skill_level" class="form-select @error('skill_level') is-invalid @enderror">
                        @foreach ($skillLabels as $value => $label)
                            <option value="{{ $value }}" @selected(old('skill_level', 'intermediate') === $value)>{{ $label }}</option>
                        @endforeach
                    </select>
                    @error('skill_level')
                        <div class="invalid-feedback">{{ $message }}</div>
                    @enderror
                </div>
                <div class="col-12 col-md-6">
                    <label for="gender" class="form-label">Gender</label>
                    <select id="gender" name="gender" class="form-select @error('gender') is-invalid @enderror">
                        @foreach ($genderLabels as $value => $label)
                            <option value="{{ $value }}" @selected(old('gender', 'unspecified') === $value)>{{ $label }}</option>
                        @endforeach
                    </select>
                    @error('gender')
                        <div class="invalid-feedback">{{ $message }}</div>
                    @enderror
                </div>
            </div>

            <div class="form-check mb-4">
                <input id="check_in" name="check_in" value="1" type="checkbox" class="form-check-input" @checked(old('check_in', '1'))>
                <label for="check_in" class="form-check-label">Check in to queue</label>
            </div>

            <div class="d-grid d-sm-flex gap-2">
                <button type="submit" class="btn btn-cq btn-cq-active">Add Player</button>
                <a href="{{ route('staff.sessions.players.index', $session) }}" class="btn btn-outline-secondary">Cancel</a>
            </div>
        </form>
    </section>
@endsection
```

- [ ] **Step 3: Create player edit view**

Create `resources/views/staff/players/edit.blade.php`:

```blade
@extends('layouts.staff')

@php
    $skillLabels = ['beginner' => 'Beginner', 'intermediate' => 'Intermediate', 'advanced' => 'Advanced'];
    $genderLabels = ['male' => 'Male', 'female' => 'Female', 'unspecified' => 'Unspecified'];
@endphp

@section('content')
    <div class="d-flex flex-column flex-lg-row align-items-lg-center justify-content-between gap-3 mb-4">
        <div>
            <p class="ck-page-kicker mb-1">Player Management</p>
            <h1 class="h3 ck-heading mb-1">Edit Player</h1>
            <div class="text-secondary small">{{ $session->name }} · {{ $player->display_name }}</div>
        </div>
        <a href="{{ route('staff.sessions.players.index', $session) }}" class="btn btn-outline-secondary align-self-start align-self-lg-center">Back to Players</a>
    </div>

    <section class="cq-card p-3 p-md-4 ck-form-panel">
        <form method="POST" action="{{ route('staff.sessions.players.update', [$session, $player]) }}">
            @csrf
            @method('PATCH')

            <div class="mb-3">
                <label for="display_name" class="form-label">Display Name</label>
                <input id="display_name" name="display_name" type="text" value="{{ old('display_name', $player->display_name) }}" class="form-control @error('display_name') is-invalid @enderror" required>
                @error('display_name')
                    <div class="invalid-feedback">{{ $message }}</div>
                @enderror
            </div>

            <div class="mb-3">
                <label for="email" class="form-label">Email</label>
                <input id="email" name="email" type="email" value="{{ old('email', $player->email) }}" class="form-control @error('email') is-invalid @enderror">
                @error('email')
                    <div class="invalid-feedback">{{ $message }}</div>
                @enderror
            </div>

            <div class="row g-2 mb-4">
                <div class="col-12 col-md-6">
                    <label for="skill_level" class="form-label">Skill Level</label>
                    <select id="skill_level" name="skill_level" class="form-select @error('skill_level') is-invalid @enderror">
                        @foreach ($skillLabels as $value => $label)
                            <option value="{{ $value }}" @selected(old('skill_level', $player->skill_level) === $value)>{{ $label }}</option>
                        @endforeach
                    </select>
                    @error('skill_level')
                        <div class="invalid-feedback">{{ $message }}</div>
                    @enderror
                </div>
                <div class="col-12 col-md-6">
                    <label for="gender" class="form-label">Gender</label>
                    <select id="gender" name="gender" class="form-select @error('gender') is-invalid @enderror">
                        @foreach ($genderLabels as $value => $label)
                            <option value="{{ $value }}" @selected(old('gender', $player->gender) === $value)>{{ $label }}</option>
                        @endforeach
                    </select>
                    @error('gender')
                        <div class="invalid-feedback">{{ $message }}</div>
                    @enderror
                </div>
            </div>

            <div class="d-grid d-sm-flex gap-2">
                <button type="submit" class="btn btn-cq btn-cq-active">Save Player</button>
                <a href="{{ route('staff.sessions.players.index', $session) }}" class="btn btn-outline-secondary">Cancel</a>
            </div>
        </form>
    </section>
@endsection
```

- [ ] **Step 4: Add Manage Players action to session dashboard**

In `resources/views/staff/sessions/show.blade.php`, replace:

```blade
    <section class="cq-card p-3 mt-3">
        <h2 class="cq-section-title mb-3">Player Standings</h2>
```

with:

```blade
    <section class="cq-card p-3 mt-3">
        <div class="d-flex flex-column flex-sm-row justify-content-between align-items-sm-center gap-2 mb-3">
            <h2 class="cq-section-title mb-0">Player Standings</h2>
            <a href="{{ route('staff.sessions.players.index', $session) }}" class="btn btn-outline-dark btn-sm">Manage Players</a>
        </div>
```

- [ ] **Step 5: Add player management CSS**

Append to `resources/css/app.css` before the media queries:

```css
.ck-form-panel {
    max-width: 44rem;
}

.ck-player-card-list {
    display: grid;
    gap: .85rem;
}

.ck-player-card {
    border: 1px solid var(--cq-line);
    border-radius: .5rem;
    padding: 1rem;
    background: #ffffff;
}

.ck-player-card .btn {
    min-height: 2.35rem;
}
```

- [ ] **Step 6: Run targeted tests**

Run:

```bash
php artisan test tests/Feature/StaffPlayerManagementTest.php
```

Expected: most CRUD tests pass, SweetAlert2 alert tests may still fail until the alert bridge task is complete.

- [ ] **Step 7: Commit views if Git is available**

Run:

```bash
git add resources/views/staff/players/index.blade.php resources/views/staff/players/create.blade.php resources/views/staff/players/edit.blade.php resources/views/staff/sessions/show.blade.php resources/css/app.css
git commit -m "feat: add staff player management views"
```

---

### Task 5: Convert Flash Alerts And Confirmations To SweetAlert2

**Files:**
- Modify: `package.json`
- Modify: `package-lock.json`
- Modify: `resources/js/app.js`
- Modify: `resources/views/layouts/staff.blade.php`
- Modify: `resources/views/auth/login.blade.php`
- Modify: `resources/views/components/auth-session-status.blade.php`
- Modify: `resources/css/app.css`

- [ ] **Step 1: Install SweetAlert2**

Run:

```bash
cmd /c npm install sweetalert2
```

Expected: `package.json` gains `sweetalert2` under dependencies and `package-lock.json` updates.

- [ ] **Step 2: Add SweetAlert2 bridge**

Replace `resources/js/app.js` with:

```js
import './bootstrap';
import 'bootstrap';
import Swal from 'sweetalert2';

import Alpine from 'alpinejs';

window.Alpine = Alpine;
window.Swal = Swal;

function parseJsonDataset(value, fallback) {
    if (!value) {
        return fallback;
    }

    try {
        return JSON.parse(value);
    } catch {
        return fallback;
    }
}

function showFlashMessages() {
    document.querySelectorAll('[data-cq-flash]').forEach((element) => {
        const status = element.dataset.cqFlashStatus;
        const errors = parseJsonDataset(element.dataset.cqFlashErrors, []);

        if (status) {
            Swal.fire({
                icon: 'success',
                title: status,
                timer: 2200,
                showConfirmButton: false,
                customClass: {
                    popup: 'ck-swal-popup',
                    title: 'ck-swal-title',
                },
            });

            return;
        }

        if (errors.length > 0) {
            Swal.fire({
                icon: 'error',
                title: 'Please check the highlighted items.',
                text: errors.join('\n'),
                confirmButtonText: 'Review',
                customClass: {
                    popup: 'ck-swal-popup',
                    title: 'ck-swal-title',
                    confirmButton: 'btn btn-cq btn-cq-active',
                },
                buttonsStyling: false,
            });
        }
    });
}

function attachConfirmations() {
    document.querySelectorAll('form[data-cq-confirm]').forEach((form) => {
        form.addEventListener('submit', (event) => {
            if (form.dataset.cqConfirmed === 'true') {
                return;
            }

            event.preventDefault();

            Swal.fire({
                icon: 'warning',
                title: form.dataset.cqConfirmTitle || 'Are you sure?',
                text: form.dataset.cqConfirmText || 'This action cannot be undone.',
                showCancelButton: true,
                confirmButtonText: form.dataset.cqConfirmButton || 'Continue',
                cancelButtonText: 'Cancel',
                customClass: {
                    popup: 'ck-swal-popup',
                    title: 'ck-swal-title',
                    confirmButton: 'btn btn-danger',
                    cancelButton: 'btn btn-outline-secondary',
                    actions: 'ck-swal-actions',
                },
                buttonsStyling: false,
            }).then((result) => {
                if (result.isConfirmed) {
                    form.dataset.cqConfirmed = 'true';
                    form.submit();
                }
            });
        });
    });
}

document.addEventListener('DOMContentLoaded', () => {
    showFlashMessages();
    attachConfirmations();
});

Alpine.start();
```

- [ ] **Step 3: Replace staff layout Bootstrap alerts with data payload**

In `resources/views/layouts/staff.blade.php`, replace:

```blade
                @if (session('status'))
                    <div class="alert alert-success" role="status">{{ session('status') }}</div>
                @endif

                @if ($errors->any())
                    <div class="alert alert-danger" role="alert">
                        <div class="fw-semibold">Please check the highlighted items.</div>
                        <ul class="mb-0">
                            @foreach ($errors->all() as $error)
                                <li>{{ $error }}</li>
                            @endforeach
                        </ul>
                    </div>
                @endif
```

with:

```blade
                <div
                    data-cq-flash
                    data-cq-flash-status="{{ e(session('status') ?? '') }}"
                    data-cq-flash-errors="{{ e(json_encode($errors->any() ? $errors->all() : [])) }}"
                    hidden
                ></div>
```

- [ ] **Step 4: Replace login Bootstrap status alert**

In `resources/views/auth/login.blade.php`, replace:

```blade
                            @if (session('status'))
                                <div class="alert alert-success" role="status">
                                    {{ session('status') }}
                                </div>
                            @endif
```

with:

```blade
                            <div
                                data-cq-flash
                                data-cq-flash-status="{{ e(session('status') ?? '') }}"
                                data-cq-flash-errors="[]"
                                hidden
                            ></div>
```

- [ ] **Step 5: Update auth session status component**

Replace `resources/views/components/auth-session-status.blade.php` with:

```blade
@props(['status'])

@if ($status)
    <div
        {{ $attributes->merge([
            'data-cq-flash' => true,
            'data-cq-flash-status' => $status,
            'data-cq-flash-errors' => '[]',
            'hidden' => true,
        ]) }}
    ></div>
@endif
```

- [ ] **Step 6: Add SweetAlert2 CSS**

Append to `resources/css/app.css` before the media queries:

```css
.ck-swal-popup {
    border-radius: .5rem;
    border: 1px solid var(--cq-line);
    font-family: var(--ck-sans);
}

.ck-swal-title {
    color: var(--cq-primary);
    font-family: var(--ck-serif);
    letter-spacing: .03em;
}

.ck-swal-actions {
    gap: .5rem;
}
```

- [ ] **Step 7: Run SweetAlert-related tests**

Run:

```bash
php artisan test tests/Feature/StaffPlayerManagementTest.php --filter=sweetalert
```

Expected: PASS for the flash payload and delete confirmation tests.

- [ ] **Step 8: Run build**

Run:

```bash
cmd /c npm run build
```

Expected: Vite builds successfully and emits updated assets under `public/build/assets`.

- [ ] **Step 9: Commit alert integration if Git is available**

Run:

```bash
git add package.json package-lock.json resources/js/app.js resources/views/layouts/staff.blade.php resources/views/auth/login.blade.php resources/views/components/auth-session-status.blade.php resources/css/app.css public/build
git commit -m "feat: convert alerts to sweetalert2"
```

---

### Task 6: QA, Security Review, And Full Verification

**Files:**
- Review: all files changed by Tasks 1-5

- [ ] **Step 1: Product owner review**

Open the implemented player management page and confirm:

- The session dashboard has a clear `Manage Players` action.
- The management page lists players and their session-specific status.
- Add/edit/delete controls are reachable on mobile-sized layouts.
- Success and error messages appear as SweetAlert2 dialogs.

- [ ] **Step 2: Architect review**

Confirm:

- No global player directory was introduced.
- Routes remain nested under `staff/sessions/{session}`.
- Player update/delete paths verify `open_play_session_id`.
- Matchmaking services were not changed.

- [ ] **Step 3: Security review**

Confirm:

- Player management routes stay inside the `auth`, `verified`, and `staff` middleware group.
- `StorePlayerRequest` and `UpdatePlayerRequest` authorize staff users.
- Cross-session player access returns 404.
- Delete uses soft delete and is blocked for in-progress matches.
- SweetAlert2 payloads are escaped data attributes, not raw inline executable JavaScript.

- [ ] **Step 4: QA targeted verification**

Run:

```bash
php artisan test tests/Feature/StaffPlayerManagementTest.php
```

Expected: all tests in `StaffPlayerManagementTest` pass.

- [ ] **Step 5: Full backend verification**

Run:

```bash
php artisan test
```

Expected: full test suite passes.

- [ ] **Step 6: Frontend production build**

Run:

```bash
cmd /c npm run build
```

Expected: Vite build passes.

- [ ] **Step 7: Final changed-file summary**

Collect changed files. If Git is unavailable, use manual file list from the implementation tasks. If Git is available, run:

```bash
git status --short
```

Expected: output lists only files related to player management CRUD, SweetAlert2 alerts, tests, and built assets.

- [ ] **Step 8: Final commit if Git is available**

Run:

```bash
git add .
git commit -m "feat: add session player management crud"
```

Expected in a normal repo: commit succeeds. If Git remains unavailable, do not attempt alternate destructive commands; report that commits were unavailable.
