libra/docs/stories/story-3.5-admin-booking-rev...

21 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->user exists 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