This commit is contained in:
Saufi
2026-05-19 09:53:36 +08:00
parent f39eca4b1c
commit b0eec13d5b
22 changed files with 1166 additions and 238 deletions

View File

@@ -40,11 +40,16 @@ class ParticipantController extends Controller
$programParticipants = $query->paginate(20)->withQueryString();
$countRow = DB::table('program_participants')
->where('program_id', $program->id)
->selectRaw("COUNT(*) as total, SUM(is_pre_registered) as pre_registered, SUM(registration_source = 'walk_in') as walk_in, SUM(status = 'checked_in') as checked_in")
->first();
$counts = [
'total' => $program->programParticipants()->count(),
'pre_registered' => $program->programParticipants()->where('is_pre_registered', true)->count(),
'walk_in' => $program->programParticipants()->where('registration_source', 'walk_in')->count(),
'checked_in' => $program->programParticipants()->where('status', 'checked_in')->count(),
'total' => (int) ($countRow->total ?? 0),
'pre_registered' => (int) ($countRow->pre_registered ?? 0),
'walk_in' => (int) ($countRow->walk_in ?? 0),
'checked_in' => (int) ($countRow->checked_in ?? 0),
];
return view('admin.programs.participants.index', compact('program', 'programParticipants', 'counts'));

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
class ProfileController extends Controller
{
public function show(): View
{
return view('admin.profile.show', ['user' => auth()->user()]);
}
public function updateEmail(Request $request): RedirectResponse
{
$validator = \Validator::make($request->all(), [
'current_password' => ['required', 'current_password'],
'email' => ['required', 'email', 'max:255', 'unique:users,email,' . auth()->id()],
], [
'current_password.current_password' => 'Kata laluan semasa tidak betul.',
'email.unique' => 'Alamat emel ini sudah digunakan.',
]);
if ($validator->fails()) {
return back()->withErrors($validator, 'email')->withInput();
}
auth()->user()->update(['email' => $request->email]);
return back()->with('email_success', 'Alamat emel berjaya dikemaskini.');
}
public function updatePassword(Request $request): RedirectResponse
{
$validator = \Validator::make($request->all(), [
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(8)],
], [
'current_password.current_password' => 'Kata laluan semasa tidak betul.',
'password.min' => 'Kata laluan baru mestilah sekurang-kurangnya 8 aksara.',
'password.confirmed' => 'Pengesahan kata laluan tidak sepadan.',
]);
if ($validator->fails()) {
return back()->withErrors($validator, 'password')->withInput();
}
auth()->user()->update(['password' => Hash::make($request->password)]);
Auth::login(auth()->user());
return back()->with('password_success', 'Kata laluan berjaya ditukar.');
}
}

View File

@@ -70,13 +70,24 @@ class ProgramController extends Controller
'questionnaire.questionnaireSet.questions',
]);
// Consolidate into 2 queries instead of 6 separate COUNTs
$ppStats = \DB::table('program_participants')
->where('program_id', $program->id)
->selectRaw("COUNT(*) as total, SUM(is_pre_registered) as pre_registered, SUM(registration_source = 'walk_in') as walk_in")
->first();
$certStats = \DB::table('certificates')
->where('program_id', $program->id)
->selectRaw("COUNT(*) as total, SUM(status IN ('generated','emailed','downloaded')) as cert_generated")
->first();
$stats = [
'total_participants' => $program->programParticipants()->count(),
'pre_registered' => $program->programParticipants()->where('is_pre_registered', true)->count(),
'walk_in' => $program->programParticipants()->where('registration_source', 'walk_in')->count(),
'total_attendances' => $program->attendances()->count(),
'total_certificates' => $program->certificates()->count(),
'generated_certificates'=> $program->certificates()->whereIn('status', ['generated', 'emailed', 'downloaded'])->count(),
'total_participants' => (int) ($ppStats->total ?? 0),
'pre_registered' => (int) ($ppStats->pre_registered ?? 0),
'walk_in' => (int) ($ppStats->walk_in ?? 0),
'total_attendances' => $program->attendances()->count(),
'total_certificates' => (int) ($certStats->total ?? 0),
'generated_certificates' => (int) ($certStats->cert_generated ?? 0),
];
return view('admin.programs.show', compact('program', 'stats'));

View File

@@ -69,6 +69,23 @@ class ProgramQuestionnaireController extends Controller
return back()->with('success', 'Soalselidik telah disahkan untuk program ini.');
}
public function preview(Program $program): View|\Illuminate\Http\RedirectResponse
{
$pq = $program->questionnaire()->with('questionnaireSet')->first();
if (! $pq || ! $pq->questionnaireSet) {
return back()->with('error', 'Tiada soalselidik untuk dipratonton.');
}
$questions = $pq->questionnaireSet->questions()
->whereNull('parent_id')
->with(['children' => fn($q) => $q->orderBy('sort_order')])
->orderBy('sort_order')
->get();
return view('admin.programs.questionnaire.preview', compact('program', 'pq', 'questions'));
}
public function detach(Program $program): RedirectResponse
{
$pq = $program->questionnaire;

View File

@@ -13,26 +13,58 @@ class QuestionController extends Controller
{
public function store(Request $request, QuestionnaireSet $set): RedirectResponse
{
if ($request->has('options')) {
$request->merge(['options' => array_values(array_filter($request->input('options', [])))]);
}
$data = $request->validate([
'question_text' => 'required|string|max:1000',
'question_type' => 'required|in:rating,single_choice,multiple_choice,short_text,long_text',
'is_required' => 'boolean',
'options' => 'nullable|array',
'options.*' => 'required|string|max:255',
'question_text' => 'required|string|max:1000',
'question_type' => 'required|in:tajuk,rating,single_choice,multiple_choice,short_text,long_text',
'is_required' => 'boolean',
'parent_id' => 'nullable|integer|exists:questionnaire_questions,id',
'options' => 'nullable|array',
'options.*' => 'required|string|max:255',
'rating_labels' => 'nullable|array',
'rating_labels.*' => 'nullable|string|max:100',
]);
if ($data['question_type'] === 'rating') {
if (empty($data['parent_id'])) {
return back()->withErrors(['parent_id' => 'Soalan rating mesti diletakkan di bawah tajuk.'])->withInput();
}
$parent = QuestionnaireQuestion::find($data['parent_id']);
if (! $parent || $parent->question_type !== 'tajuk') {
return back()->withErrors(['parent_id' => 'Parent mesti jenis Tajuk.'])->withInput();
}
}
$needsOptions = in_array($data['question_type'], ['single_choice', 'multiple_choice']);
if ($needsOptions && empty($data['options'])) {
return back()->withErrors(['options' => 'Pilihan jawapan diperlukan untuk jenis soalan ini.'])->withInput();
}
$maxOrder = $set->questions()->max('sort_order') ?? 0;
$parentId = $data['question_type'] === 'rating' ? ($data['parent_id'] ?? null) : null;
$maxOrder = $set->questions()
->when($parentId,
fn($q) => $q->where('parent_id', $parentId),
fn($q) => $q->whereNull('parent_id')
)
->max('sort_order') ?? 0;
$ratingLabels = null;
if ($data['question_type'] === 'tajuk') {
$filtered = array_filter($data['rating_labels'] ?? [], fn($v) => $v !== null && $v !== '');
$ratingLabels = ! empty($filtered) ? $filtered : null;
}
$set->questions()->create([
'question_text' => $data['question_text'],
'question_type' => $data['question_type'],
'is_required' => $data['is_required'] ?? true,
'parent_id' => $parentId,
'is_required' => $data['question_type'] === 'tajuk' ? false : ($data['is_required'] ?? true),
'options_json' => $needsOptions ? array_values(array_filter($data['options'] ?? [])) : null,
'rating_labels' => $ratingLabels,
'sort_order' => $maxOrder + 1,
]);
@@ -42,21 +74,48 @@ class QuestionController extends Controller
public function update(Request $request, QuestionnaireQuestion $question): RedirectResponse
{
if ($request->has('options')) {
$request->merge(['options' => array_values(array_filter($request->input('options', [])))]);
}
$data = $request->validate([
'question_text' => 'required|string|max:1000',
'question_type' => 'required|in:rating,single_choice,multiple_choice,short_text,long_text',
'is_required' => 'boolean',
'options' => 'nullable|array',
'options.*' => 'required|string|max:255',
'question_text' => 'required|string|max:1000',
'question_type' => 'required|in:tajuk,rating,single_choice,multiple_choice,short_text,long_text',
'is_required' => 'boolean',
'parent_id' => 'nullable|integer|exists:questionnaire_questions,id',
'options' => 'nullable|array',
'options.*' => 'required|string|max:255',
'rating_labels' => 'nullable|array',
'rating_labels.*' => 'nullable|string|max:100',
]);
if ($data['question_type'] === 'rating') {
if (empty($data['parent_id'])) {
return back()->withErrors(['parent_id' => 'Soalan rating mesti diletakkan di bawah tajuk.'])->withInput();
}
$parent = QuestionnaireQuestion::find($data['parent_id']);
if (! $parent || $parent->question_type !== 'tajuk') {
return back()->withErrors(['parent_id' => 'Parent mesti jenis Tajuk.'])->withInput();
}
}
$needsOptions = in_array($data['question_type'], ['single_choice', 'multiple_choice']);
$parentId = $data['question_type'] === 'rating' ? ($data['parent_id'] ?? null) : null;
$ratingLabels = null;
if ($data['question_type'] === 'tajuk') {
$filtered = array_filter($data['rating_labels'] ?? [], fn($v) => $v !== null && $v !== '');
$ratingLabels = ! empty($filtered) ? $filtered : null;
}
$question->update([
'question_text' => $data['question_text'],
'question_type' => $data['question_type'],
'is_required' => $data['is_required'] ?? true,
'parent_id' => $parentId,
'is_required' => $data['question_type'] === 'tajuk' ? false : ($data['is_required'] ?? true),
'options_json' => $needsOptions ? array_values(array_filter($data['options'] ?? [])) : null,
'rating_labels' => $ratingLabels,
]);
return redirect()->route('admin.questionnaires.show', $question->questionnaire_set_id)
@@ -66,6 +125,12 @@ class QuestionController extends Controller
public function destroy(QuestionnaireQuestion $question): RedirectResponse
{
$setId = $question->questionnaire_set_id;
// Cascade-delete children if this is a tajuk (DB cascade handles it too, but be explicit)
if ($question->question_type === 'tajuk') {
$question->children()->delete();
}
$question->delete();
return redirect()->route('admin.questionnaires.show', $setId)
@@ -75,8 +140,9 @@ class QuestionController extends Controller
public function reorder(Request $request): JsonResponse
{
$data = $request->validate([
'order' => 'required|array',
'order.*' => 'integer|exists:questionnaire_questions,id',
'order' => 'required|array',
'order.*' => 'integer|exists:questionnaire_questions,id',
'parent_id' => 'nullable|integer|exists:questionnaire_questions,id',
]);
foreach ($data['order'] as $sortOrder => $questionId) {

View File

@@ -52,10 +52,18 @@ class QuestionnaireSetController extends Controller
public function show(QuestionnaireSet $set): View
{
$set->load(['questions', 'creator']);
$set->load('creator');
$topLevel = $set->questions()
->whereNull('parent_id')
->with(['children' => fn($q) => $q->orderBy('sort_order')])
->orderBy('sort_order')
->get();
$totalCount = $set->questions()->count();
$usedInPrograms = $set->programs()->get();
return view('admin.questionnaires.show', compact('set', 'usedInPrograms'));
return view('admin.questionnaires.show', compact('set', 'topLevel', 'totalCount', 'usedInPrograms'));
}
public function edit(QuestionnaireSet $set): View

View File

@@ -17,7 +17,7 @@ class StatisticsController extends Controller
{
public function show(Program $program): View
{
$program->load(['attendances.participant', 'questionnaire.questionnaireSet.questions']);
$program->load(['questionnaire.questionnaireSet.questions']);
// Attendance by session
$bySession = $program->attendances()
@@ -40,23 +40,29 @@ class StatisticsController extends Controller
->pluck('total', 'status')
->toArray();
// Response rate
// Response rate + question stats
$pq = $program->questionnaire;
$responseRate = null;
$questionStats = [];
$totalResponses = 0;
if ($pq && $pq->is_confirmed) {
$totalAttended = $program->attendances()->count();
$totalAttended = array_sum($bySession); // reuse already-fetched data
$totalResponses = QuestionnaireResponse::where('program_id', $program->id)->count();
$responseRate = $totalAttended > 0 ? round($totalResponses / $totalAttended * 100) : 0;
// Rating question averages
$questions = $pq->questionnaireSet->questions ?? collect();
// Load ALL answers in one query, group by question — avoids N+1
$allAnswers = QuestionnaireAnswer::whereIn('questionnaire_question_id', $questions->pluck('id'))
->get()
->groupBy('questionnaire_question_id');
foreach ($questions as $q) {
$answers = $allAnswers->get($q->id, collect());
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);
$values = $answers->map(fn($a) => is_array($a->answer_value) ? (int) ($a->answer_value[0] ?? 0) : (int) $a->answer_value);
$questionStats[] = [
'id' => $q->id,
'text' => $q->question_text,
@@ -65,11 +71,9 @@ class StatisticsController extends Controller
'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 ($answers as $row) {
$items = is_array($row->answer_value) ? $row->answer_value : [$row->answer_value];
foreach ($items as $item) {
$counts[$item] = ($counts[$item] ?? 0) + 1;
}
@@ -86,14 +90,15 @@ class StatisticsController extends Controller
}
}
// Reuse data already computed above — no extra queries
$summary = [
'total_attendances' => $program->attendances()->count(),
'pre_registered' => $program->attendances()->where('attendance_source', 'pre_registered_staff')->count(),
'walk_in' => $program->attendances()->where('attendance_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(),
'total_attendances' => array_sum($bySession),
'pre_registered' => $bySource['pre_registered_staff'] ?? 0,
'walk_in' => $bySource['walk_in_external'] ?? 0,
'total_certificates' => array_sum($certStats),
'generated_certs' => ($certStats['generated'] ?? 0) + ($certStats['emailed'] ?? 0) + ($certStats['downloaded'] ?? 0),
'downloaded_certs' => $certStats['downloaded'] ?? 0,
'total_responses' => $totalResponses,
];
return view('admin.programs.statistics.show', compact(