reviewed epic 5 & 6 stories
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user