Files
2026-06-24 18:30:00 +08:00

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>