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(

View File

@@ -9,6 +9,7 @@ use App\Models\QuestionnaireResponse;
use App\Models\QuestionnaireAnswer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\View\View;
class QuestionnaireController extends Controller
@@ -20,27 +21,20 @@ class QuestionnaireController extends Controller
$participant = Participant::where('uuid', $participant_uuid)->firstOrFail();
// Verify participant belongs to this program
$pp = $program->programParticipants()->where('participant_id', $participant->id)->first();
abort_if(! $pp, 404);
$pq = $program->questionnaire()->with('questionnaireSet.questions')->first();
$pq = $program->questionnaire()->with('questionnaireSet')->first();
if (! $pq || ! $pq->is_confirmed) {
// No questionnaire — go straight to semak page
return redirect()->route('public.semak.show', $qr_token);
}
// Check already submitted
$alreadySubmitted = QuestionnaireResponse::where('program_id', $program->id)
->where('participant_id', $participant->id)
->exists();
if ($alreadySubmitted) {
if (QuestionnaireResponse::where('program_id', $program->id)->where('participant_id', $participant->id)->exists()) {
return view('public.questionnaire.already', compact('program', 'participant', 'qrCode'));
}
$questions = $pq->questionnaireSet->questions;
$questions = $this->loadHierarchical($pq);
return view('public.questionnaire.show', compact('program', 'participant', 'qrCode', 'pq', 'questions'));
}
@@ -55,38 +49,27 @@ class QuestionnaireController extends Controller
$pp = $program->programParticipants()->where('participant_id', $participant->id)->first();
abort_if(! $pp, 404);
$pq = $program->questionnaire()->with('questionnaireSet.questions')->first();
$pq = $program->questionnaire()->with('questionnaireSet')->first();
abort_if(! $pq || ! $pq->is_confirmed, 404);
// Prevent double-submit
$existing = QuestionnaireResponse::where('program_id', $program->id)
->where('participant_id', $participant->id)
->first();
if ($existing) {
if (QuestionnaireResponse::where('program_id', $program->id)->where('participant_id', $participant->id)->exists()) {
return view('public.questionnaire.already', compact('program', 'participant', 'qrCode'));
}
$questions = $pq->questionnaireSet->questions;
$questions = $this->loadHierarchical($pq);
$answerable = $this->flatten($questions);
// Validate required questions
$rules = [];
foreach ($questions as $q) {
if ($q->is_required) {
$rules['q_' . $q->id] = 'required';
} else {
$rules['q_' . $q->id] = 'nullable';
}
foreach ($answerable as $q) {
if ($q->question_type === 'multiple_choice') {
$rules['q_' . $q->id] = ($q->is_required ? 'required|' : 'nullable|') . 'array';
} else {
$rules['q_' . $q->id] = $q->is_required ? 'required' : 'nullable';
}
}
$validated = $request->validate($rules, [
'q_*.required' => 'Soalan ini wajib dijawab.',
]);
$request->validate($rules, ['q_*.required' => 'Soalan ini wajib dijawab.']);
// Save response
$response = QuestionnaireResponse::create([
'program_id' => $program->id,
'participant_id' => $participant->id,
@@ -96,7 +79,7 @@ class QuestionnaireController extends Controller
'user_agent' => substr($request->userAgent() ?? '', 0, 500),
]);
foreach ($questions as $q) {
foreach ($answerable as $q) {
$raw = $request->input('q_' . $q->id);
if ($raw === null && ! $q->is_required) {
@@ -110,12 +93,39 @@ class QuestionnaireController extends Controller
};
QuestionnaireAnswer::create([
'questionnaire_response_id' => $response->id,
'questionnaire_question_id' => $q->id,
'answer_value' => $value,
'questionnaire_response_id' => $response->id,
'questionnaire_question_id' => $q->id,
'answer_value' => $value,
]);
}
return view('public.questionnaire.thankyou', compact('program', 'participant', 'qrCode'));
}
// ── Helpers ──────────────────────────────────────────────────────────────
private function loadHierarchical($pq): Collection
{
return $pq->questionnaireSet->questions()
->whereNull('parent_id')
->with(['children' => fn($q) => $q->orderBy('sort_order')])
->orderBy('sort_order')
->get();
}
/** Return only answerable (non-tajuk) questions as a flat collection. */
private function flatten(Collection $topLevel): Collection
{
$out = collect();
foreach ($topLevel as $q) {
if ($q->question_type === 'tajuk') {
foreach ($q->children as $child) {
$out->push($child);
}
} else {
$out->push($q);
}
}
return $out;
}
}

View File

@@ -7,16 +7,17 @@ use Illuminate\Database\Eloquent\Model;
class QuestionnaireQuestion extends Model
{
protected $fillable = [
'questionnaire_set_id', 'question_text', 'question_type',
'options_json', 'is_required', 'sort_order',
'questionnaire_set_id', 'parent_id', 'question_text', 'question_type',
'options_json', 'rating_labels', 'is_required', 'sort_order',
];
protected function casts(): array
{
return [
'options_json' => 'array',
'is_required' => 'boolean',
'sort_order' => 'integer',
'options_json' => 'array',
'rating_labels' => 'array',
'is_required' => 'boolean',
'sort_order' => 'integer',
];
}
@@ -25,6 +26,16 @@ class QuestionnaireQuestion extends Model
return $this->belongsTo(QuestionnaireSet::class);
}
public function parent()
{
return $this->belongsTo(QuestionnaireQuestion::class, 'parent_id');
}
public function children()
{
return $this->hasMany(QuestionnaireQuestion::class, 'parent_id')->orderBy('sort_order');
}
public function answers()
{
return $this->hasMany(QuestionnaireAnswer::class);