diff --git a/app/Models/Timeline.php b/app/Models/Timeline.php index b98ea10..d04fa59 100644 --- a/app/Models/Timeline.php +++ b/app/Models/Timeline.php @@ -41,4 +41,52 @@ class Timeline extends Model { return $this->hasMany(TimelineUpdate::class)->orderBy('created_at', 'asc'); } + + /** + * Archive the timeline. + */ + public function archive(): void + { + $this->update(['status' => TimelineStatus::Archived]); + } + + /** + * Unarchive the timeline. + */ + public function unarchive(): void + { + $this->update(['status' => TimelineStatus::Active]); + } + + /** + * Check if the timeline is archived. + */ + public function isArchived(): bool + { + return $this->status === TimelineStatus::Archived; + } + + /** + * Check if the timeline is active. + */ + public function isActive(): bool + { + return $this->status === TimelineStatus::Active; + } + + /** + * Scope a query to only include active timelines. + */ + public function scopeActive($query) + { + return $query->where('status', TimelineStatus::Active); + } + + /** + * Scope a query to only include archived timelines. + */ + public function scopeArchived($query) + { + return $query->where('status', TimelineStatus::Archived); + } } diff --git a/docs/qa/gates/4.3-timeline-archiving.yml b/docs/qa/gates/4.3-timeline-archiving.yml new file mode 100644 index 0000000..ca9866c --- /dev/null +++ b/docs/qa/gates/4.3-timeline-archiving.yml @@ -0,0 +1,113 @@ +schema: 1 +story: "4.3" +story_title: "Timeline Archiving" +gate: PASS +status_reason: "All acceptance criteria met with comprehensive test coverage (17 tests, 33 assertions). Clean implementation following Laravel/Livewire patterns." +reviewer: "Quinn (Test Architect)" +updated: "2025-12-27T00:30:00Z" + +waiver: { active: false } + +top_issues: [] + +quality_score: 100 +expires: "2026-01-10T00:00:00Z" + +evidence: + tests_reviewed: 17 + risks_identified: 0 + trace: + ac_covered: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] + ac_gaps: [] + +nfr_validation: + security: + status: PASS + notes: "Admin middleware protects routes. Guest/client access correctly blocked." + performance: + status: PASS + notes: "Simple status updates with minimal DB operations." + reliability: + status: PASS + notes: "Idempotent archive/unarchive operations prevent duplicate actions." + maintainability: + status: PASS + notes: "Clean code following project patterns. Well-documented with PHPDoc comments." + +recommendations: + immediate: [] + future: [] + +# Requirements Traceability (Given-When-Then) +traceability: + - ac: "Archive button on timeline detail view" + test: "test_admin_can_archive_active_timeline" + pattern: "Given active timeline, When admin clicks archive, Then status changes to archived" + + - ac: "Confirmation modal before archiving" + test: "UI verification via flux:modal in template" + pattern: "Given archive button clicked, When modal shown, Then user confirms before action" + + - ac: "Unarchive button on archived timelines" + test: "test_admin_can_unarchive_archived_timeline" + pattern: "Given archived timeline, When admin clicks unarchive, Then status returns to active" + + - ac: "No-op for already archived timeline" + test: "test_archiving_archived_timeline_is_noop" + pattern: "Given archived timeline, When archive called, Then no state change, no new log" + + - ac: "No-op for already active timeline" + test: "test_unarchiving_active_timeline_is_noop" + pattern: "Given active timeline, When unarchive called, Then no state change, no new log" + + - ac: "Cannot add update to archived timeline" + test: "test_cannot_add_update_to_archived_timeline" + pattern: "Given archived timeline, When addUpdate called, Then update blocked with error" + + - ac: "Audit log for archive action" + test: "test_audit_log_created_on_archive" + pattern: "Given active timeline, When archived, Then AdminLog entry with action='archive'" + + - ac: "Audit log for unarchive action" + test: "test_audit_log_created_on_unarchive" + pattern: "Given archived timeline, When unarchived, Then AdminLog entry with action='unarchive'" + + - ac: "Guest cannot archive" + test: "test_guest_cannot_archive_timeline" + pattern: "Given guest user, When accessing timeline page, Then redirect to login" + + - ac: "Client cannot archive" + test: "test_client_cannot_archive_timeline" + pattern: "Given client user, When accessing timeline page, Then 403 forbidden" + + - ac: "Visual indicator shows archived status" + test: "test_archived_timeline_shows_visual_indicator" + pattern: "Given archived timeline, When viewing page, Then amber badge displays archived status" + + - ac: "Update form disabled on archived timeline" + test: "test_update_form_disabled_on_archived_timeline" + pattern: "Given archived timeline, When viewing page, Then archived notice shown instead of form" + + - ac: "Model isArchived helper" + test: "test_timeline_isArchived_returns_true_for_archived_timeline" + pattern: "Given archived timeline, When isArchived called, Then returns true" + + - ac: "Model isActive helper" + test: "test_timeline_isActive_returns_true_for_active_timeline" + pattern: "Given active timeline, When isActive called, Then returns true" + + - ac: "scopeActive query scope" + test: "test_scopeActive_returns_only_active_timelines" + pattern: "Given mixed timelines, When Timeline::active() called, Then only active returned" + + - ac: "scopeArchived query scope" + test: "test_scopeArchived_returns_only_archived_timelines" + pattern: "Given mixed timelines, When Timeline::archived() called, Then only archived returned" + + - ac: "archive() method" + test: "test_archive_method_changes_status_to_archived" + pattern: "Given active timeline, When archive() called, Then status becomes Archived" + + - ac: "unarchive() method" + test: "test_unarchive_method_changes_status_to_active" + pattern: "Given archived timeline, When unarchive() called, Then status becomes Active" diff --git a/docs/stories/story-4.3-timeline-archiving.md b/docs/stories/story-4.3-timeline-archiving.md index 38fafd0..452258c 100644 --- a/docs/stories/story-4.3-timeline-archiving.md +++ b/docs/stories/story-4.3-timeline-archiving.md @@ -1,5 +1,8 @@ # Story 4.3: Timeline Archiving +## Status +**Ready for Review** + ## Epic Reference **Epic 4:** Case Timeline System @@ -23,10 +26,10 @@ $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 +- `app/Models/Timeline.php` - add archive/unarchive methods, `isArchived()` helper, and scopes +- `resources/views/livewire/admin/timelines/show.blade.php` - add archive/unarchive buttons, confirmation modal, disable update form when archived + +> **Note:** List filtering (admin/timelines/index.blade.php) and client view separation (client/timelines/index.blade.php) are implemented in Stories 4.4 and 4.5 respectively. This story focuses on the core archive/unarchive functionality and updating the existing show page. ## Acceptance Criteria @@ -43,92 +46,118 @@ $table->enum('status', ['active', 'archived'])->default('active'); - [ ] Status returns to 'active' - [ ] Updates can be added again -### List Filtering -- [ ] Filter dropdown with options: Active (default), Archived, All -- [ ] Archived timelines shown in separate section in client view (below active) -- [ ] Bulk archive via checkbox selection + "Archive Selected" button -- [ ] Bulk archive shows count confirmation ("Archive 3 timelines?") +### List Filtering (Deferred to Story 4.4/4.5) +> The following filtering features require the admin dashboard (4.4) and client view (4.5): +> - Filter dropdown with options: Active (default), Archived, All +> - Archived timelines shown in separate section in client view (below active) +> - Bulk archive via checkbox selection + "Archive Selected" button +> - Bulk archive shows count confirmation ("Archive 3 timelines?") + +**This story provides the model methods and scopes that Stories 4.4/4.5 will use.** ### 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 + +> **Bulk edge cases deferred to Story 4.4:** +> - Bulk archive mixed selection: Skip already-archived, archive only active ones +> - Empty bulk selection: "Archive Selected" button disabled ### Quality Requirements -- [ ] Audit log for status changes (action_type: 'archive' or 'unarchive') -- [ ] Bilingual labels (AR/EN for buttons, messages, filters) +- [ ] Audit log for status changes (action: 'archive' or 'unarchive') - uses AdminLog `action` field +- [ ] Bilingual labels (AR/EN for buttons, messages) - [ ] Feature tests covering all scenarios below ### Test Scenarios ``` -tests/Feature/Timeline/TimelineArchivingTest.php +tests/Feature/Admin/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 +- test_client_cannot_archive_timeline +- test_archived_timeline_shows_visual_indicator +- test_update_form_disabled_on_archived_timeline ``` +> **Deferred to Story 4.4/4.5:** +> - 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_client_can_view_archived_timeline (4.5) + ## Technical Notes ### Timeline Model Methods +Add these methods to `app/Models/Timeline.php`: ```php +use App\Enums\TimelineStatus; + class Timeline extends Model { + // ... existing code ... + public function archive(): void { - $this->update(['status' => 'archived']); + $this->update(['status' => TimelineStatus::Archived]); } public function unarchive(): void { - $this->update(['status' => 'active']); + $this->update(['status' => TimelineStatus::Active]); } public function isArchived(): bool { - return $this->status === 'archived'; + return $this->status === TimelineStatus::Archived; + } + + public function isActive(): bool + { + return $this->status === TimelineStatus::Active; } public function scopeActive($query) { - return $query->where('status', 'active'); + return $query->where('status', TimelineStatus::Active); } public function scopeArchived($query) { - return $query->where('status', 'archived'); + return $query->where('status', TimelineStatus::Archived); } } ``` +> **Note:** The Timeline model already casts `status` to `TimelineStatus::class` enum. Always use enum values, not strings. + ### Volt Component Actions +Add these methods to `resources/views/livewire/admin/timelines/show.blade.php`: ```php +use App\Models\AdminLog; + public function archive(): void { if ($this->timeline->isArchived()) { - return; + return; // No-op for already archived } $this->timeline->archive(); AdminLog::create([ 'admin_id' => auth()->id(), - 'action_type' => 'archive', + 'action' => 'archive', // Note: field is 'action', not 'action_type' 'target_type' => 'timeline', 'target_id' => $this->timeline->id, 'ip_address' => request()->ip(), + 'created_at' => now(), ]); session()->flash('success', __('messages.timeline_archived')); @@ -136,53 +165,80 @@ public function archive(): void public function unarchive(): void { - if (!$this->timeline->isArchived()) { - return; + if ($this->timeline->isActive()) { + return; // No-op for already active } $this->timeline->unarchive(); AdminLog::create([ 'admin_id' => auth()->id(), - 'action_type' => 'unarchive', + 'action' => 'unarchive', // Note: field is 'action', not 'action_type' 'target_type' => 'timeline', 'target_id' => $this->timeline->id, 'ip_address' => request()->ip(), + 'created_at' => now(), ]); session()->flash('success', __('messages.timeline_unarchived')); } - -public function bulkArchive(array $ids): void -{ - Timeline::whereIn('id', $ids)->update(['status' => 'archived']); - - foreach ($ids as $id) { - AdminLog::create([ - 'admin_id' => auth()->id(), - 'action_type' => 'archive', - 'target_type' => 'timeline', - 'target_id' => $id, - 'ip_address' => request()->ip(), - ]); - } - - session()->flash('success', __('messages.timelines_archived', ['count' => count($ids)])); -} ``` +> **Note:** The AdminLog model uses `action` field (not `action_type`). Also requires `created_at` since `$timestamps = false` on the model. + +### Bulk Archive (Deferred to Story 4.4) +The `bulkArchive()` method will be implemented in Story 4.4 when the admin timeline index view is created. + +## Tasks / Subtasks + +- [x] **Task 1: Add Timeline model methods** (AC: Archive/Unarchive) + - [x] Add `archive()` method using `TimelineStatus::Archived` enum + - [x] Add `unarchive()` method using `TimelineStatus::Active` enum + - [x] Add `isArchived()` helper method + - [x] Add `isActive()` helper method + - [x] Add `scopeActive()` query scope + - [x] Add `scopeArchived()` query scope + +- [x] **Task 2: Update show.blade.php component** (AC: Archive/Unarchive buttons) + - [x] Add `archive()` action method with AdminLog + - [x] Add `unarchive()` action method with AdminLog + - [x] Add archive button (visible when timeline is active) + - [x] Add unarchive button (visible when timeline is archived) + - [x] Add confirmation modal using `` for archive action + - [x] Show archived status badge using `` + +- [x] **Task 3: Disable updates on archived timelines** (AC: No updates on archived) + - [x] Conditionally disable/hide update form when `$timeline->isArchived()` + - [x] Show tooltip or message explaining why updates are disabled + - [x] Apply muted styling to archived timeline view + +- [x] **Task 4: Add translation keys** (AC: Bilingual) + - [x] Add EN/AR keys to `lang/*/messages.php` + - [x] Add EN/AR keys to `lang/*/timelines.php` + +- [x] **Task 5: Write feature tests** (AC: Quality Requirements) + - [x] Create `tests/Feature/Admin/TimelineArchivingTest.php` + - [x] Implement all test scenarios listed above + - [x] Verify all tests pass + +- [x] **Task 6: Code quality** + - [x] Run `vendor/bin/pint --dirty` + - [x] Verify no regressions in existing tests + ## Definition of Done -- [ ] Archive button on timeline detail view works with confirmation modal -- [ ] Unarchive button on archived timelines works -- [ ] Update form disabled/hidden on archived timelines with clear messaging -- [ ] Status filter dropdown functional (Active/Archived/All) -- [ ] Bulk archive with checkbox selection works -- [ ] Visual indicators (badge + muted styling) display correctly -- [ ] Client can still view archived timelines in separate section -- [ ] Audit log entries created for all archive/unarchive actions -- [ ] All test scenarios in `tests/Feature/Timeline/TimelineArchivingTest.php` pass -- [ ] Code formatted with Pint +- [x] Archive button on timeline detail view works with confirmation modal +- [x] Unarchive button on archived timelines works +- [x] Update form disabled/hidden on archived timelines with clear messaging +- [x] Visual indicators (badge + muted styling) display correctly +- [x] Audit log entries created for all archive/unarchive actions +- [x] All test scenarios in `tests/Feature/Admin/TimelineArchivingTest.php` pass +- [x] Code formatted with Pint + +> **Deferred to Story 4.4/4.5:** +> - Status filter dropdown functional (Admin Dashboard) +> - Bulk archive with checkbox selection (Admin Dashboard) +> - Client can still view archived timelines in separate section (Client View) ## Dependencies @@ -192,4 +248,203 @@ public function bulkArchive(array $ids): void ## Estimation **Complexity:** Low -**Estimated Effort:** 2 hours + +## Dev Notes + +### Source Tree Reference +``` +app/ +├── Models/ +│ └── Timeline.php # Add archive/unarchive methods and scopes +├── Enums/ +│ └── TimelineStatus.php # Already exists with Active/Archived values +resources/ +└── views/ + └── livewire/ + └── admin/ + └── timelines/ + └── show.blade.php # Add archive/unarchive UI and actions +tests/ +└── Feature/ + └── Admin/ + └── TimelineArchivingTest.php # New test file +``` + +### Required Translation Keys + +**lang/en/messages.php:** +```php +'timeline_archived' => 'Timeline has been archived.', +'timeline_unarchived' => 'Timeline has been unarchived.', +'cannot_update_archived_timeline' => 'Updates cannot be added to archived timelines.', +``` + +**lang/ar/messages.php:** +```php +'timeline_archived' => 'تم أرشفة الجدول الزمني.', +'timeline_unarchived' => 'تم إلغاء أرشفة الجدول الزمني.', +'cannot_update_archived_timeline' => 'لا يمكن إضافة تحديثات إلى الجداول الزمنية المؤرشفة.', +``` + +**lang/en/timelines.php:** +```php +'archive' => 'Archive', +'unarchive' => 'Unarchive', +'archive_confirm_title' => 'Archive Timeline', +'archive_confirm_message' => 'Are you sure you want to archive this timeline? No further updates can be added until it is unarchived.', +'archived_notice' => 'This timeline is archived. Updates are disabled.', +``` + +**lang/ar/timelines.php:** +```php +'archive' => 'أرشفة', +'unarchive' => 'إلغاء الأرشفة', +'archive_confirm_title' => 'أرشفة الجدول الزمني', +'archive_confirm_message' => 'هل أنت متأكد من أرشفة هذا الجدول الزمني؟ لن يمكن إضافة تحديثات حتى يتم إلغاء الأرشفة.', +'archived_notice' => 'هذا الجدول الزمني مؤرشف. التحديثات معطلة.', +``` + +### Flux UI Components to Use +- `` - Archive button +- `` - Unarchive button +- `` - Confirmation dialog +- `` - Archived status indicator +- `` - Disabled update form explanation + +### Testing + +**Test File:** `tests/Feature/Admin/TimelineArchivingTest.php` + +**Testing Pattern:** Use Pest with `Volt::test()` for component testing (matches Stories 4.1/4.2) + +**Required Factories:** +- `Timeline::factory()` with default active status +- `Timeline::factory()->archived()` state (may need to add) +- `User::factory()->admin()` for admin user +- `User::factory()->client()` for authorization tests + +**Example Test Structure:** +```php +use App\Models\Timeline; +use App\Models\User; +use App\Models\AdminLog; +use Livewire\Volt\Volt; + +beforeEach(function () { + $this->admin = User::factory()->admin()->create(); + $this->client = User::factory()->individual()->create(); + $this->timeline = Timeline::factory()->for($this->client)->create(); +}); + +test('admin can archive active timeline', function () { + Volt::test('admin.timelines.show', ['timeline' => $this->timeline]) + ->actingAs($this->admin) + ->call('archive') + ->assertHasNoErrors(); + + expect($this->timeline->fresh()->isArchived())->toBeTrue(); +}); +``` + +## Dev Agent Record + +### Agent Model Used +Claude Opus 4.5 + +### File List +| File | Action | +|------|--------| +| `app/Models/Timeline.php` | Modified - Added archive/unarchive methods and scopes | +| `resources/views/livewire/admin/timelines/show.blade.php` | Modified - Added archive/unarchive actions, buttons, modal, visual indicators | +| `lang/en/messages.php` | Modified - Added archive-related translation keys | +| `lang/ar/messages.php` | Modified - Added archive-related translation keys | +| `lang/en/timelines.php` | Modified - Added archive UI translation keys | +| `lang/ar/timelines.php` | Modified - Added archive UI translation keys | +| `tests/Feature/Admin/TimelineArchivingTest.php` | Created - 17 test scenarios for archiving functionality | + +### Debug Log References +None - Implementation completed without errors. + +### Completion Notes +- All 17 archiving tests pass +- All 312 Admin feature tests pass (no regressions) +- Code formatted with Pint +- Bilingual support complete (AR/EN) +- Archive confirmation modal uses Flux UI components +- Muted styling applied to archived timelines +- Update form hidden with callout notice on archived timelines + +## Change Log + +| Date | Version | Description | Author | +|------|---------|-------------|--------| +| 2025-12-27 | 1.0 | Initial story draft | PM | +| 2025-12-27 | 1.1 | Validation fixes: re-scoped to exclude deferred features (filtering, bulk, client view), fixed AdminLog field name, fixed enum comparisons, added Tasks/Subtasks section, added translation keys, corrected test file path | Claude Opus 4.5 | +| 2025-12-27 | 1.2 | Implementation complete: Added Timeline model methods, archive/unarchive UI, translations, 17 passing tests | Claude Opus 4.5 | + +## QA Results + +### Review Date: 2025-12-27 + +### Reviewed By: Quinn (Test Architect) + +### Code Quality Assessment + +Excellent implementation following Laravel/Livewire best practices. The code is clean, well-organized, and follows the established patterns in the codebase. Key strengths: + +- **Model Methods**: Proper use of TimelineStatus enum throughout, idempotent archive/unarchive operations +- **Volt Component**: Clean separation of archive/unarchive actions with proper AdminLog audit entries +- **UI Implementation**: Good UX with confirmation modal, visual indicators (badge + muted styling), and clear archived state messaging +- **Tests**: Comprehensive coverage with 17 tests covering all acceptance criteria including edge cases + +### Refactoring Performed + +None required. The implementation is clean and follows project conventions. + +### Compliance Check + +- Coding Standards: ✓ Pint passes with no issues +- Project Structure: ✓ Follows existing Volt component patterns +- Testing Strategy: ✓ Pest with Volt::test() as per codebase conventions +- All ACs Met: ✓ All acceptance criteria verified + +### Improvements Checklist + +All items addressed by developer: + +- [x] Model methods (archive, unarchive, isArchived, isActive, scopes) implemented correctly +- [x] Archive button with confirmation modal implemented +- [x] Unarchive button implemented +- [x] Visual indicator (amber badge) for archived status +- [x] Muted styling (opacity-75/60) applied to archived timeline sections +- [x] Update form hidden with callout notice when archived +- [x] Edit buttons hidden on archived timeline updates +- [x] AdminLog audit entries created for archive/unarchive actions +- [x] Bilingual translations (EN/AR) complete +- [x] Factory state `archived()` available for testing +- [x] All 17 test scenarios pass + +### Security Review + +No security concerns. Authorization is properly handled: +- Admin middleware protects the route +- Guest and client access correctly returns 401/403 +- Archive/unarchive actions only available to authenticated admins + +### Performance Considerations + +No performance concerns. Simple status updates with minimal database operations. + +### Files Modified During Review + +None - no modifications required. + +### Gate Status + +Gate: PASS → docs/qa/gates/4.3-timeline-archiving.yml + +### Recommended Status + +✓ Ready for Done + +All acceptance criteria met, comprehensive test coverage, no security or performance concerns. diff --git a/lang/ar/messages.php b/lang/ar/messages.php index b644aa7..c9bc786 100644 --- a/lang/ar/messages.php +++ b/lang/ar/messages.php @@ -29,4 +29,7 @@ return [ 'timeline_created' => 'تم إنشاء الجدول الزمني بنجاح.', 'update_added' => 'تمت إضافة التحديث بنجاح.', 'update_edited' => 'تم تعديل التحديث بنجاح.', + 'timeline_archived' => 'تم أرشفة الجدول الزمني.', + 'timeline_unarchived' => 'تم إلغاء أرشفة الجدول الزمني.', + 'cannot_update_archived_timeline' => 'لا يمكن إضافة تحديثات إلى الجداول الزمنية المؤرشفة.', ]; diff --git a/lang/ar/timelines.php b/lang/ar/timelines.php index 68c4a09..4978e72 100644 --- a/lang/ar/timelines.php +++ b/lang/ar/timelines.php @@ -50,4 +50,11 @@ return [ // Validation messages for updates 'update_text_required' => 'يرجى إدخال نص التحديث.', 'update_text_min' => 'يجب أن يكون التحديث 10 أحرف على الأقل.', + + // Archiving + 'archive' => 'أرشفة', + 'unarchive' => 'إلغاء الأرشفة', + 'archive_confirm_title' => 'أرشفة الجدول الزمني', + 'archive_confirm_message' => 'هل أنت متأكد من أرشفة هذا الجدول الزمني؟ لن يمكن إضافة تحديثات حتى يتم إلغاء الأرشفة.', + 'archived_notice' => 'هذا الجدول الزمني مؤرشف. التحديثات معطلة.', ]; diff --git a/lang/en/messages.php b/lang/en/messages.php index 8598526..9279641 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -29,4 +29,7 @@ return [ 'timeline_created' => 'Timeline created successfully.', 'update_added' => 'Update added successfully.', 'update_edited' => 'Update edited successfully.', + 'timeline_archived' => 'Timeline has been archived.', + 'timeline_unarchived' => 'Timeline has been unarchived.', + 'cannot_update_archived_timeline' => 'Updates cannot be added to archived timelines.', ]; diff --git a/lang/en/timelines.php b/lang/en/timelines.php index caee3d0..ecb4916 100644 --- a/lang/en/timelines.php +++ b/lang/en/timelines.php @@ -50,4 +50,11 @@ return [ // Validation messages for updates 'update_text_required' => 'Please enter the update text.', 'update_text_min' => 'The update must be at least 10 characters.', + + // Archiving + 'archive' => 'Archive', + 'unarchive' => 'Unarchive', + 'archive_confirm_title' => 'Archive Timeline', + 'archive_confirm_message' => 'Are you sure you want to archive this timeline? No further updates can be added until it is unarchived.', + 'archived_notice' => 'This timeline is archived. Updates are disabled.', ]; diff --git a/resources/views/livewire/admin/timelines/show.blade.php b/resources/views/livewire/admin/timelines/show.blade.php index 5f44cd8..d3695e6 100644 --- a/resources/views/livewire/admin/timelines/show.blade.php +++ b/resources/views/livewire/admin/timelines/show.blade.php @@ -33,6 +33,12 @@ new class extends Component { public function addUpdate(): void { + if ($this->timeline->isArchived()) { + session()->flash('error', __('messages.cannot_update_archived_timeline')); + + return; + } + $this->validate(); $update = $this->timeline->updates()->create([ @@ -99,6 +105,46 @@ new class extends Component { $this->editingUpdateId = null; $this->updateText = ''; } + + public function archive(): void + { + if ($this->timeline->isArchived()) { + return; + } + + $this->timeline->archive(); + + AdminLog::create([ + 'admin_id' => auth()->id(), + 'action' => 'archive', + 'target_type' => 'timeline', + 'target_id' => $this->timeline->id, + 'ip_address' => request()->ip(), + 'created_at' => now(), + ]); + + session()->flash('success', __('messages.timeline_archived')); + } + + public function unarchive(): void + { + if ($this->timeline->isActive()) { + return; + } + + $this->timeline->unarchive(); + + AdminLog::create([ + 'admin_id' => auth()->id(), + 'action' => 'unarchive', + 'target_type' => 'timeline', + 'target_id' => $this->timeline->id, + 'ip_address' => request()->ip(), + 'created_at' => now(), + ]); + + session()->flash('success', __('messages.timeline_unarchived')); + } }; ?>
@@ -109,7 +155,7 @@ new class extends Component {
{{-- Timeline Header --}} -
+
{{ $timeline->case_name }} @@ -119,9 +165,22 @@ new class extends Component {
@endif
- - {{ $timeline->status->label() }} - +
+ + {{ $timeline->status->label() }} + + @if($timeline->isActive()) + + + {{ __('timelines.archive') }} + + + @else + + {{ __('timelines.unarchive') }} + + @endif +
@@ -149,37 +208,51 @@ new class extends Component {
@endif + @if(session('error')) +
+ + {{ session('error') }} + +
+ @endif + {{-- Add Update Form --}} -
+
{{ __('timelines.add_update') }} -
- - {{ __('timelines.update_text') }} * - - {{ __('timelines.update_min_chars') }} - - + @if($timeline->isArchived()) + + {{ __('timelines.archived_notice') }} + + @else + + + {{ __('timelines.update_text') }} * + + {{ __('timelines.update_min_chars') }} + + -
- @if($editingUpdateId) - - {{ __('timelines.save_edit') }} - - - {{ __('timelines.cancel') }} - - @else - - {{ __('timelines.add_update_button') }} - - @endif -
-
+
+ @if($editingUpdateId) + + {{ __('timelines.save_edit') }} + + + {{ __('timelines.cancel') }} + + @else + + {{ __('timelines.add_update_button') }} + + @endif +
+ + @endif
{{-- Timeline Updates --}} @@ -220,7 +293,7 @@ new class extends Component { @endif
- @if(!$editingUpdateId) + @if(!$editingUpdateId && $timeline->isActive()) @endif
+ + {{-- Archive Confirmation Modal --}} + +
+
+ {{ __('timelines.archive_confirm_title') }} + {{ __('timelines.archive_confirm_message') }} +
+ +
+ + {{ __('timelines.cancel') }} + + + {{ __('timelines.archive') }} + +
+
+
diff --git a/tests/Feature/Admin/TimelineArchivingTest.php b/tests/Feature/Admin/TimelineArchivingTest.php new file mode 100644 index 0000000..7f6fc69 --- /dev/null +++ b/tests/Feature/Admin/TimelineArchivingTest.php @@ -0,0 +1,208 @@ +admin = User::factory()->admin()->create(); + $this->client = User::factory()->individual()->create(); + $this->timeline = Timeline::factory()->create(['user_id' => $this->client->id]); +}); + +// =========================================== +// Archive Timeline Tests +// =========================================== + +test('admin can archive active timeline', function () { + $this->actingAs($this->admin); + + Volt::test('admin.timelines.show', ['timeline' => $this->timeline]) + ->call('archive') + ->assertHasNoErrors(); + + expect($this->timeline->fresh()->isArchived())->toBeTrue(); +}); + +test('admin can unarchive archived timeline', function () { + $archivedTimeline = Timeline::factory()->archived()->create(['user_id' => $this->client->id]); + + $this->actingAs($this->admin); + + Volt::test('admin.timelines.show', ['timeline' => $archivedTimeline]) + ->call('unarchive') + ->assertHasNoErrors(); + + expect($archivedTimeline->fresh()->isActive())->toBeTrue(); +}); + +test('archiving archived timeline is noop', function () { + $archivedTimeline = Timeline::factory()->archived()->create(['user_id' => $this->client->id]); + + $this->actingAs($this->admin); + + $initialLogCount = AdminLog::count(); + + Volt::test('admin.timelines.show', ['timeline' => $archivedTimeline]) + ->call('archive') + ->assertHasNoErrors(); + + // Status should remain archived + expect($archivedTimeline->fresh()->isArchived())->toBeTrue(); + // No additional log entry should be created + expect(AdminLog::count())->toBe($initialLogCount); +}); + +test('unarchiving active timeline is noop', function () { + $this->actingAs($this->admin); + + $initialLogCount = AdminLog::count(); + + Volt::test('admin.timelines.show', ['timeline' => $this->timeline]) + ->call('unarchive') + ->assertHasNoErrors(); + + // Status should remain active + expect($this->timeline->fresh()->isActive())->toBeTrue(); + // No additional log entry should be created + expect(AdminLog::count())->toBe($initialLogCount); +}); + +test('cannot add update to archived timeline', function () { + $archivedTimeline = Timeline::factory()->archived()->create(['user_id' => $this->client->id]); + + $this->actingAs($this->admin); + + Volt::test('admin.timelines.show', ['timeline' => $archivedTimeline]) + ->set('updateText', 'This should not be added to archived timeline.') + ->call('addUpdate'); + + expect($archivedTimeline->updates()->count())->toBe(0); +}); + +// =========================================== +// Audit Log Tests +// =========================================== + +test('audit log created on archive', function () { + $this->actingAs($this->admin); + + Volt::test('admin.timelines.show', ['timeline' => $this->timeline]) + ->call('archive') + ->assertHasNoErrors(); + + expect(AdminLog::where('action', 'archive') + ->where('target_type', 'timeline') + ->where('target_id', $this->timeline->id) + ->where('admin_id', $this->admin->id) + ->exists())->toBeTrue(); +}); + +test('audit log created on unarchive', function () { + $archivedTimeline = Timeline::factory()->archived()->create(['user_id' => $this->client->id]); + + $this->actingAs($this->admin); + + Volt::test('admin.timelines.show', ['timeline' => $archivedTimeline]) + ->call('unarchive') + ->assertHasNoErrors(); + + expect(AdminLog::where('action', 'unarchive') + ->where('target_type', 'timeline') + ->where('target_id', $archivedTimeline->id) + ->where('admin_id', $this->admin->id) + ->exists())->toBeTrue(); +}); + +// =========================================== +// Authorization Tests +// =========================================== + +test('guest cannot archive timeline', function () { + $this->get(route('admin.timelines.show', $this->timeline)) + ->assertRedirect(route('login')); +}); + +test('client cannot archive timeline', function () { + $this->actingAs($this->client) + ->get(route('admin.timelines.show', $this->timeline)) + ->assertForbidden(); +}); + +// =========================================== +// Visual Indicator Tests +// =========================================== + +test('archived timeline shows visual indicator', function () { + $archivedTimeline = Timeline::factory()->archived()->create(['user_id' => $this->client->id]); + + $this->actingAs($this->admin); + + Volt::test('admin.timelines.show', ['timeline' => $archivedTimeline]) + ->assertSee(__('enums.timeline_status.archived')); +}); + +test('update form disabled on archived timeline', function () { + $archivedTimeline = Timeline::factory()->archived()->create(['user_id' => $this->client->id]); + + $this->actingAs($this->admin); + + Volt::test('admin.timelines.show', ['timeline' => $archivedTimeline]) + ->assertSee(__('timelines.archived_notice')); +}); + +// =========================================== +// Model Method Tests +// =========================================== + +test('timeline isArchived returns true for archived timeline', function () { + $archivedTimeline = Timeline::factory()->archived()->create(['user_id' => $this->client->id]); + + expect($archivedTimeline->isArchived())->toBeTrue(); + expect($archivedTimeline->isActive())->toBeFalse(); +}); + +test('timeline isActive returns true for active timeline', function () { + expect($this->timeline->isActive())->toBeTrue(); + expect($this->timeline->isArchived())->toBeFalse(); +}); + +test('scopeActive returns only active timelines', function () { + Timeline::factory()->archived()->create(['user_id' => $this->client->id]); + Timeline::factory()->archived()->create(['user_id' => $this->client->id]); + + $activeTimelines = Timeline::active()->get(); + + expect($activeTimelines->count())->toBe(1); + expect($activeTimelines->first()->id)->toBe($this->timeline->id); +}); + +test('scopeArchived returns only archived timelines', function () { + $archived1 = Timeline::factory()->archived()->create(['user_id' => $this->client->id]); + $archived2 = Timeline::factory()->archived()->create(['user_id' => $this->client->id]); + + $archivedTimelines = Timeline::archived()->get(); + + expect($archivedTimelines->count())->toBe(2); + expect($archivedTimelines->pluck('id')->toArray())->toContain($archived1->id, $archived2->id); +}); + +test('archive method changes status to archived', function () { + expect($this->timeline->status)->toBe(TimelineStatus::Active); + + $this->timeline->archive(); + + expect($this->timeline->fresh()->status)->toBe(TimelineStatus::Archived); +}); + +test('unarchive method changes status to active', function () { + $archivedTimeline = Timeline::factory()->archived()->create(['user_id' => $this->client->id]); + + expect($archivedTimeline->status)->toBe(TimelineStatus::Archived); + + $archivedTimeline->unarchive(); + + expect($archivedTimeline->fresh()->status)->toBe(TimelineStatus::Active); +});