complete story 15.2

This commit is contained in:
Naser Mansour 2026-01-09 18:53:04 +02:00
parent 959cc0e717
commit b2b287e156
9 changed files with 566 additions and 13 deletions

View File

@ -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

View File

@ -31,6 +31,7 @@ return [
'clients' => 'العملاء',
'individual_clients' => 'العملاء الأفراد',
'company_clients' => 'الشركات العملاء',
'potential_clients' => 'العملاء المحتملون',
'bookings' => 'الحجوزات',
'pending_bookings' => 'الحجوزات المعلقة',
'all_consultations' => 'جميع الاستشارات',

View File

@ -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' => 'شركة',

View File

@ -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',

View File

@ -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',

View File

@ -81,6 +81,14 @@
>
{{ __('navigation.company_clients') }}
</flux:navlist.item>
<flux:navlist.item
icon="user-plus"
:href="route('admin.potential-clients.index')"
:current="request()->routeIs('admin.potential-clients.*')"
wire:navigate
>
{{ __('navigation.potential_clients') }}
</flux:navlist.item>
</flux:navlist.group>
<flux:navlist.group :heading="__('navigation.case_management')" class="grid">

View File

@ -0,0 +1,211 @@
<?php
use App\Enums\PotentialClientType;
use App\Models\PotentialClient;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new class extends Component {
use WithPagination;
public string $search = '';
public string $typeFilter = '';
public int $perPage = 10;
public function updatedSearch(): void
{
$this->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(),
];
}
}; ?>
<div>
<div class="page-header mb-6">
<div>
<flux:heading size="xl" class="text-xl sm:text-2xl">{{ __('potential-clients.title') }}</flux:heading>
<flux:text class="mt-1 text-zinc-500">{{ __('potential-clients.subtitle') }}</flux:text>
</div>
<div class="header-actions">
<flux:button variant="primary" href="#" icon="plus" class="w-full sm:w-auto justify-center">
{{ __('potential-clients.add_potential_client') }}
</flux:button>
</div>
</div>
<div class="mb-6 rounded-lg border border-zinc-200 bg-white p-4">
<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="__('potential-clients.search_placeholder')"
icon="magnifying-glass"
/>
</div>
<div class="w-full sm:w-48">
<flux:select wire:model.live="typeFilter">
<flux:select.option value="">{{ __('potential-clients.all_types') }}</flux:select.option>
@foreach ($types as $type)
<flux:select.option value="{{ $type->value }}">
{{ $type->label() }}
</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 {{ __('potential-clients.per_page') }}</flux:select.option>
<flux:select.option value="25">25 {{ __('potential-clients.per_page') }}</flux:select.option>
<flux:select.option value="50">50 {{ __('potential-clients.per_page') }}</flux:select.option>
</flux:select>
</div>
@if ($search || $typeFilter)
<flux:button wire:click="clearFilters" variant="ghost" icon="x-mark">
{{ __('potential-clients.clear_filters') }}
</flux:button>
@endif
</div>
</div>
<div class="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<div class="table-scroll-wrapper">
<table class="min-w-full divide-y divide-zinc-200">
<thead class="bg-zinc-50">
<tr>
<th class="px-6 py-3 text-start text-xs font-medium uppercase tracking-wider text-zinc-500">
{{ __('potential-clients.fields.name') }}
</th>
<th class="px-6 py-3 text-start text-xs font-medium uppercase tracking-wider text-zinc-500">
{{ __('potential-clients.fields.type') }}
</th>
<th class="px-6 py-3 text-start text-xs font-medium uppercase tracking-wider text-zinc-500">
{{ __('potential-clients.fields.email') }}
</th>
<th class="px-6 py-3 text-start text-xs font-medium uppercase tracking-wider text-zinc-500">
{{ __('potential-clients.fields.phone') }}
</th>
<th class="px-6 py-3 text-start text-xs font-medium uppercase tracking-wider text-zinc-500">
{{ __('potential-clients.created_at') }}
</th>
<th class="px-6 py-3 text-end text-xs font-medium uppercase tracking-wider text-zinc-500">
{{ __('potential-clients.actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 bg-white">
@forelse ($potentialClients as $potentialClient)
<tr wire:key="potential-client-{{ $potentialClient->id }}">
<td class="whitespace-nowrap px-6 py-4">
<div class="flex items-center gap-3">
<flux:avatar size="sm" :name="$potentialClient->name ?? '?'" />
<span class="font-medium text-zinc-900">{{ $potentialClient->name ?? '-' }}</span>
</div>
</td>
<td class="whitespace-nowrap px-6 py-4">
@switch($potentialClient->type)
@case(PotentialClientType::Individual)
<flux:badge color="blue" size="sm">{{ $potentialClient->type->label() }}</flux:badge>
@break
@case(PotentialClientType::Company)
<flux:badge color="purple" size="sm">{{ $potentialClient->type->label() }}</flux:badge>
@break
@case(PotentialClientType::Agency)
<flux:badge color="amber" size="sm">{{ $potentialClient->type->label() }}</flux:badge>
@break
@endswitch
</td>
<td class="whitespace-nowrap px-6 py-4 text-zinc-600">
{{ $potentialClient->email ?? '-' }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-zinc-600">
{{ $potentialClient->phone ?? '-' }}
</td>
<td class="whitespace-nowrap px-6 py-4 text-zinc-600">
{{ $potentialClient->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="#"
:title="__('potential-clients.view')"
/>
<flux:button
variant="ghost"
size="sm"
icon="pencil"
href="#"
:title="__('potential-clients.edit')"
/>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-6 py-12 text-center">
<div class="flex flex-col items-center">
<flux:icon name="user-group" class="mb-4 h-12 w-12 text-zinc-400" />
<flux:text class="text-zinc-500">
@if ($search || $typeFilter)
{{ __('potential-clients.no_potential_clients_match') }}
@else
{{ __('potential-clients.no_potential_clients_found') }}
@endif
</flux:text>
@if ($search || $typeFilter)
<flux:button wire:click="clearFilters" variant="ghost" class="mt-4">
{{ __('potential-clients.clear_filters') }}
</flux:button>
@else
<flux:button variant="primary" href="#" class="mt-4">
{{ __('potential-clients.add_potential_client') }}
</flux:button>
@endif
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if ($potentialClients->hasPages())
<div class="border-t border-zinc-200 bg-zinc-50 px-6 py-4">
{{ $potentialClients->links() }}
</div>
@endif
</div>
</div>

View File

@ -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');

View File

@ -0,0 +1,283 @@
<?php
use App\Models\PotentialClient;
use App\Models\User;
use Livewire\Volt\Volt;
beforeEach(function () {
$this->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');
});