mutu-rsab/resources/views/soal/index.blade.php
2025-12-04 09:49:23 +07:00

1345 lines
51 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@extends('layouts.template')
@section('title', 'Kuesioner Soal')
@section('custom_css')
<style>
.question-card {
border: 1px solid #dee2e6;
border-radius: 12px;
padding: 1.25rem;
background: #fff;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.04);
}
.question-layout .question-text-col {
border-right: 1px solid #f1f3f5;
}
.question-layout .answer-section {
padding-left: 1.5rem;
}
@media (max-width: 767.98px) {
.question-layout .question-text-col {
border-right: 0;
border-bottom: 1px dashed #e9ecef;
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.question-layout .answer-section {
padding-left: 0;
}
}
.option-scroll {
max-height: 220px;
overflow-y: auto;
border: 1px dashed #ced4da;
border-radius: 0.65rem;
padding: 0.75rem 1rem;
background-color: #f8f9fa;
}
.option-scroll::-webkit-scrollbar {
width: 6px;
}
.option-scroll::-webkit-scrollbar-thumb {
background-color: rgba(13, 110, 253, 0.4);
border-radius: 3px;
}
.option-search {
position: relative;
}
.option-search .search-icon {
position: absolute;
top: 50%;
left: 0.85rem;
transform: translateY(-50%);
color: #adb5bd;
pointer-events: none;
}
.option-search .search-icon svg {
width: 14px;
height: 14px;
display: block;
}
.option-search input {
padding-left: 2.4rem;
}
.lainnya-input {
display: none;
}
.lainnya-input.show {
display: block;
margin-top: 0.85rem;
}
.dual-form-wrapper .form-label {
font-weight: 600;
}
/* Skala di atas slider */
.range-scale {
position: relative;
height: 45px;
margin-top: 0px;
border-top: 2px solid #495057;
padding-top: 2px;
}
.range-track-colored {
position: relative;
margin-top: 0px;
}
.range-track-colored .range-colored {
width: 100%;
-webkit-appearance: none;
background: #ffffff;
height: 10px;
border-radius: 5px;
border: 1px solid #dee2e6;
}
/* hilangkan track default browser */
.range-colored::-webkit-slider-runnable-track {
background: transparent;
height: 10px;
}
.range-colored::-moz-range-track {
background: transparent;
height: 10px;
}
.range-scale-item {
position: absolute;
top: 0;
transform: translateX(-50%);
text-align: center;
font-size: 11px;
color: #495057;
display: flex;
flex-direction: column;
align-items: center;
}
.range-scale-label {
display: block;
margin-bottom: 4px;
font-weight: 500;
}
.range-scale-mark {
display: block;
width: 1px;
height: 8px;
background-color: #495057;
}
/* Tick besar untuk angka 1 dan kelipatan 10 */
.range-scale-item-major .range-scale-mark {
height: 12px;
width: 2px;
background-color: #212529;
}
.range-scale-item-major .range-scale-label {
font-weight: 600;
color: #212529;
font-size: 12px;
}
/* Tick khusus angka 1 */
.range-scale-item-one .range-scale-mark {
height: 12px;
background-color: #212529;
width: 2px;
}
.range-scale-item-one .range-scale-label {
color: #212529;
font-weight: 600;
font-size: 12px;
}
</style>
@endsection
@section('content')
@php
$listHal = $daftarHal ?? collect([$hal]);
$posisiAktif = $listHal->search($hal);
if ($posisiAktif === false) {
$posisiAktif = 0;
}
$totalHalaman = $totalHal ?? $listHal->count();
$progressPercentage = $totalHalaman > 0 ? round((($posisiAktif + 1) / max($totalHalaman, 1)) * 100) : 100;
$halSebelumnya = $posisiAktif > 0 ? $listHal[$posisiAktif - 1] : null;
$halBerikut = ($posisiAktif < $listHal->count() - 1) ? $listHal[$posisiAktif + 1] : null;
$isHalTerakhir = $halBerikut === null;
$formLocked = $formLocked ?? false;
$prefillJawaban = $prefillJawaban ?? [];
$existingJawaban = $existingJawaban ?? [];
@endphp
<div class="py-4">
<div class="row justify-content-center">
<div class="col-xl-8 col-lg-10">
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-2 mb-3">
<div>
<p class="text-muted mb-1">Kuesioner</p>
<h3 class="fw-semibold mb-0">{{ $soal->judul_soal ?? 'Daftar Soal' }}</h3>
</div>
<div class="d-flex align-items-center gap-2">
<a href="{{ route('soal.index') }}" class="btn btn-outline-secondary btn-sm">
&larr; Daftar Judul
</a>
<span class="badge bg-label-primary fs-6 px-3 py-2" id="badge-hal">Halaman {{ $hal }}</span>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="d-flex justify-content-between small text-muted mb-1">
<span>Progres Halaman</span>
<span id="progress-text">{{ $progressPercentage }}%</span>
</div>
<div class="progress" style="height: 8px;">
<div class="progress-bar" id="progress-bar" role="progressbar" style="width: {{ $progressPercentage }}%;" aria-valuenow="{{ $progressPercentage }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
@if ($hal === $halPertama)
<div class="card border-0 shadow-sm mb-4" id="head_soal">
<div class="card-body">
<h5 class="mb-3 text-primary">{{ $soal->judul_soal ?? '-' }}</h5>
<div class="border rounded p-3 bg-light text-body">
{!! $soal->keterangan_soal !!}
</div>
</div>
</div>
@endif
@if ($errors->any())
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<strong>Terjadi kesalahan.</strong> Silakan periksa kembali jawaban Anda.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
@endif
@if ($formLocked)
<div class="alert alert-info">
Anda sudah mengisi kuesioner ini. Jawaban di bawah ditampilkan dalam mode baca.
</div>
@endif
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<h5 class="mb-0">Daftar Pertanyaan</h5>
<small class="text-muted" id="summary-hal">Halaman {{ $hal }} dari {{ $listHal->count() }}</small>
</div>
<form id="form-soal" method="POST" action="{{ route('soal.store') }}" data-hal-list='@json($listHal->values())' data-form-locked="{{ $formLocked ? '1' : '0' }}">
@csrf
<input type="hidden" name="lms_mutu_soal_id" value="{{ $soal->id }}">
<input type="hidden" name="hal" id="input-hal" value="{{ $hal }}">
@php
$questionCounter = 0;
@endphp
@forelse ($soal->soalDetail as $detail)
@php
$detailConfig = json_decode($detail->soal, true) ?? [];
$pertanyaan = $detailConfig['soal'] ?? 'Pertanyaan tidak tersedia';
$type = $detailConfig['type'] ?? 'option';
$optionsRaw = $detailConfig['options'] ?? [];
if (is_array($optionsRaw)) {
$options = $optionsRaw;
} elseif ($type === 'option_with_range' && is_string($optionsRaw)) {
$options = [$optionsRaw];
} else {
$options = [];
}
$autoSelect = ($type === 'option' && count($options) == 1) ? true : false;
if (!is_array($options)) {
$options = [];
}
$optionValues = collect($options)->map(function ($value) {
return is_scalar($value) ? (string) $value : '';
})->all();
$existingAnswer = $existingJawaban[$detail->id] ?? null;
$prefillAnswer = $prefillJawaban[$detail->id] ?? null;
$hasDefaultAnswer = array_key_exists('default', $detailConfig);
$defaultAnswer = $hasDefaultAnswer ? $detailConfig['default'] : null;
$currentAnswer = old('jawaban.' . $detail->id, $existingAnswer ?? $prefillAnswer);
if (($currentAnswer === null || $currentAnswer === '') && $hasDefaultAnswer) {
$currentAnswer = $defaultAnswer;
}
$existingOtherAnswer = $existingAnswer && !in_array($existingAnswer, $optionValues, true) ? $existingAnswer : null;
$prefillOtherAnswer = $prefillAnswer && !in_array($prefillAnswer, $optionValues, true) ? $prefillAnswer : null;
$oldOtherAnswer = old('jawaban_lainnya.' . $detail->id, $existingOtherAnswer ?? $prefillOtherAnswer);
$hasCurrentAnswer = $currentAnswer !== null && $currentAnswer !== '';
$isCustomCurrentAnswer = $hasCurrentAnswer && !in_array($currentAnswer, $optionValues, true);
if (!$oldOtherAnswer && $isCustomCurrentAnswer) {
$oldOtherAnswer = $currentAnswer;
}
$showLainnya = $isCustomCurrentAnswer
|| (is_string($currentAnswer) && stripos($currentAnswer, 'lainnya') !== false)
|| (!empty($oldOtherAnswer));
$detailHal = $detail->hal ?? $listHal->first();
$isVisible = $detailHal == $hal;
$optionsCount = is_array($options) ? count($options) : 0;
$hasLainnyaOption = collect($options)->contains(function ($optionItem) {
return is_string($optionItem) && stripos($optionItem, 'lainnya') !== false;
});
if (!$hasLainnyaOption && $type === 'option_with_other') {
$options[] = 'Lainnya';
$optionsCount = count($options);
$hasLainnyaOption = true;
}
$isConsentQuestion = !empty($detailConfig['persetujuan_form']);
$shouldForceLainnyaSelection = $hasLainnyaOption && $showLainnya;
$dualFormConfig = $detailConfig['dual_form'] ?? null;
$useDualForm = $type === 'dual_form' || (!empty($dualFormConfig) && $dualFormConfig !== false);
$dualYearOld = null;
$dualMonthOld = null;
$questionNumber = $detailConfig['no'] ?? '';
$rangeMin = null;
$rangeMax = null;
$rangeStep = 1;
$rangeDefault = null;
$rangeTicks = [];
$rangeSubTickCount = 4;
$rangeMajorStep = 10;
if ($useDualForm && $currentAnswer) {
if (preg_match('/([0-9]+)\\s*\\(Tahun\\)/i', $currentAnswer, $matchYear)) {
$dualYearOld = $matchYear[1];
}
if (preg_match('/([0-9]+)\\s*\\(Bulan\\)/i', $currentAnswer, $matchMonth)) {
$dualMonthOld = $matchMonth[1];
}
}
if ($type === 'option_with_range' && !empty($options)) {
$rangeSource = $options[0];
if (is_array($rangeSource)) {
$rangeSource = $rangeSource['range'] ?? (isset($rangeSource['min'], $rangeSource['max']) ? $rangeSource['min'] . '-' . $rangeSource['max'] : null);
}
if (is_string($rangeSource) && preg_match('/(-?[0-9]+(?:[\\.,][0-9]+)?)\\s*-\\s*(-?[0-9]+(?:[\\.,][0-9]+)?)/', $rangeSource, $rangeMatch)) {
$rangeMin = (float) str_replace(',', '.', $rangeMatch[1]);
$rangeMax = (float) str_replace(',', '.', $rangeMatch[2]);
if ($rangeMin > $rangeMax) {
[$rangeMin, $rangeMax] = [$rangeMax, $rangeMin];
}
$rangeUsesDecimal = (fmod($rangeMin, 1) !== 0.0) || (fmod($rangeMax, 1) !== 0.0);
$rangeStep = $rangeUsesDecimal ? 0.1 : 1;
if (is_numeric($currentAnswer)) {
$numericAnswer = (float) $currentAnswer;
$rangeDefault = min(max($numericAnswer, $rangeMin), $rangeMax);
} else {
$rangeDefault = $rangeMin;
}
if ($rangeDefault === null) {
$rangeDefault = $rangeMin;
}
$rangeSpan = max($rangeMax - $rangeMin, 1);
$majorAnchors = [$rangeMin, $rangeMax];
if ($rangeMin <= 1 && $rangeMax >= 1) {
$majorAnchors[] = 1;
}
$firstMajor = max($rangeMin, ($rangeMin <= 1 ? 10 : ceil($rangeMin / $rangeMajorStep) * $rangeMajorStep));
for ($v = $firstMajor; $v <= $rangeMax; $v += $rangeMajorStep) {
$majorAnchors[] = $v;
}
sort($majorAnchors);
$uniqueAnchors = [];
foreach ($majorAnchors as $anchor) {
if (empty($uniqueAnchors) || abs(end($uniqueAnchors) - $anchor) > 0.0001) {
$uniqueAnchors[] = $anchor;
}
}
$majorAnchors = array_values(array_filter($uniqueAnchors, function ($value) use ($rangeMin, $rangeMax) {
return $value >= $rangeMin && $value <= $rangeMax;
}));
$ticks = [];
$addTick = function ($value, $isMajor = false) use (&$ticks, $rangeMin, $rangeSpan, $rangeMajorStep) {
$position = $rangeSpan > 0 ? (($value - $rangeMin) / $rangeSpan) * 100 : 0;
$isOne = abs($value - 1) < 0.0001;
$isMultiple = $rangeMajorStep > 0 ? abs(fmod($value, $rangeMajorStep)) < 0.0001 : false;
$ticks[] = [
'value' => $value,
'label' => ($isOne || $isMultiple) ? (int) round($value) : null,
'position' => max(0, min(100, $position)),
'is_major' => $isMajor,
'is_one' => $isOne,
];
};
$countAnchors = count($majorAnchors);
if ($countAnchors === 0) {
$addTick($rangeMin, true);
} else {
for ($i = 0; $i < $countAnchors; $i++) {
$currentAnchor = $majorAnchors[$i];
$addTick($currentAnchor, true);
if ($i === $countAnchors - 1) {
continue;
}
$nextAnchor = $majorAnchors[$i + 1];
$segmentLength = $nextAnchor - $currentAnchor;
if ($segmentLength <= 0 || $rangeSubTickCount <= 0) {
continue;
}
for ($sub = 1; $sub <= $rangeSubTickCount; $sub++) {
$fraction = $sub / ($rangeSubTickCount + 1);
$value = $currentAnchor + ($segmentLength * $fraction);
$addTick($value, false);
}
}
}
usort($ticks, function ($a, $b) {
if ($a['value'] == $b['value']) {
return 0;
}
return ($a['value'] < $b['value']) ? -1 : 1;
});
$rangeTicks = $ticks;
}
}
$rangeMinDisplay = ($rangeMin !== null && floor($rangeMin) == $rangeMin) ? (int) $rangeMin : $rangeMin;
$rangeMaxDisplay = ($rangeMax !== null && floor($rangeMax) == $rangeMax) ? (int) $rangeMax : $rangeMax;
$rangeDefaultDisplay = ($rangeDefault !== null && floor($rangeDefault) == $rangeDefault) ? (int) $rangeDefault : $rangeDefault;
@endphp
<div class="question-card mb-4"
data-hal-card="{{ $detailHal }}"
data-detail-id="{{ $detail->id }}"
data-consent-question="{{ $isConsentQuestion ? '1' : '0' }}"
data-question-type="{{ $type }}"
style="{{ $isVisible ? '' : 'display: none;' }}">
<div class="row g-3 align-items-start question-layout">
<div class="col-md-6 question-text-col">
<div class="d-flex align-items-center gap-3 mb-1">
@if ($questionNumber !== null)
<span class="badge rounded-pill bg-label-primary fs-6">{{ $questionNumber }}</span>
@endif
<h5 class="fw-semibold mb-0">{{ $pertanyaan }}</h5>
</div>
@if ($isConsentQuestion)
<p class="text-muted small mb-0">Pertanyaan persetujuan</p>
@endif
</div>
<div class="col-md-6 answer-section">
@if ($useDualForm)
<div class="dual-form-wrapper" data-dual-wrapper="{{ $detail->id }}">
<div class="row g-3">
<div class="col-sm-6">
<label class="form-label small text-muted mb-1">Tahun</label>
<input type="number" min="0" class="form-control"
data-dual-input="tahun"
value="{{ $dualYearOld }}"
data-field-hal="{{ $detailHal }}"
@if($formLocked) disabled @endif
placeholder="Masukkan tahun">
</div>
<div class="col-sm-6">
<label class="form-label small text-muted mb-1">Bulan</label>
<input type="number" min="0" class="form-control"
data-dual-input="bulan"
value="{{ $dualMonthOld }}"
data-field-hal="{{ $detailHal }}"
@if($formLocked) disabled @endif
placeholder="Masukkan bulan">
</div>
</div>
<input type="hidden"
class="@error('jawaban.' . $detail->id) is-invalid @enderror"
name="jawaban[{{ $detail->id }}]"
value="{{ $currentAnswer }}"
data-dual-hidden="{{ $detail->id }}"
data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" required @endif
@if($formLocked) disabled @endif>
</div>
@elseif ($type === 'textarea')
<textarea class="form-control @error('jawaban.' . $detail->id) is-invalid @enderror"
name="jawaban[{{ $detail->id }}]" rows="4"
data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" required @endif
@if($formLocked) disabled @endif
placeholder="Tulis jawaban Anda di sini">{{ $currentAnswer }}</textarea>
@elseif ($type === 'text')
<input type="text" class="form-control @error('jawaban.' . $detail->id) is-invalid @enderror"
name="jawaban[{{ $detail->id }}]" value="{{ $currentAnswer }}"
data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" required @endif
@if($formLocked) disabled @endif
placeholder="Masukkan jawaban Anda">
@elseif ($type === 'option_with_range')
@if ($rangeMin !== null && $rangeMax !== null)
<div class="range-slider-wrapper" data-range-wrapper="{{ $detail->id }}">
{{-- Track warna + slider --}}
<div class="range-track-colored">
<input type="range"
class="form-range range-colored border"
name="jawaban[{{ $detail->id }}]"
min="{{ $rangeMin }}"
max="{{ $rangeMax }}"
step="{{ $rangeStep }}"
value="{{ $rangeDefault ?? $rangeMin }}"
data-range-input="{{ $detail->id }}"
data-range-min-value="{{ $rangeMin }}"
data-range-max-value="{{ $rangeMax }}"
data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" required @endif
@if($formLocked) disabled @endif>
</div>
<div class="range-scale" aria-hidden="true">
@foreach ($rangeTicks as $tick)
@php
$label = $tick['label'] ?? null;
$isMajor = !empty($tick['is_major']);
$isOneTick = !empty($tick['is_one']);
@endphp
<div class="range-scale-item
{{ $isMajor ? 'range-scale-item-major' : '' }}
{{ $isOneTick ? 'range-scale-item-one' : '' }}"
style="left: {{ $tick['position'] }}%;">
<span class="range-scale-mark"></span>
@if(!is_null($label))
<span class="range-scale-label">{{ $label }}</span>
@endif
</div>
@endforeach
</div>
<div class="text-center mt-2 fw-semibold">
Nilai saat ini:
<span data-range-output="{{ $detail->id }}">{{ $rangeDefaultDisplay ?? $rangeMinDisplay }}</span>
</div>
<div class="input-group input-group-sm mt-3 range-manual-input">
<span class="input-group-text">Atau isi manual</span>
<input type="number"
class="form-control"
min="{{ $rangeMin }}"
max="{{ $rangeMax }}"
step="{{ $rangeStep }}"
value="{{ $rangeDefault ?? $rangeMin }}"
data-range-manual="{{ $detail->id }}"
data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" @endif
@if($formLocked) disabled @endif>
</div>
</div>
@else
<input type="text" class="form-control @error('jawaban.' . $detail->id) is-invalid @enderror"
name="jawaban[{{ $detail->id }}]" value="{{ $currentAnswer }}"
data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" required @endif
@if($formLocked) disabled @endif
placeholder="Masukkan jawaban Anda">
@endif
@else
@if (!empty($options))
@php
$lainnyaWrapperRendered = false;
@endphp
<div class="option-search mb-3">
@if(count($options) > 6)
<input type="text"
class="form-control form-control-sm option-search-input"
placeholder="Cari jawaban"
data-option-search="{{ $detail->id }}"
autocomplete="off">
@endif
</div>
<div class="option-scroll" data-option-list="{{ $detail->id }}">
@foreach ($options as $optionIndex => $option)
@php
$optionId = 'jawaban-' . $detail->id . '-' . $optionIndex;
$optionLabel = is_scalar($option) ? (string) $option : '';
$isLainnya = stripos($optionLabel, 'lainnya') !== false;
$optionValue = $isLainnya && $oldOtherAnswer ? $oldOtherAnswer : $optionLabel;
$shouldCheck = $currentAnswer === $optionValue || ($isLainnya && $shouldForceLainnyaSelection);
@endphp
<div class="form-check mb-2" data-option-item>
<input class="form-check-input @error('jawaban.' . $detail->id) is-invalid @enderror"
type="radio"
name="jawaban[{{ $detail->id }}]"
id="{{ $optionId }}"
value="{{ $optionValue }}"
data-original-value="{{ $optionLabel }}"
data-lainnya-radio="{{ $isLainnya ? $detail->id : '' }}"
data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" required @endif
@if($formLocked) disabled @endif
{{ $shouldCheck || $autoSelect ? 'checked' : '' }}>
<label class="form-check-label" for="{{ $optionId }}">
{{ $optionLabel }}
</label>
</div>
@if ($isLainnya)
@php
$lainnyaWrapperRendered = true;
@endphp
<div class="lainnya-input {{ $showLainnya ? 'show' : '' }}" data-lainnya-wrapper="{{ $detail->id }}">
<input type="text" class="form-control form-control-sm mt-2"
name="jawaban_lainnya[{{ $detail->id }}]"
value="{{ $oldOtherAnswer }}"
data-lainnya-input="{{ $detail->id }}"
data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" @endif
@if($formLocked) disabled @endif
placeholder="Tuliskan jawaban lainnya">
</div>
@endif
@endforeach
</div>
@if ($hasLainnyaOption || $type === 'option_with_other')
<small class="form-text text-muted">
Jika jawaban yang Anda cari tidak ada di daftar, pilih opsi <strong>"Lainnya"</strong> lalu isi sesuai kebutuhan.
</small>
@endif
<div class="text-muted small mt-2" data-option-empty="{{ $detail->id }}" style="display: none;">
Tidak ada jawaban yang cocok.
</div>
@if (!$lainnyaWrapperRendered && $type === 'option_with_other')
<div class="lainnya-input {{ $showLainnya ? 'show' : '' }}" data-lainnya-wrapper="{{ $detail->id }}">
<input type="text" class="form-control form-control-sm mt-2"
name="jawaban_lainnya[{{ $detail->id }}]"
value="{{ $oldOtherAnswer }}"
data-lainnya-input="{{ $detail->id }}"
data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" @endif
@if($formLocked) disabled @endif
placeholder="Tuliskan jawaban lainnya">
</div>
@endif
@else
<input type="text" class="form-control @error('jawaban.' . $detail->id) is-invalid @enderror"
name="jawaban[{{ $detail->id }}]" value="{{ $currentAnswer }}"
data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" @endif
@if($formLocked) disabled @endif
placeholder="Masukkan jawaban Anda">
@endif
@endif
@error('jawaban.' . $detail->id)
<div class="invalid-feedback d-block">{{ $message }}</div>
@enderror
@error('jawaban_lainnya.' . $detail->id)
<div class="text-danger small mt-1">{{ $message }}</div>
@enderror
</div>
</div>
</div>
@empty
<div class="alert alert-info">
Belum ada pertanyaan pada halaman ini.
</div>
@endforelse
@error('lms_mutu_soal_id')
<p class="text-danger mb-0">{{ $message }}</p>
@enderror
@error('jawaban')
<p class="text-danger mb-0">{{ $message }}</p>
@enderror
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mt-4 gap-3">
<div class="d-flex gap-2">
<button type="button"
class="btn btn-outline-secondary btn-prev-hal"
data-nav-control="prev"
{{ $halSebelumnya ? '' : 'disabled' }}>
&larr; Halaman Sebelumnya
</button>
<button type="button"
class="btn btn-outline-secondary btn-next-hal"
data-nav-control="next"
{{ $halBerikut ? '' : 'disabled' }}>
Halaman Berikutnya &rarr;
</button>
</div>
<button type="submit" class="btn btn-primary ms-md-auto"
data-final-submit="true"
style="{{ (!$isHalTerakhir || $formLocked) ? 'display: none;' : '' }}"
{{ $soal->soalDetail->isEmpty() || $formLocked ? 'disabled' : '' }}>
Simpan Jawaban
</button>
</div>
</form>
</div>
</div>
</div>
@endsection
@section('custom_js')
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('form-soal');
if (!form) {
return;
}
let halList;
try {
halList = JSON.parse(form.dataset.halList || '[]');
} catch (error) {
halList = [];
}
if (!Array.isArray(halList) || !halList.length) {
halList = [parseInt(document.getElementById('input-hal')?.value || '1', 10) || 1];
}
halList = halList.map(function (value) {
return parseInt(value, 10);
});
const hiddenHalInput = document.getElementById('input-hal');
let currentHal = parseInt(hiddenHalInput?.value || halList[0], 10);
const formLocked = form.dataset.formLocked === '1';
const navHalButtons = document.querySelectorAll('.nav-hal-btn');
const questionCards = document.querySelectorAll('[data-hal-card]');
const halInputs = document.querySelectorAll('[data-field-hal]');
const prevButton = document.querySelector('.btn-prev-hal');
const nextButton = document.querySelector('.btn-next-hal');
const finalSubmitButton = document.querySelector('[data-final-submit="true"]');
const badgeHal = document.getElementById('badge-hal');
const progressBar = document.getElementById('progress-bar');
const progressText = document.getElementById('progress-text');
const summaryHal = document.getElementById('summary-hal');
const totalHal = halList.length || 1;
const nonConsentFields = document.querySelectorAll('[data-field-hal]:not([data-consent-input="1"])');
const consentFields = document.querySelectorAll('[data-consent-input="1"][name]');
const headSoalCard = document.getElementById('head_soal');
const consentNegativeKeywords = ['tidak', 'tidak setuju'];
let immediateSubmitActive = false;
function normalizeOtherValue(value) {
return (value || '').toString().trim().toLowerCase();
}
function isOtherChoice(value) {
const normalized = normalizeOtherValue(value);
if (!normalized) {
return false;
}
return normalized === 'lainnya'
|| normalized === 'lainnya (sebutkan)'
|| normalized === 'lainnya/other'
|| normalized === 'lainnya / other'
|| normalized === 'other'
|| normalized === 'others'
|| normalized.includes('lainnya')
|| normalized.includes('other');
}
function getLabelTextByInput(input) {
if (!input || !input.id) {
return '';
}
const label = document.querySelector('label[for="' + input.id + '"]');
return label ? (label.textContent || '') : '';
}
halInputs.forEach(function (field) {
if (field.dataset && field.dataset.lainnyaInput) {
field.dataset.originalRequired = '0';
field.dataset.dynamicRequired = field.required ? '1' : '0';
} else {
field.dataset.originalRequired = field.required ? '1' : '0';
field.dataset.dynamicRequired = field.dataset.dynamicRequired || '0';
}
});
setupLainnyaInputs();
setupOptionSearch();
setupRangeInputs();
setupDualFormInputs();
setupConsentWatcher();
updateQuestionVisibility();
updateNavigationUI();
navHalButtons.forEach(function (button) {
button.addEventListener('click', function () {
const targetHal = parseInt(button.dataset.navHal, 10);
changeHal(targetHal);
});
});
if (prevButton) {
prevButton.addEventListener('click', function () {
navigateRelative(-1);
});
}
if (nextButton) {
nextButton.addEventListener('click', function () {
navigateRelative(1);
});
}
form.addEventListener('submit', function (event) {
if (formLocked) {
event.preventDefault();
return false;
}
if (!immediateSubmitActive) {
return;
}
nonConsentFields.forEach(function (field) {
if (shouldDisableFieldForImmediate(field)) {
field.disabled = true;
}
});
});
function navigateRelative(step) {
if (immediateSubmitActive) {
return;
}
const currentIndex = halList.indexOf(currentHal);
const targetHal = halList[currentIndex + step];
if (typeof targetHal === 'undefined') {
return;
}
if (headSoalCard) {
if (targetHal === 1) {
headSoalCard.classList.remove('d-none');
} else {
headSoalCard.classList.add('d-none');
}
}
changeHal(targetHal);
}
function changeHal(targetHal) {
if (immediateSubmitActive) {
return;
}
if (targetHal === currentHal || halList.indexOf(targetHal) === -1) {
return;
}
const currentIndex = halList.indexOf(currentHal);
const targetIndex = halList.indexOf(targetHal);
const movingForward = targetIndex > currentIndex;
if (movingForward && !validateHal(currentHal)) {
return;
}
currentHal = targetHal;
if (hiddenHalInput) {
hiddenHalInput.value = currentHal;
}
updateQuestionVisibility();
updateNavigationUI();
scrollPageToTop();
}
function validateHal(halValue) {
const fieldsToDisable = [];
halInputs.forEach(function (field) {
const fieldHal = parseInt(field.dataset.fieldHal, 10);
if (fieldHal !== halValue && field.required) {
field.dataset.tmpRequired = '1';
field.required = false;
fieldsToDisable.push(field);
}
});
const isValid = form.checkValidity();
if (!isValid) {
form.reportValidity();
}
fieldsToDisable.forEach(function (field) {
field.required = true;
delete field.dataset.tmpRequired;
});
return isValid;
}
function updateQuestionVisibility() {
questionCards.forEach(function (card) {
const halValue = parseInt(card.dataset.halCard, 10);
card.style.display = halValue === currentHal ? '' : 'none';
});
}
function updateNavigationUI() {
const currentIndex = halList.indexOf(currentHal);
const isFirst = currentIndex <= 0;
const isLast = currentIndex === halList.length - 1;
const consentBlocked = hasIncompleteConsentForHal(currentHal);
if (prevButton) {
prevButton.disabled = immediateSubmitActive ? true : isFirst;
}
if (nextButton) {
if (immediateSubmitActive || consentBlocked) {
nextButton.style.display = 'none';
} else {
nextButton.style.display = '';
nextButton.disabled = isLast;
}
}
navHalButtons.forEach(function (button) {
const targetHal = parseInt(button.dataset.navHal, 10);
const isActive = targetHal === currentHal;
const movingForward = targetHal > currentHal;
button.classList.toggle('btn-primary', isActive);
button.classList.toggle('btn-outline-primary', !isActive);
button.disabled = immediateSubmitActive || (consentBlocked && movingForward);
});
const progress = totalHal > 0 ? Math.round(((currentIndex + 1) / totalHal) * 100) : 100;
if (badgeHal) {
badgeHal.textContent = 'Halaman ' + currentHal;
}
if (progressBar) {
progressBar.style.width = progress + '%';
progressBar.setAttribute('aria-valuenow', progress);
}
if (progressText) {
progressText.textContent = progress + '%';
}
if (summaryHal) {
summaryHal.textContent = 'Halaman ' + (currentIndex + 1) + ' dari ' + totalHal;
}
if (finalSubmitButton) {
const canShowFinalButton = !formLocked && (isLast || immediateSubmitActive);
finalSubmitButton.style.display = canShowFinalButton ? '' : 'none';
finalSubmitButton.disabled = !canShowFinalButton;
}
}
function setupLainnyaInputs() {
const radios = form.querySelectorAll('input[type="radio"][name^="jawaban["]');
radios.forEach(function (radio) {
radio.addEventListener('change', function (event) {
const customDetailId = event.target.getAttribute('data-lainnya-radio');
const detailId = customDetailId || event.target.name.replace('jawaban[', '').replace(']', '');
const targetWrapper = detailId ? form.querySelector('[data-lainnya-wrapper="' + detailId + '"]') : null;
const labelText = getLabelTextByInput(event.target);
const shouldShow = !!customDetailId
|| isOtherChoice(event.target.value)
|| isOtherChoice(event.target.dataset.originalValue)
|| isOtherChoice(labelText);
if (targetWrapper) {
if (shouldShow) {
handleLainnyaInput(targetWrapper, true);
} else {
handleLainnyaInput(targetWrapper, false, true);
}
}
});
});
form.querySelectorAll('[data-lainnya-input]').forEach(function (input) {
input.addEventListener('input', function (event) {
const detailId = input.getAttribute('data-lainnya-input');
syncCustomOptionValue(detailId, event.target.value);
});
});
form.querySelectorAll('[data-lainnya-wrapper].show').forEach(function (wrapper) {
handleLainnyaInput(wrapper, true);
});
}
function handleLainnyaInput(wrapper, show, clearValue = false) {
if (!wrapper) {
return;
}
wrapper.classList.toggle('show', show);
const input = wrapper.querySelector('input');
const detailId = wrapper.getAttribute('data-lainnya-wrapper');
if (input) {
input.required = show;
input.dataset.dynamicRequired = show ? '1' : '0';
if (show) {
input.focus();
syncCustomOptionValue(detailId, input.value);
} else {
if (clearValue) {
input.value = '';
}
resetCustomOptionValue(detailId);
}
} else if (!show) {
resetCustomOptionValue(detailId);
}
}
function syncCustomOptionValue(detailId, customValue) {
if (!detailId) {
return;
}
const trimmedValue = (customValue || '').toString().trim();
const radio = form.querySelector('input[data-lainnya-radio="' + detailId + '"]');
if (radio) {
const originalValue = radio.dataset.originalValue || 'lainnya';
radio.value = trimmedValue || originalValue;
}
}
function resetCustomOptionValue(detailId) {
if (!detailId) {
return;
}
const radio = form.querySelector('input[data-lainnya-radio="' + detailId + '"]');
if (radio && radio.dataset.originalValue) {
radio.value = radio.dataset.originalValue;
}
}
function setupOptionSearch() {
const searchInputs = form.querySelectorAll('[data-option-search]');
searchInputs.forEach(function (input) {
const detailId = input.getAttribute('data-option-search');
const listContainer = detailId ? form.querySelector('[data-option-list="' + detailId + '"]') : null;
if (!listContainer) {
return;
}
const emptyState = detailId ? form.querySelector('[data-option-empty="' + detailId + '"]') : null;
const handler = function () {
filterOptionList(listContainer, input.value, emptyState);
};
input.addEventListener('input', handler);
handler();
});
}
function filterOptionList(container, query, emptyState) {
const normalized = (query || '').toString().trim().toLowerCase();
let visibleCount = 0;
container.querySelectorAll('[data-option-item]').forEach(function (item) {
const label = item.querySelector('.form-check-label');
const text = (label ? label.textContent : item.textContent || '').toString().trim().toLowerCase();
const shouldShow = !normalized || text.indexOf(normalized) !== -1;
item.style.display = shouldShow ? '' : 'none';
if (shouldShow) {
visibleCount++;
}
});
if (emptyState) {
emptyState.style.display = visibleCount ? 'none' : '';
}
}
function setupRangeInputs() {
const rangeInputs = form.querySelectorAll('[data-range-input]');
rangeInputs.forEach(function (input) {
const detailId = input.getAttribute('data-range-input');
const output = detailId ? form.querySelector('[data-range-output="' + detailId + '"]') : null;
const manualInput = detailId ? form.querySelector('[data-range-manual="' + detailId + '"]') : null;
const minValue = parseFloat(input.dataset.rangeMinValue || input.min || '0');
const maxValue = parseFloat(input.dataset.rangeMaxValue || input.max || '100');
const stepValue = parseFloat(input.step || '1') || 1;
function clampAndSnap(rawVal) {
let v = parseFloat(rawVal);
if (isNaN(v)) v = minValue;
if (v < minValue) v = minValue;
if (v > maxValue) v = maxValue;
return snapToStep(v, minValue, stepValue);
}
function updateBoth(fromManual) {
const current = fromManual && manualInput ? manualInput.value : input.value;
const value = clampAndSnap(current);
input.value = value;
if (output) {
output.textContent = formatRangeDisplay(value);
}
if (manualInput && !fromManual) {
manualInput.value = value;
}
updateRangeFill(input, minValue, maxValue);
}
input.addEventListener('input', function () {
updateBoth(false);
});
if (manualInput) {
manualInput.addEventListener('input', function () {
updateBoth(true);
});
}
// initial
updateBoth(false);
});
}
function formatRangeDisplay(value) {
if (value === null || value === undefined) {
return '';
}
const numberValue = Number(value);
if (!isNaN(numberValue) && Number.isFinite(numberValue)) {
return Number.isInteger(numberValue) ? String(numberValue) : numberValue.toFixed(1);
}
return value;
}
function updateRangeFill(input, min, max) {
if (!input || !isFinite(min) || !isFinite(max) || max === min) {
return;
}
const value = parseFloat(input.value || min);
const ratio = Math.max(0, Math.min(1, (value - min) / (max - min))); // 01
const percent = ratio * 100;
// hijau dari 0 sampai nilai sekarang, sisanya putih
input.style.background =
'linear-gradient(90deg, #00c070 0% ' + percent + '%, #ffffff ' + percent + '% 100%)';
}
function snapToStep(value, min, step) {
if (!isFinite(step) || step <= 0) {
return value;
}
const delta = value - min;
const steps = Math.round(delta / step);
const snapped = min + steps * step;
const decimals = getDecimalPlaces(step);
return parseFloat(snapped.toFixed(decimals));
}
function getDecimalPlaces(number) {
const value = number.toString();
if (value.indexOf('.') === -1) {
return 0;
}
return value.length - value.indexOf('.') - 1;
}
function shouldDisableFieldForImmediate(field) {
if (!field || field.dataset.consentInput === '1') {
return false;
}
const tagName = (field.tagName || '').toLowerCase();
const type = (field.type || '').toLowerCase();
if (type === 'radio' || type === 'checkbox') {
return false;
}
if (tagName === 'select') {
const value = field.value;
return value === null || value === '';
}
const value = (field.value || '').toString().trim();
return value === '';
}
function hasIncompleteConsentForHal(halValue) {
if (!halValue || formLocked) {
return false;
}
const fields = form.querySelectorAll('[data-consent-input="1"][name][data-field-hal="' + halValue + '"]');
if (!fields.length) {
return false;
}
const groupMap = {};
let needsValue = false;
fields.forEach(function (field) {
if (!field || field.disabled) {
return;
}
const type = (field.type || '').toLowerCase();
const tagName = (field.tagName || '').toLowerCase();
if (type === 'radio' || type === 'checkbox') {
const groupName = field.name || field.id || ('consent-' + halValue);
if (!groupMap[groupName]) {
groupMap[groupName] = [];
}
groupMap[groupName].push(field);
return;
}
if (tagName === 'select') {
if (field.value === null || field.value === '') {
needsValue = true;
}
return;
}
const value = (field.value || '').toString().trim();
if (!value) {
needsValue = true;
}
});
if (needsValue) {
return true;
}
return Object.keys(groupMap).some(function (groupName) {
const fieldsInGroup = groupMap[groupName] || [];
return !fieldsInGroup.some(function (input) {
return input.checked;
});
});
}
function setupDualFormInputs() {
document.querySelectorAll('[data-dual-wrapper]').forEach(function (wrapper) {
const detailId = wrapper.getAttribute('data-dual-wrapper');
const hiddenInput = wrapper.querySelector('[data-dual-hidden]');
const yearInput = wrapper.querySelector('[data-dual-input="tahun"]');
const monthInput = wrapper.querySelector('[data-dual-input="bulan"]');
const inputs = [yearInput, monthInput].filter(Boolean);
inputs.forEach(function (input) {
input.addEventListener('input', function () {
updateDualFormAnswer(detailId);
});
});
updateDualFormAnswer(detailId);
});
}
function updateDualFormAnswer(detailId) {
if (!detailId) {
return;
}
const wrapper = document.querySelector('[data-dual-wrapper="' + detailId + '"]');
if (!wrapper) {
return;
}
const hiddenInput = wrapper.querySelector('[data-dual-hidden]');
if (!hiddenInput) {
return;
}
const yearInput = wrapper.querySelector('[data-dual-input="tahun"]');
const monthInput = wrapper.querySelector('[data-dual-input="bulan"]');
const yearValue = (yearInput && yearInput.value ? yearInput.value : '').toString().trim();
const monthValue = (monthInput && monthInput.value ? monthInput.value : '').toString().trim();
const parts = [];
if (yearValue !== '') {
parts.push(yearValue + ' (Tahun)');
}
if (monthValue !== '') {
parts.push(monthValue + ' (Bulan)');
}
const combined = parts.join(' ');
hiddenInput.value = combined;
if (hiddenInput.required) {
hiddenInput.setCustomValidity(combined ? '' : 'Harap isi minimal salah satu kolom (Tahun/Bulan).');
}
}
function setupConsentWatcher() {
if (!consentFields.length) {
return;
}
consentFields.forEach(function (field) {
const handleChange = function () {
const type = (field.type || '').toLowerCase();
if ((type === 'radio' || type === 'checkbox') && !field.checked) {
return;
}
evaluateConsentValue(field.value);
};
field.addEventListener('change', handleChange);
if (shouldUseInputEvent(field)) {
field.addEventListener('input', handleChange);
}
if (
(field.type === 'radio' && field.checked) ||
(field.tagName === 'SELECT' && field.value) ||
(field.type !== 'radio' && field.value)
) {
evaluateConsentValue(field.value);
}
});
}
function shouldUseInputEvent(field) {
const tagName = (field.tagName || '').toLowerCase();
const type = (field.type || '').toLowerCase();
if (tagName === 'textarea') {
return true;
}
return type === 'text' || type === 'number' || type === 'range';
}
function evaluateConsentValue(value) {
const normalized = (value || '').toString().trim().toLowerCase();
const shouldStop = consentNegativeKeywords.some(function (keyword) {
return normalized.startsWith(keyword);
});
toggleImmediateSubmit(shouldStop);
}
function toggleImmediateSubmit(activate) {
if (immediateSubmitActive === activate) {
updateNavigationUI();
return;
}
immediateSubmitActive = activate;
halInputs.forEach(function (field) {
const originalRequired = field.dataset.originalRequired === '1';
const dynamicRequired = field.dataset.dynamicRequired === '1';
field.required = activate ? false : (originalRequired || dynamicRequired);
if (!field.required && typeof field.setCustomValidity === 'function') {
field.setCustomValidity('');
}
});
updateNavigationUI();
}
function scrollPageToTop() {
if (typeof window === 'undefined' || typeof window.scrollTo !== 'function') {
return;
}
try {
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (error) {
window.scrollTo(0, 0);
}
}
});
</script>
@endsection