33 KiB
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): arrayto validate reschedule slot availability - CalendarService (from Story 3.6): Used for
generateIcs(Consultation $consultation): stringto 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:todayprevents 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