29 KiB
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
use App\Models\Consultation;
use App\Notifications\BookingApproved;
use App\Notifications\BookingRejected;
use App\Services\CalendarService;
use Livewire\Volt\Component;
new class extends Component {
public Consultation $consultation;
public string $consultationType = 'free';
public ?float $paymentAmount = null;
public string $paymentInstructions = '';
public string $rejectionReason = '';
public bool $showApproveModal = false;
public bool $showRejectModal = false;
public function mount(Consultation $consultation): void
{
$this->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
<flux:modal wire:model="showApproveModal">
<flux:heading>{{ __('admin.approve_booking') }}</flux:heading>
<div class="space-y-4 mt-4">
<!-- Client Info -->
<div class="bg-cream p-3 rounded-lg">
<p><strong>{{ __('admin.client') }}:</strong> {{ $consultation->user->name }}</p>
<p><strong>{{ __('admin.date') }}:</strong>
{{ $consultation->scheduled_date->translatedFormat('l, d M Y') }}</p>
<p><strong>{{ __('admin.time') }}:</strong>
{{ Carbon::parse($consultation->scheduled_time)->format('g:i A') }}</p>
</div>
<!-- Consultation Type -->
<flux:field>
<flux:label>{{ __('admin.consultation_type') }}</flux:label>
<flux:radio.group wire:model.live="consultationType">
<flux:radio value="free" label="{{ __('admin.free_consultation') }}" />
<flux:radio value="paid" label="{{ __('admin.paid_consultation') }}" />
</flux:radio.group>
</flux:field>
<!-- Payment Amount (if paid) -->
@if($consultationType === 'paid')
<flux:field>
<flux:label>{{ __('admin.payment_amount') }} *</flux:label>
<flux:input
type="number"
wire:model="paymentAmount"
step="0.01"
min="0"
/>
<flux:error name="paymentAmount" />
</flux:field>
<flux:field>
<flux:label>{{ __('admin.payment_instructions') }}</flux:label>
<flux:textarea
wire:model="paymentInstructions"
rows="3"
placeholder="{{ __('admin.payment_instructions_placeholder') }}"
/>
</flux:field>
@endif
</div>
<div class="flex gap-3 mt-6">
<flux:button wire:click="$set('showApproveModal', false)">
{{ __('common.cancel') }}
</flux:button>
<flux:button variant="primary" wire:click="approve">
{{ __('admin.approve') }}
</flux:button>
</div>
</flux:modal>
Pending Bookings List Component
<?php
use App\Models\Consultation;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new class extends Component {
use WithPagination;
public string $dateFrom = '';
public string $dateTo = '';
public function with(): array
{
return [
'bookings' => 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/:
// 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';
}
}
// 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
// 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: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->userexists before sending notification
Testing Examples
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
Dev Agent Record
Status
Ready for Review
Agent Model Used
Claude Opus 4.5
File List
New Files:
resources/views/livewire/admin/bookings/pending.blade.php- Pending bookings list componentresources/views/livewire/admin/bookings/review.blade.php- Booking review/approval componentapp/Services/CalendarService.php- .ics calendar file generation serviceapp/Notifications/BookingApproved.php- Booking approval notificationapp/Notifications/BookingRejected.php- Booking rejection notificationresources/views/emails/booking-approved.blade.php- Approval email templateresources/views/emails/booking-rejected.blade.php- Rejection email templatetests/Feature/Admin/BookingReviewApprovalTest.php- Test suite for approval/rejection
Modified Files:
routes/web.php- Added admin booking routesresources/views/components/layouts/app/sidebar.blade.php- Added navigation linklang/en/navigation.php- Added booking navigation translationslang/ar/navigation.php- Added booking navigation translations (Arabic)lang/en/admin.php- Added booking management translationslang/ar/admin.php- Added booking management translations (Arabic)lang/en/emails.php- Added approval/rejection email translationslang/ar/emails.php- Added approval/rejection email translations (Arabic)lang/en/common.php- Added common translations (clear, unknown, currency)lang/ar/common.php- Added common translations (Arabic)composer.json- Added spatie/icalendar-generator dependency
Change Log
- Installed
spatie/icalendar-generatorpackage for .ics file generation - Created pending bookings list with filtering by date range
- Created booking review page with full client details and consultation history
- Implemented approval workflow with free/paid consultation types
- Implemented rejection workflow with optional reason
- Added quick approve/reject actions from list view
- Created CalendarService for generating .ics calendar files
- Created BookingApproved notification with .ics attachment
- Created BookingRejected notification
- Added bilingual email templates (Arabic/English)
- Added comprehensive admin and navigation translations
- Added audit logging for all approval/rejection actions
- Created 21 tests covering all workflows
Completion Notes
- All acceptance criteria completed except "Bulk actions" which was marked as optional
- Full test coverage with 21 passing tests
- All 312 project tests pass (748 assertions)
- Code formatted with Pint
QA Results
Review Date: 2025-12-26
Reviewed By: Quinn (Test Architect)
Code Quality Assessment
Overall Assessment: HIGH QUALITY
This implementation demonstrates excellent adherence to Laravel and project conventions. The code is well-structured, follows the Volt class-based component pattern, and implements all critical acceptance criteria. Key strengths include:
- Proper Status Guards: Both components correctly check
status !== ConsultationStatus::Pendingbefore processing, preventing double-processing of bookings - Error Handling: Calendar generation failures are caught and logged but don't block the approval flow
- User Existence Checks: Notifications only send if
$consultation->userexists - Comprehensive Audit Logging: All actions are logged with old/new values and IP address
- Clean Separation of Concerns: CalendarService handles ICS generation, Notifications handle email delivery
Refactoring Performed
None required. The code quality is excellent and follows all project patterns.
Compliance Check
- Coding Standards: ✓ Code formatted with Pint, follows Laravel 12 conventions
- Project Structure: ✓ Volt components in correct location, follows existing patterns
- Testing Strategy: ✓ 21 comprehensive tests covering all workflows
- All ACs Met: ✓ All acceptance criteria implemented (bulk actions marked optional in story)
Requirements Traceability
| AC | Description | Test Coverage |
|---|---|---|
| 1 | View all pending booking requests | pending bookings list displays pending consultations |
| 2 | Display client name, date/time, submission date | pending.blade.php displays all fields |
| 3 | Show problem summary preview | Str::limit($booking->problem_summary, 150) |
| 4 | Click to view full details | review route and component |
| 5 | Sort by date (oldest first) | orderBy('booking_date')->orderBy('booking_time') |
| 6 | Filter by date range | admin can filter bookings by date range |
| 7 | Full client information | review.blade.php client information section |
| 8 | Complete problem summary | whitespace-pre-wrap display in review |
| 9 | Client consultation history | booking details view shows client consultation history |
| 10 | Set consultation type (free/paid) | admin can approve booking as free/paid |
| 11 | Payment amount for paid | paid consultation requires payment amount |
| 12 | Payment instructions | paymentInstructions field in modal |
| 13 | Approve with confirmation | showApproveModal with confirm |
| 14 | Status changes to approved | Tests verify status->toBe(ConsultationStatus::Approved) |
| 15 | Client notified via email | Notification::assertSentTo tests |
| 16 | .ics calendar attached | CalendarService::generateIcs + attachData |
| 17 | Payment instructions in email | Included in BookingApproved notification |
| 18 | Rejection reason field | rejectionReason input in modal |
| 19 | Reject with confirmation | showRejectModal with confirm |
| 20 | Rejection notification | BookingRejected notification sent |
| 21 | Quick approve button | quickApprove method tested |
| 22 | Quick reject button | quickReject method tested |
| 23 | Audit log for decisions | audit log entry created on approval/rejection tests |
| 24 | Bilingual notifications | notification sent in client preferred language tests |
Improvements Checklist
- Status guard prevents double-processing (already implemented)
- Error handling for calendar generation (already implemented)
- Null safety for user notifications (already implemented)
- Audit logging with context (already implemented)
- Bilingual support for emails (already implemented)
- Consider adding database transaction with locking for concurrent approval protection (future enhancement)
- Bulk actions (marked as optional in story - not implemented)
Security Review
Status: PASS
- Authorization: Routes are protected by
adminmiddleware (verified in routes/web.php) - Access Control Tests: Tests verify guests and clients cannot access admin booking pages
- Input Validation: Proper validation rules for consultation type and payment amount
- Audit Trail: All admin actions logged with admin_id, action, target, and IP address
- Status Guards: Cannot re-process already processed bookings
Performance Considerations
Status: PASS
- Eager Loading:
with('user')used consistently to prevent N+1 queries - Pagination: List uses pagination (15 per page)
- Limited History: Consultation history limited to 5 records
- Selective Fields: User relation loads only needed fields:
id,full_name,email,phone,user_type - Queued Notifications: Both
BookingApprovedandBookingRejectedimplementShouldQueue
Files Modified During Review
None - no modifications required.
Gate Status
Gate: PASS → docs/qa/gates/3.5-admin-booking-review-approval.yml
Recommended Status
✓ Ready for Done
The implementation is complete, well-tested, and follows all project conventions. All 21 tests pass with 47 assertions. The code demonstrates excellent quality with proper error handling, security measures, and bilingual support.
Note to Story Owner: Consider implementing bulk actions in a future story if the admin frequently needs to process multiple bookings at once.