From 165f22fe6f465ba4b4e27cdb63964d896990c762 Mon Sep 17 00:00:00 2001 From: Saufi Date: Sat, 16 May 2026 22:54:34 +0800 Subject: [PATCH] feat: per-program statistics dashboard (Fasa 9) - StatisticsController: attendance by session/source, cert status, response rate, question averages - Statistics export as CSV - Chart.js visualisations: bar (session), doughnut (source), progress bars (cert status, ratings) Co-Authored-By: Claude Sonnet 4.6 --- .../Admin/StatisticsController.php | 122 +++++++++- .../admin/programs/statistics/show.blade.php | 218 ++++++++++++++++++ 2 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 resources/views/admin/programs/statistics/show.blade.php diff --git a/app/Http/Controllers/Admin/StatisticsController.php b/app/Http/Controllers/Admin/StatisticsController.php index cb666be..8df7fa1 100644 --- a/app/Http/Controllers/Admin/StatisticsController.php +++ b/app/Http/Controllers/Admin/StatisticsController.php @@ -3,9 +3,129 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Models\Program; +use App\Models\Attendance; +use App\Models\Certificate; +use App\Models\QuestionnaireAnswer; +use App\Models\QuestionnaireQuestion; +use App\Models\QuestionnaireResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Illuminate\View\View; class StatisticsController extends Controller { - // + public function show(Program $program): View + { + $program->load(['attendances.participant', 'questionnaire.questionnaireSet.questions']); + + // Attendance by session + $bySession = $program->attendances() + ->selectRaw('attendance_session, COUNT(*) as total') + ->groupBy('attendance_session') + ->pluck('total', 'attendance_session') + ->toArray(); + + // Attendance by source + $bySource = $program->attendances() + ->selectRaw('source, COUNT(*) as total') + ->groupBy('source') + ->pluck('total', 'source') + ->toArray(); + + // Certificate status breakdown + $certStats = $program->certificates() + ->selectRaw('status, COUNT(*) as total') + ->groupBy('status') + ->pluck('total', 'status') + ->toArray(); + + // Response rate + $pq = $program->questionnaire; + $responseRate = null; + $questionStats = []; + + if ($pq && $pq->is_confirmed) { + $totalAttended = $program->attendances()->count(); + $totalResponses = QuestionnaireResponse::where('program_id', $program->id)->count(); + $responseRate = $totalAttended > 0 ? round($totalResponses / $totalAttended * 100) : 0; + + // Rating question averages + $questions = $pq->questionnaireSet->questions ?? collect(); + foreach ($questions as $q) { + if ($q->question_type === 'rating') { + $answers = QuestionnaireAnswer::where('questionnaire_question_id', $q->id) + ->pluck('answer_value'); + $values = $answers->map(fn($v) => is_array($v) ? (int)($v[0] ?? 0) : (int)$v); + $questionStats[] = [ + 'id' => $q->id, + 'text' => $q->question_text, + 'type' => 'rating', + 'average' => $values->count() > 0 ? round($values->avg(), 2) : null, + 'count' => $values->count(), + ]; + } elseif (in_array($q->question_type, ['single_choice', 'multiple_choice'])) { + $answers = QuestionnaireAnswer::where('questionnaire_question_id', $q->id) + ->pluck('answer_value'); + $counts = []; + foreach ($answers as $val) { + $items = is_array($val) ? $val : [$val]; + foreach ($items as $item) { + $counts[$item] = ($counts[$item] ?? 0) + 1; + } + } + $questionStats[] = [ + 'id' => $q->id, + 'text' => $q->question_text, + 'type' => $q->question_type, + 'options' => $q->options_json ?? [], + 'counts' => $counts, + 'total' => $answers->count(), + ]; + } + } + } + + $summary = [ + 'total_attendances' => $program->attendances()->count(), + 'pre_registered' => $program->attendances()->where('source', 'pre_registered_staff')->count(), + 'walk_in' => $program->attendances()->where('source', 'walk_in_external')->count(), + 'total_certificates' => $program->certificates()->count(), + 'generated_certs' => $program->certificates()->whereIn('status', ['generated', 'emailed', 'downloaded'])->count(), + 'downloaded_certs' => $program->certificates()->where('status', 'downloaded')->count(), + 'total_responses' => QuestionnaireResponse::where('program_id', $program->id)->count(), + ]; + + return view('admin.programs.statistics.show', compact( + 'program', 'summary', 'bySession', 'bySource', + 'certStats', 'responseRate', 'questionStats' + )); + } + + public function export(Program $program): Response + { + $rows = $program->attendances() + ->with('participant') + ->get() + ->map(fn($a) => [ + $a->participant->name, + $a->participant->agency ?: '', + $a->attendance_session, + $a->source, + $a->checked_in_at->format('d/m/Y H:i'), + ]); + + $csv = "\xEF\xBB\xBF"; + $csv .= implode(',', ['Nama', 'Agensi', 'Sesi', 'Sumber', 'Masa Check-In']) . "\n"; + foreach ($rows as $row) { + $csv .= implode(',', array_map(fn($v) => '"' . str_replace('"', '""', $v) . '"', $row)) . "\n"; + } + + $filename = 'statistik-' . str($program->title)->slug() . '-' . now()->format('Ymd') . '.csv'; + + return response($csv, 200, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ]); + } } diff --git a/resources/views/admin/programs/statistics/show.blade.php b/resources/views/admin/programs/statistics/show.blade.php new file mode 100644 index 0000000..01d8e47 --- /dev/null +++ b/resources/views/admin/programs/statistics/show.blade.php @@ -0,0 +1,218 @@ +@extends('layouts.admin') + +@section('title', 'Statistik — ' . $program->title) +@section('header', 'Statistik Program') + +@section('breadcrumb') + + + +@endsection + +@section('header-actions') + +@endsection + +@section('content') + +{{-- Summary Cards --}} +
+
+
+
{{ $summary['total_attendances'] }}
+
Jumlah Hadir
+
+
+
+
+
{{ $summary['generated_certs'] }}
+
Sijil Dijana
+
+
+
+
+
{{ $summary['downloaded_certs'] }}
+
Sijil Dimuat Turun
+
+
+
+
+
{{ $summary['total_responses'] }}
+
Respons Soalselidik
+
+
+
+ +
+ + {{-- Attendance by Session --}} +
+
+
+
Kehadiran Mengikut Sesi
+
+
+ @if(! empty($bySession)) + + @else +
Tiada data kehadiran.
+ @endif +
+
+
+ + {{-- Attendance by Source --}} +
+
+
+
Kehadiran Mengikut Jenis
+
+
+ @if(! empty($bySource)) + + @else +
Tiada data kehadiran.
+ @endif +
+
+
+ + {{-- Certificate Status --}} +
+
+
+
Status Sijil
+
+
+ @if(! empty($certStats)) + + @php + $labels = ['pending' => 'Menunggu', 'generating' => 'Sedang Jana', 'generated' => 'Dijana', 'emailed' => 'Diemailkan', 'downloaded' => 'Dimuat Turun', 'failed' => 'Gagal']; + $colors = ['pending' => 'warning', 'generating' => 'secondary', 'generated' => 'success', 'emailed' => 'info', 'downloaded' => 'primary', 'failed' => 'danger']; + @endphp + @foreach($certStats as $status => $count) + + + + + + @endforeach +
{{ $labels[$status] ?? $status }}{{ $count }} +
+
+
+
+ @else +
Tiada sijil dijana lagi.
+ @endif +
+
+
+ + {{-- Response Rate --}} + @if($responseRate !== null) +
+
+
+
Kadar Respons Soalselidik
+
+
+
{{ $responseRate }}%
+
{{ $summary['total_responses'] }} daripada {{ $summary['total_attendances'] }} peserta hadir
+
+
+
+
+
+
+ @endif + + {{-- Question Stats --}} + @foreach($questionStats as $qs) +
+
+
+
{{ $qs['text'] }}
+
+
+ @if($qs['type'] === 'rating') +
+
{{ $qs['average'] ?? '—' }}
+
Purata daripada {{ $qs['count'] }} respons
+ @php $pct = $qs['average'] ? round($qs['average'] / 5 * 100) : 0; @endphp +
+
+
+
{{ $pct }}% daripada 5
+
+ @elseif(in_array($qs['type'], ['single_choice', 'multiple_choice'])) + + @foreach($qs['options'] as $opt) + @php $c = $qs['counts'][$opt] ?? 0; $pct = $qs['total'] > 0 ? round($c / $qs['total'] * 100) : 0; @endphp + + + + + + + @endforeach +
{{ $opt }}{{ $c }} +
+
+
+
{{ $pct }}%
+ @endif +
+
+
+ @endforeach + +
+ +@endsection + +@push('scripts') + +@endpush