libra/docs/stories/story-6.7-monthly-statistic...

20 KiB

Story 6.7: Monthly Statistics Report

Epic Reference

Epic 6: Admin Dashboard

User Story

As an admin, 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

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 Sections

1. Cover Page

  • Libra logo and branding
  • Report title: "Monthly Statistics Report"
  • Period: Month and Year (e.g., "December 2025")
  • Generated date and time

2. Table of Contents (Visual List)

  • List of sections with page numbers
  • Non-clickable (simple text list for print compatibility)

3. Executive Summary

  • Key highlights (2-3 bullet points)
  • Month-over-month comparison if prior month data exists

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

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

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

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->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');

        $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

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

  • 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