reviewed epic 5 & 6 stories
This commit is contained in:
parent
ad2d9604b8
commit
b2977c00d6
|
|
@ -49,6 +49,22 @@ So that **I can publish professional legal content for website visitors**.
|
||||||
|
|
||||||
## Technical Notes
|
## 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
|
### Database Schema
|
||||||
```php
|
```php
|
||||||
Schema::create('posts', function (Blueprint $table) {
|
Schema::create('posts', function (Blueprint $table) {
|
||||||
|
|
@ -147,7 +163,8 @@ new class extends Component {
|
||||||
$action = 'create';
|
$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(),
|
'admin_id' => auth()->id(),
|
||||||
'action_type' => $action,
|
'action_type' => $action,
|
||||||
'target_type' => 'post',
|
'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
|
### Template with Rich Text Editor
|
||||||
```blade
|
```blade
|
||||||
<div>
|
{{-- wire:poll.60s triggers autoSave every 60 seconds for draft posts --}}
|
||||||
|
<div wire:poll.60s="autoSave">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Arabic Fields -->
|
<!-- Arabic Fields -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|
@ -238,13 +269,44 @@ new class extends Component {
|
||||||
<flux:button wire:click="saveDraft">
|
<flux:button wire:click="saveDraft">
|
||||||
{{ __('admin.save_draft') }}
|
{{ __('admin.save_draft') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
|
<flux:button wire:click="preview">
|
||||||
|
{{ __('admin.preview') }}
|
||||||
|
</flux:button>
|
||||||
<flux:button variant="primary" wire:click="publish">
|
<flux:button variant="primary" wire:click="publish">
|
||||||
{{ __('admin.publish') }}
|
{{ __('admin.publish') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Preview Modal --}}
|
||||||
|
<flux:modal wire:model="showPreview" class="max-w-4xl">
|
||||||
|
<flux:heading>{{ __('admin.preview') }}</flux:heading>
|
||||||
|
<div class="mt-4 space-y-6">
|
||||||
|
<div class="border-b pb-4">
|
||||||
|
<h3 class="font-semibold text-lg mb-2">{{ __('admin.arabic_content') }}</h3>
|
||||||
|
<h2 class="text-xl font-bold" dir="rtl">{{ $title_ar }}</h2>
|
||||||
|
<div class="prose mt-2" dir="rtl">{!! clean($body_ar) !!}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-lg mb-2">{{ __('admin.english_content') }}</h3>
|
||||||
|
<h2 class="text-xl font-bold">{{ $title_en }}</h2>
|
||||||
|
<div class="prose mt-2">{!! clean($body_en) !!}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<flux:button wire:click="closePreview">{{ __('admin.close') }}</flux:button>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Trix Editor Setup
|
||||||
|
Include Trix editor assets in your layout or component:
|
||||||
|
```blade
|
||||||
|
{{-- In your layout head --}}
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/trix@2.0.0/dist/trix.css">
|
||||||
|
<script src="https://unpkg.com/trix@2.0.0/dist/trix.umd.min.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
|
|
||||||
- [ ] Can create post with bilingual content
|
- [ ] Can create post with bilingual content
|
||||||
|
|
@ -253,11 +315,164 @@ new class extends Component {
|
||||||
- [ ] Can publish directly
|
- [ ] Can publish directly
|
||||||
- [ ] Can edit existing posts
|
- [ ] Can edit existing posts
|
||||||
- [ ] Auto-save works for drafts
|
- [ ] Auto-save works for drafts
|
||||||
|
- [ ] Preview modal displays sanitized content
|
||||||
- [ ] HTML properly sanitized
|
- [ ] HTML properly sanitized
|
||||||
- [ ] Audit log created
|
- [ ] Audit log created
|
||||||
- [ ] Tests pass
|
- [ ] Tests pass
|
||||||
- [ ] Code formatted with Pint
|
- [ ] 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' => '<p>' . str_repeat('a', 200) . '</p>']);
|
||||||
|
expect(strlen($post->excerpt))->toBeLessThanOrEqual(153); // 150 + '...'
|
||||||
|
expect($post->excerpt)->not->toContain('<p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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', '<p>محتوى المقال</p>')
|
||||||
|
->set('body_en', '<p>Article content</p>')
|
||||||
|
->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', '<p>نص</p>')
|
||||||
|
->set('body_en', '<p>Safe</p><script>alert("xss")</script><strong>Bold</strong>')
|
||||||
|
->call('saveDraft');
|
||||||
|
|
||||||
|
$post = Post::first();
|
||||||
|
expect($post->body_en)->not->toContain('<script>');
|
||||||
|
expect($post->body_en)->toContain('<strong>Bold</strong>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin log created on post create', 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(AdminLog::where('action_type', 'create')->where('target_type', 'post')->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preview modal can be opened and closed', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
Volt::test('admin.posts.create')
|
||||||
|
->actingAs($admin)
|
||||||
|
->assertSet('showPreview', false)
|
||||||
|
->call('preview')
|
||||||
|
->assertSet('showPreview', true)
|
||||||
|
->call('closePreview')
|
||||||
|
->assertSet('showPreview', false);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **Epic 1:** Database schema, admin authentication
|
- **Epic 1:** Database schema, admin authentication
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@ So that **I can organize, publish, and maintain content efficiently**.
|
||||||
## Story Context
|
## Story Context
|
||||||
|
|
||||||
### Existing System Integration
|
### Existing System Integration
|
||||||
- **Integrates with:** posts table
|
- **Integrates with:** posts table, admin_logs table
|
||||||
- **Technology:** Livewire Volt with pagination
|
- **Technology:** Livewire Volt with pagination
|
||||||
- **Follows pattern:** Admin list pattern
|
- **Follows pattern:** Admin list pattern (see similar components in `resources/views/livewire/admin/`)
|
||||||
- **Touch points:** Post CRUD operations
|
- **Touch points:** Post CRUD operations, AdminLog audit trail
|
||||||
|
- **Note:** HTML sanitization uses `clean()` helper (from mews/purifier package, configured in Story 5.1)
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
|
@ -39,16 +40,34 @@ So that **I can organize, publish, and maintain content efficiently**.
|
||||||
|
|
||||||
### Quality Requirements
|
### Quality Requirements
|
||||||
- [ ] Bilingual labels
|
- [ ] Bilingual labels
|
||||||
- [ ] Audit log for delete actions
|
- [ ] Audit log for delete and status change actions
|
||||||
|
- [ ] Authorization check (admin only) on all actions
|
||||||
- [ ] Tests for list operations
|
- [ ] Tests for list operations
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- [ ] Handle post not found gracefully (race condition on delete)
|
||||||
|
- [ ] Per-page selector visible in UI (10/25/50 options)
|
||||||
|
- [ ] Bulk delete is **out of scope** for this story (marked optional)
|
||||||
|
|
||||||
## Technical Notes
|
## Technical Notes
|
||||||
|
|
||||||
|
### File Location
|
||||||
|
Create Volt component at: `resources/views/livewire/admin/posts/index.blade.php`
|
||||||
|
|
||||||
|
### Route
|
||||||
|
```php
|
||||||
|
// routes/web.php (admin group)
|
||||||
|
Route::get('/admin/posts', function () {
|
||||||
|
return view('livewire.admin.posts.index');
|
||||||
|
})->middleware(['auth', 'admin'])->name('admin.posts.index');
|
||||||
|
```
|
||||||
|
|
||||||
### Volt Component
|
### Volt Component
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
|
use App\Models\AdminLog;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
|
@ -154,6 +173,11 @@ new class extends Component {
|
||||||
<option value="draft">{{ __('admin.draft') }}</option>
|
<option value="draft">{{ __('admin.draft') }}</option>
|
||||||
<option value="published">{{ __('admin.published') }}</option>
|
<option value="published">{{ __('admin.published') }}</option>
|
||||||
</flux:select>
|
</flux:select>
|
||||||
|
<flux:select wire:model.live="perPage">
|
||||||
|
<option value="10">10 {{ __('admin.per_page') }}</option>
|
||||||
|
<option value="25">25 {{ __('admin.per_page') }}</option>
|
||||||
|
<option value="50">50 {{ __('admin.per_page') }}</option>
|
||||||
|
</flux:select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Posts Table -->
|
<!-- Posts Table -->
|
||||||
|
|
@ -220,6 +244,65 @@ new class extends Component {
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
### Test Scenarios
|
||||||
|
```php
|
||||||
|
// tests/Feature/Admin/PostManagementTest.php
|
||||||
|
|
||||||
|
test('admin can view posts list', function () {
|
||||||
|
// Create admin, create posts, visit index, assert sees posts
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can filter posts by status', function () {
|
||||||
|
// Create draft and published posts
|
||||||
|
// Filter by 'draft' - only drafts visible
|
||||||
|
// Filter by 'published' - only published visible
|
||||||
|
// Filter by '' (all) - all visible
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can search posts by title in current locale', function () {
|
||||||
|
// Create posts with known titles
|
||||||
|
// Search by partial title - matches appear
|
||||||
|
// Search with no matches - empty state shown
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can search posts by body content', function () {
|
||||||
|
// Create posts with known body content
|
||||||
|
// Search by body text - matches appear
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can sort posts by date', function () {
|
||||||
|
// Create posts with different dates
|
||||||
|
// Sort desc - newest first
|
||||||
|
// Sort asc - oldest first
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can toggle post publish status', function () {
|
||||||
|
// Create draft post
|
||||||
|
// Toggle - becomes published, AdminLog created
|
||||||
|
// Toggle again - becomes draft, AdminLog created
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can delete post with confirmation', function () {
|
||||||
|
// Create post
|
||||||
|
// Delete - post removed, AdminLog created
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pagination works correctly', function () {
|
||||||
|
// Create 15 posts
|
||||||
|
// Page 1 shows 10, page 2 shows 5
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit log records status changes', function () {
|
||||||
|
// Toggle status, verify AdminLog entry with old/new values
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit log records deletions', function () {
|
||||||
|
// Delete post, verify AdminLog entry with old values
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
|
|
||||||
- [ ] List displays all posts
|
- [ ] List displays all posts
|
||||||
|
|
@ -229,13 +312,15 @@ new class extends Component {
|
||||||
- [ ] Quick publish/unpublish toggle works
|
- [ ] Quick publish/unpublish toggle works
|
||||||
- [ ] Delete with confirmation works
|
- [ ] Delete with confirmation works
|
||||||
- [ ] Pagination works
|
- [ ] Pagination works
|
||||||
|
- [ ] Per-page selector works (10/25/50)
|
||||||
- [ ] Audit log for actions
|
- [ ] Audit log for actions
|
||||||
- [ ] Tests pass
|
- [ ] All test scenarios pass
|
||||||
- [ ] Code formatted with Pint
|
- [ ] Code formatted with Pint
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **Story 5.1:** Post creation
|
- **Story 5.1:** Post creation - `docs/stories/story-5.1-post-creation-editing.md`
|
||||||
|
- **Requires:** Post model with bilingual accessors, AdminLog model
|
||||||
|
|
||||||
## Estimation
|
## Estimation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,20 @@ So that **I can remove outdated or incorrect content from the website**.
|
||||||
- **Technology:** Livewire Volt
|
- **Technology:** Livewire Volt
|
||||||
- **Follows pattern:** Permanent delete pattern
|
- **Follows pattern:** Permanent delete pattern
|
||||||
- **Touch points:** Post management dashboard
|
- **Touch points:** Post management dashboard
|
||||||
|
- **Files to modify:** Post management Volt component from Story 5.2
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Delete Functionality
|
### Delete Functionality
|
||||||
- [ ] Delete button on post list and edit page
|
- [ ] Delete button on post list (primary location)
|
||||||
- [ ] Confirmation dialog before deletion
|
- [ ] Delete button on post edit page (secondary location)
|
||||||
|
- [ ] Confirmation modal dialog before deletion
|
||||||
- [ ] Permanent deletion (no soft delete per PRD)
|
- [ ] Permanent deletion (no soft delete per PRD)
|
||||||
- [ ] Success message after deletion
|
- [ ] Success message after deletion
|
||||||
|
- [ ] Redirect to post list after deletion from edit page
|
||||||
|
|
||||||
### Restrictions
|
### Restrictions
|
||||||
- [ ] Cannot delete while post is being viewed publicly (edge case)
|
- [ ] Admin-only access (middleware protection)
|
||||||
- [ ] Admin-only access
|
|
||||||
|
|
||||||
### Audit Trail
|
### Audit Trail
|
||||||
- [ ] Audit log entry preserved
|
- [ ] Audit log entry preserved
|
||||||
|
|
@ -63,6 +65,16 @@ So that **I can remove outdated or incorrect content from the website**.
|
||||||
</flux:modal>
|
</flux:modal>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Delete Button on Edit Page
|
||||||
|
Add delete button to the post edit form (from Story 5.1) when editing an existing post:
|
||||||
|
```blade
|
||||||
|
@if($post?->exists)
|
||||||
|
<flux:button variant="danger" wire:click="delete({{ $post->id }})">
|
||||||
|
{{ __('admin.delete_post') }}
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
```
|
||||||
|
|
||||||
### Delete Logic
|
### Delete Logic
|
||||||
```php
|
```php
|
||||||
public ?Post $postToDelete = null;
|
public ?Post $postToDelete = null;
|
||||||
|
|
@ -102,26 +114,57 @@ public function confirmDelete(): void
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
```php
|
```php
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\AdminLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Volt\Volt;
|
||||||
|
|
||||||
it('permanently deletes post', function () {
|
it('permanently deletes post', function () {
|
||||||
|
$admin = User::factory()->create();
|
||||||
$post = Post::factory()->create();
|
$post = Post::factory()->create();
|
||||||
|
|
||||||
$this->actingAs($admin)
|
Volt::test('admin.posts.index')
|
||||||
->delete(route('admin.posts.destroy', $post));
|
->actingAs($admin)
|
||||||
|
->call('delete', $post->id)
|
||||||
|
->call('confirmDelete');
|
||||||
|
|
||||||
expect(Post::find($post->id))->toBeNull();
|
expect(Post::find($post->id))->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates audit log on deletion', function () {
|
it('shows confirmation modal before deletion', function () {
|
||||||
|
$admin = User::factory()->create();
|
||||||
$post = Post::factory()->create();
|
$post = Post::factory()->create();
|
||||||
|
|
||||||
$this->actingAs($admin)
|
Volt::test('admin.posts.index')
|
||||||
->delete(route('admin.posts.destroy', $post));
|
->actingAs($admin)
|
||||||
|
->call('delete', $post->id)
|
||||||
|
->assertSet('showDeleteModal', true)
|
||||||
|
->assertSet('postToDelete.id', $post->id);
|
||||||
|
});
|
||||||
|
|
||||||
expect(AdminLog::where('target_type', 'post')
|
it('creates audit log on deletion with old values', function () {
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$post = Post::factory()->create(['title_en' => 'Test Title']);
|
||||||
|
|
||||||
|
Volt::test('admin.posts.index')
|
||||||
|
->actingAs($admin)
|
||||||
|
->call('delete', $post->id)
|
||||||
|
->call('confirmDelete');
|
||||||
|
|
||||||
|
$log = AdminLog::where('target_type', 'post')
|
||||||
->where('target_id', $post->id)
|
->where('target_id', $post->id)
|
||||||
->where('action_type', 'delete')
|
->where('action_type', 'delete')
|
||||||
->exists()
|
->first();
|
||||||
)->toBeTrue();
|
|
||||||
|
expect($log)->not->toBeNull()
|
||||||
|
->and($log->old_values)->toHaveKey('title_en', 'Test Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires admin authentication to delete', function () {
|
||||||
|
$post = Post::factory()->create();
|
||||||
|
|
||||||
|
$this->delete(route('admin.posts.destroy', $post))
|
||||||
|
->assertRedirect(route('login'));
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ So that **I can read legal updates and articles from the firm**.
|
||||||
- **Follows pattern:** Public content display
|
- **Follows pattern:** Public content display
|
||||||
- **Touch points:** Navigation, homepage
|
- **Touch points:** Navigation, homepage
|
||||||
|
|
||||||
|
### Assumptions
|
||||||
|
- Post model exists with `published()` scope (from Story 5.1)
|
||||||
|
- Post model has locale-aware `title`, `body`, and `excerpt` accessors (from Story 5.1)
|
||||||
|
- Bilingual infrastructure is in place (from Epic 1)
|
||||||
|
- Base navigation includes link to posts section (from Epic 1)
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Posts Listing Page
|
### Posts Listing Page
|
||||||
|
|
@ -173,6 +179,82 @@ new class extends Component {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
### Feature Tests
|
||||||
|
```php
|
||||||
|
use App\Models\Post;
|
||||||
|
use Livewire\Volt\Volt;
|
||||||
|
|
||||||
|
test('posts listing page shows only published posts', function () {
|
||||||
|
Post::factory()->create(['status' => 'published', 'title_en' => 'Published Post']);
|
||||||
|
Post::factory()->create(['status' => 'draft', 'title_en' => 'Draft Post']);
|
||||||
|
|
||||||
|
$this->get(route('posts.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Published Post')
|
||||||
|
->assertDontSee('Draft Post');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('posts displayed in reverse chronological order', function () {
|
||||||
|
$older = Post::factory()->create(['status' => 'published', 'created_at' => now()->subDays(5)]);
|
||||||
|
$newer = Post::factory()->create(['status' => 'published', 'created_at' => now()]);
|
||||||
|
|
||||||
|
$this->get(route('posts.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSeeInOrder([$newer->title, $older->title]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('posts listing page paginates results', function () {
|
||||||
|
Post::factory()->count(15)->create(['status' => 'published']);
|
||||||
|
|
||||||
|
$this->get(route('posts.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Next'); // pagination link
|
||||||
|
});
|
||||||
|
|
||||||
|
test('individual post page shows full content', function () {
|
||||||
|
$post = Post::factory()->create(['status' => 'published']);
|
||||||
|
|
||||||
|
$this->get(route('posts.show', $post))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee($post->title)
|
||||||
|
->assertSee($post->body);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('individual post page returns 404 for unpublished posts', function () {
|
||||||
|
$post = Post::factory()->create(['status' => 'draft']);
|
||||||
|
|
||||||
|
$this->get(route('posts.show', $post))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('individual post page returns 404 for non-existent posts', function () {
|
||||||
|
$this->get(route('posts.show', 999))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Language/RTL Tests
|
||||||
|
- [ ] Arabic content displays correctly with RTL direction
|
||||||
|
- [ ] English content displays correctly with LTR direction
|
||||||
|
- [ ] Date formatting respects locale (`translatedFormat`)
|
||||||
|
- [ ] Back link text displays in current language
|
||||||
|
|
||||||
|
### Responsive Tests
|
||||||
|
- [ ] Mobile layout renders correctly (single column, touch-friendly)
|
||||||
|
- [ ] Tablet layout renders correctly
|
||||||
|
- [ ] Typography remains readable at all breakpoints
|
||||||
|
|
||||||
|
### Key Test Scenarios
|
||||||
|
| Scenario | Expected Result |
|
||||||
|
|----------|-----------------|
|
||||||
|
| Visit /posts as guest | Shows published posts list |
|
||||||
|
| Visit /posts with no posts | Shows "no posts" message |
|
||||||
|
| Visit /posts/{id} for published post | Shows full post content |
|
||||||
|
| Visit /posts/{id} for draft post | Returns 404 |
|
||||||
|
| Switch language on posts page | Content displays in selected language |
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
|
|
||||||
- [ ] Posts listing page works
|
- [ ] Posts listing page works
|
||||||
|
|
@ -189,8 +271,8 @@ new class extends Component {
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **Story 5.1:** Post creation
|
- **Story 5.1:** Post creation (provides Post model with `published()` scope, `title`, `body`, `excerpt` accessors - see Database Schema and Post Model sections)
|
||||||
- **Epic 1:** Base UI, navigation
|
- **Epic 1:** Base UI, navigation, bilingual infrastructure
|
||||||
|
|
||||||
## Estimation
|
## Estimation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,39 +8,86 @@ As an **admin**,
|
||||||
I want **to see real-time metrics and key statistics at a glance**,
|
I want **to see real-time metrics and key statistics at a glance**,
|
||||||
So that **I can understand the current state of my practice**.
|
So that **I can understand the current state of my practice**.
|
||||||
|
|
||||||
|
## Prerequisites / Dependencies
|
||||||
|
|
||||||
|
This story requires the following to be completed first:
|
||||||
|
|
||||||
|
| Dependency | Required From | What's Needed |
|
||||||
|
|------------|---------------|---------------|
|
||||||
|
| User Model | Epic 2 | `status` (active/deactivated), `user_type` (individual/company) fields |
|
||||||
|
| Consultation Model | Epic 3 | `consultations` table with `status`, `consultation_type`, `scheduled_date` |
|
||||||
|
| Timeline Model | Epic 4 | `timelines` table with `status` (active/archived) |
|
||||||
|
| Timeline Updates | Epic 4 | `timeline_updates` table with `created_at` |
|
||||||
|
| Post Model | Epic 5 | `posts` table with `status` (published/draft), `created_at` |
|
||||||
|
| Admin Layout | Epic 1 | Admin authenticated layout with navigation |
|
||||||
|
|
||||||
|
**References:**
|
||||||
|
- Epic 6 details: `docs/epics/epic-6-admin-dashboard.md`
|
||||||
|
- PRD Dashboard Section: `docs/prd.md` Section 5.7 (Admin Dashboard)
|
||||||
|
- Database Schema: `docs/prd.md` Section 16.1
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
### User Metrics Card
|
### User Metrics Card
|
||||||
- [ ] Total active clients
|
- [ ] Total active clients (individual + company with status = active)
|
||||||
- [ ] Individual vs company breakdown
|
- [ ] Individual vs company breakdown (count each type)
|
||||||
- [ ] Deactivated clients count
|
- [ ] Deactivated clients count
|
||||||
- [ ] New clients this month
|
- [ ] New clients this month (created_at in current month)
|
||||||
|
|
||||||
### Booking Metrics Card
|
### Booking Metrics Card
|
||||||
- [ ] Pending requests count (highlighted)
|
- [ ] Pending requests count (highlighted with warning color)
|
||||||
- [ ] Today's consultations
|
- [ ] Today's consultations (scheduled for today, approved status)
|
||||||
- [ ] This week's consultations
|
- [ ] This week's consultations
|
||||||
- [ ] This month's consultations
|
- [ ] This month's consultations
|
||||||
- [ ] Free vs paid breakdown
|
- [ ] Free vs paid breakdown (consultation_type field)
|
||||||
- [ ] No-show rate percentage
|
- [ ] No-show rate percentage (no-show / total completed * 100)
|
||||||
|
|
||||||
### Timeline Metrics Card
|
### Timeline Metrics Card
|
||||||
- [ ] Active case timelines
|
- [ ] Active case timelines (status = active)
|
||||||
- [ ] Archived timelines
|
- [ ] Archived timelines (status = archived)
|
||||||
- [ ] Updates added this week
|
- [ ] Updates added this week (timeline_updates created in last 7 days)
|
||||||
|
|
||||||
### Posts Metrics Card
|
### Posts Metrics Card
|
||||||
- [ ] Total published posts
|
- [ ] Total published posts (status = published)
|
||||||
- [ ] Posts published this month
|
- [ ] Posts published this month
|
||||||
|
|
||||||
### Design
|
### Design
|
||||||
- [ ] Clean card-based layout
|
- [ ] Clean card-based layout using Flux UI components
|
||||||
- [ ] Color-coded status indicators
|
- [ ] Color-coded status indicators (gold for highlights, success green, warning colors)
|
||||||
- [ ] Responsive grid
|
- [ ] Responsive grid (2 columns on tablet, 1 on mobile, 4 on desktop)
|
||||||
|
- [ ] Navy blue and gold color scheme per PRD Section 7.1
|
||||||
|
|
||||||
## Technical Notes
|
## Technical Implementation
|
||||||
|
|
||||||
|
### Files to Create/Modify
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `resources/views/livewire/admin/dashboard.blade.php` | Main Volt component |
|
||||||
|
| `routes/web.php` | Add admin dashboard route |
|
||||||
|
|
||||||
|
### Route Definition
|
||||||
|
```php
|
||||||
|
Route::middleware(['auth', 'admin'])->prefix('admin')->group(function () {
|
||||||
|
Route::get('/dashboard', function () {
|
||||||
|
return view('livewire.admin.dashboard');
|
||||||
|
})->name('admin.dashboard');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Structure (Volt Class-Based)
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Timeline;
|
||||||
|
use App\Models\TimelineUpdate;
|
||||||
|
use App\Models\Post;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
new class extends Component {
|
new class extends Component {
|
||||||
public function with(): array
|
public function with(): array
|
||||||
{
|
{
|
||||||
|
|
@ -55,21 +102,166 @@ new class extends Component {
|
||||||
private function getUserMetrics(): array
|
private function getUserMetrics(): array
|
||||||
{
|
{
|
||||||
return Cache::remember('admin.metrics.users', 300, fn() => [
|
return Cache::remember('admin.metrics.users', 300, fn() => [
|
||||||
'total_active' => User::where('status', 'active')->whereIn('user_type', ['individual', 'company'])->count(),
|
'total_active' => User::where('status', 'active')
|
||||||
'individual' => User::where('user_type', 'individual')->where('status', 'active')->count(),
|
->whereIn('user_type', ['individual', 'company'])->count(),
|
||||||
'company' => User::where('user_type', 'company')->where('status', 'active')->count(),
|
'individual' => User::where('user_type', 'individual')
|
||||||
|
->where('status', 'active')->count(),
|
||||||
|
'company' => User::where('user_type', 'company')
|
||||||
|
->where('status', 'active')->count(),
|
||||||
'deactivated' => User::where('status', 'deactivated')->count(),
|
'deactivated' => User::where('status', 'deactivated')->count(),
|
||||||
'new_this_month' => User::whereMonth('created_at', now()->month)->count(),
|
'new_this_month' => User::whereMonth('created_at', now()->month)
|
||||||
|
->whereYear('created_at', now()->year)->count(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
private function getBookingMetrics(): array
|
||||||
|
{
|
||||||
|
return Cache::remember('admin.metrics.bookings', 300, function () {
|
||||||
|
$total = Consultation::whereIn('status', ['completed', 'no-show'])->count();
|
||||||
|
$noShows = Consultation::where('status', 'no-show')->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pending' => Consultation::where('status', 'pending')->count(),
|
||||||
|
'today' => Consultation::whereDate('scheduled_date', today())
|
||||||
|
->where('status', 'approved')->count(),
|
||||||
|
'this_week' => Consultation::whereBetween('scheduled_date', [
|
||||||
|
now()->startOfWeek(), now()->endOfWeek()
|
||||||
|
])->count(),
|
||||||
|
'this_month' => Consultation::whereMonth('scheduled_date', now()->month)
|
||||||
|
->whereYear('scheduled_date', now()->year)->count(),
|
||||||
|
'free' => Consultation::where('consultation_type', 'free')->count(),
|
||||||
|
'paid' => Consultation::where('consultation_type', 'paid')->count(),
|
||||||
|
'no_show_rate' => $total > 0 ? round(($noShows / $total) * 100, 1) : 0,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTimelineMetrics(): array
|
||||||
|
{
|
||||||
|
return Cache::remember('admin.metrics.timelines', 300, fn() => [
|
||||||
|
'active' => Timeline::where('status', 'active')->count(),
|
||||||
|
'archived' => Timeline::where('status', 'archived')->count(),
|
||||||
|
'updates_this_week' => TimelineUpdate::where('created_at', '>=', now()->subWeek())->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPostMetrics(): array
|
||||||
|
{
|
||||||
|
return Cache::remember('admin.metrics.posts', 300, fn() => [
|
||||||
|
'total_published' => Post::where('status', 'published')->count(),
|
||||||
|
'this_month' => Post::where('status', 'published')
|
||||||
|
->whereMonth('created_at', now()->month)
|
||||||
|
->whereYear('created_at', now()->year)->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{-- Dashboard content with Flux UI cards --}}
|
||||||
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Flux UI Components to Use
|
||||||
|
- `<flux:heading>` - Page title
|
||||||
|
- `<flux:card>` or custom card component - Metric cards (if available, otherwise Tailwind)
|
||||||
|
- `<flux:badge>` - Status indicators
|
||||||
|
- `<flux:text>` - Metric labels and values
|
||||||
|
|
||||||
|
### Cache Strategy
|
||||||
|
- **TTL:** 300 seconds (5 minutes) for all metrics
|
||||||
|
- **Keys:** `admin.metrics.users`, `admin.metrics.bookings`, `admin.metrics.timelines`, `admin.metrics.posts`
|
||||||
|
- **Invalidation:** Consider cache clearing when data changes (optional enhancement)
|
||||||
|
|
||||||
|
## Edge Cases & Error Handling
|
||||||
|
|
||||||
|
| Scenario | Expected Behavior |
|
||||||
|
|----------|-------------------|
|
||||||
|
| Empty database (0 clients) | All metrics display "0" - no errors |
|
||||||
|
| No consultations exist | No-show rate displays "0%" (not "N/A" or error) |
|
||||||
|
| New month (1st day) | "This month" metrics show 0 |
|
||||||
|
| Cache failure | Queries execute directly without caching (graceful degradation) |
|
||||||
|
| Division by zero (no-show rate) | Return 0 when total consultations is 0 |
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
### Test File
|
||||||
|
`tests/Feature/Admin/DashboardTest.php`
|
||||||
|
|
||||||
|
### Test Scenarios
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 1. Dashboard loads successfully for admin
|
||||||
|
test('admin can view dashboard', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get(route('admin.dashboard'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Metrics display correctly with sample data
|
||||||
|
test('dashboard displays correct user metrics', function () {
|
||||||
|
User::factory()->count(5)->create(['status' => 'active', 'user_type' => 'individual']);
|
||||||
|
User::factory()->count(3)->create(['status' => 'active', 'user_type' => 'company']);
|
||||||
|
User::factory()->count(2)->create(['status' => 'deactivated']);
|
||||||
|
|
||||||
|
// Assert metrics show 8 active, 5 individual, 3 company, 2 deactivated
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Empty state handling
|
||||||
|
test('dashboard handles empty database gracefully', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get(route('admin.dashboard'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('0'); // Should show zeros, not errors
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. No-show rate calculation
|
||||||
|
test('no-show rate calculates correctly', function () {
|
||||||
|
// Create 10 completed, 2 no-shows = 20% rate
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Cache behavior
|
||||||
|
test('metrics are cached for 5 minutes', function () {
|
||||||
|
// Verify cache key exists after first load
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Non-admin cannot access
|
||||||
|
test('non-admin cannot access dashboard', function () {
|
||||||
|
$client = User::factory()->client()->create();
|
||||||
|
|
||||||
|
$this->actingAs($client)
|
||||||
|
->get(route('admin.dashboard'))
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Testing Checklist
|
||||||
|
- [ ] Verify responsive layout on mobile (375px)
|
||||||
|
- [ ] Verify responsive layout on tablet (768px)
|
||||||
|
- [ ] Verify responsive layout on desktop (1200px+)
|
||||||
|
- [ ] Verify pending count is highlighted/prominent
|
||||||
|
- [ ] Verify color scheme matches PRD (navy blue, gold)
|
||||||
|
- [ ] Verify RTL layout works correctly (Arabic)
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
- [ ] All metric cards display correctly
|
- [ ] All metric cards display correctly with accurate data
|
||||||
- [ ] Data is accurate and cached
|
- [ ] Data is cached with 5-minute TTL
|
||||||
- [ ] Responsive layout
|
- [ ] Empty states handled gracefully (show 0, no errors)
|
||||||
- [ ] Tests pass
|
- [ ] No-show rate handles division by zero
|
||||||
|
- [ ] Responsive layout works on mobile, tablet, desktop
|
||||||
|
- [ ] Color scheme matches brand guidelines
|
||||||
|
- [ ] All tests pass
|
||||||
|
- [ ] Admin-only access enforced
|
||||||
|
- [ ] Code formatted with Pint
|
||||||
|
|
||||||
## Estimation
|
## Estimation
|
||||||
**Complexity:** Medium | **Effort:** 4-5 hours
|
**Complexity:** Medium | **Effort:** 4-5 hours
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
- Real-time updates (polling/websockets) - covered in Story 6.3
|
||||||
|
- Charts and visualizations - covered in Story 6.2
|
||||||
|
- Quick action buttons - covered in Story 6.3
|
||||||
|
|
|
||||||
|
|
@ -8,30 +8,129 @@ As an **admin**,
|
||||||
I want **to view admin action history**,
|
I want **to view admin action history**,
|
||||||
So that **I can maintain accountability and track changes**.
|
So that **I can maintain accountability and track changes**.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- **Story 1.1:** Project Setup & Database Schema (admin_logs table and AdminLog model)
|
||||||
|
- **Story 1.2:** Authentication & Role System (admin auth middleware)
|
||||||
|
- **Story 6.1:** Dashboard Overview & Statistics (admin layout, navigation patterns)
|
||||||
|
|
||||||
|
## Navigation Context
|
||||||
|
- Accessible from admin dashboard sidebar/navigation
|
||||||
|
- Route: `/admin/audit-logs`
|
||||||
|
- Named route: `admin.audit-logs`
|
||||||
|
|
||||||
|
## Background Context
|
||||||
|
|
||||||
|
### What Gets Logged
|
||||||
|
The `admin_logs` table captures significant admin actions for accountability:
|
||||||
|
- **User Management:** Create, update, delete, deactivate/reactivate users
|
||||||
|
- **Consultation Management:** Approve, reject, mark complete, mark no-show, cancel bookings
|
||||||
|
- **Timeline Management:** Create, update, archive timelines and timeline entries
|
||||||
|
- **Posts Management:** Create, update, delete posts
|
||||||
|
- **Settings Changes:** System configuration modifications
|
||||||
|
|
||||||
|
### AdminLog Model Schema
|
||||||
|
Reference: PRD §16.1 Database Schema
|
||||||
|
|
||||||
|
```
|
||||||
|
admin_logs table:
|
||||||
|
- id: bigint (primary key)
|
||||||
|
- admin_id: bigint (nullable, foreign key to users - null for system actions)
|
||||||
|
- action: string (create, update, delete, approve, reject, etc.)
|
||||||
|
- target_type: string (user, consultation, timeline, timeline_update, post, setting)
|
||||||
|
- target_id: bigint (ID of the affected record)
|
||||||
|
- old_values: json (nullable - previous state for updates)
|
||||||
|
- new_values: json (nullable - new state for creates/updates)
|
||||||
|
- ip_address: string (IPv4/IPv6 address of admin)
|
||||||
|
- created_at: timestamp
|
||||||
|
```
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Display
|
### Display Requirements
|
||||||
- [ ] Action type (create, update, delete)
|
- [ ] Paginated table of audit log entries (25 per page)
|
||||||
- [ ] Target (user, consultation, timeline, etc.)
|
- [ ] Each row displays:
|
||||||
- [ ] Old and new values (for updates)
|
- Timestamp (formatted per locale: d/m/Y H:i for AR, m/d/Y H:i for EN)
|
||||||
- [ ] Timestamp
|
- Admin name (or "System" for automated actions)
|
||||||
- [ ] IP address
|
- Action type badge (color-coded: green=create, blue=update, red=delete)
|
||||||
|
- Target type and ID (e.g., "User #123")
|
||||||
|
- IP address
|
||||||
|
- Details button to view old/new values
|
||||||
|
|
||||||
### Filtering
|
### Filtering
|
||||||
- [ ] Filter by action type
|
- [ ] Filter by action type (dropdown with available actions)
|
||||||
- [ ] Filter by target type
|
- [ ] Filter by target type (dropdown with available targets)
|
||||||
- [ ] Filter by date range
|
- [ ] Filter by date range (date pickers for from/to)
|
||||||
|
- [ ] Filters apply without page reload (Livewire)
|
||||||
|
- [ ] Reset filters button
|
||||||
|
|
||||||
### Search
|
### Search
|
||||||
- [ ] Search by target name/ID
|
- [ ] Search by target ID (exact match)
|
||||||
|
- [ ] Search updates results in real-time with debounce
|
||||||
|
|
||||||
### Features
|
### Detail Modal
|
||||||
- [ ] Pagination
|
- [ ] Modal displays full log entry details
|
||||||
- [ ] Export audit log (CSV)
|
- [ ] Shows old values in readable format (JSON pretty-printed)
|
||||||
|
- [ ] Shows new values in readable format
|
||||||
|
- [ ] Highlights differences between old/new for updates
|
||||||
|
- [ ] Bilingual labels
|
||||||
|
|
||||||
|
### CSV Export
|
||||||
|
- [ ] Export current filtered results to CSV
|
||||||
|
- [ ] CSV includes all displayed columns
|
||||||
|
- [ ] CSV filename includes export date
|
||||||
|
- [ ] Bilingual column headers based on admin's language preference
|
||||||
|
|
||||||
|
### Empty State
|
||||||
|
- [ ] Friendly message when no logs exist
|
||||||
|
- [ ] Friendly message when filters return no results
|
||||||
|
|
||||||
## Technical Notes
|
## Technical Notes
|
||||||
|
|
||||||
|
### Files to Create/Modify
|
||||||
|
- `resources/views/livewire/admin/audit-logs.blade.php` - Main Volt component
|
||||||
|
- `app/Models/AdminLog.php` - Eloquent model (if not already created in Story 1.1)
|
||||||
|
- `routes/web.php` - Add admin route (or admin routes file)
|
||||||
|
- `lang/ar/audit.php` - Arabic translations
|
||||||
|
- `lang/en/audit.php` - English translations
|
||||||
|
|
||||||
|
### AdminLog Model
|
||||||
```php
|
```php
|
||||||
|
class AdminLog extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'admin_id',
|
||||||
|
'action',
|
||||||
|
'target_type',
|
||||||
|
'target_id',
|
||||||
|
'old_values',
|
||||||
|
'new_values',
|
||||||
|
'ip_address',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'old_values' => 'array',
|
||||||
|
'new_values' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function admin(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'admin_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Volt Component Structure
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\AdminLog;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
new class extends Component {
|
new class extends Component {
|
||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
|
|
@ -40,59 +139,348 @@ new class extends Component {
|
||||||
public string $dateFrom = '';
|
public string $dateFrom = '';
|
||||||
public string $dateTo = '';
|
public string $dateTo = '';
|
||||||
public string $search = '';
|
public string $search = '';
|
||||||
|
public ?int $selectedLogId = null;
|
||||||
|
|
||||||
|
public function updatedActionFilter(): void { $this->resetPage(); }
|
||||||
|
public function updatedTargetFilter(): void { $this->resetPage(); }
|
||||||
|
public function updatedDateFrom(): void { $this->resetPage(); }
|
||||||
|
public function updatedDateTo(): void { $this->resetPage(); }
|
||||||
|
public function updatedSearch(): void { $this->resetPage(); }
|
||||||
|
|
||||||
|
public function resetFilters(): void
|
||||||
|
{
|
||||||
|
$this->reset(['actionFilter', 'targetFilter', 'dateFrom', 'dateTo', 'search']);
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showDetails(int $logId): void
|
||||||
|
{
|
||||||
|
$this->selectedLogId = $logId;
|
||||||
|
$this->dispatch('open-modal', name: 'log-details');
|
||||||
|
}
|
||||||
|
|
||||||
public function with(): array
|
public function with(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'logs' => AdminLog::query()
|
'logs' => $this->getFilteredQuery()->paginate(25),
|
||||||
|
'actionTypes' => AdminLog::distinct()->pluck('action'),
|
||||||
|
'targetTypes' => AdminLog::distinct()->pluck('target_type'),
|
||||||
|
'selectedLog' => $this->selectedLogId
|
||||||
|
? AdminLog::with('admin')->find($this->selectedLogId)
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportCsv(): StreamedResponse
|
||||||
|
{
|
||||||
|
$logs = $this->getFilteredQuery()->get();
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($logs) {
|
||||||
|
$handle = fopen('php://output', 'w');
|
||||||
|
|
||||||
|
// Header row (bilingual based on locale)
|
||||||
|
fputcsv($handle, [
|
||||||
|
__('audit.timestamp'),
|
||||||
|
__('audit.admin'),
|
||||||
|
__('audit.action'),
|
||||||
|
__('audit.target_type'),
|
||||||
|
__('audit.target_id'),
|
||||||
|
__('audit.ip_address'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($logs as $log) {
|
||||||
|
fputcsv($handle, [
|
||||||
|
$log->created_at->format('Y-m-d H:i:s'),
|
||||||
|
$log->admin?->name ?? __('audit.system'),
|
||||||
|
$log->action,
|
||||||
|
$log->target_type,
|
||||||
|
$log->target_id,
|
||||||
|
$log->ip_address,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
}, 'audit-log-' . now()->format('Y-m-d') . '.csv');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getFilteredQuery()
|
||||||
|
{
|
||||||
|
return AdminLog::query()
|
||||||
->with('admin')
|
->with('admin')
|
||||||
->when($this->actionFilter, fn($q) => $q->where('action_type', $this->actionFilter))
|
->when($this->actionFilter, fn($q) => $q->where('action', $this->actionFilter))
|
||||||
->when($this->targetFilter, fn($q) => $q->where('target_type', $this->targetFilter))
|
->when($this->targetFilter, fn($q) => $q->where('target_type', $this->targetFilter))
|
||||||
->when($this->dateFrom, fn($q) => $q->whereDate('created_at', '>=', $this->dateFrom))
|
->when($this->dateFrom, fn($q) => $q->whereDate('created_at', '>=', $this->dateFrom))
|
||||||
->when($this->dateTo, fn($q) => $q->whereDate('created_at', '<=', $this->dateTo))
|
->when($this->dateTo, fn($q) => $q->whereDate('created_at', '<=', $this->dateTo))
|
||||||
->when($this->search, fn($q) => $q->where('target_id', $this->search))
|
->when($this->search, fn($q) => $q->where('target_id', $this->search))
|
||||||
->latest()
|
->latest();
|
||||||
->paginate(25),
|
|
||||||
'actionTypes' => AdminLog::distinct()->pluck('action_type'),
|
|
||||||
'targetTypes' => AdminLog::distinct()->pluck('target_type'),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
public function exportCsv()
|
<div>
|
||||||
{
|
<!-- Filters Section -->
|
||||||
// Export filtered logs to CSV
|
<!-- Table Section -->
|
||||||
}
|
<!-- Pagination -->
|
||||||
};
|
<!-- Detail Modal -->
|
||||||
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Template
|
### Template Structure
|
||||||
```blade
|
```blade
|
||||||
@foreach($logs as $log)
|
<div class="space-y-6">
|
||||||
|
{{-- Filters --}}
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<flux:select wire:model.live="actionFilter" placeholder="{{ __('audit.filter_action') }}">
|
||||||
|
<flux:select.option value="">{{ __('audit.all_actions') }}</flux:select.option>
|
||||||
|
@foreach($actionTypes as $type)
|
||||||
|
<flux:select.option value="{{ $type }}">{{ __("audit.action_{$type}") }}</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:select wire:model.live="targetFilter" placeholder="{{ __('audit.filter_target') }}">
|
||||||
|
<flux:select.option value="">{{ __('audit.all_targets') }}</flux:select.option>
|
||||||
|
@foreach($targetTypes as $type)
|
||||||
|
<flux:select.option value="{{ $type }}">{{ __("audit.target_{$type}") }}</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:input type="date" wire:model.live="dateFrom" />
|
||||||
|
<flux:input type="date" wire:model.live="dateTo" />
|
||||||
|
<flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('audit.search_target_id') }}" />
|
||||||
|
|
||||||
|
<flux:button wire:click="resetFilters" variant="ghost">{{ __('audit.reset') }}</flux:button>
|
||||||
|
<flux:button wire:click="exportCsv">{{ __('audit.export_csv') }}</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Table --}}
|
||||||
|
@if($logs->isEmpty())
|
||||||
|
<flux:callout>{{ __('audit.no_logs_found') }}</flux:callout>
|
||||||
|
@else
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ $log->created_at->format('d/m/Y H:i') }}</td>
|
<th>{{ __('audit.timestamp') }}</th>
|
||||||
<td>{{ $log->admin?->name ?? __('admin.system') }}</td>
|
<th>{{ __('audit.admin') }}</th>
|
||||||
|
<th>{{ __('audit.action') }}</th>
|
||||||
|
<th>{{ __('audit.target') }}</th>
|
||||||
|
<th>{{ __('audit.ip_address') }}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($logs as $log)
|
||||||
|
<tr wire:key="log-{{ $log->id }}">
|
||||||
|
<td>{{ $log->created_at->translatedFormat(app()->getLocale() === 'ar' ? 'd/m/Y H:i' : 'm/d/Y H:i') }}</td>
|
||||||
|
<td>{{ $log->admin?->name ?? __('audit.system') }}</td>
|
||||||
<td>
|
<td>
|
||||||
<flux:badge>{{ $log->action_type }}</flux:badge>
|
<flux:badge
|
||||||
|
color="{{ match($log->action) {
|
||||||
|
'create' => 'green',
|
||||||
|
'update' => 'blue',
|
||||||
|
'delete' => 'red',
|
||||||
|
default => 'zinc'
|
||||||
|
} }}">
|
||||||
|
{{ __("audit.action_{$log->action}") }}
|
||||||
|
</flux:badge>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ $log->target_type }} #{{ $log->target_id }}</td>
|
<td>{{ __("audit.target_{$log->target_type}") }} #{{ $log->target_id }}</td>
|
||||||
<td>{{ $log->ip_address }}</td>
|
<td>{{ $log->ip_address }}</td>
|
||||||
<td>
|
<td>
|
||||||
<flux:button size="sm" wire:click="showDetails({{ $log->id }})">
|
<flux:button size="sm" wire:click="showDetails({{ $log->id }})">
|
||||||
{{ __('admin.details') }}
|
{{ __('audit.details') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{{ $logs->links() }}
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Detail Modal --}}
|
||||||
|
<flux:modal name="log-details">
|
||||||
|
@if($selectedLog)
|
||||||
|
<flux:heading>{{ __('audit.log_details') }}</flux:heading>
|
||||||
|
|
||||||
|
@if($selectedLog->old_values)
|
||||||
|
<div>
|
||||||
|
<strong>{{ __('audit.old_values') }}:</strong>
|
||||||
|
<pre class="bg-zinc-100 p-2 rounded text-sm overflow-auto">{{ json_encode($selectedLog->old_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($selectedLog->new_values)
|
||||||
|
<div>
|
||||||
|
<strong>{{ __('audit.new_values') }}:</strong>
|
||||||
|
<pre class="bg-zinc-100 p-2 rounded text-sm overflow-auto">{{ json_encode($selectedLog->new_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</flux:modal>
|
||||||
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Action Badge Colors
|
||||||
|
| Action | Color | Meaning |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| create | green | New record created |
|
||||||
|
| update | blue | Record modified |
|
||||||
|
| delete | red | Record removed |
|
||||||
|
| approve | green | Booking approved |
|
||||||
|
| reject | red | Booking rejected |
|
||||||
|
| archive | zinc | Timeline archived |
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
### Test Scenarios
|
||||||
|
```php
|
||||||
|
// tests/Feature/Admin/AuditLogTest.php
|
||||||
|
|
||||||
|
test('audit log page requires admin authentication', function () {
|
||||||
|
$this->get(route('admin.audit-logs'))
|
||||||
|
->assertRedirect(route('login'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit log page loads for admin', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get(route('admin.audit-logs'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSeeLivewire('admin.audit-logs');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit logs display in table', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
AdminLog::factory()->count(5)->create();
|
||||||
|
|
||||||
|
Volt::test('admin.audit-logs')
|
||||||
|
->actingAs($admin)
|
||||||
|
->assertSee('audit.timestamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can filter by action type', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
AdminLog::factory()->create(['action' => 'create']);
|
||||||
|
AdminLog::factory()->create(['action' => 'delete']);
|
||||||
|
|
||||||
|
Volt::test('admin.audit-logs')
|
||||||
|
->actingAs($admin)
|
||||||
|
->set('actionFilter', 'create')
|
||||||
|
->assertSee('create')
|
||||||
|
->assertDontSee('delete');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can filter by target type', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
AdminLog::factory()->create(['target_type' => 'user']);
|
||||||
|
AdminLog::factory()->create(['target_type' => 'consultation']);
|
||||||
|
|
||||||
|
Volt::test('admin.audit-logs')
|
||||||
|
->actingAs($admin)
|
||||||
|
->set('targetFilter', 'user')
|
||||||
|
->assertSee('user');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can filter by date range', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
AdminLog::factory()->create(['created_at' => now()->subDays(10)]);
|
||||||
|
AdminLog::factory()->create(['created_at' => now()]);
|
||||||
|
|
||||||
|
Volt::test('admin.audit-logs')
|
||||||
|
->actingAs($admin)
|
||||||
|
->set('dateFrom', now()->subDays(5)->format('Y-m-d'))
|
||||||
|
->set('dateTo', now()->format('Y-m-d'))
|
||||||
|
->assertViewHas('logs', fn($logs) => $logs->count() === 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can search by target id', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
AdminLog::factory()->create(['target_id' => 123]);
|
||||||
|
AdminLog::factory()->create(['target_id' => 456]);
|
||||||
|
|
||||||
|
Volt::test('admin.audit-logs')
|
||||||
|
->actingAs($admin)
|
||||||
|
->set('search', '123')
|
||||||
|
->assertViewHas('logs', fn($logs) => $logs->count() === 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can view log details in modal', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$log = AdminLog::factory()->create([
|
||||||
|
'old_values' => ['name' => 'Old Name'],
|
||||||
|
'new_values' => ['name' => 'New Name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::test('admin.audit-logs')
|
||||||
|
->actingAs($admin)
|
||||||
|
->call('showDetails', $log->id)
|
||||||
|
->assertSet('selectedLogId', $log->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can export filtered logs to csv', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
AdminLog::factory()->count(3)->create();
|
||||||
|
|
||||||
|
Volt::test('admin.audit-logs')
|
||||||
|
->actingAs($admin)
|
||||||
|
->call('exportCsv')
|
||||||
|
->assertFileDownloaded();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('displays empty state when no logs', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
Volt::test('admin.audit-logs')
|
||||||
|
->actingAs($admin)
|
||||||
|
->assertSee(__('audit.no_logs_found'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pagination works correctly', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
AdminLog::factory()->count(50)->create();
|
||||||
|
|
||||||
|
Volt::test('admin.audit-logs')
|
||||||
|
->actingAs($admin)
|
||||||
|
->assertViewHas('logs', fn($logs) => $logs->count() === 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset filters clears all filters', function () {
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
Volt::test('admin.audit-logs')
|
||||||
|
->actingAs($admin)
|
||||||
|
->set('actionFilter', 'create')
|
||||||
|
->set('targetFilter', 'user')
|
||||||
|
->set('search', '123')
|
||||||
|
->call('resetFilters')
|
||||||
|
->assertSet('actionFilter', '')
|
||||||
|
->assertSet('targetFilter', '')
|
||||||
|
->assertSet('search', '');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Edge Cases & Error Handling
|
||||||
|
- **Empty state:** Display friendly message when no logs exist or filters return nothing
|
||||||
|
- **Deleted admin:** Show "System" or "Deleted User" if admin_id references deleted user
|
||||||
|
- **Large JSON values:** Truncate display in table, show full in modal with scroll
|
||||||
|
- **IPv6 addresses:** Ensure column width accommodates longer IP addresses
|
||||||
|
- **Missing target:** Handle gracefully if target record was deleted (show ID only)
|
||||||
|
- **Pagination reset:** Reset to page 1 when filters change
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
- [ ] Logs display correctly
|
- [ ] Audit logs page accessible at `/admin/audit-logs`
|
||||||
- [ ] All filters work
|
- [ ] Logs display with all required columns
|
||||||
- [ ] Search works
|
- [ ] All filters work correctly (action, target, date range)
|
||||||
- [ ] Pagination works
|
- [ ] Search by target ID works
|
||||||
- [ ] CSV export works
|
- [ ] Pagination displays 25 items per page
|
||||||
- [ ] Old/new values viewable
|
- [ ] CSV export downloads with filtered data
|
||||||
- [ ] Tests pass
|
- [ ] Detail modal shows old/new values formatted
|
||||||
|
- [ ] Empty state displays when appropriate
|
||||||
|
- [ ] Bilingual support (AR/EN) complete
|
||||||
|
- [ ] Admin-only access enforced
|
||||||
|
- [ ] All tests pass
|
||||||
|
- [ ] Code formatted with Pint
|
||||||
|
|
||||||
## Estimation
|
## Estimation
|
||||||
**Complexity:** Medium | **Effort:** 3-4 hours
|
**Complexity:** Medium | **Effort:** 3-4 hours
|
||||||
|
|
|
||||||
|
|
@ -6,44 +6,563 @@
|
||||||
## User Story
|
## User Story
|
||||||
As an **admin**,
|
As an **admin**,
|
||||||
I want **visual charts showing trends and historical data**,
|
I want **visual charts showing trends and historical data**,
|
||||||
So that **I can analyze business patterns over time**.
|
So that **I can identify patterns in client acquisition and consultation outcomes to make informed business decisions**.
|
||||||
|
|
||||||
|
## 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 structure and route |
|
||||||
|
| User Model | Epic 2 | `users` table with `created_at` for tracking new clients |
|
||||||
|
| Consultation Model | Epic 3 | `consultations` table with `consultation_type`, `status`, `scheduled_date` |
|
||||||
|
| Admin Layout | Epic 1 | Admin authenticated layout with navigation |
|
||||||
|
|
||||||
|
**References:**
|
||||||
|
- Epic 6 details: `docs/epics/epic-6-admin-dashboard.md`
|
||||||
|
- PRD Analytics Section: `docs/prd.md` Section 11.1 (Analytics & Reporting)
|
||||||
|
- PRD Dashboard Section: `docs/prd.md` Section 5.7 (Admin Dashboard)
|
||||||
|
- Story 6.1 for dashboard structure: `docs/stories/story-6.1-dashboard-overview-statistics.md`
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Charts Required
|
### Monthly Trends Chart (Line Chart)
|
||||||
- [ ] Monthly Trends (Line chart): New clients and consultations per month
|
- [ ] Display new clients per month (from `users.created_at`)
|
||||||
- [ ] Consultation Breakdown (Pie/Donut): Free vs paid ratio
|
- [ ] Display consultations per month (from `consultations.scheduled_date`)
|
||||||
- [ ] No-show Rate (Line chart): Monthly no-show trend
|
- [ ] Two data series on same chart with legend
|
||||||
|
- [ ] X-axis: Month labels (e.g., "Jan 2025", "Feb 2025")
|
||||||
|
- [ ] Y-axis: Count values with appropriate scale
|
||||||
|
|
||||||
### Features
|
### Consultation Breakdown Chart (Pie/Donut)
|
||||||
- [ ] Date range selector (6 months, 12 months, custom)
|
- [ ] Show free vs paid consultation ratio
|
||||||
- [ ] Chart tooltips with exact values
|
- [ ] Display percentage labels on segments
|
||||||
- [ ] Responsive charts
|
- [ ] Legend showing "Free" and "Paid" with counts
|
||||||
- [ ] Bilingual labels
|
|
||||||
|
|
||||||
## Technical Notes
|
### No-show Rate Chart (Line Chart)
|
||||||
|
- [ ] Monthly no-show percentage trend
|
||||||
|
- [ ] X-axis: Month labels
|
||||||
|
- [ ] Y-axis: Percentage (0-100%)
|
||||||
|
- [ ] Visual threshold line at concerning rate (e.g., 20%)
|
||||||
|
|
||||||
Use Chart.js for visualizations. Aggregate data server-side.
|
### Date Range Selector
|
||||||
|
- [ ] Preset options: Last 6 months, Last 12 months
|
||||||
|
- [ ] Custom date range picker (start/end month)
|
||||||
|
- [ ] Charts update when range changes
|
||||||
|
- [ ] Default selection: Last 6 months
|
||||||
|
|
||||||
|
### Chart Features
|
||||||
|
- [ ] Tooltips showing exact values on hover
|
||||||
|
- [ ] Responsive sizing (charts resize with viewport)
|
||||||
|
- [ ] Bilingual labels (Arabic/English based on locale)
|
||||||
|
- [ ] Loading state while fetching data
|
||||||
|
- [ ] Empty state when no data available
|
||||||
|
|
||||||
|
### Design
|
||||||
|
- [ ] Charts section below statistics cards from Story 6.1
|
||||||
|
- [ ] Card-based layout for each chart
|
||||||
|
- [ ] Navy blue and gold color scheme per PRD Section 7.1
|
||||||
|
- [ ] Responsive grid (1 column mobile, 2 columns tablet+)
|
||||||
|
|
||||||
|
## Technical Implementation
|
||||||
|
|
||||||
|
### Files to Create/Modify
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `resources/views/livewire/admin/dashboard.blade.php` | Add charts section to existing dashboard |
|
||||||
|
| `app/Services/AnalyticsService.php` | Data aggregation service for chart data |
|
||||||
|
|
||||||
|
### Integration with Story 6.1 Dashboard
|
||||||
|
|
||||||
|
The charts will be added as a new section in the existing dashboard component from Story 6.1. Add below the statistics cards section.
|
||||||
|
|
||||||
|
### Component Updates (Volt Class-Based)
|
||||||
|
|
||||||
|
Add to the existing dashboard component:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
public function getChartData(string $period = '6m'): array
|
<?php
|
||||||
{
|
|
||||||
$months = $period === '6m' ? 6 : 12;
|
use App\Models\User;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Services\AnalyticsService;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public string $chartPeriod = '6m';
|
||||||
|
public ?string $customStart = null;
|
||||||
|
public ?string $customEnd = null;
|
||||||
|
|
||||||
|
public function with(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// ... existing metrics from Story 6.1 ...
|
||||||
|
'chartData' => $this->getChartData(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedChartPeriod(): void
|
||||||
|
{
|
||||||
|
// Livewire will re-render with new data
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCustomRange(string $start, string $end): void
|
||||||
|
{
|
||||||
|
$this->customStart = $start;
|
||||||
|
$this->customEnd = $end;
|
||||||
|
$this->chartPeriod = 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getChartData(): array
|
||||||
|
{
|
||||||
|
$service = app(AnalyticsService::class);
|
||||||
|
|
||||||
|
$months = match($this->chartPeriod) {
|
||||||
|
'6m' => 6,
|
||||||
|
'12m' => 12,
|
||||||
|
'custom' => $this->getCustomMonthCount(),
|
||||||
|
default => 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
$startDate = $this->chartPeriod === 'custom' && $this->customStart
|
||||||
|
? Carbon::parse($this->customStart)->startOfMonth()
|
||||||
|
: now()->subMonths($months - 1)->startOfMonth();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'labels' => collect(range($months - 1, 0))->map(fn($i) => now()->subMonths($i)->format('M Y'))->toArray(),
|
'labels' => $service->getMonthLabels($startDate, $months),
|
||||||
'clients' => $this->getMonthlyClients($months),
|
'newClients' => $service->getMonthlyNewClients($startDate, $months),
|
||||||
'consultations' => $this->getMonthlyConsultations($months),
|
'consultations' => $service->getMonthlyConsultations($startDate, $months),
|
||||||
|
'consultationBreakdown' => $service->getConsultationTypeBreakdown($startDate, $months),
|
||||||
|
'noShowRates' => $service->getMonthlyNoShowRates($startDate, $months),
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCustomMonthCount(): int
|
||||||
|
{
|
||||||
|
if (!$this->customStart || !$this->customEnd) {
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
return Carbon::parse($this->customStart)
|
||||||
|
->diffInMonths(Carbon::parse($this->customEnd)) + 1;
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analytics Service
|
||||||
|
|
||||||
|
Create `app/Services/AnalyticsService.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AnalyticsService
|
||||||
|
{
|
||||||
|
public function getMonthLabels(Carbon $startDate, int $months): array
|
||||||
|
{
|
||||||
|
return collect(range(0, $months - 1))
|
||||||
|
->map(fn($i) => $startDate->copy()->addMonths($i)->translatedFormat('M Y'))
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMonthlyNewClients(Carbon $startDate, int $months): array
|
||||||
|
{
|
||||||
|
$endDate = $startDate->copy()->addMonths($months);
|
||||||
|
|
||||||
|
$data = User::query()
|
||||||
|
->whereIn('user_type', ['individual', 'company'])
|
||||||
|
->whereBetween('created_at', [$startDate, $endDate])
|
||||||
|
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count")
|
||||||
|
->groupBy('month')
|
||||||
|
->pluck('count', 'month');
|
||||||
|
|
||||||
|
return $this->fillMonthlyData($startDate, $months, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMonthlyConsultations(Carbon $startDate, int $months): array
|
||||||
|
{
|
||||||
|
$endDate = $startDate->copy()->addMonths($months);
|
||||||
|
|
||||||
|
$data = Consultation::query()
|
||||||
|
->whereBetween('scheduled_date', [$startDate, $endDate])
|
||||||
|
->selectRaw("DATE_FORMAT(scheduled_date, '%Y-%m') as month, COUNT(*) as count")
|
||||||
|
->groupBy('month')
|
||||||
|
->pluck('count', 'month');
|
||||||
|
|
||||||
|
return $this->fillMonthlyData($startDate, $months, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConsultationTypeBreakdown(Carbon $startDate, int $months): array
|
||||||
|
{
|
||||||
|
$endDate = $startDate->copy()->addMonths($months);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'free' => Consultation::whereBetween('scheduled_date', [$startDate, $endDate])
|
||||||
|
->where('consultation_type', 'free')->count(),
|
||||||
|
'paid' => Consultation::whereBetween('scheduled_date', [$startDate, $endDate])
|
||||||
|
->where('consultation_type', 'paid')->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMonthlyNoShowRates(Carbon $startDate, int $months): array
|
||||||
|
{
|
||||||
|
$endDate = $startDate->copy()->addMonths($months);
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
for ($i = 0; $i < $months; $i++) {
|
||||||
|
$monthStart = $startDate->copy()->addMonths($i);
|
||||||
|
$monthEnd = $monthStart->copy()->endOfMonth();
|
||||||
|
|
||||||
|
$total = Consultation::whereBetween('scheduled_date', [$monthStart, $monthEnd])
|
||||||
|
->whereIn('status', ['completed', 'no-show'])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$noShows = Consultation::whereBetween('scheduled_date', [$monthStart, $monthEnd])
|
||||||
|
->where('status', 'no-show')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$results[] = $total > 0 ? round(($noShows / $total) * 100, 1) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fillMonthlyData(Carbon $startDate, int $months, Collection $data): array
|
||||||
|
{
|
||||||
|
return collect(range(0, $months - 1))
|
||||||
|
->map(fn($i) => $data->get($startDate->copy()->addMonths($i)->format('Y-m'), 0))
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Chart.js Integration with Livewire
|
||||||
|
|
||||||
|
Use `wire:ignore` to prevent Livewire from re-rendering chart canvas, and Alpine.js to manage Chart.js instances:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- Charts Section --}}
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-8">
|
||||||
|
{{-- Date Range Selector --}}
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<flux:button
|
||||||
|
wire:click="$set('chartPeriod', '6m')"
|
||||||
|
:variant="$chartPeriod === '6m' ? 'primary' : 'ghost'"
|
||||||
|
>
|
||||||
|
{{ __('Last 6 Months') }}
|
||||||
|
</flux:button>
|
||||||
|
<flux:button
|
||||||
|
wire:click="$set('chartPeriod', '12m')"
|
||||||
|
:variant="$chartPeriod === '12m' ? 'primary' : 'ghost'"
|
||||||
|
>
|
||||||
|
{{ __('Last 12 Months') }}
|
||||||
|
</flux:button>
|
||||||
|
{{-- Custom range picker would go here --}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Monthly Trends Chart --}}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 shadow">
|
||||||
|
<flux:heading size="sm">{{ __('Monthly Trends') }}</flux:heading>
|
||||||
|
<div
|
||||||
|
wire:ignore
|
||||||
|
x-data="trendsChart(@js($chartData))"
|
||||||
|
x-init="init()"
|
||||||
|
class="h-64 mt-4"
|
||||||
|
>
|
||||||
|
<canvas x-ref="canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Consultation Breakdown Chart --}}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 shadow">
|
||||||
|
<flux:heading size="sm">{{ __('Consultation Breakdown') }}</flux:heading>
|
||||||
|
<div
|
||||||
|
wire:ignore
|
||||||
|
x-data="breakdownChart(@js($chartData['consultationBreakdown']))"
|
||||||
|
x-init="init()"
|
||||||
|
class="h-64 mt-4"
|
||||||
|
>
|
||||||
|
<canvas x-ref="canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- No-show Rate Chart --}}
|
||||||
|
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-lg p-6 shadow">
|
||||||
|
<flux:heading size="sm">{{ __('No-show Rate Trend') }}</flux:heading>
|
||||||
|
<div
|
||||||
|
wire:ignore
|
||||||
|
x-data="noShowChart(@js($chartData))"
|
||||||
|
x-init="init()"
|
||||||
|
class="h-64 mt-4"
|
||||||
|
>
|
||||||
|
<canvas x-ref="canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('trendsChart', (data) => ({
|
||||||
|
chart: null,
|
||||||
|
init() {
|
||||||
|
this.chart = new Chart(this.$refs.canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: '{{ __("New Clients") }}',
|
||||||
|
data: data.newClients,
|
||||||
|
borderColor: '#D4AF37',
|
||||||
|
tension: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '{{ __("Consultations") }}',
|
||||||
|
data: data.consultations,
|
||||||
|
borderColor: '#0A1F44',
|
||||||
|
tension: 0.3,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
tooltip: { enabled: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Alpine.data('breakdownChart', (data) => ({
|
||||||
|
chart: null,
|
||||||
|
init() {
|
||||||
|
this.chart = new Chart(this.$refs.canvas, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: ['{{ __("Free") }}', '{{ __("Paid") }}'],
|
||||||
|
datasets: [{
|
||||||
|
data: [data.free, data.paid],
|
||||||
|
backgroundColor: ['#D4AF37', '#0A1F44'],
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Alpine.data('noShowChart', (data) => ({
|
||||||
|
chart: null,
|
||||||
|
init() {
|
||||||
|
this.chart = new Chart(this.$refs.canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: '{{ __("No-show Rate %") }}',
|
||||||
|
data: data.noShowRates,
|
||||||
|
borderColor: '#E74C3C',
|
||||||
|
backgroundColor: 'rgba(231, 76, 60, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: { min: 0, max: 100 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Services\AnalyticsService;
|
||||||
|
use Livewire\Volt\Volt;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
test('admin can view analytics charts section', function () {
|
||||||
|
$admin = User::factory()->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
|
## Definition of Done
|
||||||
- [ ] All charts render correctly
|
- [ ] All three charts render correctly (trends, breakdown, no-show)
|
||||||
- [ ] Date range selector works
|
- [ ] Date range selector switches between 6/12 months
|
||||||
- [ ] Tooltips functional
|
- [ ] Tooltips show exact values on all charts
|
||||||
- [ ] Mobile responsive
|
- [ ] Charts are responsive on mobile, tablet, desktop
|
||||||
- [ ] Tests pass
|
- [ ] 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
|
## Estimation
|
||||||
**Complexity:** Medium-High | **Effort:** 4-5 hours
|
**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
|
||||||
|
|
|
||||||
|
|
@ -6,50 +6,624 @@
|
||||||
## User Story
|
## User Story
|
||||||
As an **admin**,
|
As an **admin**,
|
||||||
I want **quick access to pending items and common tasks**,
|
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
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Pending Bookings Widget
|
### Pending Bookings Widget
|
||||||
- [ ] Count badge with urgent indicator
|
- [ ] Display count badge showing number of pending consultation requests
|
||||||
- [ ] Link to booking management
|
- [ ] Urgent indicator (red/warning styling) when pending count > 0
|
||||||
- [ ] Mini list of recent pending (3-5)
|
- [ ] 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
|
### Today's Schedule Widget
|
||||||
- [ ] List of today's consultations
|
- [ ] List of today's approved consultations ordered by time
|
||||||
- [ ] Time and client name
|
- [ ] Each item displays:
|
||||||
- [ ] Quick status update buttons
|
- 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
|
### Recent Timeline Updates Widget
|
||||||
- [ ] Last 5 updates made
|
- [ ] Display last 5 timeline updates across all clients
|
||||||
- [ ] Quick link to timeline
|
- [ ] 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
|
### Quick Action Buttons
|
||||||
- [ ] Create user
|
- [ ] **Create User** button - navigates to `admin.users.create`
|
||||||
- [ ] Create post
|
- [ ] **Create Post** button - navigates to `admin.posts.create`
|
||||||
- [ ] Block time slot
|
- [ ] **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
|
### Notification Bell (Header)
|
||||||
- [ ] Pending items count
|
- [ ] 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
|
```php
|
||||||
public function with(): array
|
// resources/views/livewire/admin/widgets/pending-bookings.blade.php
|
||||||
{
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public function with(): array
|
||||||
|
{
|
||||||
return [
|
return [
|
||||||
'pendingBookings' => Consultation::pending()->latest()->take(5)->get(),
|
'pendingCount' => Consultation::pending()->count(),
|
||||||
'todaySchedule' => Consultation::approved()->whereDate('scheduled_date', today())->orderBy('scheduled_time')->get(),
|
'pendingBookings' => Consultation::pending()
|
||||||
'recentUpdates' => TimelineUpdate::latest()->take(5)->with('timeline.user')->get(),
|
->with('user:id,name')
|
||||||
|
->latest()
|
||||||
|
->take(5)
|
||||||
|
->get(),
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div wire:poll.30s>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<flux:heading size="sm">{{ __('Pending Bookings') }}</flux:heading>
|
||||||
|
@if($pendingCount > 0)
|
||||||
|
<flux:badge color="red">{{ $pendingCount }}</flux:badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@forelse($pendingBookings as $booking)
|
||||||
|
<div class="py-2 border-b border-gray-100 last:border-0">
|
||||||
|
<div class="font-medium">{{ $booking->user->name }}</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
{{ $booking->scheduled_date->format('M j') }} -
|
||||||
|
<flux:badge size="sm">{{ $booking->consultation_type }}</flux:badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<flux:text class="text-gray-500">{{ __('No pending bookings') }}</flux:text>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
@if($pendingCount > 5)
|
||||||
|
<a href="{{ route('admin.consultations.index', ['status' => 'pending']) }}"
|
||||||
|
class="block mt-4 text-sm text-blue-600 hover:underline">
|
||||||
|
{{ __('View all :count pending', ['count' => $pendingCount]) }}
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Today's Schedule Widget with Actions
|
||||||
|
|
||||||
|
```php
|
||||||
|
// resources/views/livewire/admin/widgets/todays-schedule.blade.php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public function with(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'todaySchedule' => 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']);
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div wire:poll.30s>
|
||||||
|
<flux:heading size="sm" class="mb-4">{{ __("Today's Schedule") }}</flux:heading>
|
||||||
|
|
||||||
|
@forelse($todaySchedule as $consultation)
|
||||||
|
<div class="flex items-center justify-between py-2 border-b border-gray-100 last:border-0">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ \Carbon\Carbon::parse($consultation->scheduled_time)->format('g:i A') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm">{{ $consultation->user->name }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<flux:button size="xs" wire:click="markComplete({{ $consultation->id }})">
|
||||||
|
{{ __('Complete') }}
|
||||||
|
</flux:button>
|
||||||
|
<flux:button size="xs" variant="danger" wire:click="markNoShow({{ $consultation->id }})">
|
||||||
|
{{ __('No-show') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<flux:text class="text-gray-500">{{ __('No consultations scheduled today') }}</flux:text>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Block Time Slot Modal
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In quick-actions.blade.php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public bool $showBlockModal = false;
|
||||||
|
|
||||||
|
#[Validate('required|date|after_or_equal:today')]
|
||||||
|
public string $blockDate = '';
|
||||||
|
|
||||||
|
#[Validate('required')]
|
||||||
|
public string $blockStartTime = '';
|
||||||
|
|
||||||
|
#[Validate('required|after:blockStartTime')]
|
||||||
|
public string $blockEndTime = '';
|
||||||
|
|
||||||
|
public string $blockReason = '';
|
||||||
|
|
||||||
|
public function openBlockModal(): void
|
||||||
|
{
|
||||||
|
$this->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']);
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<flux:button href="{{ route('admin.users.create') }}" variant="primary">
|
||||||
|
{{ __('Create User') }}
|
||||||
|
</flux:button>
|
||||||
|
<flux:button href="{{ route('admin.posts.create') }}">
|
||||||
|
{{ __('Create Post') }}
|
||||||
|
</flux:button>
|
||||||
|
<flux:button wire:click="openBlockModal">
|
||||||
|
{{ __('Block Time Slot') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:modal wire:model="showBlockModal">
|
||||||
|
<flux:heading>{{ __('Block Time Slot') }}</flux:heading>
|
||||||
|
|
||||||
|
<form wire:submit="blockTimeSlot" class="space-y-4 mt-4">
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('Date') }}</flux:label>
|
||||||
|
<flux:input type="date" wire:model="blockDate" />
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('Start Time') }}</flux:label>
|
||||||
|
<flux:input type="time" wire:model="blockStartTime" />
|
||||||
|
</flux:field>
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('End Time') }}</flux:label>
|
||||||
|
<flux:input type="time" wire:model="blockEndTime" />
|
||||||
|
</flux:field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ __('Reason (optional)') }}</flux:label>
|
||||||
|
<flux:textarea wire:model="blockReason" rows="2" />
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<flux:button type="button" wire:click="$set('showBlockModal', false)">
|
||||||
|
{{ __('Cancel') }}
|
||||||
|
</flux:button>
|
||||||
|
<flux:button type="submit" variant="primary">
|
||||||
|
{{ __('Block Slot') }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</flux:modal>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dashboard Integration
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In resources/views/livewire/admin/dashboard.blade.php
|
||||||
|
// Add after the metrics cards from Story 6.1
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-8">
|
||||||
|
{{-- Quick Actions Panel --}}
|
||||||
|
<div class="lg:col-span-3">
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<flux:heading size="sm" class="mb-4">{{ __('Quick Actions') }}</flux:heading>
|
||||||
|
<livewire:admin.widgets.quick-actions />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Pending Bookings Widget --}}
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<livewire:admin.widgets.pending-bookings />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Today's Schedule Widget --}}
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<livewire:admin.widgets.todays-schedule />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Recent Updates Widget --}}
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<livewire:admin.widgets.recent-updates />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notification Bell Component
|
||||||
|
|
||||||
|
Add to admin layout header:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- In resources/views/components/layouts/admin.blade.php header section --}}
|
||||||
|
<div class="relative">
|
||||||
|
<a href="{{ route('admin.consultations.index', ['status' => 'pending']) }}">
|
||||||
|
<flux:icon name="bell" class="w-6 h-6" />
|
||||||
|
@if($pendingCount = \App\Models\Consultation::pending()->count())
|
||||||
|
<span class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||||
|
{{ $pendingCount > 99 ? '99+' : $pendingCount }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Model Scopes
|
||||||
|
|
||||||
|
Ensure Consultation model has these scopes (from Epic 3):
|
||||||
|
|
||||||
|
```php
|
||||||
|
// app/Models/Consultation.php
|
||||||
|
public function scopePending(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('status', 'pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeApproved(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('status', 'approved');
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Flux UI Components Used
|
||||||
|
- `<flux:heading>` - Widget titles
|
||||||
|
- `<flux:badge>` - Count badges, status indicators
|
||||||
|
- `<flux:button>` - Action buttons
|
||||||
|
- `<flux:text>` - Empty state messages
|
||||||
|
- `<flux:modal>` - Block time slot modal
|
||||||
|
- `<flux:input>` - Form inputs
|
||||||
|
- `<flux:textarea>` - Reason field
|
||||||
|
- `<flux:field>` - Form field wrapper
|
||||||
|
- `<flux:label>` - Form labels
|
||||||
|
- `<flux:icon>` - 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
|
## Definition of Done
|
||||||
- [ ] All widgets display correctly
|
- [ ] All four widgets display correctly with accurate data
|
||||||
- [ ] Quick actions work
|
- [ ] Widgets auto-refresh via Livewire polling (30s interval)
|
||||||
- [ ] Real-time updates with polling
|
- [ ] Quick action buttons navigate to correct routes
|
||||||
- [ ] Tests pass
|
- [ ] 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
|
## Estimation
|
||||||
**Complexity:** Medium | **Effort:** 3-4 hours
|
**Complexity:** Medium | **Effort:** 4-5 hours
|
||||||
|
|
|
||||||
|
|
@ -8,61 +8,372 @@ As an **admin**,
|
||||||
I want **to export user data in CSV and PDF formats**,
|
I want **to export user data in CSV and PDF formats**,
|
||||||
So that **I can generate reports and maintain offline records**.
|
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
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Export Options
|
### Export Options
|
||||||
- [ ] Export all users
|
- [ ] Export all users (both individual and company clients)
|
||||||
- [ ] Export individual clients only
|
- [ ] Export individual clients only (`user_type = 'individual'`)
|
||||||
- [ ] Export company clients only
|
- [ ] Export company clients only (`user_type = 'company'`)
|
||||||
|
|
||||||
### Filters
|
### Filters
|
||||||
- [ ] Date range (created)
|
- [ ] Date range filter on `created_at` field (start date, end date)
|
||||||
- [ ] Status (active/deactivated)
|
- [ ] Status filter: active, deactivated, or all
|
||||||
|
- [ ] Filters combine with export type (e.g., "active individual clients created in 2024")
|
||||||
|
|
||||||
### CSV Export Includes
|
### CSV Export Includes
|
||||||
- [ ] Name, email, phone
|
- [ ] Name (`name` for individual, `company_name` for company)
|
||||||
- [ ] User type
|
- [ ] Email (`email`)
|
||||||
- [ ] National ID / Company registration
|
- [ ] Phone (`phone`)
|
||||||
- [ ] Status
|
- [ ] User type (`user_type`: individual/company)
|
||||||
- [ ] Created date
|
- [ ] 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
|
### PDF Export Includes
|
||||||
- [ ] Same data with professional formatting
|
- [ ] Same data fields as CSV in tabular format
|
||||||
- [ ] Libra branding header
|
- [ ] Libra branding header (logo, firm name)
|
||||||
- [ ] Generation timestamp
|
- [ ] Generation timestamp in footer
|
||||||
|
- [ ] Page numbers if multiple pages
|
||||||
|
- [ ] Professional formatting with brand colors (Navy #0A1F44, Gold #D4AF37)
|
||||||
|
|
||||||
### Bilingual
|
### Bilingual Support
|
||||||
- [ ] Column headers based on admin language
|
- [ ] 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
|
```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
|
public function exportCsv(): StreamedResponse
|
||||||
{
|
{
|
||||||
return response()->streamDownload(function () {
|
$users = $this->getFilteredUsers();
|
||||||
$csv = Writer::createFromString();
|
$locale = auth()->user()->preferred_language ?? 'ar';
|
||||||
$csv->insertOne([__('export.name'), __('export.email'), ...]);
|
|
||||||
|
|
||||||
User::whereIn('user_type', ['individual', 'company'])
|
return response()->streamDownload(function () use ($users, $locale) {
|
||||||
->cursor()
|
$csv = Writer::createFromString();
|
||||||
->each(fn($user) => $csv->insertOne([
|
|
||||||
$user->name,
|
// 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->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();
|
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
|
## Definition of Done
|
||||||
- [ ] CSV export works with all filters
|
- [ ] Livewire component created with filter form and export buttons
|
||||||
- [ ] PDF export works with branding
|
- [ ] CSV export works with all filter combinations
|
||||||
- [ ] Large datasets handled efficiently
|
- [ ] PDF export renders with Libra branding header and footer
|
||||||
- [ ] Tests pass
|
- [ ] 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
|
## 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
|
||||||
|
|
|
||||||
|
|
@ -3,57 +3,297 @@
|
||||||
## Epic Reference
|
## Epic Reference
|
||||||
**Epic 6:** Admin Dashboard
|
**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
|
## User Story
|
||||||
As an **admin**,
|
As an **admin**,
|
||||||
I want **to export consultation/booking data**,
|
I want **to export consultation/booking data in CSV and PDF formats**,
|
||||||
So that **I can analyze and report on consultation history**.
|
So that **I can generate reports for accounting, analyze consultation patterns, and maintain offline records of client interactions**.
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Export Options
|
### Export Options
|
||||||
- [ ] Export all consultations
|
- [ ] Export all consultations
|
||||||
|
- [ ] Export filtered subset based on criteria below
|
||||||
|
|
||||||
### Filters
|
### Filters
|
||||||
- [ ] Date range
|
- [ ] Date range (scheduled_date between start/end)
|
||||||
- [ ] Consultation type (free/paid)
|
- [ ] Consultation type (free/paid)
|
||||||
- [ ] Status (approved/completed/no-show/cancelled)
|
- [ ] Status (pending/approved/completed/no-show/cancelled)
|
||||||
- [ ] Payment status
|
- [ ] Payment status (pending/received)
|
||||||
|
|
||||||
### Export Includes
|
### Export Includes
|
||||||
- [ ] Client name
|
- [ ] Client name (from user relationship)
|
||||||
- [ ] Date and time
|
- [ ] Date and time (scheduled_date, scheduled_time)
|
||||||
- [ ] Consultation type
|
- [ ] Consultation type (free/paid)
|
||||||
- [ ] Status
|
- [ ] Status
|
||||||
- [ ] Payment status
|
- [ ] Payment status
|
||||||
- [ ] Problem summary
|
- [ ] Problem summary (truncated in PDF if > 500 chars)
|
||||||
|
|
||||||
### Formats
|
### Formats
|
||||||
- [ ] CSV format
|
- [ ] CSV format with streaming download
|
||||||
- [ ] PDF format with professional layout and branding
|
- [ ] 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
|
## 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
|
```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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use League\Csv\Writer;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class ConsultationExportController extends Controller
|
||||||
{
|
{
|
||||||
$consultations = Consultation::query()
|
public function csv(Request $request): StreamedResponse
|
||||||
|
{
|
||||||
|
$consultations = $this->getFilteredConsultations($request);
|
||||||
|
|
||||||
|
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'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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')
|
->with('user')
|
||||||
->when($request->date_from, fn($q) => $q->where('scheduled_date', '>=', $request->date_from))
|
->when($request->date_from, fn ($q) => $q->where('scheduled_date', '>=', $request->date_from))
|
||||||
->when($request->status, fn($q) => $q->where('status', $request->status))
|
->when($request->date_to, fn ($q) => $q->where('scheduled_date', '<=', $request->date_to))
|
||||||
->get();
|
->when($request->type, fn ($q) => $q->where('consultation_type', $request->type))
|
||||||
|
->when($request->status, fn ($q) => $q->where('status', $request->status))
|
||||||
$pdf = Pdf::loadView('exports.consultations', compact('consultations'));
|
->when($request->payment_status, fn ($q) => $q->where('payment_status', $request->payment_status))
|
||||||
|
->orderBy('scheduled_date', 'desc');
|
||||||
return $pdf->download('consultations-export.pdf');
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
test('admin can export consultations as csv', function () {
|
||||||
|
$admin = User::factory()->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
|
## Definition of Done
|
||||||
- [ ] All filters work correctly
|
- [ ] All filters work correctly (date range, type, status, payment)
|
||||||
- [ ] CSV export accurate
|
- [ ] CSV export streams correctly with proper headers
|
||||||
- [ ] PDF professionally formatted
|
- [ ] PDF export generates with Libra branding (logo, colors)
|
||||||
- [ ] Large summaries handled in PDF
|
- [ ] Large problem summaries truncated properly in PDF
|
||||||
- [ ] Tests pass
|
- [ ] Bilingual column headers work based on admin language
|
||||||
|
- [ ] Empty results handled gracefully
|
||||||
|
- [ ] All feature tests pass
|
||||||
|
- [ ] Code formatted with Pint
|
||||||
|
|
||||||
## Estimation
|
## Estimation
|
||||||
**Complexity:** Medium | **Effort:** 3 hours
|
**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
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,35 @@ As an **admin**,
|
||||||
I want **to export timeline and case data**,
|
I want **to export timeline and case data**,
|
||||||
So that **I can maintain records and generate case reports**.
|
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
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Export Options
|
### Export Options
|
||||||
- [ ] Export all timelines (across all clients)
|
- [ ] Export all timelines (across all clients)
|
||||||
- [ ] Export timelines for specific client
|
- [ ] Export timelines for specific client (client selector/search)
|
||||||
|
|
||||||
### Filters
|
### Filters
|
||||||
- [ ] Status (active/archived)
|
- [ ] Status filter (active/archived/all)
|
||||||
- [ ] Date range
|
- [ ] Date range filter (created_at)
|
||||||
|
- [ ] Client filter (search by name/email)
|
||||||
|
|
||||||
### Export Includes
|
### Export Includes
|
||||||
- [ ] Case name and reference
|
- [ ] Case name and reference
|
||||||
|
|
@ -27,39 +47,363 @@ So that **I can maintain records and generate case reports**.
|
||||||
- [ ] Last update date
|
- [ ] Last update date
|
||||||
|
|
||||||
### Formats
|
### Formats
|
||||||
- [ ] CSV format
|
- [ ] CSV format with bilingual headers
|
||||||
- [ ] PDF format
|
- [ ] PDF format with Libra branding
|
||||||
|
|
||||||
### Optional
|
### Optional Features
|
||||||
- [ ] Include update content or summary only toggle
|
- [ ] 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
|
## Technical Notes
|
||||||
|
|
||||||
```php
|
### File Structure
|
||||||
public function exportTimelinesPdf(Request $request)
|
```
|
||||||
{
|
Routes:
|
||||||
$timelines = Timeline::query()
|
GET /admin/exports/timelines -> admin.exports.timelines (Volt page)
|
||||||
->with(['user', 'updates'])
|
|
||||||
->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', [
|
Files to Create:
|
||||||
'timelines' => $timelines,
|
resources/views/livewire/pages/admin/exports/timelines.blade.php (Volt component)
|
||||||
'includeUpdates' => $request->boolean('include_updates'),
|
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
|
||||||
|
// 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
|
||||||
|
{
|
||||||
|
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'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $pdf->download('timelines-export.pdf');
|
$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')
|
||||||
|
->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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Timeline;
|
||||||
|
use App\Models\User;
|
||||||
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
|
use League\Csv\Writer;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public string $clientSearch = '';
|
||||||
|
public ?int $clientId = null;
|
||||||
|
public string $status = 'all';
|
||||||
|
public ?string $dateFrom = null;
|
||||||
|
public ?string $dateTo = null;
|
||||||
|
public bool $includeUpdates = false;
|
||||||
|
|
||||||
|
public function getClientsProperty()
|
||||||
|
{
|
||||||
|
if (strlen($this->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 */ }
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{-- Filter controls and export buttons using Flux UI --}}
|
||||||
|
{{-- Follow layout pattern from Story 6.4/6.5 --}}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### PDF Template Structure
|
||||||
|
```blade
|
||||||
|
{{-- resources/views/exports/timelines-pdf.blade.php --}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
/* Libra branding: Navy (#0A1F44) and Gold (#D4AF37) */
|
||||||
|
body { font-family: DejaVu Sans, sans-serif; }
|
||||||
|
.header { background: #0A1F44; color: #D4AF37; padding: 20px; }
|
||||||
|
.logo { /* Libra logo styling */ }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th { background: #0A1F44; color: white; padding: 8px; }
|
||||||
|
td { border: 1px solid #ddd; padding: 8px; }
|
||||||
|
.update-entry { background: #f9f9f9; margin: 5px 0; padding: 10px; }
|
||||||
|
.footer { text-align: center; font-size: 10px; color: #666; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<img src="{{ public_path('images/logo.png') }}" class="logo">
|
||||||
|
<h1>{{ __('export.timeline_report') }}</h1>
|
||||||
|
<p>{{ __('export.generated_at') }}: {{ $generatedAt->format('Y-m-d H:i') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($filters['client'] || $filters['status'] !== 'all' || $filters['dateFrom'])
|
||||||
|
<div class="filters">
|
||||||
|
<h3>{{ __('export.applied_filters') }}</h3>
|
||||||
|
<!-- Display active filters -->
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ __('export.case_name') }}</th>
|
||||||
|
<th>{{ __('export.case_reference') }}</th>
|
||||||
|
<th>{{ __('export.client_name') }}</th>
|
||||||
|
<th>{{ __('export.status') }}</th>
|
||||||
|
<th>{{ __('export.created_date') }}</th>
|
||||||
|
<th>{{ __('export.updates_count') }}</th>
|
||||||
|
<th>{{ __('export.last_update') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($timelines as $timeline)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $timeline->case_name }}</td>
|
||||||
|
<td>{{ $timeline->case_reference ?? '-' }}</td>
|
||||||
|
<td>{{ $timeline->user->name }}</td>
|
||||||
|
<td>{{ __('status.' . $timeline->status) }}</td>
|
||||||
|
<td>{{ $timeline->created_at->format('Y-m-d') }}</td>
|
||||||
|
<td>{{ $timeline->updates_count }}</td>
|
||||||
|
<td>{{ $timeline->updates_max_created_at ? Carbon::parse($timeline->updates_max_created_at)->format('Y-m-d H:i') : '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
@if($includeUpdates && $timeline->updates->count())
|
||||||
|
<tr>
|
||||||
|
<td colspan="7">
|
||||||
|
<strong>{{ __('export.updates') }}:</strong>
|
||||||
|
@foreach($timeline->updates as $update)
|
||||||
|
<div class="update-entry">
|
||||||
|
<small>{{ $update->created_at->format('Y-m-d H:i') }}</small>
|
||||||
|
<p>{{ Str::limit($update->update_text, 500) }}</p>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="7">{{ __('export.no_records') }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>{{ __('export.libra_footer') }} | {{ config('app.url') }}</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
## Definition of Done
|
||||||
- [ ] All filters work
|
|
||||||
- [ ] CSV export works
|
- [ ] Volt component created at `resources/views/livewire/pages/admin/exports/timelines.blade.php`
|
||||||
- [ ] PDF with branding works
|
- [ ] PDF template created at `resources/views/exports/timelines-pdf.blade.php`
|
||||||
- [ ] Optional update content toggle works
|
- [ ] Route registered in admin routes
|
||||||
- [ ] Tests pass
|
- [ ] 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
|
## Estimation
|
||||||
**Complexity:** Medium | **Effort:** 3 hours
|
**Complexity:** Medium | **Effort:** 3 hours
|
||||||
|
|
|
||||||
|
|
@ -5,66 +5,588 @@
|
||||||
|
|
||||||
## User Story
|
## User Story
|
||||||
As an **admin**,
|
As an **admin**,
|
||||||
I want **to generate comprehensive monthly PDF reports**,
|
I want **to generate comprehensive monthly PDF reports from the admin dashboard**,
|
||||||
So that **I have professional summaries of business performance**.
|
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
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Generation
|
### UI Location & Generation
|
||||||
- [ ] "Generate Monthly Report" button
|
- [ ] "Generate Monthly Report" button in admin dashboard (below metrics cards or in a Reports section)
|
||||||
- [ ] Select month/year
|
- [ ] Month/year selector dropdown (default: previous month)
|
||||||
|
- [ ] Selectable range: last 12 months only (no future months)
|
||||||
|
|
||||||
### PDF Report Includes
|
### PDF Report Sections
|
||||||
- [ ] Overview of key metrics
|
|
||||||
- [ ] Charts (rendered as images)
|
|
||||||
- [ ] User statistics
|
|
||||||
- [ ] Consultation statistics
|
|
||||||
- [ ] Timeline statistics
|
|
||||||
- [ ] Post statistics
|
|
||||||
|
|
||||||
### Design
|
#### 1. Cover Page
|
||||||
- [ ] Professional layout with branding
|
- [ ] Libra logo and branding
|
||||||
- [ ] Table of contents
|
- [ ] Report title: "Monthly Statistics Report"
|
||||||
- [ ] Printable format
|
- [ ] Period: Month and Year (e.g., "December 2025")
|
||||||
- [ ] Bilingual based on admin preference
|
- [ ] Generated date and time
|
||||||
|
|
||||||
### UX
|
#### 2. Table of Contents (Visual List)
|
||||||
- [ ] Loading indicator during generation
|
- [ ] List of sections with page numbers
|
||||||
- [ ] Download on completion
|
- [ ] 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
|
```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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\MonthlyReportService;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public int $selectedYear;
|
||||||
|
public int $selectedMonth;
|
||||||
|
public bool $generating = false;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
// Default to previous month
|
||||||
|
$previousMonth = now()->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">{{ __('report.monthly_report') }}</flux:heading>
|
||||||
|
|
||||||
|
<div class="mt-6 flex items-end gap-4">
|
||||||
|
<flux:select wire:model="selectedMonth" label="{{ __('report.select_period') }}">
|
||||||
|
@foreach($this->availableMonths as $option)
|
||||||
|
<flux:option value="{{ $option['month'] }}" data-year="{{ $option['year'] }}">
|
||||||
|
{{ $option['label'] }}
|
||||||
|
</flux:option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:button
|
||||||
|
wire:click="generate"
|
||||||
|
wire:loading.attr="disabled"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
<span wire:loading.remove>{{ __('report.generate') }}</span>
|
||||||
|
<span wire:loading>{{ __('report.generating') }}</span>
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### MonthlyReportService Structure
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Timeline;
|
||||||
|
use App\Models\TimelineUpdate;
|
||||||
|
use App\Models\Post;
|
||||||
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class MonthlyReportService
|
||||||
{
|
{
|
||||||
|
public function generate(int $year, int $month): \Symfony\Component\HttpFoundation\StreamedResponse
|
||||||
|
{
|
||||||
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
|
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
|
||||||
$endDate = $startDate->copy()->endOfMonth();
|
$endDate = $startDate->copy()->endOfMonth();
|
||||||
|
$locale = Auth::user()->preferred_language ?? 'en';
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'period' => $startDate->format('F Y'),
|
'period' => $startDate->translatedFormat('F Y'),
|
||||||
'userStats' => $this->getUserStatsForPeriod($startDate, $endDate),
|
'generatedAt' => now()->translatedFormat('d M Y H:i'),
|
||||||
'consultationStats' => $this->getConsultationStatsForPeriod($startDate, $endDate),
|
'locale' => $locale,
|
||||||
'timelineStats' => $this->getTimelineStatsForPeriod($startDate, $endDate),
|
'userStats' => $this->getUserStats($startDate, $endDate),
|
||||||
'postStats' => $this->getPostStatsForPeriod($startDate, $endDate),
|
'consultationStats' => $this->getConsultationStats($startDate, $endDate),
|
||||||
|
'timelineStats' => $this->getTimelineStats($startDate, $endDate),
|
||||||
|
'postStats' => $this->getPostStats($startDate, $endDate),
|
||||||
'charts' => $this->renderChartsAsImages($startDate, $endDate),
|
'charts' => $this->renderChartsAsImages($startDate, $endDate),
|
||||||
|
'previousMonth' => $this->getPreviousMonthComparison($startDate),
|
||||||
];
|
];
|
||||||
|
|
||||||
$pdf = Pdf::loadView('exports.monthly-report', $data)
|
$pdf = Pdf::loadView('exports.monthly-report', $data)
|
||||||
->setPaper('a4', 'portrait');
|
->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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Consultation;
|
||||||
|
use App\Models\Timeline;
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Services\MonthlyReportService;
|
||||||
|
|
||||||
|
test('admin can access monthly report page', function () {
|
||||||
|
$admin = User::factory()->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
|
## Definition of Done
|
||||||
- [ ] Month/year selector works
|
- [ ] Monthly report page accessible at `/admin/reports/monthly`
|
||||||
- [ ] All statistics accurate
|
- [ ] Month/year selector works (last 12 months only)
|
||||||
- [ ] Charts rendered in PDF
|
- [ ] PDF generates with all required sections
|
||||||
- [ ] Professional branding
|
- [ ] User statistics accurate for selected month
|
||||||
- [ ] Bilingual support
|
- [ ] Consultation statistics accurate with correct no-show rate
|
||||||
- [ ] Tests pass
|
- [ ] 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
|
## Estimation
|
||||||
**Complexity:** High | **Effort:** 5-6 hours
|
**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
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,15 @@ As an **admin**,
|
||||||
I want **to configure system-wide settings**,
|
I want **to configure system-wide settings**,
|
||||||
So that **I can customize the platform to my needs**.
|
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
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Profile Settings
|
### Profile Settings
|
||||||
|
|
@ -17,20 +26,85 @@ So that **I can customize the platform to my needs**.
|
||||||
- [ ] Preferred language
|
- [ ] Preferred language
|
||||||
|
|
||||||
### Email Settings
|
### Email Settings
|
||||||
- [ ] View current sender email
|
- [ ] Display current sender email from `config('mail.from.address')`
|
||||||
- [ ] Test email functionality
|
- [ ] 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)
|
### Notification Preferences (Future Enhancement - Not in Scope)
|
||||||
- [ ] Toggle admin notifications
|
> **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.
|
||||||
- [ ] Summary email frequency
|
- Toggle admin notifications (future)
|
||||||
|
- Summary email frequency (future)
|
||||||
|
|
||||||
### Behavior
|
### Behavior
|
||||||
- [ ] Settings saved and applied immediately
|
- [ ] Settings saved and applied immediately
|
||||||
- [ ] Validation for all inputs
|
- [ ] Validation for all inputs
|
||||||
|
- [ ] Flash messages for success/error states
|
||||||
|
- [ ] Password fields cleared after successful update
|
||||||
|
|
||||||
## Technical Notes
|
## Technical Notes
|
||||||
|
|
||||||
|
### Database Migration Required
|
||||||
|
Add `preferred_language` column to users table:
|
||||||
|
|
||||||
```php
|
```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
|
||||||
|
<?php
|
||||||
|
// resources/views/livewire/admin/settings.blade.php
|
||||||
|
|
||||||
|
use App\Mail\TestEmail;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
new class extends Component {
|
new class extends Component {
|
||||||
public string $name = '';
|
public string $name = '';
|
||||||
public string $email = '';
|
public string $email = '';
|
||||||
|
|
@ -44,7 +118,7 @@ new class extends Component {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$this->name = $user->name;
|
$this->name = $user->name;
|
||||||
$this->email = $user->email;
|
$this->email = $user->email;
|
||||||
$this->preferred_language = $user->preferred_language;
|
$this->preferred_language = $user->preferred_language ?? 'ar';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateProfile(): void
|
public function updateProfile(): void
|
||||||
|
|
@ -81,19 +155,176 @@ new class extends Component {
|
||||||
|
|
||||||
public function sendTestEmail(): void
|
public function sendTestEmail(): void
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
Mail::to(auth()->user())->send(new TestEmail());
|
Mail::to(auth()->user())->send(new TestEmail());
|
||||||
session()->flash('success', __('messages.test_email_sent'));
|
session()->flash('success', __('messages.test_email_sent'));
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
session()->flash('error', __('messages.test_email_failed'));
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{-- UI Template Here --}}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
## Definition of Done
|
||||||
- [ ] Profile updates work
|
- [ ] Migration created and run for `preferred_language` column
|
||||||
- [ ] Password change works
|
- [ ] User model updated with `preferred_language` in fillable
|
||||||
- [ ] Language preference persists
|
- [ ] Settings Volt component created at `resources/views/livewire/admin/settings.blade.php`
|
||||||
- [ ] Test email sends
|
- [ ] TestEmail mailable created at `app/Mail/TestEmail.php`
|
||||||
- [ ] Validation complete
|
- [ ] Test email template created at `resources/views/emails/test.blade.php`
|
||||||
- [ ] Tests pass
|
- [ ] 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
|
## Estimation
|
||||||
**Complexity:** Medium | **Effort:** 3-4 hours
|
**Complexity:** Medium | **Effort:** 3-4 hours
|
||||||
|
|
|
||||||
|
|
@ -8,39 +8,73 @@ As an **admin**,
|
||||||
I want **to edit Terms of Service and Privacy Policy pages**,
|
I want **to edit Terms of Service and Privacy Policy pages**,
|
||||||
So that **I can maintain legal compliance and update policies**.
|
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
|
## Acceptance Criteria
|
||||||
|
|
||||||
### Pages to Edit
|
### Pages to Edit
|
||||||
- [ ] Terms of Service
|
- [ ] Terms of Service (`/page/terms`)
|
||||||
- [ ] Privacy Policy
|
- [ ] Privacy Policy (`/page/privacy`)
|
||||||
|
|
||||||
### Editor Features
|
### Editor Features
|
||||||
- [ ] Rich text editor
|
- [ ] Rich text editor using Quill.js (lightweight, RTL-friendly)
|
||||||
- [ ] Bilingual content (Arabic/English)
|
- [ ] Bilingual content with Arabic/English tabs in editor UI
|
||||||
- [ ] Save and publish
|
- [ ] Save and publish button updates database immediately
|
||||||
- [ ] Preview before publishing
|
- [ ] 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
|
### Public Display
|
||||||
- [ ] Pages accessible from footer (public)
|
- [ ] Pages accessible from footer links (no auth required)
|
||||||
- [ ] Last updated timestamp displayed
|
- [ ] 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
|
## 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
|
```php
|
||||||
// Migration
|
// Migration: create_pages_table.php
|
||||||
Schema::create('pages', function (Blueprint $table) {
|
Schema::create('pages', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->string('slug')->unique();
|
$table->string('slug')->unique(); // 'terms', 'privacy'
|
||||||
$table->string('title_ar');
|
$table->string('title_ar');
|
||||||
$table->string('title_en');
|
$table->string('title_en');
|
||||||
$table->text('content_ar');
|
$table->longText('content_ar')->nullable();
|
||||||
$table->text('content_en');
|
$table->longText('content_en')->nullable();
|
||||||
$table->timestamps();
|
$table->timestamps(); // updated_at used for "Last updated"
|
||||||
});
|
});
|
||||||
|
```
|
||||||
|
|
||||||
// Seeder
|
### Seeder
|
||||||
|
|
||||||
|
```php
|
||||||
|
// PageSeeder.php
|
||||||
Page::create([
|
Page::create([
|
||||||
'slug' => 'terms',
|
'slug' => 'terms',
|
||||||
'title_ar' => 'شروط الخدمة',
|
'title_ar' => 'شروط الخدمة',
|
||||||
|
|
@ -58,22 +92,221 @@ Page::create([
|
||||||
]);
|
]);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Public Route
|
### Routes
|
||||||
|
|
||||||
```php
|
```php
|
||||||
Route::get('/page/{slug}', function (string $slug) {
|
// Public route (web.php)
|
||||||
$page = Page::where('slug', $slug)->firstOrFail();
|
Route::get('/page/{slug}', [PageController::class, 'show'])
|
||||||
return view('pages.show', compact('page'));
|
->name('page.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
|
||||||
|
<?php
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
use App\Models\Page;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public Page $page;
|
||||||
|
public string $activeTab = 'ar';
|
||||||
|
public string $content_ar = '';
|
||||||
|
public string $content_en = '';
|
||||||
|
public bool $showPreview = false;
|
||||||
|
|
||||||
|
public function mount(string $slug): void
|
||||||
|
{
|
||||||
|
$this->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', '<p>شروط الخدمة الجديدة</p>')
|
||||||
|
->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', '<p>New terms content</p>')
|
||||||
|
->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', '<p>Updated content</p>')
|
||||||
|
->call('save');
|
||||||
|
|
||||||
|
expect($page->fresh()->updated_at)->toBeGreaterThan($originalTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('public can view terms page', function () {
|
||||||
|
Page::where('slug', 'terms')->update(['content_en' => '<p>Our terms</p>']);
|
||||||
|
|
||||||
|
$this->get('/page/terms')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Our terms');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('public can view privacy page', function () {
|
||||||
|
Page::where('slug', 'privacy')->update(['content_en' => '<p>Our privacy policy</p>']);
|
||||||
|
|
||||||
|
$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', '<script>alert("xss")</script><p>Safe content</p>')
|
||||||
|
->call('save');
|
||||||
|
|
||||||
|
$page = Page::where('slug', 'terms')->first();
|
||||||
|
expect($page->content_en)->not->toContain('<script>');
|
||||||
|
expect($page->content_en)->toContain('Safe content');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guest cannot access admin pages editor', function () {
|
||||||
|
$this->get(route('admin.pages.index'))
|
||||||
|
->assertRedirect(route('login'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client cannot access admin pages editor', function () {
|
||||||
|
$client = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($client)
|
||||||
|
->get(route('admin.pages.index'))
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
- [ ] Can edit Terms of Service
|
- [ ] Page model and migration created
|
||||||
- [ ] Can edit Privacy Policy
|
- [ ] Seeder creates terms and privacy pages
|
||||||
- [ ] Bilingual content works
|
- [ ] Admin can access legal pages list from settings/sidebar
|
||||||
- [ ] Preview works
|
- [ ] Admin can edit Terms of Service content (Arabic)
|
||||||
- [ ] Public pages accessible
|
- [ ] Admin can edit Terms of Service content (English)
|
||||||
- [ ] Last updated shows
|
- [ ] Admin can edit Privacy Policy content (Arabic)
|
||||||
- [ ] Tests pass
|
- [ ] Admin can edit Privacy Policy content (English)
|
||||||
|
- [ ] Language tabs switch between Arabic/English editor
|
||||||
|
- [ ] Rich text editor (Quill) works with RTL Arabic text
|
||||||
|
- [ ] Preview modal displays rendered content
|
||||||
|
- [ ] Save button updates database and shows success notification
|
||||||
|
- [ ] HTML content sanitized before save
|
||||||
|
- [ ] Public `/page/terms` route displays Terms of Service
|
||||||
|
- [ ] Public `/page/privacy` route displays Privacy Policy
|
||||||
|
- [ ] Public pages show content in user's language preference
|
||||||
|
- [ ] Last updated timestamp displayed on public pages
|
||||||
|
- [ ] Footer links to legal pages work
|
||||||
|
- [ ] All feature tests pass
|
||||||
|
- [ ] Code formatted with Pint
|
||||||
|
|
||||||
## Estimation
|
## Estimation
|
||||||
**Complexity:** Medium | **Effort:** 3-4 hours
|
**Complexity:** Medium | **Effort:** 4-5 hours
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
- Version history for legal pages
|
||||||
|
- Scheduled publishing
|
||||||
|
- Multiple admin approval workflow
|
||||||
|
- PDF export of legal pages
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue