WIP: halaman ujian (mayan progress)

This commit is contained in:
Mahayoga 2026-04-29 02:37:26 +07:00
parent fdda679141
commit f18c470f14
8 changed files with 439 additions and 28 deletions

View File

@ -3,11 +3,72 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\DataSoalModel; use App\Models\DataSoalModel;
use App\Models\DetailTestModel;
use App\Models\TestSessionsModel; use App\Models\TestSessionsModel;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class HalamanUjianController extends Controller class HalamanUjianController extends Controller
{ {
public function getCurrentAnswer(Request $request) {
$data_answer = DetailTestModel::where('id_soal', $request->id_question)
->where('id_test_sessions', $request->session_id)
->first();
if($data_answer != null) {
return response()->json([
'status' => 'success',
'jawaban' => $data_answer->jawaban
]);
} else {
return response()->json([
'status' => 'error',
'message' => 'Data jawaban tidak ditemukan.'
]);
}
}
public function getTimeLeft(Request $request) {
$data_session = TestSessionsModel::where('id_test_sessions', $request->session_id)->first();
if($data_session != null) {
if($data_session->status_session != '1') {
return response()->json([
'status' => 'error',
'message' => 'Data sesi tes belum dimulai atau telah usai. Silahkan cek status dengan kode untuk informasi lebih lanjut.'
]);
} else if($data_session->status_session == '1') {
if($data_session->is_active == '1') {
$time_left = $data_session->unix_end_time - time();
if($time_left <= 0) {
$data_session->update([
'status_session' => '2',
'is_active' => '0'
]);
return response()->json([
'status' => 'error',
'message' => 'Waktu sesi tes telah habis. Silahkan cek status dengan kode untuk informasi lebih lanjut.'
]);
} else {
return response()->json([
'status' => 'success',
'time_left' => $data_session->unix_end_time
]);
}
} else {
return response()->json([
'status' => 'error',
'message' => 'Sesi tes belum aktif. Silahkan mulai tes terlebih dahulu.'
]);
}
}
} else {
return response()->json([
'status' => 'error',
'message' => 'Data sesi tes tidak ditemukan. Silakan mulai tes terlebih dahulu.'
]);
}
}
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
@ -27,7 +88,23 @@ public function index(Request $request)
if($data_session != null) { if($data_session != null) {
if($data_session->status_session != '1') { if($data_session->status_session != '1') {
return redirect()->route('cek-status.index')->with('error', 'Data sesi tes belum dimulai atau telah usai. Silahkan cek status dengan kode untuk informasi lebih lanjut.'); return redirect()->route('cek-status.index')->with('error', 'Data sesi tes belum dimulai atau telah usai. Silahkan cek status dengan kode untuk informasi lebih lanjut.');
} } else if($data_session->status_session == '1') {
if($data_session->is_active == '0') {
$data_session->update([
'is_active' => '1',
'unix_start_time' => time(),
'unix_end_time' => time() + (20 * 60)
]);
} else if($data_session->is_active == '1') {
if(time() > $data_session->unix_end_time) {
$data_session->update([
'status_session' => '2',
'is_active' => '0'
]);
return redirect()->route('cek-status.index')->with('error', 'Waktu sesi tes telah habis. Silahkan cek status dengan kode untuk informasi lebih lanjut.');
}
}
}
} else { } else {
return redirect()->route('cek-status.index')->with('error', 'Data sesi tes tidak ditemukan. Silakan mulai tes terlebih dahulu.'); return redirect()->route('cek-status.index')->with('error', 'Data sesi tes tidak ditemukan. Silakan mulai tes terlebih dahulu.');
} }
@ -43,6 +120,30 @@ public function index(Request $request)
} }
$data_question = DataSoalModel::all(); $data_question = DataSoalModel::all();
$answered_question = [];
$index_question = 1;
foreach($data_question as $detail) {
$detail_answer = DetailTestModel::where('id_soal', $detail->id_soal)
->where('id_test_sessions', $id_session)
->first();
if($detail_answer != null) {
$answered_question[] = [
'id_soal' => $detail->id_soal,
'nomor_soal' => $index_question,
'answered' => true,
'jawaban' => $detail_answer->jawaban
];
} else {
$answered_question[] = [
'id_soal' => $detail->id_soal,
'nomor_soal' => $index_question,
'answered' => false,
'jawaban' => null
];
}
$index_question++;
}
$total_question = null; $total_question = null;
$is_last_question = false; $is_last_question = false;
if($question_number <= 0 || $question_number > count($data_question)) { if($question_number <= 0 || $question_number > count($data_question)) {
@ -54,7 +155,41 @@ public function index(Request $request)
$total_question = count($data_question); $total_question = count($data_question);
$data_question = $data_question[$question_number - 1]; $data_question = $data_question[$question_number - 1];
return view("user.pages.ujian.index", compact('data_session', 'data_question', 'total_question', 'is_last_question', 'question_number')); $answer = [];
if($data_question->jawaban_a != null) {
$answer[] = [
'key' => 'a',
'value' => $data_question->jawaban_a
];
}
if($data_question->jawaban_b != null) {
$answer[] = [
'key' => 'b',
'value' => $data_question->jawaban_b
];
}
if($data_question->jawaban_c != null) {
$answer[] = [
'key' => 'c',
'value' => $data_question->jawaban_c
];
}
if($data_question->jawaban_d != null) {
$answer[] = [
'key' => 'd',
'value' => $data_question->jawaban_d
];
}
if($data_question->jawaban_e != null) {
$answer[] = [
'key' => 'e',
'value' => $data_question->jawaban_e
];
}
// dd($answer);
return view("user.pages.ujian.index", compact('data_session', 'data_question', 'total_question', 'is_last_question', 'question_number', 'answer', 'answered_question'));
} }
/** /**
@ -70,7 +205,30 @@ public function create()
*/ */
public function store(Request $request) public function store(Request $request)
{ {
// $data_session = TestSessionsModel::where('id_test_sessions', $request->session_id)->first();
if($data_session != null) {
if($data_session->status_session != '1') {
return response()->json([
'status' => 'error',
'message' => 'Data sesi tes belum dimulai atau telah usai. Silahkan cek status dengan kode untuk informasi lebih lanjut.'
]);
}
DetailTestModel::updateOrCreate(
[
'id_soal' => $request->id_question,
'id_test_sessions' => $request->session_id
],
[
'jawaban' => $request->jawaban_final
]
);
} else {
return response()->json([
'status' => 'error',
'message' => 'Data sesi tes tidak ditemukan. Silakan mulai tes terlebih dahulu.'
]);
}
} }
/** /**

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class DetailTestModel extends Model
{
protected $table = 'detail_test';
protected $primaryKey = 'id_detail_test';
protected $keyType = 'string';
public $incrementing = false;
protected $fillable = [
'id_detail_test',
'id_soal',
'id_test_sessions',
'jawaban'
];
protected $casts = [
'id_detail_test' => 'string',
];
protected static function boot()
{
parent::boot();
static::creating(function ($model) {
if (empty($model->id_detail_test)) {
$model->id_detail_test = Str::uuid();
}
});
}
}

View File

@ -18,7 +18,9 @@ class TestSessionsModel extends Model
'status_session', 'status_session',
'token_session', 'token_session',
'id_booking', 'id_booking',
'unix_start_time',
'is_active', 'is_active',
'unix_end_time',
]; ];
protected $casts = [ protected $casts = [

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('detail_test', function (Blueprint $table) {
$table->dropForeign(['id_test']);
$table->dropColumn('id_test');
$table->char('id_test_sessions', 36)->after('id_soal');
$table->foreign('id_test_sessions')->references('id_test_sessions')->on('test_sessions')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('detail_test', function (Blueprint $table) {
$table->dropForeign(['id_test_sessions']);
$table->dropColumn('id_test_sessions');
$table->unsignedBigInteger('id_test')->after('id_soal');
$table->foreign('id_test')->references('id_test')->on('test')->onDelete('cascade');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('detail_test', function (Blueprint $table) {
$table->foreign('id_soal')->references('id_soal')->on('soal')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('detail_test', function (Blueprint $table) {
$table->dropForeign(['id_soal']);
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('test_sessions', function (Blueprint $table) {
$table->char('unix_start_time', 20)->default(0)->after('token_session');
$table->char('unix_end_time', 20)->default(0)->after('is_active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('test_sessions', function (Blueprint $table) {
$table->dropColumn('unix_start_time');
$table->dropColumn('unix_end_time');
});
}
};

View File

@ -7,9 +7,16 @@
<nav class="navbar navbar-expand-lg bg-body-tertiary px-2"> <nav class="navbar navbar-expand-lg bg-body-tertiary px-2">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="#">Smart Anxiety</a> <a class="navbar-brand" href="#">Smart Anxiety</a>
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="collapse navbar-collapse justify-content-between" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"></ul> <ul class="navbar-nav me-auto mb-2 mb-lg-0"></ul>
<div class="d-flex fw-bold">Time Left: <span id="time_left"></span></div> <div class="d-flex col-md-2">
<div class="col-md-6 d-flex justify-content-center align-items-center">
Time Left: <span id="time_left"></span>
</div>
<div class="col-md-6 d-flex justify-content-center align-items-center">
<span id="bg_status_tobii" class="rounded bg-warning d-block mx-2" style="width: 10px; aspect-ratio: 1/1;"></span>Status Tobii
</div>
</div>
</div> </div>
</div> </div>
</nav> </nav>
@ -19,38 +26,25 @@
<div class="col-md-9 border-end"> <div class="col-md-9 border-end">
<div class="row justify-content-center flex-column p-4"> <div class="row justify-content-center flex-column p-4">
<div class="col-md-12 border-bottom fs-4 fw-bold p-2">Soal</div> <div class="col-md-12 border-bottom fs-4 fw-bold p-2">Soal</div>
<div class="col-md-12 d-none" id="id_question" value="{{ $data_question->id_soal }}"></div>
<div class="col-md-12 p-2" style="text-align: justify">{{ $data_question->pertanyaan }}</div> <div class="col-md-12 p-2" style="text-align: justify">{{ $data_question->pertanyaan }}</div>
<div class="col-md-12"> <div class="col-md-12">
<div class="fs-4 fw-bold p-2">Jawaban:</div> <div class="fs-4 fw-bold p-2">Jawaban:</div>
<div class="form-check m-2"> @foreach ($answer as $item)
<input class="form-check-input" type="radio" name="jawaban_final" id="jawaban_a"> <div class="form-check m-2">
<label class="form-check-label" for="jawaban_a">{{ $data_question->jawaban_a }}</label> <input class="form-check-input" type="radio" name="jawaban_final" id="jawaban_{{ $item['key'] }}">
</div> <label class="form-check-label" for="jawaban_{{ $item['key'] }}">{{ $item['value'] }}</label>
<div class="form-check m-2"> </div>
<input class="form-check-input" type="radio" name="jawaban_final" id="jawaban_b"> @endforeach
<label class="form-check-label" for="jawaban_b">{{ $data_question->jawaban_b }}</label>
</div>
<div class="form-check m-2">
<input class="form-check-input" type="radio" name="jawaban_final" id="jawaban_c">
<label class="form-check-label" for="jawaban_c">{{ $data_question->jawaban_c }}</label>
</div>
<div class="form-check m-2">
<input class="form-check-input" type="radio" name="jawaban_final" id="jawaban_d">
<label class="form-check-label" for="jawaban_d">{{ $data_question->jawaban_d }}</label>
</div>
<div class="form-check m-2">
<input class="form-check-input" type="radio" name="jawaban_final" id="jawaban_e">
<label class="form-check-label" for="jawaban_e">{{ $data_question->jawaban_e }}</label>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="row p-3"> <div class="row p-3">
<div class="col-md-12 border-bottom fs-4 fw-bold p-2 mb-3">Navigasi Soal</div> <div class="col-md-12 border-bottom fs-4 fw-bold p-2 mb-3">Navigasi Soal</div>
@for ($i = 0; $i < $total_question; $i++) @foreach ($answered_question as $question)
<div onclick="changeQuestion({{ $i + 1 }})" class="col-md-1 btn d-flex justify-content-center align-items-center bg-light-subtle border rounded p-0 m-1" style="aspect-ratio: 1/1.5;">{{ $i + 1 }}</div> <div onclick="changeQuestion({{ $question['nomor_soal'] }})" id="nomor_{{ $question['nomor_soal'] }}" class="col-md-1 btn d-flex justify-content-center align-items-center {{ $question['answered'] ? 'bg-secondary-subtle' : 'bg-light-subtle' }} border rounded p-0 m-1" style="aspect-ratio: 1/1.5;">{{ $question['nomor_soal'] }}</div>
@endfor @endforeach
<div class="col-md-12 p-0 mt-3"> <div class="col-md-12 p-0 mt-3">
@if ($question_number > 1) @if ($question_number > 1)
@ -64,6 +58,28 @@
@endif @endif
</div> </div>
</div> </div>
<div class="row p-3">
<div class="col-md-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">Instruksi</h5>
<p class="card-text">Silahkan baca soal dengan teliti dan pilih jawaban yang paling tepat.</p>
<div class="card-text d-flex align-items-center">
<span class="col-md-1 bg-light-subtle border rounded p-0 m-1" style="aspect-ratio: 1/1.5;"></span>
<span>adalah tanda bahwa jawaban BELUM diisi.</span>
</div>
<div class="card-text d-flex align-items-center">
<span class="col-md-1 bg-secondary-subtle border rounded p-0 m-1" style="aspect-ratio: 1/1.5;"></span>
<span>adalah tanda bahwa jawaban SUDAH diisi.</span>
</div>
<div class="card-text d-flex align-items-center">
<span class="col-md-1 bg-info border rounded p-0 m-1 text-white" style="aspect-ratio: 1/1.5;"></span>
<span>adalah tanda bahwa anda sedang membaca/menjawab soal ini.</span>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -75,10 +91,37 @@
const ws = new WebSocket('{{ env("WEBSOCKET_URL") }}'); const ws = new WebSocket('{{ env("WEBSOCKET_URL") }}');
let SESSION_ID = '{{ $data_session->id_test_sessions }}'; let SESSION_ID = '{{ $data_session->id_test_sessions }}';
let TOKEN_SESSION = '{{ $data_session->token_session }}'; let TOKEN_SESSION = '{{ $data_session->token_session }}';
let UNIX_END_TIME = null;
function changeQuestion(num) { function changeQuestion(num) {
let url_temp = window.location.href; let url_temp = window.location.href;
let current_num = new URLSearchParams(window.location.search).get('question'); let current_num = new URLSearchParams(window.location.search).get('question');
let xhttp = new XMLHttpRequest();
let the_answer = null;
let token = document.getElementById('_token').getAttribute('value');
let formData = new FormData();
if(current_num == num) {
console.log('same question');
return;
}
document.getElementsByName('jawaban_final').forEach(element => {
if (element.checked) {
the_answer = {
key: element.id.split('_')[1],
};
}
});
if(the_answer) {
formData.append('jawaban_final', the_answer.key);
formData.append('_token', token);
formData.append('question', num);
formData.append('id_question', $('#id_question').attr('value'));
formData.append('session_id', SESSION_ID);
xhttp.open("POST", "{{ route('ujian.store') }}", false);
xhttp.send(formData);
}
stopTobii(); stopTobii();
if(!url_temp.includes('question')) { if(!url_temp.includes('question')) {
window.location.href = url_temp + '&question=' + num; window.location.href = url_temp + '&question=' + num;
@ -87,6 +130,55 @@ function changeQuestion(num) {
} }
} }
function getCurrentAnswer() {
let id_question = $('#id_question').attr('value');
let xhttp = new XMLHttpRequest();
let current_num = new URLSearchParams(window.location.search).get('question');
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
let response = JSON.parse(this.responseText);
document.getElementById('nomor_' + current_num).classList.remove('bg-light-subtle');
document.getElementById('nomor_' + current_num).classList.remove('bg-secondary-subtle');
document.getElementById('nomor_' + current_num).classList.add('bg-info');
document.getElementById('nomor_' + current_num).classList.add('text-white');
if(response.status == 'success') {
document.getElementsByName('jawaban_final').forEach(element => {
if (element.id.split('_')[1] == response.jawaban) {
element.checked = true;
}
});
}
}
};
xhttp.open("GET", "{{ route('ujian.getCurrentAnswer') }}?id_question=" + id_question + "&session_id=" + SESSION_ID, false);
xhttp.send();
}
function getTimeLeft() {
let xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
let response = JSON.parse(this.responseText);
UNIX_END_TIME = response.time_left;
}
};
xhttp.open("GET", "{{ route('ujian.getTimeLeft') }}?session_id=" + SESSION_ID, false);
xhttp.send();
}
function updateTimeLeft() {
let time_left = UNIX_END_TIME - Math.floor(Date.now() / 1000);
if(time_left < 0) {
time_left = 0;
finishExam();
}
let minutes = Math.floor(time_left / 60);
let seconds = time_left % 60;
document.getElementById('time_left').innerText = ' ' + minutes + ':' + (seconds < 10 ? '0' : '') + seconds;
}
function finishExam() { function finishExam() {
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "command", type: "command",
@ -139,7 +231,34 @@ function stopTobii() {
session_id: SESSION_ID session_id: SESSION_ID
})); }));
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if(data.type === 'reset_start_tobii') {
startTobii();
}
if(data.type === 'status_tobii_device') {
if(data.status) {
console.log('Tobii device is connected');
// document.getElementById('status_tobii_device').innerText = 'Connected';
document.getElementById('bg_status_tobii').classList.remove('bg-warning');
document.getElementById('bg_status_tobii').classList.remove('bg-danger');
document.getElementById('bg_status_tobii').classList.add('bg-success');
} else {
console.log('Tobii device is not connected');
// document.getElementById('status_tobii_device').innerText = 'Not Connected';
document.getElementById('bg_status_tobii').classList.remove('bg-success');
document.getElementById('bg_status_tobii').classList.remove('bg-warning');
document.getElementById('bg_status_tobii').classList.add('bg-danger');
}
}
};
startTobii(); startTobii();
getCurrentAnswer();
getTimeLeft();
setInterval(updateTimeLeft, 1000);
}; };
}); });
</script> </script>

View File

@ -38,6 +38,8 @@
Route::resource('tobii-receiver', TobiiReceiverController::class)->only(['store', 'show']); Route::resource('tobii-receiver', TobiiReceiverController::class)->only(['store', 'show']);
Route::get('tobii-receiver/fetch/data/{id_test_sessions}', [TobiiReceiverController::class, 'fetchDataTobii'])->name('tobii-receiver.fetchData'); Route::get('tobii-receiver/fetch/data/{id_test_sessions}', [TobiiReceiverController::class, 'fetchDataTobii'])->name('tobii-receiver.fetchData');
Route::resource('ujian', HalamanUjianController::class); Route::resource('ujian', HalamanUjianController::class);
Route::get('ujian/get/current-answer', [HalamanUjianController::class, 'getCurrentAnswer'])->name('ujian.getCurrentAnswer');
Route::get('ujian/get/time-left', [HalamanUjianController::class, 'getTimeLeft'])->name('ujian.getTimeLeft');
Route::middleware(['auth', 'role.auth'])->group(function() { Route::middleware(['auth', 'role.auth'])->group(function() {
Route::resource('dashboard', DashboardController::class); Route::resource('dashboard', DashboardController::class);
Route::resource('users', UsersController::class); Route::resource('users', UsersController::class);