# Phase 5 — Clinician Dashboard 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 7 clinician-facing dashboard pages with public profile management, photo upload (Cropper.js), and rich text editing (TipTap).

**Architecture:** One Livewire component per page. New `clinician_profiles` table for public-facing fields. Cropper.js and TipTap integrated as Alpine.js components. Routes under `/clinician/` prefix. Sidebar updated to point to real routes.

**Tech Stack:** Laravel 13, Livewire 4, Tailwind CSS 4, Alpine.js, Pest 4, Cropper.js, TipTap

---

## File Map

| File | Action | Responsibility |
|------|--------|----------------|
| Migration: create_clinician_profiles | Create | Public profile fields table |
| `app/Models/ClinicianProfile.php` | Create | Profile model with publish logic |
| `database/factories/ClinicianProfileFactory.php` | Create | Test factory |
| `app/Livewire/Clinician/ClinicianDashboard.php` | Create | Dashboard stat cards |
| `app/Livewire/Clinician/ClinicianBasicInfo.php` | Create | Hub data form |
| `app/Livewire/Clinician/ClinicianWebProfile.php` | Create | Public profile form with photo + rich text |
| `app/Livewire/Clinician/ClinicianAdminServices.php` | Create | Read-only services + charges |
| `app/Livewire/Clinician/ClinicianRevenue.php` | Create | Disbursement history |
| `app/Livewire/Clinician/ClinicianSessions.php` | Create | Sessions with filter toggles |
| `app/Livewire/Clinician/ClinicianRoster.php` | Create | Active clinicians list |
| 7 page wrapper Blade views | Create | `resources/views/clinician/*.blade.php` |
| 7 Livewire Blade views | Create | `resources/views/livewire/clinician/*.blade.php` |
| `resources/js/components/cropper.js` | Create | Alpine + Cropper.js integration |
| `resources/js/components/tiptap-editor.js` | Create | Alpine + TipTap integration |
| `app/Models/User.php` | Modify | Add `clinicianProfile()` relationship |
| `routes/web.php` | Modify | Add 7 clinician routes |
| `app/View/Components/Sidebar.php` | Modify | Update clinician nav items |
| `package.json` | Modify | Add cropperjs, @tiptap/* |
| `resources/js/app.js` | Modify | Import Alpine components |
| Test files (7+) | Create | One per component + model |

---

## Task 1: Install JS Dependencies (Cropper.js + TipTap)

**Files:**
- Modify: `package.json`

- [ ] **Step 1: Install npm packages**

```bash
npm install cropperjs @tiptap/core @tiptap/starter-kit @tiptap/extension-link
```

- [ ] **Step 2: Verify installation**

```bash
cat node_modules/cropperjs/package.json | grep version
cat node_modules/@tiptap/core/package.json | grep version
```

- [ ] **Step 3: Commit**

```bash
git add package.json package-lock.json
git commit -m "feat: install Cropper.js and TipTap dependencies"
```

---

## Task 2: clinician_profiles Migration + Model

**Files:**
- Create: migration
- Create: `app/Models/ClinicianProfile.php`
- Create: `database/factories/ClinicianProfileFactory.php`
- Modify: `app/Models/User.php`
- Create: `tests/Feature/Models/ClinicianProfileTest.php`

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

Create `tests/Feature/Models/ClinicianProfileTest.php`:

```php
<?php

use App\Models\Clinician;
use App\Models\ClinicianProfile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('belongs to a user', function () {
    $user = User::factory()->create();
    $profile = ClinicianProfile::factory()->create(['user_id' => $user->id]);

    expect($profile->user->id)->toBe($user->id);
});

it('generates a slug from display name', function () {
    $profile = ClinicianProfile::factory()->create([
        'display_first' => 'Jane',
        'display_last' => 'Doe',
    ]);

    expect($profile->slug)->toBe('jane-doe');
});

it('reports publishable when all conditions met', function () {
    $user = User::factory()->create();
    $clinician = Clinician::factory()->create([
        'user_id' => $user->id,
        'hide_profile_override' => false,
    ]);

    $profile = ClinicianProfile::factory()->create([
        'user_id' => $user->id,
        'display_first' => 'Jane',
        'display_last' => 'Doe',
        'credentials' => 'LPC',
        'professional_title' => 'Licensed Counselor',
        'headshot_path' => 'headshots/jane-doe.jpg',
        'approach_note' => 'I help with anxiety.',
        'accepting_inperson' => true,
        'hide_profile' => false,
    ]);

    expect($profile->isPublishable())->toBeTrue();
});

it('reports not publishable when hide_profile is true', function () {
    $user = User::factory()->create();
    Clinician::factory()->create(['user_id' => $user->id, 'hide_profile_override' => false]);

    $profile = ClinicianProfile::factory()->create([
        'user_id' => $user->id,
        'display_first' => 'Jane',
        'display_last' => 'Doe',
        'credentials' => 'LPC',
        'professional_title' => 'Licensed Counselor',
        'headshot_path' => 'headshots/jane-doe.jpg',
        'approach_note' => 'I help.',
        'accepting_inperson' => true,
        'hide_profile' => true,
    ]);

    expect($profile->isPublishable())->toBeFalse();
});

it('reports missing required fields', function () {
    $profile = ClinicianProfile::factory()->create([
        'display_first' => 'Jane',
        'display_last' => 'Doe',
        'credentials' => null,
        'professional_title' => null,
        'headshot_path' => null,
        'approach_note' => null,
    ]);

    $missing = $profile->getMissingRequiredFields();
    expect($missing)->toContain('credentials');
    expect($missing)->toContain('professional_title');
    expect($missing)->toContain('headshot_path');
    expect($missing)->toContain('approach_note');
});

it('is accessible from user via clinicianProfile relationship', function () {
    $user = User::factory()->create();
    ClinicianProfile::factory()->create(['user_id' => $user->id]);

    expect($user->clinicianProfile)->not->toBeNull();
});
```

- [ ] **Step 2: Create migration**

Run: `php artisan make:migration create_clinician_profiles_table --no-interaction`

Write migration:
```php
public function up(): void
{
    Schema::create('clinician_profiles', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
        $table->string('display_first');
        $table->string('display_last');
        $table->string('slug')->unique();
        $table->string('credentials', 15)->nullable();
        $table->string('professional_title', 30)->nullable();
        $table->string('headshot_path')->nullable();
        $table->string('secondary_photo_path')->nullable();
        $table->string('approach_note', 68)->nullable();
        $table->text('modalities')->nullable();
        $table->string('box1_heading', 30)->nullable();
        $table->text('box1_body')->nullable();
        $table->string('box2_heading', 30)->nullable();
        $table->text('box2_body')->nullable();
        $table->string('box3_heading', 30)->nullable();
        $table->text('box3_body')->nullable();
        $table->string('left_box_heading', 30)->nullable();
        $table->text('left_box_body')->nullable();
        $table->string('availability_note', 68)->nullable();
        $table->text('rate_note')->nullable();
        $table->boolean('accepting_inperson')->default(false);
        $table->boolean('accepting_virtual')->default(false);
        $table->json('insurance_accepted')->nullable();
        $table->string('public_email')->nullable();
        $table->string('public_phone')->nullable();
        $table->boolean('hide_profile')->default(true);
        $table->timestamps();
    });
}
```

- [ ] **Step 3: Create ClinicianProfile model**

Create `app/Models/ClinicianProfile.php`:

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;

class ClinicianProfile extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id', 'display_first', 'display_last', 'slug',
        'credentials', 'professional_title', 'headshot_path', 'secondary_photo_path',
        'approach_note', 'modalities',
        'box1_heading', 'box1_body', 'box2_heading', 'box2_body',
        'box3_heading', 'box3_body', 'left_box_heading', 'left_box_body',
        'availability_note', 'rate_note',
        'accepting_inperson', 'accepting_virtual', 'insurance_accepted',
        'public_email', 'public_phone', 'hide_profile',
    ];

    protected function casts(): array
    {
        return [
            'accepting_inperson' => 'boolean',
            'accepting_virtual' => 'boolean',
            'insurance_accepted' => 'array',
            'hide_profile' => 'boolean',
        ];
    }

    protected static function booted(): void
    {
        static::creating(function (ClinicianProfile $profile) {
            if (! $profile->slug) {
                $profile->slug = Str::slug("{$profile->display_first} {$profile->display_last}");
            }
        });
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function isPublishable(): bool
    {
        if (count($this->getMissingRequiredFields()) > 0) {
            return false;
        }

        if ($this->hide_profile) {
            return false;
        }

        $clinician = $this->user->clinician;

        return $clinician && ! $clinician->hide_profile_override;
    }

    /** @return string[] */
    public function getMissingRequiredFields(): array
    {
        $required = [
            'credentials', 'professional_title', 'headshot_path', 'approach_note',
        ];

        $missing = [];

        foreach ($required as $field) {
            if (blank($this->$field)) {
                $missing[] = $field;
            }
        }

        if (! $this->accepting_inperson && ! $this->accepting_virtual) {
            $missing[] = 'accepting_inperson or accepting_virtual';
        }

        return $missing;
    }

    public function scopePublished($query)
    {
        return $query
            ->where('hide_profile', false)
            ->whereNotNull('credentials')
            ->whereNotNull('professional_title')
            ->whereNotNull('headshot_path')
            ->whereNotNull('approach_note')
            ->where(fn ($q) => $q->where('accepting_inperson', true)->orWhere('accepting_virtual', true))
            ->whereHas('user.clinician', fn ($q) => $q->where('hide_profile_override', false));
    }
}
```

- [ ] **Step 4: Create factory**

Create `database/factories/ClinicianProfileFactory.php`:

```php
<?php

namespace Database\Factories;

use App\Models\ClinicianProfile;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<ClinicianProfile> */
class ClinicianProfileFactory extends Factory
{
    public function definition(): array
    {
        return [
            'user_id' => User::factory(),
            'display_first' => fake()->firstName(),
            'display_last' => fake()->lastName(),
            'slug' => fake()->unique()->slug(2),
            'credentials' => 'LPC, NCC',
            'professional_title' => 'Licensed Professional Counselor',
            'headshot_path' => null,
            'approach_note' => fake()->sentence(8),
            'accepting_inperson' => true,
            'accepting_virtual' => false,
            'hide_profile' => true,
        ];
    }

    public function published(): static
    {
        return $this->state(fn () => [
            'headshot_path' => 'headshots/test.jpg',
            'hide_profile' => false,
        ]);
    }
}
```

- [ ] **Step 5: Add relationship to User model**

Add to `app/Models/User.php` after the existing `clinician()` relationship (around line 107):

```php
public function clinicianProfile(): HasOne
{
    return $this->hasOne(ClinicianProfile::class);
}
```

- [ ] **Step 6: Run migration and tests**

```bash
php artisan migrate
php artisan test --compact --filter=ClinicianProfileTest
```

- [ ] **Step 7: Pint and commit**

```bash
vendor/bin/pint --dirty --format agent
git add database/migrations/*clinician_profiles* app/Models/ClinicianProfile.php database/factories/ClinicianProfileFactory.php app/Models/User.php tests/Feature/Models/ClinicianProfileTest.php
git commit -m "feat: add clinician_profiles table, model, and publish logic"
```

---

## Task 3: Alpine Components (Cropper.js + TipTap)

**Files:**
- Create: `resources/js/components/cropper.js`
- Create: `resources/js/components/tiptap-editor.js`
- Modify: `resources/js/app.js`

- [ ] **Step 1: Create Cropper.js Alpine component**

Create `resources/js/components/cropper.js`:

```js
import Cropper from 'cropperjs';
import 'cropperjs/dist/cropper.css';

export default function photoCropper() {
    return {
        cropper: null,
        showModal: false,
        previewUrl: null,

        openCropper(event) {
            const file = event.target.files[0];
            if (!file) return;

            const reader = new FileReader();
            reader.onload = (e) => {
                this.previewUrl = e.target.result;
                this.showModal = true;

                this.$nextTick(() => {
                    const image = this.$refs.cropImage;
                    if (this.cropper) this.cropper.destroy();

                    this.cropper = new Cropper(image, {
                        aspectRatio: 1,
                        viewMode: 1,
                        guides: true,
                        autoCropArea: 0.9,
                    });
                });
            };
            reader.readAsDataURL(file);
        },

        saveCrop() {
            if (!this.cropper) return;

            this.cropper.getCroppedCanvas({
                width: 600,
                height: 600,
                imageSmoothingQuality: 'high',
            }).toBlob((blob) => {
                const file = new File([blob], 'headshot.jpg', { type: 'image/jpeg' });

                // Upload to Livewire
                this.$wire.upload('headshotFile', file, () => {
                    this.closeCropper();
                });
            }, 'image/jpeg', 0.9);
        },

        closeCropper() {
            this.showModal = false;
            if (this.cropper) {
                this.cropper.destroy();
                this.cropper = null;
            }
            this.previewUrl = null;
        },
    };
}
```

- [ ] **Step 2: Create TipTap Alpine component**

Create `resources/js/components/tiptap-editor.js`:

```js
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';

export default function tiptapEditor(content, fieldName) {
    return {
        editor: null,

        init() {
            this.editor = new Editor({
                element: this.$refs.editor,
                extensions: [
                    StarterKit.configure({
                        heading: false,
                        bulletList: false,
                        orderedList: false,
                        blockquote: false,
                        codeBlock: false,
                        code: false,
                        horizontalRule: false,
                    }),
                    Link.configure({
                        openOnClick: false,
                        HTMLAttributes: {
                            target: '_blank',
                            rel: 'noopener noreferrer',
                        },
                    }),
                ],
                content: content || '',
                onUpdate: ({ editor }) => {
                    this.$wire.set(fieldName, editor.getHTML());
                },
            });
        },

        toggleBold() { this.editor?.chain().focus().toggleBold().run(); },
        toggleItalic() { this.editor?.chain().focus().toggleItalic().run(); },

        setLink() {
            const url = prompt('URL:');
            if (url) {
                this.editor?.chain().focus().setLink({ href: url }).run();
            }
        },

        removeLink() {
            this.editor?.chain().focus().unsetLink().run();
        },

        destroy() {
            this.editor?.destroy();
        },
    };
}
```

- [ ] **Step 3: Register in app.js**

Replace `resources/js/app.js`:

```js
import './bootstrap';
import photoCropper from './components/cropper.js';
import tiptapEditor from './components/tiptap-editor.js';

document.addEventListener('alpine:init', () => {
    Alpine.data('photoCropper', photoCropper);
    Alpine.data('tiptapEditor', tiptapEditor);
});
```

- [ ] **Step 4: Build and verify**

```bash
npm run build
```

- [ ] **Step 5: Commit**

```bash
git add resources/js/components/cropper.js resources/js/components/tiptap-editor.js resources/js/app.js
git commit -m "feat: add Cropper.js and TipTap Alpine components"
```

---

## Task 4: Routes + Sidebar Update

**Files:**
- Modify: `routes/web.php`
- Modify: `app/View/Components/Sidebar.php`

- [ ] **Step 1: Add clinician routes**

Add inside the protected auth group in `routes/web.php` (after the Stripe routes, before the admin group), within the `onboarding.complete` middleware:

```php
    // Clinician dashboard routes
    Route::prefix('clinician')->name('clinician.')->group(function () {
        Route::get('/dashboard', fn () => view('clinician.dashboard'))->name('dashboard');
        Route::get('/basic-info', fn () => view('clinician.basic-info'))->name('basic-info');
        Route::get('/web-profile', fn () => view('clinician.web-profile'))->name('web-profile');
        Route::get('/admin-services', fn () => view('clinician.admin-services'))->name('admin-services');
        Route::get('/revenue', fn () => view('clinician.revenue'))->name('revenue');
        Route::get('/sessions', fn () => view('clinician.sessions'))->name('sessions');
        Route::get('/roster', fn () => view('clinician.roster'))->name('roster');
    });
```

- [ ] **Step 2: Update sidebar clinician nav items**

Replace the clinician nav array in `app/View/Components/Sidebar.php`:

```php
UserRole::Clinician => [
    ['label' => 'Dashboard', 'route' => 'clinician.dashboard', 'icon' => 'chart-pie'],
    ['label' => 'Basic Info', 'route' => 'clinician.basic-info', 'icon' => 'user-circle'],
    ['label' => 'Web Profile', 'route' => 'clinician.web-profile', 'icon' => 'user-circle'],
    ['label' => 'Admin Services', 'route' => 'clinician.admin-services', 'icon' => 'briefcase'],
    ['label' => 'Revenue', 'route' => 'clinician.revenue', 'icon' => 'banknotes'],
    ['label' => 'Sessions', 'route' => 'clinician.sessions', 'icon' => 'calendar-days'],
    ['label' => 'Clinician Roster', 'route' => 'clinician.roster', 'icon' => 'user-group'],
    ['label' => 'Reports', 'route' => 'placeholder', 'icon' => 'chart-bar', 'params' => ['page' => 'reports']],
],
```

- [ ] **Step 3: Create page wrapper views**

Create 7 simple Blade files in `resources/views/clinician/`:

Each follows the pattern (example for dashboard):
```blade
{{-- resources/views/clinician/dashboard.blade.php --}}
<x-layouts.app title="Dashboard">
    <livewire:clinician.clinician-dashboard />
</x-layouts.app>
```

Repeat for: `basic-info.blade.php`, `web-profile.blade.php`, `admin-services.blade.php`, `revenue.blade.php`, `sessions.blade.php`, `roster.blade.php`.

- [ ] **Step 4: Update DashboardController to redirect clinicians**

Modify `app/Http/Controllers/DashboardController.php` to redirect clinicians to their new dashboard:

```php
if ($user->role === UserRole::Clinician) {
    return redirect()->route('clinician.dashboard');
}
```

- [ ] **Step 5: Pint and commit**

```bash
vendor/bin/pint --dirty --format agent
git add routes/web.php app/View/Components/Sidebar.php resources/views/clinician/ app/Http/Controllers/DashboardController.php
git commit -m "feat: add clinician routes, sidebar nav, and page wrappers"
```

---

## Task 5: Clinician Dashboard (stat cards)

**Files:**
- Create: `app/Livewire/Clinician/ClinicianDashboard.php`
- Create: `resources/views/livewire/clinician/clinician-dashboard.blade.php`
- Create: `tests/Feature/Clinician/ClinicianDashboardTest.php`

- [ ] **Step 1: Write test**

```php
<?php

use App\Livewire\Clinician\ClinicianDashboard;
use App\Models\Clinician;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;

uses(RefreshDatabase::class);

it('renders for a clinician user', function () {
    $user = User::factory()->create();
    Clinician::factory()->create(['user_id' => $user->id]);

    Livewire::actingAs($user)
        ->test(ClinicianDashboard::class)
        ->assertStatus(200);
});

it('shows stat cards', function () {
    $user = User::factory()->create();
    Clinician::factory()->create(['user_id' => $user->id]);

    Livewire::actingAs($user)
        ->test(ClinicianDashboard::class)
        ->assertSee('Pending Sessions')
        ->assertSee('Next Payout')
        ->assertSee('Outstanding Charges')
        ->assertSee('Profile Status');
});
```

- [ ] **Step 2: Create Livewire component**

```php
<?php

namespace App\Livewire\Clinician;

use App\Enums\DisbursementStatus;
use App\Models\Charge;
use App\Models\Session;
use Illuminate\View\View;
use Livewire\Component;

class ClinicianDashboard extends Component
{
    public function render(): View
    {
        $user = auth()->user();
        $clinician = $user->clinician;

        $pendingSessions = $clinician
            ? Session::query()->where('clinician_id', $clinician->id)->awaitingPayment()->count()
            : 0;

        $nextPayout = $clinician
            ? (float) Session::query()->where('clinician_id', $clinician->id)->pendingDisbursement()->sum('total_paid_amount')
            : 0;

        $outstandingCharges = $clinician
            ? (float) Charge::query()->where('clinician_id', $clinician->id)->pending()->sum('amount')
            : 0;

        $profile = $user->clinicianProfile;
        $profileStatus = match (true) {
            $profile?->isPublishable() => 'Published',
            $profile && count($profile->getMissingRequiredFields()) === 0 => 'Draft',
            default => 'Incomplete',
        };

        return view('livewire.clinician.clinician-dashboard', [
            'pendingSessions' => $pendingSessions,
            'nextPayout' => $nextPayout,
            'outstandingCharges' => $outstandingCharges,
            'profileStatus' => $profileStatus,
        ]);
    }
}
```

- [ ] **Step 3: Create Blade view**

Create `resources/views/livewire/clinician/clinician-dashboard.blade.php` with 4 stat cards using the same card pattern as the admin dashboard (white rounded-lg shadow, navy heading, gold accent).

- [ ] **Step 4: Run tests, Pint, commit**

```bash
php artisan test --compact --filter=ClinicianDashboardTest
vendor/bin/pint --dirty --format agent
git add app/Livewire/Clinician/ClinicianDashboard.php resources/views/livewire/clinician/clinician-dashboard.blade.php tests/Feature/Clinician/ClinicianDashboardTest.php
git commit -m "feat: add clinician dashboard with stat cards"
```

---

## Task 6: Basic Info Page (8A)

**Files:**
- Create: `app/Livewire/Clinician/ClinicianBasicInfo.php`
- Create: `resources/views/livewire/clinician/clinician-basic-info.blade.php`
- Create: `tests/Feature/Clinician/ClinicianBasicInfoTest.php`

- [ ] **Step 1: Write test**

```php
<?php

use App\Livewire\Clinician\ClinicianBasicInfo;
use App\Models\Clinician;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;

uses(RefreshDatabase::class);

it('renders basic info form', function () {
    $user = User::factory()->create();
    Clinician::factory()->create(['user_id' => $user->id]);

    Livewire::actingAs($user)
        ->test(ClinicianBasicInfo::class)
        ->assertStatus(200)
        ->assertSee($user->first_name);
});

it('saves editable fields', function () {
    $user = User::factory()->create(['phone' => null]);
    Clinician::factory()->create(['user_id' => $user->id]);

    Livewire::actingAs($user)
        ->test(ClinicianBasicInfo::class)
        ->set('phone', '503-555-0123')
        ->call('save')
        ->assertHasNoErrors();

    expect($user->fresh()->phone)->toBe('503-555-0123');
});

it('shows admin-entered fields as read-only', function () {
    $user = User::factory()->create();
    $clinician = Clinician::factory()->create(['user_id' => $user->id, 'sp_name' => 'Charlie Brown']);

    Livewire::actingAs($user)
        ->test(ClinicianBasicInfo::class)
        ->assertSee('Charlie Brown');
});
```

- [ ] **Step 2: Create component**

The component loads the user's editable fields (first_name, last_name, phone, credentials from users table) and displays admin-read-only fields (clinician_code, sp_name from clinicians table). `save()` updates only the editable fields.

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

Two-section form: "Your Information" (editable) and "Administrative" (read-only, gray background). Same card styling pattern.

- [ ] **Step 4: Run tests, Pint, commit**

```bash
php artisan test --compact --filter=ClinicianBasicInfoTest
vendor/bin/pint --dirty --format agent
git commit -m "feat: add clinician basic info page"
```

---

## Task 7: Web Profile Page (8B) — Core Form

**Files:**
- Create: `app/Livewire/Clinician/ClinicianWebProfile.php`
- Create: `resources/views/livewire/clinician/clinician-web-profile.blade.php`
- Create: `tests/Feature/Clinician/ClinicianWebProfileTest.php`

This is the largest component. It manages the `clinician_profiles` record.

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

```php
<?php

use App\Livewire\Clinician\ClinicianWebProfile;
use App\Models\Clinician;
use App\Models\ClinicianProfile;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;

uses(RefreshDatabase::class);

it('renders web profile form', function () {
    $user = User::factory()->create();
    Clinician::factory()->create(['user_id' => $user->id]);

    Livewire::actingAs($user)
        ->test(ClinicianWebProfile::class)
        ->assertStatus(200);
});

it('creates a profile on first save', function () {
    $user = User::factory()->create();
    Clinician::factory()->create(['user_id' => $user->id]);

    Livewire::actingAs($user)
        ->test(ClinicianWebProfile::class)
        ->set('display_first', 'Jane')
        ->set('display_last', 'Doe')
        ->set('approach_note', 'I help with anxiety.')
        ->call('save');

    expect(ClinicianProfile::where('user_id', $user->id)->exists())->toBeTrue();
    expect(ClinicianProfile::where('user_id', $user->id)->first()->slug)->toBe('jane-doe');
});

it('updates an existing profile', function () {
    $user = User::factory()->create();
    Clinician::factory()->create(['user_id' => $user->id]);
    ClinicianProfile::factory()->create(['user_id' => $user->id, 'display_first' => 'Jane']);

    Livewire::actingAs($user)
        ->test(ClinicianWebProfile::class)
        ->set('approach_note', 'Updated note.')
        ->call('save');

    expect(ClinicianProfile::where('user_id', $user->id)->first()->approach_note)->toBe('Updated note.');
});

it('shows publish status', function () {
    $user = User::factory()->create();
    Clinician::factory()->create(['user_id' => $user->id, 'hide_profile_override' => false]);
    ClinicianProfile::factory()->published()->create(['user_id' => $user->id]);

    Livewire::actingAs($user)
        ->test(ClinicianWebProfile::class)
        ->assertSee('Published');
});

it('validates character limits', function () {
    $user = User::factory()->create();
    Clinician::factory()->create(['user_id' => $user->id]);

    Livewire::actingAs($user)
        ->test(ClinicianWebProfile::class)
        ->set('display_first', 'Jane')
        ->set('display_last', 'Doe')
        ->set('approach_note', str_repeat('x', 69))
        ->call('save')
        ->assertHasErrors(['approach_note']);
});

it('uploads headshot via file upload', function () {
    Storage::fake('public');

    $user = User::factory()->create();
    Clinician::factory()->create(['user_id' => $user->id]);
    ClinicianProfile::factory()->create(['user_id' => $user->id, 'slug' => 'jane-doe']);

    $file = UploadedFile::fake()->image('headshot.jpg', 600, 600);

    Livewire::actingAs($user)
        ->test(ClinicianWebProfile::class)
        ->set('headshotFile', $file)
        ->call('saveHeadshot');

    $profile = ClinicianProfile::where('user_id', $user->id)->first();
    expect($profile->headshot_path)->not->toBeNull();
    Storage::disk('public')->assertExists($profile->headshot_path);
});

it('shows insurance payer checkboxes from settings', function () {
    Setting::set('insurance_payers', json_encode(['Aetna', 'Moda']));
    $user = User::factory()->create();
    Clinician::factory()->create(['user_id' => $user->id]);

    Livewire::actingAs($user)
        ->test(ClinicianWebProfile::class)
        ->assertSee('Aetna')
        ->assertSee('Moda');
});
```

- [ ] **Step 2: Create the Livewire component**

`app/Livewire/Clinician/ClinicianWebProfile.php` — properties for all profile fields, `mount()` loads from existing profile or blank. `save()` creates or updates `ClinicianProfile`. `saveHeadshot()` handles photo upload. Uses `WithFileUploads`.

Key methods:
- `mount()` — load existing profile or initialize defaults
- `save()` — validate all fields, create/update ClinicianProfile, sanitize HTML for rich text fields
- `saveHeadshot()` — validate image, store to `public/headshots/{slug}.{ext}`, update `headshot_path`
- `toggleHideProfile()` — toggle the `hide_profile` flag

Validation rules:
- `display_first` → required, string, max:255
- `display_last` → required, string, max:255
- `credentials` → nullable, string, max:15
- `professional_title` → nullable, string, max:30
- `approach_note` → nullable, string, max:68
- `box1_heading` etc → nullable, string, max:30
- `headshotFile` → nullable, image, mimes:jpg,jpeg,png,webp, max:5120

HTML sanitization for rich text: strip everything except `<p>`, `<strong>`, `<em>`, `<a>` tags using `strip_tags()` with allowed tags.

- [ ] **Step 3: Create the Blade view**

Large form view with sections:
1. Display name + slug (auto-generated)
2. Credentials + professional title
3. Photo upload with Cropper.js modal (`x-data="photoCropper()"`)
4. Approach note (with character counter)
5. Modalities (textarea)
6. Box 1-3 (heading + TipTap body each, `x-data="tiptapEditor(content, fieldName)"`)
7. Left box (heading + TipTap body)
8. Availability note + rate note
9. Accepting in-person / virtual toggles
10. Insurance accepted (checkboxes from payer list)
11. Public contact (email + phone)
12. Hide profile toggle + publish status badge

- [ ] **Step 4: Run tests, Pint, commit**

```bash
php artisan test --compact --filter=ClinicianWebProfileTest
vendor/bin/pint --dirty --format agent
git commit -m "feat: add clinician web profile with photo upload and rich text"
```

---

## Task 8: Admin Support Services (8C) — Clinician View

**Files:**
- Create: `app/Livewire/Clinician/ClinicianAdminServices.php`
- Create: `resources/views/livewire/clinician/clinician-admin-services.blade.php`
- Create: `tests/Feature/Clinician/ClinicianAdminServicesTest.php`

- [ ] **Step 1: Write test**

```php
<?php

use App\Enums\ChargeStatus;
use App\Livewire\Clinician\ClinicianAdminServices;
use App\Models\Charge;
use App\Models\Clinician;
use App\Models\ClinicianService;
use App\Models\ServiceCatalog;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;

uses(RefreshDatabase::class);

it('renders admin services page for clinician', function () {
    $user = User::factory()->create();
    Clinician::factory()->create(['user_id' => $user->id]);

    Livewire::actingAs($user)
        ->test(ClinicianAdminServices::class)
        ->assertStatus(200);
});

it('shows assigned services', function () {
    $user = User::factory()->create();
    $clinician = Clinician::factory()->create(['user_id' => $user->id]);
    $service = ServiceCatalog::factory()->recurring()->create(['title' => 'Email account']);
    ClinicianService::factory()->create(['clinician_id' => $clinician->id, 'service_catalog_id' => $service->id]);

    Livewire::actingAs($user)
        ->test(ClinicianAdminServices::class)
        ->assertSee('Email account');
});

it('shows pending and settled charges', function () {
    $user = User::factory()->create();
    $clinician = Clinician::factory()->create(['user_id' => $user->id]);
    $service = ServiceCatalog::factory()->create();
    Charge::factory()->create(['clinician_id' => $clinician->id, 'service_catalog_id' => $service->id, 'status' => ChargeStatus::Pending, 'amount' => 15.00]);
    Charge::factory()->settled()->create(['clinician_id' => $clinician->id, 'service_catalog_id' => $service->id, 'amount' => 20.00]);

    Livewire::actingAs($user)
        ->test(ClinicianAdminServices::class)
        ->assertSee('15.00')
        ->assertSee('20.00');
});
```

- [ ] **Step 2: Create component**

Read-only version of the admin `ClinicianSupportServices` — shows three sections (assigned services, pending charges, settled charges) scoped to the authenticated clinician. No CRUD actions.

- [ ] **Step 3: Create view + tests, Pint, commit**

```bash
git commit -m "feat: add clinician admin support services view (read-only)"
```

---

## Task 9: Revenue Page (8D)

**Files:**
- Create: `app/Livewire/Clinician/ClinicianRevenue.php`
- Create: `resources/views/livewire/clinician/clinician-revenue.blade.php`
- Create: `tests/Feature/Clinician/ClinicianRevenueTest.php`

- [ ] **Step 1: Write test**

```php
<?php

use App\Livewire\Clinician\ClinicianRevenue;
use App\Models\Batch;
use App\Models\Clinician;
use App\Models\Disbursement;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;

uses(RefreshDatabase::class);

it('renders revenue page', function () {
    $user = User::factory()->create();
    Clinician::factory()->create(['user_id' => $user->id]);

    Livewire::actingAs($user)
        ->test(ClinicianRevenue::class)
        ->assertStatus(200);
});

it('shows disbursement history for the clinician', function () {
    $admin = User::factory()->admin()->create();
    $user = User::factory()->create();
    $clinician = Clinician::factory()->create(['user_id' => $user->id]);
    $batch = Batch::factory()->create(['processed_by' => $admin->id, 'batch_id' => 'BATCH-260401-01']);
    Disbursement::factory()->create([
        'batch_id' => $batch->id,
        'clinician_id' => $clinician->id,
        'gross_amount' => 500.00,
        'oldest_dos' => '2026-03-01',
        'newest_dos' => '2026-03-15',
    ]);

    Livewire::actingAs($user)
        ->test(ClinicianRevenue::class)
        ->assertSee('BATCH-260401-01')
        ->assertSee('500.00');
});
```

- [ ] **Step 2: Create component**

Queries `Disbursement` where `clinician_id` matches the logged-in clinician. Table: batch_id (from batch), date, sessions, gross, fee, net, avg per session. Paginated.

- [ ] **Step 3: Create view + Pint + commit**

```bash
git commit -m "feat: add clinician revenue page with disbursement history"
```

---

## Task 10: Sessions Page (8E)

**Files:**
- Create: `app/Livewire/Clinician/ClinicianSessions.php`
- Create: `resources/views/livewire/clinician/clinician-sessions.blade.php`
- Create: `tests/Feature/Clinician/ClinicianSessionsTest.php`

- [ ] **Step 1: Write test**

```php
<?php

use App\Enums\DisbursementStatus;
use App\Livewire\Clinician\ClinicianSessions;
use App\Models\Clinician;
use App\Models\Session;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;

uses(RefreshDatabase::class);

it('renders sessions page', function () {
    $user = User::factory()->create();
    Clinician::factory()->create(['user_id' => $user->id]);

    Livewire::actingAs($user)
        ->test(ClinicianSessions::class)
        ->assertStatus(200);
});

it('shows only the clinician own sessions', function () {
    $user = User::factory()->create();
    $clinician = Clinician::factory()->create(['user_id' => $user->id]);
    $otherClinician = Clinician::factory()->create();

    Session::factory()->create(['clinician_id' => $clinician->id, 'session_id' => 'A01-123456']);
    Session::factory()->create(['clinician_id' => $otherClinician->id, 'session_id' => 'B02-654321']);

    Livewire::actingAs($user)
        ->test(ClinicianSessions::class)
        ->assertSee('A01-123456')
        ->assertDontSee('B02-654321');
});

it('allows clinician to edit client_id', function () {
    $user = User::factory()->create();
    $clinician = Clinician::factory()->create(['user_id' => $user->id]);
    $session = Session::factory()->create(['clinician_id' => $clinician->id, 'client_id' => 'A01-100']);

    Livewire::actingAs($user)
        ->test(ClinicianSessions::class)
        ->call('updateClientId', $session->id, 'A01-200');

    expect($session->fresh()->client_id)->toBe('A01-200');
});

it('filters by waiting for payment', function () {
    $user = User::factory()->create();
    $clinician = Clinician::factory()->create(['user_id' => $user->id]);

    Session::factory()->create(['clinician_id' => $clinician->id, 'client_unpaid_amount' => 25.00, 'session_id' => 'UNPAID-1']);
    Session::factory()->create(['clinician_id' => $clinician->id, 'client_unpaid_amount' => 0, 'insurance_unpaid_amount' => 0, 'session_id' => 'PAID-1']);

    Livewire::actingAs($user)
        ->test(ClinicianSessions::class)
        ->call('applyFilter', 'waiting')
        ->assertSee('UNPAID-1')
        ->assertDontSee('PAID-1');
});
```

- [ ] **Step 2: Create component**

Properties: `$filter` (all/waiting/pending/disbursed), `$perPage = 50`. Filter toggles call `applyFilter()`. Sessions scoped to clinician. Table columns: session_id, client_id, date_of_service, progress_note_status, disbursement_status, disbursement_id. ClientID editable via `updateClientId()`. No financial columns shown.

- [ ] **Step 3: Create view + Pint + commit**

```bash
git commit -m "feat: add clinician sessions page with filter toggles and ClientID edit"
```

---

## Task 11: Clinician Roster (8F)

**Files:**
- Create: `app/Livewire/Clinician/ClinicianRoster.php`
- Create: `resources/views/livewire/clinician/clinician-roster.blade.php`
- Create: `tests/Feature/Clinician/ClinicianRosterTest.php`

- [ ] **Step 1: Write test**

```php
<?php

use App\Livewire\Clinician\ClinicianRoster;
use App\Models\Clinician;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;

uses(RefreshDatabase::class);

it('renders roster page', function () {
    $user = User::factory()->create();
    Clinician::factory()->create(['user_id' => $user->id]);

    Livewire::actingAs($user)
        ->test(ClinicianRoster::class)
        ->assertStatus(200);
});

it('shows active clinicians', function () {
    $user = User::factory()->create(['first_name' => 'Jane', 'last_name' => 'Doe']);
    Clinician::factory()->create(['user_id' => $user->id]);

    $viewer = User::factory()->create();
    Clinician::factory()->create(['user_id' => $viewer->id]);

    Livewire::actingAs($viewer)
        ->test(ClinicianRoster::class)
        ->assertSee('Jane Doe');
});

it('hides clinicians with hide_from_roster', function () {
    $hidden = User::factory()->create(['first_name' => 'Hidden']);
    Clinician::factory()->hiddenFromRoster()->create(['user_id' => $hidden->id]);

    $viewer = User::factory()->create();
    Clinician::factory()->create(['user_id' => $viewer->id]);

    Livewire::actingAs($viewer)
        ->test(ClinicianRoster::class)
        ->assertDontSee('Hidden');
});

it('hides disabled users', function () {
    $disabled = User::factory()->create(['first_name' => 'Disabled', 'status' => \App\Enums\UserStatus::Disabled]);
    Clinician::factory()->create(['user_id' => $disabled->id]);

    $viewer = User::factory()->create();
    Clinician::factory()->create(['user_id' => $viewer->id]);

    Livewire::actingAs($viewer)
        ->test(ClinicianRoster::class)
        ->assertDontSee('Disabled');
});
```

- [ ] **Step 2: Create component**

Queries clinicians: `visibleOnRoster` scope + user is active. Sortable columns. "Copy email addresses" button uses Alpine.js to copy filtered emails to clipboard.

- [ ] **Step 3: Create view + Pint + commit**

```bash
git commit -m "feat: add clinician roster with sort and email copy"
```

---

## Task 12: Final Integration — Run All Tests

- [ ] **Step 1: Run full test suite**

```bash
php artisan test --compact
```

- [ ] **Step 2: Build frontend assets**

```bash
npm run build
```

- [ ] **Step 3: Fix any failures**

- [ ] **Step 4: Run Pint**

```bash
vendor/bin/pint --format agent
```

- [ ] **Step 5: Final commit if needed**

```bash
git commit -m "fix: resolve integration issues from Phase 5 clinician dashboard"
```
