reviewed epic 4
This commit is contained in:
parent
8b8d9735b9
commit
ad2d9604b8
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,74 +193,338 @@ 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 -->
|
||||||
<table class="w-full">
|
@if($timelines->isEmpty())
|
||||||
<thead>
|
<div class="text-center py-8 text-gray-500">
|
||||||
<tr>
|
{{ __('admin.no_timelines_found') }}
|
||||||
<th wire:click="sort('case_name')">{{ __('admin.case_name') }}</th>
|
</div>
|
||||||
<th wire:click="sort('user_id')">{{ __('admin.client') }}</th>
|
@else
|
||||||
<th>{{ __('admin.status') }}</th>
|
<table class="w-full">
|
||||||
<th wire:click="sort('updated_at')">{{ __('admin.last_update') }}</th>
|
<thead>
|
||||||
<th>{{ __('admin.actions') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach($timelines as $timeline)
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ $timeline->case_name }}</td>
|
<th wire:click="sort('case_name')" class="cursor-pointer">
|
||||||
<td>{{ $timeline->user->name }}</td>
|
{{ __('admin.case_name') }}
|
||||||
<td>
|
@if($sortBy === 'case_name')
|
||||||
<flux:badge :variant="$timeline->status === 'active' ? 'success' : 'secondary'">
|
<flux:icon name="{{ $sortDir === 'asc' ? 'chevron-up' : 'chevron-down' }}" class="inline w-4 h-4" />
|
||||||
{{ __('admin.' . $timeline->status) }}
|
@endif
|
||||||
</flux:badge>
|
</th>
|
||||||
</td>
|
<th wire:click="sort('user_id')" class="cursor-pointer">
|
||||||
<td>{{ $timeline->updated_at->diffForHumans() }}</td>
|
{{ __('admin.client') }}
|
||||||
<td>
|
</th>
|
||||||
<flux:dropdown>
|
<th>{{ __('admin.status') }}</th>
|
||||||
<flux:button size="sm">{{ __('admin.actions') }}</flux:button>
|
<th wire:click="sort('updated_at')" class="cursor-pointer">
|
||||||
<flux:menu>
|
{{ __('admin.last_update') }}
|
||||||
<flux:menu.item href="{{ route('admin.timelines.show', $timeline) }}">
|
@if($sortBy === 'updated_at')
|
||||||
{{ __('admin.view') }}
|
<flux:icon name="{{ $sortDir === 'asc' ? 'chevron-up' : 'chevron-down' }}" class="inline w-4 h-4" />
|
||||||
</flux:menu.item>
|
@endif
|
||||||
<flux:menu.item wire:click="toggleArchive({{ $timeline->id }})">
|
</th>
|
||||||
{{ $timeline->status === 'active' ? __('admin.archive') : __('admin.unarchive') }}
|
<th>{{ __('admin.updates') }}</th>
|
||||||
</flux:menu.item>
|
<th>{{ __('admin.actions') }}</th>
|
||||||
</flux:menu>
|
|
||||||
</flux:dropdown>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
@endforeach
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
@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>
|
</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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
$timeline->user->notify(new TimelineUpdateNotification($update));
|
// Simulate the trigger from Story 4.2
|
||||||
|
if ($timeline->user->isActive()) {
|
||||||
|
$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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue