Allowance check
This commit is contained in:
@@ -0,0 +1,579 @@
|
||||
<template>
|
||||
<div class="roulette-demo-page container-fluid">
|
||||
<header class="roulette-page-header">
|
||||
<div class="roulette-page-header-inner">
|
||||
<img src="/images/MyKoPKB-logo.png" alt="MyKoPKB" class="roulette-page-header-logo" />
|
||||
<h1 class="roulette-demo-title">Sesi Cabutan Bertuah — Kehadiran Fizikal</h1>
|
||||
<button type="button" class="btn btn-default btn-sm roulette-bg-music-btn"
|
||||
:aria-pressed="bgMusicPlaying ? 'true' : 'false'"
|
||||
:aria-label="bgMusicPlaying ? 'Pause Background Music' : 'Play Background Music'"
|
||||
@click="toggleBgMusic">
|
||||
{{ bgMusicPlaying ? 'Pause Background Music' : 'Play Background Music' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="loadError" class="alert alert-danger">
|
||||
{{ loadError }}
|
||||
</div>
|
||||
<div v-else-if="loading" class="text-muted roulette-demo-loading">
|
||||
Memuat senarai pengundi Fizikal…
|
||||
</div>
|
||||
<div v-else-if="!apiVoters.length" class="alert alert-info">
|
||||
Tiada pengundi Fizikal direkod buat masa ini. Semak kehadiran atau tambah rekod.
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && !loadError && apiVoters.length" class="roulette-sync-bar">
|
||||
<span v-if="lastSyncedAt" class="roulette-sync-time">
|
||||
Senarai menunggu dikemas kini pada <strong>{{ lastSyncedFormatted }}</strong>
|
||||
</span>
|
||||
<span v-if="refreshing" class="roulette-sync-refreshing text-muted">Mengemas kini…</span>
|
||||
<span v-if="pollError" class="text-warning roulette-sync-poll-error">{{ pollError }}</span>
|
||||
<button type="button" class="btn btn-default btn-sm roulette-sync-btn" :disabled="refreshing"
|
||||
@click="fetchFizikalVoters(false)">
|
||||
Segerakan sekarang
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && !loadError && apiVoters.length" class="row roulette-demo-layout">
|
||||
<aside class="col-md-3 col-sm-12 roulette-pending-sidebar text-left">
|
||||
<div class="panel panel-default roulette-pending-panel">
|
||||
<div class="panel-heading clearfix">
|
||||
<span>Menunggu sertai cabutan</span>
|
||||
<span class="badge pull-right">{{ pendingVoters.length }}</span>
|
||||
</div>
|
||||
<ul class="list-group roulette-pending-list">
|
||||
<li v-if="!pendingVoters.length" class="list-group-item text-muted roulette-pending-empty">
|
||||
Tiada dalam senarai menunggu — semua peserta telah diimport atau senarai kosong.
|
||||
</li>
|
||||
<template v-else>
|
||||
<li v-for="v in pendingVoters" :key="'p-' + v.id"
|
||||
class="list-group-item roulette-pending-row">
|
||||
<span class="roulette-pending-name">{{ labelForVoter(v) }}</span>
|
||||
<button type="button" class="btn btn-primary btn-xs roulette-pending-import"
|
||||
@click="importVoter(v.id)">
|
||||
Import
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div v-if="pendingVoters.length > 1" class="panel-footer text-right">
|
||||
<button type="button" class="btn btn-default btn-sm" @click="importAllPending">
|
||||
Import semua
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="col-md-9 col-sm-12 roulette-main-column">
|
||||
<div v-if="cabutanCount < 2" class="alert alert-info roulette-cabutan-hint">
|
||||
Import sekurang-kurangnya <strong>dua</strong> peserta daripada senarai menunggu untuk memulakan
|
||||
cabutan. Senarai <strong>Peserta</strong> pada roda hanya berubah apabila anda menambah peserta di
|
||||
sini; auto sync mengemas kini senarai menunggu sahaja.
|
||||
</div>
|
||||
|
||||
<roulette-elimination-wheel v-if="cabutanCount >= 2" :items="cabutanItems" :full-width="true"
|
||||
persist-key="roulette_fizikal_demo" @winner="onWinner" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RouletteEliminationWheel from './RouletteEliminationWheel.vue';
|
||||
|
||||
function buildLabelsFromVoters(voters) {
|
||||
if (!voters || !voters.length) {
|
||||
return [];
|
||||
}
|
||||
var nameCount = {};
|
||||
for (var i = 0; i < voters.length; i++) {
|
||||
var raw = voters[i].name;
|
||||
var n = (raw != null ? String(raw) : '').trim();
|
||||
nameCount[n] = (nameCount[n] || 0) + 1;
|
||||
}
|
||||
return voters.map(function (v) {
|
||||
var n = (v.name != null ? String(v.name) : '').trim();
|
||||
if (!n) {
|
||||
n = '#' + v.id;
|
||||
}
|
||||
if (nameCount[n] > 1) {
|
||||
var tag = v.no_anggota != null ? String(v.no_anggota).trim() : '';
|
||||
return tag ? n + ' (' + tag + ')' : n + ' (#' + v.id + ')';
|
||||
}
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist which voter ids were added to the cabutan (separate from wheel game state). */
|
||||
var IMPORTED_IDS_STORAGE_KEY = 'roulette_fizikal_imported_ids';
|
||||
|
||||
/** Looping background track (`public/audio/…`). */
|
||||
var BG_MUSIC_SRC = '/audio/lucky-draw-bg.mp3';
|
||||
|
||||
/** Stable string so we detect new/removed/changed voters from API. */
|
||||
function votersSnapshotSignature(voters) {
|
||||
if (!voters || !voters.length) {
|
||||
return 'empty';
|
||||
}
|
||||
var rows = voters.map(function (v) {
|
||||
return (
|
||||
String(v.id) +
|
||||
'\t' +
|
||||
String(v.name != null ? v.name : '') +
|
||||
'\t' +
|
||||
String(v.no_anggota != null ? v.no_anggota : '')
|
||||
);
|
||||
});
|
||||
rows.sort();
|
||||
return rows.join('|');
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'RouletteEliminationWheelDemo',
|
||||
|
||||
components: {
|
||||
RouletteEliminationWheel,
|
||||
},
|
||||
|
||||
data: function () {
|
||||
return {
|
||||
loading: true,
|
||||
loadError: '',
|
||||
/** Full list from GET …/fizikal-voters (updated by auto sync / manual refresh). */
|
||||
apiVoters: [],
|
||||
/** Voter ids explicitly imported into the cabutan / wheel; order = cabutan order. */
|
||||
importedParticipantIds: [],
|
||||
/** Last applied API snapshot — skip redundant Vue updates when unchanged. */
|
||||
lastSnapshotSig: '',
|
||||
refreshing: false,
|
||||
lastSyncedAt: null,
|
||||
pollError: '',
|
||||
hasLoadedOnce: false,
|
||||
pollTimerId: null,
|
||||
pollStarted: false,
|
||||
/** Interval in ms; set 0 to disable auto-refresh. */
|
||||
pollIntervalMs: 10000,
|
||||
/** True while background music is playing (user must click Main first — browser policy). */
|
||||
bgMusicPlaying: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
lastSyncedFormatted: function () {
|
||||
if (!this.lastSyncedAt) {
|
||||
return '—';
|
||||
}
|
||||
try {
|
||||
return this.lastSyncedAt.toLocaleString();
|
||||
} catch (e) {
|
||||
return String(this.lastSyncedAt);
|
||||
}
|
||||
},
|
||||
/** Label string per voter id (duplicate-name disambiguation uses full API list). */
|
||||
voterLabelsById: function () {
|
||||
var list = this.apiVoters;
|
||||
var labels = buildLabelsFromVoters(list);
|
||||
var map = {};
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var vid = list[i].id;
|
||||
var nid = typeof vid === 'number' && vid === vid ? vid : parseInt(vid, 10);
|
||||
if (nid === nid) {
|
||||
map[nid] = labels[i];
|
||||
}
|
||||
}
|
||||
return map;
|
||||
},
|
||||
pendingVoters: function () {
|
||||
var imported = this.importedParticipantIds;
|
||||
return this.apiVoters.filter(function (v) {
|
||||
var vid = typeof v.id === 'number' && v.id === v.id ? v.id : parseInt(v.id, 10);
|
||||
return imported.indexOf(vid) < 0;
|
||||
});
|
||||
},
|
||||
/** Wheel entries: stable voter id + label (order = import order). */
|
||||
cabutanItems: function () {
|
||||
var map = this.voterLabelsById;
|
||||
var out = [];
|
||||
for (var i = 0; i < this.importedParticipantIds.length; i++) {
|
||||
var id = this.importedParticipantIds[i];
|
||||
if (map[id] !== undefined) {
|
||||
out.push({ id: id, label: map[id] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
cabutanCount: function () {
|
||||
return this.cabutanItems.length;
|
||||
},
|
||||
},
|
||||
|
||||
created: function () {
|
||||
this.loadImportedIdsFromStorage();
|
||||
this.fetchFizikalVoters(false);
|
||||
},
|
||||
|
||||
beforeDestroy: function () {
|
||||
this.stopPolling();
|
||||
this.disposeBgMusic();
|
||||
},
|
||||
|
||||
methods: {
|
||||
disposeBgMusic: function () {
|
||||
this.bgMusicPlaying = false;
|
||||
if (!this._bgMusicAudio) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this._bgMusicAudio.pause();
|
||||
this._bgMusicAudio.src = '';
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
this._bgMusicAudio = null;
|
||||
},
|
||||
|
||||
ensureBgMusicAudio: function () {
|
||||
if (typeof Audio === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
if (!this._bgMusicAudio) {
|
||||
this._bgMusicAudio = new Audio(BG_MUSIC_SRC);
|
||||
this._bgMusicAudio.loop = true;
|
||||
this._bgMusicAudio.volume = 0.32;
|
||||
this._bgMusicAudio.preload = 'auto';
|
||||
}
|
||||
return this._bgMusicAudio;
|
||||
},
|
||||
|
||||
toggleBgMusic: function () {
|
||||
var vm = this;
|
||||
var a = this.ensureBgMusicAudio();
|
||||
if (!a) {
|
||||
return;
|
||||
}
|
||||
if (this.bgMusicPlaying) {
|
||||
a.pause();
|
||||
this.bgMusicPlaying = false;
|
||||
return;
|
||||
}
|
||||
var p = a.play();
|
||||
if (p && typeof p.then === 'function') {
|
||||
p.then(function () {
|
||||
vm.bgMusicPlaying = true;
|
||||
}).catch(function () {
|
||||
vm.bgMusicPlaying = false;
|
||||
});
|
||||
} else {
|
||||
this.bgMusicPlaying = true;
|
||||
}
|
||||
},
|
||||
|
||||
loadImportedIdsFromStorage: function () {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var raw = localStorage.getItem(IMPORTED_IDS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
var parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return;
|
||||
}
|
||||
this.importedParticipantIds = parsed.filter(function (x) {
|
||||
return typeof x === 'number' && x === x;
|
||||
});
|
||||
} catch (e) {
|
||||
this.importedParticipantIds = [];
|
||||
}
|
||||
},
|
||||
|
||||
saveImportedIdsToStorage: function () {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(
|
||||
IMPORTED_IDS_STORAGE_KEY,
|
||||
JSON.stringify(this.importedParticipantIds)
|
||||
);
|
||||
} catch (e) {
|
||||
// quota / private mode
|
||||
}
|
||||
},
|
||||
|
||||
/** Drop imported ids that no longer exist in the latest API list. */
|
||||
pruneImportedIdsToApi: function (list) {
|
||||
var ok = {};
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var vid = list[i].id;
|
||||
var nid = typeof vid === 'number' && vid === vid ? vid : parseInt(vid, 10);
|
||||
if (nid === nid) {
|
||||
ok[nid] = true;
|
||||
}
|
||||
}
|
||||
var next = this.importedParticipantIds.filter(function (id) {
|
||||
return ok[id];
|
||||
});
|
||||
if (next.length !== this.importedParticipantIds.length) {
|
||||
this.importedParticipantIds = next;
|
||||
this.saveImportedIdsToStorage();
|
||||
}
|
||||
},
|
||||
|
||||
labelForVoter: function (v) {
|
||||
var vid = typeof v.id === 'number' && v.id === v.id ? v.id : parseInt(v.id, 10);
|
||||
var m = this.voterLabelsById;
|
||||
return vid === vid && m[vid] != null ? m[vid] : '#' + v.id;
|
||||
},
|
||||
|
||||
importVoter: function (id) {
|
||||
var nid = typeof id === 'number' && id === id ? id : parseInt(id, 10);
|
||||
if (nid !== nid) {
|
||||
return;
|
||||
}
|
||||
if (this.importedParticipantIds.indexOf(nid) >= 0) {
|
||||
return;
|
||||
}
|
||||
this.importedParticipantIds.push(nid);
|
||||
this.saveImportedIdsToStorage();
|
||||
},
|
||||
|
||||
importAllPending: function () {
|
||||
var vm = this;
|
||||
this.pendingVoters.forEach(function (v) {
|
||||
var vid = typeof v.id === 'number' && v.id === v.id ? v.id : parseInt(v.id, 10);
|
||||
if (vid === vid && vm.importedParticipantIds.indexOf(vid) < 0) {
|
||||
vm.importedParticipantIds.push(vid);
|
||||
}
|
||||
});
|
||||
this.saveImportedIdsToStorage();
|
||||
},
|
||||
|
||||
stopPolling: function () {
|
||||
if (this.pollTimerId !== null) {
|
||||
clearInterval(this.pollTimerId);
|
||||
this.pollTimerId = null;
|
||||
}
|
||||
this.pollStarted = false;
|
||||
},
|
||||
|
||||
startPolling: function () {
|
||||
if (this.pollIntervalMs <= 0) {
|
||||
return;
|
||||
}
|
||||
if (this.pollStarted) {
|
||||
return;
|
||||
}
|
||||
this.pollStarted = true;
|
||||
var vm = this;
|
||||
this.pollTimerId = setInterval(function () {
|
||||
vm.fetchFizikalVoters(true);
|
||||
}, this.pollIntervalMs);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {boolean} silent - true = background poll (no full-page loading state).
|
||||
*/
|
||||
fetchFizikalVoters: function (silent) {
|
||||
var vm = this;
|
||||
if (!silent) {
|
||||
vm.loadError = '';
|
||||
vm.pollError = '';
|
||||
if (!vm.hasLoadedOnce) {
|
||||
vm.loading = true;
|
||||
} else {
|
||||
vm.refreshing = true;
|
||||
}
|
||||
} else {
|
||||
vm.pollError = '';
|
||||
vm.refreshing = true;
|
||||
}
|
||||
|
||||
return axios
|
||||
.get(config.API + 'public/roulette/fizikal-voters')
|
||||
.then(function (response) {
|
||||
var list =
|
||||
response.data && response.data.voters ? response.data.voters : [];
|
||||
var sig = votersSnapshotSignature(list);
|
||||
if (silent && sig === vm.lastSnapshotSig) {
|
||||
vm.lastSyncedAt = new Date();
|
||||
return;
|
||||
}
|
||||
vm.lastSnapshotSig = sig;
|
||||
vm.apiVoters = list;
|
||||
vm.pruneImportedIdsToApi(list);
|
||||
vm.lastSyncedAt = new Date();
|
||||
vm.hasLoadedOnce = true;
|
||||
})
|
||||
.catch(function (error) {
|
||||
var msg =
|
||||
error &&
|
||||
error.response &&
|
||||
error.response.data &&
|
||||
error.response.data.message
|
||||
? error.response.data.message
|
||||
: 'Gagal memuat data.';
|
||||
if (!silent && !vm.hasLoadedOnce) {
|
||||
vm.loadError = msg;
|
||||
vm.apiVoters = [];
|
||||
} else {
|
||||
vm.pollError =
|
||||
'Tidak dapat mengemas kini: ' + (msg.length > 80 ? msg.slice(0, 80) + '…' : msg);
|
||||
}
|
||||
})
|
||||
.finally(function () {
|
||||
vm.loading = false;
|
||||
vm.refreshing = false;
|
||||
vm.startPolling();
|
||||
});
|
||||
},
|
||||
|
||||
onWinner: function (payload) {
|
||||
if (typeof console !== 'undefined' && console.log) {
|
||||
console.log('[roulette demo] winner:', payload);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.roulette-demo-page {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 3rem;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.roulette-page-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #e7e7e7;
|
||||
}
|
||||
|
||||
.roulette-page-header-inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px 18px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.roulette-page-header-logo {
|
||||
display: block;
|
||||
height: 52px;
|
||||
width: auto;
|
||||
max-width: 140px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.roulette-demo-title {
|
||||
font-size: 1.65rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
max-width: min(100%, 36rem);
|
||||
}
|
||||
|
||||
.roulette-bg-music-btn {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.roulette-demo-path {
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.roulette-poll-help {
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.roulette-demo-loading {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.roulette-sync-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 16px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.roulette-sync-refreshing {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.roulette-sync-poll-error {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.roulette-sync-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.roulette-demo-layout {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.roulette-pending-sidebar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.roulette-pending-sidebar {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.roulette-pending-panel {
|
||||
position: sticky;
|
||||
top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.roulette-main-column {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.roulette-pending-panel {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.roulette-pending-list {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.roulette-pending-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.roulette-pending-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.roulette-pending-import {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.roulette-cabutan-hint {
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user