diff --git a/app/Events/PraktikumStatusUpdated.php b/app/Events/PraktikumStatusUpdated.php index 2254e39..83e1efb 100644 --- a/app/Events/PraktikumStatusUpdated.php +++ b/app/Events/PraktikumStatusUpdated.php @@ -5,11 +5,11 @@ use App\Models\Praktikum; use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class PraktikumStatusUpdated implements ShouldBroadcast +class PraktikumStatusUpdated implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; diff --git a/app/Http/Controllers/API/PraktikumController.php b/app/Http/Controllers/API/PraktikumController.php index 84f7b50..a35763b 100644 --- a/app/Http/Controllers/API/PraktikumController.php +++ b/app/Http/Controllers/API/PraktikumController.php @@ -16,7 +16,7 @@ class PraktikumController extends Controller { - private const PHASE_SEQUENCE = [ + private const PHASES = [ 'preparation', 'ta', 'fitb_jurnal', @@ -28,16 +28,10 @@ class PraktikumController extends Controller public function index(Request $request): JsonResponse { try { - $praktikums = Praktikum::with(['modul', 'kelas', 'pj']) - ->when($request->filled('kelas_id'), function ($query) use ($request) { - $query->where('kelas_id', $request->input('kelas_id')); - }) - ->when($request->filled('modul_id'), function ($query) use ($request) { - $query->where('modul_id', $request->input('modul_id')); - }) - ->when($request->filled('dk'), function ($query) use ($request) { - $query->where('dk', $request->input('dk')); - }) + $data = Praktikum::with(['modul', 'kelas', 'pj']) + ->when($request->filled('kelas_id'), fn ($q) => $q->where('kelas_id', $request->kelas_id)) + ->when($request->filled('modul_id'), fn ($q) => $q->where('modul_id', $request->modul_id)) + ->when($request->filled('dk'), fn ($q) => $q->where('dk', $request->dk)) ->orderBy('kelas_id') ->orderBy('dk') ->orderBy('modul_id') @@ -46,25 +40,21 @@ public function index(Request $request): JsonResponse return response()->json([ 'status' => 'success', 'message' => 'Praktikum retrieved successfully.', - 'data' => $praktikums, - 'phases' => self::PHASE_SEQUENCE, + 'data' => $data, + 'phases' => self::PHASES, ]); - } catch (\Throwable $th) { - return $this->respondWithServerError($th); + } catch (\Throwable $e) { + return $this->serverError($e); } } public function show(Request $request, int $kelasId): JsonResponse { try { - $praktikums = Praktikum::with(['modul', 'kelas', 'pj']) + $data = Praktikum::with(['modul', 'kelas', 'pj']) ->where('kelas_id', $kelasId) - ->when($request->filled('modul_id'), function ($query) use ($request) { - $query->where('modul_id', $request->input('modul_id')); - }) - ->when($request->filled('dk'), function ($query) use ($request) { - $query->where('dk', $request->input('dk')); - }) + ->when($request->filled('modul_id'), fn ($q) => $q->where('modul_id', $request->modul_id)) + ->when($request->filled('dk'), fn ($q) => $q->where('dk', $request->dk)) ->orderBy('dk') ->orderBy('modul_id') ->get(); @@ -72,36 +62,17 @@ public function show(Request $request, int $kelasId): JsonResponse return response()->json([ 'status' => 'success', 'message' => 'Praktikum retrieved successfully.', - 'data' => $praktikums, - 'phases' => self::PHASE_SEQUENCE, + 'data' => $data, + 'phases' => self::PHASES, ]); - } catch (\Throwable $th) { - return $this->respondWithServerError($th); + } catch (\Throwable $e) { + return $this->serverError($e); } } - // public function active(): JsonResponse - // { - // try { - // $praktikums = Praktikum::with(['modul', 'kelas', 'pj']) - // ->whereIn('status', ['running', 'paused']) - // ->orderBy('kelas_id') - // ->orderBy('dk') - // ->get(); - - // return response()->json([ - // 'status' => 'success', - // 'data' => $praktikums, - // 'phases' => self::PHASE_SEQUENCE, - // ]); - // } catch (\Throwable $th) { - // return $this->respondWithServerError($th); - // } - // } - public function store(Request $request): JsonResponse { - $validated = $request->validate([ + $data = $request->validate([ 'kelas_id' => 'required|exists:kelas,id', 'modul_id' => 'required|exists:moduls,id', 'dk' => 'required|string|in:DK1,DK2', @@ -109,14 +80,13 @@ public function store(Request $request): JsonResponse $praktikum = Praktikum::firstOrCreate( [ - 'kelas_id' => $validated['kelas_id'], - 'modul_id' => $validated['modul_id'], - 'dk' => $validated['dk'], + 'kelas_id' => $data['kelas_id'], + 'modul_id' => $data['modul_id'], + 'dk' => $data['dk'], ], [ - 'dk' => $validated['dk'], 'status' => 'idle', - 'current_phase' => self::PHASE_SEQUENCE[0], + 'current_phase' => self::PHASES[0], 'phase_elapsed_seconds' => 0, 'phase_started_at' => null, 'isActive' => false, @@ -141,14 +111,15 @@ public function store(Request $request): JsonResponse public function update(Request $request, int $id): JsonResponse { - $validated = $request->validate([ + $data = $request->validate([ 'action' => 'required|string|in:start,pause,resume,next,exit,report', 'phase' => 'nullable|string', 'report_notes' => 'required_if:action,report|string|min:3|max:65535', ]); - $phase = $validated['phase'] ?? null; - if ($phase !== null && ! in_array($phase, self::PHASE_SEQUENCE, true)) { + $phase = $data['phase'] ?? null; + + if ($phase !== null && ! in_array($phase, self::PHASES, true)) { return response()->json([ 'status' => 'error', 'message' => 'Phase is not valid.', @@ -165,48 +136,39 @@ public function update(Request $request, int $id): JsonResponse } $now = Carbon::now(); - - $pjId = null; - if ($validated['action'] === 'report') { - $pjId = optional($request->user('asisten'))->id ?? optional(Auth::user())->id ?? null; - } + $pjId = $data['action'] === 'report' + ? optional($request->user('asisten'))->id ?? optional(Auth::user())->id + : null; try { - switch ($validated['action']) { - case 'start': - $this->handleStart($praktikum, $phase ?? self::PHASE_SEQUENCE[0], $now); - break; - case 'pause': - $this->handlePause($praktikum, $now); - break; - case 'resume': - $this->handleResume($praktikum, $now); - break; - case 'next': - $this->handleNext($praktikum, $phase, $now); - break; - case 'exit': - $this->handleExit($praktikum, $now); - break; - case 'report': - $this->handleReport($praktikum, $validated['report_notes'], $now, $pjId); - break; - } - } catch (\InvalidArgumentException $exception) { + match ($data['action']) { + 'start' => $this->start($praktikum, $phase ?? self::PHASES[0], $now), + 'pause' => $this->pause($praktikum, $now), + 'resume' => $this->resume($praktikum, $now), + 'next' => $this->next($praktikum, $phase, $now), + 'exit' => $this->exit($praktikum, $now), + 'report' => $this->report($praktikum, $data['report_notes'], $now, $pjId), + }; + } catch (\InvalidArgumentException $e) { return response()->json([ 'status' => 'error', - 'message' => $exception->getMessage(), + 'message' => $e->getMessage(), ], 422); - } catch (\Throwable $th) { - return $this->respondWithServerError($th); + } catch (\Throwable $e) { + return $this->serverError($e); } $praktikum = $praktikum->fresh(['modul', 'kelas', 'pj']); + broadcast(new PraktikumStatusUpdated($praktikum)); - $progressService = app(QuestionProgressService::class); - $progressPayload = $progressService->buildForPraktikum($praktikum); - broadcast(new PraktikumProgressUpdated($praktikum->id, $progressPayload)); + $progress = app(QuestionProgressService::class) + ->buildForPraktikum($praktikum); + + broadcast(new PraktikumProgressUpdated( + $praktikum->id, + $progress + )); broadcast(new ActivePraktikumBroadcast); @@ -214,60 +176,74 @@ public function update(Request $request, int $id): JsonResponse 'status' => 'success', 'message' => 'Praktikum updated successfully.', 'data' => $praktikum, - 'phases' => self::PHASE_SEQUENCE, + 'phases' => self::PHASES, ]); } - private function handleStart(Praktikum $praktikum, string $phase, Carbon $now): void - { - // Check if there's already a running praktikum for the same kelas - $runningPraktikum = Praktikum::where('kelas_id', $praktikum->kelas_id) + private function start( + Praktikum $praktikum, + string $phase, + Carbon $now + ): void { + $running = Praktikum::where('kelas_id', $praktikum->kelas_id) ->where('dk', $praktikum->dk) ->where('id', '!=', $praktikum->id) ->whereIn('status', ['running', 'paused']) ->first(); - if ($runningPraktikum) { + if ($running) { throw new \InvalidArgumentException( 'Tidak dapat memulai praktikum. Terdapat praktikum lain yang sedang berjalan untuk kelas ini.' ); } - $praktikum->isActive = true; - $praktikum->status = 'running'; - $praktikum->current_phase = $phase; - $praktikum->started_at = $now; - $praktikum->ended_at = null; - $praktikum->report_notes = null; - $praktikum->report_submitted_at = null; - $praktikum->pj_id = optional(Auth::user())->id; - $this->resetPhaseTiming($praktikum, $now); + $praktikum->fill([ + 'isActive' => true, + 'status' => 'running', + 'current_phase' => $phase, + 'started_at' => $now, + 'ended_at' => null, + 'report_notes' => null, + 'report_submitted_at' => null, + 'pj_id' => optional(Auth::user())->id, + ]); + + $this->resetTiming($praktikum, $now); $praktikum->save(); } - private function handlePause(Praktikum $praktikum, Carbon $now): void + private function pause(Praktikum $praktikum, Carbon $now): void { if ($praktikum->status !== 'running') { - throw new \InvalidArgumentException('Praktikum is not running.'); + throw new \InvalidArgumentException( + 'Praktikum is not running.' + ); } $praktikum->status = 'paused'; $praktikum->isActive = false; $praktikum->ended_at = $now; - $this->freezePhaseTiming($praktikum, $now); + + $this->freezeTiming($praktikum, $now); $praktikum->save(); } - private function handleResume(Praktikum $praktikum, Carbon $now): void + private function resume(Praktikum $praktikum, Carbon $now): void { if ($praktikum->status !== 'paused') { - throw new \InvalidArgumentException('Praktikum is not paused.'); + throw new \InvalidArgumentException( + 'Praktikum is not paused.' + ); } $elapsed = 0; + if ($praktikum->started_at) { $reference = $praktikum->ended_at ?? $now; - $elapsed = max(0, $praktikum->started_at->diffInSeconds($reference)); + $elapsed = max( + 0, + $praktikum->started_at->diffInSeconds($reference) + ); } $praktikum->status = 'running'; @@ -278,91 +254,120 @@ private function handleResume(Praktikum $praktikum, Carbon $now): void $praktikum->save(); } - private function handleNext(Praktikum $praktikum, ?string $phase, Carbon $now): void - { - // If phase is explicitly provided, use it (for frontend control) + private function next( + Praktikum $praktikum, + ?string $phase, + Carbon $now + ): void { if ($phase !== null) { - $nextIndex = array_search($phase, self::PHASE_SEQUENCE, true); + $index = array_search($phase, self::PHASES, true); - if ($nextIndex === false) { - throw new \InvalidArgumentException('Invalid phase provided.'); + if ($index === false) { + throw new \InvalidArgumentException( + 'Invalid phase provided.' + ); } - $isLastPhase = $nextIndex === count(self::PHASE_SEQUENCE) - 1; - - if ($isLastPhase) { + if ($index === count(self::PHASES) - 1) { $praktikum->status = 'completed'; $praktikum->isActive = false; - $praktikum->ended_at = $now; $praktikum->current_phase = $phase; - $this->freezePhaseTiming($praktikum, $now); + $praktikum->ended_at = $now; + $this->freezeTiming($praktikum, $now); } else { - $praktikum->current_phase = $phase; - $praktikum->status = 'running'; - $praktikum->isActive = true; - if ($praktikum->started_at) { - $reference = $praktikum->ended_at ?? $now; - $elapsed = max(0, $praktikum->started_at->diffInSeconds($reference)); - $praktikum->started_at = $now->copy()->subSeconds($elapsed); - } - $praktikum->ended_at = null; - $this->resetPhaseTiming($praktikum, $now); + $this->moveToPhase( + $praktikum, + $phase, + $now + ); } - } else { - // Fallback to auto-calculating next phase - $currentPhase = $praktikum->current_phase ?? self::PHASE_SEQUENCE[0]; - $currentIndex = array_search($currentPhase, self::PHASE_SEQUENCE, true); - if ($currentIndex === false) { - throw new \InvalidArgumentException('Current phase is invalid.'); - } + $praktikum->save(); - $isLastPhase = $currentIndex === count(self::PHASE_SEQUENCE) - 1; + return; + } - if ($isLastPhase) { - $praktikum->status = 'completed'; - $praktikum->isActive = false; - $praktikum->ended_at = $now; - $this->freezePhaseTiming($praktikum, $now); - } else { - $praktikum->current_phase = self::PHASE_SEQUENCE[$currentIndex + 1]; - $praktikum->status = 'running'; - $praktikum->isActive = true; - if ($praktikum->started_at) { - $reference = $praktikum->ended_at ?? $now; - $elapsed = max(0, $praktikum->started_at->diffInSeconds($reference)); - $praktikum->started_at = $now->copy()->subSeconds($elapsed); - } - $praktikum->ended_at = null; - $this->resetPhaseTiming($praktikum, $now); - } + $current = $praktikum->current_phase ?? self::PHASES[0]; + $index = array_search($current, self::PHASES, true); + + if ($index === false) { + throw new \InvalidArgumentException( + 'Current phase is invalid.' + ); + } + + if ($index === count(self::PHASES) - 1) { + $praktikum->status = 'completed'; + $praktikum->isActive = false; + $praktikum->ended_at = $now; + + $this->freezeTiming($praktikum, $now); + } else { + $this->moveToPhase( + $praktikum, + self::PHASES[$index + 1], + $now + ); } $praktikum->save(); } - private function handleExit(Praktikum $praktikum, Carbon $now): void + private function moveToPhase( + Praktikum $praktikum, + string $phase, + Carbon $now + ): void { + if ($praktikum->started_at) { + $reference = $praktikum->ended_at ?? $now; + $elapsed = max( + 0, + $praktikum->started_at->diffInSeconds($reference) + ); + + $praktikum->started_at = + $now->copy()->subSeconds($elapsed); + } + + $praktikum->current_phase = $phase; + $praktikum->status = 'running'; + $praktikum->isActive = true; + $praktikum->ended_at = null; + + $this->resetTiming($praktikum, $now); + } + + private function exit(Praktikum $praktikum, Carbon $now): void { $praktikum->status = 'exited'; $praktikum->isActive = false; $praktikum->ended_at = $now; - $this->freezePhaseTiming($praktikum, $now); + + $this->freezeTiming($praktikum, $now); $praktikum->save(); } - private function handleReport(Praktikum $praktikum, string $notes, Carbon $now, ?int $pjId): void - { + private function report( + Praktikum $praktikum, + string $notes, + Carbon $now, + ?int $pjId + ): void { if ($praktikum->status !== 'completed') { - throw new \InvalidArgumentException('Laporan hanya dapat diisi setelah praktikum selesai.'); + throw new \InvalidArgumentException( + 'Laporan hanya dapat diisi setelah praktikum selesai.' + ); } - $trimmedNotes = trim($notes); + $notes = trim($notes); - if ($trimmedNotes === '') { - throw new \InvalidArgumentException('Isi laporan tidak boleh kosong.'); + if ($notes === '') { + throw new \InvalidArgumentException( + 'Isi laporan tidak boleh kosong.' + ); } - $praktikum->report_notes = $trimmedNotes; + $praktikum->report_notes = $notes; $praktikum->report_submitted_at = $now; $praktikum->pj_id = $pjId; $praktikum->save(); @@ -380,94 +385,126 @@ public function checkPraktikum(Request $request): JsonResponse ], 401); } - $kelasId = $user->kelas_id; - $dk = $user->dk; - - if (! $kelasId) { + if (! $user->kelas_id) { return response()->json([ 'status' => 'error', 'message' => 'Praktikan does not have an assigned kelas.', ], 400); } - if (! $dk) { + if (! $user->dk) { return response()->json([ 'status' => 'success', 'message' => 'Praktikan belum memilih DK. Silakan pilih DK terlebih dahulu.', 'dk_required' => true, 'data' => null, - 'phases' => self::PHASE_SEQUENCE, + 'phases' => self::PHASES, 'feedback_pending' => false, 'feedback_modul_id' => null, 'feedback_asisten_id' => null, - ], 200); + ]); } - $activePraktikum = Praktikum::with(['modul', 'kelas', 'pj']) - ->where('kelas_id', $kelasId) - ->where('dk', $dk) + $active = Praktikum::with(['modul', 'kelas', 'pj']) + ->where('kelas_id', $user->kelas_id) + ->where('dk', $user->dk) ->where('isActive', true) ->whereIn('status', ['running', 'paused']) ->first(); - $latestCompletedPraktikum = Praktikum::query() - ->where('kelas_id', $kelasId) - ->where('dk', $dk) + $completed = Praktikum::with(['modul', 'kelas', 'pj']) + ->where('kelas_id', $user->kelas_id) + ->where('dk', $user->dk) ->where('status', 'completed') ->orderByDesc('ended_at') ->orderByDesc('updated_at') ->first(); $feedbackPending = false; - $pendingModulId = null; - $pendingAsistenId = null; - - if ($latestCompletedPraktikum) { - $hasSubmittedFeedback = LaporanPraktikan::query() + $feedbackModulId = null; + $feedbackAsistenId = null; + + /* + * Feedback must belong to THIS run. + * + * Old feedback for the same practitioner/module must not + * suppress feedback after the prakticum is started again. + */ + if ($completed) { + $feedbackQuery = LaporanPraktikan::query() ->where('praktikan_id', $user->id) - ->where('modul_id', $latestCompletedPraktikum->modul_id) - ->exists(); - - $praktikumHasReport = trim((string) ($latestCompletedPraktikum->report_notes ?? '')) !== ''; + ->where('modul_id', $completed->modul_id); + + if ($completed->started_at) { + $feedbackQuery->where( + 'updated_at', + '>=', + $completed->started_at + ); + } - if (! $hasSubmittedFeedback && ! $praktikumHasReport) { + if (! $feedbackQuery->exists()) { $feedbackPending = true; - $pendingModulId = $latestCompletedPraktikum->modul_id; - $pendingAsistenId = $latestCompletedPraktikum->pj_id; + $feedbackModulId = $completed->modul_id; + $feedbackAsistenId = $completed->pj_id; } } - if ($activePraktikum) { - $activePraktikum->setAttribute( + /* + * Important for TK: + * + * entering feedback marks the prakticum completed and + * isActive=false. While feedback is still pending, return + * that completed praktikum as `data` so PraktikumPage can + * observe the TK -> feedback transition and show TK score. + */ + $display = $active ?? + ($feedbackPending ? $completed : null); + + if ($display) { + $sameModule = + $feedbackPending && + $feedbackModulId !== null && + (int) $display->modul_id === + (int) $feedbackModulId; + + $display->setAttribute( 'feedback_pending', - $feedbackPending - && $pendingModulId !== null - && $pendingModulId === $activePraktikum->modul_id - && trim((string) ($activePraktikum->report_notes ?? '')) === '' + $sameModule + ); + + $display->setAttribute( + 'feedback_modul_id', + $feedbackModulId + ); + + $display->setAttribute( + 'feedback_asisten_id', + $feedbackAsistenId ); - $activePraktikum->setAttribute('feedback_modul_id', $pendingModulId); - $activePraktikum->setAttribute('feedback_asisten_id', $pendingAsistenId); } return response()->json([ 'status' => 'success', - 'message' => $activePraktikum + 'message' => $active ? 'Active praktikum found.' - : 'No active praktikum for this kelas.', - 'data' => $activePraktikum, - 'phases' => self::PHASE_SEQUENCE, + : ($feedbackPending + ? 'Praktikum completed. Feedback pending.' + : 'No active praktikum for this kelas.'), + 'data' => $display, + 'phases' => self::PHASES, 'feedback_pending' => $feedbackPending, - 'feedback_modul_id' => $pendingModulId, - 'feedback_asisten_id' => $pendingAsistenId, + 'feedback_modul_id' => $feedbackModulId, + 'feedback_asisten_id' => $feedbackAsistenId, ]); - } catch (\Throwable $th) { - return $this->respondWithServerError($th); + } catch (\Throwable $e) { + return $this->serverError($e); } } public function storeDk(Request $request): JsonResponse { - $validated = $request->validate([ + $data = $request->validate([ 'dk' => 'required|string|in:DK1,DK2', ]); @@ -480,7 +517,7 @@ public function storeDk(Request $request): JsonResponse ], 401); } - $user->dk = $validated['dk']; + $user->dk = $data['dk']; $user->save(); return response()->json([ @@ -493,14 +530,10 @@ public function storeDk(Request $request): JsonResponse public function history(Request $request): JsonResponse { try { - $praktikums = Praktikum::with(['modul', 'kelas', 'pj']) + $data = Praktikum::with(['modul', 'kelas', 'pj']) ->whereNotNull('report_notes') - ->when($request->filled('kelas_id'), function ($query) use ($request) { - $query->where('kelas_id', $request->input('kelas_id')); - }) - ->when($request->filled('modul_id'), function ($query) use ($request) { - $query->where('modul_id', $request->input('modul_id')); - }) + ->when($request->filled('kelas_id'), fn ($q) => $q->where('kelas_id', $request->kelas_id)) + ->when($request->filled('modul_id'), fn ($q) => $q->where('modul_id', $request->modul_id)) ->orderByDesc('report_submitted_at') ->orderByDesc('updated_at') ->get(); @@ -508,42 +541,58 @@ public function history(Request $request): JsonResponse return response()->json([ 'status' => 'success', 'message' => 'History retrieved successfully.', - 'data' => $praktikums, + 'data' => $data, ]); - } catch (\Throwable $th) { - return $this->respondWithServerError($th); + } catch (\Throwable $e) { + return $this->serverError($e); } } - private function calculatePhaseElapsedSeconds(Praktikum $praktikum, Carbon $now): int - { - $baseSeconds = max(0, (int) ($praktikum->phase_elapsed_seconds ?? 0)); + private function elapsed( + Praktikum $praktikum, + Carbon $now + ): int { + $seconds = max( + 0, + (int) ($praktikum->phase_elapsed_seconds ?? 0) + ); if ($praktikum->phase_started_at instanceof Carbon) { - return $baseSeconds + max(0, $praktikum->phase_started_at->diffInSeconds($now)); + $seconds += max( + 0, + $praktikum->phase_started_at->diffInSeconds($now) + ); } - return $baseSeconds; + return $seconds; } - private function resetPhaseTiming(Praktikum $praktikum, Carbon $now): void - { + private function resetTiming( + Praktikum $praktikum, + Carbon $now + ): void { $praktikum->phase_elapsed_seconds = 0; $praktikum->phase_started_at = $now; } - private function freezePhaseTiming(Praktikum $praktikum, Carbon $now): void - { - $praktikum->phase_elapsed_seconds = $this->calculatePhaseElapsedSeconds($praktikum, $now); + private function freezeTiming( + Praktikum $praktikum, + Carbon $now + ): void { + $praktikum->phase_elapsed_seconds = + $this->elapsed($praktikum, $now); + $praktikum->phase_started_at = null; } - private function respondWithServerError(\Throwable $throwable): JsonResponse + private function serverError(\Throwable $e): JsonResponse { + report($e); + return response()->json([ 'status' => 'error', 'message' => 'An error occurred while processing the request.', - 'error' => $throwable->getMessage(), + 'error' => $e->getMessage(), ], 500); } } diff --git a/app/Http/Controllers/API/SoalFITBController.php b/app/Http/Controllers/API/SoalFITBController.php index 3e6f438..60d1808 100644 --- a/app/Http/Controllers/API/SoalFITBController.php +++ b/app/Http/Controllers/API/SoalFITBController.php @@ -62,7 +62,7 @@ public function show($id) $all_fitb = SoalFitb::where('modul_id', $id)->get(); if ($all_fitb->isEmpty()) { return response()->json([ - 'message' => "Soal dengan modul ID $id tidak ditemukan.", + 'message' => "Soal dengan modul ID $id tidak ditemukan, atau mungkin belum ada inputan sama sekali :(", ], 404); } diff --git a/app/Http/Controllers/API/SoalJurnalController.php b/app/Http/Controllers/API/SoalJurnalController.php index 1746512..158ceea 100644 --- a/app/Http/Controllers/API/SoalJurnalController.php +++ b/app/Http/Controllers/API/SoalJurnalController.php @@ -64,7 +64,7 @@ public function show($id) $all_jurnal = SoalJurnal::where('modul_id', $id)->get(); if ($all_jurnal->isEmpty()) { return response()->json([ - 'message' => "Soal dengan modul ID $id tidak ditemukan.", + 'message' => "Soal dengan modul ID $id tidak ditemukan, atau mungkin belum ada inputan sama sekali :(", ], 404); } diff --git a/app/Http/Controllers/API/SoalTAController.php b/app/Http/Controllers/API/SoalTAController.php index 94f3c04..221aa73 100644 --- a/app/Http/Controllers/API/SoalTAController.php +++ b/app/Http/Controllers/API/SoalTAController.php @@ -60,55 +60,66 @@ public function show(Request $request, int $modulId): JsonResponse } $user = auth('praktikan')->user(); - $soalQuery = SoalTa::with('options')->where('modul_id', $modulId); + $query = SoalTa::with('options')->where('modul_id', $modulId); - $questionIds = collect(Arr::wrap($request->query('question_ids'))) - ->flatMap(fn ($value) => is_array($value) ? $value : explode(',', (string) $value)) - ->map(fn ($value) => (int) $value) - ->filter(fn ($value) => $value > 0) + $ids = collect(Arr::wrap($request->query('question_ids'))) + ->flatMap(fn ($v) => is_array($v) ? $v : explode(',', (string) $v)) + ->map(fn ($v) => (int) $v) + ->filter(fn ($v) => $v > 0) + ->unique() ->values(); - if ($user) { - if ($questionIds->isNotEmpty()) { - $soals = (clone $soalQuery) - ->whereIn('id', $questionIds) - ->get() - ->keyBy('id'); + if (! $user) { + $soals = $ids->isNotEmpty() + ? (clone $query)->whereIn('id', $ids)->get() + : $query->get(); - $ordered = $questionIds - ->map(fn ($id) => $soals->get($id)) - ->filter(); - - $missingCount = max(0, $questionIds->count() - $ordered->count()); - - if ($missingCount > 0) { - $fallback = (clone $soalQuery) - ->whereNotIn('id', $questionIds) - ->inRandomOrder() - ->take($missingCount) - ->get(); - - $ordered = $ordered->merge($fallback); - } + return response()->json([ + 'message' => 'Soal retrieved successfully.', + 'data' => $soals + ->map(fn (SoalTa $soal) => $this->formatAssistantSoal($soal)) + ->values(), + ]); + } - $soals = $ordered; - } else { - $limit = $this->isTotPraktikan($user) ? 15 : 10; - $soals = (clone $soalQuery)->inRandomOrder()->take($limit)->get(); - } + $limit = $this->isTotPraktikan($user) ? 15 : 10; + $target = min($limit, (clone $query)->count()); - $data = $soals->map(fn (SoalTa $soal) => $this->formatPraktikanSoal($soal)); + if ($ids->isEmpty()) { + $soals = (clone $query) + ->inRandomOrder() + ->take($target) + ->get(); } else { - $soals = $questionIds->isNotEmpty() - ? (clone $soalQuery)->whereIn('id', $questionIds)->get() - : $soalQuery->get(); - - $data = $soals->map(fn (SoalTa $soal) => $this->formatAssistantSoal($soal)); + $byId = (clone $query) + ->whereIn('id', $ids) + ->get() + ->keyBy('id'); + + $soals = $ids + ->map(fn ($id) => $byId->get($id)) + ->filter() + ->take($target) + ->values(); + + $needed = max(0, $target - $soals->count()); + + if ($needed) { + $extra = (clone $query) + ->whereNotIn('id', $soals->pluck('id')) + ->inRandomOrder() + ->take($needed) + ->get(); + + $soals = $soals->merge($extra)->values(); + } } return response()->json([ 'message' => 'Soal retrieved successfully.', - 'data' => $data, + 'data' => $soals + ->map(fn (SoalTa $soal) => $this->formatPraktikanSoal($soal)) + ->values(), ]); } diff --git a/app/Http/Controllers/API/SoalTKController.php b/app/Http/Controllers/API/SoalTKController.php index c8b4e35..3cda957 100644 --- a/app/Http/Controllers/API/SoalTKController.php +++ b/app/Http/Controllers/API/SoalTKController.php @@ -59,55 +59,66 @@ public function show(Request $request, int $modulId): JsonResponse } $user = auth('praktikan')->user(); - $soalQuery = SoalTk::with('options')->where('modul_id', $modulId); + $query = SoalTk::with('options')->where('modul_id', $modulId); - $questionIds = collect(Arr::wrap($request->query('question_ids'))) - ->flatMap(fn ($value) => is_array($value) ? $value : explode(',', (string) $value)) - ->map(fn ($value) => (int) $value) - ->filter(fn ($value) => $value > 0) + $ids = collect(Arr::wrap($request->query('question_ids'))) + ->flatMap(fn ($v) => is_array($v) ? $v : explode(',', (string) $v)) + ->map(fn ($v) => (int) $v) + ->filter(fn ($v) => $v > 0) + ->unique() ->values(); - if ($user) { - if ($questionIds->isNotEmpty()) { - $soals = (clone $soalQuery) - ->whereIn('id', $questionIds) - ->get() - ->keyBy('id'); + if (! $user) { + $soals = $ids->isNotEmpty() + ? (clone $query)->whereIn('id', $ids)->get() + : $query->get(); - $ordered = $questionIds - ->map(fn ($id) => $soals->get($id)) - ->filter(); - - $missingCount = max(0, $questionIds->count() - $ordered->count()); - - if ($missingCount > 0) { - $fallback = (clone $soalQuery) - ->whereNotIn('id', $questionIds) - ->inRandomOrder() - ->take($missingCount) - ->get(); - - $ordered = $ordered->merge($fallback); - } + return response()->json([ + 'message' => 'Soal retrieved successfully.', + 'data' => $soals + ->map(fn (SoalTk $soal) => $this->formatAssistantSoal($soal)) + ->values(), + ]); + } - $soals = $ordered; - } else { - $limit = $this->isTotPraktikan($user) ? 15 : 10; - $soals = (clone $soalQuery)->inRandomOrder()->take($limit)->get(); - } + $limit = $this->isTotPraktikan($user) ? 15 : 10; + $target = min($limit, (clone $query)->count()); - $data = $soals->map(fn (SoalTk $soal) => $this->formatPraktikanSoal($soal)); + if ($ids->isEmpty()) { + $soals = (clone $query) + ->inRandomOrder() + ->take($target) + ->get(); } else { - $soals = $questionIds->isNotEmpty() - ? (clone $soalQuery)->whereIn('id', $questionIds)->get() - : $soalQuery->get(); - - $data = $soals->map(fn (SoalTk $soal) => $this->formatAssistantSoal($soal)); + $byId = (clone $query) + ->whereIn('id', $ids) + ->get() + ->keyBy('id'); + + $soals = $ids + ->map(fn ($id) => $byId->get($id)) + ->filter() + ->take($target) + ->values(); + + $needed = max(0, $target - $soals->count()); + + if ($needed) { + $extra = (clone $query) + ->whereNotIn('id', $soals->pluck('id')) + ->inRandomOrder() + ->take($needed) + ->get(); + + $soals = $soals->merge($extra)->values(); + } } return response()->json([ 'message' => 'Soal retrieved successfully.', - 'data' => $data, + 'data' => $soals + ->map(fn (SoalTk $soal) => $this->formatPraktikanSoal($soal)) + ->values(), ]); } diff --git a/app/Models/SoalTp.php b/app/Models/SoalTp.php index d0041c5..36c0e09 100644 --- a/app/Models/SoalTp.php +++ b/app/Models/SoalTp.php @@ -31,11 +31,13 @@ class SoalTp extends Model protected $casts = [ 'modul_id' => 'int', + 'enable_file_upload' => 'boolean', ]; protected $fillable = [ 'modul_id', 'soal', + 'enable_file_upload', ]; public function modul() diff --git a/bun.lock b/bun.lock index 9dadfe5..c4e36c4 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "dependencies": { @@ -7,7 +8,8 @@ "@inertiajs/inertia": "^0.11.1", "@tanstack/react-query": "^5.90.5", "@types/react": "^19.1.9", - "caniuse-lite": "^1.0.30001734", + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", "js-cookie": "^3.0.5", "laravel-echo": "^2.2.4", "lodash": "^4.17.21", @@ -394,6 +396,8 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], @@ -408,7 +412,7 @@ "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], - "caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="], + "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], diff --git a/package-lock.json b/package-lock.json index 2f77143..af20264 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "@inertiajs/inertia": "^0.11.1", "@tanstack/react-query": "^5.90.5", "@types/react": "^19.1.9", - "caniuse-lite": "^1.0.30001734", + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", "js-cookie": "^3.0.5", + "katex": "^0.18.2", "laravel-echo": "^2.2.4", "lodash": "^4.17.21", "mermaid": "^11.12.3", @@ -19,8 +21,10 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", "recharts": "^3.6.0", + "rehype-katex": "^7.0.1", "remark-breaks": "^4.0.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0" }, "devDependencies": { "@headlessui/react": "^2.0.0", @@ -1978,6 +1982,11 @@ "@types/unist": "*" } }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==" + }, "node_modules/@types/lodash": { "version": "4.17.20", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", @@ -2243,6 +2252,17 @@ "dev": true, "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2352,9 +2372,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001753", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz", - "integrity": "sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==", + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", "funding": [ { "type": "opencollective", @@ -2368,8 +2388,7 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ], - "license": "CC-BY-4.0" + ] }, "node_modules/ccount": { "version": "2.0.1", @@ -3303,6 +3322,17 @@ "node": ">=10.0.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3768,6 +3798,83 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-parse-selector": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", @@ -3808,6 +3915,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -4104,14 +4226,13 @@ } }, "node_modules/katex": { - "version": "0.16.33", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.33.tgz", - "integrity": "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==", + "version": "0.18.2", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.2.tgz", + "integrity": "sha512-3snve4y0SXTMequLim1FMiPvLGElySam0blN5xQZBqEY3lOJz1TnuaaiugnBZBqHzlSAGmFTYL7MCQkORuLKCA==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" ], - "license": "MIT", "dependencies": { "commander": "^8.3.0" }, @@ -4427,6 +4548,24 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -4608,6 +4747,29 @@ "uuid": "^11.1.0" } }, + "node_modules/mermaid/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "engines": { + "node": ">= 12" + } + }, + "node_modules/mermaid/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -4798,6 +4960,47 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "engines": { + "node": ">= 12" + } + }, + "node_modules/micromark-extension-math/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -5403,6 +5606,17 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-data-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", @@ -5999,6 +6213,47 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "engines": { + "node": ">= 12" + } + }, + "node_modules/rehype-katex/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/remark-breaks": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", @@ -6032,6 +6287,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -6776,6 +7046,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -6802,6 +7085,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", @@ -6918,6 +7214,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -7099,6 +7408,15 @@ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 807d80e..7c8ceed 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,10 @@ "@inertiajs/inertia": "^0.11.1", "@tanstack/react-query": "^5.90.5", "@types/react": "^19.1.9", - "caniuse-lite": "^1.0.30001734", + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", "js-cookie": "^3.0.5", + "katex": "^0.18.2", "laravel-echo": "^2.2.4", "lodash": "^4.17.21", "mermaid": "^11.12.3", @@ -37,7 +39,9 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", "recharts": "^3.6.0", + "rehype-katex": "^7.0.1", "remark-breaks": "^4.0.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0" } } diff --git a/resources/js/Components/Assistants/Forms/FormSoalInput.jsx b/resources/js/Components/Assistants/Forms/FormSoalInput.jsx index bf450db..1a63474 100644 --- a/resources/js/Components/Assistants/Forms/FormSoalInput.jsx +++ b/resources/js/Components/Assistants/Forms/FormSoalInput.jsx @@ -1,70 +1,327 @@ -import { Suspense, lazy, useEffect, useMemo, useState } from 'react'; -const SoalInputPG = lazy(() => import('../Soal/SoalInputPG')); -const SoalInputEssay = lazy(() => import('../Soal/SoalInputEssay')); -const ModalSaveSoal = lazy(() => import('../Modals/ModalSaveSoal')); +import { + Suspense, + lazy, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; + import { useModulesQuery } from "@/hooks/useModulesQuery"; import toast from "react-hot-toast"; +const SoalInputPG = lazy(() => import("../Soal/SoalInputPG")); +const SoalInputEssay = lazy(() => import("../Soal/SoalInputEssay")); + +const CATEGORIES = [ + ["tp", "TP", "Tes Pendahuluan"], + ["ta", "TA", "Tes Awal"], + ["fitb", "FITB", "Fill in the blank"], + ["jurnal", "Jurnal", "Jurnal"], + ["tm", "Mandiri", "Mandiri"], + ["tk", "TK", "Tes Keterampilan"], +]; + +const getScrollParent = (node) => { + for (let el = node?.parentElement; el && el !== document.body; el = el.parentElement) { + if (/(auto|scroll)/.test(getComputedStyle(el).overflowY)) return el; + } + + return window; +}; + + +function SoalNavigator({ count, active, onChange }) { + const ref = useRef(null); + const [capacity, setCapacity] = useState(8); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const update = () => + setCapacity(Math.max(1, Math.floor(el.clientWidth / 38))); + + update(); + + const observer = new ResizeObserver(update); + observer.observe(el); + + return () => observer.disconnect(); + }, []); + + if (!count) return null; + + const visible = count <= capacity ? count : Math.max(1, capacity - 2); + const start = Math.max( + 0, + Math.min(active - Math.floor(visible / 2), count - visible), + ); + const end = Math.min(count, start + visible); + + const go = (index) => { + if (index >= 0 && index < count) onChange(index); + }; + + return ( +
+ + +
+ {start > 0 && ( + + )} + + {Array.from( + { length: end - start }, + (_, i) => start + i, + ).map((index) => ( + + ))} + + {end < count && ( + + )} +
+ + + {active + 1} / {count} + + + +
+ ); +} + + export default function FormSoalInput({ isEditable = true }) { - const [isModalSaveOpen, setIsModalSaveOpen] = useState(false); + const rootRef = useRef(null); + const restoreScrollRef = useRef(null); + const [kategoriSoal, setKategoriSoal] = useState(""); const [selectedModul, setSelectedModul] = useState(""); + const [activeSoal, setActiveSoal] = useState(0); + const { data: moduls = [], isLoading: modulesLoading, isError: modulesError, error: modulesQueryError, + refetch: refetchModules, } = useModulesQuery(); - const handleModulChange = (e) => { - const value = e.target.value; - setSelectedModul(value); + + /* + * Restore scroll immediately after a card-triggered render. + * + * This also works when the container did not overflow before + * the click but becomes scrollable after mounting the new content. + */ + useLayoutEffect(() => { + const saved = restoreScrollRef.current; + if (!saved) return; + + restoreScrollRef.current = null; + + if (saved.target === window) { + window.scrollTo(saved.left, saved.top); + } else { + saved.target.scrollLeft = saved.left; + saved.target.scrollTop = saved.top; + } + }); + + + const preserveScroll = (callback) => { + const target = getScrollParent(rootRef.current); + + restoreScrollRef.current = + target === window + ? { + target, + top: window.scrollY, + left: window.scrollX, + } + : { + target, + top: target.scrollTop, + left: target.scrollLeft, + }; + + callback(); + }; + + + const selectedModuleData = useMemo( + () => + selectedModul + ? moduls.find( + (m) => String(m.idM) === String(selectedModul), + ) ?? null + : null, + [moduls, selectedModul], + ); + + + const counts = useMemo( + () => + selectedModuleData + ? { + tp: selectedModuleData.soal_tp_count ?? 0, + ta: selectedModuleData.soal_ta_count ?? 0, + fitb: selectedModuleData.soal_fitb_count ?? 0, + jurnal: selectedModuleData.soal_jurnal_count ?? 0, + tm: selectedModuleData.soal_tm_count ?? 0, + tk: selectedModuleData.soal_tk_count ?? 0, + } + : null, + [selectedModuleData], + ); + + const currentCount = counts?.[kategoriSoal] ?? 0; + + + /* + * Dropdowns and cards ultimately use the same state changes. + * No automatic scrolling occurs here. + */ + const changeCategory = (value) => { + setKategoriSoal(value); + setActiveSoal(0); }; - const handleCloseModalSave = () => { - setIsModalSaveOpen(false); - setKategoriSoal(""); - setSelectedModul(""); + const changeModule = (value) => { + setSelectedModul(value); + setActiveSoal(0); }; - const handleValidationError = ({ message, includeModuleNotice = false } = {}) => { - const baseMessage = message ?? "Soal belum ditambahkan!!"; - const notice = includeModuleNotice ? " Pastikan memilih modul terlebih dahulu." : ""; - toast.error(`${baseMessage}${notice}`.trim()); + + /* + * Only the question navigator intentionally scrolls. + */ + const goToSoal = (index) => { + if (index < 0 || index >= currentCount) return; + + setActiveSoal(index); + + requestAnimationFrame(() => { + const target = document.getElementById( + `soal-${kategoriSoal}-${index}`, + ); + + if (!target) return; + + const parent = getScrollParent(target); + + if (parent === window) { + window.scrollTo({ + top: + window.scrollY + + target.getBoundingClientRect().top - + 100, + behavior: "smooth", + }); + + return; + } + + parent.scrollTo({ + top: + parent.scrollTop + + target.getBoundingClientRect().top - + parent.getBoundingClientRect().top - + 100, + behavior: "smooth", + }); + }); }; + + useEffect(() => { + setActiveSoal(0); + }, [kategoriSoal, selectedModul]); + + + const handleValidationError = ({ + message, + includeModuleNotice = false, + } = {}) => + toast.error( + `${message ?? "Soal belum ditambahkan!!"}${ + includeModuleNotice + ? " Pastikan memilih modul terlebih dahulu." + : "" + }`.trim(), + ); + + const handleSuccessNotification = () => { toast.success("Soal berhasil ditambahkan!!"); + refetchModules(); }; - const selectedModuleData = useMemo(() => { - if (!selectedModul) { - return null; - } - return ( - moduls.find((module) => String(module.idM) === String(selectedModul)) ?? null - ); - }, [moduls, selectedModul]); + const renderSoal = () => { + if (!kategoriSoal || !selectedModul) return null; - const soalCountMeta = useMemo(() => { - if (!selectedModuleData) { - return null; + const props = { + kategoriSoal, + modul: selectedModul, + modules: moduls, + onModalSuccess: handleSuccessNotification, + onModalValidation: handleValidationError, + onChangeModul: changeModule, + isEditable, + }; + + if (["tp", "fitb", "jurnal", "tm"].includes(kategoriSoal)) { + return ; } - return { - tp: selectedModuleData.soal_tp_count ?? 0, - ta: selectedModuleData.soal_ta_count ?? 0, - fitb: selectedModuleData.soal_fitb_count ?? 0, - jurnal: selectedModuleData.soal_jurnal_count ?? 0, - tm: selectedModuleData.soal_tm_count ?? 0, - tk: selectedModuleData.soal_tk_count ?? 0, - }; - }, [selectedModuleData]); + if (["ta", "tk"].includes(kategoriSoal)) { + return ; + } + + return null; + }; + return ( -
- {/* Pilih kategori soal dan modul */} +
+ {/* Category + Module */}
+
+
+
- {selectedModuleData && soalCountMeta && ( -
- {[ - { key: 'tp', label: 'TP' }, - { key: 'ta', label: 'TA' }, - { key: 'fitb', label: 'FITB' }, - { key: 'jurnal', label: 'Jurnal' }, - { key: 'tm', label: 'Mandiri' }, - { key: 'tk', label: 'TK' }, - ].map((item) => ( -
-
- {item.label} -
-
- {soalCountMeta[item.key] ?? 0} -
-
- ))} -
+ {/* Module cards */} + {!selectedModul && ( +
+
+

+ Pilih Modul +

+ +

+ Pilih modul Indonesia atau English untuk mulai + mengelola soal. +

+
+ + {modulesLoading ? ( +

+ Memuat modul... +

+ ) : modulesError ? ( +

+ {modulesQueryError?.message ?? + "Gagal memuat modul"} +

+ ) : ( +
+ {moduls.map((m) => { + const english = + Number(m?.isEnglish ?? 0) === 1; + + return ( + + ); + })} +
+ )} +
)} - {/* Input soal berdasarkan kategori soal */} - Memuat soal...
}> - {(() => { - if (!kategoriSoal) return null; - const essayTypes = ["tp", "fitb", "jurnal", "tm"]; - const pgTypes = ["ta", "tk"]; - if (essayTypes.includes(kategoriSoal) && selectedModul) { - return ( - - ); - } - if (pgTypes.includes(kategoriSoal) && selectedModul) { + + {/* Category cards */} + {selectedModuleData && counts && ( +
+ {CATEGORIES.map(([key, label]) => { + const selected = kategoriSoal === key; + return ( - + ); - } - return null; - })()} - + })} +
+ )} - {isModalSaveOpen && ( - - - + + {/* Sticky navigator */} + {selectedModul && kategoriSoal && currentCount > 0 && ( +
+ +
)} + + + {/* Questions */} +
+ + Memuat soal... +
+ } + > + {renderSoal()} + +
); } diff --git a/resources/js/Components/Assistants/Modals/ModalBatchEditSoal.jsx b/resources/js/Components/Assistants/Modals/ModalBatchEditSoal.jsx index a3889b4..a16fe26 100644 --- a/resources/js/Components/Assistants/Modals/ModalBatchEditSoal.jsx +++ b/resources/js/Components/Assistants/Modals/ModalBatchEditSoal.jsx @@ -1,351 +1,692 @@ import { useEffect, useMemo, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import remarkBreaks from "remark-breaks"; -import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'; -import c from 'react-syntax-highlighter/dist/esm/languages/prism/c'; -import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; + import { ModalOverlay } from "@/Components/Common/ModalPortal"; import ModalCloseButton from "@/Components/Common/ModalCloseButton"; +import DepthToggleButton from "@/Components/Common/DepthToggleButton"; + +import MarkdownRenderer from "../../MarkdownRenderer"; +import PairNavigator from "./PairNavigator"; + + +const getModuleId = (item) => { + const value = + item?.idM ?? + item?.id ?? + item?.value ?? + item?.uuid ?? + item?.ID; + + return value == null ? "" : String(value); +}; + + +const getModuleLabel = (item) => + item?.judul ?? + item?.nama ?? + item?.nama_modul ?? + item?.label ?? + `Modul ${getModuleId(item)}`; + + +const extractQuestions = (dataset) => { + if (Array.isArray(dataset)) return dataset; + + return [ + dataset?.soal, + dataset?.questions, + dataset?.items, + dataset?.data, + dataset?.data?.soal, + dataset?.data?.questions, + dataset?.data?.items, + dataset?.data?.data, + ].find(Array.isArray) ?? []; +}; + + +const emptyDraft = () => ({ + id: null, + soal: "", + enable_file_upload: false, + originalSoal: "", + originalEnableFileUpload: false, + _deleted: false, +}); + + +const createDrafts = (dataset) => + extractQuestions(dataset).map((item) => { + const soal = item?.soal ?? item?.pertanyaan ?? ""; + const upload = Boolean(item?.enable_file_upload); + + return { + id: item?.id ?? item?.soal_id ?? null, + soal, + enable_file_upload: upload, + originalSoal: soal, + originalEnableFileUpload: upload, + _deleted: false, + }; + }); + + +const activeCount = (items) => + items.filter( + (item) => + !item?._deleted && + String(item?.soal ?? "").trim(), + ).length; + -SyntaxHighlighter.registerLanguage('c', c); +function QuestionCard({ + item, + index, + side, + supportsFileUpload, + isSaving, + onChange, + onCreate, + onCopy, +}) { + if (!item) { + return ( +
+

+ Soal {index + 1} tidak ada +

+ +

+ Modul {side} memiliki soal lebih sedikit. +

+ + +
+ ); + } -const tabs = [ - { key: "text", label: "Text" }, - { key: "preview", label: "Preview" }, - { key: "split", label: "Side by Side" }, -]; + if (item._deleted) { + return ( +
+ + Soal {index + 1} akan dihapus + + + +
+ ); + } + + return ( +
+
+
+ + Soal {index + 1} + + + + {side} + + + {!item.id && ( + + BARU + + )} +
+ +
+ {onCopy && ( + + )} + + +
+
+ +
+
+ + +