15 KiB
15 KiB
Story 6.6: Data Export - Timeline Reports
Epic Reference
Epic 6: Admin Dashboard
User Story
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 (client selector/search)
Filters
- Status filter (active/archived/all)
- Date range filter (created_at)
- Client filter (search by name/email)
Export Includes
- Case name and reference
- Client name
- Status
- Created date
- Number of updates
- Last update date
Formats
- CSV format with bilingual headers
- PDF format with Libra branding
Optional Features
- Include update content toggle (full content vs summary only)
- When enabled, PDF includes all update entries per timeline
UI Requirements
- Filter controls match Story 6.4/6.5 layout
- Export buttons clearly visible
- Loading state during export generation
- Success/error feedback messages
Technical Notes
File Structure
Routes:
GET /admin/exports/timelines -> admin.exports.timelines (Volt page)
Files to Create:
resources/views/livewire/pages/admin/exports/timelines.blade.php (Volt component)
resources/views/exports/timelines-pdf.blade.php (PDF template)
Models Required (from Epic 4):
app/Models/Timeline.php
app/Models/TimelineUpdate.php
Database Schema Reference
// 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
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'),
]);
$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
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
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
{{-- 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
// 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
includeUpdatesis 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 controlstest_admin_can_export_all_timelines_csv- CSV downloads with all timelinestest_admin_can_export_all_timelines_pdf- PDF downloads with brandingtest_admin_can_filter_by_client- Only selected client's timelines exportedtest_admin_can_filter_by_status_active- Only active timelines exportedtest_admin_can_filter_by_status_archived- Only archived timelines exportedtest_admin_can_filter_by_date_range- Timelines within range exportedtest_include_updates_toggle_adds_content_to_pdf- Update text appears in PDFtest_csv_headers_match_admin_language- AR/EN headers based on locale
Validation Tests
test_date_from_cannot_be_after_date_to- Validation error showntest_client_filter_only_shows_individual_and_company_users- Admin users excluded
Edge Case Tests
test_export_empty_results_returns_valid_csv- Empty CSV with headerstest_export_empty_results_returns_valid_pdf- PDF with "no records" messagetest_timeline_without_updates_shows_zero_count- updates_count = 0test_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 redirecttest_guest_redirected_to_login- Redirect to login page
Definition of Done
- 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