# Story 3.7: Consultation Management ## Epic Reference **Epic 3:** Booking & Consultation System ## User Story As an **admin**, I want **to manage consultations throughout their lifecycle**, So that **I can track completed sessions, handle no-shows, and maintain accurate records**. ## Story Context ### Existing System Integration - **Integrates with:** consultations table, notifications - **Technology:** Livewire Volt, Flux UI - **Follows pattern:** Admin management dashboard - **Touch points:** Consultation status, payment tracking, admin notes ### Service Dependencies This story relies on services created in previous stories: - **AvailabilityService** (from Story 3.3): Used for `getAvailableSlots(Carbon $date): array` to validate reschedule slot availability - **CalendarService** (from Story 3.6): Used for `generateIcs(Consultation $consultation): string` to generate new .ics file on reschedule These services must be implemented before the reschedule functionality can work. ## Acceptance Criteria ### Consultations List View - [ ] View all consultations with filters: - Status (pending/approved/completed/cancelled/no_show) - Type (free/paid) - Payment status (pending/received/not_applicable) - Date range - Client name/email search - [ ] Sort by date, status, client name - [ ] Pagination (15/25/50 per page) - [ ] Quick status indicators ### Status Management - [ ] Mark consultation as completed - [ ] Mark consultation as no-show - [ ] Cancel booking on behalf of client - [ ] Status change confirmation ### Rescheduling - [ ] Reschedule appointment to new date/time - [ ] Validate new slot availability - [ ] Send notification to client - [ ] Generate new .ics file ### Payment Tracking - [ ] Mark payment as received (for paid consultations) - [ ] Payment date recorded - [ ] Payment status visible in list ### Admin Notes - [ ] Add internal admin notes - [ ] Notes not visible to client - [ ] View notes in consultation detail - [ ] Edit/delete notes ### Client History - [ ] View all consultations for a specific client - [ ] Linked from user profile - [ ] Summary statistics per client ### Quality Requirements - [ ] Audit log for all status changes - [ ] Bilingual labels - [ ] Tests for status transitions ## Technical Notes ### Status Enum ```php enum ConsultationStatus: string { case Pending = 'pending'; case Approved = 'approved'; case Completed = 'completed'; case Cancelled = 'cancelled'; case NoShow = 'no_show'; } enum PaymentStatus: string { case Pending = 'pending'; case Received = 'received'; case NotApplicable = 'not_applicable'; } ``` ### Consultation Model Methods ```php class Consultation extends Model { public function markAsCompleted(): void { $this->update(['status' => ConsultationStatus::Completed]); } public function markAsNoShow(): void { $this->update(['status' => ConsultationStatus::NoShow]); } public function cancel(): void { $this->update(['status' => ConsultationStatus::Cancelled]); } public function markPaymentReceived(): void { $this->update([ 'payment_status' => PaymentStatus::Received, 'payment_received_at' => now(), ]); } public function reschedule(string $newDate, string $newTime): void { $this->update([ 'scheduled_date' => $newDate, 'scheduled_time' => $newTime, ]); } // Scopes public function scopeUpcoming($query) { return $query->where('scheduled_date', '>=', today()) ->where('status', ConsultationStatus::Approved); } public function scopePast($query) { return $query->where('scheduled_date', '<', today()) ->orWhereIn('status', [ ConsultationStatus::Completed, ConsultationStatus::Cancelled, ConsultationStatus::NoShow, ]); } } ``` ### Volt Component for Management ```php resetPage(); } public function markCompleted(int $id): void { $consultation = Consultation::findOrFail($id); $oldStatus = $consultation->status; $consultation->markAsCompleted(); $this->logStatusChange($consultation, $oldStatus, 'completed'); session()->flash('success', __('messages.marked_completed')); } public function markNoShow(int $id): void { $consultation = Consultation::findOrFail($id); $oldStatus = $consultation->status; $consultation->markAsNoShow(); $this->logStatusChange($consultation, $oldStatus, 'no_show'); session()->flash('success', __('messages.marked_no_show')); } public function cancel(int $id): void { $consultation = Consultation::findOrFail($id); $oldStatus = $consultation->status; $consultation->cancel(); // Notify client $consultation->user->notify(new ConsultationCancelled($consultation)); $this->logStatusChange($consultation, $oldStatus, 'cancelled'); session()->flash('success', __('messages.consultation_cancelled')); } public function markPaymentReceived(int $id): void { $consultation = Consultation::findOrFail($id); $consultation->markPaymentReceived(); AdminLog::create([ 'admin_id' => auth()->id(), 'action_type' => 'payment_received', 'target_type' => 'consultation', 'target_id' => $consultation->id, 'ip_address' => request()->ip(), ]); session()->flash('success', __('messages.payment_marked_received')); } private function logStatusChange(Consultation $consultation, string $oldStatus, string $newStatus): void { AdminLog::create([ 'admin_id' => auth()->id(), 'action_type' => 'status_change', 'target_type' => 'consultation', 'target_id' => $consultation->id, 'old_values' => ['status' => $oldStatus], 'new_values' => ['status' => $newStatus], 'ip_address' => request()->ip(), ]); } public function with(): array { return [ 'consultations' => Consultation::query() ->with('user') ->when($this->search, fn($q) => $q->whereHas('user', fn($uq) => $uq->where('name', 'like', "%{$this->search}%") ->orWhere('email', 'like', "%{$this->search}%") )) ->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter)) ->when($this->typeFilter, fn($q) => $q->where('type', $this->typeFilter)) ->when($this->paymentFilter, fn($q) => $q->where('payment_status', $this->paymentFilter)) ->when($this->dateFrom, fn($q) => $q->where('scheduled_date', '>=', $this->dateFrom)) ->when($this->dateTo, fn($q) => $q->where('scheduled_date', '<=', $this->dateTo)) ->orderBy($this->sortBy, $this->sortDir) ->paginate(15), ]; } }; ``` ### Reschedule Component ```php consultation = $consultation; $this->newDate = $consultation->scheduled_date->format('Y-m-d'); } public function updatedNewDate(): void { if ($this->newDate) { $service = app(AvailabilityService::class); $this->availableSlots = $service->getAvailableSlots( Carbon::parse($this->newDate) ); $this->newTime = ''; } } public function reschedule(): void { $this->validate([ 'newDate' => ['required', 'date', 'after_or_equal:today'], 'newTime' => ['required'], ]); // Verify slot available $service = app(AvailabilityService::class); $slots = $service->getAvailableSlots(Carbon::parse($this->newDate)); if (!in_array($this->newTime, $slots)) { $this->addError('newTime', __('booking.slot_not_available')); return; } $oldDate = $this->consultation->scheduled_date; $oldTime = $this->consultation->scheduled_time; $this->consultation->reschedule($this->newDate, $this->newTime); // Generate new .ics $calendarService = app(CalendarService::class); $icsContent = $calendarService->generateIcs($this->consultation->fresh()); // Notify client $this->consultation->user->notify( new ConsultationRescheduled($this->consultation, $oldDate, $oldTime, $icsContent) ); // Log AdminLog::create([ 'admin_id' => auth()->id(), 'action_type' => 'reschedule', 'target_type' => 'consultation', 'target_id' => $this->consultation->id, 'old_values' => ['date' => $oldDate, 'time' => $oldTime], 'new_values' => ['date' => $this->newDate, 'time' => $this->newTime], 'ip_address' => request()->ip(), ]); session()->flash('success', __('messages.consultation_rescheduled')); $this->redirect(route('admin.consultations.index')); } }; ``` ### Admin Notes ```php // Design Decision: JSON array on consultation vs separate table // Chosen: JSON array because: // - Notes are always fetched with consultation (no N+1) // - Simple CRUD without extra joins // - Audit trail embedded in consultation record // - No need for complex querying of notes independently // // Trade-off: Cannot easily query "all notes by admin X" - acceptable for this use case // In Consultation model: protected $casts = [ 'admin_notes' => 'array', // [{text, admin_id, created_at, updated_at?}] ]; public function addNote(string $note): void { $notes = $this->admin_notes ?? []; $notes[] = [ 'text' => $note, 'admin_id' => auth()->id(), 'created_at' => now()->toISOString(), ]; $this->update(['admin_notes' => $notes]); } public function updateNote(int $index, string $newText): void { $notes = $this->admin_notes ?? []; if (isset($notes[$index])) { $notes[$index]['text'] = $newText; $notes[$index]['updated_at'] = now()->toISOString(); $this->update(['admin_notes' => $notes]); } } public function deleteNote(int $index): void { $notes = $this->admin_notes ?? []; if (isset($notes[$index])) { array_splice($notes, $index, 1); $this->update(['admin_notes' => array_values($notes)]); } } ``` ### Edge Cases Handle these scenarios with appropriate validation and error handling: **Status Transition Guards:** ```php // In Consultation model - add these guards to status change methods public function markAsCompleted(): void { // Only approved consultations can be marked completed if ($this->status !== ConsultationStatus::Approved) { throw new \InvalidArgumentException( __('messages.invalid_status_transition', ['from' => $this->status->value, 'to' => 'completed']) ); } $this->update(['status' => ConsultationStatus::Completed]); } public function markAsNoShow(): void { // Only approved consultations can be marked as no-show if ($this->status !== ConsultationStatus::Approved) { throw new \InvalidArgumentException( __('messages.invalid_status_transition', ['from' => $this->status->value, 'to' => 'no_show']) ); } $this->update(['status' => ConsultationStatus::NoShow]); } public function cancel(): void { // Can cancel pending or approved, but not completed/no_show/already cancelled if (!in_array($this->status, [ConsultationStatus::Pending, ConsultationStatus::Approved])) { throw new \InvalidArgumentException( __('messages.cannot_cancel_consultation') ); } $this->update(['status' => ConsultationStatus::Cancelled]); } public function markPaymentReceived(): void { // Only paid consultations can have payment marked if ($this->type !== 'paid') { throw new \InvalidArgumentException(__('messages.not_paid_consultation')); } // Prevent double-marking if ($this->payment_status === PaymentStatus::Received) { throw new \InvalidArgumentException(__('messages.payment_already_received')); } $this->update([ 'payment_status' => PaymentStatus::Received, 'payment_received_at' => now(), ]); } ``` **Reschedule Edge Cases:** - **Slot already booked:** Handled by `AvailabilityService::getAvailableSlots()` - returns only truly available slots - **Reschedule to past date:** Validation rule `after_or_equal:today` prevents this - **Same date/time selected:** Allow but skip notification if no change detected: ```php // In reschedule() method if ($oldDate->format('Y-m-d') === $this->newDate && $oldTime === $this->newTime) { session()->flash('info', __('messages.no_changes_made')); return; } ``` **Concurrent Modification:** ```php // Use database transaction with locking in Volt component public function markCompleted(int $id): void { DB::transaction(function () use ($id) { $consultation = Consultation::lockForUpdate()->findOrFail($id); // ... rest of logic }); } ``` **Notification Failures:** ```php // In reschedule() - don't block on notification failure try { $this->consultation->user->notify( new ConsultationRescheduled($this->consultation, $oldDate, $oldTime, $icsContent) ); } catch (\Exception $e) { Log::error('Failed to send reschedule notification', [ 'consultation_id' => $this->consultation->id, 'error' => $e->getMessage(), ]); // Continue - consultation is rescheduled, notification failure is non-blocking } ``` **Missing User (Deleted Account):** ```php // Guard against orphaned consultations if (!$this->consultation->user) { session()->flash('error', __('messages.client_account_not_found')); return; } ``` ### Testing Examples ```php use App\Models\Consultation; use App\Models\User; use App\Notifications\ConsultationCancelled; use App\Notifications\ConsultationRescheduled; use App\Services\AvailabilityService; use App\Services\CalendarService; use Illuminate\Support\Facades\Notification; use Livewire\Volt\Volt; // ========================================== // CONSULTATIONS LIST VIEW TESTS // ========================================== it('displays all consultations for admin', function () { $admin = User::factory()->admin()->create(); $consultations = Consultation::factory()->count(3)->approved()->create(); Volt::test('admin.consultations.index') ->actingAs($admin) ->assertSee($consultations->first()->user->name) ->assertStatus(200); }); it('filters consultations by status', function () { $admin = User::factory()->admin()->create(); $approved = Consultation::factory()->approved()->create(); $completed = Consultation::factory()->completed()->create(); Volt::test('admin.consultations.index') ->actingAs($admin) ->set('statusFilter', 'approved') ->assertSee($approved->user->name) ->assertDontSee($completed->user->name); }); it('filters consultations by payment status', function () { $admin = User::factory()->admin()->create(); $pendingPayment = Consultation::factory()->approved()->create([ 'type' => 'paid', 'payment_status' => 'pending', ]); $received = Consultation::factory()->approved()->create([ 'type' => 'paid', 'payment_status' => 'received', ]); Volt::test('admin.consultations.index') ->actingAs($admin) ->set('paymentFilter', 'pending') ->assertSee($pendingPayment->user->name) ->assertDontSee($received->user->name); }); it('searches consultations by client name or email', function () { $admin = User::factory()->admin()->create(); $targetUser = User::factory()->create(['name' => 'John Doe', 'email' => 'john@test.com']); $otherUser = User::factory()->create(['name' => 'Jane Smith']); $targetConsultation = Consultation::factory()->for($targetUser)->approved()->create(); $otherConsultation = Consultation::factory()->for($otherUser)->approved()->create(); Volt::test('admin.consultations.index') ->actingAs($admin) ->set('search', 'John') ->assertSee('John Doe') ->assertDontSee('Jane Smith'); }); it('filters consultations by date range', function () { $admin = User::factory()->admin()->create(); $oldConsultation = Consultation::factory()->approved()->create([ 'scheduled_date' => now()->subDays(10), ]); $newConsultation = Consultation::factory()->approved()->create([ 'scheduled_date' => now()->addDays(5), ]); Volt::test('admin.consultations.index') ->actingAs($admin) ->set('dateFrom', now()->toDateString()) ->assertSee($newConsultation->user->name) ->assertDontSee($oldConsultation->user->name); }); // ========================================== // STATUS MANAGEMENT TESTS // ========================================== it('can mark consultation as completed', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create(); Volt::test('admin.consultations.index') ->actingAs($admin) ->call('markCompleted', $consultation->id) ->assertHasNoErrors(); expect($consultation->fresh()->status->value)->toBe('completed'); }); it('cannot mark pending consultation as completed', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.consultations.index') ->actingAs($admin) ->call('markCompleted', $consultation->id) ->assertHasErrors(); expect($consultation->fresh()->status->value)->toBe('pending'); }); it('can mark consultation as no-show', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create(); Volt::test('admin.consultations.index') ->actingAs($admin) ->call('markNoShow', $consultation->id) ->assertHasNoErrors(); expect($consultation->fresh()->status->value)->toBe('no_show'); }); it('cannot mark already completed consultation as no-show', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->completed()->create(); Volt::test('admin.consultations.index') ->actingAs($admin) ->call('markNoShow', $consultation->id) ->assertHasErrors(); }); it('can cancel approved consultation', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create(); Volt::test('admin.consultations.index') ->actingAs($admin) ->call('cancel', $consultation->id) ->assertHasNoErrors(); expect($consultation->fresh()->status->value)->toBe('cancelled'); Notification::assertSentTo($consultation->user, ConsultationCancelled::class); }); it('can cancel pending consultation', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.consultations.index') ->actingAs($admin) ->call('cancel', $consultation->id) ->assertHasNoErrors(); expect($consultation->fresh()->status->value)->toBe('cancelled'); }); it('cannot cancel already cancelled consultation', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->cancelled()->create(); Volt::test('admin.consultations.index') ->actingAs($admin) ->call('cancel', $consultation->id) ->assertHasErrors(); }); // ========================================== // PAYMENT TRACKING TESTS // ========================================== it('can mark payment as received for paid consultation', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create([ 'type' => 'paid', 'payment_amount' => 150.00, 'payment_status' => 'pending', ]); Volt::test('admin.consultations.index') ->actingAs($admin) ->call('markPaymentReceived', $consultation->id) ->assertHasNoErrors(); expect($consultation->fresh()) ->payment_status->value->toBe('received') ->payment_received_at->not->toBeNull(); }); it('cannot mark payment received for free consultation', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create([ 'type' => 'free', 'payment_status' => 'not_applicable', ]); Volt::test('admin.consultations.index') ->actingAs($admin) ->call('markPaymentReceived', $consultation->id) ->assertHasErrors(); }); it('cannot mark payment received twice', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create([ 'type' => 'paid', 'payment_status' => 'received', 'payment_received_at' => now()->subDay(), ]); Volt::test('admin.consultations.index') ->actingAs($admin) ->call('markPaymentReceived', $consultation->id) ->assertHasErrors(); }); // ========================================== // RESCHEDULE TESTS // ========================================== it('can reschedule consultation to available slot', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create([ 'scheduled_date' => now()->addDays(3), 'scheduled_time' => '10:00:00', ]); $newDate = now()->addDays(5)->format('Y-m-d'); $newTime = '14:00:00'; // Mock availability service $this->mock(AvailabilityService::class) ->shouldReceive('getAvailableSlots') ->andReturn(['14:00:00', '15:00:00', '16:00:00']); Volt::test('admin.consultations.reschedule', ['consultation' => $consultation]) ->actingAs($admin) ->set('newDate', $newDate) ->set('newTime', $newTime) ->call('reschedule') ->assertHasNoErrors() ->assertRedirect(route('admin.consultations.index')); expect($consultation->fresh()) ->scheduled_date->format('Y-m-d')->toBe($newDate) ->scheduled_time->toBe($newTime); Notification::assertSentTo($consultation->user, ConsultationRescheduled::class); }); it('cannot reschedule to unavailable slot', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create(); $newDate = now()->addDays(5)->format('Y-m-d'); $newTime = '14:00:00'; // Mock availability service - slot not available $this->mock(AvailabilityService::class) ->shouldReceive('getAvailableSlots') ->andReturn(['10:00:00', '11:00:00']); // 14:00 not in list Volt::test('admin.consultations.reschedule', ['consultation' => $consultation]) ->actingAs($admin) ->set('newDate', $newDate) ->set('newTime', $newTime) ->call('reschedule') ->assertHasErrors(['newTime']); }); it('cannot reschedule to past date', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create(); Volt::test('admin.consultations.reschedule', ['consultation' => $consultation]) ->actingAs($admin) ->set('newDate', now()->subDay()->format('Y-m-d')) ->set('newTime', '10:00:00') ->call('reschedule') ->assertHasErrors(['newDate']); }); it('generates new ics file on reschedule', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create(); $newDate = now()->addDays(5)->format('Y-m-d'); $newTime = '14:00:00'; $this->mock(AvailabilityService::class) ->shouldReceive('getAvailableSlots') ->andReturn(['14:00:00']); $calendarMock = $this->mock(CalendarService::class); $calendarMock->shouldReceive('generateIcs') ->once() ->andReturn('BEGIN:VCALENDAR...'); Volt::test('admin.consultations.reschedule', ['consultation' => $consultation]) ->actingAs($admin) ->set('newDate', $newDate) ->set('newTime', $newTime) ->call('reschedule') ->assertHasNoErrors(); }); // ========================================== // ADMIN NOTES TESTS // ========================================== it('can add admin note to consultation', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create(); Volt::test('admin.consultations.detail', ['consultation' => $consultation]) ->actingAs($admin) ->set('newNote', 'Client requested Arabic documents') ->call('addNote') ->assertHasNoErrors(); $notes = $consultation->fresh()->admin_notes; expect($notes)->toHaveCount(1) ->and($notes[0]['text'])->toBe('Client requested Arabic documents') ->and($notes[0]['admin_id'])->toBe($admin->id); }); it('can update admin note', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create([ 'admin_notes' => [ ['text' => 'Original note', 'admin_id' => $admin->id, 'created_at' => now()->toISOString()], ], ]); Volt::test('admin.consultations.detail', ['consultation' => $consultation]) ->actingAs($admin) ->call('updateNote', 0, 'Updated note') ->assertHasNoErrors(); $notes = $consultation->fresh()->admin_notes; expect($notes[0]['text'])->toBe('Updated note') ->and($notes[0])->toHaveKey('updated_at'); }); it('can delete admin note', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create([ 'admin_notes' => [ ['text' => 'Note 1', 'admin_id' => $admin->id, 'created_at' => now()->toISOString()], ['text' => 'Note 2', 'admin_id' => $admin->id, 'created_at' => now()->toISOString()], ], ]); Volt::test('admin.consultations.detail', ['consultation' => $consultation]) ->actingAs($admin) ->call('deleteNote', 0) ->assertHasNoErrors(); $notes = $consultation->fresh()->admin_notes; expect($notes)->toHaveCount(1) ->and($notes[0]['text'])->toBe('Note 2'); }); // ========================================== // AUDIT LOG TESTS // ========================================== it('creates audit log entry on status change', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create(); Volt::test('admin.consultations.index') ->actingAs($admin) ->call('markCompleted', $consultation->id); $this->assertDatabaseHas('admin_logs', [ 'admin_id' => $admin->id, 'action_type' => 'status_change', 'target_type' => 'consultation', 'target_id' => $consultation->id, ]); }); it('creates audit log entry on payment received', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create([ 'type' => 'paid', 'payment_status' => 'pending', ]); Volt::test('admin.consultations.index') ->actingAs($admin) ->call('markPaymentReceived', $consultation->id); $this->assertDatabaseHas('admin_logs', [ 'admin_id' => $admin->id, 'action_type' => 'payment_received', 'target_type' => 'consultation', 'target_id' => $consultation->id, ]); }); it('creates audit log entry on reschedule', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create(); $this->mock(AvailabilityService::class) ->shouldReceive('getAvailableSlots') ->andReturn(['14:00:00']); Volt::test('admin.consultations.reschedule', ['consultation' => $consultation]) ->actingAs($admin) ->set('newDate', now()->addDays(5)->format('Y-m-d')) ->set('newTime', '14:00:00') ->call('reschedule'); $this->assertDatabaseHas('admin_logs', [ 'admin_id' => $admin->id, 'action_type' => 'reschedule', 'target_type' => 'consultation', 'target_id' => $consultation->id, ]); }); // ========================================== // CLIENT HISTORY TESTS // ========================================== it('displays consultation history for specific client', function () { $admin = User::factory()->admin()->create(); $client = User::factory()->create(); $consultations = Consultation::factory()->count(3)->for($client)->create(); Volt::test('admin.clients.consultation-history', ['user' => $client]) ->actingAs($admin) ->assertSee($consultations->first()->scheduled_date->format('Y-m-d')) ->assertStatus(200); }); it('shows summary statistics for client', function () { $admin = User::factory()->admin()->create(); $client = User::factory()->create(); Consultation::factory()->for($client)->completed()->count(2)->create(); Consultation::factory()->for($client)->cancelled()->create(); Consultation::factory()->for($client)->create(['status' => 'no_show']); Volt::test('admin.clients.consultation-history', ['user' => $client]) ->actingAs($admin) ->assertSee('2') // completed count ->assertSee('1'); // no-show count }); // ========================================== // BILINGUAL TESTS // ========================================== it('displays labels in Arabic when locale is ar', function () { $admin = User::factory()->admin()->create(['preferred_language' => 'ar']); $consultation = Consultation::factory()->approved()->create(); app()->setLocale('ar'); Volt::test('admin.consultations.index') ->actingAs($admin) ->assertSee(__('admin.consultations')); }); it('sends cancellation notification in client preferred language', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $arabicUser = User::factory()->create(['preferred_language' => 'ar']); $consultation = Consultation::factory()->approved()->for($arabicUser)->create(); Volt::test('admin.consultations.index') ->actingAs($admin) ->call('cancel', $consultation->id); Notification::assertSentTo($arabicUser, ConsultationCancelled::class, function ($notification) { return $notification->consultation->user->preferred_language === 'ar'; }); }); ``` ## Definition of Done - [ ] List view with all filters working - [ ] Can mark consultation as completed - [ ] Can mark consultation as no-show - [ ] Can cancel consultation - [ ] Can reschedule consultation - [ ] Can mark payment as received - [ ] Can add admin notes - [ ] Client notified on reschedule/cancel - [ ] New .ics sent on reschedule - [ ] Audit logging complete - [ ] Bilingual support - [ ] Tests for all status changes - [ ] Code formatted with Pint ## Dependencies - **Story 3.5:** `docs/stories/story-3.5-admin-booking-review-approval.md` - Creates approved consultations to manage; provides AdminLog pattern - **Story 3.6:** `docs/stories/story-3.6-calendar-file-generation.md` - CalendarService for .ics generation on reschedule - **Story 3.3:** `docs/stories/story-3.3-availability-calendar-display.md` - AvailabilityService for slot validation on reschedule - **Epic 8:** `docs/epics/epic-8-email-notifications.md` - ConsultationCancelled and ConsultationRescheduled notifications ## Risk Assessment - **Primary Risk:** Status change on wrong consultation - **Mitigation:** Confirmation dialogs, clear identification - **Rollback:** Manual status correction, audit log ## Estimation **Complexity:** Medium-High **Estimated Effort:** 5-6 hours