# Phase 5 — Clinician Dashboard & Profiles Design

> **Date:** 2026-04-05
> **Status:** Approved
> **Scope:** Clinician-facing dashboard pages, public profile system, photo upload, rich text editor

---

## Context

PlutoHub has admin-side infrastructure complete (Phases 2-4). Phase 5 builds the clinician-facing dashboard: profile management, session views, revenue tracking, and a public-profile system consumed by the public website API (Phase 6).

### What Already Exists
- Onboarding wizard (3-step: name → credentials → availability) at `clinician.profile`
- `users` table has basic profile fields (credentials, availability, photo_path, insurance_payers)
- `clinicians` table has admin-managed fields (sp_name, hide_profile_override, hide_from_roster)
- Sidebar nav items for clinician role (all pointing to placeholders except Profile)
- Admin-side components for sessions, charges, disbursements (query patterns reusable)
- Stripe Connect/Customer fields on clinicians table

### What Needs Building
- `clinician_profiles` table with 20+ public-facing fields
- Cropper.js for square photo crop
- TipTap rich text editor (bold, italic, links)
- 7 Livewire components (Dashboard, BasicInfo, WebProfile, AdminServices, Revenue, Sessions, Roster)
- Routes, controllers, sidebar updates
- Profile publish logic

---

## 1. clinician_profiles Table & Model

### Migration

New table `clinician_profiles` with one-to-one relationship to `users`.

| Field | Type | Nullable | Default | Notes |
|-------|------|----------|---------|-------|
| id | bigint PK | | | |
| user_id | FK → users, unique | | | |
| display_first | string | No | | Display name (may differ from legal) |
| display_last | string | No | | |
| slug | string, unique | No | | Auto-generated from display name |
| credentials | string(15) | Yes | | e.g. "LPC, NCC" |
| professional_title | string(30) | Yes | | e.g. "Licensed Professional Counselor" |
| headshot_path | string | Yes | | Path in storage/app/public/headshots/ |
| secondary_photo_path | string | Yes | | Optional second photo |
| approach_note | string(68) | Yes | | Short summary for listing card |
| modalities | text | Yes | | Free text, no limit |
| box1_heading | string(30) | Yes | | Render section only if filled |
| box1_body | text | Yes | | Rich text (bold, italic, links) |
| box2_heading | string(30) | Yes | | |
| box2_body | text | Yes | | Rich text |
| box3_heading | string(30) | Yes | | |
| box3_body | text | Yes | | Rich text |
| left_box_heading | string(30) | Yes | | |
| left_box_body | text | Yes | | Rich text, ~50 words |
| availability_note | string(68) | Yes | | |
| rate_note | text | Yes | | |
| accepting_inperson | boolean | | false | |
| accepting_virtual | boolean | | false | |
| insurance_accepted | JSON | Yes | | Array of payer names |
| public_email | string | Yes | | Shown on public profile only |
| public_phone | string | Yes | | Shown on public profile only |
| hide_profile | boolean | | true | Clinician's toggle (hidden by default) |
| timestamps | | | | |

### Model: `ClinicianProfile`

```
- user(): BelongsTo → User
- scopePublished(): where hide_profile=false, join clinicians where hide_profile_override=false, all required fields filled
- isPublishable(): bool — checks all publish conditions
- getMissingRequiredFields(): array — returns names of incomplete required fields
- generateSlug(): sets slug from display_first + display_last (Str::slug)
```

**Required fields for publish:** display_first, display_last, credentials, professional_title, headshot_path, approach_note, at least one of accepting_inperson/accepting_virtual.

**Relationships:** Add `clinicianProfile(): HasOne` to User model.

---

## 2. Photo Upload with Cropper.js

### Dependencies
- `npm install cropperjs` (JS library)
- Create `resources/js/components/cropper.js` — Alpine.js component

### Flow
1. File input triggers file selection
2. Selected image loads in a Cropper.js modal overlay
3. Aspect ratio locked to 1:1 (square)
4. User adjusts crop area, clicks "Save Crop"
5. Cropped canvas is converted to Blob, sent to Livewire via `$wire.upload()`
6. Livewire validates: jpg/png/webp, max 5MB
7. Saved to `storage/app/public/headshots/{slug}.{ext}`
8. Path stored in `clinician_profiles.headshot_path`

### Alpine Component
```js
Alpine.data('photoCropper', () => ({
    cropper: null,
    showModal: false,
    
    init() { /* setup */ },
    openCropper(event) { /* load image into Cropper.js modal */ },
    saveCrop() { /* canvas.toBlob → upload to Livewire */ },
    closeCropper() { /* cleanup */ },
}))
```

### Server Validation
- File types: `mimes:jpg,jpeg,png,webp`
- Max size: `max:5120` (5MB)
- Processed image stored as-is (no server-side resize — Cropper.js handles the crop)

---

## 3. TipTap Rich Text Editor

### Dependencies
- `npm install @tiptap/core @tiptap/starter-kit @tiptap/extension-link`
- Create `resources/js/components/tiptap-editor.js` — Alpine.js component

### Alpine Component
```js
Alpine.data('tiptapEditor', (content, fieldName) => ({
    editor: null,
    
    init() {
        this.editor = new Editor({
            element: this.$refs.editor,
            extensions: [StarterKit.configure({ /* disable everything except bold, italic, paragraph */ }), Link],
            content: content,
            onUpdate: ({ editor }) => {
                this.$wire.set(fieldName, editor.getHTML());
            },
        });
    },
    
    destroy() { this.editor?.destroy(); },
}))
```

### Toolbar
Three buttons only: **Bold** | *Italic* | Link (prompts for URL)

### Server-Side Sanitization
Before saving, strip all HTML except: `<p>`, `<strong>`, `<em>`, `<a href="...">`. Use a simple regex or `HTMLPurifier` (already available in Laravel ecosystem).

### Usage
Used for fields: `box1_body`, `box2_body`, `box3_body`, `left_box_body`. Each field gets its own TipTap instance.

---

## 4. Clinician Dashboard Pages

### 4a. Dashboard Home

**Route:** `/clinician/dashboard`
**Component:** `ClinicianDashboard`

Stat cards for the logged-in clinician:
| Stat | Source |
|------|--------|
| Pending sessions | Sessions where client_unpaid > 0 or insurance_unpaid > 0 |
| Next payout estimate | Sum of sessions with disbursement_status = pending |
| Outstanding charges | Sum of pending charges amount |
| Profile status | Published / Draft / Missing N fields |

### 4b. Basic Info (8A)

**Route:** `/clinician/basic-info`
**Component:** `ClinicianBasicInfo`

Form with clinician hub data. Three field categories:

**Clinician-editable:** first_name, last_name, phone, credentials (from users table)

**Admin-entered (read-only for clinician):** SP mapping name, hire date, clinician code, admin notes (from clinicians table)

**Calculated (display only):** Session count, revenue totals (computed from sessions/disbursements)

### 4c. Web Profile (8B)

**Route:** `/clinician/web-profile`
**Component:** `ClinicianWebProfile`

Form with all `clinician_profiles` fields. Key interactions:

- **Photo upload:** Cropper.js modal for headshot (and optional secondary photo)
- **Rich text:** TipTap editors for box1-3 bodies and left_box_body
- **Character limits:** Enforced client-side (maxlength) and server-side (validation rules)
- **Insurance multi-select:** Checkboxes from admin-managed payer list (Setting::get('insurance_payers'))
- **hide_profile toggle:** Defaults ON (hidden). Toggle OFF to publish.
- **Publish status indicator:** Shows "Published", "Draft", or "Missing: [field list]"
- **Auto-save:** Changes save on form submit (no auto-save on blur)
- **Slug auto-generation:** Generated from display_first + display_last on first save, immutable after

### 4d. Admin Support Services (8C)

**Route:** `/clinician/admin-services`
**Component:** `ClinicianAdminServices`

Read-only for clinicians. Three sections (same data as admin ClinicianSupportServices but without CRUD):

1. **Monthly/Ongoing Services:** List of assigned services with price and activation date
2. **Pending Charges:** Table with date, description, amount, status badge
3. **Paid/Processed Charges:** Historical table with settlement ID and date

### 4e. Revenue (8D)

**Route:** `/clinician/revenue`
**Component:** `ClinicianRevenue`

Disbursement batch history for this clinician.

Table columns: Batch ID, Date, Sessions, Gross, Stripe Fee, Net, Avg per session.

"See batch details →" link navigates to Sessions page pre-filtered by that batch's disbursement ID.

### 4f. Sessions (8E)

**Route:** `/clinician/sessions`
**Component:** `ClinicianSessions`

All of the clinician's session records with filter toggles:
- **All** (default)
- **Waiting for payment** — client_unpaid > 0 OR insurance_unpaid > 0
- **Pending disbursement** — fully paid but no DisbursementID
- **Disbursed** — has DisbursementID

Table columns: session_id, client_id, date_of_service, progress_note_status, disbursement_status, disbursement_id.

**Editable by clinician:** ClientID only (via inline edit or slide-over).

Financial columns (amounts, payment sources) are NOT shown to clinicians per SPEC.

### 4g. Clinician Roster (8F)

**Route:** `/clinician/roster`
**Component:** `ClinicianRoster`

List of all active clinicians visible to Hub users.

Table columns: Name, clinician code, credentials, email, phone, accepting_inperson, accepting_virtual.

**Excludes:** Disabled users and those with `hide_from_roster = true`.

**Sortable:** By any column, including both "accepting" fields.

**"Copy email addresses" button:** Copies all visible (filtered) clinician emails to clipboard.

---

## 5. Routes & Sidebar

### Routes

Add inside protected auth group, prefixed with `/clinician`:

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

Existing `/clinician/profile` route (onboarding) remains separate.

### Sidebar Update

Replace placeholder routes in the Clinician section of `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']],
],
```

---

## 6. Profile Publish Logic

A profile is published when ALL conditions are true:
1. All required fields complete: display_first, display_last, credentials, professional_title, headshot_path, approach_note, at least one of accepting_inperson/accepting_virtual
2. `clinician_profiles.hide_profile = false` (clinician's choice)
3. `clinicians.hide_profile_override = false` (admin's override)

Changes go live immediately on save — no admin approval needed. Admin can always suppress via `hide_profile_override`.

The Web Profile form shows:
- Green "Published" badge when all conditions met
- Yellow "Draft" badge when hide_profile is true but fields are complete
- Red "Missing: [fields]" indicator listing incomplete required fields

---

## New Files Summary

| File | Type |
|------|------|
| Migration: create_clinician_profiles_table | Migration |
| `app/Models/ClinicianProfile.php` | Model |
| `database/factories/ClinicianProfileFactory.php` | Factory |
| `app/Livewire/Clinician/ClinicianDashboard.php` | Livewire component |
| `app/Livewire/Clinician/ClinicianBasicInfo.php` | Livewire component |
| `app/Livewire/Clinician/ClinicianWebProfile.php` | Livewire component |
| `app/Livewire/Clinician/ClinicianAdminServices.php` | Livewire component |
| `app/Livewire/Clinician/ClinicianRevenue.php` | Livewire component |
| `app/Livewire/Clinician/ClinicianSessions.php` | Livewire component |
| `app/Livewire/Clinician/ClinicianRoster.php` | Livewire component |
| 7 Blade views for clinician pages | Views |
| 7 Livewire Blade views | Views |
| `resources/js/components/cropper.js` | Alpine/JS |
| `resources/js/components/tiptap-editor.js` | Alpine/JS |

## Modified Files Summary

| File | Changes |
|------|---------|
| `app/Models/User.php` | Add `clinicianProfile()` HasOne relationship |
| `routes/web.php` | Add 7 clinician routes |
| `app/View/Components/Sidebar.php` | Update clinician nav items to real routes |
| `package.json` | Add cropperjs, @tiptap/* packages |
| `resources/js/app.js` | Import cropper and tiptap Alpine components |

## Testing Strategy

- Model tests: ClinicianProfile publish logic, slug generation, missing fields
- Component tests: each Livewire component renders correctly, form saves, validation
- Photo upload: test file validation (type, size), path storage
- Roster: test filtering (excludes disabled + hidden), sort, email copy
- Sessions: test filter toggles, ClientID edit (only own records)
- Revenue: test scoped to logged-in clinician only
