cmplete story 4.1 with qa tests
This commit is contained in:
parent
f28ee5624d
commit
e669e97ca1
|
|
@ -0,0 +1,53 @@
|
|||
# Quality Gate: Story 4.1 - Timeline Creation
|
||||
# Generated by Quinn (Test Architect) on 2025-12-27
|
||||
|
||||
schema: 1
|
||||
story: "4.1"
|
||||
story_title: "Timeline Creation"
|
||||
gate: PASS
|
||||
status_reason: "All acceptance criteria met with comprehensive test coverage (22 tests). Clean implementation following established patterns. No security or performance concerns."
|
||||
reviewer: "Quinn (Test Architect)"
|
||||
updated: "2025-12-27T00:00:00Z"
|
||||
|
||||
waiver: { active: false }
|
||||
|
||||
top_issues: []
|
||||
|
||||
risk_summary:
|
||||
totals: { critical: 0, high: 0, medium: 0, low: 0 }
|
||||
recommendations:
|
||||
must_fix: []
|
||||
monitor: []
|
||||
|
||||
quality_score: 100
|
||||
expires: "2026-01-10T00:00:00Z"
|
||||
|
||||
evidence:
|
||||
tests_reviewed: 22
|
||||
tests_passing: 22
|
||||
risks_identified: 0
|
||||
trace:
|
||||
ac_covered: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
|
||||
ac_gaps: []
|
||||
|
||||
nfr_validation:
|
||||
security:
|
||||
status: PASS
|
||||
notes: "Admin middleware protects route. Validation on all inputs. Parameterized queries via Eloquent. CSRF handled by Livewire."
|
||||
performance:
|
||||
status: PASS
|
||||
notes: "Search limited to 10 results. 300ms debounce on input. 2-char minimum threshold."
|
||||
reliability:
|
||||
status: PASS
|
||||
notes: "Proper error handling via Livewire validation. Flash messages for user feedback."
|
||||
maintainability:
|
||||
status: PASS
|
||||
notes: "Clean class-based Volt component. Follows existing admin CRUD patterns. Well-structured translations."
|
||||
|
||||
recommendations:
|
||||
immediate: []
|
||||
future:
|
||||
- action: "Consider adding loading state indicator during form submission"
|
||||
refs: ["resources/views/livewire/admin/timelines/create.blade.php"]
|
||||
- action: "Consider pagination if client list could exceed 100+ records"
|
||||
refs: ["resources/views/livewire/admin/timelines/create.blade.php:42-57"]
|
||||
|
|
@ -208,44 +208,44 @@ new class extends Component {
|
|||
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
|
||||
- [x] `test_admin_can_view_timeline_creation_form` - Admin can access /admin/timelines/create
|
||||
- [x] `test_admin_can_search_clients_by_name` - Search returns matching users
|
||||
- [x] `test_admin_can_search_clients_by_email` - Search returns matching users
|
||||
- [x] `test_admin_can_create_timeline_with_required_fields` - Timeline created with case_name only
|
||||
- [x] `test_admin_can_create_timeline_with_case_reference` - Timeline created with optional reference
|
||||
- [x] `test_initial_notes_creates_first_timeline_update` - TimelineUpdate record created
|
||||
- [x] `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
|
||||
- [x] `test_case_name_is_required` - Validation error without case_name
|
||||
- [x] `test_case_reference_must_be_unique` - Validation error on duplicate reference
|
||||
- [x] `test_case_reference_allows_multiple_nulls` - Multiple timelines without reference allowed
|
||||
- [x] `test_client_selection_is_required` - Validation error without selecting client
|
||||
- [x] `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
|
||||
- [x] `test_non_admin_cannot_access_timeline_creation` - Redirect or 403 for non-admin users
|
||||
- [x] `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
|
||||
- [x] `test_search_only_returns_individual_and_company_users` - Admin users not in results
|
||||
- [x] `test_search_only_returns_active_users` - Deactivated users not in results
|
||||
- [x] `test_can_create_multiple_timelines_for_same_client` - No unique constraint on user_id
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] 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
|
||||
- [ ] Timeline created with correct data
|
||||
- [ ] Initial notes saved as first update
|
||||
- [ ] Unique reference validation works
|
||||
- [ ] Client can view timeline immediately (verified via client dashboard)
|
||||
- [ ] Audit log created
|
||||
- [ ] All translation keys added (AR/EN)
|
||||
- [ ] All tests pass
|
||||
- [ ] Code formatted with Pint
|
||||
- [x] Volt component created at `resources/views/livewire/admin/timelines/create.blade.php`
|
||||
- [x] Route registered for admin timeline creation
|
||||
- [x] Can search and select client (individual/company only)
|
||||
- [x] Can enter case name and reference
|
||||
- [x] Timeline created with correct data
|
||||
- [x] Initial notes saved as first update
|
||||
- [x] Unique reference validation works
|
||||
- [ ] Client can view timeline immediately (verified via client dashboard) - *Requires future story for client timeline view*
|
||||
- [x] Audit log created
|
||||
- [x] All translation keys added (AR/EN)
|
||||
- [x] All tests pass
|
||||
- [x] Code formatted with Pint
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
|
@ -256,3 +256,138 @@ All tests should use Pest and be placed in `tests/Feature/Admin/TimelineCreation
|
|||
## Estimation
|
||||
|
||||
**Complexity:** Low-Medium
|
||||
|
||||
---
|
||||
|
||||
## QA Results
|
||||
|
||||
### Review Date: 2025-12-27
|
||||
|
||||
### Reviewed By: Quinn (Test Architect)
|
||||
|
||||
### Code Quality Assessment
|
||||
|
||||
**Overall: Excellent implementation.** The timeline creation feature is well-structured, follows established codebase patterns, and demonstrates strong adherence to Laravel/Livewire best practices. The code is clean, maintainable, and properly tested.
|
||||
|
||||
**Strengths:**
|
||||
- Class-based Volt component pattern matches existing admin components exactly
|
||||
- Proper use of User model scopes (`clients()`, `active()`) instead of raw queries
|
||||
- Correct implementation of AdminLog with existing field names (`action` vs story spec's `action_type`)
|
||||
- Flux UI components used consistently throughout
|
||||
- Bilingual support complete with both EN/AR translation files
|
||||
- Comprehensive test coverage (22 tests) covering happy paths, validation, authorization, and edge cases
|
||||
|
||||
### Refactoring Performed
|
||||
|
||||
None required. The implementation is clean and follows established patterns.
|
||||
|
||||
### Compliance Check
|
||||
|
||||
- Coding Standards: ✓ Follows class-based Volt pattern, uses Flux UI, proper validation
|
||||
- Project Structure: ✓ Files placed in correct locations matching existing structure
|
||||
- Testing Strategy: ✓ Pest tests with Volt::test() pattern, comprehensive coverage
|
||||
- All ACs Met: ✓ All acceptance criteria fulfilled (see traceability below)
|
||||
|
||||
### Requirements Traceability (Given-When-Then)
|
||||
|
||||
| AC# | Acceptance Criteria | Test Coverage | Status |
|
||||
|-----|---------------------|---------------|--------|
|
||||
| 1 | Select client (search by name/email) - only individual/company users | `test_admin_can_search_clients_by_name`, `test_admin_can_search_clients_by_email`, `test_search_only_returns_individual_and_company_users` | ✓ |
|
||||
| 2 | Case name/title (required) | `test_case_name_is_required`, `test_admin_can_create_timeline_with_required_fields` | ✓ |
|
||||
| 3 | Case reference (optional, unique if provided) | `test_admin_can_create_timeline_with_case_reference`, `test_case_reference_must_be_unique`, `test_case_reference_allows_multiple_nulls` | ✓ |
|
||||
| 4 | Initial notes (optional) | `test_initial_notes_creates_first_timeline_update`, `test_timeline_without_initial_notes_has_no_updates` | ✓ |
|
||||
| 5 | Timeline assigned to selected client | `test_admin_can_create_timeline_with_required_fields` (verifies user_id) | ✓ |
|
||||
| 6 | Creation date automatically recorded | Timestamps handled by Eloquent | ✓ |
|
||||
| 7 | Status defaults to 'active' | `test_timeline_status_defaults_to_active` | ✓ |
|
||||
| 8 | Can create multiple timelines per client | `test_can_create_multiple_timelines_for_same_client` | ✓ |
|
||||
| 9 | Confirmation message on success | Component uses `session()->flash('success', ...)` | ✓ |
|
||||
| 10 | Client must exist validation | `test_selected_client_must_exist` | ✓ |
|
||||
| 11 | Audit log entry created | `test_audit_log_created_on_timeline_creation` | ✓ |
|
||||
| 12 | Bilingual labels/messages | EN/AR translation files complete | ✓ |
|
||||
| 13 | Authorization tests | `test_non_admin_cannot_access_timeline_creation`, `test_guest_cannot_access_timeline_creation` | ✓ |
|
||||
| 14 | Search only returns active users | `test_search_only_returns_active_users` | ✓ |
|
||||
|
||||
### Improvements Checklist
|
||||
|
||||
All items completed by the developer:
|
||||
|
||||
- [x] Volt component created with proper class-based pattern
|
||||
- [x] Client search implemented with debounce for performance
|
||||
- [x] Validation rules properly defined
|
||||
- [x] AdminLog integration working correctly
|
||||
- [x] Translation files complete for both languages
|
||||
- [x] All 22 tests passing
|
||||
- [x] Code formatted with Pint
|
||||
|
||||
### Security Review
|
||||
|
||||
**Status: PASS**
|
||||
|
||||
- Authorization: Admin middleware properly protects the route (`routes/web.php:83-85`)
|
||||
- Validation: All user inputs validated before database operations
|
||||
- SQL Injection: Uses Eloquent query builder with parameterized queries
|
||||
- CSRF: Handled by Livewire automatically
|
||||
- Mass Assignment: Only fillable attributes used
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
**Status: PASS**
|
||||
|
||||
- Client search uses `limit(10)` to prevent excessive results
|
||||
- Debounce (300ms) on search input reduces server requests
|
||||
- Minimum 2-character search threshold prevents over-querying
|
||||
- Eager loading not needed (no relationship hydration in search)
|
||||
|
||||
### Files Modified During Review
|
||||
|
||||
None. No refactoring was necessary.
|
||||
|
||||
### Gate Status
|
||||
|
||||
Gate: **PASS** → docs/qa/gates/4.1-timeline-creation.yml
|
||||
|
||||
### Recommended Status
|
||||
|
||||
✓ **Ready for Done**
|
||||
|
||||
All acceptance criteria have been met, tests pass, code follows established patterns, and no security or performance concerns were identified.
|
||||
|
||||
---
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Status
|
||||
**Ready for Review**
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### File List
|
||||
|
||||
**Created:**
|
||||
- `resources/views/livewire/admin/timelines/create.blade.php` - Volt component for timeline creation
|
||||
- `lang/en/timelines.php` - English translation keys for timelines
|
||||
- `lang/ar/timelines.php` - Arabic translation keys for timelines
|
||||
- `tests/Feature/Admin/TimelineCreationTest.php` - 22 Pest tests for timeline creation
|
||||
|
||||
**Modified:**
|
||||
- `routes/web.php` - Added admin.timelines.create route
|
||||
- `lang/en/messages.php` - Added timeline_created message
|
||||
- `lang/ar/messages.php` - Added timeline_created message (Arabic)
|
||||
|
||||
### Change Log
|
||||
1. Created Volt component with client search, case name/reference inputs, initial notes
|
||||
2. Implemented searchable client dropdown (individual/company users only, active only)
|
||||
3. Added validation rules (case name required, case reference unique)
|
||||
4. Created audit logging on timeline creation
|
||||
5. Initial notes saved as first TimelineUpdate if provided
|
||||
6. Added bilingual translation support (EN/AR)
|
||||
7. Registered route at `/admin/timelines/create`
|
||||
8. Created 22 comprehensive Pest tests covering all scenarios
|
||||
|
||||
### Completion Notes
|
||||
- Component path adjusted from `pages/admin/timelines/` to `admin/timelines/` to match existing codebase structure
|
||||
- Redirect after creation goes to `admin.dashboard` (not `admin.timelines.show`) since show route is in a future story
|
||||
- Used existing User model scopes (`clients()`, `active()`) instead of raw queries
|
||||
- AdminLog uses `action` field (not `action_type` as in story spec) to match existing model
|
||||
- One DoD item remains unchecked: "Client can view timeline immediately" requires client dashboard timeline view (future story)
|
||||
|
|
|
|||
|
|
@ -24,4 +24,7 @@ return [
|
|||
'not_paid_consultation' => 'هذه ليست استشارة مدفوعة.',
|
||||
'payment_already_received' => 'تم تحديد الدفع كمستلم مسبقاً.',
|
||||
'client_account_not_found' => 'حساب العميل غير موجود.',
|
||||
|
||||
// Timeline Management
|
||||
'timeline_created' => 'تم إنشاء الجدول الزمني بنجاح.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Page titles and navigation
|
||||
'timelines' => 'الجداول الزمنية',
|
||||
'create_timeline' => 'إنشاء جدول زمني',
|
||||
'back_to_timelines' => 'العودة إلى الجداول الزمنية',
|
||||
|
||||
// Form labels
|
||||
'case_name' => 'اسم القضية',
|
||||
'case_reference' => 'رقم مرجع القضية',
|
||||
'initial_notes' => 'ملاحظات أولية',
|
||||
'select_client' => 'اختر العميل',
|
||||
'search_client' => 'البحث بالاسم أو البريد الإلكتروني...',
|
||||
'selected_client' => 'العميل المحدد',
|
||||
|
||||
// Form actions
|
||||
'create' => 'إنشاء',
|
||||
'cancel' => 'إلغاء',
|
||||
|
||||
// Placeholders
|
||||
'case_name_placeholder' => 'أدخل اسم القضية',
|
||||
'case_reference_placeholder' => 'أدخل رقم مرجع القضية (اختياري)',
|
||||
'initial_notes_placeholder' => 'أضف ملاحظات أولية لهذا الجدول الزمني (اختياري)',
|
||||
|
||||
// Validation messages
|
||||
'case_reference_exists' => 'رقم مرجع القضية هذا مستخدم بالفعل.',
|
||||
'client_required' => 'يرجى اختيار عميل.',
|
||||
|
||||
// Search
|
||||
'no_clients_found' => 'لم يتم العثور على عملاء مطابقين لبحثك.',
|
||||
'type_to_search' => 'اكتب حرفين على الأقل للبحث...',
|
||||
];
|
||||
|
|
@ -24,4 +24,7 @@ return [
|
|||
'not_paid_consultation' => 'This is not a paid consultation.',
|
||||
'payment_already_received' => 'Payment has already been marked as received.',
|
||||
'client_account_not_found' => 'Client account not found.',
|
||||
|
||||
// Timeline Management
|
||||
'timeline_created' => 'Timeline created successfully.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Page titles and navigation
|
||||
'timelines' => 'Timelines',
|
||||
'create_timeline' => 'Create Timeline',
|
||||
'back_to_timelines' => 'Back to Timelines',
|
||||
|
||||
// Form labels
|
||||
'case_name' => 'Case Name',
|
||||
'case_reference' => 'Case Reference',
|
||||
'initial_notes' => 'Initial Notes',
|
||||
'select_client' => 'Select Client',
|
||||
'search_client' => 'Search by name or email...',
|
||||
'selected_client' => 'Selected Client',
|
||||
|
||||
// Form actions
|
||||
'create' => 'Create',
|
||||
'cancel' => 'Cancel',
|
||||
|
||||
// Placeholders
|
||||
'case_name_placeholder' => 'Enter case name',
|
||||
'case_reference_placeholder' => 'Enter case reference (optional)',
|
||||
'initial_notes_placeholder' => 'Add initial notes for this timeline (optional)',
|
||||
|
||||
// Validation messages
|
||||
'case_reference_exists' => 'This case reference is already in use.',
|
||||
'client_required' => 'Please select a client.',
|
||||
|
||||
// Search
|
||||
'no_clients_found' => 'No clients found matching your search.',
|
||||
'type_to_search' => 'Type at least 2 characters to search...',
|
||||
];
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\TimelineStatus;
|
||||
use App\Models\AdminLog;
|
||||
use App\Models\Timeline;
|
||||
use App\Models\User;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
public string $search = '';
|
||||
public ?int $selectedUserId = null;
|
||||
public ?User $selectedUser = null;
|
||||
public string $caseName = '';
|
||||
public string $caseReference = '';
|
||||
public string $initialNotes = '';
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'selectedUserId' => ['required', 'exists:users,id'],
|
||||
'caseName' => ['required', 'string', 'max:255'],
|
||||
'caseReference' => ['nullable', 'string', 'max:50', 'unique:timelines,case_reference'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'selectedUserId.required' => __('timelines.client_required'),
|
||||
'caseReference.unique' => __('timelines.case_reference_exists'),
|
||||
];
|
||||
}
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
if ($this->selectedUser && ! str_contains(strtolower($this->selectedUser->full_name), strtolower($this->search))) {
|
||||
$this->selectedUserId = null;
|
||||
$this->selectedUser = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getClientsProperty()
|
||||
{
|
||||
if (strlen($this->search) < 2) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return User::query()
|
||||
->clients()
|
||||
->active()
|
||||
->where(function ($query) {
|
||||
$query->where('full_name', 'like', "%{$this->search}%")
|
||||
->orWhere('email', 'like', "%{$this->search}%");
|
||||
})
|
||||
->limit(10)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function selectUser(int $userId): void
|
||||
{
|
||||
$this->selectedUserId = $userId;
|
||||
$this->selectedUser = User::find($userId);
|
||||
$this->search = $this->selectedUser->full_name;
|
||||
}
|
||||
|
||||
public function clearSelection(): void
|
||||
{
|
||||
$this->selectedUserId = null;
|
||||
$this->selectedUser = null;
|
||||
$this->search = '';
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$timeline = Timeline::create([
|
||||
'user_id' => $this->selectedUserId,
|
||||
'case_name' => $this->caseName,
|
||||
'case_reference' => $this->caseReference ?: null,
|
||||
'status' => TimelineStatus::Active,
|
||||
]);
|
||||
|
||||
if ($this->initialNotes) {
|
||||
$timeline->updates()->create([
|
||||
'admin_id' => auth()->id(),
|
||||
'update_text' => $this->initialNotes,
|
||||
]);
|
||||
}
|
||||
|
||||
AdminLog::create([
|
||||
'admin_id' => auth()->id(),
|
||||
'action' => 'create',
|
||||
'target_type' => 'timeline',
|
||||
'target_id' => $timeline->id,
|
||||
'new_values' => $timeline->toArray(),
|
||||
'ip_address' => request()->ip(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
session()->flash('success', __('messages.timeline_created'));
|
||||
|
||||
$this->redirect(route('admin.dashboard'), navigate: true);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<flux:button variant="ghost" :href="route('admin.dashboard')" wire:navigate icon="arrow-left">
|
||||
{{ __('timelines.back_to_timelines') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<flux:heading size="xl">{{ __('timelines.create_timeline') }}</flux:heading>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<form wire:submit="create" class="space-y-6">
|
||||
{{-- Client Selection --}}
|
||||
<flux:field>
|
||||
<flux:label>{{ __('timelines.select_client') }} *</flux:label>
|
||||
|
||||
@if($selectedUser)
|
||||
<div class="flex items-center gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:avatar size="sm" name="{{ $selectedUser->full_name }}" />
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $selectedUser->full_name }}</div>
|
||||
<div class="text-sm text-zinc-500 dark:text-zinc-400">{{ $selectedUser->email }}</div>
|
||||
</div>
|
||||
<flux:button variant="ghost" size="sm" wire:click="clearSelection" icon="x-mark" />
|
||||
</div>
|
||||
@else
|
||||
<div class="relative">
|
||||
<flux:input
|
||||
wire:model.live.debounce.300ms="search"
|
||||
type="text"
|
||||
placeholder="{{ __('timelines.search_client') }}"
|
||||
icon="magnifying-glass"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
@if(strlen($search) >= 2)
|
||||
<div class="absolute z-10 mt-1 w-full rounded-lg border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
|
||||
@forelse($this->clients as $client)
|
||||
<button
|
||||
type="button"
|
||||
wire:key="client-{{ $client->id }}"
|
||||
wire:click="selectUser({{ $client->id }})"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-zinc-50 dark:hover:bg-zinc-700 first:rounded-t-lg last:rounded-b-lg"
|
||||
>
|
||||
<flux:avatar size="sm" name="{{ $client->full_name }}" />
|
||||
<div>
|
||||
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $client->full_name }}</div>
|
||||
<div class="text-sm text-zinc-500 dark:text-zinc-400">{{ $client->email }}</div>
|
||||
</div>
|
||||
</button>
|
||||
@empty
|
||||
<div class="px-4 py-3 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{{ __('timelines.no_clients_found') }}
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@elseif(strlen($search) > 0 && strlen($search) < 2)
|
||||
<div class="absolute z-10 mt-1 w-full rounded-lg border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{{ __('timelines.type_to_search') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:error name="selectedUserId" />
|
||||
</flux:field>
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
{{-- Case Name --}}
|
||||
<flux:field>
|
||||
<flux:label>{{ __('timelines.case_name') }} *</flux:label>
|
||||
<flux:input
|
||||
wire:model="caseName"
|
||||
type="text"
|
||||
required
|
||||
placeholder="{{ __('timelines.case_name_placeholder') }}"
|
||||
/>
|
||||
<flux:error name="caseName" />
|
||||
</flux:field>
|
||||
|
||||
{{-- Case Reference --}}
|
||||
<flux:field>
|
||||
<flux:label>{{ __('timelines.case_reference') }} <span class="text-zinc-400">({{ __('common.optional') }})</span></flux:label>
|
||||
<flux:input
|
||||
wire:model="caseReference"
|
||||
type="text"
|
||||
placeholder="{{ __('timelines.case_reference_placeholder') }}"
|
||||
/>
|
||||
<flux:error name="caseReference" />
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
{{-- Initial Notes --}}
|
||||
<flux:field>
|
||||
<flux:label>{{ __('timelines.initial_notes') }} <span class="text-zinc-400">({{ __('common.optional') }})</span></flux:label>
|
||||
<flux:textarea
|
||||
wire:model="initialNotes"
|
||||
rows="4"
|
||||
placeholder="{{ __('timelines.initial_notes_placeholder') }}"
|
||||
/>
|
||||
<flux:error name="initialNotes" />
|
||||
</flux:field>
|
||||
|
||||
<div class="flex items-center justify-end gap-4 border-t border-zinc-200 pt-6 dark:border-zinc-700">
|
||||
<flux:button variant="ghost" :href="route('admin.dashboard')" wire:navigate>
|
||||
{{ __('timelines.cancel') }}
|
||||
</flux:button>
|
||||
<flux:button variant="primary" type="submit">
|
||||
{{ __('timelines.create') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -79,6 +79,11 @@ Route::middleware(['auth', 'active'])->group(function () {
|
|||
Volt::route('/clients/{user}/consultations', 'admin.clients.consultation-history')
|
||||
->name('admin.clients.consultation-history');
|
||||
|
||||
// Timelines Management
|
||||
Route::prefix('timelines')->name('admin.timelines.')->group(function () {
|
||||
Volt::route('/create', 'admin.timelines.create')->name('create');
|
||||
});
|
||||
|
||||
// Admin Settings
|
||||
Route::prefix('settings')->name('admin.settings.')->group(function () {
|
||||
Volt::route('/working-hours', 'admin.settings.working-hours')->name('working-hours');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,359 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\TimelineStatus;
|
||||
use App\Models\AdminLog;
|
||||
use App\Models\Timeline;
|
||||
use App\Models\User;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->admin = User::factory()->admin()->create();
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// Happy Path Tests
|
||||
// ===========================================
|
||||
|
||||
test('admin can view timeline creation form', function () {
|
||||
$this->actingAs($this->admin)
|
||||
->get(route('admin.timelines.create'))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('admin can search clients by name', function () {
|
||||
$client = User::factory()->individual()->create(['full_name' => 'John Doe']);
|
||||
User::factory()->individual()->create(['full_name' => 'Jane Smith']);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$component = Volt::test('admin.timelines.create')
|
||||
->set('search', 'John');
|
||||
|
||||
expect($component->get('clients'))->toHaveCount(1);
|
||||
expect($component->get('clients')->first()->full_name)->toBe('John Doe');
|
||||
});
|
||||
|
||||
test('admin can search clients by email', function () {
|
||||
$client = User::factory()->individual()->create([
|
||||
'full_name' => 'John Doe',
|
||||
'email' => 'john@example.com',
|
||||
]);
|
||||
User::factory()->individual()->create([
|
||||
'full_name' => 'Jane Smith',
|
||||
'email' => 'jane@example.com',
|
||||
]);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$component = Volt::test('admin.timelines.create')
|
||||
->set('search', 'john@');
|
||||
|
||||
expect($component->get('clients'))->toHaveCount(1);
|
||||
expect($component->get('clients')->first()->email)->toBe('john@example.com');
|
||||
});
|
||||
|
||||
test('admin can create timeline with required fields', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.timelines.create')
|
||||
->set('selectedUserId', $client->id)
|
||||
->call('selectUser', $client->id)
|
||||
->set('caseName', 'Test Case')
|
||||
->call('create')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect(route('admin.dashboard'));
|
||||
|
||||
expect(Timeline::where('case_name', 'Test Case')->exists())->toBeTrue();
|
||||
|
||||
$timeline = Timeline::where('case_name', 'Test Case')->first();
|
||||
expect($timeline->user_id)->toBe($client->id);
|
||||
expect($timeline->status)->toBe(TimelineStatus::Active);
|
||||
expect($timeline->case_reference)->toBeNull();
|
||||
});
|
||||
|
||||
test('admin can create timeline with case reference', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.timelines.create')
|
||||
->set('selectedUserId', $client->id)
|
||||
->call('selectUser', $client->id)
|
||||
->set('caseName', 'Test Case')
|
||||
->set('caseReference', 'REF-001')
|
||||
->call('create')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$timeline = Timeline::where('case_name', 'Test Case')->first();
|
||||
expect($timeline->case_reference)->toBe('REF-001');
|
||||
});
|
||||
|
||||
test('initial notes creates first timeline update', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.timelines.create')
|
||||
->set('selectedUserId', $client->id)
|
||||
->call('selectUser', $client->id)
|
||||
->set('caseName', 'Test Case')
|
||||
->set('initialNotes', 'Initial case notes here')
|
||||
->call('create')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$timeline = Timeline::where('case_name', 'Test Case')->first();
|
||||
expect($timeline->updates)->toHaveCount(1);
|
||||
|
||||
$update = $timeline->updates->first();
|
||||
expect($update->update_text)->toBe('Initial case notes here');
|
||||
expect($update->admin_id)->toBe($this->admin->id);
|
||||
});
|
||||
|
||||
test('audit log created on timeline creation', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.timelines.create')
|
||||
->set('selectedUserId', $client->id)
|
||||
->call('selectUser', $client->id)
|
||||
->set('caseName', 'Test Case')
|
||||
->call('create')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(AdminLog::where('action', 'create')
|
||||
->where('target_type', 'timeline')
|
||||
->where('admin_id', $this->admin->id)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// Validation Tests
|
||||
// ===========================================
|
||||
|
||||
test('case name is required', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.timelines.create')
|
||||
->set('selectedUserId', $client->id)
|
||||
->call('selectUser', $client->id)
|
||||
->set('caseName', '')
|
||||
->call('create')
|
||||
->assertHasErrors(['caseName' => 'required']);
|
||||
});
|
||||
|
||||
test('case reference must be unique', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
Timeline::factory()->create(['case_reference' => 'REF-001']);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.timelines.create')
|
||||
->set('selectedUserId', $client->id)
|
||||
->call('selectUser', $client->id)
|
||||
->set('caseName', 'Test Case')
|
||||
->set('caseReference', 'REF-001')
|
||||
->call('create')
|
||||
->assertHasErrors(['caseReference' => 'unique']);
|
||||
});
|
||||
|
||||
test('case reference allows multiple nulls', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
|
||||
// Create a timeline without case_reference
|
||||
Timeline::factory()->create([
|
||||
'user_id' => $client->id,
|
||||
'case_reference' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
// Creating another timeline without case_reference should succeed
|
||||
Volt::test('admin.timelines.create')
|
||||
->set('selectedUserId', $client->id)
|
||||
->call('selectUser', $client->id)
|
||||
->set('caseName', 'Test Case')
|
||||
->set('caseReference', '')
|
||||
->call('create')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Timeline::where('user_id', $client->id)->whereNull('case_reference')->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('client selection is required', function () {
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.timelines.create')
|
||||
->set('caseName', 'Test Case')
|
||||
->call('create')
|
||||
->assertHasErrors(['selectedUserId' => 'required']);
|
||||
});
|
||||
|
||||
test('selected client must exist', function () {
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.timelines.create')
|
||||
->set('selectedUserId', 99999)
|
||||
->set('caseName', 'Test Case')
|
||||
->call('create')
|
||||
->assertHasErrors(['selectedUserId' => 'exists']);
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// Authorization Tests
|
||||
// ===========================================
|
||||
|
||||
test('non-admin cannot access timeline creation', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
|
||||
$this->actingAs($client)
|
||||
->get(route('admin.timelines.create'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('guest cannot access timeline creation', function () {
|
||||
$this->get(route('admin.timelines.create'))
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// Edge Case Tests
|
||||
// ===========================================
|
||||
|
||||
test('search only returns individual and company users', function () {
|
||||
$individualClient = User::factory()->individual()->create(['full_name' => 'Individual Client']);
|
||||
$companyClient = User::factory()->company()->create(['full_name' => 'Company Client']);
|
||||
$adminUser = User::factory()->admin()->create(['full_name' => 'Admin User']);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$component = Volt::test('admin.timelines.create')
|
||||
->set('search', 'Client');
|
||||
|
||||
$clientNames = $component->get('clients')->pluck('full_name')->toArray();
|
||||
|
||||
expect($clientNames)->toContain('Individual Client');
|
||||
expect($clientNames)->toContain('Company Client');
|
||||
expect($clientNames)->not->toContain('Admin User');
|
||||
});
|
||||
|
||||
test('search only returns active users', function () {
|
||||
$activeClient = User::factory()->individual()->create(['full_name' => 'Active Client']);
|
||||
$deactivatedClient = User::factory()->individual()->deactivated()->create(['full_name' => 'Deactivated Client']);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$component = Volt::test('admin.timelines.create')
|
||||
->set('search', 'Client');
|
||||
|
||||
$clientNames = $component->get('clients')->pluck('full_name')->toArray();
|
||||
|
||||
expect($clientNames)->toContain('Active Client');
|
||||
expect($clientNames)->not->toContain('Deactivated Client');
|
||||
});
|
||||
|
||||
test('can create multiple timelines for same client', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
// Create first timeline
|
||||
Volt::test('admin.timelines.create')
|
||||
->set('selectedUserId', $client->id)
|
||||
->call('selectUser', $client->id)
|
||||
->set('caseName', 'First Case')
|
||||
->call('create')
|
||||
->assertHasNoErrors();
|
||||
|
||||
// Create second timeline for same client
|
||||
Volt::test('admin.timelines.create')
|
||||
->set('selectedUserId', $client->id)
|
||||
->call('selectUser', $client->id)
|
||||
->set('caseName', 'Second Case')
|
||||
->call('create')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Timeline::where('user_id', $client->id)->count())->toBe(2);
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// Additional Tests
|
||||
// ===========================================
|
||||
|
||||
test('search requires at least 2 characters', function () {
|
||||
User::factory()->individual()->create(['full_name' => 'John Doe']);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$component = Volt::test('admin.timelines.create')
|
||||
->set('search', 'J');
|
||||
|
||||
expect($component->get('clients'))->toHaveCount(0);
|
||||
|
||||
$component->set('search', 'Jo');
|
||||
expect($component->get('clients'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('selecting user sets search to user name', function () {
|
||||
$client = User::factory()->individual()->create(['full_name' => 'John Doe']);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$component = Volt::test('admin.timelines.create')
|
||||
->set('search', 'John')
|
||||
->call('selectUser', $client->id);
|
||||
|
||||
expect($component->get('selectedUserId'))->toBe($client->id);
|
||||
expect($component->get('search'))->toBe('John Doe');
|
||||
});
|
||||
|
||||
test('clearing selection resets user and search', function () {
|
||||
$client = User::factory()->individual()->create(['full_name' => 'John Doe']);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$component = Volt::test('admin.timelines.create')
|
||||
->call('selectUser', $client->id)
|
||||
->call('clearSelection');
|
||||
|
||||
expect($component->get('selectedUserId'))->toBeNull();
|
||||
expect($component->get('selectedUser'))->toBeNull();
|
||||
expect($component->get('search'))->toBe('');
|
||||
});
|
||||
|
||||
test('timeline without initial notes has no updates', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.timelines.create')
|
||||
->set('selectedUserId', $client->id)
|
||||
->call('selectUser', $client->id)
|
||||
->set('caseName', 'Test Case')
|
||||
->set('initialNotes', '')
|
||||
->call('create')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$timeline = Timeline::where('case_name', 'Test Case')->first();
|
||||
expect($timeline->updates)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('timeline status defaults to active', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.timelines.create')
|
||||
->set('selectedUserId', $client->id)
|
||||
->call('selectUser', $client->id)
|
||||
->set('caseName', 'Test Case')
|
||||
->call('create')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$timeline = Timeline::where('case_name', 'Test Case')->first();
|
||||
expect($timeline->status)->toBe(TimelineStatus::Active);
|
||||
});
|
||||
Loading…
Reference in New Issue