From b0eec13d5bd7bd6a3c44b4eb69d8545279eab917 Mon Sep 17 00:00:00 2001 From: Saufi Date: Tue, 19 May 2026 09:53:36 +0800 Subject: [PATCH] first --- .dockerignore | 4 +- .../Admin/ParticipantController.php | 13 +- .../Controllers/Admin/ProfileController.php | 60 +++ .../Controllers/Admin/ProgramController.php | 23 +- .../Admin/ProgramQuestionnaireController.php | 17 + .../Controllers/Admin/QuestionController.php | 96 ++++- .../Admin/QuestionnaireSetController.php | 12 +- .../Admin/StatisticsController.php | 41 +- .../Public/QuestionnaireController.php | 76 ++-- app/Models/QuestionnaireQuestion.php | 21 +- ..._and_labels_to_questionnaire_questions.php | 29 ++ ...163447_add_tajuk_to_question_type_enum.php | 17 + database/seeders/AdminSeeder.php | 6 +- resources/views/admin/profile/show.blade.php | 137 +++++++ .../programs/questionnaire/preview.blade.php | 151 ++++++++ .../programs/questionnaire/show.blade.php | 73 ++-- .../views/admin/questionnaires/show.blade.php | 353 +++++++++++++++--- .../views/auth/forgot-password.blade.php | 90 +++-- resources/views/auth/reset-password.blade.php | 112 ++++-- resources/views/layouts/admin.blade.php | 6 + .../views/public/questionnaire/show.blade.php | 60 ++- routes/web.php | 7 + 22 files changed, 1166 insertions(+), 238 deletions(-) create mode 100644 app/Http/Controllers/Admin/ProfileController.php create mode 100644 database/migrations/2026_05_18_161355_add_parent_and_labels_to_questionnaire_questions.php create mode 100644 database/migrations/2026_05_18_163447_add_tajuk_to_question_type_enum.php create mode 100644 resources/views/admin/profile/show.blade.php create mode 100644 resources/views/admin/programs/questionnaire/preview.blade.php diff --git a/.dockerignore b/.dockerignore index b572ec2..70f101b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -29,9 +29,9 @@ public/build .phpunit.cache phpunit.xml -# Docker files (tidak perlu dalam app container) +# Docker Compose files (tidak perlu dalam app container) docker-compose*.yml -docker/ +# docker/ TIDAK diexclude — Dockerfile perlukan docker/entrypoint.sh dan docker/php/php.ini # Logs & cache storage/logs/* diff --git a/app/Http/Controllers/Admin/ParticipantController.php b/app/Http/Controllers/Admin/ParticipantController.php index 14edc40..c2f3ba5 100644 --- a/app/Http/Controllers/Admin/ParticipantController.php +++ b/app/Http/Controllers/Admin/ParticipantController.php @@ -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')); diff --git a/app/Http/Controllers/Admin/ProfileController.php b/app/Http/Controllers/Admin/ProfileController.php new file mode 100644 index 0000000..c68b425 --- /dev/null +++ b/app/Http/Controllers/Admin/ProfileController.php @@ -0,0 +1,60 @@ + 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.'); + } +} diff --git a/app/Http/Controllers/Admin/ProgramController.php b/app/Http/Controllers/Admin/ProgramController.php index f023cae..f5acba6 100644 --- a/app/Http/Controllers/Admin/ProgramController.php +++ b/app/Http/Controllers/Admin/ProgramController.php @@ -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')); diff --git a/app/Http/Controllers/Admin/ProgramQuestionnaireController.php b/app/Http/Controllers/Admin/ProgramQuestionnaireController.php index 7f78b1a..c24c2b2 100644 --- a/app/Http/Controllers/Admin/ProgramQuestionnaireController.php +++ b/app/Http/Controllers/Admin/ProgramQuestionnaireController.php @@ -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; diff --git a/app/Http/Controllers/Admin/QuestionController.php b/app/Http/Controllers/Admin/QuestionController.php index c4b339c..cdf215c 100644 --- a/app/Http/Controllers/Admin/QuestionController.php +++ b/app/Http/Controllers/Admin/QuestionController.php @@ -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) { diff --git a/app/Http/Controllers/Admin/QuestionnaireSetController.php b/app/Http/Controllers/Admin/QuestionnaireSetController.php index 5703c39..0baa321 100644 --- a/app/Http/Controllers/Admin/QuestionnaireSetController.php +++ b/app/Http/Controllers/Admin/QuestionnaireSetController.php @@ -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 diff --git a/app/Http/Controllers/Admin/StatisticsController.php b/app/Http/Controllers/Admin/StatisticsController.php index 606f07b..8ab841f 100644 --- a/app/Http/Controllers/Admin/StatisticsController.php +++ b/app/Http/Controllers/Admin/StatisticsController.php @@ -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( diff --git a/app/Http/Controllers/Public/QuestionnaireController.php b/app/Http/Controllers/Public/QuestionnaireController.php index eb0d5e1..21650af 100644 --- a/app/Http/Controllers/Public/QuestionnaireController.php +++ b/app/Http/Controllers/Public/QuestionnaireController.php @@ -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; + } } diff --git a/app/Models/QuestionnaireQuestion.php b/app/Models/QuestionnaireQuestion.php index 49c8f40..77f6b6c 100644 --- a/app/Models/QuestionnaireQuestion.php +++ b/app/Models/QuestionnaireQuestion.php @@ -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); diff --git a/database/migrations/2026_05_18_161355_add_parent_and_labels_to_questionnaire_questions.php b/database/migrations/2026_05_18_161355_add_parent_and_labels_to_questionnaire_questions.php new file mode 100644 index 0000000..2745c72 --- /dev/null +++ b/database/migrations/2026_05_18_161355_add_parent_and_labels_to_questionnaire_questions.php @@ -0,0 +1,29 @@ +foreignId('parent_id') + ->nullable() + ->after('questionnaire_set_id') + ->constrained('questionnaire_questions') + ->cascadeOnDelete(); + + $table->json('rating_labels')->nullable()->after('options_json'); + }); + } + + public function down(): void + { + Schema::table('questionnaire_questions', function (Blueprint $table) { + $table->dropForeign(['parent_id']); + $table->dropColumn(['parent_id', 'rating_labels']); + }); + } +}; diff --git a/database/migrations/2026_05_18_163447_add_tajuk_to_question_type_enum.php b/database/migrations/2026_05_18_163447_add_tajuk_to_question_type_enum.php new file mode 100644 index 0000000..a172e75 --- /dev/null +++ b/database/migrations/2026_05_18_163447_add_tajuk_to_question_type_enum.php @@ -0,0 +1,17 @@ + 'admin@mbip.gov.my'], + ['email' => 'saufi@mbip.gov.my'], [ 'name' => 'Admin eCert MBIP', - 'password' => Hash::make('Admin@MBIP2025!'), + 'password' => Hash::make('YongTauFu26'), 'role' => 'super_admin', ] ); - $this->command->info('Admin account created: admin@mbip.gov.my / Admin@MBIP2025!'); + $this->command->info('Admin account created: saufi@mbip.gov.my / YongTauFu26'); } } diff --git a/resources/views/admin/profile/show.blade.php b/resources/views/admin/profile/show.blade.php new file mode 100644 index 0000000..9184b48 --- /dev/null +++ b/resources/views/admin/profile/show.blade.php @@ -0,0 +1,137 @@ +@extends('layouts.admin') + +@section('title', 'Profil Saya') +@section('header', 'Profil Saya') + +@section('breadcrumb') + +@endsection + +@section('content') + +
+ + {{-- Account Info --}} +
+
+
+
+ +
+
+
{{ $user->name }}
+
{{ $user->email }}
+ + {{ $user->isSuperAdmin() ? 'Super Admin' : 'Admin Program' }} + +
+
+
+
+ + {{-- Update Email --}} +
+
+
+
+ Tukar Alamat Emel +
+
+
+ + @if(session('email_success')) +
+ {{ session('email_success') }} +
+ @endif + +
+ @csrf @method('PUT') + +
+ + + @error('current_password', 'email') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('email', 'email') +
{{ $message }}
+ @enderror +
+ + +
+
+
+
+ + {{-- Update Password --}} +
+
+
+
+ Tukar Kata Laluan +
+
+
+ + @if(session('password_success')) +
+ {{ session('password_success') }} +
+ @endif + +
+ @csrf @method('PUT') + +
+ + + @error('current_password', 'password') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('password', 'password') +
{{ $message }}
+ @enderror +
+ +
+ + +
+ + +
+
+
+
+ +
+ +@endsection diff --git a/resources/views/admin/programs/questionnaire/preview.blade.php b/resources/views/admin/programs/questionnaire/preview.blade.php new file mode 100644 index 0000000..1047fc4 --- /dev/null +++ b/resources/views/admin/programs/questionnaire/preview.blade.php @@ -0,0 +1,151 @@ +@extends('layouts.public') + +@section('title', 'Pratonton Soalselidik — ' . $program->title) + +@section('hero') +

{{ $program->title }}

+
+ Borang Penilaian Program +
+@endsection + +@push('styles') + +@endpush + +@section('content') + +
+ PRATONTON ADMIN — Borang ini tidak akan dihantar + +
+ +
+
+
+ +
+
{{ $pq->questionnaireSet->title }}
+ @if($pq->questionnaireSet->description) +

{{ $pq->questionnaireSet->description }}

+ @endif +

+ Sila jawab semua soalan sebelum memuat turun sijil anda, PESERTA CONTOH. +

+
+ +
+ + @php $qNum = 0; @endphp + + @foreach($questions as $q) + + @if($q->question_type === 'tajuk') + {{-- ── Section header ─────────────────────────────── --}} +
+ {{ $q->question_text }} +
+ + @foreach($q->children as $child) + @php $qNum++ @endphp +
+ +
+ @for($i = 1; $i <= 5; $i++) +
+ + +
+ @endfor +
+
+ @endforeach + + @else + {{-- ── Standalone question ─────────────────────────── --}} + @php $qNum++ @endphp +
+ + + @if($q->question_type === 'rating') +
+ @for($i = 1; $i <= 5; $i++) +
+ + +
+ @endfor +
+ + @elseif($q->question_type === 'single_choice') + @foreach($q->options_json ?? [] as $opt) +
+ + +
+ @endforeach + + @elseif($q->question_type === 'multiple_choice') + @foreach($q->options_json ?? [] as $opt) +
+ + +
+ @endforeach + + @elseif($q->question_type === 'short_text') + + + @elseif($q->question_type === 'long_text') + + @endif +
+ @endif + + @endforeach + +
+ +
+ + Ini adalah pratonton admin. Borang ini tidak boleh dihantar. +
+
+ +@endsection diff --git a/resources/views/admin/programs/questionnaire/show.blade.php b/resources/views/admin/programs/questionnaire/show.blade.php index 6935632..0ae93be 100644 --- a/resources/views/admin/programs/questionnaire/show.blade.php +++ b/resources/views/admin/programs/questionnaire/show.blade.php @@ -10,9 +10,17 @@ @endsection @section('header-actions') - - Kembali - +
+ @if($pq && $pq->questionnaireSet) + + Pratonton + + @endif + + Kembali + +
@endsection @section('content') @@ -68,25 +76,48 @@ {{-- List Questions --}}
Senarai Soalan:
- @foreach($pq->questionnaireSet->questions as $q) -
- {{ $loop->iteration }} -
-
{{ $q->question_text }}
- - {{ match($q->question_type) { - 'rating' => 'Rating', - 'single_choice' => 'Pilihan Tunggal', - 'multiple_choice' => 'Pilihan Berganda', - 'short_text' => 'Teks Pendek', - 'long_text' => 'Teks Panjang', - } }} - - @if($q->is_required) - Wajib - @endif + @php + $allQs = $pq->questionnaireSet->questions->sortBy('sort_order'); + $topQs = $allQs->whereNull('parent_id'); + $qNum = 0; + @endphp + @foreach($topQs as $q) + @if($q->question_type === 'tajuk') +
+ Tajuk +
{{ $q->question_text }}
-
+ @foreach($allQs->where('parent_id', $q->id)->sortBy('sort_order') as $child) + @php $qNum++ @endphp +
+ {{ $qNum }} +
+
{{ $child->question_text }}
+ Rating 1–5 + @if($child->is_required)Wajib@endif +
+
+ @endforeach + @else + @php $qNum++ @endphp +
+ {{ $qNum }} +
+
{{ $q->question_text }}
+ + {{ match($q->question_type) { + 'rating' => 'Rating 1–5', + 'single_choice' => 'Pilihan Tunggal', + 'multiple_choice' => 'Pilihan Berganda', + 'short_text' => 'Teks Pendek', + 'long_text' => 'Teks Panjang', + default => $q->question_type, + } }} + + @if($q->is_required)Wajib@endif +
+
+ @endif @endforeach
diff --git a/resources/views/admin/questionnaires/show.blade.php b/resources/views/admin/questionnaires/show.blade.php index 3d2030b..d2a1daa 100644 --- a/resources/views/admin/questionnaires/show.blade.php +++ b/resources/views/admin/questionnaires/show.blade.php @@ -31,13 +31,25 @@
@endsection +@push('styles') + +@endpush + @section('content')
{{-- Left: Questions --}}
- {{-- Status Banner --}} @if($set->status === 'draft')
@@ -55,61 +67,140 @@
Senarai Soalan - {{ $set->questions->count() }} + {{ $totalCount }}
+ Seret untuk susun semula
- @if($set->questions->isEmpty()) + @if($totalCount === 0)
Belum ada soalan. Tambah soalan menggunakan borang di sebelah kanan.
@else
    - @foreach($set->questions as $q) + @foreach($topLevel as $q) + + @if($q->question_type === 'tajuk') + {{-- ── TAJUK BLOCK ── --}} +
  • + {{-- Tajuk header row --}} +
    + + Tajuk +
    {{ $q->question_text }}
    + @if($q->rating_labels) +
    + @php $labels = array_filter($q->rating_labels); @endphp + @if(!empty($labels)) + {{ implode(' · ', $labels) }} + @endif +
    + @endif +
    + +
    + @csrf @method('DELETE') + +
    +
    +
    + {{-- Rating labels pills --}} + @if($q->rating_labels && array_filter($q->rating_labels)) +
    + @foreach(array_filter($q->rating_labels) as $val => $lbl) + {{ $val }}: {{ $lbl }} + @endforeach +
    + @endif + {{-- Children list --}} +
      + @foreach($q->children as $child) +
    • + +
      +
      + {{ $loop->iteration }} + Rating 1–5 + @if($child->is_required) + Wajib + @endif +
      +
      {{ $child->question_text }}
      +
      +
      + +
      + @csrf @method('DELETE') + +
      +
      +
    • + @endforeach + @if($q->children->isEmpty()) +
    • + Tiada soalan dalam bahagian ini +
    • + @endif +
    +
  • + + @else + {{-- ── STANDALONE QUESTION ── --}}
  • -
    -
    - {{ $loop->iteration }} - - {{ match($q->question_type) { - 'rating' => 'Rating', - 'single_choice' => 'Pilihan Tunggal', - 'multiple_choice' => 'Pilihan Berganda', - 'short_text' => 'Teks Pendek', - 'long_text' => 'Teks Panjang', - default => $q->question_type, - } }} - - @if($q->is_required) - Wajib +
    + +
    +
    + {{ $loop->iteration }} + + {{ match($q->question_type) { + 'rating' => 'Rating 1–5', + 'single_choice' => 'Pilihan Tunggal', + 'multiple_choice' => 'Pilihan Berganda', + 'short_text' => 'Teks Pendek', + 'long_text' => 'Teks Panjang', + default => $q->question_type, + } }} + + @if($q->is_required) + Wajib + @endif +
    +
    {{ $q->question_text }}
    + @if($q->options_json) +
    + @foreach($q->options_json as $opt) + {{ $opt }} + @endforeach +
    @endif
    -
    {{ $q->question_text }}
    - @if($q->options_json) -
    - @foreach($q->options_json as $opt) - {{ $opt }} - @endforeach -
    - @endif
    @csrf @method('DELETE') - +
  • + @endif + @endforeach
@endif @@ -143,27 +234,31 @@
-
Tambah Soalan
+
+ Tambah Soalan +
- {{-- Add Question Form --}}
@csrf + {{-- Question text --}}
- + + placeholder="Taip soalan atau nama bahagian..."> @error('question_text')
{{ $message }}
@enderror
+ {{-- Question type --}}
- - + @@ -172,11 +267,41 @@
-
- + {{-- Required (hidden for tajuk) --}} +
+
+ {{-- Parent selector (rating only) --}} +
+ + + @error('parent_id') +
{{ $message }}
+ @enderror +
+ + {{-- Rating labels (tajuk only) --}} +
+ +
+ @for ($i = 1; $i <= 5; $i++) +
+ {{ $i }} + +
+ @endfor +
+
Kosongkan jika tiada label untuk nilai tersebut.
+
+ + {{-- Options (choice types) --}}
@@ -192,13 +317,17 @@ + @error('options') +
{{ $message }}
+ @enderror
-
@@ -212,21 +341,49 @@ @endsection @push('scripts') + @endpush diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php index cb32e08..e6d9898 100644 --- a/resources/views/auth/forgot-password.blade.php +++ b/resources/views/auth/forgot-password.blade.php @@ -1,25 +1,67 @@ - -
- {{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }} + + + + + + Terlupa Kata Laluan — eCert MBIP + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + + +
+
+ +

eCert MBIP

+ Sistem Pengurusan Sijil Digital +
+ +
+
+
Terlupa Kata Laluan?
+

+ Masukkan alamat emel anda dan kami akan hantar pautan untuk menetapkan semula kata laluan. +

+ + @if(session('status')) +
+ {{ session('status') }} +
+ @endif + + + @csrf +
+ + + @error('email') +
{{ $message }}
+ @enderror +
+ + + + + +
+
+ +
+ Majlis Bandaraya Ipoh Perak © {{ date('Y') }} +
- - - - -
- @csrf - - -
- - - -
- -
- - {{ __('Email Password Reset Link') }} - -
-
- + + diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php index a6494cc..d340fab 100644 --- a/resources/views/auth/reset-password.blade.php +++ b/resources/views/auth/reset-password.blade.php @@ -1,39 +1,83 @@ - -
- @csrf - - - - - -
- - - + + + + + + Tetapkan Semula Kata Laluan — eCert MBIP + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + + +
+
+ +

eCert MBIP

+ Sistem Pengurusan Sijil Digital
- -
- - - +
+
+
Tetapkan Semula Kata Laluan
+

Masukkan kata laluan baru untuk akaun anda.

+ + + @csrf + + +
+ + + @error('email') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('password') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('password_confirmation') +
{{ $message }}
+ @enderror +
+ + + + + +
- -
- - - - - +
+ Majlis Bandaraya Ipoh Perak © {{ date('Y') }}
- -
- - {{ __('Reset Password') }} - -
- - +
+ + diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 166ee61..a57259e 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -66,6 +66,12 @@ {{ auth()->user()->isSuperAdmin() ? 'Super Admin' : 'Admin Program' }} +
@csrf