From b2b287e1561cef1f5438e62913b0960d8e7d7840 Mon Sep 17 00:00:00 2001 From: Naser Mansour Date: Fri, 9 Jan 2026 18:53:04 +0200 Subject: [PATCH] complete story 15.2 --- docs/stories/story-15.2-list-filter.md | 44 ++- lang/ar/navigation.php | 1 + lang/ar/potential-clients.php | 13 + lang/en/navigation.php | 1 + lang/en/potential-clients.php | 13 + .../components/layouts/app/sidebar.blade.php | 8 + .../admin/potential-clients/index.blade.php | 211 +++++++++++++ routes/web.php | 5 + .../Feature/Admin/PotentialClientListTest.php | 283 ++++++++++++++++++ 9 files changed, 566 insertions(+), 13 deletions(-) create mode 100644 resources/views/livewire/admin/potential-clients/index.blade.php create mode 100644 tests/Feature/Admin/PotentialClientListTest.php diff --git a/docs/stories/story-15.2-list-filter.md b/docs/stories/story-15.2-list-filter.md index 5ea920a..ea6385e 100644 --- a/docs/stories/story-15.2-list-filter.md +++ b/docs/stories/story-15.2-list-filter.md @@ -189,19 +189,37 @@ Follow the existing pattern from `admin/clients/individual/index.blade.php`: ## Dev Checklist -- [ ] Add route for potential clients index -- [ ] Create Volt component `admin/potential-clients/index.blade.php` -- [ ] Implement search functionality with debounce -- [ ] Implement type filter dropdown -- [ ] Implement pagination with per-page selector -- [ ] Add navigation item to admin sidebar -- [ ] Style type badges with correct colors -- [ ] Handle empty state (no data) -- [ ] Handle empty state (no filter matches) -- [ ] Add English translations -- [ ] Add Arabic translations -- [ ] Test RTL layout -- [ ] Write feature tests for list and filtering +- [x] Add route for potential clients index +- [x] Create Volt component `admin/potential-clients/index.blade.php` +- [x] Implement search functionality with debounce +- [x] Implement type filter dropdown +- [x] Implement pagination with per-page selector +- [x] Add navigation item to admin sidebar +- [x] Style type badges with correct colors +- [x] Handle empty state (no data) +- [x] Handle empty state (no filter matches) +- [x] Add English translations +- [x] Add Arabic translations +- [x] Test RTL layout +- [x] Write feature tests for list and filtering + +## File List + +### Created +- `resources/views/livewire/admin/potential-clients/index.blade.php` - Volt component for list page +- `tests/Feature/Admin/PotentialClientListTest.php` - Feature tests for list and filtering + +### Modified +- `routes/web.php` - Added potential clients route +- `resources/views/components/layouts/app/sidebar.blade.php` - Added navigation item +- `lang/en/potential-clients.php` - Added list-related translations +- `lang/ar/potential-clients.php` - Added list-related translations +- `lang/en/navigation.php` - Added potential_clients key +- `lang/ar/navigation.php` - Added potential_clients key + +## Status + +Ready for Review ## Estimation diff --git a/lang/ar/navigation.php b/lang/ar/navigation.php index 5d76abb..8a292e4 100644 --- a/lang/ar/navigation.php +++ b/lang/ar/navigation.php @@ -31,6 +31,7 @@ return [ 'clients' => 'العملاء', 'individual_clients' => 'العملاء الأفراد', 'company_clients' => 'الشركات العملاء', + 'potential_clients' => 'العملاء المحتملون', 'bookings' => 'الحجوزات', 'pending_bookings' => 'الحجوزات المعلقة', 'all_consultations' => 'جميع الاستشارات', diff --git a/lang/ar/potential-clients.php b/lang/ar/potential-clients.php index badf61a..5ce6814 100644 --- a/lang/ar/potential-clients.php +++ b/lang/ar/potential-clients.php @@ -3,6 +3,19 @@ return [ 'title' => 'العملاء المحتملون', 'singular' => 'عميل محتمل', + 'subtitle' => 'إدارة العملاء المحتملين', + 'add_potential_client' => 'إضافة عميل محتمل', + 'all_types' => 'جميع الأنواع', + 'search_placeholder' => 'البحث بالاسم أو البريد أو الهاتف...', + 'no_potential_clients_found' => 'لم يتم العثور على عملاء محتملون', + 'no_potential_clients_match' => 'لا يوجد عملاء محتملون يطابقون الفلاتر', + 'clear_filters' => 'مسح الفلاتر', + 'per_page' => 'لكل صفحة', + 'actions' => 'الإجراءات', + 'view' => 'عرض', + 'edit' => 'تعديل', + 'delete' => 'حذف', + 'created_at' => 'تاريخ الإنشاء', 'types' => [ 'individual' => 'فرد', 'company' => 'شركة', diff --git a/lang/en/navigation.php b/lang/en/navigation.php index fe7d303..7d11ffd 100644 --- a/lang/en/navigation.php +++ b/lang/en/navigation.php @@ -31,6 +31,7 @@ return [ 'clients' => 'Clients', 'individual_clients' => 'Individual Clients', 'company_clients' => 'Company Clients', + 'potential_clients' => 'Potential Clients', 'bookings' => 'Bookings', 'pending_bookings' => 'Pending Bookings', 'all_consultations' => 'All Consultations', diff --git a/lang/en/potential-clients.php b/lang/en/potential-clients.php index fe4a60f..8b8bbdd 100644 --- a/lang/en/potential-clients.php +++ b/lang/en/potential-clients.php @@ -3,6 +3,19 @@ return [ 'title' => 'Potential Clients', 'singular' => 'Potential Client', + 'subtitle' => 'Manage prospective clients', + 'add_potential_client' => 'Add Potential Client', + 'all_types' => 'All Types', + 'search_placeholder' => 'Search by name, email, or phone...', + 'no_potential_clients_found' => 'No potential clients found', + 'no_potential_clients_match' => 'No potential clients match your filters', + 'clear_filters' => 'Clear filters', + 'per_page' => 'per page', + 'actions' => 'Actions', + 'view' => 'View', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'created_at' => 'Created', 'types' => [ 'individual' => 'Individual', 'company' => 'Company', diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index 7b293b7..dd9139c 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -81,6 +81,14 @@ > {{ __('navigation.company_clients') }} + + {{ __('navigation.potential_clients') }} + diff --git a/resources/views/livewire/admin/potential-clients/index.blade.php b/resources/views/livewire/admin/potential-clients/index.blade.php new file mode 100644 index 0000000..986d8b2 --- /dev/null +++ b/resources/views/livewire/admin/potential-clients/index.blade.php @@ -0,0 +1,211 @@ +resetPage(); + } + + public function updatedTypeFilter(): void + { + $this->resetPage(); + } + + public function updatedPerPage(): void + { + $this->resetPage(); + } + + public function clearFilters(): void + { + $this->search = ''; + $this->typeFilter = ''; + $this->resetPage(); + } + + public function with(): array + { + return [ + 'potentialClients' => PotentialClient::query() + ->when($this->search, fn ($q) => $q->where(function ($q) { + $q->where('name', 'like', "%{$this->search}%") + ->orWhere('email', 'like', "%{$this->search}%") + ->orWhere('phone', 'like', "%{$this->search}%"); + })) + ->when($this->typeFilter, fn ($q) => $q->where('type', $this->typeFilter)) + ->latest() + ->paginate($this->perPage), + 'types' => PotentialClientType::cases(), + ]; + } +}; ?> + +
+ + +
+
+
+ +
+
+ + {{ __('potential-clients.all_types') }} + @foreach ($types as $type) + + {{ $type->label() }} + + @endforeach + +
+
+ + 10 {{ __('potential-clients.per_page') }} + 25 {{ __('potential-clients.per_page') }} + 50 {{ __('potential-clients.per_page') }} + +
+ @if ($search || $typeFilter) + + {{ __('potential-clients.clear_filters') }} + + @endif +
+
+ +
+
+ + + + + + + + + + + + + @forelse ($potentialClients as $potentialClient) + + + + + + + + + @empty + + + + @endforelse + +
+ {{ __('potential-clients.fields.name') }} + + {{ __('potential-clients.fields.type') }} + + {{ __('potential-clients.fields.email') }} + + {{ __('potential-clients.fields.phone') }} + + {{ __('potential-clients.created_at') }} + + {{ __('potential-clients.actions') }} +
+
+ + {{ $potentialClient->name ?? '-' }} +
+
+ @switch($potentialClient->type) + @case(PotentialClientType::Individual) + {{ $potentialClient->type->label() }} + @break + @case(PotentialClientType::Company) + {{ $potentialClient->type->label() }} + @break + @case(PotentialClientType::Agency) + {{ $potentialClient->type->label() }} + @break + @endswitch + + {{ $potentialClient->email ?? '-' }} + + {{ $potentialClient->phone ?? '-' }} + + {{ $potentialClient->created_at->format('Y-m-d') }} + +
+ + +
+
+
+ + + @if ($search || $typeFilter) + {{ __('potential-clients.no_potential_clients_match') }} + @else + {{ __('potential-clients.no_potential_clients_found') }} + @endif + + @if ($search || $typeFilter) + + {{ __('potential-clients.clear_filters') }} + + @else + + {{ __('potential-clients.add_potential_client') }} + + @endif +
+
+
+ + @if ($potentialClients->hasPages()) +
+ {{ $potentialClients->links() }} +
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index 79bec36..461bafe 100644 --- a/routes/web.php +++ b/routes/web.php @@ -92,6 +92,11 @@ Route::middleware(['auth', 'active'])->group(function () { Volt::route('/blocked-times', 'admin.settings.blocked-times')->name('blocked-times'); }); + // Potential Clients Management + Route::prefix('potential-clients')->name('admin.potential-clients.')->group(function () { + Volt::route('/', 'admin.potential-clients.index')->name('index'); + }); + // Legal Pages Management Route::prefix('pages')->name('admin.pages.')->group(function () { Volt::route('/', 'admin.pages.index')->name('index'); diff --git a/tests/Feature/Admin/PotentialClientListTest.php b/tests/Feature/Admin/PotentialClientListTest.php new file mode 100644 index 0000000..4269340 --- /dev/null +++ b/tests/Feature/Admin/PotentialClientListTest.php @@ -0,0 +1,283 @@ +admin = User::factory()->admin()->create(); +}); + +// =========================================== +// Access Tests +// =========================================== + +test('admin can access potential clients index page', function () { + $this->actingAs($this->admin) + ->get(route('admin.potential-clients.index')) + ->assertOk(); +}); + +test('non-admin cannot access potential clients index page', function () { + $client = User::factory()->individual()->create(); + + $this->actingAs($client) + ->get(route('admin.potential-clients.index')) + ->assertForbidden(); +}); + +test('unauthenticated user cannot access potential clients index page', function () { + $this->get(route('admin.potential-clients.index')) + ->assertRedirect(route('login')); +}); + +// =========================================== +// List Display Tests +// =========================================== + +test('index page displays potential clients', function () { + $potentialClient = PotentialClient::factory()->create(['name' => 'Test Potential']); + + $this->actingAs($this->admin); + + Volt::test('admin.potential-clients.index') + ->assertSee('Test Potential'); +}); + +test('potential clients sorted by created_at desc by default', function () { + $oldClient = PotentialClient::factory()->create([ + 'name' => 'Old Client', + 'created_at' => now()->subDays(10), + ]); + + $newClient = PotentialClient::factory()->create([ + 'name' => 'New Client', + 'created_at' => now(), + ]); + + $this->actingAs($this->admin); + + Volt::test('admin.potential-clients.index') + ->assertSeeInOrder(['New Client', 'Old Client']); +}); + +test('displays type badge for individual potential client', function () { + PotentialClient::factory()->individual()->create(['name' => 'Individual Test']); + + $this->actingAs($this->admin); + + Volt::test('admin.potential-clients.index') + ->assertSee('Individual Test'); +}); + +test('displays type badge for company potential client', function () { + PotentialClient::factory()->company()->create(['name' => 'Company Test']); + + $this->actingAs($this->admin); + + Volt::test('admin.potential-clients.index') + ->assertSee('Company Test'); +}); + +test('displays type badge for agency potential client', function () { + PotentialClient::factory()->agency()->create(['name' => 'Agency Test']); + + $this->actingAs($this->admin); + + Volt::test('admin.potential-clients.index') + ->assertSee('Agency Test'); +}); + +// =========================================== +// Search Tests +// =========================================== + +test('can search potential clients by name', function () { + PotentialClient::factory()->create(['name' => 'John Doe']); + PotentialClient::factory()->create(['name' => 'Jane Smith']); + + $this->actingAs($this->admin); + + Volt::test('admin.potential-clients.index') + ->set('search', 'John') + ->assertSee('John Doe') + ->assertDontSee('Jane Smith'); +}); + +test('can search potential clients by email', function () { + PotentialClient::factory()->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + PotentialClient::factory()->create([ + 'name' => 'Jane Smith', + 'email' => 'jane@example.com', + ]); + + $this->actingAs($this->admin); + + Volt::test('admin.potential-clients.index') + ->set('search', 'john@') + ->assertSee('John Doe') + ->assertDontSee('Jane Smith'); +}); + +test('can search potential clients by phone', function () { + PotentialClient::factory()->create([ + 'name' => 'John Doe', + 'phone' => '+970599111111', + ]); + PotentialClient::factory()->create([ + 'name' => 'Jane Smith', + 'phone' => '+970599222222', + ]); + + $this->actingAs($this->admin); + + Volt::test('admin.potential-clients.index') + ->set('search', '111111') + ->assertSee('John Doe') + ->assertDontSee('Jane Smith'); +}); + +// =========================================== +// Type Filter Tests +// =========================================== + +test('can filter potential clients by individual type', function () { + PotentialClient::factory()->individual()->create(['name' => 'Individual Client']); + PotentialClient::factory()->company()->create(['name' => 'Company Client']); + PotentialClient::factory()->agency()->create(['name' => 'Agency Client']); + + $this->actingAs($this->admin); + + Volt::test('admin.potential-clients.index') + ->set('typeFilter', 'individual') + ->assertSee('Individual Client') + ->assertDontSee('Company Client') + ->assertDontSee('Agency Client'); +}); + +test('can filter potential clients by company type', function () { + PotentialClient::factory()->individual()->create(['name' => 'Individual Client']); + PotentialClient::factory()->company()->create(['name' => 'Company Client']); + PotentialClient::factory()->agency()->create(['name' => 'Agency Client']); + + $this->actingAs($this->admin); + + Volt::test('admin.potential-clients.index') + ->set('typeFilter', 'company') + ->assertDontSee('Individual Client') + ->assertSee('Company Client') + ->assertDontSee('Agency Client'); +}); + +test('can filter potential clients by agency type', function () { + PotentialClient::factory()->individual()->create(['name' => 'Individual Client']); + PotentialClient::factory()->company()->create(['name' => 'Company Client']); + PotentialClient::factory()->agency()->create(['name' => 'Agency Client']); + + $this->actingAs($this->admin); + + Volt::test('admin.potential-clients.index') + ->set('typeFilter', 'agency') + ->assertDontSee('Individual Client') + ->assertDontSee('Company Client') + ->assertSee('Agency Client'); +}); + +// =========================================== +// Clear Filters Tests +// =========================================== + +test('clear filters resets search and type filter', function () { + PotentialClient::factory()->create(['name' => 'Test Client']); + + $this->actingAs($this->admin); + + $component = Volt::test('admin.potential-clients.index') + ->set('search', 'something') + ->set('typeFilter', 'individual') + ->call('clearFilters'); + + expect($component->get('search'))->toBe(''); + expect($component->get('typeFilter'))->toBe(''); +}); + +// =========================================== +// Pagination Tests +// =========================================== + +test('can change per page value', function () { + $this->actingAs($this->admin); + + $component = Volt::test('admin.potential-clients.index') + ->set('perPage', 25); + + expect($component->get('perPage'))->toBe(25); +}); + +test('changing per page resets to first page', function () { + PotentialClient::factory()->count(15)->create(); + + $this->actingAs($this->admin); + + $component = Volt::test('admin.potential-clients.index') + ->set('perPage', 10) + ->call('gotoPage', 2) + ->set('perPage', 25); + + // After changing perPage, page should reset + expect($component->get('perPage'))->toBe(25); +}); + +// =========================================== +// Empty State Tests +// =========================================== + +test('shows empty state message when no potential clients exist', function () { + $this->actingAs($this->admin); + + Volt::test('admin.potential-clients.index') + ->assertSee(__('potential-clients.no_potential_clients_found')); +}); + +test('shows filter empty state when no potential clients match filters', function () { + PotentialClient::factory()->company()->create(['name' => 'Company Only']); + + $this->actingAs($this->admin); + + Volt::test('admin.potential-clients.index') + ->set('typeFilter', 'individual') + ->assertSee(__('potential-clients.no_potential_clients_match')); +}); + +// =========================================== +// Search Resets Pagination Tests +// =========================================== + +test('updating search resets page to 1', function () { + PotentialClient::factory()->count(15)->create(); + + $this->actingAs($this->admin); + + $component = Volt::test('admin.potential-clients.index') + ->call('gotoPage', 2) + ->set('search', 'test'); + + // Page should be reset after search update + expect($component->get('search'))->toBe('test'); +}); + +test('updating type filter resets page to 1', function () { + PotentialClient::factory()->count(15)->create(); + + $this->actingAs($this->admin); + + $component = Volt::test('admin.potential-clients.index') + ->call('gotoPage', 2) + ->set('typeFilter', 'individual'); + + // Page should be reset after filter update + expect($component->get('typeFilter'))->toBe('individual'); +});