Compare commits

..

5 Commits

Author SHA1 Message Date
f77eb9aeb1 Merge branch 'main' of https://git.rsabhk.co.id/muhammad_thoriq/mutu-rsab 2025-12-04 09:49:27 +07:00
52fc370b17 done lms mutu soal 2025-12-04 09:49:23 +07:00
0fd0ee8929 progress 2025-12-04 09:40:40 +07:00
ec7a1af1d0 progress 2025-12-04 09:09:44 +07:00
2205bc6a27 progress penggaris 2025-12-03 15:52:02 +07:00

View File

@ -89,79 +89,88 @@
/* Skala di atas slider */ /* Skala di atas slider */
.range-scale { .range-scale {
position: relative; position: relative;
height: 40px; height: 45px;
margin-bottom: 8px; margin-top: 0px;
border-top: 2px solid #495057;
padding-top: 2px;
}
.range-track-colored {
position: relative;
margin-top: 0px;
} }
.range-track-colored .range-colored { .range-track-colored .range-colored {
width: 100%; width: 100%;
-webkit-appearance: none; -webkit-appearance: none;
background: #ffffff; /* default putih, nanti diwarnai JS */ background: #ffffff;
height: 8px; height: 10px;
border-radius: 4px; border-radius: 5px;
border: 1px solid #dee2e6;
} }
/* hilangkan track default browser */ /* hilangkan track default browser */
.range-colored::-webkit-slider-runnable-track { .range-colored::-webkit-slider-runnable-track {
background: transparent; background: transparent;
height: 8px; height: 10px;
} }
.range-colored::-moz-range-track { .range-colored::-moz-range-track {
background: transparent; background: transparent;
height: 8px; height: 10px;
} }
/* thumb */
.range-colored::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #000;
cursor: pointer;
position: relative;
z-index: 2;
}
.range-colored::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #000;
cursor: pointer;
position: relative;
z-index: 2;
}
.range-scale-item { .range-scale-item {
position: absolute; position: absolute;
bottom: 0; top: 0;
transform: translateX(-50%); transform: translateX(-50%);
text-align: center; text-align: center;
font-size: 10px; font-size: 11px;
color: #555; color: #495057;
display: flex;
flex-direction: column;
align-items: center;
}
.range-scale-label {
display: block;
margin-bottom: 4px;
font-weight: 500;
} }
.range-scale-mark { .range-scale-mark {
display: block; display: block;
width: 1px; width: 1px;
height: 8px; height: 8px;
margin-top:-10px; background-color: #495057;
background-color: #555;
} }
/* Tick besar tiap 10 */ /* Tick besar untuk angka 1 dan kelipatan 10 */
.range-scale-item-major .range-scale-mark { .range-scale-item-major .range-scale-mark {
height: 12px; height: 12px;
background-color: #000; width: 2px;
background-color: #212529;
}
.range-scale-item-major .range-scale-label {
font-weight: 600; font-weight: 600;
color: #212529;
font-size: 12px;
} }
/* Tick khusus angka 1 biar lebih pendek (opsional) */ /* Tick khusus angka 1 */
.range-scale-item-one .range-scale-mark { .range-scale-item-one .range-scale-mark {
height: 10px; height: 12px;
background-color: #212529;
width: 2px;
} }
.range-scale-item-one .range-scale-label {
color: #212529;
font-weight: 600;
font-size: 12px;
}
</style> </style>
@endsection @endsection
@ -211,7 +220,6 @@
</div> </div>
</div> </div>
</div> </div>
@if ($hal === $halPertama) @if ($hal === $halPertama)
<div class="card border-0 shadow-sm mb-4" id="head_soal"> <div class="card border-0 shadow-sm mb-4" id="head_soal">
<div class="card-body"> <div class="card-body">
@ -312,6 +320,8 @@
$rangeStep = 1; $rangeStep = 1;
$rangeDefault = null; $rangeDefault = null;
$rangeTicks = []; $rangeTicks = [];
$rangeSubTickCount = 4;
$rangeMajorStep = 10;
if ($useDualForm && $currentAnswer) { if ($useDualForm && $currentAnswer) {
if (preg_match('/([0-9]+)\\s*\\(Tahun\\)/i', $currentAnswer, $matchYear)) { if (preg_match('/([0-9]+)\\s*\\(Tahun\\)/i', $currentAnswer, $matchYear)) {
$dualYearOld = $matchYear[1]; $dualYearOld = $matchYear[1];
@ -344,18 +354,74 @@
} }
$rangeSpan = max($rangeMax - $rangeMin, 1); $rangeSpan = max($rangeMax - $rangeMin, 1);
$startTick = (int) ceil($rangeMin); $majorAnchors = [$rangeMin, $rangeMax];
$endTick = (int) floor($rangeMax); if ($rangeMin <= 1 && $rangeMax >= 1) {
$majorAnchors[] = 1;
for ($v = $startTick; $v <= $endTick; $v++) {
$position = (($v - $rangeMin) / $rangeSpan) * 100;
$rangeTicks[] = [
'value' => $v, // nilai asli
'label' => ($v === 1 || $v % 10 === 0) ? $v : null, // label hanya 1 & kelipatan 10
'position' => max(0, min(100, $position)),
];
} }
$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; $rangeMinDisplay = ($rangeMin !== null && floor($rangeMin) == $rangeMin) ? (int) $rangeMin : $rangeMin;
@ -410,48 +476,26 @@
value="{{ $currentAnswer }}" value="{{ $currentAnswer }}"
data-dual-hidden="{{ $detail->id }}" data-dual-hidden="{{ $detail->id }}"
data-field-hal="{{ $detailHal }}" data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" @endif @if ($isConsentQuestion) data-consent-input="1" required @endif
@if($formLocked) disabled @endif> @if($formLocked) disabled @endif>
</div> </div>
@elseif ($type === 'textarea') @elseif ($type === 'textarea')
<textarea class="form-control @error('jawaban.' . $detail->id) is-invalid @enderror" <textarea class="form-control @error('jawaban.' . $detail->id) is-invalid @enderror"
name="jawaban[{{ $detail->id }}]" rows="4" name="jawaban[{{ $detail->id }}]" rows="4"
data-field-hal="{{ $detailHal }}" data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" @endif @if ($isConsentQuestion) data-consent-input="1" required @endif
@if($formLocked) disabled @endif @if($formLocked) disabled @endif
placeholder="Tulis jawaban Anda di sini">{{ $currentAnswer }}</textarea> placeholder="Tulis jawaban Anda di sini">{{ $currentAnswer }}</textarea>
@elseif ($type === 'text') @elseif ($type === 'text')
<input type="text" class="form-control @error('jawaban.' . $detail->id) is-invalid @enderror" <input type="text" class="form-control @error('jawaban.' . $detail->id) is-invalid @enderror"
name="jawaban[{{ $detail->id }}]" value="{{ $currentAnswer }}" name="jawaban[{{ $detail->id }}]" value="{{ $currentAnswer }}"
data-field-hal="{{ $detailHal }}" data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" @endif @if ($isConsentQuestion) data-consent-input="1" required @endif
@if($formLocked) disabled @endif @if($formLocked) disabled @endif
placeholder="Masukkan jawaban Anda"> placeholder="Masukkan jawaban Anda">
@elseif ($type === 'option_with_range') @elseif ($type === 'option_with_range')
@if ($rangeMin !== null && $rangeMax !== null) @if ($rangeMin !== null && $rangeMax !== null)
<div class="range-slider-wrapper" data-range-wrapper="{{ $detail->id }}"> <div class="range-slider-wrapper" data-range-wrapper="{{ $detail->id }}">
<div class="range-scale" aria-hidden="true">
@foreach ($rangeTicks as $tick)
@php
$value = (int) $tick['value'];
$label = $tick['label']; // bisa null
$isMajor = ($value === 1) || ($value % 10 === 0);
$isOneTick = ($value === 1);
@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>
{{-- Track warna + slider --}} {{-- Track warna + slider --}}
<div class="range-track-colored"> <div class="range-track-colored">
<input type="range" <input type="range"
@ -465,9 +509,29 @@
data-range-min-value="{{ $rangeMin }}" data-range-min-value="{{ $rangeMin }}"
data-range-max-value="{{ $rangeMax }}" data-range-max-value="{{ $rangeMax }}"
data-field-hal="{{ $detailHal }}" data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" required @endif
@if($formLocked) disabled @endif> @if($formLocked) disabled @endif>
</div> </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"> <div class="text-center mt-2 fw-semibold">
Nilai saat ini: Nilai saat ini:
<span data-range-output="{{ $detail->id }}">{{ $rangeDefaultDisplay ?? $rangeMinDisplay }}</span> <span data-range-output="{{ $detail->id }}">{{ $rangeDefaultDisplay ?? $rangeMinDisplay }}</span>
@ -490,7 +554,7 @@
<input type="text" class="form-control @error('jawaban.' . $detail->id) is-invalid @enderror" <input type="text" class="form-control @error('jawaban.' . $detail->id) is-invalid @enderror"
name="jawaban[{{ $detail->id }}]" value="{{ $currentAnswer }}" name="jawaban[{{ $detail->id }}]" value="{{ $currentAnswer }}"
data-field-hal="{{ $detailHal }}" data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" @endif @if ($isConsentQuestion) data-consent-input="1" required @endif
@if($formLocked) disabled @endif @if($formLocked) disabled @endif
placeholder="Masukkan jawaban Anda"> placeholder="Masukkan jawaban Anda">
@endif @endif
@ -526,7 +590,7 @@
data-original-value="{{ $optionLabel }}" data-original-value="{{ $optionLabel }}"
data-lainnya-radio="{{ $isLainnya ? $detail->id : '' }}" data-lainnya-radio="{{ $isLainnya ? $detail->id : '' }}"
data-field-hal="{{ $detailHal }}" data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" @endif @if ($isConsentQuestion) data-consent-input="1" required @endif
@if($formLocked) disabled @endif @if($formLocked) disabled @endif
{{ $shouldCheck || $autoSelect ? 'checked' : '' }}> {{ $shouldCheck || $autoSelect ? 'checked' : '' }}>
<label class="form-check-label" for="{{ $optionId }}"> <label class="form-check-label" for="{{ $optionId }}">
@ -667,7 +731,7 @@
const summaryHal = document.getElementById('summary-hal'); const summaryHal = document.getElementById('summary-hal');
const totalHal = halList.length || 1; const totalHal = halList.length || 1;
const nonConsentFields = document.querySelectorAll('[data-field-hal]:not([data-consent-input="1"])'); const nonConsentFields = document.querySelectorAll('[data-field-hal]:not([data-consent-input="1"])');
const consentFields = document.querySelectorAll('[data-consent-input="1"]'); const consentFields = document.querySelectorAll('[data-consent-input="1"][name]');
const headSoalCard = document.getElementById('head_soal'); const headSoalCard = document.getElementById('head_soal');
const consentNegativeKeywords = ['tidak', 'tidak setuju']; const consentNegativeKeywords = ['tidak', 'tidak setuju'];
let immediateSubmitActive = false; let immediateSubmitActive = false;
@ -791,6 +855,7 @@
} }
updateQuestionVisibility(); updateQuestionVisibility();
updateNavigationUI(); updateNavigationUI();
scrollPageToTop();
} }
function validateHal(halValue) { function validateHal(halValue) {
@ -829,11 +894,13 @@
const isFirst = currentIndex <= 0; const isFirst = currentIndex <= 0;
const isLast = currentIndex === halList.length - 1; const isLast = currentIndex === halList.length - 1;
const consentBlocked = hasIncompleteConsentForHal(currentHal);
if (prevButton) { if (prevButton) {
prevButton.disabled = immediateSubmitActive ? true : isFirst; prevButton.disabled = immediateSubmitActive ? true : isFirst;
} }
if (nextButton) { if (nextButton) {
if (immediateSubmitActive) { if (immediateSubmitActive || consentBlocked) {
nextButton.style.display = 'none'; nextButton.style.display = 'none';
} else { } else {
nextButton.style.display = ''; nextButton.style.display = '';
@ -842,10 +909,12 @@
} }
navHalButtons.forEach(function (button) { navHalButtons.forEach(function (button) {
const isActive = parseInt(button.dataset.navHal, 10) === currentHal; 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-primary', isActive);
button.classList.toggle('btn-outline-primary', !isActive); button.classList.toggle('btn-outline-primary', !isActive);
button.disabled = immediateSubmitActive; button.disabled = immediateSubmitActive || (consentBlocked && movingForward);
}); });
const progress = totalHal > 0 ? Math.round(((currentIndex + 1) / totalHal) * 100) : 100; const progress = totalHal > 0 ? Math.round(((currentIndex + 1) / totalHal) * 100) : 100;
@ -1099,6 +1168,58 @@ function setupRangeInputs() {
return value === ''; 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() { function setupDualFormInputs() {
document.querySelectorAll('[data-dual-wrapper]').forEach(function (wrapper) { document.querySelectorAll('[data-dual-wrapper]').forEach(function (wrapper) {
const detailId = wrapper.getAttribute('data-dual-wrapper'); const detailId = wrapper.getAttribute('data-dual-wrapper');
@ -1150,12 +1271,18 @@ function setupRangeInputs() {
return; return;
} }
consentFields.forEach(function (field) { consentFields.forEach(function (field) {
field.addEventListener('change', function (event) { const handleChange = function () {
if (field.type === 'radio' && !field.checked) { const type = (field.type || '').toLowerCase();
if ((type === 'radio' || type === 'checkbox') && !field.checked) {
return; return;
} }
evaluateConsentValue(event.target.value); evaluateConsentValue(field.value);
}); };
field.addEventListener('change', handleChange);
if (shouldUseInputEvent(field)) {
field.addEventListener('input', handleChange);
}
if ( if (
(field.type === 'radio' && field.checked) || (field.type === 'radio' && field.checked) ||
@ -1167,6 +1294,15 @@ function setupRangeInputs() {
}); });
} }
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) { function evaluateConsentValue(value) {
const normalized = (value || '').toString().trim().toLowerCase(); const normalized = (value || '').toString().trim().toLowerCase();
const shouldStop = consentNegativeKeywords.some(function (keyword) { const shouldStop = consentNegativeKeywords.some(function (keyword) {
@ -1191,6 +1327,17 @@ function setupRangeInputs() {
}); });
updateNavigationUI(); 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> </script>