feat: questionnaire management (Fasa 6)
- QuestionnaireSetController: full CRUD + publish/archive - QuestionController: store, update, destroy, reorder - ProgramQuestionnaireController: attach, confirm, detach - Public/QuestionnaireController: show form, submit responses, double-submit guard - Views: admin questionnaire CRUD, program questionnaire assign, public form + thankyou/already Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,86 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Program;
|
||||
use App\Models\ProgramQuestionnaire;
|
||||
use App\Models\QuestionnaireSet;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProgramQuestionnaireController extends Controller
|
||||
{
|
||||
//
|
||||
public function show(Program $program): View
|
||||
{
|
||||
$pq = $program->questionnaire()->with('questionnaireSet.questions')->first();
|
||||
|
||||
$availableSets = QuestionnaireSet::where('status', 'published')
|
||||
->withCount('questions')
|
||||
->orderBy('title')
|
||||
->get();
|
||||
|
||||
return view('admin.programs.questionnaire.show', compact('program', 'pq', 'availableSets'));
|
||||
}
|
||||
|
||||
public function attach(Request $request, Program $program): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'questionnaire_set_id' => 'required|exists:questionnaire_sets,id',
|
||||
]);
|
||||
|
||||
if ($program->questionnaire()->exists()) {
|
||||
return back()->with('error', 'Program ini sudah ada soalselidik dilampirkan. Tanggalkan dahulu sebelum lampir yang baru.');
|
||||
}
|
||||
|
||||
$set = QuestionnaireSet::findOrFail($data['questionnaire_set_id']);
|
||||
|
||||
if ($set->status !== 'published') {
|
||||
return back()->with('error', 'Hanya soalselidik yang diterbitkan boleh dilampirkan.');
|
||||
}
|
||||
|
||||
ProgramQuestionnaire::create([
|
||||
'program_id' => $program->id,
|
||||
'questionnaire_set_id' => $set->id,
|
||||
'is_confirmed' => false,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Soalselidik berjaya dilampirkan. Sila sahkan sebelum program bermula.');
|
||||
}
|
||||
|
||||
public function confirm(Request $request, Program $program): RedirectResponse
|
||||
{
|
||||
$pq = $program->questionnaire;
|
||||
|
||||
if (! $pq) {
|
||||
return back()->with('error', 'Tiada soalselidik untuk disahkan.');
|
||||
}
|
||||
|
||||
$pq->update([
|
||||
'is_confirmed' => true,
|
||||
'confirmed_at' => now(),
|
||||
'confirmed_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Soalselidik telah disahkan untuk program ini.');
|
||||
}
|
||||
|
||||
public function detach(Program $program): RedirectResponse
|
||||
{
|
||||
$pq = $program->questionnaire;
|
||||
|
||||
if (! $pq) {
|
||||
return back()->with('error', 'Tiada soalselidik untuk ditanggalkan.');
|
||||
}
|
||||
|
||||
if ($pq->is_confirmed) {
|
||||
$hasResponses = \App\Models\QuestionnaireResponse::where('program_id', $program->id)->exists();
|
||||
if ($hasResponses) {
|
||||
return back()->with('error', 'Soalselidik tidak boleh ditanggalkan kerana sudah ada respons diterima.');
|
||||
}
|
||||
}
|
||||
|
||||
$pq->delete();
|
||||
|
||||
return back()->with('success', 'Soalselidik berjaya ditanggalkan.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,87 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QuestionnaireQuestion;
|
||||
use App\Models\QuestionnaireSet;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class QuestionController extends Controller
|
||||
{
|
||||
//
|
||||
public function store(Request $request, QuestionnaireSet $set): RedirectResponse
|
||||
{
|
||||
$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',
|
||||
]);
|
||||
|
||||
$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;
|
||||
|
||||
$set->questions()->create([
|
||||
'question_text' => $data['question_text'],
|
||||
'question_type' => $data['question_type'],
|
||||
'is_required' => $data['is_required'] ?? true,
|
||||
'options_json' => $needsOptions ? array_values(array_filter($data['options'] ?? [])) : null,
|
||||
'sort_order' => $maxOrder + 1,
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.questionnaires.show', $set)
|
||||
->with('success', 'Soalan berjaya ditambah.');
|
||||
}
|
||||
|
||||
public function update(Request $request, QuestionnaireQuestion $question): RedirectResponse
|
||||
{
|
||||
$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',
|
||||
]);
|
||||
|
||||
$needsOptions = in_array($data['question_type'], ['single_choice', 'multiple_choice']);
|
||||
|
||||
$question->update([
|
||||
'question_text' => $data['question_text'],
|
||||
'question_type' => $data['question_type'],
|
||||
'is_required' => $data['is_required'] ?? true,
|
||||
'options_json' => $needsOptions ? array_values(array_filter($data['options'] ?? [])) : null,
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.questionnaires.show', $question->questionnaire_set_id)
|
||||
->with('success', 'Soalan berjaya dikemaskini.');
|
||||
}
|
||||
|
||||
public function destroy(QuestionnaireQuestion $question): RedirectResponse
|
||||
{
|
||||
$setId = $question->questionnaire_set_id;
|
||||
$question->delete();
|
||||
|
||||
return redirect()->route('admin.questionnaires.show', $setId)
|
||||
->with('success', 'Soalan berjaya dipadam.');
|
||||
}
|
||||
|
||||
public function reorder(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'order' => 'required|array',
|
||||
'order.*' => 'integer|exists:questionnaire_questions,id',
|
||||
]);
|
||||
|
||||
foreach ($data['order'] as $sortOrder => $questionId) {
|
||||
QuestionnaireQuestion::where('id', $questionId)
|
||||
->update(['sort_order' => $sortOrder + 1]);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,63 +3,108 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QuestionnaireSet;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class QuestionnaireSetController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
public function index(Request $request): View
|
||||
{
|
||||
//
|
||||
$query = QuestionnaireSet::withCount('questions')
|
||||
->with('creator')
|
||||
->latest();
|
||||
|
||||
if ($request->filled('q')) {
|
||||
$query->where('title', 'like', '%' . $request->q . '%');
|
||||
}
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$sets = $query->paginate(20)->withQueryString();
|
||||
|
||||
return view('admin.questionnaires.index', compact('sets'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create()
|
||||
public function create(): View
|
||||
{
|
||||
//
|
||||
return view('admin.questionnaires.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
//
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$data['created_by'] = auth()->id();
|
||||
$data['status'] = 'draft';
|
||||
|
||||
$set = QuestionnaireSet::create($data);
|
||||
|
||||
return redirect()->route('admin.questionnaires.show', $set)
|
||||
->with('success', 'Set soalselidik berjaya dicipta.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(string $id)
|
||||
public function show(QuestionnaireSet $set): View
|
||||
{
|
||||
//
|
||||
$set->load(['questions', 'creator']);
|
||||
$usedInPrograms = $set->programs()->get();
|
||||
|
||||
return view('admin.questionnaires.show', compact('set', 'usedInPrograms'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit(string $id)
|
||||
public function edit(QuestionnaireSet $set): View
|
||||
{
|
||||
//
|
||||
return view('admin.questionnaires.edit', compact('set'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
public function update(Request $request, QuestionnaireSet $set): RedirectResponse
|
||||
{
|
||||
//
|
||||
$data = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$set->update($data);
|
||||
|
||||
return redirect()->route('admin.questionnaires.show', $set)
|
||||
->with('success', 'Set soalselidik berjaya dikemaskini.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(string $id)
|
||||
public function destroy(QuestionnaireSet $set): RedirectResponse
|
||||
{
|
||||
//
|
||||
$confirmed = $set->programQuestionnaires()->where('is_confirmed', true)->exists();
|
||||
|
||||
if ($confirmed) {
|
||||
return back()->with('error', 'Soalselidik ini tidak boleh dipadam kerana sudah disahkan untuk program.');
|
||||
}
|
||||
|
||||
$set->delete();
|
||||
|
||||
return redirect()->route('admin.questionnaires.index')
|
||||
->with('success', 'Set soalselidik berjaya dipadam.');
|
||||
}
|
||||
|
||||
public function publish(QuestionnaireSet $set): RedirectResponse
|
||||
{
|
||||
if ($set->questions()->count() === 0) {
|
||||
return back()->with('error', 'Tambah sekurang-kurangnya satu soalan sebelum menerbitkan.');
|
||||
}
|
||||
|
||||
$set->update(['status' => 'published']);
|
||||
|
||||
return back()->with('success', 'Set soalselidik diterbitkan.');
|
||||
}
|
||||
|
||||
public function archive(QuestionnaireSet $set): RedirectResponse
|
||||
{
|
||||
$set->update(['status' => 'archived']);
|
||||
|
||||
return back()->with('success', 'Set soalselidik diarkibkan.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,119 @@
|
||||
namespace App\Http\Controllers\Public;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Participant;
|
||||
use App\Models\ProgramQrCode;
|
||||
use App\Models\QuestionnaireResponse;
|
||||
use App\Models\QuestionnaireAnswer;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class QuestionnaireController extends Controller
|
||||
{
|
||||
//
|
||||
public function show(string $qr_token, string $participant_uuid): View|RedirectResponse
|
||||
{
|
||||
$qrCode = ProgramQrCode::where('token', $qr_token)->where('is_active', true)->firstOrFail();
|
||||
$program = $qrCode->program;
|
||||
|
||||
$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();
|
||||
|
||||
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) {
|
||||
return view('public.questionnaire.already', compact('program', 'participant', 'qrCode'));
|
||||
}
|
||||
|
||||
$questions = $pq->questionnaireSet->questions;
|
||||
|
||||
return view('public.questionnaire.show', compact('program', 'participant', 'qrCode', 'pq', 'questions'));
|
||||
}
|
||||
|
||||
public function submit(Request $request, string $qr_token, string $participant_uuid): View|RedirectResponse
|
||||
{
|
||||
$qrCode = ProgramQrCode::where('token', $qr_token)->where('is_active', true)->firstOrFail();
|
||||
$program = $qrCode->program;
|
||||
|
||||
$participant = Participant::where('uuid', $participant_uuid)->firstOrFail();
|
||||
|
||||
$pp = $program->programParticipants()->where('participant_id', $participant->id)->first();
|
||||
abort_if(! $pp, 404);
|
||||
|
||||
$pq = $program->questionnaire()->with('questionnaireSet.questions')->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) {
|
||||
return view('public.questionnaire.already', compact('program', 'participant', 'qrCode'));
|
||||
}
|
||||
|
||||
$questions = $pq->questionnaireSet->questions;
|
||||
|
||||
// Validate required questions
|
||||
$rules = [];
|
||||
foreach ($questions as $q) {
|
||||
if ($q->is_required) {
|
||||
$rules['q_' . $q->id] = 'required';
|
||||
} else {
|
||||
$rules['q_' . $q->id] = 'nullable';
|
||||
}
|
||||
if ($q->question_type === 'multiple_choice') {
|
||||
$rules['q_' . $q->id] = ($q->is_required ? 'required|' : 'nullable|') . 'array';
|
||||
}
|
||||
}
|
||||
|
||||
$validated = $request->validate($rules, [
|
||||
'q_*.required' => 'Soalan ini wajib dijawab.',
|
||||
]);
|
||||
|
||||
// Save response
|
||||
$response = QuestionnaireResponse::create([
|
||||
'program_id' => $program->id,
|
||||
'participant_id' => $participant->id,
|
||||
'questionnaire_set_id' => $pq->questionnaire_set_id,
|
||||
'submitted_at' => now(),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => substr($request->userAgent() ?? '', 0, 500),
|
||||
]);
|
||||
|
||||
foreach ($questions as $q) {
|
||||
$raw = $request->input('q_' . $q->id);
|
||||
|
||||
if ($raw === null && ! $q->is_required) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = match ($q->question_type) {
|
||||
'multiple_choice' => (array) $raw,
|
||||
'rating' => (int) $raw,
|
||||
default => $raw,
|
||||
};
|
||||
|
||||
QuestionnaireAnswer::create([
|
||||
'questionnaire_response_id' => $response->id,
|
||||
'questionnaire_question_id' => $q->id,
|
||||
'answer_value' => $value,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('public.questionnaire.thankyou', compact('program', 'participant', 'qrCode'));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user