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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 . '"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
218
resources/views/admin/programs/statistics/show.blade.php
Normal file
218
resources/views/admin/programs/statistics/show.blade.php
Normal file
@@ -0,0 +1,218 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Statistik — ' . $program->title)
|
||||
@section('header', 'Statistik Program')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.programs.index') }}">Program</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.programs.show', $program) }}">{{ Str::limit($program->title, 30) }}</a></li>
|
||||
<li class="breadcrumb-item active">Statistik</li>
|
||||
@endsection
|
||||
|
||||
@section('header-actions')
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ route('admin.programs.statistics.export', $program) }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-download me-1"></i> Export CSV
|
||||
</a>
|
||||
<a href="{{ route('admin.programs.show', $program) }}#tab-statistics" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i> Kembali
|
||||
</a>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- Summary Cards --}}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 bg-primary bg-opacity-10 text-center p-3">
|
||||
<div class="fs-2 fw-bold text-primary">{{ $summary['total_attendances'] }}</div>
|
||||
<div class="small text-muted">Jumlah Hadir</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 bg-success bg-opacity-10 text-center p-3">
|
||||
<div class="fs-2 fw-bold text-success">{{ $summary['generated_certs'] }}</div>
|
||||
<div class="small text-muted">Sijil Dijana</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 bg-warning bg-opacity-10 text-center p-3">
|
||||
<div class="fs-2 fw-bold text-warning">{{ $summary['downloaded_certs'] }}</div>
|
||||
<div class="small text-muted">Sijil Dimuat Turun</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card border-0 bg-info bg-opacity-10 text-center p-3">
|
||||
<div class="fs-2 fw-bold text-info">{{ $summary['total_responses'] }}</div>
|
||||
<div class="small text-muted">Respons Soalselidik</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
{{-- Attendance by Session --}}
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h6 class="mb-0 fw-semibold"><i class="bi bi-bar-chart me-2 text-primary"></i>Kehadiran Mengikut Sesi</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if(! empty($bySession))
|
||||
<canvas id="sessionChart" height="200"></canvas>
|
||||
@else
|
||||
<div class="text-center py-4 text-muted small">Tiada data kehadiran.</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Attendance by Source --}}
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h6 class="mb-0 fw-semibold"><i class="bi bi-pie-chart me-2 text-success"></i>Kehadiran Mengikut Jenis</h6>
|
||||
</div>
|
||||
<div class="card-body d-flex align-items-center justify-content-center">
|
||||
@if(! empty($bySource))
|
||||
<canvas id="sourceChart" height="200"></canvas>
|
||||
@else
|
||||
<div class="text-center py-4 text-muted small">Tiada data kehadiran.</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Certificate Status --}}
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h6 class="mb-0 fw-semibold"><i class="bi bi-award me-2 text-warning"></i>Status Sijil</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if(! empty($certStats))
|
||||
<table class="table table-sm">
|
||||
@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)
|
||||
<tr>
|
||||
<td><span class="badge bg-{{ $colors[$status] ?? 'secondary' }}">{{ $labels[$status] ?? $status }}</span></td>
|
||||
<td class="fw-bold">{{ $count }}</td>
|
||||
<td class="w-50">
|
||||
<div class="progress" style="height:8px;">
|
||||
<div class="progress-bar bg-{{ $colors[$status] ?? 'secondary' }}"
|
||||
style="width:{{ $summary['total_certificates'] > 0 ? round($count / $summary['total_certificates'] * 100) : 0 }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
@else
|
||||
<div class="text-center py-4 text-muted small">Tiada sijil dijana lagi.</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Response Rate --}}
|
||||
@if($responseRate !== null)
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h6 class="mb-0 fw-semibold"><i class="bi bi-clipboard2-check me-2 text-info"></i>Kadar Respons Soalselidik</h6>
|
||||
</div>
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="display-4 fw-bold text-primary mb-1">{{ $responseRate }}%</div>
|
||||
<div class="text-muted small">{{ $summary['total_responses'] }} daripada {{ $summary['total_attendances'] }} peserta hadir</div>
|
||||
<div class="progress mt-3" style="height:12px;">
|
||||
<div class="progress-bar bg-primary" style="width:{{ $responseRate }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Question Stats --}}
|
||||
@foreach($questionStats as $qs)
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h6 class="mb-0 fw-semibold small">{{ $qs['text'] }}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if($qs['type'] === 'rating')
|
||||
<div class="text-center">
|
||||
<div class="display-6 fw-bold text-warning">{{ $qs['average'] ?? '—' }}</div>
|
||||
<div class="text-muted small">Purata daripada {{ $qs['count'] }} respons</div>
|
||||
@php $pct = $qs['average'] ? round($qs['average'] / 5 * 100) : 0; @endphp
|
||||
<div class="progress mt-2" style="height:10px;">
|
||||
<div class="progress-bar bg-warning" style="width:{{ $pct }}%"></div>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:0.75rem;">{{ $pct }}% daripada 5</div>
|
||||
</div>
|
||||
@elseif(in_array($qs['type'], ['single_choice', 'multiple_choice']))
|
||||
<table class="table table-sm mb-0">
|
||||
@foreach($qs['options'] as $opt)
|
||||
@php $c = $qs['counts'][$opt] ?? 0; $pct = $qs['total'] > 0 ? round($c / $qs['total'] * 100) : 0; @endphp
|
||||
<tr>
|
||||
<td class="small text-truncate" style="max-width:180px;">{{ $opt }}</td>
|
||||
<td class="text-end fw-bold small">{{ $c }}</td>
|
||||
<td class="w-40">
|
||||
<div class="progress" style="height:8px;">
|
||||
<div class="progress-bar bg-info" style="width:{{ $pct }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end small text-muted">{{ $pct }}%</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
@if(! empty($bySession))
|
||||
new Chart(document.getElementById('sessionChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: @json(array_map('ucfirst', array_keys($bySession))),
|
||||
datasets: [{
|
||||
label: 'Kehadiran',
|
||||
data: @json(array_values($bySession)),
|
||||
backgroundColor: 'rgba(26,86,160,0.7)',
|
||||
borderRadius: 4,
|
||||
}]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } } }
|
||||
});
|
||||
@endif
|
||||
|
||||
@if(! empty($bySource))
|
||||
const sourceLabels = {
|
||||
'pre_registered_staff': 'Kakitangan',
|
||||
'walk_in_external': 'Orang Luar',
|
||||
};
|
||||
new Chart(document.getElementById('sourceChart'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: @json(array_map(fn($k) => $sourceLabels[$k] ?? $k, array_keys($bySource))),
|
||||
datasets: [{
|
||||
data: @json(array_values($bySource)),
|
||||
backgroundColor: ['rgba(26,86,160,0.7)', 'rgba(34,197,94,0.7)'],
|
||||
}]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
|
||||
});
|
||||
@endif
|
||||
</script>
|
||||
@endpush
|
||||
Reference in New Issue
Block a user