reviewed epic 4

This commit is contained in:
Naser Mansour 2025-12-20 23:57:39 +02:00
parent 8b8d9735b9
commit ad2d9604b8
6 changed files with 1144 additions and 117 deletions

View File

@ -11,15 +11,20 @@ So that **I can track and communicate progress on their legal matters**.
## Story Context ## Story Context
### Existing System Integration ### Existing System Integration
- **Integrates with:** timelines table, users table - **Integrates with:** timelines table, timeline_updates table, users table, admin_logs table
- **Technology:** Livewire Volt, Flux UI - **Technology:** Livewire Volt, Flux UI
- **Follows pattern:** Admin CRUD pattern - **Follows pattern:** Admin CRUD pattern (see existing admin components)
- **Touch points:** User relationship, client dashboard - **Touch points:** User relationship, client dashboard, audit logging
### Reference Documents
- **Epic:** `docs/epics/epic-4-case-timeline.md`
- **Database Schema:** `docs/stories/story-1.1-project-setup-database-schema.md#database-schema-reference`
- **User Model:** Requires users with `user_type` of 'individual' or 'company' (from Epic 2)
## Acceptance Criteria ## Acceptance Criteria
### Timeline Creation Form ### Timeline Creation Form
- [ ] Select client (search by name/email) - [ ] Select client (search by name/email) - only users with user_type 'individual' or 'company'
- [ ] Case name/title (required) - [ ] Case name/title (required)
- [ ] Case reference number (optional, unique if provided) - [ ] Case reference number (optional, unique if provided)
- [ ] Initial notes (optional) - [ ] Initial notes (optional)
@ -38,15 +43,30 @@ So that **I can track and communicate progress on their legal matters**.
- [ ] Client must exist - [ ] Client must exist
### Quality Requirements ### Quality Requirements
- [ ] Audit log entry created - [ ] Audit log entry created via AdminLog model
- [ ] Bilingual labels and messages - [ ] Bilingual labels and messages
- [ ] Tests for creation flow - [ ] Tests for creation flow
## Technical Notes ## Technical Notes
### File Structure
```
Routes:
GET /admin/timelines/create -> admin.timelines.create
POST handled by Livewire component
Files to Create:
resources/views/livewire/pages/admin/timelines/create.blade.php (Volt component)
Models Required (from Story 1.1):
app/Models/Timeline.php
app/Models/TimelineUpdate.php
app/Models/AdminLog.php
```
### Database Schema ### Database Schema
```php ```php
// timelines table // timelines table (from Story 1.1)
Schema::create('timelines', function (Blueprint $table) { Schema::create('timelines', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->foreignId('user_id')->constrained()->cascadeOnDelete();
@ -55,13 +75,49 @@ Schema::create('timelines', function (Blueprint $table) {
$table->enum('status', ['active', 'archived'])->default('active'); $table->enum('status', ['active', 'archived'])->default('active');
$table->timestamps(); $table->timestamps();
}); });
// timeline_updates table (from Story 1.1)
Schema::create('timeline_updates', function (Blueprint $table) {
$table->id();
$table->foreignId('timeline_id')->constrained()->cascadeOnDelete();
$table->foreignId('admin_id')->constrained('users')->cascadeOnDelete();
$table->text('update_text');
$table->timestamps();
});
```
### Required Translation Keys
```php
// resources/lang/en/messages.php
'timeline_created' => 'Timeline created successfully.',
// resources/lang/ar/messages.php
'timeline_created' => 'تم إنشاء الجدول الزمني بنجاح.',
// resources/lang/en/labels.php
'case_name' => 'Case Name',
'case_reference' => 'Case Reference',
'initial_notes' => 'Initial Notes',
'select_client' => 'Select Client',
'search_client' => 'Search by name or email...',
'create_timeline' => 'Create Timeline',
// resources/lang/ar/labels.php
'case_name' => 'اسم القضية',
'case_reference' => 'رقم مرجع القضية',
'initial_notes' => 'ملاحظات أولية',
'select_client' => 'اختر العميل',
'search_client' => 'البحث بالاسم أو البريد الإلكتروني...',
'create_timeline' => 'إنشاء جدول زمني',
``` ```
### Volt Component ### Volt Component
```php ```php
<?php <?php
use App\Models\{Timeline, User}; use App\Models\AdminLog;
use App\Models\Timeline;
use App\Models\User;
use Livewire\Volt\Component; use Livewire\Volt\Component;
new class extends Component { new class extends Component {
@ -72,10 +128,37 @@ new class extends Component {
public string $caseReference = ''; public string $caseReference = '';
public string $initialNotes = ''; public string $initialNotes = '';
public function updatedSearch(): void
{
// Reset selection when search changes
if ($this->selectedUser && ! str_contains(strtolower($this->selectedUser->name), strtolower($this->search))) {
$this->selectedUserId = null;
$this->selectedUser = null;
}
}
public function getClientsProperty()
{
if (strlen($this->search) < 2) {
return collect();
}
return User::query()
->whereIn('user_type', ['individual', 'company'])
->where('status', 'active')
->where(function ($query) {
$query->where('name', 'like', "%{$this->search}%")
->orWhere('email', 'like', "%{$this->search}%");
})
->limit(10)
->get();
}
public function selectUser(int $userId): void public function selectUser(int $userId): void
{ {
$this->selectedUserId = $userId; $this->selectedUserId = $userId;
$this->selectedUser = User::find($userId); $this->selectedUser = User::find($userId);
$this->search = $this->selectedUser->name;
} }
public function create(): void public function create(): void
@ -113,27 +196,63 @@ new class extends Component {
session()->flash('success', __('messages.timeline_created')); session()->flash('success', __('messages.timeline_created'));
$this->redirect(route('admin.timelines.show', $timeline)); $this->redirect(route('admin.timelines.show', $timeline));
} }
}; }; ?>
<div>
{{-- Component template here using Flux UI --}}
</div>
``` ```
## Test Scenarios
All tests should use Pest and be placed in `tests/Feature/Admin/TimelineCreationTest.php`.
### Happy Path Tests
- [ ] `test_admin_can_view_timeline_creation_form` - Admin can access /admin/timelines/create
- [ ] `test_admin_can_search_clients_by_name` - Search returns matching users
- [ ] `test_admin_can_search_clients_by_email` - Search returns matching users
- [ ] `test_admin_can_create_timeline_with_required_fields` - Timeline created with case_name only
- [ ] `test_admin_can_create_timeline_with_case_reference` - Timeline created with optional reference
- [ ] `test_initial_notes_creates_first_timeline_update` - TimelineUpdate record created
- [ ] `test_audit_log_created_on_timeline_creation` - AdminLog entry exists
### Validation Tests
- [ ] `test_case_name_is_required` - Validation error without case_name
- [ ] `test_case_reference_must_be_unique` - Validation error on duplicate reference
- [ ] `test_case_reference_allows_multiple_nulls` - Multiple timelines without reference allowed
- [ ] `test_client_selection_is_required` - Validation error without selecting client
- [ ] `test_selected_client_must_exist` - Validation error for non-existent user_id
### Authorization Tests
- [ ] `test_non_admin_cannot_access_timeline_creation` - Redirect or 403 for non-admin users
- [ ] `test_guest_cannot_access_timeline_creation` - Redirect to login
### Edge Case Tests
- [ ] `test_search_only_returns_individual_and_company_users` - Admin users not in results
- [ ] `test_search_only_returns_active_users` - Deactivated users not in results
- [ ] `test_can_create_multiple_timelines_for_same_client` - No unique constraint on user_id
## Definition of Done ## Definition of Done
- [ ] Can search and select client - [ ] Volt component created at `resources/views/livewire/pages/admin/timelines/create.blade.php`
- [ ] Route registered for admin timeline creation
- [ ] Can search and select client (individual/company only)
- [ ] Can enter case name and reference - [ ] Can enter case name and reference
- [ ] Timeline created with correct data - [ ] Timeline created with correct data
- [ ] Initial notes saved as first update - [ ] Initial notes saved as first update
- [ ] Unique reference validation works - [ ] Unique reference validation works
- [ ] Client can view timeline immediately - [ ] Client can view timeline immediately (verified via client dashboard)
- [ ] Audit log created - [ ] Audit log created
- [ ] Tests pass - [ ] All translation keys added (AR/EN)
- [ ] All tests pass
- [ ] Code formatted with Pint - [ ] Code formatted with Pint
## Dependencies ## Dependencies
- **Epic 1:** Database schema, authentication - **Story 1.1:** Database schema (timelines, timeline_updates, admin_logs tables and models)
- **Epic 2:** User accounts - **Story 1.2:** Authentication & role system (admin role check)
- **Story 2.1/2.2:** User accounts exist to assign timelines to
## Estimation ## Estimation
**Complexity:** Low-Medium **Complexity:** Low-Medium
**Estimated Effort:** 2-3 hours

View File

@ -12,10 +12,31 @@ So that **I can keep clients informed about their case progress**.
### Existing System Integration ### Existing System Integration
- **Integrates with:** timeline_updates table, timelines table - **Integrates with:** timeline_updates table, timelines table
- **Technology:** Livewire Volt, rich text editor - **Technology:** Livewire Volt, rich text editor (Trix recommended)
- **Follows pattern:** Nested CRUD pattern - **Follows pattern:** Nested CRUD pattern
- **Touch points:** Client notifications, timeline view - **Touch points:** Client notifications, timeline view
### Previous Story Context (Story 4.1)
Story 4.1 established:
- `Timeline` model with fields: `user_id`, `case_name`, `case_reference`, `status`
- `updates()` hasMany relationship on Timeline model
- `timelines` table with foreign key to users
- Admin can create timelines for any client
- Initial notes saved as first timeline update
### Prerequisites
- `Timeline` model with `updates()` relationship (from Story 4.1)
- `TimelineUpdate` model (create in this story)
- `AdminLog` model for audit logging (from Epic 1)
- HTML sanitization: use `mews/purifier` package with `clean()` helper
- Rich text editor: Trix (ships with Laravel) or similar
### Implementation Files
- **Volt Component:** `resources/views/livewire/pages/admin/timelines/updates.blade.php`
- **Model:** `app/Models/TimelineUpdate.php`
- **Notification:** `app/Notifications/TimelineUpdateNotification.php`
- **Route:** `admin.timelines.show` (timeline detail page with updates section)
## Acceptance Criteria ## Acceptance Criteria
### Add Update ### Add Update
@ -43,9 +64,22 @@ So that **I can keep clients informed about their case progress**.
- [ ] Visual timeline representation - [ ] Visual timeline representation
### Quality Requirements ### Quality Requirements
- [ ] HTML sanitization for security - [ ] HTML sanitization using `mews/purifier` package
- [ ] Audit log for edits - [ ] Audit log for add/edit operations via AdminLog
- [ ] Tests for add/edit operations - [ ] Feature tests for all operations
### Test Scenarios
- [ ] Can add update with valid text (min 10 chars)
- [ ] Cannot add update with empty text - validation error
- [ ] Cannot add update with text < 10 chars - validation error
- [ ] HTML is sanitized (script tags, XSS vectors removed)
- [ ] Client receives notification email on new update
- [ ] Audit log created when update is added
- [ ] Audit log created when update is edited (includes old/new values)
- [ ] Updates display in chronological order (oldest first)
- [ ] Admin name and timestamp automatically recorded
- [ ] Edit preserves original created_at, updates updated_at
- [ ] Cannot change timestamp or admin on edit
## Technical Notes ## Technical Notes
@ -60,11 +94,48 @@ Schema::create('timeline_updates', function (Blueprint $table) {
}); });
``` ```
### TimelineUpdate Model
```php
// app/Models/TimelineUpdate.php
class TimelineUpdate extends Model
{
protected $fillable = ['timeline_id', 'admin_id', 'update_text'];
public function timeline(): BelongsTo
{
return $this->belongsTo(Timeline::class);
}
public function admin(): BelongsTo
{
return $this->belongsTo(User::class, 'admin_id');
}
}
```
### Timeline Model Relationship (add to existing model)
```php
// In app/Models/Timeline.php - add this relationship
public function updates(): HasMany
{
return $this->hasMany(TimelineUpdate::class)->orderBy('created_at', 'asc');
}
```
### Setup Requirements
```bash
# Install HTML Purifier for sanitization
composer require mews/purifier
# Publish config (optional, defaults are secure)
php artisan vendor:publish --provider="Mews\Purifier\PurifierServiceProvider"
```
### Volt Component ### Volt Component
```php ```php
<?php <?php
use App\Models\{Timeline, TimelineUpdate}; use App\Models\{Timeline, TimelineUpdate, AdminLog};
use App\Notifications\TimelineUpdateNotification; use App\Notifications\TimelineUpdateNotification;
use Livewire\Volt\Component; use Livewire\Volt\Component;
@ -79,12 +150,13 @@ new class extends Component {
'updateText' => ['required', 'string', 'min:10'], 'updateText' => ['required', 'string', 'min:10'],
]); ]);
// clean() is from mews/purifier - sanitizes HTML, removes XSS vectors
$update = $this->timeline->updates()->create([ $update = $this->timeline->updates()->create([
'admin_id' => auth()->id(), 'admin_id' => auth()->id(),
'update_text' => clean($this->updateText), // Sanitize HTML 'update_text' => clean($this->updateText),
]); ]);
// Notify client // Notify client (queued - works when Epic 8 email is ready)
$this->timeline->user->notify(new TimelineUpdateNotification($update)); $this->timeline->user->notify(new TimelineUpdateNotification($update));
AdminLog::create([ AdminLog::create([
@ -131,6 +203,12 @@ new class extends Component {
$this->updateText = ''; $this->updateText = '';
session()->flash('success', __('messages.update_edited')); session()->flash('success', __('messages.update_edited'));
} }
public function cancelEdit(): void
{
$this->editingUpdate = null;
$this->updateText = '';
}
}; };
``` ```
@ -148,8 +226,11 @@ new class extends Component {
## Dependencies ## Dependencies
- **Story 4.1:** Timeline creation - **Story 4.1:** Timeline creation (REQUIRED - must be complete)
- **Epic 8:** Email notifications - **Epic 8:** Email notifications (SOFT DEPENDENCY)
- If Epic 8 is not complete, implement notification dispatch but skip email tests
- Create `TimelineUpdateNotification` class with queued email
- Notification will work once Epic 8 email infrastructure is ready
## Estimation ## Estimation

View File

@ -11,19 +11,32 @@ So that **I can organize active and completed case timelines**.
## Story Context ## Story Context
### Existing System Integration ### Existing System Integration
- **Integrates with:** timelines table - **Integrates with:** timelines table (status field from Story 4.1)
- **Technology:** Livewire Volt - **Technology:** Livewire Volt
- **Follows pattern:** Soft state change pattern - **Follows pattern:** Soft state change pattern
- **Touch points:** Timeline list views - **Touch points:** Timeline list views, timeline detail view
### Prerequisites (from Story 4.1)
The `timelines` table includes:
```php
$table->enum('status', ['active', 'archived'])->default('active');
```
### Files to Modify
- `app/Models/Timeline.php` - add archive/unarchive methods and scopes
- `resources/views/livewire/admin/timelines/show.blade.php` - add archive/unarchive button
- `resources/views/livewire/admin/timelines/index.blade.php` - add status filter dropdown
- `resources/views/livewire/client/timelines/index.blade.php` - separate archived timelines display
## Acceptance Criteria ## Acceptance Criteria
### Archive Timeline ### Archive Timeline
- [ ] Archive button on timeline detail - [ ] Archive button on timeline detail view (admin only)
- [ ] Confirmation modal before archiving ("Are you sure?")
- [ ] Status changes to 'archived' - [ ] Status changes to 'archived'
- [ ] Timeline remains visible to client - [ ] Timeline remains visible to client (read-only as always)
- [ ] No further updates can be added (until unarchived) - [ ] No further updates can be added (show disabled state with tooltip)
- [ ] Visual indicator shows archived status - [ ] Visual indicator shows archived status (badge + muted styling)
### Unarchive Timeline ### Unarchive Timeline
- [ ] Unarchive button on archived timelines - [ ] Unarchive button on archived timelines
@ -31,14 +44,41 @@ So that **I can organize active and completed case timelines**.
- [ ] Updates can be added again - [ ] Updates can be added again
### List Filtering ### List Filtering
- [ ] Filter timelines by status (active/archived/all) - [ ] Filter dropdown with options: Active (default), Archived, All
- [ ] Archived timelines sorted separately in client view - [ ] Archived timelines shown in separate section in client view (below active)
- [ ] Bulk archive option for multiple timelines - [ ] Bulk archive via checkbox selection + "Archive Selected" button
- [ ] Bulk archive shows count confirmation ("Archive 3 timelines?")
### Edge Cases
- [ ] Archiving already-archived timeline: No-op, no error shown
- [ ] Unarchiving already-active timeline: No-op, no error shown
- [ ] Adding update to archived timeline: Prevented with error message
- [ ] Bulk archive mixed selection: Skip already-archived, archive only active ones
- [ ] Empty bulk selection: "Archive Selected" button disabled
### Quality Requirements ### Quality Requirements
- [ ] Audit log for status changes - [ ] Audit log for status changes (action_type: 'archive' or 'unarchive')
- [ ] Bilingual labels - [ ] Bilingual labels (AR/EN for buttons, messages, filters)
- [ ] Tests for archive/unarchive - [ ] Feature tests covering all scenarios below
### Test Scenarios
```
tests/Feature/Timeline/TimelineArchivingTest.php
- test_admin_can_archive_active_timeline
- test_admin_can_unarchive_archived_timeline
- test_archiving_archived_timeline_is_noop
- test_unarchiving_active_timeline_is_noop
- test_cannot_add_update_to_archived_timeline
- test_client_can_view_archived_timeline
- test_admin_can_filter_by_active_status
- test_admin_can_filter_by_archived_status
- test_admin_can_filter_all_statuses
- test_bulk_archive_updates_multiple_timelines
- test_bulk_archive_skips_already_archived
- test_audit_log_created_on_archive
- test_audit_log_created_on_unarchive
- test_guest_cannot_archive_timeline
```
## Technical Notes ## Technical Notes
@ -133,20 +173,21 @@ public function bulkArchive(array $ids): void
## Definition of Done ## Definition of Done
- [ ] Can archive active timeline - [ ] Archive button on timeline detail view works with confirmation modal
- [ ] Can unarchive archived timeline - [ ] Unarchive button on archived timelines works
- [ ] Cannot add updates to archived timeline - [ ] Update form disabled/hidden on archived timelines with clear messaging
- [ ] Filter by status works - [ ] Status filter dropdown functional (Active/Archived/All)
- [ ] Bulk archive works - [ ] Bulk archive with checkbox selection works
- [ ] Visual indicators correct - [ ] Visual indicators (badge + muted styling) display correctly
- [ ] Audit log created - [ ] Client can still view archived timelines in separate section
- [ ] Tests pass - [ ] Audit log entries created for all archive/unarchive actions
- [ ] All test scenarios in `tests/Feature/Timeline/TimelineArchivingTest.php` pass
- [ ] Code formatted with Pint - [ ] Code formatted with Pint
## Dependencies ## Dependencies
- **Story 4.1:** Timeline creation - **Story 4.1:** Timeline creation (`docs/stories/story-4.1-timeline-creation.md`) - provides Timeline model and schema
- **Story 4.2:** Timeline updates - **Story 4.2:** Timeline updates (`docs/stories/story-4.2-timeline-updates-management.md`) - provides update blocking context
## Estimation ## Estimation

View File

@ -15,6 +15,26 @@ So that **I can efficiently track and update case progress**.
- **Technology:** Livewire Volt with pagination - **Technology:** Livewire Volt with pagination
- **Follows pattern:** Admin list/dashboard pattern - **Follows pattern:** Admin list/dashboard pattern
- **Touch points:** All timeline operations - **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 ## Acceptance Criteria
@ -26,23 +46,27 @@ So that **I can efficiently track and update case progress**.
- Last update date - Last update date
- Update count - Update count
- [ ] Pagination (15/25/50 per page) - [ ] Pagination (15/25/50 per page)
- [ ] Empty state message when no timelines exist
### Filtering ### Filtering
- [ ] Filter by client (search/select) - [ ] Filter by client (search/select)
- [ ] Filter by status (active/archived/all) - [ ] Filter by status (active/archived/all)
- [ ] Filter by date range (created/updated) - [ ] Filter by date range (created/updated)
- [ ] Search by case name or reference - [ ] Search by case name or reference
- [ ] Clear filters option
- [ ] Show "No results" message when filters return empty
### Sorting ### Sorting
- [ ] Sort by client name - [ ] Sort by client name
- [ ] Sort by case name - [ ] Sort by case name
- [ ] Sort by last updated - [ ] Sort by last updated
- [ ] Sort by created date - [ ] Sort by created date
- [ ] Visual indicator for current sort column/direction
### Quick Actions ### Quick Actions
- [ ] View timeline details - [ ] View timeline details
- [ ] Add update (inline or link) - [ ] Add update (inline or link)
- [ ] Archive/unarchive toggle - [ ] Archive/unarchive toggle with confirmation
### Quality Requirements ### Quality Requirements
- [ ] Fast loading with eager loading - [ ] Fast loading with eager loading
@ -51,11 +75,22 @@ So that **I can efficiently track and update case progress**.
## Technical Notes ## 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 ### Volt Component
```php ```php
<?php <?php
use App\Models\Timeline; use App\Models\Timeline;
use App\Models\AdminLog;
use Livewire\Volt\Component; use Livewire\Volt\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
@ -71,7 +106,17 @@ new class extends Component {
public string $sortDir = 'desc'; public string $sortDir = 'desc';
public int $perPage = 15; 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(); $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 public function with(): array
{ {
return [ return [
@ -102,6 +178,10 @@ new class extends Component {
->when($this->dateTo, fn($q) => $q->where('created_at', '<=', $this->dateTo)) ->when($this->dateTo, fn($q) => $q->where('created_at', '<=', $this->dateTo))
->orderBy($this->sortBy, $this->sortDir) ->orderBy($this->sortBy, $this->sortDir)
->paginate($this->perPage), ->paginate($this->perPage),
'clients' => \App\Models\User::query()
->whereHas('timelines')
->orderBy('name')
->get(['id', 'name']),
]; ];
} }
}; };
@ -113,28 +193,66 @@ new class extends Component {
<!-- Filters Row --> <!-- Filters Row -->
<div class="flex flex-wrap gap-4 mb-6"> <div class="flex flex-wrap gap-4 mb-6">
<flux:input wire:model.live.debounce="search" placeholder="{{ __('admin.search_cases') }}" /> <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"> <flux:select wire:model.live="statusFilter">
<option value="">{{ __('admin.all_statuses') }}</option> <option value="">{{ __('admin.all_statuses') }}</option>
<option value="active">{{ __('admin.active') }}</option> <option value="active">{{ __('admin.active') }}</option>
<option value="archived">{{ __('admin.archived') }}</option> <option value="archived">{{ __('admin.archived') }}</option>
</flux:select> </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> </div>
<!-- Table --> <!-- Table -->
@if($timelines->isEmpty())
<div class="text-center py-8 text-gray-500">
{{ __('admin.no_timelines_found') }}
</div>
@else
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr> <tr>
<th wire:click="sort('case_name')">{{ __('admin.case_name') }}</th> <th wire:click="sort('case_name')" class="cursor-pointer">
<th wire:click="sort('user_id')">{{ __('admin.client') }}</th> {{ __('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>{{ __('admin.status') }}</th>
<th wire:click="sort('updated_at')">{{ __('admin.last_update') }}</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> <th>{{ __('admin.actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach($timelines as $timeline) @foreach($timelines as $timeline)
<tr> <tr wire:key="timeline-{{ $timeline->id }}">
<td>{{ $timeline->case_name }}</td> <td>{{ $timeline->case_name }}</td>
<td>{{ $timeline->user->name }}</td> <td>{{ $timeline->user->name }}</td>
<td> <td>
@ -143,6 +261,7 @@ new class extends Component {
</flux:badge> </flux:badge>
</td> </td>
<td>{{ $timeline->updated_at->diffForHumans() }}</td> <td>{{ $timeline->updated_at->diffForHumans() }}</td>
<td>{{ $timeline->updates_count }}</td>
<td> <td>
<flux:dropdown> <flux:dropdown>
<flux:button size="sm">{{ __('admin.actions') }}</flux:button> <flux:button size="sm">{{ __('admin.actions') }}</flux:button>
@ -150,7 +269,13 @@ new class extends Component {
<flux:menu.item href="{{ route('admin.timelines.show', $timeline) }}"> <flux:menu.item href="{{ route('admin.timelines.show', $timeline) }}">
{{ __('admin.view') }} {{ __('admin.view') }}
</flux:menu.item> </flux:menu.item>
<flux:menu.item wire:click="toggleArchive({{ $timeline->id }})"> <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') }} {{ $timeline->status === 'active' ? __('admin.archive') : __('admin.unarchive') }}
</flux:menu.item> </flux:menu.item>
</flux:menu> </flux:menu>
@ -161,26 +286,245 @@ new class extends Component {
</tbody> </tbody>
</table> </table>
<div class="mt-4">
{{ $timelines->links() }} {{ $timelines->links() }}
</div> </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 ## Definition of Done
- [ ] List displays all timelines - [ ] List displays all timelines
- [ ] All filters working - [ ] All filters working (status, client, search, date range)
- [ ] All sorts working - [ ] Clear filters button resets all filters
- [ ] Quick actions functional - [ ] All sorts working with visual indicators
- [ ] Pagination working - [ ] Quick actions functional (view, add update, archive/unarchive)
- [ ] No N+1 queries - [ ] Archive/unarchive has confirmation dialog
- [ ] Bilingual support - [ ] Pagination working with per-page selector
- [ ] Tests pass - [ ] 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 - [ ] Code formatted with Pint
## Dependencies ## Dependencies
- **Story 4.1:** Timeline creation - **Story 4.1:** Timeline creation (`docs/stories/story-4.1-timeline-creation.md`) - Timeline model and relationships
- **Story 4.3:** Archive functionality - **Story 4.3:** Archive functionality (`docs/stories/story-4.3-timeline-archiving.md`) - Archive/unarchive methods on Timeline model
## Estimation ## Estimation

View File

@ -15,6 +15,43 @@ So that **I can track the progress of my legal matters**.
- **Technology:** Livewire Volt (read-only) - **Technology:** Livewire Volt (read-only)
- **Follows pattern:** Client dashboard pattern - **Follows pattern:** Client dashboard pattern
- **Touch points:** Client portal navigation - **Touch points:** Client portal navigation
- **Authorization:** Client middleware protects routes (defined in `routes/web.php`)
### Relationship to Story 7.3
This story (4.5) implements the **core timeline viewing functionality** for clients as part of the Case Timeline epic. Story 7.3 (My Cases/Timelines View) in Epic 7 focuses on the **client dashboard integration** and will reuse the components created here. Implement this story first; Story 7.3 will integrate these components into the dashboard layout.
### 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`
- Database schema:
```php
Schema::create('timelines', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('case_name');
$table->string('case_reference')->nullable()->unique();
$table->enum('status', ['active', 'archived'])->default('active');
$table->timestamps();
});
```
**From Story 4.2 (`docs/stories/story-4.2-timeline-updates-management.md`):**
- `TimelineUpdate` model with fields: `timeline_id`, `admin_id`, `update_text`
- Timeline has `updates()` HasMany relationship
**From Story 4.3 (`docs/stories/story-4.3-timeline-archiving.md`):**
- Timeline model has scopes: `scopeActive()`, `scopeArchived()`
- Timeline model has methods: `isArchived()`
**User Model Requirement:**
The `User` model must have:
```php
public function timelines(): HasMany
{
return $this->hasMany(Timeline::class);
}
```
## Acceptance Criteria ## Acceptance Criteria
@ -40,7 +77,7 @@ So that **I can track the progress of my legal matters**.
### Restrictions ### Restrictions
- [ ] Read-only (no edit/comment) - [ ] Read-only (no edit/comment)
- [ ] No ability to archive/delete - [ ] No ability to archive/delete
- [ ] Only see own timelines - [ ] Only see own timelines (403 for unauthorized access)
### UX Features ### UX Features
- [ ] Recent updates indicator (new since last view, optional) - [ ] Recent updates indicator (new since last view, optional)
@ -49,9 +86,38 @@ So that **I can track the progress of my legal matters**.
## Technical Notes ## Technical Notes
### File Structure
```
Routes (add to routes/web.php within client middleware group):
GET /client/timelines -> client.timelines.index
GET /client/timelines/{timeline} -> client.timelines.show
Files to Create:
resources/views/livewire/pages/client/timelines/index.blade.php (List component)
resources/views/livewire/pages/client/timelines/show.blade.php (Detail component)
Tests:
tests/Feature/Client/TimelineViewTest.php
```
### Route Definition
```php
// routes/web.php - within client middleware group
Route::middleware(['auth', 'verified', 'client'])->prefix('client')->name('client.')->group(function () {
Route::get('/timelines', function () {
return view('livewire.pages.client.timelines.index');
})->name('timelines.index');
Route::get('/timelines/{timeline}', function (Timeline $timeline) {
return view('livewire.pages.client.timelines.show', ['timeline' => $timeline]);
})->name('timelines.show');
});
```
### Volt Component for List ### Volt Component for List
```php ```php
<?php <?php
// resources/views/livewire/pages/client/timelines/index.blade.php
use Livewire\Volt\Component; use Livewire\Volt\Component;
@ -75,12 +141,88 @@ new class extends Component {
->get(), ->get(),
]; ];
} }
}; }; ?>
<div>
<flux:heading class="mb-6">{{ __('client.my_cases') }}</flux:heading>
{{-- Active Timelines --}}
@if($activeTimelines->isNotEmpty())
<div class="mb-8">
<h2 class="text-lg font-semibold text-charcoal mb-4">{{ __('client.active_cases') }}</h2>
<div class="space-y-4">
@foreach($activeTimelines as $timeline)
<div wire:key="timeline-{{ $timeline->id }}" class="bg-white p-4 rounded-lg shadow-sm border-l-4 border-gold">
<div class="flex justify-between items-start">
<div>
<h3 class="font-medium text-charcoal">{{ $timeline->case_name }}</h3>
@if($timeline->case_reference)
<p class="text-sm text-charcoal/70">{{ __('client.reference') }}: {{ $timeline->case_reference }}</p>
@endif
<p class="text-sm text-charcoal/60 mt-1">
{{ __('client.updates') }}: {{ $timeline->updates_count }}
@if($timeline->updates->first())
· {{ __('client.last_update') }}: {{ $timeline->updates->first()->created_at->diffForHumans() }}
@endif
</p>
</div>
<div class="flex items-center gap-2">
<flux:badge variant="success">{{ __('client.active') }}</flux:badge>
<flux:button size="sm" href="{{ route('client.timelines.show', $timeline) }}">
{{ __('client.view') }}
</flux:button>
</div>
</div>
</div>
@endforeach
</div>
</div>
@endif
{{-- Archived Timelines --}}
@if($archivedTimelines->isNotEmpty())
<div>
<h2 class="text-lg font-semibold text-charcoal/70 mb-4">{{ __('client.archived_cases') }}</h2>
<div class="space-y-4 opacity-75">
@foreach($archivedTimelines as $timeline)
<div wire:key="timeline-{{ $timeline->id }}" class="bg-gray-50 p-4 rounded-lg shadow-sm">
<div class="flex justify-between items-start">
<div>
<h3 class="font-medium text-charcoal/80">{{ $timeline->case_name }}</h3>
@if($timeline->case_reference)
<p class="text-sm text-charcoal/60">{{ __('client.reference') }}: {{ $timeline->case_reference }}</p>
@endif
<p class="text-sm text-charcoal/50 mt-1">
{{ __('client.updates') }}: {{ $timeline->updates_count }}
</p>
</div>
<div class="flex items-center gap-2">
<flux:badge variant="secondary">{{ __('client.archived') }}</flux:badge>
<flux:button size="sm" variant="ghost" href="{{ route('client.timelines.show', $timeline) }}">
{{ __('client.view') }}
</flux:button>
</div>
</div>
</div>
@endforeach
</div>
</div>
@endif
{{-- Empty State --}}
@if($activeTimelines->isEmpty() && $archivedTimelines->isEmpty())
<div class="text-center py-12">
<flux:icon name="folder-open" class="w-12 h-12 text-charcoal/30 mx-auto mb-4" />
<p class="text-charcoal/70">{{ __('client.no_cases_yet') }}</p>
</div>
@endif
</div>
``` ```
### Timeline Detail View ### Timeline Detail View
```php ```php
<?php <?php
// resources/views/livewire/pages/client/timelines/show.blade.php
use App\Models\Timeline; use App\Models\Timeline;
use Livewire\Volt\Component; use Livewire\Volt\Component;
@ -90,16 +232,13 @@ new class extends Component {
public function mount(Timeline $timeline): void public function mount(Timeline $timeline): void
{ {
// Ensure client owns this timeline // Authorization: Ensure client owns this timeline
abort_unless($timeline->user_id === auth()->id(), 403); abort_unless($timeline->user_id === auth()->id(), 403);
$this->timeline = $timeline->load(['updates' => fn($q) => $q->oldest()]); $this->timeline = $timeline->load(['updates' => fn($q) => $q->oldest()]);
} }
}; }; ?>
```
### Template
```blade
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<!-- Header --> <!-- Header -->
<div class="flex justify-between items-start mb-6"> <div class="flex justify-between items-start mb-6">
@ -121,7 +260,7 @@ new class extends Component {
<div class="space-y-6"> <div class="space-y-6">
@forelse($timeline->updates as $update) @forelse($timeline->updates as $update)
<div class="relative {{ app()->getLocale() === 'ar' ? 'pr-12' : 'pl-12' }}"> <div wire:key="update-{{ $update->id }}" class="relative {{ app()->getLocale() === 'ar' ? 'pr-12' : 'pl-12' }}">
<!-- Dot --> <!-- Dot -->
<div class="absolute {{ app()->getLocale() === 'ar' ? 'right-2' : 'left-2' }} top-2 w-4 h-4 rounded-full bg-gold border-4 border-cream"></div> <div class="absolute {{ app()->getLocale() === 'ar' ? 'right-2' : 'left-2' }} top-2 w-4 h-4 rounded-full bg-gold border-4 border-cream"></div>
@ -150,23 +289,251 @@ new class extends Component {
</div> </div>
``` ```
### Required Translation Keys
```php
// resources/lang/en/client.php
'my_cases' => 'My Cases',
'active_cases' => 'Active Cases',
'archived_cases' => 'Archived Cases',
'reference' => 'Reference',
'updates' => 'Updates',
'last_update' => 'Last update',
'active' => 'Active',
'archived' => 'Archived',
'view' => 'View',
'back_to_cases' => 'Back to Cases',
'no_cases_yet' => 'You don\'t have any cases yet.',
'no_updates_yet' => 'No updates yet.',
// resources/lang/ar/client.php
'my_cases' => 'قضاياي',
'active_cases' => 'القضايا النشطة',
'archived_cases' => 'القضايا المؤرشفة',
'reference' => 'المرجع',
'updates' => 'التحديثات',
'last_update' => 'آخر تحديث',
'active' => 'نشط',
'archived' => 'مؤرشف',
'view' => 'عرض',
'back_to_cases' => 'العودة للقضايا',
'no_cases_yet' => 'لا توجد لديك قضايا بعد.',
'no_updates_yet' => 'لا توجد تحديثات بعد.',
```
## Test Scenarios
All tests should use Pest and be placed in `tests/Feature/Client/TimelineViewTest.php`.
```php
<?php
use App\Models\{User, Timeline, TimelineUpdate};
use Livewire\Volt\Volt;
// Authorization Tests
test('client can view own timelines list', function () {
$client = User::factory()->create(['user_type' => 'individual']);
Timeline::factory()->count(3)->create(['user_id' => $client->id]);
$this->actingAs($client)
->get(route('client.timelines.index'))
->assertOk()
->assertSeeLivewire('pages.client.timelines.index');
});
test('client cannot view other clients timelines in list', function () {
$client = User::factory()->create(['user_type' => 'individual']);
$otherClient = User::factory()->create(['user_type' => 'individual']);
$otherTimeline = Timeline::factory()->create([
'user_id' => $otherClient->id,
'case_name' => 'Other Client Case',
]);
Volt::test('pages.client.timelines.index')
->actingAs($client)
->assertDontSee('Other Client Case');
});
test('client can view own timeline detail', function () {
$client = User::factory()->create(['user_type' => 'individual']);
$timeline = Timeline::factory()->create([
'user_id' => $client->id,
'case_name' => 'My Contract Case',
]);
$this->actingAs($client)
->get(route('client.timelines.show', $timeline))
->assertOk()
->assertSee('My Contract Case');
});
test('client cannot view other clients timeline detail', function () {
$client = User::factory()->create(['user_type' => 'individual']);
$otherClient = User::factory()->create(['user_type' => 'individual']);
$otherTimeline = Timeline::factory()->create(['user_id' => $otherClient->id]);
$this->actingAs($client)
->get(route('client.timelines.show', $otherTimeline))
->assertForbidden();
});
test('guest cannot access timelines', function () {
$this->get(route('client.timelines.index'))
->assertRedirect(route('login'));
});
test('admin cannot access client timeline routes', function () {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->get(route('client.timelines.index'))
->assertForbidden();
});
// List View Tests
test('active timelines displayed separately from archived', function () {
$client = User::factory()->create(['user_type' => 'individual']);
Timeline::factory()->create([
'user_id' => $client->id,
'case_name' => 'Active Case',
'status' => 'active',
]);
Timeline::factory()->create([
'user_id' => $client->id,
'case_name' => 'Archived Case',
'status' => 'archived',
]);
Volt::test('pages.client.timelines.index')
->actingAs($client)
->assertSee('Active Case')
->assertSee('Archived Case')
->assertSeeInOrder([__('client.active_cases'), 'Active Case', __('client.archived_cases'), 'Archived Case']);
});
test('timeline list shows update count', function () {
$client = User::factory()->create(['user_type' => 'individual']);
$timeline = Timeline::factory()->create(['user_id' => $client->id]);
TimelineUpdate::factory()->count(5)->create(['timeline_id' => $timeline->id]);
Volt::test('pages.client.timelines.index')
->actingAs($client)
->assertSee('5');
});
test('empty state shown when no timelines', function () {
$client = User::factory()->create(['user_type' => 'individual']);
Volt::test('pages.client.timelines.index')
->actingAs($client)
->assertSee(__('client.no_cases_yet'));
});
// Detail View Tests
test('timeline detail shows all updates chronologically', function () {
$client = User::factory()->create(['user_type' => 'individual']);
$timeline = Timeline::factory()->create(['user_id' => $client->id]);
$oldUpdate = TimelineUpdate::factory()->create([
'timeline_id' => $timeline->id,
'update_text' => 'First Update',
'created_at' => now()->subDays(2),
]);
$newUpdate = TimelineUpdate::factory()->create([
'timeline_id' => $timeline->id,
'update_text' => 'Second Update',
'created_at' => now()->subDay(),
]);
Volt::test('pages.client.timelines.show', ['timeline' => $timeline])
->actingAs($client)
->assertSeeInOrder(['First Update', 'Second Update']);
});
test('timeline detail shows case name and reference', function () {
$client = User::factory()->create(['user_type' => 'individual']);
$timeline = Timeline::factory()->create([
'user_id' => $client->id,
'case_name' => 'Property Dispute',
'case_reference' => 'REF-2024-001',
]);
Volt::test('pages.client.timelines.show', ['timeline' => $timeline])
->actingAs($client)
->assertSee('Property Dispute')
->assertSee('REF-2024-001');
});
test('timeline detail shows status badge', function () {
$client = User::factory()->create(['user_type' => 'individual']);
$activeTimeline = Timeline::factory()->create([
'user_id' => $client->id,
'status' => 'active',
]);
Volt::test('pages.client.timelines.show', ['timeline' => $activeTimeline])
->actingAs($client)
->assertSee(__('client.active'));
});
test('empty updates shows no updates message', function () {
$client = User::factory()->create(['user_type' => 'individual']);
$timeline = Timeline::factory()->create(['user_id' => $client->id]);
Volt::test('pages.client.timelines.show', ['timeline' => $timeline])
->actingAs($client)
->assertSee(__('client.no_updates_yet'));
});
// Read-Only Enforcement Tests
test('client cannot edit timeline or updates', function () {
$client = User::factory()->create(['user_type' => 'individual']);
$timeline = Timeline::factory()->create(['user_id' => $client->id]);
// Verify no edit methods exist on the component
Volt::test('pages.client.timelines.show', ['timeline' => $timeline])
->actingAs($client)
->assertMethodDoesNotExist('edit')
->assertMethodDoesNotExist('update')
->assertMethodDoesNotExist('delete')
->assertMethodDoesNotExist('archive');
});
// N+1 Query Prevention Test
test('timeline list uses eager loading', function () {
$client = User::factory()->create(['user_type' => 'individual']);
Timeline::factory()->count(10)->create(['user_id' => $client->id]);
// Component should load with reasonable query count
$this->actingAs($client);
Volt::test('pages.client.timelines.index');
// With eager loading: ~3-4 queries (timelines, updates count, latest update)
});
```
## Definition of Done ## Definition of Done
- [ ] Volt components created at specified file locations
- [ ] Routes registered for client timeline views
- [ ] Client can view list of their timelines - [ ] Client can view list of their timelines
- [ ] Active/archived clearly separated - [ ] Active/archived clearly separated with visual distinction
- [ ] Can view individual timeline details - [ ] Can view individual timeline details
- [ ] All updates displayed chronologically - [ ] All updates displayed chronologically (oldest first)
- [ ] Read-only (no edit capabilities) - [ ] Read-only enforced (no edit/delete methods)
- [ ] Cannot view other clients' timelines - [ ] Cannot view other clients' timelines (403 response)
- [ ] Empty state displayed when no timelines
- [ ] Mobile responsive - [ ] Mobile responsive
- [ ] RTL support - [ ] RTL support with proper positioning
- [ ] Tests pass - [ ] All translation keys added (AR/EN)
- [ ] All tests pass
- [ ] Code formatted with Pint - [ ] Code formatted with Pint
## Dependencies ## Dependencies
- **Story 4.1-4.3:** Timeline management - **Story 4.1:** Timeline creation (`docs/stories/story-4.1-timeline-creation.md`) - Timeline model and database
- **Epic 7:** Client dashboard structure - **Story 4.2:** Timeline updates (`docs/stories/story-4.2-timeline-updates-management.md`) - TimelineUpdate model
- **Story 4.3:** Timeline archiving (`docs/stories/story-4.3-timeline-archiving.md`) - Active/archived scopes
- **Story 7.3:** Will integrate these components into client dashboard (`docs/stories/story-7.3-my-cases-timelines-view.md`)
## Estimation ## Estimation

View File

@ -16,6 +16,31 @@ So that **I stay informed about my case progress without checking the portal**.
- **Follows pattern:** Event-driven notification pattern - **Follows pattern:** Event-driven notification pattern
- **Touch points:** Timeline update creation - **Touch points:** Timeline update creation
### Previous Story Context (Story 4.2)
Story 4.2 established:
- `TimelineUpdate` model with fields: `timeline_id`, `admin_id`, `update_text`
- `TimelineUpdate` belongs to `Timeline` and `User` (admin)
- `Timeline` has many `TimelineUpdate` records
- Updates created via `addUpdate()` method in timeline management Volt component
- Notification trigger point: after `$this->timeline->updates()->create([...])`
### Prerequisites
The following must exist before implementing this story:
- **User model requirements:**
- `preferred_language` field (string, defaults to 'ar') - from Epic 2 user registration
- `isActive()` method returning boolean (checks `status !== 'deactivated'`) - from Epic 2
- **Route requirement:**
- `client.timelines.show` route must exist (from Story 4.5)
- **Models available:**
- `Timeline` model with `user()` relationship
- `TimelineUpdate` model with `timeline()` relationship
### Implementation Files
- **Notification:** `app/Notifications/TimelineUpdateNotification.php`
- **Arabic Template:** `resources/views/emails/timeline/update/ar.blade.php`
- **English Template:** `resources/views/emails/timeline/update/en.blade.php`
- **Modify:** Story 4.2 Volt component to trigger notification
## Acceptance Criteria ## Acceptance Criteria
### Notification Trigger ### Notification Trigger
@ -168,18 +193,33 @@ if ($this->timeline->user->isActive()) {
} }
``` ```
### Testing ### Test Scenarios
- [ ] Notification sent when timeline update created
- [ ] No notification to deactivated user (`status === 'deactivated'`)
- [ ] Arabic template renders with correct content (case name, update text, date)
- [ ] English template renders with correct content
- [ ] Uses client's `preferred_language` for template selection (defaults to 'ar')
- [ ] Email is queued (uses `ShouldQueue` interface)
- [ ] Email contains valid link to timeline view
- [ ] Subject line includes case name in correct language
### Testing Examples
```php ```php
use App\Models\{User, Timeline, TimelineUpdate};
use App\Notifications\TimelineUpdateNotification; use App\Notifications\TimelineUpdateNotification;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
it('sends notification when timeline update created', function () { it('sends notification when timeline update created', function () {
Notification::fake(); Notification::fake();
$timeline = Timeline::factory()->create(); $user = User::factory()->create(['status' => 'active']);
$timeline = Timeline::factory()->for($user)->create();
$update = TimelineUpdate::factory()->create(['timeline_id' => $timeline->id]); $update = TimelineUpdate::factory()->create(['timeline_id' => $timeline->id]);
// Simulate the trigger from Story 4.2
if ($timeline->user->isActive()) {
$timeline->user->notify(new TimelineUpdateNotification($update)); $timeline->user->notify(new TimelineUpdateNotification($update));
}
Notification::assertSentTo($timeline->user, TimelineUpdateNotification::class); Notification::assertSentTo($timeline->user, TimelineUpdateNotification::class);
}); });
@ -189,12 +229,45 @@ it('does not send notification to deactivated user', function () {
$user = User::factory()->create(['status' => 'deactivated']); $user = User::factory()->create(['status' => 'deactivated']);
$timeline = Timeline::factory()->for($user)->create(); $timeline = Timeline::factory()->for($user)->create();
$update = TimelineUpdate::factory()->create(['timeline_id' => $timeline->id]);
// Add update (should check user status) // Simulate the trigger from Story 4.2 - should NOT notify
// ... if ($timeline->user->isActive()) {
$timeline->user->notify(new TimelineUpdateNotification($update));
}
Notification::assertNotSentTo($user, TimelineUpdateNotification::class); Notification::assertNotSentTo($user, TimelineUpdateNotification::class);
}); });
it('uses arabic template for arabic-preferred user', function () {
Notification::fake();
$user = User::factory()->create(['preferred_language' => 'ar']);
$timeline = Timeline::factory()->for($user)->create(['case_name' => 'Test Case']);
$update = TimelineUpdate::factory()->create(['timeline_id' => $timeline->id]);
$user->notify(new TimelineUpdateNotification($update));
Notification::assertSentTo($user, TimelineUpdateNotification::class, function ($notification, $channels, $notifiable) {
$mail = $notification->toMail($notifiable);
return str_contains($mail->subject, 'تحديث جديد على قضيتك');
});
});
it('uses english template for english-preferred user', function () {
Notification::fake();
$user = User::factory()->create(['preferred_language' => 'en']);
$timeline = Timeline::factory()->for($user)->create(['case_name' => 'Test Case']);
$update = TimelineUpdate::factory()->create(['timeline_id' => $timeline->id]);
$user->notify(new TimelineUpdateNotification($update));
Notification::assertSentTo($user, TimelineUpdateNotification::class, function ($notification, $channels, $notifiable) {
$mail = $notification->toMail($notifiable);
return str_contains($mail->subject, 'New update on your case');
});
});
``` ```
## Definition of Done ## Definition of Done
@ -211,8 +284,10 @@ it('does not send notification to deactivated user', function () {
## Dependencies ## Dependencies
- **Story 4.2:** Timeline updates management - **Story 4.2:** Timeline updates management (REQUIRED - notification triggered from addUpdate method)
- **Epic 8:** Email infrastructure - **Story 4.5:** Client timeline view (REQUIRED - provides `client.timelines.show` route used in email link)
- **Epic 2:** User model with `preferred_language` field and `isActive()` method
- **Epic 8:** Email infrastructure (SOFT DEPENDENCY - notification will queue but email delivery requires Epic 8)
## Estimation ## Estimation