20 KiB
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)
8. Trends Chart
- 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_languagesetting
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