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

11 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

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

// Add admin_notes column to consultations or separate table
// In Consultation model:
protected $casts = [
    'admin_notes' => 'array', // [{text, admin_id, created_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]);
}

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: Booking approval
  • Story 3.6: Calendar file generation
  • Epic 8: Email 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