diff --git a/docs/stories/story-5.1-post-creation-editing.md b/docs/stories/story-5.1-post-creation-editing.md index 64d9631..5521924 100644 --- a/docs/stories/story-5.1-post-creation-editing.md +++ b/docs/stories/story-5.1-post-creation-editing.md @@ -49,6 +49,22 @@ So that **I can publish professional legal content for website visitors**. ## Technical Notes +### HTML Sanitization +Use the `mews/purifier` package for HTML sanitization via the `clean()` helper: +```bash +composer require mews/purifier +php artisan vendor:publish --provider="Mews\Purifier\PurifierServiceProvider" +``` + +Configure `config/purifier.php` to allow only safe rich text tags: +```php +'default' => [ + 'HTML.Allowed' => 'h2,h3,p,br,b,strong,i,em,u,ul,ol,li,a[href|title],blockquote', + 'AutoFormat.AutoParagraph' => true, + 'AutoFormat.RemoveEmpty' => true, +], +``` + ### Database Schema ```php Schema::create('posts', function (Blueprint $table) { @@ -147,7 +163,8 @@ new class extends Component { $action = 'create'; } - AdminLog::create([ + // AdminLog model exists from Epic 1 (Story 1.1) - see admin_logs table in schema + \App\Models\AdminLog::create([ 'admin_id' => auth()->id(), 'action_type' => $action, 'target_type' => 'post', @@ -181,12 +198,26 @@ new class extends Component { ]); } } + + // Preview opens modal with sanitized rendered content + public bool $showPreview = false; + + public function preview(): void + { + $this->showPreview = true; + } + + public function closePreview(): void + { + $this->showPreview = false; + } }; ``` ### Template with Rich Text Editor ```blade -
+{{-- wire:poll.60s triggers autoSave every 60 seconds for draft posts --}} +
@@ -238,13 +269,44 @@ new class extends Component { {{ __('admin.save_draft') }} + + {{ __('admin.preview') }} + {{ __('admin.publish') }}
+ + {{-- Preview Modal --}} + + {{ __('admin.preview') }} +
+
+

{{ __('admin.arabic_content') }}

+

{{ $title_ar }}

+
{!! clean($body_ar) !!}
+
+
+

{{ __('admin.english_content') }}

+

{{ $title_en }}

+
{!! clean($body_en) !!}
+
+
+
+ {{ __('admin.close') }} +
+
``` +### Trix Editor Setup +Include Trix editor assets in your layout or component: +```blade +{{-- In your layout head --}} + + +``` + ## Definition of Done - [ ] Can create post with bilingual content @@ -253,11 +315,164 @@ new class extends Component { - [ ] Can publish directly - [ ] Can edit existing posts - [ ] Auto-save works for drafts +- [ ] Preview modal displays sanitized content - [ ] HTML properly sanitized - [ ] Audit log created - [ ] Tests pass - [ ] Code formatted with Pint +## Test Scenarios + +### Unit Tests (Post Model) +```php +test('post has bilingual title accessor', function () { + $post = Post::factory()->create(['title_ar' => 'عنوان', 'title_en' => 'Title']); + app()->setLocale('ar'); + expect($post->title)->toBe('عنوان'); + app()->setLocale('en'); + expect($post->title)->toBe('Title'); +}); + +test('post excerpt strips HTML and limits to 150 chars', function () { + $post = Post::factory()->create(['body_en' => '

' . str_repeat('a', 200) . '

']); + expect(strlen($post->excerpt))->toBeLessThanOrEqual(153); // 150 + '...' + expect($post->excerpt)->not->toContain('

'); +}); + +test('published scope returns only published posts', function () { + Post::factory()->create(['status' => 'draft']); + Post::factory()->create(['status' => 'published']); + expect(Post::published()->count())->toBe(1); +}); +``` + +### Feature Tests (Volt Component) +```php +use Livewire\Volt\Volt; + +test('admin can create post with valid bilingual content', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.posts.create') + ->actingAs($admin) + ->set('title_ar', 'عنوان المقال') + ->set('title_en', 'Article Title') + ->set('body_ar', '

محتوى المقال

') + ->set('body_en', '

Article content

') + ->call('saveDraft') + ->assertHasNoErrors(); + + expect(Post::where('title_en', 'Article Title')->exists())->toBeTrue(); +}); + +test('create post fails with missing required fields', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.posts.create') + ->actingAs($admin) + ->set('title_ar', '') + ->set('title_en', 'Title') + ->call('save') + ->assertHasErrors(['title_ar']); +}); + +test('save draft preserves draft status', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.posts.create') + ->actingAs($admin) + ->set('title_ar', 'عنوان') + ->set('title_en', 'Title') + ->set('body_ar', 'محتوى') + ->set('body_en', 'Content') + ->call('saveDraft'); + + expect(Post::first()->status)->toBe('draft'); +}); + +test('publish changes status to published', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.posts.create') + ->actingAs($admin) + ->set('title_ar', 'عنوان') + ->set('title_en', 'Title') + ->set('body_ar', 'محتوى') + ->set('body_en', 'Content') + ->call('publish'); + + expect(Post::first()->status)->toBe('published'); +}); + +test('edit existing post updates content', function () { + $admin = User::factory()->admin()->create(); + $post = Post::factory()->create(['title_en' => 'Original']); + + Volt::test('admin.posts.edit', ['post' => $post]) + ->actingAs($admin) + ->set('title_en', 'Updated') + ->call('save') + ->assertHasNoErrors(); + + expect($post->fresh()->title_en)->toBe('Updated'); +}); + +test('auto-save only fires for draft posts', function () { + $admin = User::factory()->admin()->create(); + $post = Post::factory()->create(['status' => 'published', 'title_en' => 'Original']); + + Volt::test('admin.posts.edit', ['post' => $post]) + ->actingAs($admin) + ->set('title_en', 'Changed') + ->call('autoSave'); + + // Published post should NOT be auto-saved + expect($post->fresh()->title_en)->toBe('Original'); +}); + +test('HTML sanitization removes script tags but keeps allowed formatting', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.posts.create') + ->actingAs($admin) + ->set('title_ar', 'عنوان') + ->set('title_en', 'Title') + ->set('body_ar', '

نص

') + ->set('body_en', '

Safe

Bold') + ->call('saveDraft'); + + $post = Post::first(); + expect($post->body_en)->not->toContain(' + +@endpush +``` + +### NPM Dependencies + +Chart.js can be loaded via CDN (as shown) or installed via npm: + +```bash +npm install chart.js +``` + +Then import in `resources/js/app.js`: +```js +import Chart from 'chart.js/auto'; +window.Chart = Chart; +``` + +## Edge Cases & Error Handling + +| Scenario | Expected Behavior | +|----------|-------------------| +| No data for selected period | Show "No data available" message in chart area | +| Only one month of data | Chart renders single point with label | +| Zero consultations (division by zero for no-show rate) | Return 0% no-show rate, not error | +| Very large numbers | Y-axis scales appropriately with Chart.js auto-scaling | +| Custom range spans years | Labels show "Jan 2024", "Jan 2025" to distinguish | +| RTL language (Arabic) | Chart labels render correctly, legend on appropriate side | +| Chart.js fails to load | Show fallback message "Charts unavailable" | + +## Testing Requirements + +### Test File +`tests/Feature/Admin/AnalyticsChartsTest.php` + +### Test Scenarios + +```php +admin()->create(); + + $this->actingAs($admin) + ->get(route('admin.dashboard')) + ->assertSuccessful() + ->assertSee(__('Monthly Trends')); +}); + +test('chart data returns correct monthly client counts', function () { + // Create clients across different months + User::factory()->create([ + 'user_type' => 'individual', + 'created_at' => now()->subMonths(2), + ]); + User::factory()->count(3)->create([ + 'user_type' => 'individual', + 'created_at' => now()->subMonth(), + ]); + User::factory()->count(2)->create([ + 'user_type' => 'company', + 'created_at' => now(), + ]); + + $service = new AnalyticsService(); + $data = $service->getMonthlyNewClients(now()->subMonths(2)->startOfMonth(), 3); + + expect($data)->toBe([1, 3, 2]); +}); + +test('consultation breakdown calculates free vs paid correctly', function () { + Consultation::factory()->count(5)->create(['consultation_type' => 'free']); + Consultation::factory()->count(3)->create(['consultation_type' => 'paid']); + + $service = new AnalyticsService(); + $breakdown = $service->getConsultationTypeBreakdown(now()->subYear(), 12); + + expect($breakdown['free'])->toBe(5); + expect($breakdown['paid'])->toBe(3); +}); + +test('no-show rate calculates correctly', function () { + // Create 8 completed, 2 no-shows = 20% rate + Consultation::factory()->count(8)->create([ + 'status' => 'completed', + 'scheduled_date' => now(), + ]); + Consultation::factory()->count(2)->create([ + 'status' => 'no-show', + 'scheduled_date' => now(), + ]); + + $service = new AnalyticsService(); + $rates = $service->getMonthlyNoShowRates(now()->startOfMonth(), 1); + + expect($rates[0])->toBe(20.0); +}); + +test('no-show rate returns zero when no consultations exist', function () { + $service = new AnalyticsService(); + $rates = $service->getMonthlyNoShowRates(now()->startOfMonth(), 1); + + expect($rates[0])->toBe(0); +}); + +test('date range selector changes chart period', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.dashboard') + ->actingAs($admin) + ->assertSet('chartPeriod', '6m') + ->set('chartPeriod', '12m') + ->assertSet('chartPeriod', '12m'); +}); + +test('chart handles empty data gracefully', function () { + $admin = User::factory()->admin()->create(); + + // No clients or consultations created + $this->actingAs($admin) + ->get(route('admin.dashboard')) + ->assertSuccessful(); + // Should not throw errors +}); + +test('non-admin cannot access analytics charts', function () { + $client = User::factory()->create(['user_type' => 'individual']); + + $this->actingAs($client) + ->get(route('admin.dashboard')) + ->assertForbidden(); +}); +``` + +### Manual Testing Checklist +- [ ] Verify charts render on desktop (1200px+) +- [ ] Verify charts resize correctly on tablet (768px) +- [ ] Verify charts stack vertically on mobile (375px) +- [ ] Verify tooltips show exact values on hover +- [ ] Verify 6-month button is selected by default +- [ ] Verify 12-month button updates all charts +- [ ] Verify chart colors match brand (navy #0A1F44, gold #D4AF37) +- [ ] Verify charts work in Arabic (RTL) mode +- [ ] Verify loading state appears while data fetches +- [ ] Verify empty state message when no data + ## Definition of Done -- [ ] All charts render correctly -- [ ] Date range selector works -- [ ] Tooltips functional -- [ ] Mobile responsive -- [ ] Tests pass +- [ ] All three charts render correctly (trends, breakdown, no-show) +- [ ] Date range selector switches between 6/12 months +- [ ] Tooltips show exact values on all charts +- [ ] Charts are responsive on mobile, tablet, desktop +- [ ] Bilingual labels work (Arabic/English) +- [ ] Empty state handled gracefully +- [ ] No-show rate handles zero consultations (no division error) +- [ ] AnalyticsService unit tests pass +- [ ] Feature tests for chart data pass +- [ ] Code formatted with Pint +- [ ] Admin-only access enforced ## Estimation **Complexity:** Medium-High | **Effort:** 4-5 hours + +## Out of Scope +- Custom date range picker with calendar UI (can use simple month selects) +- Exporting charts as images +- Real-time chart updates (polling) - charts update on page load/range change only +- Animated chart transitions diff --git a/docs/stories/story-6.3-quick-actions-panel.md b/docs/stories/story-6.3-quick-actions-panel.md index d985321..613e9ec 100644 --- a/docs/stories/story-6.3-quick-actions-panel.md +++ b/docs/stories/story-6.3-quick-actions-panel.md @@ -6,50 +6,624 @@ ## User Story As an **admin**, I want **quick access to pending items and common tasks**, -So that **I can efficiently manage my daily workflow**. +So that **I can efficiently manage my daily workflow without navigating away from the dashboard**. + +## Business Context + +The admin dashboard from Story 6.1 provides static metrics. This story adds actionable widgets that surface items requiring immediate attention (pending bookings, today's schedule) and shortcuts to frequent tasks. This transforms the dashboard from an information display into a productivity hub. + +## Prerequisites / Dependencies + +This story requires the following to be completed first: + +| Dependency | Required From | What's Needed | +|------------|---------------|---------------| +| Dashboard Layout | Story 6.1 | Admin dashboard page with card-based layout | +| Consultation Model | Epic 3 | `consultations` table with `status`, `scheduled_date`, `scheduled_time` fields | +| Consultation Scopes | Epic 3 | `pending()` and `approved()` query scopes on Consultation model | +| Timeline Model | Epic 4 | `timelines` table with `user_id`, `case_name` fields | +| TimelineUpdate Model | Epic 4 | `timeline_updates` table with `timeline_id`, `created_at` | +| User Model | Epic 2 | Client users that consultations reference | +| Post Routes | Epic 5 | Route for creating posts (`admin.posts.create`) | +| User Routes | Epic 2 | Route for creating users (`admin.users.create`) | + +**References:** +- Story 6.1 Dashboard Layout: `docs/stories/story-6.1-dashboard-overview-statistics.md` +- Epic 6 Dashboard Details: `docs/epics/epic-6-admin-dashboard.md` +- PRD Dashboard Section: `docs/prd.md` Section 5.7 (Admin Dashboard) ## Acceptance Criteria ### Pending Bookings Widget -- [ ] Count badge with urgent indicator -- [ ] Link to booking management -- [ ] Mini list of recent pending (3-5) +- [ ] Display count badge showing number of pending consultation requests +- [ ] Urgent indicator (red/warning styling) when pending count > 0 +- [ ] Mini list showing up to 5 most recent pending bookings with: + - Client name + - Requested date + - Consultation type (free/paid) +- [ ] "View All" link navigating to booking management page (`admin.consultations.index`) +- [ ] Empty state message when no pending bookings ### Today's Schedule Widget -- [ ] List of today's consultations -- [ ] Time and client name -- [ ] Quick status update buttons +- [ ] List of today's approved consultations ordered by time +- [ ] Each item displays: + - Scheduled time (formatted for locale) + - Client name + - Consultation type badge (free/paid) +- [ ] Quick status buttons for each consultation: + - "Complete" - marks as completed + - "No-show" - marks as no-show +- [ ] Empty state message when no consultations scheduled today ### Recent Timeline Updates Widget -- [ ] Last 5 updates made -- [ ] Quick link to timeline +- [ ] Display last 5 timeline updates across all clients +- [ ] Each item shows: + - Update preview (truncated to ~50 chars) + - Case name + - Client name + - Relative timestamp ("2 hours ago") +- [ ] Click navigates to the specific timeline (`admin.timelines.show`) +- [ ] Empty state message when no recent updates ### Quick Action Buttons -- [ ] Create user -- [ ] Create post -- [ ] Block time slot +- [ ] **Create User** button - navigates to `admin.users.create` +- [ ] **Create Post** button - navigates to `admin.posts.create` +- [ ] **Block Time Slot** button - opens modal to block availability + - Date picker for selecting date + - Time range (start/end time) + - Optional reason field + - Save creates a "blocked" consultation record -### Notification Bell -- [ ] Pending items count +### Notification Bell (Header) +- [ ] Bell icon in admin header/navbar +- [ ] Badge showing total pending items count (pending bookings) +- [ ] Badge hidden when count is 0 +- [ ] Click navigates to pending bookings -## Technical Notes +### Real-time Updates +- [ ] Widgets auto-refresh via Livewire polling every 30 seconds +- [ ] No full page reload required +- [ ] Visual indication during refresh (subtle loading state) + +## Technical Implementation + +### Files to Create/Modify + +| File | Purpose | +|------|---------| +| `resources/views/livewire/admin/dashboard.blade.php` | Add widget sections to existing dashboard | +| `resources/views/livewire/admin/widgets/pending-bookings.blade.php` | Pending bookings widget (Volt component) | +| `resources/views/livewire/admin/widgets/todays-schedule.blade.php` | Today's schedule widget (Volt component) | +| `resources/views/livewire/admin/widgets/recent-updates.blade.php` | Recent timeline updates widget (Volt component) | +| `resources/views/livewire/admin/widgets/quick-actions.blade.php` | Quick action buttons widget (Volt component) | +| `resources/views/components/layouts/admin.blade.php` | Add notification bell to admin header | + +### Widget Architecture + +Each widget is a separate Volt component for isolation and independent polling: ```php -public function with(): array +// resources/views/livewire/admin/widgets/pending-bookings.blade.php + Consultation::pending()->count(), + 'pendingBookings' => Consultation::pending() + ->with('user:id,name') + ->latest() + ->take(5) + ->get(), + ]; + } +}; ?> + +
+
+ {{ __('Pending Bookings') }} + @if($pendingCount > 0) + {{ $pendingCount }} + @endif +
+ + @forelse($pendingBookings as $booking) +
+
{{ $booking->user->name }}
+
+ {{ $booking->scheduled_date->format('M j') }} - + {{ $booking->consultation_type }} +
+
+ @empty + {{ __('No pending bookings') }} + @endforelse + + @if($pendingCount > 5) + + {{ __('View all :count pending', ['count' => $pendingCount]) }} + + @endif +
+``` + +### Today's Schedule Widget with Actions + +```php +// resources/views/livewire/admin/widgets/todays-schedule.blade.php + Consultation::approved() + ->whereDate('scheduled_date', today()) + ->with('user:id,name') + ->orderBy('scheduled_time') + ->get(), + ]; + } + + public function markComplete(int $consultationId): void + { + $consultation = Consultation::findOrFail($consultationId); + $consultation->update(['status' => 'completed']); + // Optionally dispatch event for logging + } + + public function markNoShow(int $consultationId): void + { + $consultation = Consultation::findOrFail($consultationId); + $consultation->update(['status' => 'no-show']); + } +}; ?> + +
+ {{ __("Today's Schedule") }} + + @forelse($todaySchedule as $consultation) +
+
+
+ {{ \Carbon\Carbon::parse($consultation->scheduled_time)->format('g:i A') }} +
+
{{ $consultation->user->name }}
+
+
+ + {{ __('Complete') }} + + + {{ __('No-show') }} + +
+
+ @empty + {{ __('No consultations scheduled today') }} + @endforelse +
+``` + +### Block Time Slot Modal + +```php +// In quick-actions.blade.php +blockDate = today()->format('Y-m-d'); + $this->showBlockModal = true; + } + + public function blockTimeSlot(): void + { + $this->validate(); + + Consultation::create([ + 'scheduled_date' => $this->blockDate, + 'scheduled_time' => $this->blockStartTime, + 'end_time' => $this->blockEndTime, + 'status' => 'blocked', + 'notes' => $this->blockReason, + 'user_id' => null, // No client for blocked slots + ]); + + $this->showBlockModal = false; + $this->reset(['blockDate', 'blockStartTime', 'blockEndTime', 'blockReason']); + } +}; ?> + +
+
+ + {{ __('Create User') }} + + + {{ __('Create Post') }} + + + {{ __('Block Time Slot') }} + +
+ + + {{ __('Block Time Slot') }} + +
+ + {{ __('Date') }} + + + +
+ + {{ __('Start Time') }} + + + + {{ __('End Time') }} + + +
+ + + {{ __('Reason (optional)') }} + + + +
+ + {{ __('Cancel') }} + + + {{ __('Block Slot') }} + +
+
+
+
+``` + +### Dashboard Integration + +```php +// In resources/views/livewire/admin/dashboard.blade.php +// Add after the metrics cards from Story 6.1 + +
+ {{-- Quick Actions Panel --}} +
+
+ {{ __('Quick Actions') }} + +
+
+ + {{-- Pending Bookings Widget --}} +
+ +
+ + {{-- Today's Schedule Widget --}} +
+ +
+ + {{-- Recent Updates Widget --}} +
+ +
+
+``` + +### Notification Bell Component + +Add to admin layout header: + +```blade +{{-- In resources/views/components/layouts/admin.blade.php header section --}} + +``` + +### Required Model Scopes + +Ensure Consultation model has these scopes (from Epic 3): + +```php +// app/Models/Consultation.php +public function scopePending(Builder $query): Builder { - return [ - 'pendingBookings' => Consultation::pending()->latest()->take(5)->get(), - 'todaySchedule' => Consultation::approved()->whereDate('scheduled_date', today())->orderBy('scheduled_time')->get(), - 'recentUpdates' => TimelineUpdate::latest()->take(5)->with('timeline.user')->get(), - ]; + return $query->where('status', 'pending'); +} + +public function scopeApproved(Builder $query): Builder +{ + return $query->where('status', 'approved'); } ``` +### Flux UI Components Used +- `` - Widget titles +- `` - Count badges, status indicators +- `` - Action buttons +- `` - Empty state messages +- `` - Block time slot modal +- `` - Form inputs +- `` - Reason field +- `` - Form field wrapper +- `` - Form labels +- `` - Bell icon for notifications + +## Edge Cases & Error Handling + +| Scenario | Expected Behavior | +|----------|-------------------| +| No pending bookings | Show "No pending bookings" message, badge hidden | +| Empty today's schedule | Show "No consultations scheduled today" message | +| No timeline updates | Show "No recent updates" message | +| 100+ pending bookings | Badge shows "99+", list shows 5, "View all" shows count | +| Quick action routes don't exist | Buttons still render, navigate to 404 (graceful) | +| Mark complete fails | Show error toast, don't update UI | +| Block time slot validation fails | Show inline validation errors | +| Blocked slot in past | Validation prevents it (after_or_equal:today) | +| User deleted after consultation created | Handle with optional chaining on user name | +| Polling during action | `wire:poll` pauses during active requests | + +## Assumptions + +1. **Consultation statuses:** `pending`, `approved`, `completed`, `no-show`, `cancelled`, `blocked` +2. **Blocked slots:** Stored as Consultation records with `status = 'blocked'` and `user_id = null` +3. **Routes exist:** `admin.consultations.index`, `admin.users.create`, `admin.posts.create` from respective epics +4. **Admin middleware:** All routes protected by `auth` and `admin` middleware +5. **Timezone:** All times displayed in application timezone (from config) + +## Testing Requirements + +### Test File +`tests/Feature/Admin/QuickActionsPanelTest.php` + +### Test Scenarios + +```php +use App\Models\User; +use App\Models\Consultation; +use App\Models\Timeline; +use App\Models\TimelineUpdate; +use Livewire\Volt\Volt; + +// Widget Display Tests +test('pending bookings widget displays pending count', function () { + $admin = User::factory()->admin()->create(); + Consultation::factory()->count(3)->pending()->create(); + + Volt::test('admin.widgets.pending-bookings') + ->actingAs($admin) + ->assertSee('3') + ->assertSee('Pending Bookings'); +}); + +test('pending bookings widget shows empty state when none pending', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.widgets.pending-bookings') + ->actingAs($admin) + ->assertSee('No pending bookings'); +}); + +test('today schedule widget shows only today approved consultations', function () { + $admin = User::factory()->admin()->create(); + $client = User::factory()->client()->create(['name' => 'Test Client']); + + // Today's approved - should show + Consultation::factory()->approved()->create([ + 'user_id' => $client->id, + 'scheduled_date' => today(), + 'scheduled_time' => '10:00', + ]); + + // Tomorrow's approved - should NOT show + Consultation::factory()->approved()->create([ + 'scheduled_date' => today()->addDay(), + ]); + + // Today's pending - should NOT show + Consultation::factory()->pending()->create([ + 'scheduled_date' => today(), + ]); + + Volt::test('admin.widgets.todays-schedule') + ->actingAs($admin) + ->assertSee('Test Client') + ->assertSee('10:00'); +}); + +test('today schedule widget shows empty state when no consultations', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.widgets.todays-schedule') + ->actingAs($admin) + ->assertSee('No consultations scheduled today'); +}); + +// Action Tests +test('admin can mark consultation as completed', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create([ + 'scheduled_date' => today(), + ]); + + Volt::test('admin.widgets.todays-schedule') + ->actingAs($admin) + ->call('markComplete', $consultation->id) + ->assertHasNoErrors(); + + expect($consultation->fresh()->status)->toBe('completed'); +}); + +test('admin can mark consultation as no-show', function () { + $admin = User::factory()->admin()->create(); + $consultation = Consultation::factory()->approved()->create([ + 'scheduled_date' => today(), + ]); + + Volt::test('admin.widgets.todays-schedule') + ->actingAs($admin) + ->call('markNoShow', $consultation->id) + ->assertHasNoErrors(); + + expect($consultation->fresh()->status)->toBe('no-show'); +}); + +// Block Time Slot Tests +test('admin can block a time slot', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.widgets.quick-actions') + ->actingAs($admin) + ->call('openBlockModal') + ->set('blockDate', today()->format('Y-m-d')) + ->set('blockStartTime', '09:00') + ->set('blockEndTime', '10:00') + ->set('blockReason', 'Personal appointment') + ->call('blockTimeSlot') + ->assertHasNoErrors(); + + expect(Consultation::where('status', 'blocked')->count())->toBe(1); +}); + +test('block time slot validates required fields', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.widgets.quick-actions') + ->actingAs($admin) + ->call('openBlockModal') + ->set('blockDate', '') + ->call('blockTimeSlot') + ->assertHasErrors(['blockDate']); +}); + +test('block time slot prevents past dates', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.widgets.quick-actions') + ->actingAs($admin) + ->call('openBlockModal') + ->set('blockDate', today()->subDay()->format('Y-m-d')) + ->set('blockStartTime', '09:00') + ->set('blockEndTime', '10:00') + ->call('blockTimeSlot') + ->assertHasErrors(['blockDate']); +}); + +// Recent Updates Widget Tests +test('recent updates widget shows last 5 updates', function () { + $admin = User::factory()->admin()->create(); + $timeline = Timeline::factory()->create(); + TimelineUpdate::factory()->count(7)->create(['timeline_id' => $timeline->id]); + + Volt::test('admin.widgets.recent-updates') + ->actingAs($admin) + ->assertViewHas('recentUpdates', fn($updates) => $updates->count() === 5); +}); + +// Access Control Tests +test('non-admin cannot access dashboard widgets', function () { + $client = User::factory()->client()->create(); + + $this->actingAs($client) + ->get(route('admin.dashboard')) + ->assertForbidden(); +}); + +// Notification Bell Tests +test('notification bell shows pending count in header', function () { + $admin = User::factory()->admin()->create(); + Consultation::factory()->count(5)->pending()->create(); + + $this->actingAs($admin) + ->get(route('admin.dashboard')) + ->assertSee('5'); // Badge count +}); + +test('notification bell hidden when no pending items', function () { + $admin = User::factory()->admin()->create(); + + // No badge should render when count is 0 + $this->actingAs($admin) + ->get(route('admin.dashboard')) + ->assertSuccessful(); +}); +``` + +### Manual Testing Checklist +- [ ] Verify polling updates data every 30 seconds without page reload +- [ ] Verify responsive layout on mobile (375px) - widgets stack vertically +- [ ] Verify responsive layout on tablet (768px) - 2 column grid +- [ ] Verify responsive layout on desktop (1200px+) - 3 column grid +- [ ] Verify pending badge uses red/warning color when > 0 +- [ ] Verify quick action buttons navigate to correct pages +- [ ] Verify block time slot modal opens and closes correctly +- [ ] Verify RTL layout works correctly (Arabic) +- [ ] Verify notification bell shows in header on all admin pages +- [ ] Verify "Complete" and "No-show" buttons work without page refresh + ## Definition of Done -- [ ] All widgets display correctly -- [ ] Quick actions work -- [ ] Real-time updates with polling -- [ ] Tests pass +- [ ] All four widgets display correctly with accurate data +- [ ] Widgets auto-refresh via Livewire polling (30s interval) +- [ ] Quick action buttons navigate to correct routes +- [ ] Block time slot modal creates blocked consultation record +- [ ] Mark complete/no-show actions update consultation status +- [ ] Notification bell shows pending count in admin header +- [ ] Empty states render gracefully for all widgets +- [ ] Edge cases handled (100+ items, missing data) +- [ ] All tests pass +- [ ] Responsive layout works on mobile, tablet, desktop +- [ ] RTL support for Arabic +- [ ] Code formatted with Pint + +## Out of Scope +- WebSocket real-time updates (using polling instead) +- Push notifications to browser +- Email notifications for pending items +- Widget position customization +- Dashboard layout preferences ## Estimation -**Complexity:** Medium | **Effort:** 3-4 hours +**Complexity:** Medium | **Effort:** 4-5 hours diff --git a/docs/stories/story-6.4-data-export-user-lists.md b/docs/stories/story-6.4-data-export-user-lists.md index 1854a1c..6518a1a 100644 --- a/docs/stories/story-6.4-data-export-user-lists.md +++ b/docs/stories/story-6.4-data-export-user-lists.md @@ -8,61 +8,372 @@ As an **admin**, I want **to export user data in CSV and PDF formats**, So that **I can generate reports and maintain offline records**. +## Dependencies +- **Epic 2 Complete:** User Management system with User model and data +- **Story 6.1:** Dashboard Overview (provides the admin dashboard layout where export UI will be accessible) +- **Packages Required:** `league/csv` and `barryvdh/laravel-dompdf` must be installed + +## References +- PRD Section 11.2: Export Functionality - defines exportable data and formats +- PRD Section 5.3: User Management System - defines user fields (individual vs company) +- `docs/epics/epic-6-admin-dashboard.md#story-6.4` - epic-level acceptance criteria +- User model: `app/Models/User.php` - contains `user_type`, `national_id`, `company_cert_number` fields + ## Acceptance Criteria ### Export Options -- [ ] Export all users -- [ ] Export individual clients only -- [ ] Export company clients only +- [ ] Export all users (both individual and company clients) +- [ ] Export individual clients only (`user_type = 'individual'`) +- [ ] Export company clients only (`user_type = 'company'`) ### Filters -- [ ] Date range (created) -- [ ] Status (active/deactivated) +- [ ] Date range filter on `created_at` field (start date, end date) +- [ ] Status filter: active, deactivated, or all +- [ ] Filters combine with export type (e.g., "active individual clients created in 2024") ### CSV Export Includes -- [ ] Name, email, phone -- [ ] User type -- [ ] National ID / Company registration -- [ ] Status -- [ ] Created date +- [ ] Name (`name` for individual, `company_name` for company) +- [ ] Email (`email`) +- [ ] Phone (`phone`) +- [ ] User type (`user_type`: individual/company) +- [ ] National ID / Company registration (`national_id` for individual, `company_cert_number` for company) +- [ ] Status (`status`: active/deactivated) +- [ ] Created date (`created_at` formatted per locale) +- [ ] UTF-8 BOM for proper Arabic character display in Excel ### PDF Export Includes -- [ ] Same data with professional formatting -- [ ] Libra branding header -- [ ] Generation timestamp +- [ ] Same data fields as CSV in tabular format +- [ ] Libra branding header (logo, firm name) +- [ ] Generation timestamp in footer +- [ ] Page numbers if multiple pages +- [ ] Professional formatting with brand colors (Navy #0A1F44, Gold #D4AF37) -### Bilingual -- [ ] Column headers based on admin language +### Bilingual Support +- [ ] Column headers based on admin's `preferred_language` setting +- [ ] Date formatting per locale (DD/MM/YYYY for Arabic, MM/DD/YYYY for English) +- [ ] PDF title and footer text bilingual -## Technical Notes +### UI Requirements +- [ ] Export section accessible from Admin User Management page +- [ ] Filter form with: user type dropdown, status dropdown, date range picker +- [ ] "Export CSV" and "Export PDF" buttons +- [ ] Loading indicator during export generation +- [ ] Success toast on download start +- [ ] Error toast if export fails -Use league/csv for CSV and barryvdh/laravel-dompdf for PDF. +## Technical Implementation +### Files to Create/Modify + +**Livewire Component:** +``` +resources/views/livewire/admin/users/export-users.blade.php +``` + +**PDF Template:** +``` +resources/views/pdf/users-export.blade.php +``` + +**Translation File (add export keys):** +``` +resources/lang/ar/export.php +resources/lang/en/export.php +``` + +### Routes +Export actions will be methods on the Livewire component - no separate routes needed. The component handles streaming responses. + +### Key User Model Fields ```php +// From User model (app/Models/User.php) +$user->name // Individual's full name +$user->company_name // Company name (if company type) +$user->email +$user->phone +$user->user_type // 'individual' or 'company' +$user->national_id // Individual's national ID +$user->company_cert_number // Company registration number +$user->status // 'active' or 'deactivated' +$user->preferred_language // 'ar' or 'en' +$user->created_at +``` + +### Implementation Pattern + +**CSV Export (Streamed Response):** +```php +use League\Csv\Writer; +use Symfony\Component\HttpFoundation\StreamedResponse; + public function exportCsv(): StreamedResponse { - return response()->streamDownload(function () { - $csv = Writer::createFromString(); - $csv->insertOne([__('export.name'), __('export.email'), ...]); + $users = $this->getFilteredUsers(); + $locale = auth()->user()->preferred_language ?? 'ar'; - User::whereIn('user_type', ['individual', 'company']) - ->cursor() - ->each(fn($user) => $csv->insertOne([ - $user->name, + return response()->streamDownload(function () use ($users, $locale) { + $csv = Writer::createFromString(); + + // UTF-8 BOM for Excel Arabic support + echo "\xEF\xBB\xBF"; + + // Headers based on admin language + $csv->insertOne([ + __('export.name', [], $locale), + __('export.email', [], $locale), + __('export.phone', [], $locale), + __('export.user_type', [], $locale), + __('export.id_number', [], $locale), + __('export.status', [], $locale), + __('export.created_at', [], $locale), + ]); + + $users->cursor()->each(function ($user) use ($csv, $locale) { + $csv->insertOne([ + $user->user_type === 'company' ? $user->company_name : $user->name, $user->email, - // ... - ])); + $user->phone, + __('export.type_' . $user->user_type, [], $locale), + $user->user_type === 'company' ? $user->company_cert_number : $user->national_id, + __('export.status_' . $user->status, [], $locale), + $user->created_at->format($locale === 'ar' ? 'd/m/Y' : 'm/d/Y'), + ]); + }); echo $csv->toString(); - }, 'users-export.csv'); + }, 'users-export-' . now()->format('Y-m-d') . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); } ``` +**PDF Export:** +```php +use Barryvdh\DomPDF\Facade\Pdf; + +public function exportPdf() +{ + $users = $this->getFilteredUsers()->get(); + $locale = auth()->user()->preferred_language ?? 'ar'; + + $pdf = Pdf::loadView('pdf.users-export', [ + 'users' => $users, + 'locale' => $locale, + 'generatedAt' => now(), + 'filters' => $this->getActiveFilters(), + ]); + + // RTL support for Arabic + if ($locale === 'ar') { + $pdf->setOption('isHtml5ParserEnabled', true); + $pdf->setOption('defaultFont', 'DejaVu Sans'); + } + + return response()->streamDownload( + fn () => print($pdf->output()), + 'users-export-' . now()->format('Y-m-d') . '.pdf' + ); +} +``` + +**Filter Query Builder:** +```php +private function getFilteredUsers() +{ + return User::query() + ->when($this->userType !== 'all', fn ($q) => $q->where('user_type', $this->userType)) + ->when($this->status !== 'all', fn ($q) => $q->where('status', $this->status)) + ->when($this->dateFrom, fn ($q) => $q->whereDate('created_at', '>=', $this->dateFrom)) + ->when($this->dateTo, fn ($q) => $q->whereDate('created_at', '<=', $this->dateTo)) + ->whereIn('user_type', ['individual', 'company']) // Exclude admin + ->orderBy('created_at', 'desc'); +} +``` + +### Translation Keys Required +```php +// resources/lang/en/export.php +return [ + 'name' => 'Name', + 'email' => 'Email', + 'phone' => 'Phone', + 'user_type' => 'User Type', + 'id_number' => 'ID Number', + 'status' => 'Status', + 'created_at' => 'Created Date', + 'type_individual' => 'Individual', + 'type_company' => 'Company', + 'status_active' => 'Active', + 'status_deactivated' => 'Deactivated', + 'users_export_title' => 'Users Export', + 'generated_at' => 'Generated at', + 'page' => 'Page', +]; + +// resources/lang/ar/export.php +return [ + 'name' => 'الاسم', + 'email' => 'البريد الإلكتروني', + 'phone' => 'الهاتف', + 'user_type' => 'نوع المستخدم', + 'id_number' => 'رقم الهوية', + 'status' => 'الحالة', + 'created_at' => 'تاريخ الإنشاء', + 'type_individual' => 'فرد', + 'type_company' => 'شركة', + 'status_active' => 'نشط', + 'status_deactivated' => 'معطل', + 'users_export_title' => 'تصدير المستخدمين', + 'generated_at' => 'تم الإنشاء في', + 'page' => 'صفحة', +]; +``` + +## Edge Cases & Error Handling + +### Empty Dataset +- If no users match filters, show info message: "No users match the selected filters" +- Do not generate empty export file + +### Large Datasets (1000+ users) +- Use `cursor()` for CSV to avoid memory issues +- For PDF with 500+ users, show warning: "Large export may take a moment" +- Consider chunking PDF generation or limiting to 500 users with message to narrow filters + +### Export Failures +- Catch exceptions and show error toast: "Export failed. Please try again." +- Log error details for debugging + +### Concurrent Requests +- Disable export buttons while export is in progress (wire:loading) + +### Arabic Content +- CSV: Include UTF-8 BOM (`\xEF\xBB\xBF`) for Excel compatibility +- PDF: Use font that supports Arabic (DejaVu Sans or Cairo) + +## Testing Requirements + +### Feature Tests +```php +// tests/Feature/Admin/UserExportTest.php + +test('admin can export all users as CSV', function () { + $admin = User::factory()->admin()->create(); + User::factory()->count(5)->individual()->create(); + User::factory()->count(3)->company()->create(); + + Volt::test('admin.users.export-users') + ->actingAs($admin) + ->set('userType', 'all') + ->call('exportCsv') + ->assertFileDownloaded(); +}); + +test('admin can export filtered users by type', function () { + $admin = User::factory()->admin()->create(); + User::factory()->count(5)->individual()->create(); + User::factory()->count(3)->company()->create(); + + // Test individual filter + Volt::test('admin.users.export-users') + ->actingAs($admin) + ->set('userType', 'individual') + ->call('exportCsv') + ->assertFileDownloaded(); +}); + +test('admin can export users filtered by status', function () { + $admin = User::factory()->admin()->create(); + User::factory()->count(3)->individual()->active()->create(); + User::factory()->count(2)->individual()->deactivated()->create(); + + Volt::test('admin.users.export-users') + ->actingAs($admin) + ->set('status', 'active') + ->call('exportCsv') + ->assertFileDownloaded(); +}); + +test('admin can export users filtered by date range', function () { + $admin = User::factory()->admin()->create(); + User::factory()->individual()->create(['created_at' => now()->subDays(10)]); + User::factory()->individual()->create(['created_at' => now()->subDays(5)]); + User::factory()->individual()->create(['created_at' => now()]); + + Volt::test('admin.users.export-users') + ->actingAs($admin) + ->set('dateFrom', now()->subDays(7)->format('Y-m-d')) + ->set('dateTo', now()->format('Y-m-d')) + ->call('exportCsv') + ->assertFileDownloaded(); +}); + +test('admin can export users as PDF', function () { + $admin = User::factory()->admin()->create(); + User::factory()->count(5)->individual()->create(); + + Volt::test('admin.users.export-users') + ->actingAs($admin) + ->call('exportPdf') + ->assertFileDownloaded(); +}); + +test('export shows message when no users match filters', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.users.export-users') + ->actingAs($admin) + ->set('userType', 'individual') + ->set('status', 'active') + ->call('exportCsv') + ->assertHasNoErrors() + ->assertDispatched('notify'); // Empty dataset notification +}); + +test('export headers use admin preferred language', function () { + $admin = User::factory()->admin()->create(['preferred_language' => 'ar']); + User::factory()->individual()->create(); + + // This would need custom assertion to check CSV content + // Verify Arabic headers are used +}); + +test('non-admin cannot access export', function () { + $client = User::factory()->individual()->create(); + + Volt::test('admin.users.export-users') + ->actingAs($client) + ->assertForbidden(); +}); +``` + +### Manual Testing Checklist +- [ ] CSV opens correctly in Excel with Arabic characters displaying properly +- [ ] PDF renders with Libra branding (logo, colors) +- [ ] PDF Arabic content is readable (correct font, RTL if needed) +- [ ] Date range picker works correctly +- [ ] Combined filters produce correct results +- [ ] Large export (500+ users) completes without timeout +- [ ] Export buttons disabled during generation (loading state) + ## Definition of Done -- [ ] CSV export works with all filters -- [ ] PDF export works with branding -- [ ] Large datasets handled efficiently -- [ ] Tests pass +- [ ] Livewire component created with filter form and export buttons +- [ ] CSV export works with all filter combinations +- [ ] PDF export renders with Libra branding header and footer +- [ ] Translation files created for both Arabic and English +- [ ] UTF-8 BOM included in CSV for Arabic Excel compatibility +- [ ] Large datasets handled efficiently using cursor/chunking +- [ ] Empty dataset shows appropriate message (no empty file generated) +- [ ] Error handling with user-friendly toast messages +- [ ] Loading states on export buttons +- [ ] All feature tests pass +- [ ] Manual testing checklist completed +- [ ] Code formatted with Pint ## Estimation -**Complexity:** Medium | **Effort:** 3-4 hours +**Complexity:** Medium | **Effort:** 4-5 hours + +## Out of Scope +- Background job processing for very large exports (defer to future enhancement) +- Email delivery of export files +- Scheduled/automated exports diff --git a/docs/stories/story-6.5-data-export-consultation-records.md b/docs/stories/story-6.5-data-export-consultation-records.md index 53c382a..e565b97 100644 --- a/docs/stories/story-6.5-data-export-consultation-records.md +++ b/docs/stories/story-6.5-data-export-consultation-records.md @@ -3,57 +3,297 @@ ## Epic Reference **Epic 6:** Admin Dashboard +## Dependencies +- **Story 6.4:** Uses identical export patterns (CSV streaming, PDF generation, bilingual headers) +- **Epic 3:** Consultation model with `user` relationship and booking statuses + ## User Story As an **admin**, -I want **to export consultation/booking data**, -So that **I can analyze and report on consultation history**. +I want **to export consultation/booking data in CSV and PDF formats**, +So that **I can generate reports for accounting, analyze consultation patterns, and maintain offline records of client interactions**. ## Acceptance Criteria ### Export Options - [ ] Export all consultations +- [ ] Export filtered subset based on criteria below ### Filters -- [ ] Date range +- [ ] Date range (scheduled_date between start/end) - [ ] Consultation type (free/paid) -- [ ] Status (approved/completed/no-show/cancelled) -- [ ] Payment status +- [ ] Status (pending/approved/completed/no-show/cancelled) +- [ ] Payment status (pending/received) ### Export Includes -- [ ] Client name -- [ ] Date and time -- [ ] Consultation type +- [ ] Client name (from user relationship) +- [ ] Date and time (scheduled_date, scheduled_time) +- [ ] Consultation type (free/paid) - [ ] Status - [ ] Payment status -- [ ] Problem summary +- [ ] Problem summary (truncated in PDF if > 500 chars) ### Formats -- [ ] CSV format -- [ ] PDF format with professional layout and branding +- [ ] CSV format with streaming download +- [ ] PDF format with professional layout and Libra branding + +### Bilingual Support +- [ ] Column headers based on admin's preferred language +- [ ] Use translation keys from `resources/lang/{locale}/export.php` ## Technical Notes +### Implementation Pattern +Follow the export pattern established in Story 6.4. Reuse any base export functionality created there. + +### Files to Create/Modify + +``` +app/Http/Controllers/Admin/ConsultationExportController.php # New controller +resources/views/exports/consultations.blade.php # PDF template +resources/lang/en/export.php # Add consultation keys +resources/lang/ar/export.php # Add consultation keys +routes/web.php # Add export routes +``` + +### Routes + ```php -public function exportConsultationsPdf(Request $request) +// In admin routes group +Route::prefix('exports')->name('admin.exports.')->group(function () { + Route::get('consultations/csv', [ConsultationExportController::class, 'csv'])->name('consultations.csv'); + Route::get('consultations/pdf', [ConsultationExportController::class, 'pdf'])->name('consultations.pdf'); +}); +``` + +### Controller Implementation + +```php +with('user') - ->when($request->date_from, fn($q) => $q->where('scheduled_date', '>=', $request->date_from)) - ->when($request->status, fn($q) => $q->where('status', $request->status)) - ->get(); + public function csv(Request $request): StreamedResponse + { + $consultations = $this->getFilteredConsultations($request); - $pdf = Pdf::loadView('exports.consultations', compact('consultations')); + return response()->streamDownload(function () use ($consultations) { + $csv = Writer::createFromString(); + $csv->insertOne([ + __('export.client_name'), + __('export.date'), + __('export.time'), + __('export.consultation_type'), + __('export.status'), + __('export.payment_status'), + __('export.problem_summary'), + ]); - return $pdf->download('consultations-export.pdf'); + $consultations->cursor()->each(fn ($consultation) => $csv->insertOne([ + $consultation->user->full_name, + $consultation->scheduled_date->format('Y-m-d'), + $consultation->scheduled_time->format('H:i'), + __('consultations.type.'.$consultation->consultation_type), + __('consultations.status.'.$consultation->status), + __('consultations.payment.'.$consultation->payment_status), + $consultation->problem_summary, + ])); + + echo $csv->toString(); + }, 'consultations-export-'.now()->format('Y-m-d').'.csv'); + } + + public function pdf(Request $request) + { + $consultations = $this->getFilteredConsultations($request)->get(); + + $pdf = Pdf::loadView('exports.consultations', [ + 'consultations' => $consultations, + 'generatedAt' => now(), + 'filters' => $request->only(['date_from', 'date_to', 'type', 'status', 'payment_status']), + ]); + + return $pdf->download('consultations-export-'.now()->format('Y-m-d').'.pdf'); + } + + private function getFilteredConsultations(Request $request) + { + return Consultation::query() + ->with('user') + ->when($request->date_from, fn ($q) => $q->where('scheduled_date', '>=', $request->date_from)) + ->when($request->date_to, fn ($q) => $q->where('scheduled_date', '<=', $request->date_to)) + ->when($request->type, fn ($q) => $q->where('consultation_type', $request->type)) + ->when($request->status, fn ($q) => $q->where('status', $request->status)) + ->when($request->payment_status, fn ($q) => $q->where('payment_status', $request->payment_status)) + ->orderBy('scheduled_date', 'desc'); + } } ``` +### PDF Template Structure + +The PDF template (`resources/views/exports/consultations.blade.php`) should include: +- Libra logo and branding header (navy blue #0A1F44, gold #D4AF37) +- Report title with generation timestamp +- Applied filters summary +- Data table with alternating row colors +- Problem summary truncated to 500 chars with "..." if longer +- Footer with page numbers + +### Translation Keys + +Add to `resources/lang/en/export.php`: +```php +'client_name' => 'Client Name', +'date' => 'Date', +'time' => 'Time', +'consultation_type' => 'Type', +'status' => 'Status', +'payment_status' => 'Payment Status', +'problem_summary' => 'Problem Summary', +``` + +Add to `resources/lang/ar/export.php`: +```php +'client_name' => 'اسم العميل', +'date' => 'التاريخ', +'time' => 'الوقت', +'consultation_type' => 'النوع', +'status' => 'الحالة', +'payment_status' => 'حالة الدفع', +'problem_summary' => 'ملخص المشكلة', +``` + +### Edge Cases + +- **Empty results:** Return empty CSV with headers only, or PDF with "No consultations found" message +- **Large datasets:** Use cursor() for CSV streaming; for PDF, consider chunking or limiting to 500 records with warning +- **Large problem summaries:** Truncate to 500 characters in PDF table cells with "..." indicator + +## Testing Requirements + +### Feature Tests + +Create `tests/Feature/Admin/ConsultationExportTest.php`: + +```php +admin()->create(); + Consultation::factory()->count(5)->create(); + + $response = $this->actingAs($admin) + ->get(route('admin.exports.consultations.csv')); + + $response->assertOk(); + $response->assertHeader('content-type', 'text/csv; charset=UTF-8'); +}); + +test('admin can export consultations as pdf', function () { + $admin = User::factory()->admin()->create(); + Consultation::factory()->count(5)->create(); + + $response = $this->actingAs($admin) + ->get(route('admin.exports.consultations.pdf')); + + $response->assertOk(); + $response->assertHeader('content-type', 'application/pdf'); +}); + +test('consultation export filters by date range', function () { + $admin = User::factory()->admin()->create(); + Consultation::factory()->create(['scheduled_date' => now()->subDays(10)]); + Consultation::factory()->create(['scheduled_date' => now()]); + + $response = $this->actingAs($admin) + ->get(route('admin.exports.consultations.csv', [ + 'date_from' => now()->subDays(5)->format('Y-m-d'), + ])); + + $response->assertOk(); + // CSV should contain only 1 consultation (today's) +}); + +test('consultation export filters by type', function () { + $admin = User::factory()->admin()->create(); + Consultation::factory()->create(['consultation_type' => 'free']); + Consultation::factory()->create(['consultation_type' => 'paid']); + + $response = $this->actingAs($admin) + ->get(route('admin.exports.consultations.csv', ['type' => 'paid'])); + + $response->assertOk(); +}); + +test('consultation export filters by status', function () { + $admin = User::factory()->admin()->create(); + Consultation::factory()->create(['status' => 'completed']); + Consultation::factory()->create(['status' => 'no-show']); + + $response = $this->actingAs($admin) + ->get(route('admin.exports.consultations.csv', ['status' => 'completed'])); + + $response->assertOk(); +}); + +test('consultation export handles empty results', function () { + $admin = User::factory()->admin()->create(); + + $response = $this->actingAs($admin) + ->get(route('admin.exports.consultations.csv')); + + $response->assertOk(); + // Should return CSV with headers only +}); + +test('guests cannot access consultation exports', function () { + $response = $this->get(route('admin.exports.consultations.csv')); + + $response->assertRedirect(route('login')); +}); +``` + +### Test Scenarios Checklist + +- [ ] CSV export with no filters returns all consultations +- [ ] CSV export with date range filter works correctly +- [ ] CSV export with type filter (free/paid) works correctly +- [ ] CSV export with status filter works correctly +- [ ] CSV export with payment status filter works correctly +- [ ] CSV export with combined filters works correctly +- [ ] PDF export generates valid PDF with branding +- [ ] PDF export includes applied filters summary +- [ ] Empty export returns appropriate response (not error) +- [ ] Large dataset (500+ records) exports within reasonable time +- [ ] Bilingual headers render correctly based on admin locale +- [ ] Unauthenticated users are redirected to login + ## Definition of Done -- [ ] All filters work correctly -- [ ] CSV export accurate -- [ ] PDF professionally formatted -- [ ] Large summaries handled in PDF -- [ ] Tests pass +- [ ] All filters work correctly (date range, type, status, payment) +- [ ] CSV export streams correctly with proper headers +- [ ] PDF export generates with Libra branding (logo, colors) +- [ ] Large problem summaries truncated properly in PDF +- [ ] Bilingual column headers work based on admin language +- [ ] Empty results handled gracefully +- [ ] All feature tests pass +- [ ] Code formatted with Pint ## Estimation **Complexity:** Medium | **Effort:** 3 hours + +## References +- **PRD Section 11.2:** Export Functionality requirements +- **Story 6.4:** User export implementation (follow same patterns) +- **Epic 3:** Consultation model structure and statuses diff --git a/docs/stories/story-6.6-data-export-timeline-reports.md b/docs/stories/story-6.6-data-export-timeline-reports.md index 02f287f..a1b25c8 100644 --- a/docs/stories/story-6.6-data-export-timeline-reports.md +++ b/docs/stories/story-6.6-data-export-timeline-reports.md @@ -8,15 +8,35 @@ As an **admin**, I want **to export timeline and case data**, So that **I can maintain records and generate case reports**. +## Story Context + +### UI Location +This export feature is part of the Admin Dashboard exports section, accessible via the admin navigation. The timeline export page provides filter controls and export buttons for both CSV and PDF formats. + +### Existing System Integration +- **Follows pattern:** Story 6.4 (User Lists Export) and Story 6.5 (Consultation Export) - same UI layout, filter approach, and export mechanisms +- **Integrates with:** Timeline model, TimelineUpdate model, User model +- **Technology:** Livewire Volt, Flux UI, league/csv, barryvdh/laravel-dompdf +- **Touch points:** Admin dashboard navigation, timeline management section + +### Reference Documents +- **Epic:** `docs/epics/epic-6-admin-dashboard.md#story-66-data-export---timeline-reports` +- **Export Pattern Reference:** `docs/stories/story-6.4-data-export-user-lists.md` - establishes CSV/PDF export patterns +- **Similar Implementation:** `docs/stories/story-6.5-data-export-consultation-records.md` - query and filter patterns +- **Timeline System:** `docs/epics/epic-4-case-timeline.md` - timeline model and relationships +- **Timeline Schema:** `docs/stories/story-4.1-timeline-creation.md#database-schema` - database structure +- **PRD Export Requirements:** `docs/prd.md#117-export-functionality` - business requirements + ## Acceptance Criteria ### Export Options - [ ] Export all timelines (across all clients) -- [ ] Export timelines for specific client +- [ ] Export timelines for specific client (client selector/search) ### Filters -- [ ] Status (active/archived) -- [ ] Date range +- [ ] Status filter (active/archived/all) +- [ ] Date range filter (created_at) +- [ ] Client filter (search by name/email) ### Export Includes - [ ] Case name and reference @@ -27,39 +47,363 @@ So that **I can maintain records and generate case reports**. - [ ] Last update date ### Formats -- [ ] CSV format -- [ ] PDF format +- [ ] CSV format with bilingual headers +- [ ] PDF format with Libra branding -### Optional -- [ ] Include update content or summary only toggle +### Optional Features +- [ ] Include update content toggle (full content vs summary only) +- [ ] When enabled, PDF includes all update entries per timeline + +### UI Requirements +- [ ] Filter controls match Story 6.4/6.5 layout +- [ ] Export buttons clearly visible +- [ ] Loading state during export generation +- [ ] Success/error feedback messages ## Technical Notes +### File Structure +``` +Routes: + GET /admin/exports/timelines -> admin.exports.timelines (Volt page) + +Files to Create: + resources/views/livewire/pages/admin/exports/timelines.blade.php (Volt component) + resources/views/exports/timelines-pdf.blade.php (PDF template) + +Models Required (from Epic 4): + app/Models/Timeline.php + app/Models/TimelineUpdate.php +``` + +### Database Schema Reference ```php -public function exportTimelinesPdf(Request $request) +// timelines table (from Story 4.1) +// Fields: id, user_id, case_name, case_reference, status, created_at, updated_at + +// timeline_updates table (from Story 4.1) +// Fields: id, timeline_id, admin_id, update_text, created_at, updated_at +``` + +### CSV Export Implementation +```php +use League\Csv\Writer; + +public function exportCsv(): StreamedResponse { - $timelines = Timeline::query() - ->with(['user', 'updates']) + return response()->streamDownload(function () { + $csv = Writer::createFromString(); + $csv->insertOne([ + __('export.case_name'), + __('export.case_reference'), + __('export.client_name'), + __('export.status'), + __('export.created_date'), + __('export.updates_count'), + __('export.last_update'), + ]); + + $this->getFilteredTimelines() + ->cursor() + ->each(fn($timeline) => $csv->insertOne([ + $timeline->case_name, + $timeline->case_reference ?? '-', + $timeline->user->name, + __('status.' . $timeline->status), + $timeline->created_at->format('Y-m-d'), + $timeline->updates_count, + $timeline->updates_max_created_at + ? Carbon::parse($timeline->updates_max_created_at)->format('Y-m-d H:i') + : '-', + ])); + + echo $csv->toString(); + }, 'timelines-export-' . now()->format('Y-m-d') . '.csv'); +} + +private function getFilteredTimelines() +{ + return Timeline::query() + ->with('user') ->withCount('updates') - ->when($request->client_id, fn($q) => $q->where('user_id', $request->client_id)) - ->when($request->status, fn($q) => $q->where('status', $request->status)) - ->get(); - - $pdf = Pdf::loadView('exports.timelines', [ - 'timelines' => $timelines, - 'includeUpdates' => $request->boolean('include_updates'), - ]); - - return $pdf->download('timelines-export.pdf'); + ->withMax('updates', 'created_at') + ->when($this->clientId, fn($q) => $q->where('user_id', $this->clientId)) + ->when($this->status && $this->status !== 'all', fn($q) => $q->where('status', $this->status)) + ->when($this->dateFrom, fn($q) => $q->where('created_at', '>=', $this->dateFrom)) + ->when($this->dateTo, fn($q) => $q->where('created_at', '<=', $this->dateTo)) + ->orderBy('created_at', 'desc'); } ``` +### PDF Export Implementation +```php +use Barryvdh\DomPDF\Facade\Pdf; + +public function exportPdf(): Response +{ + $timelines = $this->getFilteredTimelines() + ->when($this->includeUpdates, fn($q) => $q->with('updates')) + ->get(); + + $pdf = Pdf::loadView('exports.timelines-pdf', [ + 'timelines' => $timelines, + 'includeUpdates' => $this->includeUpdates, + 'generatedAt' => now(), + 'filters' => [ + 'status' => $this->status, + 'dateFrom' => $this->dateFrom, + 'dateTo' => $this->dateTo, + 'client' => $this->clientId ? User::find($this->clientId)?->name : null, + ], + ]); + + return $pdf->download('timelines-report-' . now()->format('Y-m-d') . '.pdf'); +} +``` + +### Volt Component Structure +```php +clientSearch) < 2) { + return collect(); + } + + return User::query() + ->whereIn('user_type', ['individual', 'company']) + ->where(fn($q) => $q + ->where('name', 'like', "%{$this->clientSearch}%") + ->orWhere('email', 'like', "%{$this->clientSearch}%")) + ->limit(10) + ->get(); + } + + public function selectClient(int $id): void + { + $this->clientId = $id; + $this->clientSearch = User::find($id)?->name ?? ''; + } + + public function clearClient(): void + { + $this->clientId = null; + $this->clientSearch = ''; + } + + public function exportCsv(): StreamedResponse { /* see above */ } + public function exportPdf(): Response { /* see above */ } +}; ?> + +
+ {{-- Filter controls and export buttons using Flux UI --}} + {{-- Follow layout pattern from Story 6.4/6.5 --}} +
+``` + +### PDF Template Structure +```blade +{{-- resources/views/exports/timelines-pdf.blade.php --}} + + + + + + + +
+ +

{{ __('export.timeline_report') }}

+

{{ __('export.generated_at') }}: {{ $generatedAt->format('Y-m-d H:i') }}

+
+ + @if($filters['client'] || $filters['status'] !== 'all' || $filters['dateFrom']) +
+

{{ __('export.applied_filters') }}

+ +
+ @endif + + + + + + + + + + + + + + + @forelse($timelines as $timeline) + + + + + + + + + + @if($includeUpdates && $timeline->updates->count()) + + + + @endif + @empty + + + + @endforelse + +
{{ __('export.case_name') }}{{ __('export.case_reference') }}{{ __('export.client_name') }}{{ __('export.status') }}{{ __('export.created_date') }}{{ __('export.updates_count') }}{{ __('export.last_update') }}
{{ $timeline->case_name }}{{ $timeline->case_reference ?? '-' }}{{ $timeline->user->name }}{{ __('status.' . $timeline->status) }}{{ $timeline->created_at->format('Y-m-d') }}{{ $timeline->updates_count }}{{ $timeline->updates_max_created_at ? Carbon::parse($timeline->updates_max_created_at)->format('Y-m-d H:i') : '-' }}
+ {{ __('export.updates') }}: + @foreach($timeline->updates as $update) +
+ {{ $update->created_at->format('Y-m-d H:i') }} +

{{ Str::limit($update->update_text, 500) }}

+
+ @endforeach +
{{ __('export.no_records') }}
+ + + + +``` + +### Required Translation Keys +```php +// resources/lang/en/export.php +'timeline_report' => 'Timeline Report', +'case_name' => 'Case Name', +'case_reference' => 'Case Reference', +'client_name' => 'Client Name', +'status' => 'Status', +'created_date' => 'Created Date', +'updates_count' => 'Updates', +'last_update' => 'Last Update', +'updates' => 'Updates', +'generated_at' => 'Generated At', +'applied_filters' => 'Applied Filters', +'no_records' => 'No records found', +'libra_footer' => 'Libra Law Firm', +'export_timelines' => 'Export Timelines', +'include_updates' => 'Include Update Content', +'all_clients' => 'All Clients', +'select_client' => 'Select Client', + +// resources/lang/ar/export.php +'timeline_report' => 'تقرير الجدول الزمني', +'case_name' => 'اسم القضية', +'case_reference' => 'رقم المرجع', +'client_name' => 'اسم العميل', +'status' => 'الحالة', +'created_date' => 'تاريخ الإنشاء', +'updates_count' => 'التحديثات', +'last_update' => 'آخر تحديث', +'updates' => 'التحديثات', +'generated_at' => 'تاريخ الإنشاء', +'applied_filters' => 'الفلاتر المطبقة', +'no_records' => 'لا توجد سجلات', +'libra_footer' => 'مكتب ليبرا للمحاماة', +'export_timelines' => 'تصدير الجداول الزمنية', +'include_updates' => 'تضمين محتوى التحديثات', +'all_clients' => 'جميع العملاء', +'select_client' => 'اختر العميل', +``` + +### Edge Cases & Error Handling +- **Empty results:** Generate valid file with headers only, show info message +- **Large datasets:** Use `cursor()` for memory-efficient iteration in CSV +- **PDF memory limits:** When `includeUpdates` is true and data is large, consider: + - Limiting to first 100 timelines with warning message + - Truncating update text to 500 characters +- **Arabic content in PDF:** Use DejaVu Sans font which supports Arabic characters +- **Date range validation:** Ensure dateFrom <= dateTo + +## Test Scenarios + +All tests should use Pest and be placed in `tests/Feature/Admin/TimelineExportTest.php`. + +### Happy Path Tests +- [ ] `test_admin_can_access_timeline_export_page` - Page loads with filter controls +- [ ] `test_admin_can_export_all_timelines_csv` - CSV downloads with all timelines +- [ ] `test_admin_can_export_all_timelines_pdf` - PDF downloads with branding +- [ ] `test_admin_can_filter_by_client` - Only selected client's timelines exported +- [ ] `test_admin_can_filter_by_status_active` - Only active timelines exported +- [ ] `test_admin_can_filter_by_status_archived` - Only archived timelines exported +- [ ] `test_admin_can_filter_by_date_range` - Timelines within range exported +- [ ] `test_include_updates_toggle_adds_content_to_pdf` - Update text appears in PDF +- [ ] `test_csv_headers_match_admin_language` - AR/EN headers based on locale + +### Validation Tests +- [ ] `test_date_from_cannot_be_after_date_to` - Validation error shown +- [ ] `test_client_filter_only_shows_individual_and_company_users` - Admin users excluded + +### Edge Case Tests +- [ ] `test_export_empty_results_returns_valid_csv` - Empty CSV with headers +- [ ] `test_export_empty_results_returns_valid_pdf` - PDF with "no records" message +- [ ] `test_timeline_without_updates_shows_zero_count` - updates_count = 0 +- [ ] `test_timeline_without_reference_shows_dash` - case_reference displays "-" +- [ ] `test_pdf_renders_arabic_content_correctly` - Arabic text not garbled + +### Authorization Tests +- [ ] `test_non_admin_cannot_access_timeline_export` - 403 or redirect +- [ ] `test_guest_redirected_to_login` - Redirect to login page + ## Definition of Done -- [ ] All filters work -- [ ] CSV export works -- [ ] PDF with branding works -- [ ] Optional update content toggle works -- [ ] Tests pass + +- [ ] Volt component created at `resources/views/livewire/pages/admin/exports/timelines.blade.php` +- [ ] PDF template created at `resources/views/exports/timelines-pdf.blade.php` +- [ ] Route registered in admin routes +- [ ] Navigation link added to admin dashboard exports section +- [ ] All filters work (client, status, date range) +- [ ] CSV export generates valid file with correct data +- [ ] PDF export generates with Libra branding (navy/gold) +- [ ] Include updates toggle works for PDF +- [ ] Empty results handled gracefully +- [ ] Bilingual support (AR/EN headers and labels) +- [ ] All translation keys added +- [ ] All tests pass +- [ ] Code formatted with Pint + +## Dependencies + +- **Story 6.4:** Data Export - User Lists (establishes export patterns, packages already installed) +- **Story 6.5:** Data Export - Consultation Records (similar implementation pattern) +- **Story 4.1:** Timeline Creation (Timeline model, database schema) +- **Story 4.2:** Timeline Updates Management (TimelineUpdate model) +- **Story 1.3:** Bilingual Infrastructure (translation system) ## Estimation **Complexity:** Medium | **Effort:** 3 hours diff --git a/docs/stories/story-6.7-monthly-statistics-report.md b/docs/stories/story-6.7-monthly-statistics-report.md index c7a4867..3a6605f 100644 --- a/docs/stories/story-6.7-monthly-statistics-report.md +++ b/docs/stories/story-6.7-monthly-statistics-report.md @@ -5,66 +5,588 @@ ## User Story As an **admin**, -I want **to generate comprehensive monthly PDF reports**, -So that **I have professional summaries of business performance**. +I want **to generate comprehensive monthly PDF reports from the admin dashboard**, +So that **I can archive business performance records, share summaries with stakeholders, and track month-over-month trends**. + +## Prerequisites / Dependencies + +This story requires the following to be completed first: + +| Dependency | Required From | What's Needed | +|------------|---------------|---------------| +| Dashboard Metrics | Story 6.1 | Metrics calculation patterns and caching strategy | +| Analytics Charts | Story 6.2 | Chart.js implementation and data aggregation methods | +| User Export | Story 6.4 | DomPDF setup and PDF branding patterns | +| Consultation Export | Story 6.5 | Export service patterns | +| Timeline Export | Story 6.6 | Export patterns with related data | +| User Model | Epic 2 | User statistics queries | +| Consultation Model | Epic 3 | Consultation statistics queries | +| Timeline Model | Epic 4 | Timeline statistics queries | +| Post Model | Epic 5 | Post statistics queries | + +**References:** +- Epic 6 details: `docs/epics/epic-6-admin-dashboard.md` +- Dashboard metrics implementation: `docs/stories/story-6.1-dashboard-overview-statistics.md` +- Chart patterns: `docs/stories/story-6.2-analytics-charts.md` +- PDF export patterns: `docs/stories/story-6.4-data-export-user-lists.md` ## Acceptance Criteria -### Generation -- [ ] "Generate Monthly Report" button -- [ ] Select month/year +### UI Location & Generation +- [ ] "Generate Monthly Report" button in admin dashboard (below metrics cards or in a Reports section) +- [ ] Month/year selector dropdown (default: previous month) +- [ ] Selectable range: last 12 months only (no future months) -### PDF Report Includes -- [ ] Overview of key metrics -- [ ] Charts (rendered as images) -- [ ] User statistics -- [ ] Consultation statistics -- [ ] Timeline statistics -- [ ] Post statistics +### PDF Report Sections -### Design -- [ ] Professional layout with branding -- [ ] Table of contents -- [ ] Printable format -- [ ] Bilingual based on admin preference +#### 1. Cover Page +- [ ] Libra logo and branding +- [ ] Report title: "Monthly Statistics Report" +- [ ] Period: Month and Year (e.g., "December 2025") +- [ ] Generated date and time -### UX -- [ ] Loading indicator during generation -- [ ] Download on completion +#### 2. Table of Contents (Visual List) +- [ ] List of sections with page numbers +- [ ] Non-clickable (simple text list for print compatibility) -## Technical Notes +#### 3. Executive Summary +- [ ] Key highlights (2-3 bullet points) +- [ ] Month-over-month comparison if prior month data exists -Pre-render charts as base64 images for PDF inclusion. +#### 4. User Statistics Section +- [ ] New clients registered this month +- [ ] Total active clients (end of month) +- [ ] Individual vs company breakdown +- [ ] Client growth trend (compared to previous month) + +#### 5. Consultation Statistics Section +- [ ] Total consultations this month +- [ ] Approved/Completed/Cancelled/No-show breakdown +- [ ] Free vs paid ratio +- [ ] No-show rate percentage +- [ ] Pie chart: Consultation types (rendered as image) + +#### 6. Timeline Statistics Section +- [ ] Active timelines (end of month) +- [ ] New timelines created this month +- [ ] Timeline updates added this month +- [ ] Archived timelines this month + +#### 7. Post Statistics Section +- [ ] Posts published this month +- [ ] Total published posts (cumulative) + +#### 8. Trends Chart +- [ ] Line chart showing monthly consultations trend (last 6 months ending with selected month) +- [ ] Rendered as base64 PNG image + +### Design Requirements +- [ ] Professional A4 portrait layout +- [ ] Libra branding: Navy Blue (#0A1F44) headers, Gold (#D4AF37) accents +- [ ] Consistent typography and spacing +- [ ] Print-friendly (no dark backgrounds, adequate margins) +- [ ] Bilingual: Arabic or English based on admin's `preferred_language` setting + +### UX Requirements +- [ ] Loading indicator with "Generating report..." message during PDF creation +- [ ] Disable generate button while processing +- [ ] Auto-download PDF on completion +- [ ] Success toast notification after download starts +- [ ] Error handling with user-friendly message if generation fails + +## Technical Implementation + +### Files to Create/Modify + +| File | Purpose | +|------|---------| +| `resources/views/livewire/admin/reports/monthly-report.blade.php` | Volt component for report generation UI | +| `resources/views/exports/monthly-report.blade.php` | PDF template (Blade view for DomPDF) | +| `app/Services/MonthlyReportService.php` | Statistics aggregation and PDF generation logic | +| `routes/web.php` | Add report generation route | +| `resources/lang/en/report.php` | English translations for report labels | +| `resources/lang/ar/report.php` | Arabic translations for report labels | + +### Route Definition ```php -public function generateMonthlyReport(int $year, int $month) +Route::middleware(['auth', 'admin'])->prefix('admin')->group(function () { + Route::get('/reports/monthly', function () { + return view('livewire.admin.reports.monthly-report'); + })->name('admin.reports.monthly'); + + Route::post('/reports/monthly/generate', [MonthlyReportController::class, 'generate']) + ->name('admin.reports.monthly.generate'); +}); +``` + +### Volt Component Structure + +```php +subMonth(); + $this->selectedYear = $previousMonth->year; + $this->selectedMonth = $previousMonth->month; + } + + public function getAvailableMonthsProperty(): array + { + $months = []; + for ($i = 1; $i <= 12; $i++) { + $date = now()->subMonths($i); + $months[] = [ + 'year' => $date->year, + 'month' => $date->month, + 'label' => $date->translatedFormat('F Y'), + ]; + } + return $months; + } + + public function generate(): \Symfony\Component\HttpFoundation\StreamedResponse + { + $this->generating = true; + + try { + $service = app(MonthlyReportService::class); + return $service->generate($this->selectedYear, $this->selectedMonth); + } finally { + $this->generating = false; + } + } +}; ?> + +
+ {{ __('report.monthly_report') }} + +
+ + @foreach($this->availableMonths as $option) + + {{ $option['label'] }} + + @endforeach + + + + {{ __('report.generate') }} + {{ __('report.generating') }} + +
+
+``` + +### MonthlyReportService Structure + +```php +startOfMonth(); - $endDate = $startDate->copy()->endOfMonth(); + public function generate(int $year, int $month): \Symfony\Component\HttpFoundation\StreamedResponse + { + $startDate = Carbon::create($year, $month, 1)->startOfMonth(); + $endDate = $startDate->copy()->endOfMonth(); + $locale = Auth::user()->preferred_language ?? 'en'; - $data = [ - 'period' => $startDate->format('F Y'), - 'userStats' => $this->getUserStatsForPeriod($startDate, $endDate), - 'consultationStats' => $this->getConsultationStatsForPeriod($startDate, $endDate), - 'timelineStats' => $this->getTimelineStatsForPeriod($startDate, $endDate), - 'postStats' => $this->getPostStatsForPeriod($startDate, $endDate), - 'charts' => $this->renderChartsAsImages($startDate, $endDate), - ]; + $data = [ + 'period' => $startDate->translatedFormat('F Y'), + 'generatedAt' => now()->translatedFormat('d M Y H:i'), + 'locale' => $locale, + 'userStats' => $this->getUserStats($startDate, $endDate), + 'consultationStats' => $this->getConsultationStats($startDate, $endDate), + 'timelineStats' => $this->getTimelineStats($startDate, $endDate), + 'postStats' => $this->getPostStats($startDate, $endDate), + 'charts' => $this->renderChartsAsImages($startDate, $endDate), + 'previousMonth' => $this->getPreviousMonthComparison($startDate), + ]; - $pdf = Pdf::loadView('exports.monthly-report', $data) - ->setPaper('a4', 'portrait'); + $pdf = Pdf::loadView('exports.monthly-report', $data) + ->setPaper('a4', 'portrait'); - return $pdf->download("monthly-report-{$year}-{$month}.pdf"); + $filename = "monthly-report-{$year}-{$month}.pdf"; + + return $pdf->download($filename); + } + + private function getUserStats(Carbon $start, Carbon $end): array + { + return [ + 'new_clients' => User::whereBetween('created_at', [$start, $end]) + ->whereIn('user_type', ['individual', 'company'])->count(), + 'total_active' => User::where('status', 'active') + ->where('created_at', '<=', $end) + ->whereIn('user_type', ['individual', 'company'])->count(), + 'individual' => User::where('user_type', 'individual') + ->where('status', 'active') + ->where('created_at', '<=', $end)->count(), + 'company' => User::where('user_type', 'company') + ->where('status', 'active') + ->where('created_at', '<=', $end)->count(), + ]; + } + + private function getConsultationStats(Carbon $start, Carbon $end): array + { + $total = Consultation::whereBetween('scheduled_date', [$start, $end])->count(); + $completed = Consultation::whereBetween('scheduled_date', [$start, $end]) + ->whereIn('status', ['completed', 'no-show'])->count(); + $noShows = Consultation::whereBetween('scheduled_date', [$start, $end]) + ->where('status', 'no-show')->count(); + + return [ + 'total' => $total, + 'approved' => Consultation::whereBetween('scheduled_date', [$start, $end]) + ->where('status', 'approved')->count(), + 'completed' => Consultation::whereBetween('scheduled_date', [$start, $end]) + ->where('status', 'completed')->count(), + 'cancelled' => Consultation::whereBetween('scheduled_date', [$start, $end]) + ->where('status', 'cancelled')->count(), + 'no_show' => $noShows, + 'free' => Consultation::whereBetween('scheduled_date', [$start, $end]) + ->where('consultation_type', 'free')->count(), + 'paid' => Consultation::whereBetween('scheduled_date', [$start, $end]) + ->where('consultation_type', 'paid')->count(), + 'no_show_rate' => $completed > 0 ? round(($noShows / $completed) * 100, 1) : 0, + ]; + } + + private function getTimelineStats(Carbon $start, Carbon $end): array + { + return [ + 'active' => Timeline::where('status', 'active') + ->where('created_at', '<=', $end)->count(), + 'new' => Timeline::whereBetween('created_at', [$start, $end])->count(), + 'updates' => TimelineUpdate::whereBetween('created_at', [$start, $end])->count(), + 'archived' => Timeline::where('status', 'archived') + ->whereBetween('updated_at', [$start, $end])->count(), + ]; + } + + private function getPostStats(Carbon $start, Carbon $end): array + { + return [ + 'this_month' => Post::where('status', 'published') + ->whereBetween('created_at', [$start, $end])->count(), + 'total' => Post::where('status', 'published') + ->where('created_at', '<=', $end)->count(), + ]; + } + + /** + * Render charts as base64 PNG images using QuickChart.io API + * Alternative: Use Browsershot for server-side rendering of Chart.js + */ + private function renderChartsAsImages(Carbon $start, Carbon $end): array + { + // Option 1: QuickChart.io (no server dependencies) + $consultationPieChart = $this->generateQuickChart([ + 'type' => 'pie', + 'data' => [ + 'labels' => [__('report.free'), __('report.paid')], + 'datasets' => [[ + 'data' => [ + Consultation::whereBetween('scheduled_date', [$start, $end]) + ->where('consultation_type', 'free')->count(), + Consultation::whereBetween('scheduled_date', [$start, $end]) + ->where('consultation_type', 'paid')->count(), + ], + 'backgroundColor' => ['#0A1F44', '#D4AF37'], + ]], + ], + ]); + + $trendChart = $this->generateTrendChart($start); + + return [ + 'consultation_pie' => $consultationPieChart, + 'trend_line' => $trendChart, + ]; + } + + private function generateQuickChart(array $config): string + { + $url = 'https://quickchart.io/chart?c=' . urlencode(json_encode($config)) . '&w=400&h=300'; + + try { + $imageData = file_get_contents($url); + return 'data:image/png;base64,' . base64_encode($imageData); + } catch (\Exception $e) { + // Return empty string if chart generation fails + return ''; + } + } + + private function generateTrendChart(Carbon $endMonth): string + { + $labels = []; + $data = []; + + for ($i = 5; $i >= 0; $i--) { + $month = $endMonth->copy()->subMonths($i); + $labels[] = $month->translatedFormat('M Y'); + $data[] = Consultation::whereMonth('scheduled_date', $month->month) + ->whereYear('scheduled_date', $month->year)->count(); + } + + return $this->generateQuickChart([ + 'type' => 'line', + 'data' => [ + 'labels' => $labels, + 'datasets' => [[ + 'label' => __('report.consultations'), + 'data' => $data, + 'borderColor' => '#D4AF37', + 'fill' => false, + ]], + ], + ]); + } + + private function getPreviousMonthComparison(Carbon $currentStart): ?array + { + $prevStart = $currentStart->copy()->subMonth()->startOfMonth(); + $prevEnd = $prevStart->copy()->endOfMonth(); + + $prevConsultations = Consultation::whereBetween('scheduled_date', [$prevStart, $prevEnd])->count(); + + if ($prevConsultations === 0) { + return null; + } + + return [ + 'consultations' => $prevConsultations, + 'clients' => User::whereBetween('created_at', [$prevStart, $prevEnd]) + ->whereIn('user_type', ['individual', 'company'])->count(), + ]; + } } ``` +### PDF Template Structure (`exports/monthly-report.blade.php`) + +Key sections to include: +- Header with Libra logo and branding +- Cover page with report title and period +- Table of contents (simple numbered list) +- Each statistics section with tables and optional charts +- Footer with page numbers and generation timestamp + +## Edge Cases & Error Handling + +| Scenario | Expected Behavior | +|----------|-------------------| +| Month with zero data | Report generates with all zeros - no errors, sections still appear | +| First month ever (no previous comparison) | "Previous month comparison" section hidden or shows "N/A" | +| QuickChart.io unavailable | Charts section shows placeholder text "Chart unavailable" | +| PDF generation timeout (>30s) | Show error toast: "Report generation timed out. Please try again." | +| Large data volume | Use chunked queries, consider job queue for very large datasets | +| Admin has no preferred_language set | Default to English ('en') | +| Invalid month/year selection | Validation prevents selection (only last 12 months available) | + +## Testing Requirements + +### Test File +`tests/Feature/Admin/MonthlyReportTest.php` + +### Test Scenarios + +```php +admin()->create(); + + $this->actingAs($admin) + ->get(route('admin.reports.monthly')) + ->assertSuccessful() + ->assertSee(__('report.monthly_report')); +}); + +test('non-admin cannot access monthly report page', function () { + $client = User::factory()->client()->create(); + + $this->actingAs($client) + ->get(route('admin.reports.monthly')) + ->assertForbidden(); +}); + +test('monthly report generates valid PDF', function () { + $admin = User::factory()->admin()->create(); + + // Create test data for the month + User::factory()->count(5)->create([ + 'user_type' => 'individual', + 'created_at' => now()->subMonth(), + ]); + Consultation::factory()->count(10)->create([ + 'scheduled_date' => now()->subMonth(), + ]); + + $service = new MonthlyReportService(); + $response = $service->generate( + now()->subMonth()->year, + now()->subMonth()->month + ); + + expect($response->headers->get('content-type'))->toContain('pdf'); +}); + +test('report handles month with no data gracefully', function () { + $admin = User::factory()->admin()->create(); + + $service = new MonthlyReportService(); + + // Generate for a month with no data + $response = $service->generate(2020, 1); + + expect($response->headers->get('content-type'))->toContain('pdf'); +}); + +test('report respects admin language preference', function () { + $admin = User::factory()->admin()->create(['preferred_language' => 'ar']); + + $this->actingAs($admin); + + $service = new MonthlyReportService(); + // Verify Arabic locale is used (check data passed to view) +}); + +test('user statistics are accurate for selected month', function () { + $targetMonth = now()->subMonth(); + + // Create 3 users in target month + User::factory()->count(3)->create([ + 'user_type' => 'individual', + 'status' => 'active', + 'created_at' => $targetMonth, + ]); + + // Create 2 users in different month (should not be counted) + User::factory()->count(2)->create([ + 'user_type' => 'individual', + 'created_at' => now()->subMonths(3), + ]); + + $service = new MonthlyReportService(); + $reflection = new ReflectionClass($service); + $method = $reflection->getMethod('getUserStats'); + $method->setAccessible(true); + + $stats = $method->invoke( + $service, + $targetMonth->startOfMonth(), + $targetMonth->endOfMonth() + ); + + expect($stats['new_clients'])->toBe(3); +}); + +test('consultation statistics calculate no-show rate correctly', function () { + $targetMonth = now()->subMonth(); + + // 8 completed + 2 no-shows = 20% no-show rate + Consultation::factory()->count(8)->create([ + 'status' => 'completed', + 'scheduled_date' => $targetMonth, + ]); + Consultation::factory()->count(2)->create([ + 'status' => 'no-show', + 'scheduled_date' => $targetMonth, + ]); + + $service = new MonthlyReportService(); + $reflection = new ReflectionClass($service); + $method = $reflection->getMethod('getConsultationStats'); + $method->setAccessible(true); + + $stats = $method->invoke( + $service, + $targetMonth->startOfMonth(), + $targetMonth->endOfMonth() + ); + + expect($stats['no_show_rate'])->toBe(20.0); +}); + +test('available months shows only last 12 months', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.reports.monthly-report') + ->actingAs($admin) + ->assertSet('availableMonths', function ($months) { + return count($months) === 12; + }); +}); +``` + +### Manual Testing Checklist +- [ ] Generate report for previous month - PDF downloads correctly +- [ ] Verify all statistics match dashboard metrics for same period +- [ ] Check PDF renders correctly when printed +- [ ] Test with Arabic language preference - labels in Arabic +- [ ] Test with English language preference - labels in English +- [ ] Verify charts render as images in PDF +- [ ] Test loading indicator appears during generation +- [ ] Verify month selector only shows last 12 months + ## Definition of Done -- [ ] Month/year selector works -- [ ] All statistics accurate -- [ ] Charts rendered in PDF -- [ ] Professional branding -- [ ] Bilingual support -- [ ] Tests pass +- [ ] Monthly report page accessible at `/admin/reports/monthly` +- [ ] Month/year selector works (last 12 months only) +- [ ] PDF generates with all required sections +- [ ] User statistics accurate for selected month +- [ ] Consultation statistics accurate with correct no-show rate +- [ ] Timeline statistics accurate +- [ ] Post statistics accurate +- [ ] Charts render as images in PDF +- [ ] Professional branding (navy blue, gold, Libra logo) +- [ ] Table of contents present +- [ ] Bilingual support (Arabic/English based on admin preference) +- [ ] Loading indicator during generation +- [ ] Empty month handled gracefully (zeros, no errors) +- [ ] Admin-only access enforced +- [ ] All tests pass +- [ ] Code formatted with Pint ## Estimation **Complexity:** High | **Effort:** 5-6 hours + +## Out of Scope +- Scheduled/automated monthly report generation +- Email delivery of reports +- Custom date range reports (only full months) +- Comparison with same month previous year +- PDF versioning or storage diff --git a/docs/stories/story-6.8-system-settings.md b/docs/stories/story-6.8-system-settings.md index afd7a6c..3703bbe 100644 --- a/docs/stories/story-6.8-system-settings.md +++ b/docs/stories/story-6.8-system-settings.md @@ -8,6 +8,15 @@ As an **admin**, I want **to configure system-wide settings**, So that **I can customize the platform to my needs**. +## Dependencies +- **Story 1.2:** Authentication & Role System (admin auth, User model) +- **Story 8.1:** Email Infrastructure Setup (mail configuration for test email) + +## Navigation Context +- Accessible from admin dashboard sidebar/navigation +- Route: `/admin/settings` +- Named route: `admin.settings` + ## Acceptance Criteria ### Profile Settings @@ -17,20 +26,85 @@ So that **I can customize the platform to my needs**. - [ ] Preferred language ### Email Settings -- [ ] View current sender email -- [ ] Test email functionality +- [ ] Display current sender email from `config('mail.from.address')` +- [ ] Display current sender name from `config('mail.from.name')` +- [ ] "Send Test Email" button that sends a test email to admin's email address +- [ ] Success/error feedback after test email attempt -### Notification Preferences (Optional) -- [ ] Toggle admin notifications -- [ ] Summary email frequency +### Notification Preferences (Future Enhancement - Not in Scope) +> **Note:** The following are documented for future consideration but are NOT required for this story's completion. All admin notifications are currently mandatory per PRD. +- Toggle admin notifications (future) +- Summary email frequency (future) ### Behavior - [ ] Settings saved and applied immediately - [ ] Validation for all inputs +- [ ] Flash messages for success/error states +- [ ] Password fields cleared after successful update ## Technical Notes +### Database Migration Required +Add `preferred_language` column to users table: + ```php +// database/migrations/xxxx_add_preferred_language_to_users_table.php +Schema::table('users', function (Blueprint $table) { + $table->string('preferred_language', 2)->default('ar')->after('remember_token'); +}); +``` + +### Files to Create/Modify + +| File | Action | Purpose | +|------|--------|---------| +| `resources/views/livewire/admin/settings.blade.php` | Create | Main settings Volt component | +| `app/Mail/TestEmail.php` | Create | Test email mailable | +| `resources/views/emails/test.blade.php` | Create | Test email template | +| `database/migrations/xxxx_add_preferred_language_to_users_table.php` | Create | Migration | +| `routes/web.php` | Modify | Add admin settings route | +| `app/Models/User.php` | Modify | Add `preferred_language` to fillable | + +### TestEmail Mailable + +```php +// app/Mail/TestEmail.php +namespace App\Mail; + +use Illuminate\Mail\Mailable; +use Illuminate\Mail\Mailables\Content; +use Illuminate\Mail\Mailables\Envelope; + +class TestEmail extends Mailable +{ + public function envelope(): Envelope + { + return new Envelope( + subject: __('messages.test_email_subject'), + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.test', + ); + } +} +``` + +### Volt Component Structure + +```php +user(); $this->name = $user->name; $this->email = $user->email; - $this->preferred_language = $user->preferred_language; + $this->preferred_language = $user->preferred_language ?? 'ar'; } public function updateProfile(): void @@ -81,19 +155,176 @@ new class extends Component { public function sendTestEmail(): void { - Mail::to(auth()->user())->send(new TestEmail()); - session()->flash('success', __('messages.test_email_sent')); + try { + Mail::to(auth()->user())->send(new TestEmail()); + session()->flash('success', __('messages.test_email_sent')); + } catch (\Exception $e) { + session()->flash('error', __('messages.test_email_failed')); + } } -}; +}; ?> + +
+ {{-- UI Template Here --}} +
+``` + +### Edge Cases & Error Handling +- **Wrong current password:** Validation rule `current_password` handles this automatically +- **Duplicate email:** `Rule::unique` with `ignore(auth()->id())` prevents self-collision +- **Email send failure:** Wrap in try/catch, show user-friendly error message +- **Empty preferred_language:** Default to 'ar' in mount() if null + +## Testing Requirements + +### Test File +`tests/Feature/Admin/SettingsTest.php` + +### Test Scenarios + +**Profile Update Tests:** +```php +test('admin can view settings page', function () { + $admin = User::factory()->create(); + + $this->actingAs($admin) + ->get(route('admin.settings')) + ->assertOk() + ->assertSeeLivewire('admin.settings'); +}); + +test('admin can update profile information', function () { + $admin = User::factory()->create(); + + Volt::test('admin.settings') + ->actingAs($admin) + ->set('name', 'Updated Name') + ->set('email', 'updated@example.com') + ->set('preferred_language', 'en') + ->call('updateProfile') + ->assertHasNoErrors(); + + expect($admin->fresh()) + ->name->toBe('Updated Name') + ->email->toBe('updated@example.com') + ->preferred_language->toBe('en'); +}); + +test('profile update validates required fields', function () { + $admin = User::factory()->create(); + + Volt::test('admin.settings') + ->actingAs($admin) + ->set('name', '') + ->set('email', '') + ->call('updateProfile') + ->assertHasErrors(['name', 'email']); +}); + +test('profile update prevents duplicate email', function () { + $existingUser = User::factory()->create(['email' => 'taken@example.com']); + $admin = User::factory()->create(); + + Volt::test('admin.settings') + ->actingAs($admin) + ->set('email', 'taken@example.com') + ->call('updateProfile') + ->assertHasErrors(['email']); +}); +``` + +**Password Update Tests:** +```php +test('admin can update password with correct current password', function () { + $admin = User::factory()->create([ + 'password' => Hash::make('old-password'), + ]); + + Volt::test('admin.settings') + ->actingAs($admin) + ->set('current_password', 'old-password') + ->set('password', 'new-password') + ->set('password_confirmation', 'new-password') + ->call('updatePassword') + ->assertHasNoErrors(); + + expect(Hash::check('new-password', $admin->fresh()->password))->toBeTrue(); +}); + +test('password update fails with wrong current password', function () { + $admin = User::factory()->create([ + 'password' => Hash::make('correct-password'), + ]); + + Volt::test('admin.settings') + ->actingAs($admin) + ->set('current_password', 'wrong-password') + ->set('password', 'new-password') + ->set('password_confirmation', 'new-password') + ->call('updatePassword') + ->assertHasErrors(['current_password']); +}); + +test('password update requires confirmation match', function () { + $admin = User::factory()->create([ + 'password' => Hash::make('old-password'), + ]); + + Volt::test('admin.settings') + ->actingAs($admin) + ->set('current_password', 'old-password') + ->set('password', 'new-password') + ->set('password_confirmation', 'different-password') + ->call('updatePassword') + ->assertHasErrors(['password']); +}); +``` + +**Test Email Tests:** +```php +test('admin can send test email', function () { + Mail::fake(); + $admin = User::factory()->create(); + + Volt::test('admin.settings') + ->actingAs($admin) + ->call('sendTestEmail') + ->assertHasNoErrors(); + + Mail::assertSent(TestEmail::class, fn ($mail) => + $mail->hasTo($admin->email) + ); +}); + +test('test email failure shows error message', function () { + Mail::fake(); + Mail::shouldReceive('to->send')->andThrow(new \Exception('SMTP error')); + + $admin = User::factory()->create(); + + Volt::test('admin.settings') + ->actingAs($admin) + ->call('sendTestEmail') + ->assertSessionHas('error'); +}); ``` ## Definition of Done -- [ ] Profile updates work -- [ ] Password change works -- [ ] Language preference persists -- [ ] Test email sends -- [ ] Validation complete -- [ ] Tests pass +- [ ] Migration created and run for `preferred_language` column +- [ ] User model updated with `preferred_language` in fillable +- [ ] Settings Volt component created at `resources/views/livewire/admin/settings.blade.php` +- [ ] TestEmail mailable created at `app/Mail/TestEmail.php` +- [ ] Test email template created at `resources/views/emails/test.blade.php` +- [ ] Route added: `Route::get('/admin/settings', ...)->name('admin.settings')` +- [ ] Profile update works with validation +- [ ] Password change works with current password verification +- [ ] Language preference persists across sessions +- [ ] Test email sends successfully (or shows error on failure) +- [ ] Email settings display current sender info from config +- [ ] All flash messages display correctly (success/error) +- [ ] UI follows Flux UI component patterns +- [ ] All tests pass (`php artisan test --filter=SettingsTest`) +- [ ] Code formatted with Pint ## Estimation **Complexity:** Medium | **Effort:** 3-4 hours diff --git a/docs/stories/story-6.9-legal-pages-editor.md b/docs/stories/story-6.9-legal-pages-editor.md index 3309eb7..8a63a5d 100644 --- a/docs/stories/story-6.9-legal-pages-editor.md +++ b/docs/stories/story-6.9-legal-pages-editor.md @@ -8,39 +8,73 @@ As an **admin**, I want **to edit Terms of Service and Privacy Policy pages**, So that **I can maintain legal compliance and update policies**. +## Dependencies +- **Epic 1:** Base authentication and admin middleware +- **Story 6.8:** System Settings (admin settings UI patterns and navigation) + +## References +- **PRD Section 10.1:** Legal & Compliance - Required pages specification +- **PRD Section 5.7H:** Settings - Terms of Service and Privacy Policy editor requirements +- **PRD Section 9.3:** User Privacy - Terms and Privacy page requirements +- **PRD Section 16.3:** Third-party dependencies - Rich text editor options (TinyMCE or Quill) + ## Acceptance Criteria ### Pages to Edit -- [ ] Terms of Service -- [ ] Privacy Policy +- [ ] Terms of Service (`/page/terms`) +- [ ] Privacy Policy (`/page/privacy`) ### Editor Features -- [ ] Rich text editor -- [ ] Bilingual content (Arabic/English) -- [ ] Save and publish -- [ ] Preview before publishing +- [ ] Rich text editor using Quill.js (lightweight, RTL-friendly) +- [ ] Bilingual content with Arabic/English tabs in editor UI +- [ ] Save and publish button updates database immediately +- [ ] Preview opens modal showing rendered content in selected language +- [ ] HTML content sanitized before save (prevent XSS) + +### Admin UI Location +- [ ] Accessible under Admin Dashboard > Settings section +- [ ] Sidebar item: "Legal Pages" or integrate into Settings page +- [ ] List view showing both pages with "Edit" action +- [ ] Edit page shows language tabs (Arabic | English) above editor ### Public Display -- [ ] Pages accessible from footer (public) -- [ ] Last updated timestamp displayed +- [ ] Pages accessible from footer links (no auth required) +- [ ] Route: `/page/{slug}` where slug is `terms` or `privacy` +- [ ] Content displayed in user's current language preference +- [ ] Last updated timestamp displayed at bottom of page +- [ ] Professional layout consistent with site design (navy/gold) ## Technical Notes -Store in database settings table or dedicated pages table. +### Architecture +This project uses **class-based Volt components** for interactivity. Follow existing patterns in `resources/views/livewire/`. + +### Rich Text Editor +Use **Quill.js** for the rich text editor: +- Lightweight and RTL-compatible +- Include via CDN or npm +- Configure toolbar: bold, italic, underline, lists, links, headings +- Bind to Livewire with `wire:model` on hidden textarea + +### Model & Migration ```php -// Migration +// Migration: create_pages_table.php Schema::create('pages', function (Blueprint $table) { $table->id(); - $table->string('slug')->unique(); + $table->string('slug')->unique(); // 'terms', 'privacy' $table->string('title_ar'); $table->string('title_en'); - $table->text('content_ar'); - $table->text('content_en'); - $table->timestamps(); + $table->longText('content_ar')->nullable(); + $table->longText('content_en')->nullable(); + $table->timestamps(); // updated_at used for "Last updated" }); +``` -// Seeder +### Seeder + +```php +// PageSeeder.php Page::create([ 'slug' => 'terms', 'title_ar' => 'شروط الخدمة', @@ -58,22 +92,221 @@ Page::create([ ]); ``` -### Public Route +### Routes + ```php -Route::get('/page/{slug}', function (string $slug) { - $page = Page::where('slug', $slug)->firstOrFail(); - return view('pages.show', compact('page')); -})->name('page.show'); +// Public route (web.php) +Route::get('/page/{slug}', [PageController::class, 'show']) + ->name('page.show') + ->where('slug', 'terms|privacy'); + +// Admin route (protected by admin middleware) +Route::middleware(['auth', 'admin'])->prefix('admin')->group(function () { + Route::get('/pages', PagesIndex::class)->name('admin.pages.index'); + Route::get('/pages/{slug}/edit', PagesEdit::class)->name('admin.pages.edit'); +}); +``` + +### Volt Component Structure + +```php +// resources/views/livewire/admin/pages/edit.blade.php +page = Page::where('slug', $slug)->firstOrFail(); + $this->content_ar = $this->page->content_ar ?? ''; + $this->content_en = $this->page->content_en ?? ''; + } + + public function save(): void + { + $this->page->update([ + 'content_ar' => clean($this->content_ar), // Sanitize HTML + 'content_en' => clean($this->content_en), + ]); + + $this->dispatch('notify', message: __('Page saved successfully')); + } + + public function togglePreview(): void + { + $this->showPreview = !$this->showPreview; + } +}; ?> +``` + +### HTML Sanitization +Use `mews/purifier` package or similar to sanitize rich text HTML before saving: +```bash +composer require mews/purifier +``` + +### Edge Cases +- **Empty content:** Allow saving empty content (legal pages may be drafted later) +- **Large content:** Use `longText` column type, consider lazy loading for edit +- **Concurrent edits:** Single admin system - not a concern +- **RTL in editor:** Quill supports RTL via `direction: rtl` CSS on editor container + +## Test Scenarios + +### Feature Tests + +```php +// tests/Feature/Admin/LegalPagesTest.php + +test('admin can view legal pages list', function () { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->get(route('admin.pages.index')) + ->assertOk() + ->assertSee('Terms of Service') + ->assertSee('Privacy Policy'); +}); + +test('admin can edit terms of service in Arabic', function () { + $admin = User::factory()->admin()->create(); + $page = Page::where('slug', 'terms')->first(); + + Volt::test('admin.pages.edit', ['slug' => 'terms']) + ->actingAs($admin) + ->set('content_ar', '

شروط الخدمة الجديدة

') + ->call('save') + ->assertHasNoErrors(); + + expect($page->fresh()->content_ar)->toContain('شروط الخدمة الجديدة'); +}); + +test('admin can edit terms of service in English', function () { + $admin = User::factory()->admin()->create(); + $page = Page::where('slug', 'terms')->first(); + + Volt::test('admin.pages.edit', ['slug' => 'terms']) + ->actingAs($admin) + ->set('content_en', '

New terms content

') + ->call('save') + ->assertHasNoErrors(); + + expect($page->fresh()->content_en)->toContain('New terms content'); +}); + +test('admin can preview page content', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.pages.edit', ['slug' => 'terms']) + ->actingAs($admin) + ->call('togglePreview') + ->assertSet('showPreview', true); +}); + +test('updated_at timestamp changes on save', function () { + $admin = User::factory()->admin()->create(); + $page = Page::where('slug', 'terms')->first(); + $originalTimestamp = $page->updated_at; + + $this->travel(1)->minute(); + + Volt::test('admin.pages.edit', ['slug' => 'terms']) + ->actingAs($admin) + ->set('content_en', '

Updated content

') + ->call('save'); + + expect($page->fresh()->updated_at)->toBeGreaterThan($originalTimestamp); +}); + +test('public can view terms page', function () { + Page::where('slug', 'terms')->update(['content_en' => '

Our terms

']); + + $this->get('/page/terms') + ->assertOk() + ->assertSee('Our terms'); +}); + +test('public can view privacy page', function () { + Page::where('slug', 'privacy')->update(['content_en' => '

Our privacy policy

']); + + $this->get('/page/privacy') + ->assertOk() + ->assertSee('Our privacy policy'); +}); + +test('public page shows last updated timestamp', function () { + $page = Page::where('slug', 'terms')->first(); + + $this->get('/page/terms') + ->assertOk() + ->assertSee($page->updated_at->format('M d, Y')); +}); + +test('invalid page slug returns 404', function () { + $this->get('/page/invalid-slug') + ->assertNotFound(); +}); + +test('html content is sanitized on save', function () { + $admin = User::factory()->admin()->create(); + + Volt::test('admin.pages.edit', ['slug' => 'terms']) + ->actingAs($admin) + ->set('content_en', '

Safe content

') + ->call('save'); + + $page = Page::where('slug', 'terms')->first(); + expect($page->content_en)->not->toContain('