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

@ -86,82 +86,91 @@
.dual-form-wrapper .form-label {
font-weight: 600;
}
/* Skala di atas slider */
/* Skala di atas slider */
.range-scale {
position: relative;
height: 40px;
margin-bottom: 8px;
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; /* default putih, nanti diwarnai JS */
height: 8px;
border-radius: 4px;
background: #ffffff;
height: 10px;
border-radius: 5px;
border: 1px solid #dee2e6;
}
/* hilangkan track default browser */
.range-colored::-webkit-slider-runnable-track {
background: transparent;
height: 8px;
height: 10px;
}
.range-colored::-moz-range-track {
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 {
position: absolute;
bottom: 0;
top: 0;
transform: translateX(-50%);
text-align: center;
font-size: 10px;
color: #555;
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;
margin-top:-10px;
background-color: #555;
background-color: #495057;
}
/* Tick besar tiap 10 */
/* Tick besar untuk angka 1 dan kelipatan 10 */
.range-scale-item-major .range-scale-mark {
height: 12px;
background-color: #000;
width: 2px;
background-color: #212529;
}
.range-scale-item-major .range-scale-label {
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 {
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>
@endsection
@ -211,7 +220,6 @@
</div>
</div>
</div>
@if ($hal === $halPertama)
<div class="card border-0 shadow-sm mb-4" id="head_soal">
<div class="card-body">
@ -312,6 +320,8 @@
$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];
@ -344,18 +354,74 @@
}
$rangeSpan = max($rangeMax - $rangeMin, 1);
$startTick = (int) ceil($rangeMin);
$endTick = (int) floor($rangeMax);
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)),
];
$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;
@ -410,48 +476,26 @@
value="{{ $currentAnswer }}"
data-dual-hidden="{{ $detail->id }}"
data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" @endif
@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" @endif
@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" @endif
@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 }}">
<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 --}}
<div class="range-track-colored">
<input type="range"
@ -465,9 +509,29 @@
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>
@ -490,7 +554,7 @@
<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 ($isConsentQuestion) data-consent-input="1" required @endif
@if($formLocked) disabled @endif
placeholder="Masukkan jawaban Anda">
@endif
@ -526,7 +590,7 @@
data-original-value="{{ $optionLabel }}"
data-lainnya-radio="{{ $isLainnya ? $detail->id : '' }}"
data-field-hal="{{ $detailHal }}"
@if ($isConsentQuestion) data-consent-input="1" @endif
@if ($isConsentQuestion) data-consent-input="1" required @endif
@if($formLocked) disabled @endif
{{ $shouldCheck || $autoSelect ? 'checked' : '' }}>
<label class="form-check-label" for="{{ $optionId }}">
@ -667,7 +731,7 @@
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"]');
const consentFields = document.querySelectorAll('[data-consent-input="1"][name]');
const headSoalCard = document.getElementById('head_soal');
const consentNegativeKeywords = ['tidak', 'tidak setuju'];
let immediateSubmitActive = false;
@ -791,6 +855,7 @@
}
updateQuestionVisibility();
updateNavigationUI();
scrollPageToTop();
}
function validateHal(halValue) {
@ -829,11 +894,13 @@
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) {
if (immediateSubmitActive || consentBlocked) {
nextButton.style.display = 'none';
} else {
nextButton.style.display = '';
@ -842,10 +909,12 @@
}
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-outline-primary', !isActive);
button.disabled = immediateSubmitActive;
button.disabled = immediateSubmitActive || (consentBlocked && movingForward);
});
const progress = totalHal > 0 ? Math.round(((currentIndex + 1) / totalHal) * 100) : 100;
@ -984,57 +1053,57 @@
}
}
function setupRangeInputs() {
const rangeInputs = form.querySelectorAll('[data-range-input]');
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;
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;
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 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);
function updateBoth(fromManual) {
const current = fromManual && manualInput ? manualInput.value : input.value;
const value = clampAndSnap(current);
input.value = value;
input.value = value;
if (output) {
output.textContent = formatRangeDisplay(value);
}
if (manualInput && !fromManual) {
manualInput.value = value;
}
if (output) {
output.textContent = formatRangeDisplay(value);
}
if (manualInput && !fromManual) {
manualInput.value = value;
}
updateRangeFill(input, minValue, maxValue);
}
updateRangeFill(input, minValue, maxValue);
}
input.addEventListener('input', function () {
updateBoth(false);
input.addEventListener('input', function () {
updateBoth(false);
});
if (manualInput) {
manualInput.addEventListener('input', function () {
updateBoth(true);
});
}
// initial
updateBoth(false);
});
if (manualInput) {
manualInput.addEventListener('input', function () {
updateBoth(true);
});
}
// initial
updateBoth(false);
});
}
function formatRangeDisplay(value) {
@ -1099,6 +1168,58 @@ function setupRangeInputs() {
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');
@ -1150,12 +1271,18 @@ function setupRangeInputs() {
return;
}
consentFields.forEach(function (field) {
field.addEventListener('change', function (event) {
if (field.type === 'radio' && !field.checked) {
const handleChange = function () {
const type = (field.type || '').toLowerCase();
if ((type === 'radio' || type === 'checkbox') && !field.checked) {
return;
}
evaluateConsentValue(event.target.value);
});
evaluateConsentValue(field.value);
};
field.addEventListener('change', handleChange);
if (shouldUseInputEvent(field)) {
field.addEventListener('input', handleChange);
}
if (
(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) {
const normalized = (value || '').toString().trim().toLowerCase();
const shouldStop = consentNegativeKeywords.some(function (keyword) {
@ -1191,6 +1327,17 @@ function setupRangeInputs() {
});
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>