reviewed epic 4
This commit is contained in:
@@ -15,6 +15,26 @@ So that **I can efficiently track and update case progress**.
|
||||
- **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
|
||||
|
||||
@@ -26,23 +46,27 @@ So that **I can efficiently track and update case progress**.
|
||||
- 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
|
||||
- [ ] Archive/unarchive toggle with confirmation
|
||||
|
||||
### Quality Requirements
|
||||
- [ ] Fast loading with eager loading
|
||||
@@ -51,11 +75,22 @@ So that **I can efficiently track and update case progress**.
|
||||
|
||||
## 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
|
||||
<?php
|
||||
|
||||
use App\Models\Timeline;
|
||||
use App\Models\AdminLog;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
@@ -71,7 +106,17 @@ new class extends Component {
|
||||
public string $sortDir = 'desc';
|
||||
public int $perPage = 15;
|
||||
|
||||
public function updatedSearch()
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedClientFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedStatusFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
@@ -86,6 +131,37 @@ new class extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
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 [
|
||||
@@ -102,6 +178,10 @@ new class extends Component {
|
||||
->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']),
|
||||
];
|
||||
}
|
||||
};
|
||||
@@ -113,74 +193,338 @@ new class extends Component {
|
||||
<!-- 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>
|
||||
<!-- More filters... -->
|
||||
|
||||
<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 -->
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th wire:click="sort('case_name')">{{ __('admin.case_name') }}</th>
|
||||
<th wire:click="sort('user_id')">{{ __('admin.client') }}</th>
|
||||
<th>{{ __('admin.status') }}</th>
|
||||
<th wire:click="sort('updated_at')">{{ __('admin.last_update') }}</th>
|
||||
<th>{{ __('admin.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($timelines as $timeline)
|
||||
@if($timelines->isEmpty())
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
{{ __('admin.no_timelines_found') }}
|
||||
</div>
|
||||
@else
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
<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 wire:click="toggleArchive({{ $timeline->id }})">
|
||||
{{ $timeline->status === 'active' ? __('admin.archive') : __('admin.unarchive') }}
|
||||
</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</td>
|
||||
<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>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
{{ $timelines->links() }}
|
||||
<div class="mt-4">
|
||||
{{ $timelines->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
```
|
||||
|
||||
## 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
|
||||
- [ ] All sorts working
|
||||
- [ ] Quick actions functional
|
||||
- [ ] Pagination working
|
||||
- [ ] No N+1 queries
|
||||
- [ ] Bilingual support
|
||||
- [ ] Tests pass
|
||||
- [ ] 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
|
||||
- **Story 4.3:** Archive functionality
|
||||
- **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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user