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
|
||||
|
||||
### HTML Sanitization
|
||||
Use the `mews/purifier` package for HTML sanitization via the `clean()` helper:
|
||||
```bash
|
||||
composer require mews/purifier
|
||||
php artisan vendor:publish --provider="Mews\Purifier\PurifierServiceProvider"
|
||||
```
|
||||
|
||||
Configure `config/purifier.php` to allow only safe rich text tags:
|
||||
```php
|
||||
'default' => [
|
||||
'HTML.Allowed' => 'h2,h3,p,br,b,strong,i,em,u,ul,ol,li,a[href|title],blockquote',
|
||||
'AutoFormat.AutoParagraph' => true,
|
||||
'AutoFormat.RemoveEmpty' => true,
|
||||
],
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
```php
|
||||
Schema::create('posts', function (Blueprint $table) {
|
||||
|
|
@ -147,7 +163,8 @@ new class extends Component {
|
|||
$action = 'create';
|
||||
}
|
||||
|
||||
AdminLog::create([
|
||||
// AdminLog model exists from Epic 1 (Story 1.1) - see admin_logs table in schema
|
||||
\App\Models\AdminLog::create([
|
||||
'admin_id' => auth()->id(),
|
||||
'action_type' => $action,
|
||||
'target_type' => 'post',
|
||||
|
|
@ -181,12 +198,26 @@ new class extends Component {
|
|||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Preview opens modal with sanitized rendered content
|
||||
public bool $showPreview = false;
|
||||
|
||||
public function preview(): void
|
||||
{
|
||||
$this->showPreview = true;
|
||||
}
|
||||
|
||||
public function closePreview(): void
|
||||
{
|
||||
$this->showPreview = false;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Template with Rich Text Editor
|
||||
```blade
|
||||
<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">
|
||||
<!-- Arabic Fields -->
|
||||
<div class="space-y-4">
|
||||
|
|
@ -238,13 +269,44 @@ new class extends Component {
|
|||
<flux:button wire:click="saveDraft">
|
||||
{{ __('admin.save_draft') }}
|
||||
</flux:button>
|
||||
<flux:button wire:click="preview">
|
||||
{{ __('admin.preview') }}
|
||||
</flux:button>
|
||||
<flux:button variant="primary" wire:click="publish">
|
||||
{{ __('admin.publish') }}
|
||||
</flux:button>
|
||||
</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>
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
- [ ] Can create post with bilingual content
|
||||
|
|
@ -253,11 +315,164 @@ new class extends Component {
|
|||
- [ ] Can publish directly
|
||||
- [ ] Can edit existing posts
|
||||
- [ ] Auto-save works for drafts
|
||||
- [ ] Preview modal displays sanitized content
|
||||
- [ ] HTML properly sanitized
|
||||
- [ ] Audit log created
|
||||
- [ ] Tests pass
|
||||
- [ ] Code formatted with Pint
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### Unit Tests (Post Model)
|
||||
```php
|
||||
test('post has bilingual title accessor', function () {
|
||||
$post = Post::factory()->create(['title_ar' => 'عنوان', 'title_en' => 'Title']);
|
||||
app()->setLocale('ar');
|
||||
expect($post->title)->toBe('عنوان');
|
||||
app()->setLocale('en');
|
||||
expect($post->title)->toBe('Title');
|
||||
});
|
||||
|
||||
test('post excerpt strips HTML and limits to 150 chars', function () {
|
||||
$post = Post::factory()->create(['body_en' => '<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
|
||||
|
||||
- **Epic 1:** Database schema, admin authentication
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@ So that **I can organize, publish, and maintain content efficiently**.
|
|||
## Story Context
|
||||
|
||||
### Existing System Integration
|
||||
- **Integrates with:** posts table
|
||||
- **Integrates with:** posts table, admin_logs table
|
||||
- **Technology:** Livewire Volt with pagination
|
||||
- **Follows pattern:** Admin list pattern
|
||||
- **Touch points:** Post CRUD operations
|
||||
- **Follows pattern:** Admin list pattern (see similar components in `resources/views/livewire/admin/`)
|
||||
- **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
|
||||
|
||||
|
|
@ -39,16 +40,34 @@ So that **I can organize, publish, and maintain content efficiently**.
|
|||
|
||||
### Quality Requirements
|
||||
- [ ] 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
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\AdminLog;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
|
|
@ -154,6 +173,11 @@ new class extends Component {
|
|||
<option value="draft">{{ __('admin.draft') }}</option>
|
||||
<option value="published">{{ __('admin.published') }}</option>
|
||||
</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>
|
||||
|
||||
<!-- Posts Table -->
|
||||
|
|
@ -220,6 +244,65 @@ new class extends Component {
|
|||
</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
|
||||
|
||||
- [ ] List displays all posts
|
||||
|
|
@ -229,13 +312,15 @@ new class extends Component {
|
|||
- [ ] Quick publish/unpublish toggle works
|
||||
- [ ] Delete with confirmation works
|
||||
- [ ] Pagination works
|
||||
- [ ] Per-page selector works (10/25/50)
|
||||
- [ ] Audit log for actions
|
||||
- [ ] Tests pass
|
||||
- [ ] All test scenarios pass
|
||||
- [ ] Code formatted with Pint
|
||||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
|
|
@ -15,18 +15,20 @@ So that **I can remove outdated or incorrect content from the website**.
|
|||
- **Technology:** Livewire Volt
|
||||
- **Follows pattern:** Permanent delete pattern
|
||||
- **Touch points:** Post management dashboard
|
||||
- **Files to modify:** Post management Volt component from Story 5.2
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Delete Functionality
|
||||
- [ ] Delete button on post list and edit page
|
||||
- [ ] Confirmation dialog before deletion
|
||||
- [ ] Delete button on post list (primary location)
|
||||
- [ ] Delete button on post edit page (secondary location)
|
||||
- [ ] Confirmation modal dialog before deletion
|
||||
- [ ] Permanent deletion (no soft delete per PRD)
|
||||
- [ ] Success message after deletion
|
||||
- [ ] Redirect to post list after deletion from edit page
|
||||
|
||||
### Restrictions
|
||||
- [ ] Cannot delete while post is being viewed publicly (edge case)
|
||||
- [ ] Admin-only access
|
||||
- [ ] Admin-only access (middleware protection)
|
||||
|
||||
### Audit Trail
|
||||
- [ ] Audit log entry preserved
|
||||
|
|
@ -63,6 +65,16 @@ So that **I can remove outdated or incorrect content from the website**.
|
|||
</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
|
||||
```php
|
||||
public ?Post $postToDelete = null;
|
||||
|
|
@ -102,26 +114,57 @@ public function confirmDelete(): void
|
|||
|
||||
### Testing
|
||||
```php
|
||||
use App\Models\Post;
|
||||
use App\Models\AdminLog;
|
||||
use App\Models\User;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
it('permanently deletes post', function () {
|
||||
$admin = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->delete(route('admin.posts.destroy', $post));
|
||||
Volt::test('admin.posts.index')
|
||||
->actingAs($admin)
|
||||
->call('delete', $post->id)
|
||||
->call('confirmDelete');
|
||||
|
||||
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();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->delete(route('admin.posts.destroy', $post));
|
||||
Volt::test('admin.posts.index')
|
||||
->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('action_type', 'delete')
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
->first();
|
||||
|
||||
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
|
||||
- **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
|
||||
|
||||
### 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
|
||||
|
||||
- [ ] Posts listing page works
|
||||
|
|
@ -189,8 +271,8 @@ new class extends Component {
|
|||
|
||||
## Dependencies
|
||||
|
||||
- **Story 5.1:** Post creation
|
||||
- **Epic 1:** Base UI, navigation
|
||||
- **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, bilingual infrastructure
|
||||
|
||||
## Estimation
|
||||
|
||||
|
|
|
|||
|
|
@ -8,39 +8,86 @@ As an **admin**,
|
|||
I want **to see real-time metrics and key statistics at a glance**,
|
||||
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
|
||||
|
||||
### User Metrics Card
|
||||
- [ ] Total active clients
|
||||
- [ ] Individual vs company breakdown
|
||||
- [ ] Total active clients (individual + company with status = active)
|
||||
- [ ] Individual vs company breakdown (count each type)
|
||||
- [ ] Deactivated clients count
|
||||
- [ ] New clients this month
|
||||
- [ ] New clients this month (created_at in current month)
|
||||
|
||||
### Booking Metrics Card
|
||||
- [ ] Pending requests count (highlighted)
|
||||
- [ ] Today's consultations
|
||||
- [ ] Pending requests count (highlighted with warning color)
|
||||
- [ ] Today's consultations (scheduled for today, approved status)
|
||||
- [ ] This week's consultations
|
||||
- [ ] This month's consultations
|
||||
- [ ] Free vs paid breakdown
|
||||
- [ ] No-show rate percentage
|
||||
- [ ] Free vs paid breakdown (consultation_type field)
|
||||
- [ ] No-show rate percentage (no-show / total completed * 100)
|
||||
|
||||
### Timeline Metrics Card
|
||||
- [ ] Active case timelines
|
||||
- [ ] Archived timelines
|
||||
- [ ] Updates added this week
|
||||
- [ ] Active case timelines (status = active)
|
||||
- [ ] Archived timelines (status = archived)
|
||||
- [ ] Updates added this week (timeline_updates created in last 7 days)
|
||||
|
||||
### Posts Metrics Card
|
||||
- [ ] Total published posts
|
||||
- [ ] Total published posts (status = published)
|
||||
- [ ] Posts published this month
|
||||
|
||||
### Design
|
||||
- [ ] Clean card-based layout
|
||||
- [ ] Color-coded status indicators
|
||||
- [ ] Responsive grid
|
||||
- [ ] Clean card-based layout using Flux UI components
|
||||
- [ ] Color-coded status indicators (gold for highlights, success green, warning colors)
|
||||
- [ ] 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
|
||||
|
||||
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 {
|
||||
public function with(): array
|
||||
{
|
||||
|
|
@ -55,21 +102,166 @@ new class extends Component {
|
|||
private function getUserMetrics(): array
|
||||
{
|
||||
return Cache::remember('admin.metrics.users', 300, fn() => [
|
||||
'total_active' => User::where('status', 'active')->whereIn('user_type', ['individual', 'company'])->count(),
|
||||
'individual' => User::where('user_type', 'individual')->where('status', 'active')->count(),
|
||||
'company' => User::where('user_type', 'company')->where('status', 'active')->count(),
|
||||
'total_active' => User::where('status', 'active')
|
||||
->whereIn('user_type', ['individual', 'company'])->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(),
|
||||
'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
|
||||
- [ ] All metric cards display correctly
|
||||
- [ ] Data is accurate and cached
|
||||
- [ ] Responsive layout
|
||||
- [ ] Tests pass
|
||||
- [ ] All metric cards display correctly with accurate data
|
||||
- [ ] Data is cached with 5-minute TTL
|
||||
- [ ] Empty states handled gracefully (show 0, no errors)
|
||||
- [ ] 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
|
||||
**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**,
|
||||
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
|
||||
|
||||
### Display
|
||||
- [ ] Action type (create, update, delete)
|
||||
- [ ] Target (user, consultation, timeline, etc.)
|
||||
- [ ] Old and new values (for updates)
|
||||
- [ ] Timestamp
|
||||
- [ ] IP address
|
||||
### Display Requirements
|
||||
- [ ] Paginated table of audit log entries (25 per page)
|
||||
- [ ] Each row displays:
|
||||
- Timestamp (formatted per locale: d/m/Y H:i for AR, m/d/Y H:i for EN)
|
||||
- Admin name (or "System" for automated actions)
|
||||
- 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
|
||||
- [ ] Filter by action type
|
||||
- [ ] Filter by target type
|
||||
- [ ] Filter by date range
|
||||
- [ ] Filter by action type (dropdown with available actions)
|
||||
- [ ] Filter by target type (dropdown with available targets)
|
||||
- [ ] Filter by date range (date pickers for from/to)
|
||||
- [ ] Filters apply without page reload (Livewire)
|
||||
- [ ] Reset filters button
|
||||
|
||||
### Search
|
||||
- [ ] Search by target name/ID
|
||||
- [ ] Search by target ID (exact match)
|
||||
- [ ] Search updates results in real-time with debounce
|
||||
|
||||
### Features
|
||||
- [ ] Pagination
|
||||
- [ ] Export audit log (CSV)
|
||||
### Detail Modal
|
||||
- [ ] Modal displays full log entry details
|
||||
- [ ] 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
|
||||
|
||||
### 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
|
||||
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 {
|
||||
use WithPagination;
|
||||
|
||||
|
|
@ -40,59 +139,348 @@ new class extends Component {
|
|||
public string $dateFrom = '';
|
||||
public string $dateTo = '';
|
||||
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
|
||||
{
|
||||
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')
|
||||
->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->dateFrom, fn($q) => $q->whereDate('created_at', '>=', $this->dateFrom))
|
||||
->when($this->dateTo, fn($q) => $q->whereDate('created_at', '<=', $this->dateTo))
|
||||
->when($this->search, fn($q) => $q->where('target_id', $this->search))
|
||||
->latest()
|
||||
->paginate(25),
|
||||
'actionTypes' => AdminLog::distinct()->pluck('action_type'),
|
||||
'targetTypes' => AdminLog::distinct()->pluck('target_type'),
|
||||
];
|
||||
->latest();
|
||||
}
|
||||
}; ?>
|
||||
|
||||
public function exportCsv()
|
||||
{
|
||||
// Export filtered logs to CSV
|
||||
}
|
||||
};
|
||||
<div>
|
||||
<!-- Filters Section -->
|
||||
<!-- Table Section -->
|
||||
<!-- Pagination -->
|
||||
<!-- Detail Modal -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Template
|
||||
### Template Structure
|
||||
```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>
|
||||
<td>{{ $log->created_at->format('d/m/Y H:i') }}</td>
|
||||
<td>{{ $log->admin?->name ?? __('admin.system') }}</td>
|
||||
<th>{{ __('audit.timestamp') }}</th>
|
||||
<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>
|
||||
<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>{{ $log->target_type }} #{{ $log->target_id }}</td>
|
||||
<td>{{ __("audit.target_{$log->target_type}") }} #{{ $log->target_id }}</td>
|
||||
<td>{{ $log->ip_address }}</td>
|
||||
<td>
|
||||
<flux:button size="sm" wire:click="showDetails({{ $log->id }})">
|
||||
{{ __('admin.details') }}
|
||||
{{ __('audit.details') }}
|
||||
</flux:button>
|
||||
</td>
|
||||
</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
|
||||
- [ ] Logs display correctly
|
||||
- [ ] All filters work
|
||||
- [ ] Search works
|
||||
- [ ] Pagination works
|
||||
- [ ] CSV export works
|
||||
- [ ] Old/new values viewable
|
||||
- [ ] Tests pass
|
||||
- [ ] Audit logs page accessible at `/admin/audit-logs`
|
||||
- [ ] Logs display with all required columns
|
||||
- [ ] All filters work correctly (action, target, date range)
|
||||
- [ ] Search by target ID works
|
||||
- [ ] Pagination displays 25 items per page
|
||||
- [ ] CSV export downloads with filtered data
|
||||
- [ ] 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
|
||||
**Complexity:** Medium | **Effort:** 3-4 hours
|
||||
|
|
|
|||
|
|
@ -6,44 +6,563 @@
|
|||
## User Story
|
||||
As an **admin**,
|
||||
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
|
||||
|
||||
### Charts Required
|
||||
- [ ] Monthly Trends (Line chart): New clients and consultations per month
|
||||
- [ ] Consultation Breakdown (Pie/Donut): Free vs paid ratio
|
||||
- [ ] No-show Rate (Line chart): Monthly no-show trend
|
||||
### Monthly Trends Chart (Line Chart)
|
||||
- [ ] Display new clients per month (from `users.created_at`)
|
||||
- [ ] Display consultations per month (from `consultations.scheduled_date`)
|
||||
- [ ] 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
|
||||
- [ ] Date range selector (6 months, 12 months, custom)
|
||||
- [ ] Chart tooltips with exact values
|
||||
- [ ] Responsive charts
|
||||
- [ ] Bilingual labels
|
||||
### Consultation Breakdown Chart (Pie/Donut)
|
||||
- [ ] Show free vs paid consultation ratio
|
||||
- [ ] Display percentage labels on segments
|
||||
- [ ] Legend showing "Free" and "Paid" with counts
|
||||
|
||||
## 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
|
||||
public function getChartData(string $period = '6m'): array
|
||||
{
|
||||
$months = $period === '6m' ? 6 : 12;
|
||||
<?php
|
||||
|
||||
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 [
|
||||
'labels' => collect(range($months - 1, 0))->map(fn($i) => now()->subMonths($i)->format('M Y'))->toArray(),
|
||||
'clients' => $this->getMonthlyClients($months),
|
||||
'consultations' => $this->getMonthlyConsultations($months),
|
||||
'labels' => $service->getMonthLabels($startDate, $months),
|
||||
'newClients' => $service->getMonthlyNewClients($startDate, $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
|
||||
- [ ] All charts render correctly
|
||||
- [ ] Date range selector works
|
||||
- [ ] Tooltips functional
|
||||
- [ ] Mobile responsive
|
||||
- [ ] Tests pass
|
||||
- [ ] All three charts render correctly (trends, breakdown, no-show)
|
||||
- [ ] Date range selector switches between 6/12 months
|
||||
- [ ] Tooltips show exact values on all charts
|
||||
- [ ] Charts are responsive on mobile, tablet, desktop
|
||||
- [ ] Bilingual labels work (Arabic/English)
|
||||
- [ ] Empty state handled gracefully
|
||||
- [ ] No-show rate handles zero consultations (no division error)
|
||||
- [ ] AnalyticsService unit tests pass
|
||||
- [ ] Feature tests for chart data pass
|
||||
- [ ] Code formatted with Pint
|
||||
- [ ] Admin-only access enforced
|
||||
|
||||
## Estimation
|
||||
**Complexity:** Medium-High | **Effort:** 4-5 hours
|
||||
|
||||
## Out of Scope
|
||||
- Custom date range picker with calendar UI (can use simple month selects)
|
||||
- Exporting charts as images
|
||||
- Real-time chart updates (polling) - charts update on page load/range change only
|
||||
- Animated chart transitions
|
||||
|
|
|
|||
|
|
@ -6,50 +6,624 @@
|
|||
## User Story
|
||||
As an **admin**,
|
||||
I want **quick access to pending items and common tasks**,
|
||||
So that **I can efficiently manage my daily workflow**.
|
||||
So that **I can efficiently manage my daily workflow without navigating away from the dashboard**.
|
||||
|
||||
## Business Context
|
||||
|
||||
The admin dashboard from Story 6.1 provides static metrics. This story adds actionable widgets that surface items requiring immediate attention (pending bookings, today's schedule) and shortcuts to frequent tasks. This transforms the dashboard from an information display into a productivity hub.
|
||||
|
||||
## Prerequisites / Dependencies
|
||||
|
||||
This story requires the following to be completed first:
|
||||
|
||||
| Dependency | Required From | What's Needed |
|
||||
|------------|---------------|---------------|
|
||||
| Dashboard Layout | Story 6.1 | Admin dashboard page with card-based layout |
|
||||
| Consultation Model | Epic 3 | `consultations` table with `status`, `scheduled_date`, `scheduled_time` fields |
|
||||
| Consultation Scopes | Epic 3 | `pending()` and `approved()` query scopes on Consultation model |
|
||||
| Timeline Model | Epic 4 | `timelines` table with `user_id`, `case_name` fields |
|
||||
| TimelineUpdate Model | Epic 4 | `timeline_updates` table with `timeline_id`, `created_at` |
|
||||
| User Model | Epic 2 | Client users that consultations reference |
|
||||
| Post Routes | Epic 5 | Route for creating posts (`admin.posts.create`) |
|
||||
| User Routes | Epic 2 | Route for creating users (`admin.users.create`) |
|
||||
|
||||
**References:**
|
||||
- Story 6.1 Dashboard Layout: `docs/stories/story-6.1-dashboard-overview-statistics.md`
|
||||
- Epic 6 Dashboard Details: `docs/epics/epic-6-admin-dashboard.md`
|
||||
- PRD Dashboard Section: `docs/prd.md` Section 5.7 (Admin Dashboard)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Pending Bookings Widget
|
||||
- [ ] Count badge with urgent indicator
|
||||
- [ ] Link to booking management
|
||||
- [ ] Mini list of recent pending (3-5)
|
||||
- [ ] Display count badge showing number of pending consultation requests
|
||||
- [ ] Urgent indicator (red/warning styling) when pending count > 0
|
||||
- [ ] Mini list showing up to 5 most recent pending bookings with:
|
||||
- Client name
|
||||
- Requested date
|
||||
- Consultation type (free/paid)
|
||||
- [ ] "View All" link navigating to booking management page (`admin.consultations.index`)
|
||||
- [ ] Empty state message when no pending bookings
|
||||
|
||||
### Today's Schedule Widget
|
||||
- [ ] List of today's consultations
|
||||
- [ ] Time and client name
|
||||
- [ ] Quick status update buttons
|
||||
- [ ] List of today's approved consultations ordered by time
|
||||
- [ ] Each item displays:
|
||||
- Scheduled time (formatted for locale)
|
||||
- Client name
|
||||
- Consultation type badge (free/paid)
|
||||
- [ ] Quick status buttons for each consultation:
|
||||
- "Complete" - marks as completed
|
||||
- "No-show" - marks as no-show
|
||||
- [ ] Empty state message when no consultations scheduled today
|
||||
|
||||
### Recent Timeline Updates Widget
|
||||
- [ ] Last 5 updates made
|
||||
- [ ] Quick link to timeline
|
||||
- [ ] Display last 5 timeline updates across all clients
|
||||
- [ ] Each item shows:
|
||||
- Update preview (truncated to ~50 chars)
|
||||
- Case name
|
||||
- Client name
|
||||
- Relative timestamp ("2 hours ago")
|
||||
- [ ] Click navigates to the specific timeline (`admin.timelines.show`)
|
||||
- [ ] Empty state message when no recent updates
|
||||
|
||||
### Quick Action Buttons
|
||||
- [ ] Create user
|
||||
- [ ] Create post
|
||||
- [ ] Block time slot
|
||||
- [ ] **Create User** button - navigates to `admin.users.create`
|
||||
- [ ] **Create Post** button - navigates to `admin.posts.create`
|
||||
- [ ] **Block Time Slot** button - opens modal to block availability
|
||||
- Date picker for selecting date
|
||||
- Time range (start/end time)
|
||||
- Optional reason field
|
||||
- Save creates a "blocked" consultation record
|
||||
|
||||
### Notification Bell
|
||||
- [ ] Pending items count
|
||||
### Notification Bell (Header)
|
||||
- [ ] Bell icon in admin header/navbar
|
||||
- [ ] Badge showing total pending items count (pending bookings)
|
||||
- [ ] Badge hidden when count is 0
|
||||
- [ ] Click navigates to pending bookings
|
||||
|
||||
## Technical Notes
|
||||
### Real-time Updates
|
||||
- [ ] Widgets auto-refresh via Livewire polling every 30 seconds
|
||||
- [ ] No full page reload required
|
||||
- [ ] Visual indication during refresh (subtle loading state)
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Files to Create/Modify
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `resources/views/livewire/admin/dashboard.blade.php` | Add widget sections to existing dashboard |
|
||||
| `resources/views/livewire/admin/widgets/pending-bookings.blade.php` | Pending bookings widget (Volt component) |
|
||||
| `resources/views/livewire/admin/widgets/todays-schedule.blade.php` | Today's schedule widget (Volt component) |
|
||||
| `resources/views/livewire/admin/widgets/recent-updates.blade.php` | Recent timeline updates widget (Volt component) |
|
||||
| `resources/views/livewire/admin/widgets/quick-actions.blade.php` | Quick action buttons widget (Volt component) |
|
||||
| `resources/views/components/layouts/admin.blade.php` | Add notification bell to admin header |
|
||||
|
||||
### Widget Architecture
|
||||
|
||||
Each widget is a separate Volt component for isolation and independent polling:
|
||||
|
||||
```php
|
||||
public function with(): array
|
||||
{
|
||||
// resources/views/livewire/admin/widgets/pending-bookings.blade.php
|
||||
<?php
|
||||
|
||||
use App\Models\Consultation;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
'pendingBookings' => Consultation::pending()->latest()->take(5)->get(),
|
||||
'todaySchedule' => Consultation::approved()->whereDate('scheduled_date', today())->orderBy('scheduled_time')->get(),
|
||||
'recentUpdates' => TimelineUpdate::latest()->take(5)->with('timeline.user')->get(),
|
||||
'pendingCount' => Consultation::pending()->count(),
|
||||
'pendingBookings' => Consultation::pending()
|
||||
->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
|
||||
- [ ] All widgets display correctly
|
||||
- [ ] Quick actions work
|
||||
- [ ] Real-time updates with polling
|
||||
- [ ] Tests pass
|
||||
- [ ] All four widgets display correctly with accurate data
|
||||
- [ ] Widgets auto-refresh via Livewire polling (30s interval)
|
||||
- [ ] Quick action buttons navigate to correct routes
|
||||
- [ ] Block time slot modal creates blocked consultation record
|
||||
- [ ] Mark complete/no-show actions update consultation status
|
||||
- [ ] Notification bell shows pending count in admin header
|
||||
- [ ] Empty states render gracefully for all widgets
|
||||
- [ ] Edge cases handled (100+ items, missing data)
|
||||
- [ ] All tests pass
|
||||
- [ ] Responsive layout works on mobile, tablet, desktop
|
||||
- [ ] RTL support for Arabic
|
||||
- [ ] Code formatted with Pint
|
||||
|
||||
## Out of Scope
|
||||
- WebSocket real-time updates (using polling instead)
|
||||
- Push notifications to browser
|
||||
- Email notifications for pending items
|
||||
- Widget position customization
|
||||
- Dashboard layout preferences
|
||||
|
||||
## Estimation
|
||||
**Complexity:** Medium | **Effort:** 3-4 hours
|
||||
**Complexity:** Medium | **Effort:** 4-5 hours
|
||||
|
|
|
|||
|
|
@ -8,61 +8,372 @@ As an **admin**,
|
|||
I want **to export user data in CSV and PDF formats**,
|
||||
So that **I can generate reports and maintain offline records**.
|
||||
|
||||
## Dependencies
|
||||
- **Epic 2 Complete:** User Management system with User model and data
|
||||
- **Story 6.1:** Dashboard Overview (provides the admin dashboard layout where export UI will be accessible)
|
||||
- **Packages Required:** `league/csv` and `barryvdh/laravel-dompdf` must be installed
|
||||
|
||||
## References
|
||||
- PRD Section 11.2: Export Functionality - defines exportable data and formats
|
||||
- PRD Section 5.3: User Management System - defines user fields (individual vs company)
|
||||
- `docs/epics/epic-6-admin-dashboard.md#story-6.4` - epic-level acceptance criteria
|
||||
- User model: `app/Models/User.php` - contains `user_type`, `national_id`, `company_cert_number` fields
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Export Options
|
||||
- [ ] Export all users
|
||||
- [ ] Export individual clients only
|
||||
- [ ] Export company clients only
|
||||
- [ ] Export all users (both individual and company clients)
|
||||
- [ ] Export individual clients only (`user_type = 'individual'`)
|
||||
- [ ] Export company clients only (`user_type = 'company'`)
|
||||
|
||||
### Filters
|
||||
- [ ] Date range (created)
|
||||
- [ ] Status (active/deactivated)
|
||||
- [ ] Date range filter on `created_at` field (start date, end date)
|
||||
- [ ] Status filter: active, deactivated, or all
|
||||
- [ ] Filters combine with export type (e.g., "active individual clients created in 2024")
|
||||
|
||||
### CSV Export Includes
|
||||
- [ ] Name, email, phone
|
||||
- [ ] User type
|
||||
- [ ] National ID / Company registration
|
||||
- [ ] Status
|
||||
- [ ] Created date
|
||||
- [ ] Name (`name` for individual, `company_name` for company)
|
||||
- [ ] Email (`email`)
|
||||
- [ ] Phone (`phone`)
|
||||
- [ ] User type (`user_type`: individual/company)
|
||||
- [ ] National ID / Company registration (`national_id` for individual, `company_cert_number` for company)
|
||||
- [ ] Status (`status`: active/deactivated)
|
||||
- [ ] Created date (`created_at` formatted per locale)
|
||||
- [ ] UTF-8 BOM for proper Arabic character display in Excel
|
||||
|
||||
### PDF Export Includes
|
||||
- [ ] Same data with professional formatting
|
||||
- [ ] Libra branding header
|
||||
- [ ] Generation timestamp
|
||||
- [ ] Same data fields as CSV in tabular format
|
||||
- [ ] Libra branding header (logo, firm name)
|
||||
- [ ] Generation timestamp in footer
|
||||
- [ ] Page numbers if multiple pages
|
||||
- [ ] Professional formatting with brand colors (Navy #0A1F44, Gold #D4AF37)
|
||||
|
||||
### Bilingual
|
||||
- [ ] Column headers based on admin language
|
||||
### Bilingual Support
|
||||
- [ ] Column headers based on admin's `preferred_language` setting
|
||||
- [ ] Date formatting per locale (DD/MM/YYYY for Arabic, MM/DD/YYYY for English)
|
||||
- [ ] PDF title and footer text bilingual
|
||||
|
||||
## Technical Notes
|
||||
### UI Requirements
|
||||
- [ ] Export section accessible from Admin User Management page
|
||||
- [ ] Filter form with: user type dropdown, status dropdown, date range picker
|
||||
- [ ] "Export CSV" and "Export PDF" buttons
|
||||
- [ ] Loading indicator during export generation
|
||||
- [ ] Success toast on download start
|
||||
- [ ] Error toast if export fails
|
||||
|
||||
Use league/csv for CSV and barryvdh/laravel-dompdf for PDF.
|
||||
## Technical Implementation
|
||||
|
||||
### Files to Create/Modify
|
||||
|
||||
**Livewire Component:**
|
||||
```
|
||||
resources/views/livewire/admin/users/export-users.blade.php
|
||||
```
|
||||
|
||||
**PDF Template:**
|
||||
```
|
||||
resources/views/pdf/users-export.blade.php
|
||||
```
|
||||
|
||||
**Translation File (add export keys):**
|
||||
```
|
||||
resources/lang/ar/export.php
|
||||
resources/lang/en/export.php
|
||||
```
|
||||
|
||||
### Routes
|
||||
Export actions will be methods on the Livewire component - no separate routes needed. The component handles streaming responses.
|
||||
|
||||
### Key User Model Fields
|
||||
```php
|
||||
// From User model (app/Models/User.php)
|
||||
$user->name // Individual's full name
|
||||
$user->company_name // Company name (if company type)
|
||||
$user->email
|
||||
$user->phone
|
||||
$user->user_type // 'individual' or 'company'
|
||||
$user->national_id // Individual's national ID
|
||||
$user->company_cert_number // Company registration number
|
||||
$user->status // 'active' or 'deactivated'
|
||||
$user->preferred_language // 'ar' or 'en'
|
||||
$user->created_at
|
||||
```
|
||||
|
||||
### Implementation Pattern
|
||||
|
||||
**CSV Export (Streamed Response):**
|
||||
```php
|
||||
use League\Csv\Writer;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
public function exportCsv(): StreamedResponse
|
||||
{
|
||||
return response()->streamDownload(function () {
|
||||
$csv = Writer::createFromString();
|
||||
$csv->insertOne([__('export.name'), __('export.email'), ...]);
|
||||
$users = $this->getFilteredUsers();
|
||||
$locale = auth()->user()->preferred_language ?? 'ar';
|
||||
|
||||
User::whereIn('user_type', ['individual', 'company'])
|
||||
->cursor()
|
||||
->each(fn($user) => $csv->insertOne([
|
||||
$user->name,
|
||||
return response()->streamDownload(function () use ($users, $locale) {
|
||||
$csv = Writer::createFromString();
|
||||
|
||||
// UTF-8 BOM for Excel Arabic support
|
||||
echo "\xEF\xBB\xBF";
|
||||
|
||||
// Headers based on admin language
|
||||
$csv->insertOne([
|
||||
__('export.name', [], $locale),
|
||||
__('export.email', [], $locale),
|
||||
__('export.phone', [], $locale),
|
||||
__('export.user_type', [], $locale),
|
||||
__('export.id_number', [], $locale),
|
||||
__('export.status', [], $locale),
|
||||
__('export.created_at', [], $locale),
|
||||
]);
|
||||
|
||||
$users->cursor()->each(function ($user) use ($csv, $locale) {
|
||||
$csv->insertOne([
|
||||
$user->user_type === 'company' ? $user->company_name : $user->name,
|
||||
$user->email,
|
||||
// ...
|
||||
]));
|
||||
$user->phone,
|
||||
__('export.type_' . $user->user_type, [], $locale),
|
||||
$user->user_type === 'company' ? $user->company_cert_number : $user->national_id,
|
||||
__('export.status_' . $user->status, [], $locale),
|
||||
$user->created_at->format($locale === 'ar' ? 'd/m/Y' : 'm/d/Y'),
|
||||
]);
|
||||
});
|
||||
|
||||
echo $csv->toString();
|
||||
}, 'users-export.csv');
|
||||
}, 'users-export-' . now()->format('Y-m-d') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
**PDF Export:**
|
||||
```php
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
|
||||
public function exportPdf()
|
||||
{
|
||||
$users = $this->getFilteredUsers()->get();
|
||||
$locale = auth()->user()->preferred_language ?? 'ar';
|
||||
|
||||
$pdf = Pdf::loadView('pdf.users-export', [
|
||||
'users' => $users,
|
||||
'locale' => $locale,
|
||||
'generatedAt' => now(),
|
||||
'filters' => $this->getActiveFilters(),
|
||||
]);
|
||||
|
||||
// RTL support for Arabic
|
||||
if ($locale === 'ar') {
|
||||
$pdf->setOption('isHtml5ParserEnabled', true);
|
||||
$pdf->setOption('defaultFont', 'DejaVu Sans');
|
||||
}
|
||||
|
||||
return response()->streamDownload(
|
||||
fn () => print($pdf->output()),
|
||||
'users-export-' . now()->format('Y-m-d') . '.pdf'
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Filter Query Builder:**
|
||||
```php
|
||||
private function getFilteredUsers()
|
||||
{
|
||||
return User::query()
|
||||
->when($this->userType !== 'all', fn ($q) => $q->where('user_type', $this->userType))
|
||||
->when($this->status !== 'all', fn ($q) => $q->where('status', $this->status))
|
||||
->when($this->dateFrom, fn ($q) => $q->whereDate('created_at', '>=', $this->dateFrom))
|
||||
->when($this->dateTo, fn ($q) => $q->whereDate('created_at', '<=', $this->dateTo))
|
||||
->whereIn('user_type', ['individual', 'company']) // Exclude admin
|
||||
->orderBy('created_at', 'desc');
|
||||
}
|
||||
```
|
||||
|
||||
### Translation Keys Required
|
||||
```php
|
||||
// resources/lang/en/export.php
|
||||
return [
|
||||
'name' => 'Name',
|
||||
'email' => 'Email',
|
||||
'phone' => 'Phone',
|
||||
'user_type' => 'User Type',
|
||||
'id_number' => 'ID Number',
|
||||
'status' => 'Status',
|
||||
'created_at' => 'Created Date',
|
||||
'type_individual' => 'Individual',
|
||||
'type_company' => 'Company',
|
||||
'status_active' => 'Active',
|
||||
'status_deactivated' => 'Deactivated',
|
||||
'users_export_title' => 'Users Export',
|
||||
'generated_at' => 'Generated at',
|
||||
'page' => 'Page',
|
||||
];
|
||||
|
||||
// resources/lang/ar/export.php
|
||||
return [
|
||||
'name' => 'الاسم',
|
||||
'email' => 'البريد الإلكتروني',
|
||||
'phone' => 'الهاتف',
|
||||
'user_type' => 'نوع المستخدم',
|
||||
'id_number' => 'رقم الهوية',
|
||||
'status' => 'الحالة',
|
||||
'created_at' => 'تاريخ الإنشاء',
|
||||
'type_individual' => 'فرد',
|
||||
'type_company' => 'شركة',
|
||||
'status_active' => 'نشط',
|
||||
'status_deactivated' => 'معطل',
|
||||
'users_export_title' => 'تصدير المستخدمين',
|
||||
'generated_at' => 'تم الإنشاء في',
|
||||
'page' => 'صفحة',
|
||||
];
|
||||
```
|
||||
|
||||
## Edge Cases & Error Handling
|
||||
|
||||
### Empty Dataset
|
||||
- If no users match filters, show info message: "No users match the selected filters"
|
||||
- Do not generate empty export file
|
||||
|
||||
### Large Datasets (1000+ users)
|
||||
- Use `cursor()` for CSV to avoid memory issues
|
||||
- For PDF with 500+ users, show warning: "Large export may take a moment"
|
||||
- Consider chunking PDF generation or limiting to 500 users with message to narrow filters
|
||||
|
||||
### Export Failures
|
||||
- Catch exceptions and show error toast: "Export failed. Please try again."
|
||||
- Log error details for debugging
|
||||
|
||||
### Concurrent Requests
|
||||
- Disable export buttons while export is in progress (wire:loading)
|
||||
|
||||
### Arabic Content
|
||||
- CSV: Include UTF-8 BOM (`\xEF\xBB\xBF`) for Excel compatibility
|
||||
- PDF: Use font that supports Arabic (DejaVu Sans or Cairo)
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Feature Tests
|
||||
```php
|
||||
// tests/Feature/Admin/UserExportTest.php
|
||||
|
||||
test('admin can export all users as CSV', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
User::factory()->count(5)->individual()->create();
|
||||
User::factory()->count(3)->company()->create();
|
||||
|
||||
Volt::test('admin.users.export-users')
|
||||
->actingAs($admin)
|
||||
->set('userType', 'all')
|
||||
->call('exportCsv')
|
||||
->assertFileDownloaded();
|
||||
});
|
||||
|
||||
test('admin can export filtered users by type', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
User::factory()->count(5)->individual()->create();
|
||||
User::factory()->count(3)->company()->create();
|
||||
|
||||
// Test individual filter
|
||||
Volt::test('admin.users.export-users')
|
||||
->actingAs($admin)
|
||||
->set('userType', 'individual')
|
||||
->call('exportCsv')
|
||||
->assertFileDownloaded();
|
||||
});
|
||||
|
||||
test('admin can export users filtered by status', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
User::factory()->count(3)->individual()->active()->create();
|
||||
User::factory()->count(2)->individual()->deactivated()->create();
|
||||
|
||||
Volt::test('admin.users.export-users')
|
||||
->actingAs($admin)
|
||||
->set('status', 'active')
|
||||
->call('exportCsv')
|
||||
->assertFileDownloaded();
|
||||
});
|
||||
|
||||
test('admin can export users filtered by date range', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
User::factory()->individual()->create(['created_at' => now()->subDays(10)]);
|
||||
User::factory()->individual()->create(['created_at' => now()->subDays(5)]);
|
||||
User::factory()->individual()->create(['created_at' => now()]);
|
||||
|
||||
Volt::test('admin.users.export-users')
|
||||
->actingAs($admin)
|
||||
->set('dateFrom', now()->subDays(7)->format('Y-m-d'))
|
||||
->set('dateTo', now()->format('Y-m-d'))
|
||||
->call('exportCsv')
|
||||
->assertFileDownloaded();
|
||||
});
|
||||
|
||||
test('admin can export users as PDF', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
User::factory()->count(5)->individual()->create();
|
||||
|
||||
Volt::test('admin.users.export-users')
|
||||
->actingAs($admin)
|
||||
->call('exportPdf')
|
||||
->assertFileDownloaded();
|
||||
});
|
||||
|
||||
test('export shows message when no users match filters', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
Volt::test('admin.users.export-users')
|
||||
->actingAs($admin)
|
||||
->set('userType', 'individual')
|
||||
->set('status', 'active')
|
||||
->call('exportCsv')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('notify'); // Empty dataset notification
|
||||
});
|
||||
|
||||
test('export headers use admin preferred language', function () {
|
||||
$admin = User::factory()->admin()->create(['preferred_language' => 'ar']);
|
||||
User::factory()->individual()->create();
|
||||
|
||||
// This would need custom assertion to check CSV content
|
||||
// Verify Arabic headers are used
|
||||
});
|
||||
|
||||
test('non-admin cannot access export', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
|
||||
Volt::test('admin.users.export-users')
|
||||
->actingAs($client)
|
||||
->assertForbidden();
|
||||
});
|
||||
```
|
||||
|
||||
### Manual Testing Checklist
|
||||
- [ ] CSV opens correctly in Excel with Arabic characters displaying properly
|
||||
- [ ] PDF renders with Libra branding (logo, colors)
|
||||
- [ ] PDF Arabic content is readable (correct font, RTL if needed)
|
||||
- [ ] Date range picker works correctly
|
||||
- [ ] Combined filters produce correct results
|
||||
- [ ] Large export (500+ users) completes without timeout
|
||||
- [ ] Export buttons disabled during generation (loading state)
|
||||
|
||||
## Definition of Done
|
||||
- [ ] CSV export works with all filters
|
||||
- [ ] PDF export works with branding
|
||||
- [ ] Large datasets handled efficiently
|
||||
- [ ] Tests pass
|
||||
- [ ] Livewire component created with filter form and export buttons
|
||||
- [ ] CSV export works with all filter combinations
|
||||
- [ ] PDF export renders with Libra branding header and footer
|
||||
- [ ] Translation files created for both Arabic and English
|
||||
- [ ] UTF-8 BOM included in CSV for Arabic Excel compatibility
|
||||
- [ ] Large datasets handled efficiently using cursor/chunking
|
||||
- [ ] Empty dataset shows appropriate message (no empty file generated)
|
||||
- [ ] Error handling with user-friendly toast messages
|
||||
- [ ] Loading states on export buttons
|
||||
- [ ] All feature tests pass
|
||||
- [ ] Manual testing checklist completed
|
||||
- [ ] Code formatted with Pint
|
||||
|
||||
## Estimation
|
||||
**Complexity:** Medium | **Effort:** 3-4 hours
|
||||
**Complexity:** Medium | **Effort:** 4-5 hours
|
||||
|
||||
## Out of Scope
|
||||
- Background job processing for very large exports (defer to future enhancement)
|
||||
- Email delivery of export files
|
||||
- Scheduled/automated exports
|
||||
|
|
|
|||
|
|
@ -3,57 +3,297 @@
|
|||
## Epic Reference
|
||||
**Epic 6:** Admin Dashboard
|
||||
|
||||
## Dependencies
|
||||
- **Story 6.4:** Uses identical export patterns (CSV streaming, PDF generation, bilingual headers)
|
||||
- **Epic 3:** Consultation model with `user` relationship and booking statuses
|
||||
|
||||
## User Story
|
||||
As an **admin**,
|
||||
I want **to export consultation/booking data**,
|
||||
So that **I can analyze and report on consultation history**.
|
||||
I want **to export consultation/booking data in CSV and PDF formats**,
|
||||
So that **I can generate reports for accounting, analyze consultation patterns, and maintain offline records of client interactions**.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Export Options
|
||||
- [ ] Export all consultations
|
||||
- [ ] Export filtered subset based on criteria below
|
||||
|
||||
### Filters
|
||||
- [ ] Date range
|
||||
- [ ] Date range (scheduled_date between start/end)
|
||||
- [ ] Consultation type (free/paid)
|
||||
- [ ] Status (approved/completed/no-show/cancelled)
|
||||
- [ ] Payment status
|
||||
- [ ] Status (pending/approved/completed/no-show/cancelled)
|
||||
- [ ] Payment status (pending/received)
|
||||
|
||||
### Export Includes
|
||||
- [ ] Client name
|
||||
- [ ] Date and time
|
||||
- [ ] Consultation type
|
||||
- [ ] Client name (from user relationship)
|
||||
- [ ] Date and time (scheduled_date, scheduled_time)
|
||||
- [ ] Consultation type (free/paid)
|
||||
- [ ] Status
|
||||
- [ ] Payment status
|
||||
- [ ] Problem summary
|
||||
- [ ] Problem summary (truncated in PDF if > 500 chars)
|
||||
|
||||
### Formats
|
||||
- [ ] CSV format
|
||||
- [ ] PDF format with professional layout and branding
|
||||
- [ ] CSV format with streaming download
|
||||
- [ ] PDF format with professional layout and Libra branding
|
||||
|
||||
### Bilingual Support
|
||||
- [ ] Column headers based on admin's preferred language
|
||||
- [ ] Use translation keys from `resources/lang/{locale}/export.php`
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Implementation Pattern
|
||||
Follow the export pattern established in Story 6.4. Reuse any base export functionality created there.
|
||||
|
||||
### Files to Create/Modify
|
||||
|
||||
```
|
||||
app/Http/Controllers/Admin/ConsultationExportController.php # New controller
|
||||
resources/views/exports/consultations.blade.php # PDF template
|
||||
resources/lang/en/export.php # Add consultation keys
|
||||
resources/lang/ar/export.php # Add consultation keys
|
||||
routes/web.php # Add export routes
|
||||
```
|
||||
|
||||
### Routes
|
||||
|
||||
```php
|
||||
public function exportConsultationsPdf(Request $request)
|
||||
// In admin routes group
|
||||
Route::prefix('exports')->name('admin.exports.')->group(function () {
|
||||
Route::get('consultations/csv', [ConsultationExportController::class, 'csv'])->name('consultations.csv');
|
||||
Route::get('consultations/pdf', [ConsultationExportController::class, 'pdf'])->name('consultations.pdf');
|
||||
});
|
||||
```
|
||||
|
||||
### Controller Implementation
|
||||
|
||||
```php
|
||||
<?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')
|
||||
->when($request->date_from, fn($q) => $q->where('scheduled_date', '>=', $request->date_from))
|
||||
->when($request->status, fn($q) => $q->where('status', $request->status))
|
||||
->get();
|
||||
|
||||
$pdf = Pdf::loadView('exports.consultations', compact('consultations'));
|
||||
|
||||
return $pdf->download('consultations-export.pdf');
|
||||
->when($request->date_from, fn ($q) => $q->where('scheduled_date', '>=', $request->date_from))
|
||||
->when($request->date_to, fn ($q) => $q->where('scheduled_date', '<=', $request->date_to))
|
||||
->when($request->type, fn ($q) => $q->where('consultation_type', $request->type))
|
||||
->when($request->status, fn ($q) => $q->where('status', $request->status))
|
||||
->when($request->payment_status, fn ($q) => $q->where('payment_status', $request->payment_status))
|
||||
->orderBy('scheduled_date', 'desc');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PDF Template Structure
|
||||
|
||||
The PDF template (`resources/views/exports/consultations.blade.php`) should include:
|
||||
- Libra logo and branding header (navy blue #0A1F44, gold #D4AF37)
|
||||
- Report title with generation timestamp
|
||||
- Applied filters summary
|
||||
- Data table with alternating row colors
|
||||
- Problem summary truncated to 500 chars with "..." if longer
|
||||
- Footer with page numbers
|
||||
|
||||
### Translation Keys
|
||||
|
||||
Add to `resources/lang/en/export.php`:
|
||||
```php
|
||||
'client_name' => 'Client Name',
|
||||
'date' => 'Date',
|
||||
'time' => 'Time',
|
||||
'consultation_type' => 'Type',
|
||||
'status' => 'Status',
|
||||
'payment_status' => 'Payment Status',
|
||||
'problem_summary' => 'Problem Summary',
|
||||
```
|
||||
|
||||
Add to `resources/lang/ar/export.php`:
|
||||
```php
|
||||
'client_name' => 'اسم العميل',
|
||||
'date' => 'التاريخ',
|
||||
'time' => 'الوقت',
|
||||
'consultation_type' => 'النوع',
|
||||
'status' => 'الحالة',
|
||||
'payment_status' => 'حالة الدفع',
|
||||
'problem_summary' => 'ملخص المشكلة',
|
||||
```
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **Empty results:** Return empty CSV with headers only, or PDF with "No consultations found" message
|
||||
- **Large datasets:** Use cursor() for CSV streaming; for PDF, consider chunking or limiting to 500 records with warning
|
||||
- **Large problem summaries:** Truncate to 500 characters in PDF table cells with "..." indicator
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Feature Tests
|
||||
|
||||
Create `tests/Feature/Admin/ConsultationExportTest.php`:
|
||||
|
||||
```php
|
||||
<?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
|
||||
- [ ] All filters work correctly
|
||||
- [ ] CSV export accurate
|
||||
- [ ] PDF professionally formatted
|
||||
- [ ] Large summaries handled in PDF
|
||||
- [ ] Tests pass
|
||||
- [ ] All filters work correctly (date range, type, status, payment)
|
||||
- [ ] CSV export streams correctly with proper headers
|
||||
- [ ] PDF export generates with Libra branding (logo, colors)
|
||||
- [ ] Large problem summaries truncated properly in PDF
|
||||
- [ ] Bilingual column headers work based on admin language
|
||||
- [ ] Empty results handled gracefully
|
||||
- [ ] All feature tests pass
|
||||
- [ ] Code formatted with Pint
|
||||
|
||||
## Estimation
|
||||
**Complexity:** Medium | **Effort:** 3 hours
|
||||
|
||||
## References
|
||||
- **PRD Section 11.2:** Export Functionality requirements
|
||||
- **Story 6.4:** User export implementation (follow same patterns)
|
||||
- **Epic 3:** Consultation model structure and statuses
|
||||
|
|
|
|||
|
|
@ -8,15 +8,35 @@ As an **admin**,
|
|||
I want **to export timeline and case data**,
|
||||
So that **I can maintain records and generate case reports**.
|
||||
|
||||
## Story Context
|
||||
|
||||
### UI Location
|
||||
This export feature is part of the Admin Dashboard exports section, accessible via the admin navigation. The timeline export page provides filter controls and export buttons for both CSV and PDF formats.
|
||||
|
||||
### Existing System Integration
|
||||
- **Follows pattern:** Story 6.4 (User Lists Export) and Story 6.5 (Consultation Export) - same UI layout, filter approach, and export mechanisms
|
||||
- **Integrates with:** Timeline model, TimelineUpdate model, User model
|
||||
- **Technology:** Livewire Volt, Flux UI, league/csv, barryvdh/laravel-dompdf
|
||||
- **Touch points:** Admin dashboard navigation, timeline management section
|
||||
|
||||
### Reference Documents
|
||||
- **Epic:** `docs/epics/epic-6-admin-dashboard.md#story-66-data-export---timeline-reports`
|
||||
- **Export Pattern Reference:** `docs/stories/story-6.4-data-export-user-lists.md` - establishes CSV/PDF export patterns
|
||||
- **Similar Implementation:** `docs/stories/story-6.5-data-export-consultation-records.md` - query and filter patterns
|
||||
- **Timeline System:** `docs/epics/epic-4-case-timeline.md` - timeline model and relationships
|
||||
- **Timeline Schema:** `docs/stories/story-4.1-timeline-creation.md#database-schema` - database structure
|
||||
- **PRD Export Requirements:** `docs/prd.md#117-export-functionality` - business requirements
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Export Options
|
||||
- [ ] Export all timelines (across all clients)
|
||||
- [ ] Export timelines for specific client
|
||||
- [ ] Export timelines for specific client (client selector/search)
|
||||
|
||||
### Filters
|
||||
- [ ] Status (active/archived)
|
||||
- [ ] Date range
|
||||
- [ ] Status filter (active/archived/all)
|
||||
- [ ] Date range filter (created_at)
|
||||
- [ ] Client filter (search by name/email)
|
||||
|
||||
### Export Includes
|
||||
- [ ] Case name and reference
|
||||
|
|
@ -27,39 +47,363 @@ So that **I can maintain records and generate case reports**.
|
|||
- [ ] Last update date
|
||||
|
||||
### Formats
|
||||
- [ ] CSV format
|
||||
- [ ] PDF format
|
||||
- [ ] CSV format with bilingual headers
|
||||
- [ ] PDF format with Libra branding
|
||||
|
||||
### Optional
|
||||
- [ ] Include update content or summary only toggle
|
||||
### Optional Features
|
||||
- [ ] Include update content toggle (full content vs summary only)
|
||||
- [ ] When enabled, PDF includes all update entries per timeline
|
||||
|
||||
### UI Requirements
|
||||
- [ ] Filter controls match Story 6.4/6.5 layout
|
||||
- [ ] Export buttons clearly visible
|
||||
- [ ] Loading state during export generation
|
||||
- [ ] Success/error feedback messages
|
||||
|
||||
## Technical Notes
|
||||
|
||||
```php
|
||||
public function exportTimelinesPdf(Request $request)
|
||||
{
|
||||
$timelines = Timeline::query()
|
||||
->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();
|
||||
### File Structure
|
||||
```
|
||||
Routes:
|
||||
GET /admin/exports/timelines -> admin.exports.timelines (Volt page)
|
||||
|
||||
$pdf = Pdf::loadView('exports.timelines', [
|
||||
'timelines' => $timelines,
|
||||
'includeUpdates' => $request->boolean('include_updates'),
|
||||
Files to Create:
|
||||
resources/views/livewire/pages/admin/exports/timelines.blade.php (Volt component)
|
||||
resources/views/exports/timelines-pdf.blade.php (PDF template)
|
||||
|
||||
Models Required (from Epic 4):
|
||||
app/Models/Timeline.php
|
||||
app/Models/TimelineUpdate.php
|
||||
```
|
||||
|
||||
### Database Schema Reference
|
||||
```php
|
||||
// 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
|
||||
- [ ] All filters work
|
||||
- [ ] CSV export works
|
||||
- [ ] PDF with branding works
|
||||
- [ ] Optional update content toggle works
|
||||
- [ ] Tests pass
|
||||
|
||||
- [ ] Volt component created at `resources/views/livewire/pages/admin/exports/timelines.blade.php`
|
||||
- [ ] PDF template created at `resources/views/exports/timelines-pdf.blade.php`
|
||||
- [ ] Route registered in admin routes
|
||||
- [ ] Navigation link added to admin dashboard exports section
|
||||
- [ ] All filters work (client, status, date range)
|
||||
- [ ] CSV export generates valid file with correct data
|
||||
- [ ] PDF export generates with Libra branding (navy/gold)
|
||||
- [ ] Include updates toggle works for PDF
|
||||
- [ ] Empty results handled gracefully
|
||||
- [ ] Bilingual support (AR/EN headers and labels)
|
||||
- [ ] All translation keys added
|
||||
- [ ] All tests pass
|
||||
- [ ] Code formatted with Pint
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Story 6.4:** Data Export - User Lists (establishes export patterns, packages already installed)
|
||||
- **Story 6.5:** Data Export - Consultation Records (similar implementation pattern)
|
||||
- **Story 4.1:** Timeline Creation (Timeline model, database schema)
|
||||
- **Story 4.2:** Timeline Updates Management (TimelineUpdate model)
|
||||
- **Story 1.3:** Bilingual Infrastructure (translation system)
|
||||
|
||||
## Estimation
|
||||
**Complexity:** Medium | **Effort:** 3 hours
|
||||
|
|
|
|||
|
|
@ -5,66 +5,588 @@
|
|||
|
||||
## User Story
|
||||
As an **admin**,
|
||||
I want **to generate comprehensive monthly PDF reports**,
|
||||
So that **I have professional summaries of business performance**.
|
||||
I want **to generate comprehensive monthly PDF reports from the admin dashboard**,
|
||||
So that **I can archive business performance records, share summaries with stakeholders, and track month-over-month trends**.
|
||||
|
||||
## Prerequisites / Dependencies
|
||||
|
||||
This story requires the following to be completed first:
|
||||
|
||||
| Dependency | Required From | What's Needed |
|
||||
|------------|---------------|---------------|
|
||||
| Dashboard Metrics | Story 6.1 | Metrics calculation patterns and caching strategy |
|
||||
| Analytics Charts | Story 6.2 | Chart.js implementation and data aggregation methods |
|
||||
| User Export | Story 6.4 | DomPDF setup and PDF branding patterns |
|
||||
| Consultation Export | Story 6.5 | Export service patterns |
|
||||
| Timeline Export | Story 6.6 | Export patterns with related data |
|
||||
| User Model | Epic 2 | User statistics queries |
|
||||
| Consultation Model | Epic 3 | Consultation statistics queries |
|
||||
| Timeline Model | Epic 4 | Timeline statistics queries |
|
||||
| Post Model | Epic 5 | Post statistics queries |
|
||||
|
||||
**References:**
|
||||
- Epic 6 details: `docs/epics/epic-6-admin-dashboard.md`
|
||||
- Dashboard metrics implementation: `docs/stories/story-6.1-dashboard-overview-statistics.md`
|
||||
- Chart patterns: `docs/stories/story-6.2-analytics-charts.md`
|
||||
- PDF export patterns: `docs/stories/story-6.4-data-export-user-lists.md`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Generation
|
||||
- [ ] "Generate Monthly Report" button
|
||||
- [ ] Select month/year
|
||||
### UI Location & Generation
|
||||
- [ ] "Generate Monthly Report" button in admin dashboard (below metrics cards or in a Reports section)
|
||||
- [ ] Month/year selector dropdown (default: previous month)
|
||||
- [ ] Selectable range: last 12 months only (no future months)
|
||||
|
||||
### PDF Report Includes
|
||||
- [ ] Overview of key metrics
|
||||
- [ ] Charts (rendered as images)
|
||||
- [ ] User statistics
|
||||
- [ ] Consultation statistics
|
||||
- [ ] Timeline statistics
|
||||
- [ ] Post statistics
|
||||
### PDF Report Sections
|
||||
|
||||
### Design
|
||||
- [ ] Professional layout with branding
|
||||
- [ ] Table of contents
|
||||
- [ ] Printable format
|
||||
- [ ] Bilingual based on admin preference
|
||||
#### 1. Cover Page
|
||||
- [ ] Libra logo and branding
|
||||
- [ ] Report title: "Monthly Statistics Report"
|
||||
- [ ] Period: Month and Year (e.g., "December 2025")
|
||||
- [ ] Generated date and time
|
||||
|
||||
### UX
|
||||
- [ ] Loading indicator during generation
|
||||
- [ ] Download on completion
|
||||
#### 2. Table of Contents (Visual List)
|
||||
- [ ] List of sections with page numbers
|
||||
- [ ] Non-clickable (simple text list for print compatibility)
|
||||
|
||||
## Technical Notes
|
||||
#### 3. Executive Summary
|
||||
- [ ] Key highlights (2-3 bullet points)
|
||||
- [ ] Month-over-month comparison if prior month data exists
|
||||
|
||||
Pre-render charts as base64 images for PDF inclusion.
|
||||
#### 4. User Statistics Section
|
||||
- [ ] New clients registered this month
|
||||
- [ ] Total active clients (end of month)
|
||||
- [ ] Individual vs company breakdown
|
||||
- [ ] Client growth trend (compared to previous month)
|
||||
|
||||
#### 5. Consultation Statistics Section
|
||||
- [ ] Total consultations this month
|
||||
- [ ] Approved/Completed/Cancelled/No-show breakdown
|
||||
- [ ] Free vs paid ratio
|
||||
- [ ] No-show rate percentage
|
||||
- [ ] Pie chart: Consultation types (rendered as image)
|
||||
|
||||
#### 6. Timeline Statistics Section
|
||||
- [ ] Active timelines (end of month)
|
||||
- [ ] New timelines created this month
|
||||
- [ ] Timeline updates added this month
|
||||
- [ ] Archived timelines this month
|
||||
|
||||
#### 7. Post Statistics Section
|
||||
- [ ] Posts published this month
|
||||
- [ ] Total published posts (cumulative)
|
||||
|
||||
#### 8. Trends Chart
|
||||
- [ ] Line chart showing monthly consultations trend (last 6 months ending with selected month)
|
||||
- [ ] Rendered as base64 PNG image
|
||||
|
||||
### Design Requirements
|
||||
- [ ] Professional A4 portrait layout
|
||||
- [ ] Libra branding: Navy Blue (#0A1F44) headers, Gold (#D4AF37) accents
|
||||
- [ ] Consistent typography and spacing
|
||||
- [ ] Print-friendly (no dark backgrounds, adequate margins)
|
||||
- [ ] Bilingual: Arabic or English based on admin's `preferred_language` setting
|
||||
|
||||
### UX Requirements
|
||||
- [ ] Loading indicator with "Generating report..." message during PDF creation
|
||||
- [ ] Disable generate button while processing
|
||||
- [ ] Auto-download PDF on completion
|
||||
- [ ] Success toast notification after download starts
|
||||
- [ ] Error handling with user-friendly message if generation fails
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Files to Create/Modify
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `resources/views/livewire/admin/reports/monthly-report.blade.php` | Volt component for report generation UI |
|
||||
| `resources/views/exports/monthly-report.blade.php` | PDF template (Blade view for DomPDF) |
|
||||
| `app/Services/MonthlyReportService.php` | Statistics aggregation and PDF generation logic |
|
||||
| `routes/web.php` | Add report generation route |
|
||||
| `resources/lang/en/report.php` | English translations for report labels |
|
||||
| `resources/lang/ar/report.php` | Arabic translations for report labels |
|
||||
|
||||
### Route Definition
|
||||
|
||||
```php
|
||||
public function generateMonthlyReport(int $year, int $month)
|
||||
Route::middleware(['auth', 'admin'])->prefix('admin')->group(function () {
|
||||
Route::get('/reports/monthly', function () {
|
||||
return view('livewire.admin.reports.monthly-report');
|
||||
})->name('admin.reports.monthly');
|
||||
|
||||
Route::post('/reports/monthly/generate', [MonthlyReportController::class, 'generate'])
|
||||
->name('admin.reports.monthly.generate');
|
||||
});
|
||||
```
|
||||
|
||||
### Volt Component Structure
|
||||
|
||||
```php
|
||||
<?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();
|
||||
$endDate = $startDate->copy()->endOfMonth();
|
||||
$locale = Auth::user()->preferred_language ?? 'en';
|
||||
|
||||
$data = [
|
||||
'period' => $startDate->format('F Y'),
|
||||
'userStats' => $this->getUserStatsForPeriod($startDate, $endDate),
|
||||
'consultationStats' => $this->getConsultationStatsForPeriod($startDate, $endDate),
|
||||
'timelineStats' => $this->getTimelineStatsForPeriod($startDate, $endDate),
|
||||
'postStats' => $this->getPostStatsForPeriod($startDate, $endDate),
|
||||
'period' => $startDate->translatedFormat('F Y'),
|
||||
'generatedAt' => now()->translatedFormat('d M Y H:i'),
|
||||
'locale' => $locale,
|
||||
'userStats' => $this->getUserStats($startDate, $endDate),
|
||||
'consultationStats' => $this->getConsultationStats($startDate, $endDate),
|
||||
'timelineStats' => $this->getTimelineStats($startDate, $endDate),
|
||||
'postStats' => $this->getPostStats($startDate, $endDate),
|
||||
'charts' => $this->renderChartsAsImages($startDate, $endDate),
|
||||
'previousMonth' => $this->getPreviousMonthComparison($startDate),
|
||||
];
|
||||
|
||||
$pdf = Pdf::loadView('exports.monthly-report', $data)
|
||||
->setPaper('a4', 'portrait');
|
||||
|
||||
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
|
||||
- [ ] Month/year selector works
|
||||
- [ ] All statistics accurate
|
||||
- [ ] Charts rendered in PDF
|
||||
- [ ] Professional branding
|
||||
- [ ] Bilingual support
|
||||
- [ ] Tests pass
|
||||
- [ ] Monthly report page accessible at `/admin/reports/monthly`
|
||||
- [ ] Month/year selector works (last 12 months only)
|
||||
- [ ] PDF generates with all required sections
|
||||
- [ ] User statistics accurate for selected month
|
||||
- [ ] Consultation statistics accurate with correct no-show rate
|
||||
- [ ] Timeline statistics accurate
|
||||
- [ ] Post statistics accurate
|
||||
- [ ] Charts render as images in PDF
|
||||
- [ ] Professional branding (navy blue, gold, Libra logo)
|
||||
- [ ] Table of contents present
|
||||
- [ ] Bilingual support (Arabic/English based on admin preference)
|
||||
- [ ] Loading indicator during generation
|
||||
- [ ] Empty month handled gracefully (zeros, no errors)
|
||||
- [ ] Admin-only access enforced
|
||||
- [ ] All tests pass
|
||||
- [ ] Code formatted with Pint
|
||||
|
||||
## Estimation
|
||||
**Complexity:** High | **Effort:** 5-6 hours
|
||||
|
||||
## Out of Scope
|
||||
- Scheduled/automated monthly report generation
|
||||
- Email delivery of reports
|
||||
- Custom date range reports (only full months)
|
||||
- Comparison with same month previous year
|
||||
- PDF versioning or storage
|
||||
|
|
|
|||
|
|
@ -8,6 +8,15 @@ As an **admin**,
|
|||
I want **to configure system-wide settings**,
|
||||
So that **I can customize the platform to my needs**.
|
||||
|
||||
## Dependencies
|
||||
- **Story 1.2:** Authentication & Role System (admin auth, User model)
|
||||
- **Story 8.1:** Email Infrastructure Setup (mail configuration for test email)
|
||||
|
||||
## Navigation Context
|
||||
- Accessible from admin dashboard sidebar/navigation
|
||||
- Route: `/admin/settings`
|
||||
- Named route: `admin.settings`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Profile Settings
|
||||
|
|
@ -17,20 +26,85 @@ So that **I can customize the platform to my needs**.
|
|||
- [ ] Preferred language
|
||||
|
||||
### Email Settings
|
||||
- [ ] View current sender email
|
||||
- [ ] Test email functionality
|
||||
- [ ] Display current sender email from `config('mail.from.address')`
|
||||
- [ ] Display current sender name from `config('mail.from.name')`
|
||||
- [ ] "Send Test Email" button that sends a test email to admin's email address
|
||||
- [ ] Success/error feedback after test email attempt
|
||||
|
||||
### Notification Preferences (Optional)
|
||||
- [ ] Toggle admin notifications
|
||||
- [ ] Summary email frequency
|
||||
### Notification Preferences (Future Enhancement - Not in Scope)
|
||||
> **Note:** The following are documented for future consideration but are NOT required for this story's completion. All admin notifications are currently mandatory per PRD.
|
||||
- Toggle admin notifications (future)
|
||||
- Summary email frequency (future)
|
||||
|
||||
### Behavior
|
||||
- [ ] Settings saved and applied immediately
|
||||
- [ ] Validation for all inputs
|
||||
- [ ] Flash messages for success/error states
|
||||
- [ ] Password fields cleared after successful update
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Database Migration Required
|
||||
Add `preferred_language` column to users table:
|
||||
|
||||
```php
|
||||
// database/migrations/xxxx_add_preferred_language_to_users_table.php
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('preferred_language', 2)->default('ar')->after('remember_token');
|
||||
});
|
||||
```
|
||||
|
||||
### Files to Create/Modify
|
||||
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| `resources/views/livewire/admin/settings.blade.php` | Create | Main settings Volt component |
|
||||
| `app/Mail/TestEmail.php` | Create | Test email mailable |
|
||||
| `resources/views/emails/test.blade.php` | Create | Test email template |
|
||||
| `database/migrations/xxxx_add_preferred_language_to_users_table.php` | Create | Migration |
|
||||
| `routes/web.php` | Modify | Add admin settings route |
|
||||
| `app/Models/User.php` | Modify | Add `preferred_language` to fillable |
|
||||
|
||||
### TestEmail Mailable
|
||||
|
||||
```php
|
||||
// app/Mail/TestEmail.php
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
class TestEmail extends Mailable
|
||||
{
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: __('messages.test_email_subject'),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.test',
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Volt Component Structure
|
||||
|
||||
```php
|
||||
<?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 {
|
||||
public string $name = '';
|
||||
public string $email = '';
|
||||
|
|
@ -44,7 +118,7 @@ new class extends Component {
|
|||
$user = auth()->user();
|
||||
$this->name = $user->name;
|
||||
$this->email = $user->email;
|
||||
$this->preferred_language = $user->preferred_language;
|
||||
$this->preferred_language = $user->preferred_language ?? 'ar';
|
||||
}
|
||||
|
||||
public function updateProfile(): void
|
||||
|
|
@ -81,19 +155,176 @@ new class extends Component {
|
|||
|
||||
public function sendTestEmail(): void
|
||||
{
|
||||
try {
|
||||
Mail::to(auth()->user())->send(new TestEmail());
|
||||
session()->flash('success', __('messages.test_email_sent'));
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', __('messages.test_email_failed'));
|
||||
}
|
||||
};
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<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
|
||||
- [ ] Profile updates work
|
||||
- [ ] Password change works
|
||||
- [ ] Language preference persists
|
||||
- [ ] Test email sends
|
||||
- [ ] Validation complete
|
||||
- [ ] Tests pass
|
||||
- [ ] Migration created and run for `preferred_language` column
|
||||
- [ ] User model updated with `preferred_language` in fillable
|
||||
- [ ] Settings Volt component created at `resources/views/livewire/admin/settings.blade.php`
|
||||
- [ ] TestEmail mailable created at `app/Mail/TestEmail.php`
|
||||
- [ ] Test email template created at `resources/views/emails/test.blade.php`
|
||||
- [ ] Route added: `Route::get('/admin/settings', ...)->name('admin.settings')`
|
||||
- [ ] Profile update works with validation
|
||||
- [ ] Password change works with current password verification
|
||||
- [ ] Language preference persists across sessions
|
||||
- [ ] Test email sends successfully (or shows error on failure)
|
||||
- [ ] Email settings display current sender info from config
|
||||
- [ ] All flash messages display correctly (success/error)
|
||||
- [ ] UI follows Flux UI component patterns
|
||||
- [ ] All tests pass (`php artisan test --filter=SettingsTest`)
|
||||
- [ ] Code formatted with Pint
|
||||
|
||||
## Estimation
|
||||
**Complexity:** Medium | **Effort:** 3-4 hours
|
||||
|
|
|
|||
|
|
@ -8,39 +8,73 @@ As an **admin**,
|
|||
I want **to edit Terms of Service and Privacy Policy pages**,
|
||||
So that **I can maintain legal compliance and update policies**.
|
||||
|
||||
## Dependencies
|
||||
- **Epic 1:** Base authentication and admin middleware
|
||||
- **Story 6.8:** System Settings (admin settings UI patterns and navigation)
|
||||
|
||||
## References
|
||||
- **PRD Section 10.1:** Legal & Compliance - Required pages specification
|
||||
- **PRD Section 5.7H:** Settings - Terms of Service and Privacy Policy editor requirements
|
||||
- **PRD Section 9.3:** User Privacy - Terms and Privacy page requirements
|
||||
- **PRD Section 16.3:** Third-party dependencies - Rich text editor options (TinyMCE or Quill)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Pages to Edit
|
||||
- [ ] Terms of Service
|
||||
- [ ] Privacy Policy
|
||||
- [ ] Terms of Service (`/page/terms`)
|
||||
- [ ] Privacy Policy (`/page/privacy`)
|
||||
|
||||
### Editor Features
|
||||
- [ ] Rich text editor
|
||||
- [ ] Bilingual content (Arabic/English)
|
||||
- [ ] Save and publish
|
||||
- [ ] Preview before publishing
|
||||
- [ ] Rich text editor using Quill.js (lightweight, RTL-friendly)
|
||||
- [ ] Bilingual content with Arabic/English tabs in editor UI
|
||||
- [ ] Save and publish button updates database immediately
|
||||
- [ ] Preview opens modal showing rendered content in selected language
|
||||
- [ ] HTML content sanitized before save (prevent XSS)
|
||||
|
||||
### Admin UI Location
|
||||
- [ ] Accessible under Admin Dashboard > Settings section
|
||||
- [ ] Sidebar item: "Legal Pages" or integrate into Settings page
|
||||
- [ ] List view showing both pages with "Edit" action
|
||||
- [ ] Edit page shows language tabs (Arabic | English) above editor
|
||||
|
||||
### Public Display
|
||||
- [ ] Pages accessible from footer (public)
|
||||
- [ ] Last updated timestamp displayed
|
||||
- [ ] Pages accessible from footer links (no auth required)
|
||||
- [ ] Route: `/page/{slug}` where slug is `terms` or `privacy`
|
||||
- [ ] Content displayed in user's current language preference
|
||||
- [ ] Last updated timestamp displayed at bottom of page
|
||||
- [ ] Professional layout consistent with site design (navy/gold)
|
||||
|
||||
## Technical Notes
|
||||
|
||||
Store in database settings table or dedicated pages table.
|
||||
### Architecture
|
||||
This project uses **class-based Volt components** for interactivity. Follow existing patterns in `resources/views/livewire/`.
|
||||
|
||||
### Rich Text Editor
|
||||
Use **Quill.js** for the rich text editor:
|
||||
- Lightweight and RTL-compatible
|
||||
- Include via CDN or npm
|
||||
- Configure toolbar: bold, italic, underline, lists, links, headings
|
||||
- Bind to Livewire with `wire:model` on hidden textarea
|
||||
|
||||
### Model & Migration
|
||||
|
||||
```php
|
||||
// Migration
|
||||
// Migration: create_pages_table.php
|
||||
Schema::create('pages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('slug')->unique();
|
||||
$table->string('slug')->unique(); // 'terms', 'privacy'
|
||||
$table->string('title_ar');
|
||||
$table->string('title_en');
|
||||
$table->text('content_ar');
|
||||
$table->text('content_en');
|
||||
$table->timestamps();
|
||||
$table->longText('content_ar')->nullable();
|
||||
$table->longText('content_en')->nullable();
|
||||
$table->timestamps(); // updated_at used for "Last updated"
|
||||
});
|
||||
```
|
||||
|
||||
// Seeder
|
||||
### Seeder
|
||||
|
||||
```php
|
||||
// PageSeeder.php
|
||||
Page::create([
|
||||
'slug' => 'terms',
|
||||
'title_ar' => 'شروط الخدمة',
|
||||
|
|
@ -58,22 +92,221 @@ Page::create([
|
|||
]);
|
||||
```
|
||||
|
||||
### Public Route
|
||||
### Routes
|
||||
|
||||
```php
|
||||
Route::get('/page/{slug}', function (string $slug) {
|
||||
$page = Page::where('slug', $slug)->firstOrFail();
|
||||
return view('pages.show', compact('page'));
|
||||
})->name('page.show');
|
||||
// Public route (web.php)
|
||||
Route::get('/page/{slug}', [PageController::class, 'show'])
|
||||
->name('page.show')
|
||||
->where('slug', 'terms|privacy');
|
||||
|
||||
// Admin route (protected by admin middleware)
|
||||
Route::middleware(['auth', 'admin'])->prefix('admin')->group(function () {
|
||||
Route::get('/pages', PagesIndex::class)->name('admin.pages.index');
|
||||
Route::get('/pages/{slug}/edit', PagesEdit::class)->name('admin.pages.edit');
|
||||
});
|
||||
```
|
||||
|
||||
### Volt Component Structure
|
||||
|
||||
```php
|
||||
// resources/views/livewire/admin/pages/edit.blade.php
|
||||
<?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
|
||||
- [ ] Can edit Terms of Service
|
||||
- [ ] Can edit Privacy Policy
|
||||
- [ ] Bilingual content works
|
||||
- [ ] Preview works
|
||||
- [ ] Public pages accessible
|
||||
- [ ] Last updated shows
|
||||
- [ ] Tests pass
|
||||
- [ ] Page model and migration created
|
||||
- [ ] Seeder creates terms and privacy pages
|
||||
- [ ] Admin can access legal pages list from settings/sidebar
|
||||
- [ ] Admin can edit Terms of Service content (Arabic)
|
||||
- [ ] Admin can edit Terms of Service content (English)
|
||||
- [ ] Admin can edit Privacy Policy content (Arabic)
|
||||
- [ ] 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
|
||||
**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