# Story 3.5: Admin Booking Review & Approval ## Epic Reference **Epic 3:** Booking & Consultation System ## User Story As an **admin**, I want **to review, categorize, and approve or reject booking requests**, So that **I can manage my consultation schedule and set appropriate consultation types**. ## Story Context ### Existing System Integration - **Integrates with:** consultations table, notifications, .ics generation - **Technology:** Livewire Volt, Flux UI - **Follows pattern:** Admin action workflow - **Touch points:** Client notifications, calendar file ## Acceptance Criteria ### Pending Bookings List - [ ] View all pending booking requests - [ ] Display: client name, requested date/time, submission date - [ ] Show problem summary preview - [ ] Click to view full details - [ ] Sort by date (oldest first default) - [ ] Filter by date range ### Booking Details View - [ ] Full client information - [ ] Complete problem summary - [ ] Client consultation history - [ ] Requested date and time ### Approval Workflow - [ ] Set consultation type: - Free consultation - Paid consultation - [ ] If paid: set payment amount - [ ] If paid: add payment instructions (optional) - [ ] Approve button with confirmation - [ ] On approval: - Status changes to 'approved' - Client notified via email - .ics calendar file attached to email - Payment instructions included if paid ### Rejection Workflow - [ ] Optional rejection reason field - [ ] Reject button with confirmation - [ ] On rejection: - Status changes to 'rejected' - Client notified via email with reason ### Quick Actions - [ ] Quick approve (free) button on list - [ ] Quick reject button on list - [ ] Bulk actions (optional) ### Quality Requirements - [ ] Audit log for all decisions - [ ] Bilingual notifications - [ ] Tests for approval/rejection flow ## Technical Notes ### Consultation Status Flow ``` pending -> approved (admin approves) pending -> rejected (admin rejects) approved -> completed (after consultation) approved -> no_show (client didn't attend) approved -> cancelled (admin cancels) ``` ### Volt Component for Review ```php consultation = $consultation; } public function approve(): void { $this->validate([ 'consultationType' => ['required', 'in:free,paid'], 'paymentAmount' => ['required_if:consultationType,paid', 'nullable', 'numeric', 'min:0'], 'paymentInstructions' => ['nullable', 'string', 'max:1000'], ]); $this->consultation->update([ 'status' => 'approved', 'type' => $this->consultationType, 'payment_amount' => $this->consultationType === 'paid' ? $this->paymentAmount : null, 'payment_status' => $this->consultationType === 'paid' ? 'pending' : 'not_applicable', ]); // Generate calendar file $calendarService = app(CalendarService::class); $icsContent = $calendarService->generateIcs($this->consultation); // Send notification with .ics attachment $this->consultation->user->notify( new BookingApproved( $this->consultation, $icsContent, $this->paymentInstructions ) ); // Log action AdminLog::create([ 'admin_id' => auth()->id(), 'action_type' => 'approve', 'target_type' => 'consultation', 'target_id' => $this->consultation->id, 'old_values' => ['status' => 'pending'], 'new_values' => [ 'status' => 'approved', 'type' => $this->consultationType, 'payment_amount' => $this->paymentAmount, ], 'ip_address' => request()->ip(), ]); session()->flash('success', __('messages.booking_approved')); $this->redirect(route('admin.bookings.pending')); } public function reject(): void { $this->validate([ 'rejectionReason' => ['nullable', 'string', 'max:1000'], ]); $this->consultation->update([ 'status' => 'rejected', ]); // Send rejection notification $this->consultation->user->notify( new BookingRejected($this->consultation, $this->rejectionReason) ); // Log action AdminLog::create([ 'admin_id' => auth()->id(), 'action_type' => 'reject', 'target_type' => 'consultation', 'target_id' => $this->consultation->id, 'old_values' => ['status' => 'pending'], 'new_values' => [ 'status' => 'rejected', 'reason' => $this->rejectionReason, ], 'ip_address' => request()->ip(), ]); session()->flash('success', __('messages.booking_rejected')); $this->redirect(route('admin.bookings.pending')); } }; ``` ### Blade Template for Approval Modal ```blade {{ __('admin.approve_booking') }}

{{ __('admin.client') }}: {{ $consultation->user->name }}

{{ __('admin.date') }}: {{ $consultation->scheduled_date->translatedFormat('l, d M Y') }}

{{ __('admin.time') }}: {{ Carbon::parse($consultation->scheduled_time)->format('g:i A') }}

{{ __('admin.consultation_type') }} @if($consultationType === 'paid') {{ __('admin.payment_amount') }} * {{ __('admin.payment_instructions') }} @endif
{{ __('common.cancel') }} {{ __('admin.approve') }}
``` ### Pending Bookings List Component ```php Consultation::where('status', 'pending') ->when($this->dateFrom, fn($q) => $q->where('scheduled_date', '>=', $this->dateFrom)) ->when($this->dateTo, fn($q) => $q->where('scheduled_date', '<=', $this->dateTo)) ->with('user') ->orderBy('scheduled_date') ->orderBy('scheduled_time') ->paginate(15), ]; } public function quickApprove(int $id): void { $consultation = Consultation::findOrFail($id); $consultation->update([ 'status' => 'approved', 'type' => 'free', 'payment_status' => 'not_applicable', ]); // Generate and send notification with .ics // ... session()->flash('success', __('messages.booking_approved')); } public function quickReject(int $id): void { $consultation = Consultation::findOrFail($id); $consultation->update(['status' => 'rejected']); // Send rejection notification // ... session()->flash('success', __('messages.booking_rejected')); } }; ``` ### Notification Classes Create these notification classes in `app/Notifications/`: ```php // app/Notifications/BookingApproved.php namespace App\Notifications; use App\Models\Consultation; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; class BookingApproved extends Notification implements ShouldQueue { use Queueable; public function __construct( public Consultation $consultation, public string $icsContent, public ?string $paymentInstructions = null ) {} public function via(object $notifiable): array { return ['mail']; } public function toMail(object $notifiable): MailMessage { $locale = $notifiable->preferred_language ?? 'ar'; return (new MailMessage) ->subject($this->getSubject($locale)) ->markdown('emails.booking.approved', [ 'consultation' => $this->consultation, 'paymentInstructions' => $this->paymentInstructions, 'locale' => $locale, ]) ->attachData( $this->icsContent, 'consultation.ics', ['mime' => 'text/calendar'] ); } private function getSubject(string $locale): string { return $locale === 'ar' ? 'تمت الموافقة على حجز استشارتك' : 'Your Consultation Booking Approved'; } } ``` ```php // app/Notifications/BookingRejected.php namespace App\Notifications; use App\Models\Consultation; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; class BookingRejected extends Notification implements ShouldQueue { use Queueable; public function __construct( public Consultation $consultation, public ?string $rejectionReason = null ) {} public function via(object $notifiable): array { return ['mail']; } public function toMail(object $notifiable): MailMessage { $locale = $notifiable->preferred_language ?? 'ar'; return (new MailMessage) ->subject($this->getSubject($locale)) ->markdown('emails.booking.rejected', [ 'consultation' => $this->consultation, 'rejectionReason' => $this->rejectionReason, 'locale' => $locale, ]); } private function getSubject(string $locale): string { return $locale === 'ar' ? 'بخصوص طلب الاستشارة الخاص بك' : 'Regarding Your Consultation Request'; } } ``` ### Error Handling ```php // In the approve() method, wrap calendar generation with error handling public function approve(): void { // ... validation ... try { $calendarService = app(CalendarService::class); $icsContent = $calendarService->generateIcs($this->consultation); } catch (\Exception $e) { // Log error but don't block approval Log::error('Failed to generate calendar file', [ 'consultation_id' => $this->consultation->id, 'error' => $e->getMessage(), ]); $icsContent = null; } // Update consultation status regardless $this->consultation->update([ 'status' => 'approved', 'type' => $this->consultationType, 'payment_amount' => $this->consultationType === 'paid' ? $this->paymentAmount : null, 'payment_status' => $this->consultationType === 'paid' ? 'pending' : 'not_applicable', ]); // Send notification (with or without .ics) $this->consultation->user->notify( new BookingApproved( $this->consultation, $icsContent ?? '', $this->paymentInstructions ) ); // ... audit log and redirect ... } ``` ### Edge Cases - **Already approved booking:** The approve/reject buttons should only appear for `status = 'pending'`. Add guard clause: ```php if ($this->consultation->status !== 'pending') { session()->flash('error', __('messages.booking_already_processed')); return; } ``` - **Concurrent approval:** Use database transaction with locking to prevent race conditions - **Missing user:** Check `$this->consultation->user` exists before sending notification ### Testing Examples ```php use App\Models\Consultation; use App\Models\User; use App\Notifications\BookingApproved; use App\Notifications\BookingRejected; use App\Services\CalendarService; use Illuminate\Support\Facades\Notification; use Livewire\Volt\Volt; // Test: Admin can view pending bookings list it('displays pending bookings list for admin', function () { $admin = User::factory()->admin()->create(); $consultations = Consultation::factory()->count(3)->pending()->create(); Volt::test('admin.bookings.pending-list') ->actingAs($admin) ->assertSee($consultations->first()->user->name) ->assertStatus(200); }); // Test: Admin can approve booking as free consultation it('can approve booking as free consultation', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.bookings.review', ['consultation' => $consultation]) ->actingAs($admin) ->set('consultationType', 'free') ->call('approve') ->assertHasNoErrors() ->assertRedirect(route('admin.bookings.pending')); expect($consultation->fresh()) ->status->toBe('approved') ->type->toBe('free') ->payment_status->toBe('not_applicable'); Notification::assertSentTo($consultation->user, BookingApproved::class); }); // Test: Admin can approve booking as paid consultation with amount it('can approve booking as paid consultation with amount', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.bookings.review', ['consultation' => $consultation]) ->actingAs($admin) ->set('consultationType', 'paid') ->set('paymentAmount', 150.00) ->set('paymentInstructions', 'Bank transfer to account XYZ') ->call('approve') ->assertHasNoErrors(); expect($consultation->fresh()) ->status->toBe('approved') ->type->toBe('paid') ->payment_amount->toBe(150.00) ->payment_status->toBe('pending'); }); // Test: Paid consultation requires payment amount it('requires payment amount for paid consultation', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.bookings.review', ['consultation' => $consultation]) ->actingAs($admin) ->set('consultationType', 'paid') ->set('paymentAmount', null) ->call('approve') ->assertHasErrors(['paymentAmount']); }); // Test: Admin can reject booking with reason it('can reject booking with optional reason', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.bookings.review', ['consultation' => $consultation]) ->actingAs($admin) ->set('rejectionReason', 'Schedule conflict') ->call('reject') ->assertHasNoErrors() ->assertRedirect(route('admin.bookings.pending')); expect($consultation->fresh())->status->toBe('rejected'); Notification::assertSentTo($consultation->user, BookingRejected::class); }); // Test: Quick approve from list it('can quick approve booking from list', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.bookings.pending-list') ->actingAs($admin) ->call('quickApprove', $consultation->id) ->assertHasNoErrors(); expect($consultation->fresh()) ->status->toBe('approved') ->type->toBe('free'); }); // Test: Quick reject from list it('can quick reject booking from list', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.bookings.pending-list') ->actingAs($admin) ->call('quickReject', $consultation->id) ->assertHasNoErrors(); expect($consultation->fresh())->status->toBe('rejected'); }); // Test: Audit log entry created on approval it('creates audit log entry on approval', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->pending()->create(); Volt::test('admin.bookings.review', ['consultation' => $consultation]) ->actingAs($admin) ->set('consultationType', 'free') ->call('approve'); $this->assertDatabaseHas('admin_logs', [ 'admin_id' => $admin->id, 'action_type' => 'approve', 'target_type' => 'consultation', 'target_id' => $consultation->id, ]); }); // Test: Cannot approve already processed booking it('cannot approve already approved booking', function () { $admin = User::factory()->admin()->create(); $consultation = Consultation::factory()->approved()->create(); Volt::test('admin.bookings.review', ['consultation' => $consultation]) ->actingAs($admin) ->call('approve') ->assertHasErrors(); }); // Test: Filter bookings by date range it('can filter pending bookings by date range', function () { $admin = User::factory()->admin()->create(); $oldBooking = Consultation::factory()->pending()->create([ 'scheduled_date' => now()->subDays(10), ]); $newBooking = Consultation::factory()->pending()->create([ 'scheduled_date' => now()->addDays(5), ]); Volt::test('admin.bookings.pending-list') ->actingAs($admin) ->set('dateFrom', now()->toDateString()) ->assertSee($newBooking->user->name) ->assertDontSee($oldBooking->user->name); }); // Test: Bilingual notification (Arabic) it('sends approval notification in client preferred language', function () { Notification::fake(); $admin = User::factory()->admin()->create(); $arabicUser = User::factory()->create(['preferred_language' => 'ar']); $consultation = Consultation::factory()->pending()->create(['user_id' => $arabicUser->id]); Volt::test('admin.bookings.review', ['consultation' => $consultation]) ->actingAs($admin) ->set('consultationType', 'free') ->call('approve'); Notification::assertSentTo($arabicUser, BookingApproved::class, function ($notification) { return $notification->consultation->user->preferred_language === 'ar'; }); }); ``` ## Definition of Done - [ ] Pending bookings list displays correctly - [ ] Can view booking details - [ ] Can approve as free consultation - [ ] Can approve as paid with amount - [ ] Can reject with optional reason - [ ] Approval sends email with .ics file - [ ] Rejection sends email with reason - [ ] Quick actions work from list - [ ] Audit log entries created - [ ] Bilingual support complete - [ ] Tests for approval/rejection - [ ] Code formatted with Pint ## Dependencies - **Story 3.4:** `docs/stories/story-3.4-booking-request-submission.md` - Creates pending bookings to review - **Story 3.6:** `docs/stories/story-3.6-calendar-file-generation.md` - CalendarService for .ics generation - **Epic 8:** `docs/epics/epic-8-email-notifications.md#story-84-booking-approved-email` - BookingApproved notification - **Epic 8:** `docs/epics/epic-8-email-notifications.md#story-85-booking-rejected-email` - BookingRejected notification ## Risk Assessment - **Primary Risk:** Approving wrong booking - **Mitigation:** Confirmation dialog, clear booking details display - **Rollback:** Admin can cancel approved booking ## Estimation **Complexity:** Medium **Estimated Effort:** 4-5 hours