# 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:
```php
// 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
```php
// routes/web.php (within admin middleware group)
Route::get('/admin/timelines', \App\Livewire\Admin\Timelines\Index::class)
->name('admin.timelines.index');
```
### Volt Component
```php
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
```blade
@foreach($clients as $client)
@endforeach
{{ __('admin.clear_filters') }}
@if($timelines->isEmpty())
{{ __('admin.no_timelines_found') }}
@else
|
{{ __('admin.case_name') }}
@if($sortBy === 'case_name')
@endif
|
{{ __('admin.client') }}
|
{{ __('admin.status') }} |
{{ __('admin.last_update') }}
@if($sortBy === 'updated_at')
@endif
|
{{ __('admin.updates') }} |
{{ __('admin.actions') }} |
@foreach($timelines as $timeline)
| {{ $timeline->case_name }} |
{{ $timeline->user->name }} |
{{ __('admin.' . $timeline->status) }}
|
{{ $timeline->updated_at->diffForHumans() }} |
{{ $timeline->updates_count }} |
{{ __('admin.actions') }}
{{ __('admin.view') }}
{{ __('admin.add_update') }}
{{ $timeline->status === 'active' ? __('admin.archive') : __('admin.unarchive') }}
|
@endforeach
{{ $timelines->links() }}
@endif
```
## Test Scenarios
### Feature Tests
Create test file: `tests/Feature/Admin/TimelineDashboardTest.php`
```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