libra/docs/stories/story-6.6-data-export-timel...

22 KiB

Story 6.6: Data Export - Timeline Reports

Note: The color values in this story were implemented with the original Navy+Gold palette. These colors were updated in Epic 10 (Brand Color Refresh) to the new Charcoal+Warm Gray palette. See docs/brand.md for current color specifications.

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 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 (handled by filter logic)
  • 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 (dispatches notification)
  • 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 (uses DejaVu Sans)

Authorization Tests

  • test_non_admin_cannot_access_timeline_export - 403 or redirect
  • test_guest_redirected_to_login - Redirect to login page

Definition of Done

  • Volt component created at resources/views/livewire/admin/timelines/export-timelines.blade.php (adjusted path for consistency)
  • PDF template created at resources/views/pdf/timelines-export.blade.php (adjusted path for consistency)
  • Route registered in admin routes
  • Navigation link added to admin timelines index page
  • 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 (26 tests)
  • 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


Dev Agent Record

Status

Ready for Review

Agent Model Used

Claude Opus 4.5

File List

Created:

  • resources/views/livewire/admin/timelines/export-timelines.blade.php - Volt component for timeline export
  • resources/views/pdf/timelines-export.blade.php - PDF template with Libra branding
  • tests/Feature/Admin/TimelineExportTest.php - 26 feature tests

Modified:

  • routes/web.php - Added export route at admin/timelines/export
  • lang/en/export.php - Added timeline export translation keys
  • lang/ar/export.php - Added Arabic timeline export translations
  • resources/views/livewire/admin/timelines/index.blade.php - Added export button

Completion Notes

  • All 26 tests pass (CSV export, PDF export, filtering, client search, authorization)
  • Follows established patterns from Story 6.4 and 6.5
  • Component location differs slightly from story spec (placed alongside other timeline components for consistency)
  • Export button added to timelines index page header
  • Include updates toggle limits to 10 updates per timeline in PDF to prevent memory issues
  • Memory issue during full test suite is pre-existing (occurs in users-export.blade.php, not this implementation)

Change Log

Date Change By
2025-12-27 Initial implementation complete Claude Opus 4.5

QA Results

Review Date: 2025-12-27

Reviewed By: Quinn (Test Architect)

Code Quality Assessment

Overall Rating: Excellent

The implementation demonstrates strong adherence to established patterns from Story 6.4 and 6.5. The code is well-structured, follows Laravel/Livewire best practices, and properly implements all acceptance criteria.

Strengths:

  • Consistent with established export component patterns (users, consultations)
  • Proper use of Volt class-based component architecture
  • Memory-efficient CSV export using cursor() for large datasets
  • Appropriate eager loading with selective columns (with('user:id,full_name'))
  • Proper handling of Timeline status enum throughout
  • Good RTL/LTR support for Arabic content in PDF
  • DejaVu Sans font for proper Arabic character rendering
  • UTF-8 BOM included for Excel Arabic compatibility
  • Well-implemented client search with dropdown functionality
  • Appropriate memory safeguards (10 updates limit per timeline in PDF, >500 warning)

Requirements Traceability

AC Test Coverage Given-When-Then
Export all timelines admin_can_export_all_timelines_csv, admin_can_export_all_timelines_pdf Given admin on export page, When no filters set, Then all timelines exported
Export for specific client admin_can_filter_by_client Given admin selects client, When export triggered, Then only that client's timelines included
Status filter admin_can_filter_by_status_active, admin_can_filter_by_status_archived Given admin selects status, When export triggered, Then only matching status exported
Date range filter admin_can_filter_by_date_range Given admin sets date range, When export triggered, Then only timelines in range exported
Client filter search client_search_returns_matching_clients, client_search_excludes_admin_users, client_search_requires_minimum_2_characters Given admin types in search, When >= 2 chars entered, Then matching clients shown excluding admins
Export includes all data fields timeline_without_updates_shows_zero_count, timeline_without_reference_shows_dash Given timeline with various data states, When exported, Then all fields correctly formatted
CSV with bilingual headers export_uses_admin_preferred_language_for_headers, export_uses_English_when_admin_prefers_English Given admin preference set, When export generated, Then headers match language
PDF with Libra branding admin_can_export_all_timelines_pdf Given admin exports PDF, When file generated, Then Navy/Gold branding present
Include updates toggle include_updates_toggle_adds_content_to_pdf Given includeUpdates enabled, When PDF exported, Then update entries included
UI filter controls preview_count_updates_when_filters_change, clear_filters_resets_all_filter_values Given filters applied, When count displayed, Then matches filtered records
Loading states ✓ Implemented with wire:loading Given export in progress, When button clicked, Then loading state shown
Empty results handling CSV_export_dispatches_notification_when_no_timelines_match, PDF_export_dispatches_notification_when_no_timelines_match Given no matching results, When export attempted, Then notification dispatched
Authorization non_admin_cannot_access_timeline_export, unauthenticated_user_cannot_access_timeline_export_page Given non-admin/guest, When accessing page, Then 403/redirect

Refactoring Performed

None required - implementation follows established patterns and best practices.

Compliance Check

  • Coding Standards: ✓ Pint passes with no issues
  • Project Structure: ✓ Component placed in admin/timelines/ alongside related components
  • Testing Strategy: ✓ 26 comprehensive Pest tests covering all scenarios
  • All ACs Met: ✓ All acceptance criteria have corresponding test coverage

Improvements Checklist

  • All acceptance criteria implemented
  • CSV export with cursor for memory efficiency
  • PDF export with memory limits (10 updates per timeline, 500 record warning)
  • Bilingual support (AR/EN headers and labels)
  • RTL/LTR direction handling in PDF
  • Export button added to timelines index page
  • Route properly registered
  • All translations added
  • Authorization tests included
  • Consider adding test for combined filter edge cases (all filters at once with specific values)
  • Consider adding test to verify CSV content structure (beyond just download success)

Security Review

Status: PASS

  • ✓ Admin middleware properly applied via route group
  • ✓ No SQL injection vulnerabilities (using Eloquent query builder with proper parameter binding)
  • ✓ Client search excludes admin users (prevents leaking admin data)
  • ✓ No sensitive data exposed in exports beyond authorized scope
  • ✓ CSRF protection via Livewire's built-in mechanisms

Performance Considerations

Status: PASS

  • ✓ CSV uses cursor() for memory-efficient large dataset handling
  • ✓ PDF limits updates to 10 per timeline to prevent memory exhaustion
  • ✓ Warning shown when exporting >500 records
  • ✓ Proper eager loading with selective columns (user:id,full_name)
  • ✓ Preview count uses COUNT query, not collection count
  • ✓ Client search limited to 10 results

Files Modified During Review

None - implementation is complete and correct as delivered.

Gate Status

Gate: PASSdocs/qa/gates/6.6-data-export-timeline-reports.yml

Ready for Done - All acceptance criteria met with comprehensive test coverage. Implementation follows established patterns and passes all quality checks.