libra/docs/stories/story-6.3-quick-actions-pan...

21 KiB

Story 6.3: Quick Actions Panel

Epic Reference

Epic 6: Admin Dashboard

User Story

As an admin, I want quick access to pending items and common tasks, So that I can efficiently manage my daily workflow without navigating away from the dashboard.

Business Context

The admin dashboard from Story 6.1 provides static metrics. This story adds actionable widgets that surface items requiring immediate attention (pending bookings, today's schedule) and shortcuts to frequent tasks. This transforms the dashboard from an information display into a productivity hub.

Prerequisites / Dependencies

This story requires the following to be completed first:

Dependency Required From What's Needed
Dashboard Layout Story 6.1 Admin dashboard page with card-based layout
Consultation Model Epic 3 consultations table with status, scheduled_date, scheduled_time fields
Consultation Scopes Epic 3 pending() and approved() query scopes on Consultation model
Timeline Model Epic 4 timelines table with user_id, case_name fields
TimelineUpdate Model Epic 4 timeline_updates table with timeline_id, created_at
User Model Epic 2 Client users that consultations reference
Post Routes Epic 5 Route for creating posts (admin.posts.create)
User Routes Epic 2 Route for creating users (admin.users.create)

References:

  • Story 6.1 Dashboard Layout: docs/stories/story-6.1-dashboard-overview-statistics.md
  • Epic 6 Dashboard Details: docs/epics/epic-6-admin-dashboard.md
  • PRD Dashboard Section: docs/prd.md Section 5.7 (Admin Dashboard)

Acceptance Criteria

Pending Bookings Widget

  • Display count badge showing number of pending consultation requests
  • Urgent indicator (red/warning styling) when pending count > 0
  • Mini list showing up to 5 most recent pending bookings with:
    • Client name
    • Requested date
    • Consultation type (free/paid)
  • "View All" link navigating to booking management page (admin.consultations.index)
  • Empty state message when no pending bookings

Today's Schedule Widget

  • List of today's approved consultations ordered by time
  • Each item displays:
    • Scheduled time (formatted for locale)
    • Client name
    • Consultation type badge (free/paid)
  • Quick status buttons for each consultation:
    • "Complete" - marks as completed
    • "No-show" - marks as no-show
  • Empty state message when no consultations scheduled today

Recent Timeline Updates Widget

  • Display last 5 timeline updates across all clients
  • Each item shows:
    • Update preview (truncated to ~50 chars)
    • Case name
    • Client name
    • Relative timestamp ("2 hours ago")
  • Click navigates to the specific timeline (admin.timelines.show)
  • Empty state message when no recent updates

Quick Action Buttons

  • Create User button - navigates to admin.users.create
  • Create Post button - navigates to admin.posts.create
  • Block Time Slot button - opens modal to block availability
    • Date picker for selecting date
    • Time range (start/end time)
    • Optional reason field
    • Save creates a "blocked" consultation record

Notification Bell (Header)

  • Bell icon in admin header/navbar
  • Badge showing total pending items count (pending bookings)
  • Badge hidden when count is 0
  • Click navigates to pending bookings

Real-time Updates

  • Widgets auto-refresh via Livewire polling every 30 seconds
  • No full page reload required
  • Visual indication during refresh (subtle loading state)

Technical Implementation

Files to Create/Modify

File Purpose
resources/views/livewire/admin/dashboard.blade.php Add widget sections to existing dashboard
resources/views/livewire/admin/widgets/pending-bookings.blade.php Pending bookings widget (Volt component)
resources/views/livewire/admin/widgets/todays-schedule.blade.php Today's schedule widget (Volt component)
resources/views/livewire/admin/widgets/recent-updates.blade.php Recent timeline updates widget (Volt component)
resources/views/livewire/admin/widgets/quick-actions.blade.php Quick action buttons widget (Volt component)
resources/views/components/layouts/admin.blade.php Add notification bell to admin header

Widget Architecture

Each widget is a separate Volt component for isolation and independent polling:

// resources/views/livewire/admin/widgets/pending-bookings.blade.php
<?php

use App\Models\Consultation;
use Livewire\Volt\Component;

new class extends Component {
    public function with(): array
    {
        return [
            'pendingCount' => Consultation::pending()->count(),
            'pendingBookings' => Consultation::pending()
                ->with('user:id,name')
                ->latest()
                ->take(5)
                ->get(),
        ];
    }
}; ?>

<div wire:poll.30s>
    <div class="flex items-center justify-between mb-4">
        <flux:heading size="sm">{{ __('Pending Bookings') }}</flux:heading>
        @if($pendingCount > 0)
            <flux:badge color="red">{{ $pendingCount }}</flux:badge>
        @endif
    </div>

    @forelse($pendingBookings as $booking)
        <div class="py-2 border-b border-gray-100 last:border-0">
            <div class="font-medium">{{ $booking->user->name }}</div>
            <div class="text-sm text-gray-500">
                {{ $booking->scheduled_date->format('M j') }} -
                <flux:badge size="sm">{{ $booking->consultation_type }}</flux:badge>
            </div>
        </div>
    @empty
        <flux:text class="text-gray-500">{{ __('No pending bookings') }}</flux:text>
    @endforelse

    @if($pendingCount > 5)
        <a href="{{ route('admin.consultations.index', ['status' => 'pending']) }}"
           class="block mt-4 text-sm text-blue-600 hover:underline">
            {{ __('View all :count pending', ['count' => $pendingCount]) }}
        </a>
    @endif
</div>

Today's Schedule Widget with Actions

// resources/views/livewire/admin/widgets/todays-schedule.blade.php
<?php

use App\Models\Consultation;
use Livewire\Volt\Component;

new class extends Component {
    public function with(): array
    {
        return [
            'todaySchedule' => Consultation::approved()
                ->whereDate('scheduled_date', today())
                ->with('user:id,name')
                ->orderBy('scheduled_time')
                ->get(),
        ];
    }

    public function markComplete(int $consultationId): void
    {
        $consultation = Consultation::findOrFail($consultationId);
        $consultation->update(['status' => 'completed']);
        // Optionally dispatch event for logging
    }

    public function markNoShow(int $consultationId): void
    {
        $consultation = Consultation::findOrFail($consultationId);
        $consultation->update(['status' => 'no-show']);
    }
}; ?>

<div wire:poll.30s>
    <flux:heading size="sm" class="mb-4">{{ __("Today's Schedule") }}</flux:heading>

    @forelse($todaySchedule as $consultation)
        <div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-0">
            <div>
                <div class="font-medium">
                    {{ \Carbon\Carbon::parse($consultation->scheduled_time)->format('g:i A') }}
                </div>
                <div class="text-sm">{{ $consultation->user->name }}</div>
            </div>
            <div class="flex gap-2">
                <flux:button size="xs" wire:click="markComplete({{ $consultation->id }})">
                    {{ __('Complete') }}
                </flux:button>
                <flux:button size="xs" variant="danger" wire:click="markNoShow({{ $consultation->id }})">
                    {{ __('No-show') }}
                </flux:button>
            </div>
        </div>
    @empty
        <flux:text class="text-gray-500">{{ __('No consultations scheduled today') }}</flux:text>
    @endforelse
</div>

Block Time Slot Modal

// In quick-actions.blade.php
<?php

use App\Models\Consultation;
use Livewire\Volt\Component;
use Livewire\Attributes\Validate;

new class extends Component {
    public bool $showBlockModal = false;

    #[Validate('required|date|after_or_equal:today')]
    public string $blockDate = '';

    #[Validate('required')]
    public string $blockStartTime = '';

    #[Validate('required|after:blockStartTime')]
    public string $blockEndTime = '';

    public string $blockReason = '';

    public function openBlockModal(): void
    {
        $this->blockDate = today()->format('Y-m-d');
        $this->showBlockModal = true;
    }

    public function blockTimeSlot(): void
    {
        $this->validate();

        Consultation::create([
            'scheduled_date' => $this->blockDate,
            'scheduled_time' => $this->blockStartTime,
            'end_time' => $this->blockEndTime,
            'status' => 'blocked',
            'notes' => $this->blockReason,
            'user_id' => null, // No client for blocked slots
        ]);

        $this->showBlockModal = false;
        $this->reset(['blockDate', 'blockStartTime', 'blockEndTime', 'blockReason']);
    }
}; ?>

<div>
    <div class="flex flex-wrap gap-3">
        <flux:button href="{{ route('admin.users.create') }}" variant="primary">
            {{ __('Create User') }}
        </flux:button>
        <flux:button href="{{ route('admin.posts.create') }}">
            {{ __('Create Post') }}
        </flux:button>
        <flux:button wire:click="openBlockModal">
            {{ __('Block Time Slot') }}
        </flux:button>
    </div>

    <flux:modal wire:model="showBlockModal">
        <flux:heading>{{ __('Block Time Slot') }}</flux:heading>

        <form wire:submit="blockTimeSlot" class="space-y-4 mt-4">
            <flux:field>
                <flux:label>{{ __('Date') }}</flux:label>
                <flux:input type="date" wire:model="blockDate" />
            </flux:field>

            <div class="grid grid-cols-2 gap-4">
                <flux:field>
                    <flux:label>{{ __('Start Time') }}</flux:label>
                    <flux:input type="time" wire:model="blockStartTime" />
                </flux:field>
                <flux:field>
                    <flux:label>{{ __('End Time') }}</flux:label>
                    <flux:input type="time" wire:model="blockEndTime" />
                </flux:field>
            </div>

            <flux:field>
                <flux:label>{{ __('Reason (optional)') }}</flux:label>
                <flux:textarea wire:model="blockReason" rows="2" />
            </flux:field>

            <div class="flex justify-end gap-3">
                <flux:button type="button" wire:click="$set('showBlockModal', false)">
                    {{ __('Cancel') }}
                </flux:button>
                <flux:button type="submit" variant="primary">
                    {{ __('Block Slot') }}
                </flux:button>
            </div>
        </form>
    </flux:modal>
</div>

Dashboard Integration

// In resources/views/livewire/admin/dashboard.blade.php
// Add after the metrics cards from Story 6.1

<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-8">
    {{-- Quick Actions Panel --}}
    <div class="lg:col-span-3">
        <div class="bg-white rounded-lg shadow p-6">
            <flux:heading size="sm" class="mb-4">{{ __('Quick Actions') }}</flux:heading>
            <livewire:admin.widgets.quick-actions />
        </div>
    </div>

    {{-- Pending Bookings Widget --}}
    <div class="bg-white rounded-lg shadow p-6">
        <livewire:admin.widgets.pending-bookings />
    </div>

    {{-- Today's Schedule Widget --}}
    <div class="bg-white rounded-lg shadow p-6">
        <livewire:admin.widgets.todays-schedule />
    </div>

    {{-- Recent Updates Widget --}}
    <div class="bg-white rounded-lg shadow p-6">
        <livewire:admin.widgets.recent-updates />
    </div>
</div>

Notification Bell Component

Add to admin layout header:

{{-- In resources/views/components/layouts/admin.blade.php header section --}}
<div class="relative">
    <a href="{{ route('admin.consultations.index', ['status' => 'pending']) }}">
        <flux:icon name="bell" class="w-6 h-6" />
        @if($pendingCount = \App\Models\Consultation::pending()->count())
            <span class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
                {{ $pendingCount > 99 ? '99+' : $pendingCount }}
            </span>
        @endif
    </a>
</div>

Required Model Scopes

Ensure Consultation model has these scopes (from Epic 3):

// app/Models/Consultation.php
public function scopePending(Builder $query): Builder
{
    return $query->where('status', 'pending');
}

public function scopeApproved(Builder $query): Builder
{
    return $query->where('status', 'approved');
}

Flux UI Components Used

  • <flux:heading> - Widget titles
  • <flux:badge> - Count badges, status indicators
  • <flux:button> - Action buttons
  • <flux:text> - Empty state messages
  • <flux:modal> - Block time slot modal
  • <flux:input> - Form inputs
  • <flux:textarea> - Reason field
  • <flux:field> - Form field wrapper
  • <flux:label> - Form labels
  • <flux:icon> - Bell icon for notifications

Edge Cases & Error Handling

Scenario Expected Behavior
No pending bookings Show "No pending bookings" message, badge hidden
Empty today's schedule Show "No consultations scheduled today" message
No timeline updates Show "No recent updates" message
100+ pending bookings Badge shows "99+", list shows 5, "View all" shows count
Quick action routes don't exist Buttons still render, navigate to 404 (graceful)
Mark complete fails Show error toast, don't update UI
Block time slot validation fails Show inline validation errors
Blocked slot in past Validation prevents it (after_or_equal:today)
User deleted after consultation created Handle with optional chaining on user name
Polling during action wire:poll pauses during active requests

Assumptions

  1. Consultation statuses: pending, approved, completed, no-show, cancelled, blocked
  2. Blocked slots: Stored as Consultation records with status = 'blocked' and user_id = null
  3. Routes exist: admin.consultations.index, admin.users.create, admin.posts.create from respective epics
  4. Admin middleware: All routes protected by auth and admin middleware
  5. Timezone: All times displayed in application timezone (from config)

Testing Requirements

Test File

tests/Feature/Admin/QuickActionsPanelTest.php

Test Scenarios

use App\Models\User;
use App\Models\Consultation;
use App\Models\Timeline;
use App\Models\TimelineUpdate;
use Livewire\Volt\Volt;

// Widget Display Tests
test('pending bookings widget displays pending count', function () {
    $admin = User::factory()->admin()->create();
    Consultation::factory()->count(3)->pending()->create();

    Volt::test('admin.widgets.pending-bookings')
        ->actingAs($admin)
        ->assertSee('3')
        ->assertSee('Pending Bookings');
});

test('pending bookings widget shows empty state when none pending', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.widgets.pending-bookings')
        ->actingAs($admin)
        ->assertSee('No pending bookings');
});

test('today schedule widget shows only today approved consultations', function () {
    $admin = User::factory()->admin()->create();
    $client = User::factory()->client()->create(['name' => 'Test Client']);

    // Today's approved - should show
    Consultation::factory()->approved()->create([
        'user_id' => $client->id,
        'scheduled_date' => today(),
        'scheduled_time' => '10:00',
    ]);

    // Tomorrow's approved - should NOT show
    Consultation::factory()->approved()->create([
        'scheduled_date' => today()->addDay(),
    ]);

    // Today's pending - should NOT show
    Consultation::factory()->pending()->create([
        'scheduled_date' => today(),
    ]);

    Volt::test('admin.widgets.todays-schedule')
        ->actingAs($admin)
        ->assertSee('Test Client')
        ->assertSee('10:00');
});

test('today schedule widget shows empty state when no consultations', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.widgets.todays-schedule')
        ->actingAs($admin)
        ->assertSee('No consultations scheduled today');
});

// Action Tests
test('admin can mark consultation as completed', function () {
    $admin = User::factory()->admin()->create();
    $consultation = Consultation::factory()->approved()->create([
        'scheduled_date' => today(),
    ]);

    Volt::test('admin.widgets.todays-schedule')
        ->actingAs($admin)
        ->call('markComplete', $consultation->id)
        ->assertHasNoErrors();

    expect($consultation->fresh()->status)->toBe('completed');
});

test('admin can mark consultation as no-show', function () {
    $admin = User::factory()->admin()->create();
    $consultation = Consultation::factory()->approved()->create([
        'scheduled_date' => today(),
    ]);

    Volt::test('admin.widgets.todays-schedule')
        ->actingAs($admin)
        ->call('markNoShow', $consultation->id)
        ->assertHasNoErrors();

    expect($consultation->fresh()->status)->toBe('no-show');
});

// Block Time Slot Tests
test('admin can block a time slot', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.widgets.quick-actions')
        ->actingAs($admin)
        ->call('openBlockModal')
        ->set('blockDate', today()->format('Y-m-d'))
        ->set('blockStartTime', '09:00')
        ->set('blockEndTime', '10:00')
        ->set('blockReason', 'Personal appointment')
        ->call('blockTimeSlot')
        ->assertHasNoErrors();

    expect(Consultation::where('status', 'blocked')->count())->toBe(1);
});

test('block time slot validates required fields', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.widgets.quick-actions')
        ->actingAs($admin)
        ->call('openBlockModal')
        ->set('blockDate', '')
        ->call('blockTimeSlot')
        ->assertHasErrors(['blockDate']);
});

test('block time slot prevents past dates', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.widgets.quick-actions')
        ->actingAs($admin)
        ->call('openBlockModal')
        ->set('blockDate', today()->subDay()->format('Y-m-d'))
        ->set('blockStartTime', '09:00')
        ->set('blockEndTime', '10:00')
        ->call('blockTimeSlot')
        ->assertHasErrors(['blockDate']);
});

// Recent Updates Widget Tests
test('recent updates widget shows last 5 updates', function () {
    $admin = User::factory()->admin()->create();
    $timeline = Timeline::factory()->create();
    TimelineUpdate::factory()->count(7)->create(['timeline_id' => $timeline->id]);

    Volt::test('admin.widgets.recent-updates')
        ->actingAs($admin)
        ->assertViewHas('recentUpdates', fn($updates) => $updates->count() === 5);
});

// Access Control Tests
test('non-admin cannot access dashboard widgets', function () {
    $client = User::factory()->client()->create();

    $this->actingAs($client)
        ->get(route('admin.dashboard'))
        ->assertForbidden();
});

// Notification Bell Tests
test('notification bell shows pending count in header', function () {
    $admin = User::factory()->admin()->create();
    Consultation::factory()->count(5)->pending()->create();

    $this->actingAs($admin)
        ->get(route('admin.dashboard'))
        ->assertSee('5'); // Badge count
});

test('notification bell hidden when no pending items', function () {
    $admin = User::factory()->admin()->create();

    // No badge should render when count is 0
    $this->actingAs($admin)
        ->get(route('admin.dashboard'))
        ->assertSuccessful();
});

Manual Testing Checklist

  • Verify polling updates data every 30 seconds without page reload
  • Verify responsive layout on mobile (375px) - widgets stack vertically
  • Verify responsive layout on tablet (768px) - 2 column grid
  • Verify responsive layout on desktop (1200px+) - 3 column grid
  • Verify pending badge uses red/warning color when > 0
  • Verify quick action buttons navigate to correct pages
  • Verify block time slot modal opens and closes correctly
  • Verify RTL layout works correctly (Arabic)
  • Verify notification bell shows in header on all admin pages
  • Verify "Complete" and "No-show" buttons work without page refresh

Definition of Done

  • All four widgets display correctly with accurate data
  • Widgets auto-refresh via Livewire polling (30s interval)
  • Quick action buttons navigate to correct routes
  • Block time slot modal creates blocked consultation record
  • Mark complete/no-show actions update consultation status
  • Notification bell shows pending count in admin header
  • Empty states render gracefully for all widgets
  • Edge cases handled (100+ items, missing data)
  • All tests pass
  • Responsive layout works on mobile, tablet, desktop
  • RTL support for Arabic
  • Code formatted with Pint

Out of Scope

  • WebSocket real-time updates (using polling instead)
  • Push notifications to browser
  • Email notifications for pending items
  • Widget position customization
  • Dashboard layout preferences

Estimation

Complexity: Medium | Effort: 4-5 hours