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

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 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

  • 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