libra/docs/stories/story-4.4-admin-timeline-da...

18 KiB

Story 4.4: Admin Timeline Dashboard

Epic Reference

Epic 4: Case Timeline System

User Story

As an admin, I want a central view to manage all timelines across all clients, So that I can efficiently track and update case progress.

Story Context

Existing System Integration

  • Integrates with: timelines table, users table
  • Technology: Livewire Volt with pagination
  • Follows pattern: Admin list/dashboard pattern
  • Touch points: All timeline operations
  • Authorization: Admin middleware protects route (defined in routes/web.php)

Prerequisites from Previous Stories

From Story 4.1 (docs/stories/story-4.1-timeline-creation.md):

  • Timeline model exists with fields: user_id, case_name, case_reference, status
  • Relationships: belongsTo(User::class) as user, hasMany(TimelineUpdate::class) as updates
  • Database schema:
    // timelines table
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('case_name');
    $table->string('case_reference')->nullable()->unique();
    $table->enum('status', ['active', 'archived'])->default('active');
    

From Story 4.3 (docs/stories/story-4.3-timeline-archiving.md):

  • Timeline model has archive(), unarchive(), isArchived() methods
  • Scopes available: scopeActive(), scopeArchived()
  • AdminLog is created on status changes

Acceptance Criteria

List View

  • Display all timelines with:
    • Case name
    • Client name
    • Status (active/archived)
    • Last update date
    • Update count
  • Pagination (15/25/50 per page)
  • Empty state message when no timelines exist

Filtering

  • Filter by client (search/select)
  • Filter by status (active/archived/all)
  • Filter by date range (created/updated)
  • Search by case name or reference
  • Clear filters option
  • Show "No results" message when filters return empty

Sorting

  • Sort by client name
  • Sort by case name
  • Sort by last updated
  • Sort by created date
  • Visual indicator for current sort column/direction

Quick Actions

  • View timeline details
  • Add update (inline or link)
  • Archive/unarchive toggle with confirmation

Quality Requirements

  • Fast loading with eager loading
  • Bilingual support
  • Tests for filtering/sorting

Technical Notes

File Location

resources/views/livewire/admin/timelines/index.blade.php

Route Definition

// routes/web.php (within admin middleware group)
Route::get('/admin/timelines', \App\Livewire\Admin\Timelines\Index::class)
    ->name('admin.timelines.index');

Volt Component

<?php

use App\Models\Timeline;
use App\Models\AdminLog;
use Livewire\Volt\Component;
use Livewire\WithPagination;

new class extends Component {
    use WithPagination;

    public string $search = '';
    public string $clientFilter = '';
    public string $statusFilter = '';
    public string $dateFrom = '';
    public string $dateTo = '';
    public string $sortBy = 'updated_at';
    public string $sortDir = 'desc';
    public int $perPage = 15;

    public function updatedSearch(): void
    {
        $this->resetPage();
    }

    public function updatedClientFilter(): void
    {
        $this->resetPage();
    }

    public function updatedStatusFilter(): void
    {
        $this->resetPage();
    }

    public function sort(string $column): void
    {
        if ($this->sortBy === $column) {
            $this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
        } else {
            $this->sortBy = $column;
            $this->sortDir = 'asc';
        }
    }

    public function toggleArchive(int $id): void
    {
        $timeline = Timeline::findOrFail($id);

        if ($timeline->isArchived()) {
            $timeline->unarchive();
            $action = 'unarchive';
            $message = __('messages.timeline_unarchived');
        } else {
            $timeline->archive();
            $action = 'archive';
            $message = __('messages.timeline_archived');
        }

        AdminLog::create([
            'admin_id' => auth()->id(),
            'action_type' => $action,
            'target_type' => 'timeline',
            'target_id' => $timeline->id,
            'ip_address' => request()->ip(),
        ]);

        session()->flash('success', $message);
    }

    public function clearFilters(): void
    {
        $this->reset(['search', 'clientFilter', 'statusFilter', 'dateFrom', 'dateTo']);
        $this->resetPage();
    }

    public function with(): array
    {
        return [
            'timelines' => Timeline::query()
                ->with(['user', 'updates' => fn($q) => $q->latest()->limit(1)])
                ->withCount('updates')
                ->when($this->search, fn($q) => $q->where(function($q) {
                    $q->where('case_name', 'like', "%{$this->search}%")
                      ->orWhere('case_reference', 'like', "%{$this->search}%");
                }))
                ->when($this->clientFilter, fn($q) => $q->where('user_id', $this->clientFilter))
                ->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter))
                ->when($this->dateFrom, fn($q) => $q->where('created_at', '>=', $this->dateFrom))
                ->when($this->dateTo, fn($q) => $q->where('created_at', '<=', $this->dateTo))
                ->orderBy($this->sortBy, $this->sortDir)
                ->paginate($this->perPage),
            'clients' => \App\Models\User::query()
                ->whereHas('timelines')
                ->orderBy('name')
                ->get(['id', 'name']),
        ];
    }
};

Template Structure

<div>
    <!-- Filters Row -->
    <div class="flex flex-wrap gap-4 mb-6">
        <flux:input wire:model.live.debounce="search" placeholder="{{ __('admin.search_cases') }}" />

        <flux:select wire:model.live="clientFilter">
            <option value="">{{ __('admin.all_clients') }}</option>
            @foreach($clients as $client)
                <option value="{{ $client->id }}">{{ $client->name }}</option>
            @endforeach
        </flux:select>

        <flux:select wire:model.live="statusFilter">
            <option value="">{{ __('admin.all_statuses') }}</option>
            <option value="active">{{ __('admin.active') }}</option>
            <option value="archived">{{ __('admin.archived') }}</option>
        </flux:select>

        <flux:input type="date" wire:model.live="dateFrom" />
        <flux:input type="date" wire:model.live="dateTo" />

        <flux:button variant="ghost" wire:click="clearFilters">
            {{ __('admin.clear_filters') }}
        </flux:button>

        <flux:select wire:model.live="perPage">
            <option value="15">15</option>
            <option value="25">25</option>
            <option value="50">50</option>
        </flux:select>
    </div>

    <!-- Table -->
    @if($timelines->isEmpty())
        <div class="text-center py-8 text-gray-500">
            {{ __('admin.no_timelines_found') }}
        </div>
    @else
        <table class="w-full">
            <thead>
                <tr>
                    <th wire:click="sort('case_name')" class="cursor-pointer">
                        {{ __('admin.case_name') }}
                        @if($sortBy === 'case_name')
                            <flux:icon name="{{ $sortDir === 'asc' ? 'chevron-up' : 'chevron-down' }}" class="inline w-4 h-4" />
                        @endif
                    </th>
                    <th wire:click="sort('user_id')" class="cursor-pointer">
                        {{ __('admin.client') }}
                    </th>
                    <th>{{ __('admin.status') }}</th>
                    <th wire:click="sort('updated_at')" class="cursor-pointer">
                        {{ __('admin.last_update') }}
                        @if($sortBy === 'updated_at')
                            <flux:icon name="{{ $sortDir === 'asc' ? 'chevron-up' : 'chevron-down' }}" class="inline w-4 h-4" />
                        @endif
                    </th>
                    <th>{{ __('admin.updates') }}</th>
                    <th>{{ __('admin.actions') }}</th>
                </tr>
            </thead>
            <tbody>
                @foreach($timelines as $timeline)
                    <tr wire:key="timeline-{{ $timeline->id }}">
                        <td>{{ $timeline->case_name }}</td>
                        <td>{{ $timeline->user->name }}</td>
                        <td>
                            <flux:badge :variant="$timeline->status === 'active' ? 'success' : 'secondary'">
                                {{ __('admin.' . $timeline->status) }}
                            </flux:badge>
                        </td>
                        <td>{{ $timeline->updated_at->diffForHumans() }}</td>
                        <td>{{ $timeline->updates_count }}</td>
                        <td>
                            <flux:dropdown>
                                <flux:button size="sm">{{ __('admin.actions') }}</flux:button>
                                <flux:menu>
                                    <flux:menu.item href="{{ route('admin.timelines.show', $timeline) }}">
                                        {{ __('admin.view') }}
                                    </flux:menu.item>
                                    <flux:menu.item href="{{ route('admin.timelines.updates.create', $timeline) }}">
                                        {{ __('admin.add_update') }}
                                    </flux:menu.item>
                                    <flux:menu.item
                                        wire:click="toggleArchive({{ $timeline->id }})"
                                        wire:confirm="{{ $timeline->status === 'active' ? __('admin.confirm_archive') : __('admin.confirm_unarchive') }}"
                                    >
                                        {{ $timeline->status === 'active' ? __('admin.archive') : __('admin.unarchive') }}
                                    </flux:menu.item>
                                </flux:menu>
                            </flux:dropdown>
                        </td>
                    </tr>
                @endforeach
            </tbody>
        </table>

        <div class="mt-4">
            {{ $timelines->links() }}
        </div>
    @endif
</div>

Test Scenarios

Feature Tests

Create test file: tests/Feature/Admin/TimelineDashboardTest.php

use App\Models\{User, Timeline};
use Livewire\Volt\Volt;

// List View Tests
test('admin can view timeline dashboard', function () {
    $admin = User::factory()->admin()->create();
    Timeline::factory()->count(5)->create();

    $this->actingAs($admin)
        ->get(route('admin.timelines.index'))
        ->assertOk()
        ->assertSeeLivewire('admin.timelines.index');
});

test('timeline dashboard displays all timelines with correct data', function () {
    $admin = User::factory()->admin()->create();
    $timeline = Timeline::factory()->create(['case_name' => 'Test Case']);

    Volt::test('admin.timelines.index')
        ->actingAs($admin)
        ->assertSee('Test Case')
        ->assertSee($timeline->user->name);
});

test('dashboard shows empty state when no timelines exist', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.timelines.index')
        ->actingAs($admin)
        ->assertSee(__('admin.no_timelines_found'));
});

// Filtering Tests
test('can filter timelines by status', function () {
    $admin = User::factory()->admin()->create();
    $active = Timeline::factory()->create(['status' => 'active', 'case_name' => 'Active Case']);
    $archived = Timeline::factory()->create(['status' => 'archived', 'case_name' => 'Archived Case']);

    Volt::test('admin.timelines.index')
        ->actingAs($admin)
        ->set('statusFilter', 'active')
        ->assertSee('Active Case')
        ->assertDontSee('Archived Case');
});

test('can filter timelines by client', function () {
    $admin = User::factory()->admin()->create();
    $client1 = User::factory()->create();
    $client2 = User::factory()->create();
    Timeline::factory()->create(['user_id' => $client1->id, 'case_name' => 'Client1 Case']);
    Timeline::factory()->create(['user_id' => $client2->id, 'case_name' => 'Client2 Case']);

    Volt::test('admin.timelines.index')
        ->actingAs($admin)
        ->set('clientFilter', $client1->id)
        ->assertSee('Client1 Case')
        ->assertDontSee('Client2 Case');
});

test('can search timelines by case name', function () {
    $admin = User::factory()->admin()->create();
    Timeline::factory()->create(['case_name' => 'Contract Dispute']);
    Timeline::factory()->create(['case_name' => 'Property Issue']);

    Volt::test('admin.timelines.index')
        ->actingAs($admin)
        ->set('search', 'Contract')
        ->assertSee('Contract Dispute')
        ->assertDontSee('Property Issue');
});

test('can search timelines by case reference', function () {
    $admin = User::factory()->admin()->create();
    Timeline::factory()->create(['case_reference' => 'REF-123', 'case_name' => 'Case A']);
    Timeline::factory()->create(['case_reference' => 'REF-456', 'case_name' => 'Case B']);

    Volt::test('admin.timelines.index')
        ->actingAs($admin)
        ->set('search', 'REF-123')
        ->assertSee('Case A')
        ->assertDontSee('Case B');
});

test('clear filters resets all filter values', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.timelines.index')
        ->actingAs($admin)
        ->set('search', 'test')
        ->set('statusFilter', 'active')
        ->call('clearFilters')
        ->assertSet('search', '')
        ->assertSet('statusFilter', '');
});

// Sorting Tests
test('can sort timelines by case name', function () {
    $admin = User::factory()->admin()->create();
    Timeline::factory()->create(['case_name' => 'Zebra Case']);
    Timeline::factory()->create(['case_name' => 'Alpha Case']);

    Volt::test('admin.timelines.index')
        ->actingAs($admin)
        ->call('sort', 'case_name')
        ->assertSet('sortBy', 'case_name')
        ->assertSet('sortDir', 'asc');
});

test('clicking same sort column toggles direction', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.timelines.index')
        ->actingAs($admin)
        ->call('sort', 'case_name')
        ->assertSet('sortDir', 'asc')
        ->call('sort', 'case_name')
        ->assertSet('sortDir', 'desc');
});

// Quick Actions Tests
test('can archive timeline from dashboard', function () {
    $admin = User::factory()->admin()->create();
    $timeline = Timeline::factory()->create(['status' => 'active']);

    Volt::test('admin.timelines.index')
        ->actingAs($admin)
        ->call('toggleArchive', $timeline->id);

    expect($timeline->fresh()->status)->toBe('archived');
});

test('can unarchive timeline from dashboard', function () {
    $admin = User::factory()->admin()->create();
    $timeline = Timeline::factory()->create(['status' => 'archived']);

    Volt::test('admin.timelines.index')
        ->actingAs($admin)
        ->call('toggleArchive', $timeline->id);

    expect($timeline->fresh()->status)->toBe('active');
});

test('toggle archive creates admin log entry', function () {
    $admin = User::factory()->admin()->create();
    $timeline = Timeline::factory()->create(['status' => 'active']);

    Volt::test('admin.timelines.index')
        ->actingAs($admin)
        ->call('toggleArchive', $timeline->id);

    $this->assertDatabaseHas('admin_logs', [
        'admin_id' => $admin->id,
        'action_type' => 'archive',
        'target_type' => 'timeline',
        'target_id' => $timeline->id,
    ]);
});

// Pagination Tests
test('pagination displays correct number of items', function () {
    $admin = User::factory()->admin()->create();
    Timeline::factory()->count(20)->create();

    Volt::test('admin.timelines.index')
        ->actingAs($admin)
        ->assertSet('perPage', 15);
});

test('can change items per page', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.timelines.index')
        ->actingAs($admin)
        ->set('perPage', 25)
        ->assertSet('perPage', 25);
});

// N+1 Query Prevention Test
test('dashboard uses eager loading to prevent N+1 queries', function () {
    $admin = User::factory()->admin()->create();
    Timeline::factory()->count(10)->create();

    // This test verifies the query count stays reasonable
    // The component should make ~3-4 queries regardless of timeline count
    $this->actingAs($admin);

    Volt::test('admin.timelines.index');

    // If N+1 exists, this would be 10+ queries
    // With eager loading, should be ~4 queries (timelines, users, updates, clients)
});

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

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

test('guest cannot access timeline dashboard', function () {
    $this->get(route('admin.timelines.index'))
        ->assertRedirect(route('login'));
});

Definition of Done

  • List displays all timelines
  • All filters working (status, client, search, date range)
  • Clear filters button resets all filters
  • All sorts working with visual indicators
  • Quick actions functional (view, add update, archive/unarchive)
  • Archive/unarchive has confirmation dialog
  • Pagination working with per-page selector
  • Empty state displayed when no results
  • No N+1 queries (verified with eager loading)
  • Bilingual support for all labels
  • All tests pass
  • Code formatted with Pint

Dependencies

  • Story 4.1: Timeline creation (docs/stories/story-4.1-timeline-creation.md) - Timeline model and relationships
  • Story 4.3: Archive functionality (docs/stories/story-4.3-timeline-archiving.md) - Archive/unarchive methods on Timeline model

Estimation

Complexity: Medium Estimated Effort: 3-4 hours