finished story 2.1 with qa test and added future recommendations for the dev
This commit is contained in:
parent
22fc7d7ae1
commit
0ec089bbb1
|
|
@ -133,6 +133,22 @@ class User extends Authenticatable
|
||||||
return $query->whereIn('user_type', [UserType::Individual, UserType::Company]);
|
return $query->whereIn('user_type', [UserType::Individual, UserType::Company]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to filter individual clients.
|
||||||
|
*/
|
||||||
|
public function scopeIndividual($query)
|
||||||
|
{
|
||||||
|
return $query->where('user_type', UserType::Individual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to filter company clients.
|
||||||
|
*/
|
||||||
|
public function scopeCompanies($query)
|
||||||
|
{
|
||||||
|
return $query->where('user_type', UserType::Company);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope to filter active users.
|
* Scope to filter active users.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
schema: 1
|
||||||
|
story: "2.1"
|
||||||
|
story_title: "Individual Client Account Management"
|
||||||
|
gate: PASS
|
||||||
|
status_reason: "All 24 acceptance criteria met with comprehensive test coverage (32 tests). Clean implementation following Laravel/Livewire best practices with proper authorization, validation, and audit logging."
|
||||||
|
reviewer: "Quinn (Test Architect)"
|
||||||
|
updated: "2025-12-26T00:00:00Z"
|
||||||
|
|
||||||
|
waiver: { active: false }
|
||||||
|
|
||||||
|
top_issues: []
|
||||||
|
|
||||||
|
quality_score: 95
|
||||||
|
|
||||||
|
expires: "2026-01-09T00:00:00Z"
|
||||||
|
|
||||||
|
evidence:
|
||||||
|
tests_reviewed: 32
|
||||||
|
risks_identified: 0
|
||||||
|
trace:
|
||||||
|
ac_covered: [1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]
|
||||||
|
ac_gaps: [4] # Password strength indicator marked optional in story
|
||||||
|
|
||||||
|
nfr_validation:
|
||||||
|
security:
|
||||||
|
status: PASS
|
||||||
|
notes: "Admin middleware protects all routes. Authorization tests verify access control. Password properly hashed."
|
||||||
|
performance:
|
||||||
|
status: PASS
|
||||||
|
notes: "Pagination implemented, N+1 prevented with loadCount(), search uses database-level filtering."
|
||||||
|
reliability:
|
||||||
|
status: PASS
|
||||||
|
notes: "Comprehensive error handling with validation rules and custom messages."
|
||||||
|
maintainability:
|
||||||
|
status: PASS
|
||||||
|
notes: "Clean component architecture, DRY translations, fully testable via Volt::test()."
|
||||||
|
|
||||||
|
risk_summary:
|
||||||
|
totals:
|
||||||
|
critical: 0
|
||||||
|
high: 0
|
||||||
|
medium: 0
|
||||||
|
low: 0
|
||||||
|
recommendations:
|
||||||
|
must_fix: []
|
||||||
|
monitor: []
|
||||||
|
|
||||||
|
recommendations:
|
||||||
|
immediate: []
|
||||||
|
future:
|
||||||
|
- action: "Consider adding password strength indicator (optional AC4)"
|
||||||
|
refs: ["resources/views/livewire/admin/clients/individual/create.blade.php"]
|
||||||
|
- action: "Add delete functionality when requirements are clarified"
|
||||||
|
refs: ["resources/views/livewire/admin/clients/individual/index.blade.php"]
|
||||||
|
|
@ -255,3 +255,216 @@ test('admin can create individual client', function () {
|
||||||
|
|
||||||
**Complexity:** Medium
|
**Complexity:** Medium
|
||||||
**Estimated Effort:** 4-5 hours
|
**Estimated Effort:** 4-5 hours
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dev Agent Record
|
||||||
|
|
||||||
|
### Status
|
||||||
|
**Done**
|
||||||
|
|
||||||
|
### Agent Model Used
|
||||||
|
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||||
|
|
||||||
|
### File List
|
||||||
|
**Created:**
|
||||||
|
- `resources/views/livewire/admin/clients/individual/index.blade.php` - List view with search/filter/pagination
|
||||||
|
- `resources/views/livewire/admin/clients/individual/create.blade.php` - Create individual client form
|
||||||
|
- `resources/views/livewire/admin/clients/individual/edit.blade.php` - Edit individual client form
|
||||||
|
- `resources/views/livewire/admin/clients/individual/show.blade.php` - Client profile/view page
|
||||||
|
- `lang/en/clients.php` - English translations for clients module
|
||||||
|
- `lang/ar/clients.php` - Arabic translations for clients module
|
||||||
|
- `tests/Feature/Admin/IndividualClientTest.php` - 32 comprehensive tests for CRUD operations
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `app/Models/User.php` - Added `scopeIndividual()` and `scopeCompanies()` scopes
|
||||||
|
- `routes/web.php` - Added admin routes for individual clients CRUD
|
||||||
|
- `resources/views/components/layouts/app/sidebar.blade.php` - Added User Management navigation for admins
|
||||||
|
- `lang/en/navigation.php` - Added navigation translations for user management
|
||||||
|
- `lang/ar/navigation.php` - Added Arabic navigation translations for user management
|
||||||
|
|
||||||
|
### Change Log
|
||||||
|
| Date | Change | Files |
|
||||||
|
|------|--------|-------|
|
||||||
|
| 2025-12-26 | Added User model scopes for individual and company clients | app/Models/User.php |
|
||||||
|
| 2025-12-26 | Created bilingual translation files for clients module | lang/en/clients.php, lang/ar/clients.php |
|
||||||
|
| 2025-12-26 | Created individual clients CRUD Volt components | resources/views/livewire/admin/clients/individual/*.blade.php |
|
||||||
|
| 2025-12-26 | Added admin routes for individual clients management | routes/web.php |
|
||||||
|
| 2025-12-26 | Added sidebar navigation for User Management | resources/views/components/layouts/app/sidebar.blade.php |
|
||||||
|
| 2025-12-26 | Added navigation translations | lang/en/navigation.php, lang/ar/navigation.php |
|
||||||
|
| 2025-12-26 | Created comprehensive test suite (32 tests) | tests/Feature/Admin/IndividualClientTest.php |
|
||||||
|
|
||||||
|
### Completion Notes
|
||||||
|
- All CRUD operations implemented with class-based Volt components following existing patterns
|
||||||
|
- Bilingual support complete for all form labels, messages, and navigation
|
||||||
|
- Search supports partial match on name, email, and national_id
|
||||||
|
- Filter by status (active/deactivated/all) with clear filters functionality
|
||||||
|
- Pagination with 10/25/50 per page options, sorted by created_at desc by default
|
||||||
|
- Audit logging (AdminLog) implemented for create and update operations
|
||||||
|
- Client profile page shows consultation and timeline counts with summary stats
|
||||||
|
- All 32 tests pass covering create, list, search, filter, edit, view, and authorization
|
||||||
|
- Full test suite (200 tests) passes with no regressions
|
||||||
|
- Code formatted with Laravel Pint
|
||||||
|
|
||||||
|
### Definition of Done Checklist
|
||||||
|
- [x] Create individual client form works
|
||||||
|
- [x] List view displays all individual clients
|
||||||
|
- [x] Search and filter functional
|
||||||
|
- [x] Edit client works with validation
|
||||||
|
- [x] View profile shows complete information
|
||||||
|
- [x] Duplicate prevention works (email and national_id unique validation)
|
||||||
|
- [x] Audit logging implemented (AdminLog entries for create/update)
|
||||||
|
- [x] Bilingual support complete
|
||||||
|
- [x] Tests pass for all CRUD operations (32 tests)
|
||||||
|
- [x] Code formatted with Pint
|
||||||
|
|
||||||
|
## QA Results
|
||||||
|
|
||||||
|
### Review Date: 2025-12-26
|
||||||
|
|
||||||
|
### Reviewed By: Quinn (Test Architect)
|
||||||
|
|
||||||
|
### Risk Assessment
|
||||||
|
**Risk Level: LOW-MEDIUM**
|
||||||
|
- Not a security-critical feature (no auth/payment handling)
|
||||||
|
- 32 tests added covering all CRUD operations
|
||||||
|
- Diff is moderate (~700 lines across all new files)
|
||||||
|
- First gate for this story (no previous FAIL)
|
||||||
|
- 6 acceptance criteria sections with clear requirements
|
||||||
|
|
||||||
|
### Code Quality Assessment
|
||||||
|
|
||||||
|
**Overall: EXCELLENT**
|
||||||
|
|
||||||
|
The implementation follows Laravel/Livewire best practices consistently:
|
||||||
|
|
||||||
|
1. **Architecture & Patterns**
|
||||||
|
- Class-based Volt components used correctly throughout
|
||||||
|
- Proper use of `WithPagination` trait for list view
|
||||||
|
- Clean separation of concerns with PHP logic in class block
|
||||||
|
- Follows existing project patterns for admin components
|
||||||
|
|
||||||
|
2. **Code Structure**
|
||||||
|
- Consistent file organization in `resources/views/livewire/admin/clients/individual/`
|
||||||
|
- User model scopes (`scopeIndividual()`, `scopeCompanies()`) properly implemented
|
||||||
|
- Clean route definitions using Volt::route()
|
||||||
|
|
||||||
|
3. **Validation**
|
||||||
|
- Comprehensive rules for all form fields
|
||||||
|
- Proper handling of unique constraints on edit (using `Rule::unique()->ignore()`)
|
||||||
|
- Custom error messages for duplicate email/national_id
|
||||||
|
|
||||||
|
4. **Audit Logging**
|
||||||
|
- AdminLog entries created for both create and update operations
|
||||||
|
- Old and new values captured appropriately
|
||||||
|
- IP address tracking implemented
|
||||||
|
|
||||||
|
### Refactoring Performed
|
||||||
|
|
||||||
|
None required. Code is clean and follows project conventions.
|
||||||
|
|
||||||
|
### Compliance Check
|
||||||
|
|
||||||
|
- Coding Standards: ✓ Pint passes with --dirty flag
|
||||||
|
- Project Structure: ✓ Files in correct locations per story spec
|
||||||
|
- Testing Strategy: ✓ 32 tests covering all acceptance criteria
|
||||||
|
- All ACs Met: ✓ See traceability matrix below
|
||||||
|
|
||||||
|
### Requirements Traceability
|
||||||
|
|
||||||
|
| Acceptance Criteria | Test Coverage | Status |
|
||||||
|
|---------------------|---------------|--------|
|
||||||
|
| AC1: Create form with all required fields | `admin can create individual client with all valid data`, field validation tests | ✓ |
|
||||||
|
| AC2: Validation for all required fields | 5 tests for missing required fields | ✓ |
|
||||||
|
| AC3: Duplicate email/National ID prevention | `cannot create client with duplicate email/national_id` | ✓ |
|
||||||
|
| AC4: Password strength indicator | Optional per story - not implemented | ⚠️ Optional |
|
||||||
|
| AC5: Success message on creation | Tested via redirect assertion | ✓ |
|
||||||
|
| AC6: List view individual clients only | `index page displays only individual clients` | ✓ |
|
||||||
|
| AC7: Columns display | Verified via component assertions | ✓ |
|
||||||
|
| AC8: Pagination 10/25/50 | Component property `perPage` tested | ✓ |
|
||||||
|
| AC9: Default sort by created_at desc | `clients sorted by created_at desc by default` | ✓ |
|
||||||
|
| AC10: Search by name/email/National ID | 3 search tests with partial match | ✓ |
|
||||||
|
| AC11: Filter by status | `can filter clients by active/deactivated status` | ✓ |
|
||||||
|
| AC12: Real-time search with debounce | `wire:model.live.debounce.300ms` in template | ✓ |
|
||||||
|
| AC13: Clear filters button | `clear filters resets search and filter` | ✓ |
|
||||||
|
| AC14: Edit all client information | `can edit existing client information` | ✓ |
|
||||||
|
| AC15: Cannot change user_type | Edit form doesn't expose user_type field | ✓ |
|
||||||
|
| AC16: Edit validation same as create | `validation rules apply on edit` | ✓ |
|
||||||
|
| AC17: Success message on update | Tested via session flash + redirect | ✓ |
|
||||||
|
| AC18: View client profile | `profile page displays all client information` | ✓ |
|
||||||
|
| AC19: Consultation history summary | `profile shows consultation count` | ✓ |
|
||||||
|
| AC20: Timeline history summary | `profile shows timeline count` | ✓ |
|
||||||
|
| AC21: Bilingual form labels | Translation files complete (en/ar) | ✓ |
|
||||||
|
| AC22: Proper form validation display | Flux:error components used | ✓ |
|
||||||
|
| AC23: Audit log entries | Tests verify AdminLog entries | ✓ |
|
||||||
|
| AC24: Tests for CRUD operations | 32 tests pass | ✓ |
|
||||||
|
|
||||||
|
### Improvements Checklist
|
||||||
|
|
||||||
|
All items completed by developer - no action required:
|
||||||
|
|
||||||
|
- [x] Proper validation rules with custom messages
|
||||||
|
- [x] Clean route structure with named routes
|
||||||
|
- [x] Bilingual translations complete
|
||||||
|
- [x] Sidebar navigation for admin users
|
||||||
|
- [x] AdminLog entries for audit trail
|
||||||
|
- [x] Proper eager loading with loadCount() for profile stats
|
||||||
|
|
||||||
|
### Security Review
|
||||||
|
|
||||||
|
**Status: PASS**
|
||||||
|
|
||||||
|
1. **Authorization**: Routes properly protected by `admin` middleware
|
||||||
|
2. **Authentication**: Tests verify non-admin and unauthenticated access blocked
|
||||||
|
3. **Data Validation**: All inputs validated server-side before processing
|
||||||
|
4. **Password Handling**: Uses `Hash::make()` for password storage (proper bcrypt)
|
||||||
|
5. **Sensitive Data**: national_id marked as hidden in User model
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
|
||||||
|
**Status: PASS**
|
||||||
|
|
||||||
|
1. **N+1 Prevention**: Profile page uses `loadCount()` for relationship counts
|
||||||
|
2. **Pagination**: Implemented with configurable per-page options
|
||||||
|
3. **Search**: Uses database-level filtering, not PHP array filtering
|
||||||
|
4. **Eager Loading**: Properly scoped queries with when() clauses
|
||||||
|
|
||||||
|
### Maintainability Assessment
|
||||||
|
|
||||||
|
**Status: PASS**
|
||||||
|
|
||||||
|
1. **Single Responsibility**: Each component handles one view/action
|
||||||
|
2. **DRY**: Translation keys reused across components
|
||||||
|
3. **Testability**: All operations fully testable via Volt::test()
|
||||||
|
4. **Documentation**: Code is self-documenting with clear method names
|
||||||
|
|
||||||
|
### Files Modified During Review
|
||||||
|
|
||||||
|
None. No refactoring was necessary.
|
||||||
|
|
||||||
|
### Gate Status
|
||||||
|
|
||||||
|
Gate: **PASS** → docs/qa/gates/2.1-individual-client-account-management.yml
|
||||||
|
|
||||||
|
### Recommended Status
|
||||||
|
|
||||||
|
✓ **Ready for Done** - All acceptance criteria met, comprehensive test coverage, no blocking issues found.
|
||||||
|
|
||||||
|
### Future Recommendations for Dev Agent
|
||||||
|
|
||||||
|
The following items are **not blocking** but should be considered for future implementation:
|
||||||
|
|
||||||
|
1. **Password Strength Indicator (Optional)**
|
||||||
|
- **File:** `resources/views/livewire/admin/clients/individual/create.blade.php`
|
||||||
|
- **Description:** AC4 in the story marked this as optional. Consider adding a visual password strength indicator (e.g., weak/medium/strong) using JavaScript or a Livewire reactive property.
|
||||||
|
- **Priority:** Low
|
||||||
|
|
||||||
|
2. **Delete Client Functionality**
|
||||||
|
- **File:** `resources/views/livewire/admin/clients/individual/index.blade.php`
|
||||||
|
- **Description:** The current story scope covers create, view, edit, and search only. Translation keys for delete already exist (`clients.delete`, `clients.client_deleted`). When requirements are clarified, add a delete action with confirmation modal and appropriate AdminLog entry.
|
||||||
|
- **Priority:** Medium - implement when a future story requires it
|
||||||
|
- **Considerations:**
|
||||||
|
- Soft delete vs hard delete decision needed
|
||||||
|
- Handle cascading relationships (consultations, timelines)
|
||||||
|
- Add authorization check before deletion
|
||||||
|
- Create test coverage for delete operation
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Navigation & Headers
|
||||||
|
'individual_clients' => 'العملاء الأفراد',
|
||||||
|
'company_clients' => 'الشركات العملاء',
|
||||||
|
'clients' => 'العملاء',
|
||||||
|
'client' => 'العميل',
|
||||||
|
'create_client' => 'إنشاء عميل',
|
||||||
|
'edit_client' => 'تعديل العميل',
|
||||||
|
'view_client' => 'عرض العميل',
|
||||||
|
'client_profile' => 'ملف العميل',
|
||||||
|
'back_to_clients' => 'العودة للعملاء',
|
||||||
|
|
||||||
|
// Form Labels
|
||||||
|
'full_name' => 'الاسم الكامل',
|
||||||
|
'national_id' => 'رقم الهوية الوطنية',
|
||||||
|
'email' => 'البريد الإلكتروني',
|
||||||
|
'phone' => 'رقم الهاتف',
|
||||||
|
'password' => 'كلمة المرور',
|
||||||
|
'preferred_language' => 'اللغة المفضلة',
|
||||||
|
'status' => 'الحالة',
|
||||||
|
'user_type' => 'نوع المستخدم',
|
||||||
|
'created_at' => 'تاريخ الإنشاء',
|
||||||
|
'updated_at' => 'تاريخ التحديث',
|
||||||
|
|
||||||
|
// Status Values
|
||||||
|
'active' => 'نشط',
|
||||||
|
'deactivated' => 'معطل',
|
||||||
|
|
||||||
|
// User Types
|
||||||
|
'individual' => 'فرد',
|
||||||
|
'company' => 'شركة',
|
||||||
|
|
||||||
|
// Language Options
|
||||||
|
'arabic' => 'العربية',
|
||||||
|
'english' => 'الإنجليزية',
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
'create' => 'إنشاء',
|
||||||
|
'save' => 'حفظ',
|
||||||
|
'update' => 'تحديث',
|
||||||
|
'edit' => 'تعديل',
|
||||||
|
'view' => 'عرض',
|
||||||
|
'delete' => 'حذف',
|
||||||
|
'cancel' => 'إلغاء',
|
||||||
|
'search' => 'بحث',
|
||||||
|
'filter' => 'تصفية',
|
||||||
|
'clear_filters' => 'مسح الفلاتر',
|
||||||
|
'actions' => 'الإجراءات',
|
||||||
|
|
||||||
|
// Search & Filter
|
||||||
|
'search_placeholder' => 'البحث بالاسم أو البريد الإلكتروني أو رقم الهوية...',
|
||||||
|
'filter_by_status' => 'تصفية حسب الحالة',
|
||||||
|
'all_statuses' => 'جميع الحالات',
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
'per_page' => 'لكل صفحة',
|
||||||
|
'showing' => 'عرض',
|
||||||
|
'of' => 'من',
|
||||||
|
'results' => 'نتيجة',
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
'client_created' => 'تم إنشاء العميل بنجاح.',
|
||||||
|
'client_updated' => 'تم تحديث العميل بنجاح.',
|
||||||
|
'client_deleted' => 'تم حذف العميل بنجاح.',
|
||||||
|
'no_clients_found' => 'لا يوجد عملاء.',
|
||||||
|
'no_clients_match' => 'لا يوجد عملاء مطابقين لمعايير البحث.',
|
||||||
|
|
||||||
|
// Validation Messages
|
||||||
|
'email_exists' => 'هذا البريد الإلكتروني مسجل بالفعل.',
|
||||||
|
'national_id_exists' => 'رقم الهوية الوطنية مسجل بالفعل.',
|
||||||
|
|
||||||
|
// Profile Page
|
||||||
|
'client_information' => 'معلومات العميل',
|
||||||
|
'contact_information' => 'معلومات الاتصال',
|
||||||
|
'account_information' => 'معلومات الحساب',
|
||||||
|
'consultation_history' => 'سجل الاستشارات',
|
||||||
|
'timeline_history' => 'سجل القضايا',
|
||||||
|
'total_consultations' => 'إجمالي الاستشارات',
|
||||||
|
'total_timelines' => 'إجمالي القضايا',
|
||||||
|
'pending_consultations' => 'الاستشارات المعلقة',
|
||||||
|
'completed_consultations' => 'الاستشارات المكتملة',
|
||||||
|
'active_timelines' => 'القضايا النشطة',
|
||||||
|
'view_all_consultations' => 'عرض جميع الاستشارات',
|
||||||
|
'view_all_timelines' => 'عرض جميع القضايا',
|
||||||
|
'no_consultations' => 'لا توجد استشارات بعد.',
|
||||||
|
'no_timelines' => 'لا توجد قضايا بعد.',
|
||||||
|
'member_since' => 'عضو منذ',
|
||||||
|
];
|
||||||
|
|
@ -21,4 +21,10 @@ return [
|
||||||
'language' => 'اللغة',
|
'language' => 'اللغة',
|
||||||
'arabic' => 'العربية',
|
'arabic' => 'العربية',
|
||||||
'english' => 'English',
|
'english' => 'English',
|
||||||
|
|
||||||
|
// Admin Navigation
|
||||||
|
'user_management' => 'إدارة المستخدمين',
|
||||||
|
'clients' => 'العملاء',
|
||||||
|
'individual_clients' => 'العملاء الأفراد',
|
||||||
|
'company_clients' => 'الشركات العملاء',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Navigation & Headers
|
||||||
|
'individual_clients' => 'Individual Clients',
|
||||||
|
'company_clients' => 'Company Clients',
|
||||||
|
'clients' => 'Clients',
|
||||||
|
'client' => 'Client',
|
||||||
|
'create_client' => 'Create Client',
|
||||||
|
'edit_client' => 'Edit Client',
|
||||||
|
'view_client' => 'View Client',
|
||||||
|
'client_profile' => 'Client Profile',
|
||||||
|
'back_to_clients' => 'Back to Clients',
|
||||||
|
|
||||||
|
// Form Labels
|
||||||
|
'full_name' => 'Full Name',
|
||||||
|
'national_id' => 'National ID Number',
|
||||||
|
'email' => 'Email Address',
|
||||||
|
'phone' => 'Phone Number',
|
||||||
|
'password' => 'Password',
|
||||||
|
'preferred_language' => 'Preferred Language',
|
||||||
|
'status' => 'Status',
|
||||||
|
'user_type' => 'User Type',
|
||||||
|
'created_at' => 'Created Date',
|
||||||
|
'updated_at' => 'Updated Date',
|
||||||
|
|
||||||
|
// Status Values
|
||||||
|
'active' => 'Active',
|
||||||
|
'deactivated' => 'Deactivated',
|
||||||
|
|
||||||
|
// User Types
|
||||||
|
'individual' => 'Individual',
|
||||||
|
'company' => 'Company',
|
||||||
|
|
||||||
|
// Language Options
|
||||||
|
'arabic' => 'Arabic',
|
||||||
|
'english' => 'English',
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
'create' => 'Create',
|
||||||
|
'save' => 'Save',
|
||||||
|
'update' => 'Update',
|
||||||
|
'edit' => 'Edit',
|
||||||
|
'view' => 'View',
|
||||||
|
'delete' => 'Delete',
|
||||||
|
'cancel' => 'Cancel',
|
||||||
|
'search' => 'Search',
|
||||||
|
'filter' => 'Filter',
|
||||||
|
'clear_filters' => 'Clear Filters',
|
||||||
|
'actions' => 'Actions',
|
||||||
|
|
||||||
|
// Search & Filter
|
||||||
|
'search_placeholder' => 'Search by name, email, or National ID...',
|
||||||
|
'filter_by_status' => 'Filter by Status',
|
||||||
|
'all_statuses' => 'All Statuses',
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
'per_page' => 'Per Page',
|
||||||
|
'showing' => 'Showing',
|
||||||
|
'of' => 'of',
|
||||||
|
'results' => 'results',
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
'client_created' => 'Client created successfully.',
|
||||||
|
'client_updated' => 'Client updated successfully.',
|
||||||
|
'client_deleted' => 'Client deleted successfully.',
|
||||||
|
'no_clients_found' => 'No clients found.',
|
||||||
|
'no_clients_match' => 'No clients match your search criteria.',
|
||||||
|
|
||||||
|
// Validation Messages
|
||||||
|
'email_exists' => 'This email address is already registered.',
|
||||||
|
'national_id_exists' => 'This National ID is already registered.',
|
||||||
|
|
||||||
|
// Profile Page
|
||||||
|
'client_information' => 'Client Information',
|
||||||
|
'contact_information' => 'Contact Information',
|
||||||
|
'account_information' => 'Account Information',
|
||||||
|
'consultation_history' => 'Consultation History',
|
||||||
|
'timeline_history' => 'Timeline History',
|
||||||
|
'total_consultations' => 'Total Consultations',
|
||||||
|
'total_timelines' => 'Total Timelines',
|
||||||
|
'pending_consultations' => 'Pending Consultations',
|
||||||
|
'completed_consultations' => 'Completed Consultations',
|
||||||
|
'active_timelines' => 'Active Timelines',
|
||||||
|
'view_all_consultations' => 'View All Consultations',
|
||||||
|
'view_all_timelines' => 'View All Timelines',
|
||||||
|
'no_consultations' => 'No consultations yet.',
|
||||||
|
'no_timelines' => 'No timelines yet.',
|
||||||
|
'member_since' => 'Member Since',
|
||||||
|
];
|
||||||
|
|
@ -21,4 +21,10 @@ return [
|
||||||
'language' => 'Language',
|
'language' => 'Language',
|
||||||
'arabic' => 'العربية',
|
'arabic' => 'العربية',
|
||||||
'english' => 'English',
|
'english' => 'English',
|
||||||
|
|
||||||
|
// Admin Navigation
|
||||||
|
'user_management' => 'User Management',
|
||||||
|
'clients' => 'Clients',
|
||||||
|
'individual_clients' => 'Individual Clients',
|
||||||
|
'company_clients' => 'Company Clients',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,19 @@
|
||||||
<flux:navlist.group :heading="__('Platform')" class="grid">
|
<flux:navlist.group :heading="__('Platform')" class="grid">
|
||||||
<flux:navlist.item icon="home" :href="$dashboardRoute" :current="$isDashboard" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
<flux:navlist.item icon="home" :href="$dashboardRoute" :current="$isDashboard" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||||
</flux:navlist.group>
|
</flux:navlist.group>
|
||||||
|
|
||||||
|
@if (auth()->user()->isAdmin())
|
||||||
|
<flux:navlist.group :heading="__('navigation.user_management')" class="grid">
|
||||||
|
<flux:navlist.item
|
||||||
|
icon="users"
|
||||||
|
:href="route('admin.clients.individual.index')"
|
||||||
|
:current="request()->routeIs('admin.clients.individual.*')"
|
||||||
|
wire:navigate
|
||||||
|
>
|
||||||
|
{{ __('navigation.individual_clients') }}
|
||||||
|
</flux:navlist.item>
|
||||||
|
</flux:navlist.group>
|
||||||
|
@endif
|
||||||
</flux:navlist>
|
</flux:navlist>
|
||||||
|
|
||||||
<flux:spacer />
|
<flux:spacer />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\UserStatus;
|
||||||
|
use App\Enums\UserType;
|
||||||
|
use App\Models\AdminLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public string $full_name = '';
|
||||||
|
public string $national_id = '';
|
||||||
|
public string $email = '';
|
||||||
|
public string $phone = '';
|
||||||
|
public string $password = '';
|
||||||
|
public string $preferred_language = 'ar';
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'full_name' => ['required', 'string', 'max:255'],
|
||||||
|
'national_id' => ['required', 'string', 'max:50', 'unique:users,national_id'],
|
||||||
|
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||||
|
'phone' => ['required', 'string', 'max:20'],
|
||||||
|
'password' => ['required', 'string', 'min:8'],
|
||||||
|
'preferred_language' => ['required', 'in:ar,en'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'national_id.unique' => __('clients.national_id_exists'),
|
||||||
|
'email.unique' => __('clients.email_exists'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): void
|
||||||
|
{
|
||||||
|
$validated = $this->validate();
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'user_type' => UserType::Individual,
|
||||||
|
'full_name' => $validated['full_name'],
|
||||||
|
'national_id' => $validated['national_id'],
|
||||||
|
'email' => $validated['email'],
|
||||||
|
'phone' => $validated['phone'],
|
||||||
|
'password' => Hash::make($validated['password']),
|
||||||
|
'preferred_language' => $validated['preferred_language'],
|
||||||
|
'status' => UserStatus::Active,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AdminLog::create([
|
||||||
|
'admin_id' => auth()->id(),
|
||||||
|
'action' => 'create',
|
||||||
|
'target_type' => 'user',
|
||||||
|
'target_id' => $user->id,
|
||||||
|
'new_values' => $user->only(['full_name', 'email', 'national_id', 'phone', 'preferred_language']),
|
||||||
|
'ip_address' => request()->ip(),
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->flash('success', __('clients.client_created'));
|
||||||
|
|
||||||
|
$this->redirect(route('admin.clients.individual.index'), navigate: true);
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<flux:button variant="ghost" :href="route('admin.clients.individual.index')" wire:navigate icon="arrow-left">
|
||||||
|
{{ __('clients.back_to_clients') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<flux:heading size="xl">{{ __('clients.create_client') }}</flux:heading>
|
||||||
|
<flux:text class="mt-1 text-zinc-500 dark:text-zinc-400">{{ __('clients.individual_clients') }}</flux:text>
|
||||||
|
</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">
|
||||||
|
<div class="grid gap-6 sm:grid-cols-2">
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('clients.full_name') }} *</flux:label>
|
||||||
|
<flux:input
|
||||||
|
wire:model="full_name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<flux:error name="full_name" />
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('clients.national_id') }} *</flux:label>
|
||||||
|
<flux:input
|
||||||
|
wire:model="national_id"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<flux:error name="national_id" />
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('clients.email') }} *</flux:label>
|
||||||
|
<flux:input
|
||||||
|
wire:model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<flux:error name="email" />
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('clients.phone') }} *</flux:label>
|
||||||
|
<flux:input
|
||||||
|
wire:model="phone"
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<flux:error name="phone" />
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('clients.password') }} *</flux:label>
|
||||||
|
<flux:input
|
||||||
|
wire:model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<flux:error name="password" />
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('clients.preferred_language') }} *</flux:label>
|
||||||
|
<flux:select wire:model="preferred_language" required>
|
||||||
|
<flux:select.option value="ar">{{ __('clients.arabic') }}</flux:select.option>
|
||||||
|
<flux:select.option value="en">{{ __('clients.english') }}</flux:select.option>
|
||||||
|
</flux:select>
|
||||||
|
<flux:error name="preferred_language" />
|
||||||
|
</flux:field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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.clients.individual.index')" wire:navigate>
|
||||||
|
{{ __('clients.cancel') }}
|
||||||
|
</flux:button>
|
||||||
|
<flux:button variant="primary" type="submit">
|
||||||
|
{{ __('clients.create') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\UserStatus;
|
||||||
|
use App\Models\AdminLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public User $client;
|
||||||
|
|
||||||
|
public string $full_name = '';
|
||||||
|
public string $national_id = '';
|
||||||
|
public string $email = '';
|
||||||
|
public string $phone = '';
|
||||||
|
public string $password = '';
|
||||||
|
public string $preferred_language = '';
|
||||||
|
public string $status = '';
|
||||||
|
|
||||||
|
public function mount(User $client): void
|
||||||
|
{
|
||||||
|
$this->client = $client;
|
||||||
|
$this->full_name = $client->full_name;
|
||||||
|
$this->national_id = $client->national_id ?? '';
|
||||||
|
$this->email = $client->email;
|
||||||
|
$this->phone = $client->phone ?? '';
|
||||||
|
$this->preferred_language = $client->preferred_language ?? 'ar';
|
||||||
|
$this->status = $client->status->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'full_name' => ['required', 'string', 'max:255'],
|
||||||
|
'national_id' => ['required', 'string', 'max:50', Rule::unique('users', 'national_id')->ignore($this->client->id)],
|
||||||
|
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($this->client->id)],
|
||||||
|
'phone' => ['required', 'string', 'max:20'],
|
||||||
|
'password' => ['nullable', 'string', 'min:8'],
|
||||||
|
'preferred_language' => ['required', 'in:ar,en'],
|
||||||
|
'status' => ['required', 'in:active,deactivated'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'national_id.unique' => __('clients.national_id_exists'),
|
||||||
|
'email.unique' => __('clients.email_exists'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(): void
|
||||||
|
{
|
||||||
|
$validated = $this->validate();
|
||||||
|
|
||||||
|
$oldValues = $this->client->only(['full_name', 'email', 'national_id', 'phone', 'preferred_language', 'status']);
|
||||||
|
|
||||||
|
$this->client->full_name = $validated['full_name'];
|
||||||
|
$this->client->national_id = $validated['national_id'];
|
||||||
|
$this->client->email = $validated['email'];
|
||||||
|
$this->client->phone = $validated['phone'];
|
||||||
|
$this->client->preferred_language = $validated['preferred_language'];
|
||||||
|
$this->client->status = $validated['status'];
|
||||||
|
|
||||||
|
if (! empty($validated['password'])) {
|
||||||
|
$this->client->password = Hash::make($validated['password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->client->save();
|
||||||
|
|
||||||
|
AdminLog::create([
|
||||||
|
'admin_id' => auth()->id(),
|
||||||
|
'action' => 'update',
|
||||||
|
'target_type' => 'user',
|
||||||
|
'target_id' => $this->client->id,
|
||||||
|
'old_values' => $oldValues,
|
||||||
|
'new_values' => $this->client->only(['full_name', 'email', 'national_id', 'phone', 'preferred_language', 'status']),
|
||||||
|
'ip_address' => request()->ip(),
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->flash('success', __('clients.client_updated'));
|
||||||
|
|
||||||
|
$this->redirect(route('admin.clients.individual.index'), navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function with(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'statuses' => UserStatus::cases(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<flux:button variant="ghost" :href="route('admin.clients.individual.index')" wire:navigate icon="arrow-left">
|
||||||
|
{{ __('clients.back_to_clients') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<flux:heading size="xl">{{ __('clients.edit_client') }}</flux:heading>
|
||||||
|
<flux:text class="mt-1 text-zinc-500 dark:text-zinc-400">{{ $client->full_name }}</flux:text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
<form wire:submit="update" class="space-y-6">
|
||||||
|
<div class="grid gap-6 sm:grid-cols-2">
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('clients.full_name') }} *</flux:label>
|
||||||
|
<flux:input
|
||||||
|
wire:model="full_name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<flux:error name="full_name" />
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('clients.national_id') }} *</flux:label>
|
||||||
|
<flux:input
|
||||||
|
wire:model="national_id"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<flux:error name="national_id" />
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('clients.email') }} *</flux:label>
|
||||||
|
<flux:input
|
||||||
|
wire:model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<flux:error name="email" />
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('clients.phone') }} *</flux:label>
|
||||||
|
<flux:input
|
||||||
|
wire:model="phone"
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<flux:error name="phone" />
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('clients.password') }}</flux:label>
|
||||||
|
<flux:input
|
||||||
|
wire:model="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="{{ __('Leave blank to keep current password') }}"
|
||||||
|
/>
|
||||||
|
<flux:error name="password" />
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('clients.preferred_language') }} *</flux:label>
|
||||||
|
<flux:select wire:model="preferred_language" required>
|
||||||
|
<flux:select.option value="ar">{{ __('clients.arabic') }}</flux:select.option>
|
||||||
|
<flux:select.option value="en">{{ __('clients.english') }}</flux:select.option>
|
||||||
|
</flux:select>
|
||||||
|
<flux:error name="preferred_language" />
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('clients.status') }} *</flux:label>
|
||||||
|
<flux:select wire:model="status" required>
|
||||||
|
@foreach ($statuses as $statusOption)
|
||||||
|
<flux:select.option value="{{ $statusOption->value }}">
|
||||||
|
{{ __('clients.' . $statusOption->value) }}
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
<flux:error name="status" />
|
||||||
|
</flux:field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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.clients.individual.index')" wire:navigate>
|
||||||
|
{{ __('clients.cancel') }}
|
||||||
|
</flux:button>
|
||||||
|
<flux:button variant="primary" type="submit">
|
||||||
|
{{ __('clients.update') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\UserStatus;
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public string $search = '';
|
||||||
|
public string $statusFilter = '';
|
||||||
|
public int $perPage = 10;
|
||||||
|
|
||||||
|
public function updatedSearch(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedStatusFilter(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedPerPage(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearFilters(): void
|
||||||
|
{
|
||||||
|
$this->search = '';
|
||||||
|
$this->statusFilter = '';
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function with(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'clients' => User::individual()
|
||||||
|
->when($this->search, fn ($q) => $q->where(function ($q) {
|
||||||
|
$q->where('full_name', 'like', "%{$this->search}%")
|
||||||
|
->orWhere('email', 'like', "%{$this->search}%")
|
||||||
|
->orWhere('national_id', 'like', "%{$this->search}%");
|
||||||
|
}))
|
||||||
|
->when($this->statusFilter, fn ($q) => $q->where('status', $this->statusFilter))
|
||||||
|
->latest()
|
||||||
|
->paginate($this->perPage),
|
||||||
|
'statuses' => UserStatus::cases(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="xl">{{ __('clients.individual_clients') }}</flux:heading>
|
||||||
|
<flux:text class="mt-1 text-zinc-500 dark:text-zinc-400">{{ __('clients.clients') }}</flux:text>
|
||||||
|
</div>
|
||||||
|
<flux:button variant="primary" :href="route('admin.clients.individual.create')" wire:navigate icon="plus">
|
||||||
|
{{ __('clients.create_client') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6 rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||||
|
<div class="flex-1">
|
||||||
|
<flux:input
|
||||||
|
wire:model.live.debounce.300ms="search"
|
||||||
|
:placeholder="__('clients.search_placeholder')"
|
||||||
|
icon="magnifying-glass"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-full sm:w-48">
|
||||||
|
<flux:select wire:model.live="statusFilter">
|
||||||
|
<flux:select.option value="">{{ __('clients.all_statuses') }}</flux:select.option>
|
||||||
|
@foreach ($statuses as $status)
|
||||||
|
<flux:select.option value="{{ $status->value }}">
|
||||||
|
{{ __('clients.' . $status->value) }}
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
</div>
|
||||||
|
<div class="w-full sm:w-32">
|
||||||
|
<flux:select wire:model.live="perPage">
|
||||||
|
<flux:select.option value="10">10 {{ __('clients.per_page') }}</flux:select.option>
|
||||||
|
<flux:select.option value="25">25 {{ __('clients.per_page') }}</flux:select.option>
|
||||||
|
<flux:select.option value="50">50 {{ __('clients.per_page') }}</flux:select.option>
|
||||||
|
</flux:select>
|
||||||
|
</div>
|
||||||
|
@if ($search || $statusFilter)
|
||||||
|
<flux:button wire:click="clearFilters" variant="ghost" icon="x-mark">
|
||||||
|
{{ __('clients.clear_filters') }}
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
|
||||||
|
<thead class="bg-zinc-50 dark:bg-zinc-900">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-start text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ __('clients.full_name') }}
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-start text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ __('clients.email') }}
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-start text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ __('clients.national_id') }}
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-start text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ __('clients.phone') }}
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-start text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ __('clients.status') }}
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-start text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ __('clients.created_at') }}
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-end text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ __('clients.actions') }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-zinc-200 bg-white dark:divide-zinc-700 dark:bg-zinc-800">
|
||||||
|
@forelse ($clients as $client)
|
||||||
|
<tr wire:key="client-{{ $client->id }}">
|
||||||
|
<td class="whitespace-nowrap px-6 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<flux:avatar size="sm" :name="$client->full_name" />
|
||||||
|
<span class="font-medium text-zinc-900 dark:text-zinc-100">{{ $client->full_name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-6 py-4 text-zinc-600 dark:text-zinc-400">
|
||||||
|
{{ $client->email }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-6 py-4 text-zinc-600 dark:text-zinc-400">
|
||||||
|
{{ $client->national_id }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-6 py-4 text-zinc-600 dark:text-zinc-400">
|
||||||
|
{{ $client->phone }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-6 py-4">
|
||||||
|
@if ($client->status === UserStatus::Active)
|
||||||
|
<flux:badge color="green" size="sm">{{ __('clients.active') }}</flux:badge>
|
||||||
|
@else
|
||||||
|
<flux:badge color="red" size="sm">{{ __('clients.deactivated') }}</flux:badge>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-6 py-4 text-zinc-600 dark:text-zinc-400">
|
||||||
|
{{ $client->created_at->format('Y-m-d') }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-6 py-4 text-end">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<flux:button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="eye"
|
||||||
|
:href="route('admin.clients.individual.show', $client)"
|
||||||
|
wire:navigate
|
||||||
|
:title="__('clients.view')"
|
||||||
|
/>
|
||||||
|
<flux:button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="pencil"
|
||||||
|
:href="route('admin.clients.individual.edit', $client)"
|
||||||
|
wire:navigate
|
||||||
|
:title="__('clients.edit')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-6 py-12 text-center">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<flux:icon name="users" class="mb-4 h-12 w-12 text-zinc-400" />
|
||||||
|
<flux:text class="text-zinc-500 dark:text-zinc-400">
|
||||||
|
@if ($search || $statusFilter)
|
||||||
|
{{ __('clients.no_clients_match') }}
|
||||||
|
@else
|
||||||
|
{{ __('clients.no_clients_found') }}
|
||||||
|
@endif
|
||||||
|
</flux:text>
|
||||||
|
@if ($search || $statusFilter)
|
||||||
|
<flux:button wire:click="clearFilters" variant="ghost" class="mt-4">
|
||||||
|
{{ __('clients.clear_filters') }}
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($clients->hasPages())
|
||||||
|
<div class="border-t border-zinc-200 bg-zinc-50 px-6 py-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||||
|
{{ $clients->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\ConsultationStatus;
|
||||||
|
use App\Enums\TimelineStatus;
|
||||||
|
use App\Enums\UserStatus;
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public User $client;
|
||||||
|
|
||||||
|
public function mount(User $client): void
|
||||||
|
{
|
||||||
|
$this->client = $client->loadCount([
|
||||||
|
'consultations',
|
||||||
|
'consultations as pending_consultations_count' => fn ($q) => $q->where('status', ConsultationStatus::Pending),
|
||||||
|
'consultations as completed_consultations_count' => fn ($q) => $q->where('status', ConsultationStatus::Completed),
|
||||||
|
'timelines',
|
||||||
|
'timelines as active_timelines_count' => fn ($q) => $q->where('status', TimelineStatus::Active),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<flux:button variant="ghost" :href="route('admin.clients.individual.index')" wire:navigate icon="arrow-left">
|
||||||
|
{{ __('clients.back_to_clients') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
<flux:button variant="primary" :href="route('admin.clients.individual.edit', $client)" wire:navigate icon="pencil">
|
||||||
|
{{ __('clients.edit_client') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<flux:heading size="xl">{{ __('clients.client_profile') }}</flux:heading>
|
||||||
|
<flux:text class="mt-1 text-zinc-500 dark:text-zinc-400">{{ $client->full_name }}</flux:text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-3">
|
||||||
|
{{-- Client Information --}}
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div class="rounded-lg border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
<div class="border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||||
|
<flux:heading size="lg">{{ __('clients.client_information') }}</flux:heading>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-start gap-6">
|
||||||
|
<flux:avatar size="xl" :name="$client->full_name" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="grid gap-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<flux:text class="text-sm font-medium text-zinc-500 dark:text-zinc-400">{{ __('clients.full_name') }}</flux:text>
|
||||||
|
<flux:text class="mt-1 text-zinc-900 dark:text-zinc-100">{{ $client->full_name }}</flux:text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<flux:text class="text-sm font-medium text-zinc-500 dark:text-zinc-400">{{ __('clients.national_id') }}</flux:text>
|
||||||
|
<flux:text class="mt-1 text-zinc-900 dark:text-zinc-100">{{ $client->national_id }}</flux:text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<flux:text class="text-sm font-medium text-zinc-500 dark:text-zinc-400">{{ __('clients.email') }}</flux:text>
|
||||||
|
<flux:text class="mt-1 text-zinc-900 dark:text-zinc-100">{{ $client->email }}</flux:text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<flux:text class="text-sm font-medium text-zinc-500 dark:text-zinc-400">{{ __('clients.phone') }}</flux:text>
|
||||||
|
<flux:text class="mt-1 text-zinc-900 dark:text-zinc-100">{{ $client->phone }}</flux:text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<flux:text class="text-sm font-medium text-zinc-500 dark:text-zinc-400">{{ __('clients.preferred_language') }}</flux:text>
|
||||||
|
<flux:text class="mt-1 text-zinc-900 dark:text-zinc-100">
|
||||||
|
{{ $client->preferred_language === 'ar' ? __('clients.arabic') : __('clients.english') }}
|
||||||
|
</flux:text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<flux:text class="text-sm font-medium text-zinc-500 dark:text-zinc-400">{{ __('clients.status') }}</flux:text>
|
||||||
|
<div class="mt-1">
|
||||||
|
@if ($client->status === UserStatus::Active)
|
||||||
|
<flux:badge color="green">{{ __('clients.active') }}</flux:badge>
|
||||||
|
@else
|
||||||
|
<flux:badge color="red">{{ __('clients.deactivated') }}</flux:badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<flux:text class="text-sm font-medium text-zinc-500 dark:text-zinc-400">{{ __('clients.member_since') }}</flux:text>
|
||||||
|
<flux:text class="mt-1 text-zinc-900 dark:text-zinc-100">{{ $client->created_at->format('Y-m-d') }}</flux:text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<flux:text class="text-sm font-medium text-zinc-500 dark:text-zinc-400">{{ __('clients.user_type') }}</flux:text>
|
||||||
|
<flux:text class="mt-1 text-zinc-900 dark:text-zinc-100">{{ __('clients.individual') }}</flux:text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Stats Sidebar --}}
|
||||||
|
<div class="space-y-6">
|
||||||
|
{{-- Consultation Summary --}}
|
||||||
|
<div class="rounded-lg border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
<div class="border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||||
|
<flux:heading size="lg">{{ __('clients.consultation_history') }}</flux:heading>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('clients.total_consultations') }}</flux:text>
|
||||||
|
<flux:badge color="zinc">{{ $client->consultations_count }}</flux:badge>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('clients.pending_consultations') }}</flux:text>
|
||||||
|
<flux:badge color="yellow">{{ $client->pending_consultations_count }}</flux:badge>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('clients.completed_consultations') }}</flux:text>
|
||||||
|
<flux:badge color="green">{{ $client->completed_consultations_count }}</flux:badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if ($client->consultations_count > 0)
|
||||||
|
<div class="mt-4 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||||
|
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ __('clients.view_all_consultations') }}
|
||||||
|
</flux:text>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="mt-4 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||||
|
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ __('clients.no_consultations') }}
|
||||||
|
</flux:text>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Timeline Summary --}}
|
||||||
|
<div class="rounded-lg border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
<div class="border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||||
|
<flux:heading size="lg">{{ __('clients.timeline_history') }}</flux:heading>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('clients.total_timelines') }}</flux:text>
|
||||||
|
<flux:badge color="zinc">{{ $client->timelines_count }}</flux:badge>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('clients.active_timelines') }}</flux:text>
|
||||||
|
<flux:badge color="blue">{{ $client->active_timelines_count }}</flux:badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if ($client->timelines_count > 0)
|
||||||
|
<div class="mt-4 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||||
|
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ __('clients.view_all_timelines') }}
|
||||||
|
</flux:text>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="mt-4 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||||
|
<flux:text class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ __('clients.no_timelines') }}
|
||||||
|
</flux:text>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -43,6 +43,14 @@ Route::middleware(['auth', 'active'])->group(function () {
|
||||||
Route::middleware('admin')->prefix('admin')->group(function () {
|
Route::middleware('admin')->prefix('admin')->group(function () {
|
||||||
Route::view('/dashboard', 'livewire.admin.dashboard-placeholder')
|
Route::view('/dashboard', 'livewire.admin.dashboard-placeholder')
|
||||||
->name('admin.dashboard');
|
->name('admin.dashboard');
|
||||||
|
|
||||||
|
// Individual Clients Management
|
||||||
|
Route::prefix('clients/individual')->name('admin.clients.individual.')->group(function () {
|
||||||
|
Volt::route('/', 'admin.clients.individual.index')->name('index');
|
||||||
|
Volt::route('/create', 'admin.clients.individual.create')->name('create');
|
||||||
|
Volt::route('/{client}', 'admin.clients.individual.show')->name('show');
|
||||||
|
Volt::route('/{client}/edit', 'admin.clients.individual.edit')->name('edit');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Client routes
|
// Client routes
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,509 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\UserStatus;
|
||||||
|
use App\Enums\UserType;
|
||||||
|
use App\Models\AdminLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Volt\Volt;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->admin = User::factory()->admin()->create();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// Create Client Tests
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
test('admin can access individual clients index page', function () {
|
||||||
|
$this->actingAs($this->admin)
|
||||||
|
->get(route('admin.clients.individual.index'))
|
||||||
|
->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can access create individual client page', function () {
|
||||||
|
$this->actingAs($this->admin)
|
||||||
|
->get(route('admin.clients.individual.create'))
|
||||||
|
->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can create individual client with all valid data', function () {
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.create')
|
||||||
|
->set('full_name', 'Test Client')
|
||||||
|
->set('email', 'testclient@example.com')
|
||||||
|
->set('national_id', '123456789')
|
||||||
|
->set('phone', '+970599123456')
|
||||||
|
->set('password', 'password123')
|
||||||
|
->set('preferred_language', 'ar')
|
||||||
|
->call('create')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertRedirect(route('admin.clients.individual.index'));
|
||||||
|
|
||||||
|
expect(User::where('email', 'testclient@example.com')->exists())->toBeTrue();
|
||||||
|
|
||||||
|
$client = User::where('email', 'testclient@example.com')->first();
|
||||||
|
expect($client->user_type)->toBe(UserType::Individual);
|
||||||
|
expect($client->status)->toBe(UserStatus::Active);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('created client has user_type set to individual', function () {
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.create')
|
||||||
|
->set('full_name', 'Test Client')
|
||||||
|
->set('email', 'testclient@example.com')
|
||||||
|
->set('national_id', '123456789')
|
||||||
|
->set('phone', '+970599123456')
|
||||||
|
->set('password', 'password123')
|
||||||
|
->set('preferred_language', 'ar')
|
||||||
|
->call('create')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$client = User::where('email', 'testclient@example.com')->first();
|
||||||
|
expect($client->user_type)->toBe(UserType::Individual);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin log entry created on successful creation', function () {
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.create')
|
||||||
|
->set('full_name', 'Test Client')
|
||||||
|
->set('email', 'testclient@example.com')
|
||||||
|
->set('national_id', '123456789')
|
||||||
|
->set('phone', '+970599123456')
|
||||||
|
->set('password', 'password123')
|
||||||
|
->set('preferred_language', 'ar')
|
||||||
|
->call('create')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect(AdminLog::where('action', 'create')
|
||||||
|
->where('target_type', 'user')
|
||||||
|
->where('admin_id', $this->admin->id)
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot create client without required name field', function () {
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.create')
|
||||||
|
->set('full_name', '')
|
||||||
|
->set('email', 'testclient@example.com')
|
||||||
|
->set('national_id', '123456789')
|
||||||
|
->set('phone', '+970599123456')
|
||||||
|
->set('password', 'password123')
|
||||||
|
->set('preferred_language', 'ar')
|
||||||
|
->call('create')
|
||||||
|
->assertHasErrors(['full_name' => 'required']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot create client without required email field', function () {
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.create')
|
||||||
|
->set('full_name', 'Test Client')
|
||||||
|
->set('email', '')
|
||||||
|
->set('national_id', '123456789')
|
||||||
|
->set('phone', '+970599123456')
|
||||||
|
->set('password', 'password123')
|
||||||
|
->set('preferred_language', 'ar')
|
||||||
|
->call('create')
|
||||||
|
->assertHasErrors(['email' => 'required']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot create client without required national_id field', function () {
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.create')
|
||||||
|
->set('full_name', 'Test Client')
|
||||||
|
->set('email', 'testclient@example.com')
|
||||||
|
->set('national_id', '')
|
||||||
|
->set('phone', '+970599123456')
|
||||||
|
->set('password', 'password123')
|
||||||
|
->set('preferred_language', 'ar')
|
||||||
|
->call('create')
|
||||||
|
->assertHasErrors(['national_id' => 'required']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot create client without required phone field', function () {
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.create')
|
||||||
|
->set('full_name', 'Test Client')
|
||||||
|
->set('email', 'testclient@example.com')
|
||||||
|
->set('national_id', '123456789')
|
||||||
|
->set('phone', '')
|
||||||
|
->set('password', 'password123')
|
||||||
|
->set('preferred_language', 'ar')
|
||||||
|
->call('create')
|
||||||
|
->assertHasErrors(['phone' => 'required']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot create client with invalid email format', function () {
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.create')
|
||||||
|
->set('full_name', 'Test Client')
|
||||||
|
->set('email', 'invalid-email')
|
||||||
|
->set('national_id', '123456789')
|
||||||
|
->set('phone', '+970599123456')
|
||||||
|
->set('password', 'password123')
|
||||||
|
->set('preferred_language', 'ar')
|
||||||
|
->call('create')
|
||||||
|
->assertHasErrors(['email' => 'email']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot create client with duplicate email', function () {
|
||||||
|
User::factory()->individual()->create(['email' => 'existing@example.com']);
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.create')
|
||||||
|
->set('full_name', 'Test Client')
|
||||||
|
->set('email', 'existing@example.com')
|
||||||
|
->set('national_id', '123456789')
|
||||||
|
->set('phone', '+970599123456')
|
||||||
|
->set('password', 'password123')
|
||||||
|
->set('preferred_language', 'ar')
|
||||||
|
->call('create')
|
||||||
|
->assertHasErrors(['email' => 'unique']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot create client with duplicate national_id', function () {
|
||||||
|
User::factory()->individual()->create(['national_id' => '123456789']);
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.create')
|
||||||
|
->set('full_name', 'Test Client')
|
||||||
|
->set('email', 'testclient@example.com')
|
||||||
|
->set('national_id', '123456789')
|
||||||
|
->set('phone', '+970599123456')
|
||||||
|
->set('password', 'password123')
|
||||||
|
->set('preferred_language', 'ar')
|
||||||
|
->call('create')
|
||||||
|
->assertHasErrors(['national_id' => 'unique']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot create client with password less than 8 characters', function () {
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.create')
|
||||||
|
->set('full_name', 'Test Client')
|
||||||
|
->set('email', 'testclient@example.com')
|
||||||
|
->set('national_id', '123456789')
|
||||||
|
->set('phone', '+970599123456')
|
||||||
|
->set('password', 'short')
|
||||||
|
->set('preferred_language', 'ar')
|
||||||
|
->call('create')
|
||||||
|
->assertHasErrors(['password' => 'min']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// List View Tests
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
test('index page displays only individual clients', function () {
|
||||||
|
// Create different types of users
|
||||||
|
$individualClient = User::factory()->individual()->create(['full_name' => 'Individual Test']);
|
||||||
|
$companyClient = User::factory()->company()->create(['full_name' => 'Company Test']);
|
||||||
|
$adminUser = User::factory()->admin()->create(['full_name' => 'Admin Test']);
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.index')
|
||||||
|
->assertSee('Individual Test')
|
||||||
|
->assertDontSee('Company Test')
|
||||||
|
->assertDontSee('Admin Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clients sorted by created_at desc by default', function () {
|
||||||
|
$oldClient = User::factory()->individual()->create([
|
||||||
|
'full_name' => 'Old Client',
|
||||||
|
'created_at' => now()->subDays(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newClient = User::factory()->individual()->create([
|
||||||
|
'full_name' => 'New Client',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
$response = Volt::test('admin.clients.individual.index');
|
||||||
|
|
||||||
|
// The new client should appear before the old client
|
||||||
|
$response->assertSeeInOrder(['New Client', 'Old Client']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// Search & Filter Tests
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
test('can search clients by name (partial match)', function () {
|
||||||
|
User::factory()->individual()->create(['full_name' => 'John Doe']);
|
||||||
|
User::factory()->individual()->create(['full_name' => 'Jane Smith']);
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.index')
|
||||||
|
->set('search', 'John')
|
||||||
|
->assertSee('John Doe')
|
||||||
|
->assertDontSee('Jane Smith');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can search clients by email (partial match)', function () {
|
||||||
|
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);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.index')
|
||||||
|
->set('search', 'john@')
|
||||||
|
->assertSee('John Doe')
|
||||||
|
->assertDontSee('Jane Smith');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can search clients by national_id (partial match)', function () {
|
||||||
|
User::factory()->individual()->create([
|
||||||
|
'full_name' => 'John Doe',
|
||||||
|
'national_id' => '111222333',
|
||||||
|
]);
|
||||||
|
User::factory()->individual()->create([
|
||||||
|
'full_name' => 'Jane Smith',
|
||||||
|
'national_id' => '444555666',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.index')
|
||||||
|
->set('search', '111222')
|
||||||
|
->assertSee('John Doe')
|
||||||
|
->assertDontSee('Jane Smith');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can filter clients by active status', function () {
|
||||||
|
User::factory()->individual()->create([
|
||||||
|
'full_name' => 'Active Client',
|
||||||
|
'status' => UserStatus::Active,
|
||||||
|
]);
|
||||||
|
User::factory()->individual()->deactivated()->create([
|
||||||
|
'full_name' => 'Deactivated Client',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.index')
|
||||||
|
->set('statusFilter', 'active')
|
||||||
|
->assertSee('Active Client')
|
||||||
|
->assertDontSee('Deactivated Client');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can filter clients by deactivated status', function () {
|
||||||
|
User::factory()->individual()->create([
|
||||||
|
'full_name' => 'Active Client',
|
||||||
|
'status' => UserStatus::Active,
|
||||||
|
]);
|
||||||
|
User::factory()->individual()->deactivated()->create([
|
||||||
|
'full_name' => 'Deactivated Client',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.index')
|
||||||
|
->set('statusFilter', 'deactivated')
|
||||||
|
->assertDontSee('Active Client')
|
||||||
|
->assertSee('Deactivated Client');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clear filters resets search and filter', function () {
|
||||||
|
User::factory()->individual()->create(['full_name' => 'Test Client']);
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
$component = Volt::test('admin.clients.individual.index')
|
||||||
|
->set('search', 'something')
|
||||||
|
->set('statusFilter', 'active')
|
||||||
|
->call('clearFilters');
|
||||||
|
|
||||||
|
expect($component->get('search'))->toBe('');
|
||||||
|
expect($component->get('statusFilter'))->toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// Edit Client Tests
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
test('admin can access edit individual client page', function () {
|
||||||
|
$client = User::factory()->individual()->create();
|
||||||
|
|
||||||
|
$this->actingAs($this->admin)
|
||||||
|
->get(route('admin.clients.individual.edit', $client))
|
||||||
|
->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edit form pre-populates with current values', function () {
|
||||||
|
$client = User::factory()->individual()->create([
|
||||||
|
'full_name' => 'Original Name',
|
||||||
|
'email' => 'original@example.com',
|
||||||
|
'national_id' => '987654321',
|
||||||
|
'phone' => '+970599000000',
|
||||||
|
'preferred_language' => 'en',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
$component = Volt::test('admin.clients.individual.edit', ['client' => $client]);
|
||||||
|
|
||||||
|
expect($component->get('full_name'))->toBe('Original Name');
|
||||||
|
expect($component->get('email'))->toBe('original@example.com');
|
||||||
|
expect($component->get('national_id'))->toBe('987654321');
|
||||||
|
expect($component->get('phone'))->toBe('+970599000000');
|
||||||
|
expect($component->get('preferred_language'))->toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can edit existing client information', function () {
|
||||||
|
$client = User::factory()->individual()->create();
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.edit', ['client' => $client])
|
||||||
|
->set('full_name', 'Updated Name')
|
||||||
|
->set('email', 'updated@example.com')
|
||||||
|
->set('national_id', '111111111')
|
||||||
|
->set('phone', '+970599111111')
|
||||||
|
->set('preferred_language', 'en')
|
||||||
|
->set('status', 'active')
|
||||||
|
->call('update')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertRedirect(route('admin.clients.individual.index'));
|
||||||
|
|
||||||
|
$client->refresh();
|
||||||
|
|
||||||
|
expect($client->full_name)->toBe('Updated Name');
|
||||||
|
expect($client->email)->toBe('updated@example.com');
|
||||||
|
expect($client->national_id)->toBe('111111111');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validation rules apply on edit (except unique for own record)', function () {
|
||||||
|
$client = User::factory()->individual()->create([
|
||||||
|
'email' => 'client@example.com',
|
||||||
|
'national_id' => '123456789',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
// Should not error when keeping same email
|
||||||
|
Volt::test('admin.clients.individual.edit', ['client' => $client])
|
||||||
|
->set('full_name', 'Updated Name')
|
||||||
|
->set('email', 'client@example.com')
|
||||||
|
->set('national_id', '123456789')
|
||||||
|
->set('phone', '+970599111111')
|
||||||
|
->set('preferred_language', 'en')
|
||||||
|
->set('status', 'active')
|
||||||
|
->call('update')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin log entry created on successful update', function () {
|
||||||
|
$client = User::factory()->individual()->create();
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.edit', ['client' => $client])
|
||||||
|
->set('full_name', 'Updated Name')
|
||||||
|
->set('email', 'updated@example.com')
|
||||||
|
->set('national_id', '111111111')
|
||||||
|
->set('phone', '+970599111111')
|
||||||
|
->set('preferred_language', 'en')
|
||||||
|
->set('status', 'active')
|
||||||
|
->call('update')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect(AdminLog::where('action', 'update')
|
||||||
|
->where('target_type', 'user')
|
||||||
|
->where('target_id', $client->id)
|
||||||
|
->where('admin_id', $this->admin->id)
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// View Profile Tests
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
test('admin can access view individual client page', function () {
|
||||||
|
$client = User::factory()->individual()->create();
|
||||||
|
|
||||||
|
$this->actingAs($this->admin)
|
||||||
|
->get(route('admin.clients.individual.show', $client))
|
||||||
|
->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile page displays all client information', function () {
|
||||||
|
$client = User::factory()->individual()->create([
|
||||||
|
'full_name' => 'John Doe',
|
||||||
|
'email' => 'john@example.com',
|
||||||
|
'national_id' => '123456789',
|
||||||
|
'phone' => '+970599123456',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
Volt::test('admin.clients.individual.show', ['client' => $client])
|
||||||
|
->assertSee('John Doe')
|
||||||
|
->assertSee('john@example.com')
|
||||||
|
->assertSee('123456789')
|
||||||
|
->assertSee('+970599123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile shows consultation count', function () {
|
||||||
|
$client = User::factory()->individual()->create();
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
$component = Volt::test('admin.clients.individual.show', ['client' => $client]);
|
||||||
|
|
||||||
|
// The component should load consultation counts
|
||||||
|
expect($component->get('client')->consultations_count)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile shows timeline count', function () {
|
||||||
|
$client = User::factory()->individual()->create();
|
||||||
|
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
|
||||||
|
$component = Volt::test('admin.clients.individual.show', ['client' => $client]);
|
||||||
|
|
||||||
|
// The component should load timeline counts
|
||||||
|
expect($component->get('client')->timelines_count)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// Authorization Tests
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
test('non-admin cannot access individual clients pages', function () {
|
||||||
|
$client = User::factory()->individual()->create();
|
||||||
|
|
||||||
|
$this->actingAs($client);
|
||||||
|
|
||||||
|
$this->get(route('admin.clients.individual.index'))->assertForbidden();
|
||||||
|
$this->get(route('admin.clients.individual.create'))->assertForbidden();
|
||||||
|
$this->get(route('admin.clients.individual.show', $client))->assertForbidden();
|
||||||
|
$this->get(route('admin.clients.individual.edit', $client))->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unauthenticated user cannot access individual clients pages', function () {
|
||||||
|
$client = User::factory()->individual()->create();
|
||||||
|
|
||||||
|
$this->get(route('admin.clients.individual.index'))->assertRedirect(route('login'));
|
||||||
|
$this->get(route('admin.clients.individual.create'))->assertRedirect(route('login'));
|
||||||
|
$this->get(route('admin.clients.individual.show', $client))->assertRedirect(route('login'));
|
||||||
|
$this->get(route('admin.clients.individual.edit', $client))->assertRedirect(route('login'));
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue