libra/docs/stories/story-3.7-consultation-mana...

33 KiB

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

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

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

use App\Models\Consultation;
use Livewire\Volt\Component;
use Livewire\WithPagination;

new class extends Component {
    use WithPagination;

    public string $search = '';
    public string $statusFilter = '';
    public string $typeFilter = '';
    public string $paymentFilter = '';
    public string $dateFrom = '';
    public string $dateTo = '';
    public string $sortBy = 'scheduled_date';
    public string $sortDir = 'desc';

    public function updatedSearch()
    {
        $this->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

use App\Models\Consultation;
use App\Services\{AvailabilityService, CalendarService};
use App\Notifications\ConsultationRescheduled;
use Livewire\Volt\Component;

new class extends Component {
    public Consultation $consultation;
    public string $newDate = '';
    public string $newTime = '';
    public array $availableSlots = [];

    public function mount(Consultation $consultation): void
    {
        $this->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

// 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:

// 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:
// In reschedule() method
if ($oldDate->format('Y-m-d') === $this->newDate && $oldTime === $this->newTime) {
    session()->flash('info', __('messages.no_changes_made'));
    return;
}

Concurrent Modification:

// 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:

// 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):

// Guard against orphaned consultations
if (!$this->consultation->user) {
    session()->flash('error', __('messages.client_account_not_found'));
    return;
}

Testing Examples

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