335 lines
15 KiB
PHP
335 lines
15 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="ms">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<title>Cabutan Bertuah · KOIPB MAT</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
|
<link href="{{ asset('css/koipb.css') }}?v=1" rel="stylesheet">
|
|
</head>
|
|
<body class="projector">
|
|
<div class="draw-topbar">
|
|
<div class="d-flex align-items-center gap-3">
|
|
<img src="{{ asset('images/logo-koipb.jpg') }}" onerror="this.style.display='none'">
|
|
<div>
|
|
<div class="fw-bold fs-5">Cabutan Bertuah Mesyuarat Agung Tahunan</div>
|
|
<div class="small" style="color:var(--koipb-teal-light)">{{ $session->nama }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex align-items-center gap-3">
|
|
<span class="badge bg-light text-dark fs-6"><i class="bi bi-people-fill text-teal"></i> Peserta Layak: <b id="eligibleCount">{{ $eligibleCount }}</b></span>
|
|
<a href="{{ route('dashboard') }}" class="btn btn-outline-light btn-sm"><i class="bi bi-grid"></i> Dashboard</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="draw-grid">
|
|
<!-- KIRI: WHEEL -->
|
|
<div class="wheel-stage">
|
|
<div class="mb-3 w-100 text-center" style="max-width:560px;">
|
|
<div class="text-uppercase small text-white-50" style="letter-spacing:2px;">Nombor Cabutan</div>
|
|
<div class="d-flex align-items-center justify-content-center gap-3">
|
|
<button id="prevPrize" class="btn btn-outline-light btn-sm rounded-circle" title="Hadiah sebelum" style="width:38px;height:38px;"><i class="bi bi-chevron-up"></i></button>
|
|
<div id="currentNumber" style="font-size:3.4rem;font-weight:900;line-height:1;color:var(--koipb-gold);">—</div>
|
|
<div class="text-white-50 fs-5">/ <span id="totalNumber">{{ $startNumber }}</span></div>
|
|
<button id="nextPrize" class="btn btn-outline-light btn-sm rounded-circle" title="Hadiah seterusnya" style="width:38px;height:38px;"><i class="bi bi-chevron-down"></i></button>
|
|
</div>
|
|
<div id="prizeNameTop" class="prize-title mt-1">—</div>
|
|
<div id="doneNote" class="badge bg-success fs-6 mt-2" style="display:none"><i class="bi bi-check2-all"></i> Semua cabutan selesai</div>
|
|
</div>
|
|
|
|
<div class="wheel-pointer"><i class="bi bi-caret-down-fill"></i></div>
|
|
<canvas id="wheelCanvas" width="500" height="500"></canvas>
|
|
|
|
<button id="spinBtn" class="btn-spin mt-4" disabled><i class="bi bi-arrow-repeat"></i> SPIN</button>
|
|
<div class="small mt-2 text-white-50" id="poolNote"></div>
|
|
</div>
|
|
|
|
<!-- KANAN: PANEL KEPUTUSAN -->
|
|
<div class="panel-result" id="resultPanel">
|
|
<div id="bigMsg" class="text-center mb-3" style="display:none"></div>
|
|
|
|
<div class="text-uppercase small text-white-50">Nombor Cabutan</div>
|
|
<div class="d-flex align-items-baseline gap-3">
|
|
<div class="display-5 fw-bold" id="prizeOrder">—</div>
|
|
<div class="prize-title" id="prizeName">Belum dipilih</div>
|
|
</div>
|
|
<hr class="border-light opacity-25">
|
|
|
|
<div id="winnerBox" class="flex-grow-1 d-flex flex-column justify-content-center text-center" style="opacity:.4">
|
|
<div class="text-white-50">Calon Pemenang</div>
|
|
<div class="winner-name my-2" id="winnerName">—</div>
|
|
<div id="winnerMeta" class="fs-5 text-white-50"></div>
|
|
</div>
|
|
|
|
<div class="text-center my-3">
|
|
<span class="pill" id="statusPill" style="display:none"></span>
|
|
</div>
|
|
|
|
<div class="d-grid gap-2 d-md-flex justify-content-md-center" id="actionBtns">
|
|
<button id="confirmBtn" class="btn btn-success btn-lg px-4" style="display:none"><i class="bi bi-check-circle-fill"></i> Confirm Pemenang</button>
|
|
<button id="cancelBtn" class="btn btn-danger btn-lg px-4" style="display:none"><i class="bi bi-x-circle-fill"></i> Batal / Tiada Di Dewan</button>
|
|
<button id="redrawBtn" class="btn btn-warning btn-lg px-4" style="display:none"><i class="bi bi-arrow-repeat"></i> Cabut Semula</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js"></script>
|
|
<script>
|
|
const urls = {
|
|
pool: "{{ route('draw.pool') }}",
|
|
spin: "{{ route('draw.spin') }}",
|
|
confirm: "{{ route('draw.confirm') }}",
|
|
cancel: "{{ route('draw.cancel') }}",
|
|
};
|
|
$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content } });
|
|
|
|
// Senarai hadiah berserta Nombor Cabutan (seq), serta nombor mula (had kehadiran)
|
|
let PRIZES = {!! json_encode($prizes) !!};
|
|
const START_NUMBER = {{ $startNumber }};
|
|
|
|
const COLORS = ['#0a8f9c','#2b3036','#14b8c8','#e0a91b','#1e9e5a','#086e78','#1bb6c4','#3a4149'];
|
|
const canvas = document.getElementById('wheelCanvas');
|
|
const ctx = canvas.getContext('2d');
|
|
let pool = [], rotation = 0, spinning = false, currentResultId = null, currentPrizeId = null, currentSeq = null;
|
|
|
|
function loadPool(cb){
|
|
$.getJSON(urls.pool).done(res => { pool = res.pool || []; $('#poolNote').text(pool.length + ' peserta dalam pool'); drawWheel(); cb && cb(); });
|
|
}
|
|
|
|
function drawWheel(){
|
|
const n = Math.max(pool.length, 1);
|
|
const cx = 250, cy = 250, r = 240;
|
|
const seg = (2*Math.PI)/n;
|
|
ctx.clearRect(0,0,500,500);
|
|
ctx.save(); ctx.translate(cx,cy); ctx.rotate(rotation);
|
|
const showLabels = pool.length > 0 && pool.length <= 40;
|
|
for (let i=0;i<n;i++){
|
|
ctx.beginPath(); ctx.moveTo(0,0);
|
|
ctx.arc(0,0,r, i*seg, (i+1)*seg);
|
|
ctx.closePath();
|
|
ctx.fillStyle = COLORS[i % COLORS.length];
|
|
ctx.fill();
|
|
ctx.strokeStyle = 'rgba(255,255,255,.15)'; ctx.stroke();
|
|
if (showLabels && pool[i]){
|
|
ctx.save();
|
|
ctx.rotate(i*seg + seg/2);
|
|
ctx.textAlign='right'; ctx.fillStyle='#fff'; ctx.font='bold 13px Segoe UI';
|
|
const lbl = pool[i].label || pool[i].nama || '';
|
|
ctx.fillText(lbl.substring(0,16), r-14, 5);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
ctx.restore();
|
|
// hub
|
|
ctx.beginPath(); ctx.arc(cx,cy,38,0,2*Math.PI); ctx.fillStyle='#fff'; ctx.fill();
|
|
ctx.fillStyle='#0a8f9c'; ctx.font='bold 30px Segoe UI'; ctx.textAlign='center';
|
|
ctx.fillText('★', cx, cy+11);
|
|
if (!showLabels && pool.length>0){
|
|
ctx.fillStyle='#fff'; ctx.font='bold 18px Segoe UI';
|
|
ctx.fillText(pool.length + ' peserta', cx, cy+70);
|
|
}
|
|
}
|
|
|
|
function animateTo(winnerIndex, done){
|
|
const n = pool.length; if (n===0){ done(); return; }
|
|
const seg = (2*Math.PI)/n;
|
|
// sudut supaya tengah segmen winner berada di atas (pointer pada -90°)
|
|
const target = (-Math.PI/2) - (winnerIndex*seg + seg/2);
|
|
const turns = 6 + Math.floor(Math.random()*3);
|
|
const finalRot = target + turns*2*Math.PI;
|
|
const startRot = rotation % (2*Math.PI);
|
|
const duration = 5200, t0 = performance.now();
|
|
function frame(now){
|
|
let p = Math.min((now-t0)/duration, 1);
|
|
const ease = 1 - Math.pow(1-p, 3); // ease-out cubic
|
|
rotation = startRot + (finalRot - startRot) * ease;
|
|
drawWheel();
|
|
if (p < 1) requestAnimationFrame(frame); else { spinning=false; done(); }
|
|
}
|
|
requestAnimationFrame(frame);
|
|
}
|
|
|
|
// ---- Nombor Cabutan: cabutan menurun dari nombor terakhir (had = kehadiran) ----
|
|
function prizeBySeq(seq){ return PRIZES.find(p => p.seq === seq) || null; }
|
|
|
|
// Hadiah pertama yang boleh dicabut (seq tertinggi <= START_NUMBER yang belum disahkan)
|
|
function firstDrawable(){
|
|
for (let s = START_NUMBER; s >= 1; s--){
|
|
const p = prizeBySeq(s);
|
|
if (p && p.status !== 'disahkan') return s;
|
|
}
|
|
return 0;
|
|
}
|
|
// Hadiah seterusnya (menurun) yang masih boleh dicabut, bermula bawah daripada 'from'
|
|
function nextDrawableDown(from){
|
|
for (let s = from; s >= 1; s--){
|
|
const p = prizeBySeq(s);
|
|
if (p && p.status !== 'disahkan') return s;
|
|
}
|
|
return 0;
|
|
}
|
|
function prevDrawableUp(from){
|
|
for (let s = from; s <= START_NUMBER; s++){
|
|
const p = prizeBySeq(s);
|
|
if (p && p.status !== 'disahkan') return s;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function gotoSeq(seq){
|
|
resetPanel();
|
|
currentSeq = seq;
|
|
const p = seq ? prizeBySeq(seq) : null;
|
|
if (!p){
|
|
currentPrizeId = null;
|
|
$('#currentNumber').text('—');
|
|
$('#spinBtn').prop('disabled', true);
|
|
$('#prevPrize,#nextPrize').prop('disabled', true);
|
|
if (START_NUMBER === 0){
|
|
$('#prizeNameTop').text('Tiada kehadiran direkod');
|
|
$('#prizeOrder').text('—'); $('#prizeName').text('Tiada peserta hadir');
|
|
$('#doneNote').hide();
|
|
} else {
|
|
$('#prizeNameTop').text('Selesai');
|
|
$('#prizeOrder').text('—'); $('#prizeName').text('Selesai');
|
|
$('#doneNote').show();
|
|
}
|
|
return;
|
|
}
|
|
$('#doneNote').hide();
|
|
currentPrizeId = p.id;
|
|
$('#currentNumber').text(seq);
|
|
$('#prizeNameTop').text(p.nama_hadiah);
|
|
$('#prizeOrder').text('No. ' + seq);
|
|
$('#prizeName').text(p.nama_hadiah);
|
|
$('#spinBtn').prop('disabled', false);
|
|
// butang naik/turun
|
|
$('#prevPrize').prop('disabled', prevDrawableUp(seq + 1) === 0);
|
|
$('#nextPrize').prop('disabled', nextDrawableDown(seq - 1) === 0);
|
|
}
|
|
|
|
$('#nextPrize').on('click', () => { if (spinning) return; const s = nextDrawableDown(currentSeq - 1); if (s) gotoSeq(s); });
|
|
$('#prevPrize').on('click', () => { if (spinning) return; const s = prevDrawableUp(currentSeq + 1); if (s) gotoSeq(s); });
|
|
|
|
function resetPanel(){
|
|
$('#resultPanel').removeClass('win cancel');
|
|
$('#bigMsg').hide().empty();
|
|
$('#statusPill').hide();
|
|
$('#confirmBtn,#cancelBtn,#redrawBtn').hide();
|
|
$('#winnerBox').css('opacity', .4);
|
|
$('#winnerName').text('—'); $('#winnerMeta').text('');
|
|
currentResultId = null;
|
|
}
|
|
|
|
function lockUi(lock){
|
|
$('#spinBtn,#prevPrize,#nextPrize,#confirmBtn,#cancelBtn,#redrawBtn').prop('disabled', lock);
|
|
}
|
|
|
|
// ---- SPIN ----
|
|
function doSpin(){
|
|
if (spinning || !currentPrizeId) return;
|
|
spinning = true; lockUi(true); resetPanel();
|
|
$('#spinBtn').html('<span class="spinner-border spinner-border-sm"></span> Memusing...');
|
|
$.post(urls.spin, { prize_id: currentPrizeId })
|
|
.done(res => {
|
|
pool = res.pool;
|
|
animateTo(res.winner_index, () => revealWinner(res));
|
|
})
|
|
.fail(xhr => {
|
|
spinning=false; lockUi(false); $('#spinBtn').html('<i class="bi bi-arrow-repeat"></i> SPIN');
|
|
Swal.fire({icon:'warning', title:'Tidak boleh spin', text: xhr.responseJSON?.message || 'Ralat.'});
|
|
});
|
|
}
|
|
|
|
function revealWinner(res){
|
|
currentResultId = res.result_id;
|
|
const w = res.winner;
|
|
$('#winnerBox').css('opacity', 1);
|
|
$('#winnerName').text(w.nama);
|
|
$('#winnerMeta').html(`${w.no_pekerja ?? ''} · ${w.jabatan ?? '-'}<br><small>${w.masked_kp ?? ''}</small>`);
|
|
$('#statusPill').attr('class','pill pill-sedang').text('Menunggu Pengesahan').show();
|
|
$('#spinBtn').html('<i class="bi bi-arrow-repeat"></i> SPIN').prop('disabled', true);
|
|
$('#prevPrize,#nextPrize').prop('disabled', true);
|
|
$('#confirmBtn,#cancelBtn').show().prop('disabled', false);
|
|
$('#redrawBtn').hide();
|
|
}
|
|
|
|
// ---- CONFIRM ----
|
|
$('#confirmBtn').on('click', function(){
|
|
lockUi(true);
|
|
$.post(urls.confirm, { result_id: currentResultId })
|
|
.done(res => {
|
|
$('#resultPanel').addClass('win').removeClass('cancel');
|
|
$('#statusPill').attr('class','pill pill-disahkan').text('DISAHKAN ✓').show();
|
|
$('#bigMsg').html('<div class="big-msg" style="color:var(--koipb-gold)">🎉 TAHNIAH! 🎉</div>').show();
|
|
$('#confirmBtn,#cancelBtn,#redrawBtn').hide();
|
|
fireConfetti();
|
|
markPrizeDone(currentPrizeId);
|
|
// Auto-turun ke nombor cabutan seterusnya selepas 2.4 saat
|
|
const nextSeq = nextDrawableDown(currentSeq - 1);
|
|
setTimeout(() => loadPool(() => gotoSeq(nextSeq)), 2400);
|
|
})
|
|
.fail(xhr => { lockUi(false); Swal.fire({icon:'error', title:'Gagal sahkan', text: xhr.responseJSON?.message || 'Ralat.'}); });
|
|
});
|
|
|
|
// ---- CANCEL ----
|
|
$('#cancelBtn').on('click', function(){
|
|
Swal.fire({
|
|
icon:'warning', title:'Batalkan cabutan?', text:'Pemenang tiada di dewan. Hadiah akan dicabut semula.',
|
|
showCancelButton:true, confirmButtonText:'Ya, Batalkan', cancelButtonText:'Tidak', confirmButtonColor:'#c1272d',
|
|
input:'text', inputPlaceholder:'Sebab (pilihan)'
|
|
}).then(r => {
|
|
if (!r.isConfirmed) return;
|
|
lockUi(true);
|
|
$.post(urls.cancel, { result_id: currentResultId, sebab: r.value || '' })
|
|
.done(res => {
|
|
$('#resultPanel').addClass('cancel').removeClass('win');
|
|
$('#statusPill').attr('class','pill pill-redraw').text('DIBATALKAN').show();
|
|
$('#bigMsg').html('<div class="big-msg" style="color:#ff8a8a;font-size:2rem">Cabutan Dibatalkan<br><small style="font-size:1.1rem">Pemenang Tiada Di Dewan</small></div>').show();
|
|
$('#confirmBtn,#cancelBtn').hide();
|
|
$('#redrawBtn').show().prop('disabled', false);
|
|
loadPool();
|
|
})
|
|
.fail(xhr => { lockUi(false); Swal.fire({icon:'error', title:'Gagal', text: xhr.responseJSON?.message || 'Ralat.'}); });
|
|
});
|
|
});
|
|
|
|
// ---- REDRAW (cabut semula hadiah sama) ----
|
|
$('#redrawBtn').on('click', () => doSpin());
|
|
$('#spinBtn').on('click', () => doSpin());
|
|
|
|
function markPrizeDone(prizeId){
|
|
const p = PRIZES.find(x => x.id === prizeId);
|
|
if (p) p.status = 'disahkan';
|
|
}
|
|
|
|
function fireConfetti(){
|
|
const end = Date.now() + 1500;
|
|
(function frame(){
|
|
confetti({ particleCount: 6, angle: 60, spread: 70, origin: { x: 0 }, colors:['#0a8f9c','#e0a91b','#1e9e5a','#fff'] });
|
|
confetti({ particleCount: 6, angle: 120, spread: 70, origin: { x: 1 }, colors:['#0a8f9c','#e0a91b','#1e9e5a','#fff'] });
|
|
if (Date.now() < end) requestAnimationFrame(frame);
|
|
})();
|
|
confetti({ particleCount: 160, spread: 100, origin: { y: 0.6 } });
|
|
}
|
|
|
|
// ---- Mula: pilih nombor cabutan tertinggi yang boleh dicabut ----
|
|
@if($pending)
|
|
@php $pendingWinner = ['nama' => $pending->member->nama, 'no_pekerja' => $pending->member->no_pekerja, 'jabatan' => $pending->member->jabatan, 'masked_kp' => $pending->member->maskedKp()]; @endphp
|
|
loadPool(() => {
|
|
const pendingPrize = PRIZES.find(p => p.id === {{ $pending->prize_id }});
|
|
gotoSeq(pendingPrize ? pendingPrize.seq : firstDrawable());
|
|
revealWinner({ result_id: {{ $pending->id }}, winner: {!! json_encode($pendingWinner) !!} });
|
|
});
|
|
@else
|
|
loadPool(() => gotoSeq(firstDrawable()));
|
|
@endif
|
|
</script>
|
|
</body>
|
|
</html>
|