Files
E-Vote/resources/assets/js/components/demo/admin/home/result.vue
T
ISMAIL MASSERAN 3775c4a876 Allowance check
2026-04-14 02:34:00 +00:00

712 lines
18 KiB
Vue

<template>
<div class="result-page">
<div class="result-header">
<div>
<div class="result-kicker">Keputusan Undian</div>
<h4 class="result-title">{{ String(position_id) === '0' ? 'Dashboard Keputusan' :
(getPosition(position_id) || 'Keputusan') }}</h4>
<div class="result-subtitle">Kemaskini terakhir: <b>{{ last_update }}</b></div>
</div>
<div class="result-actions">
<button class="btn btn-info" @click="refreshNominees()">
<i class="fa fa-refresh"></i> Kemaskini Keputusan
</button>
</div>
</div>
<div v-if="String(position_id) === '0'" class="result-stats">
<div class="result-stat">
<div class="result-stat__label">Jumlah Undi</div>
<div class="result-stat__value">{{ totalVotes }}</div>
</div>
<div class="result-stat">
<div class="result-stat__label">Bil. Jawatan</div>
<div class="result-stat__value">{{ (data.positions || []).length }}</div>
</div>
<div class="result-stat">
<div class="result-stat__label">Calon Tertinggi</div>
<div class="result-stat__value result-stat__value--text">{{ topNomineeLabel }}</div>
<div class="result-stat__hint">{{ topNomineeVotes }} undi</div>
</div>
</div>
<div class="result-filters">
<span class="result-filters__label">Jawatan:</span>
<button type="button" class="result-chip" :class="{ 'is-active': String(position_id) === '0' }"
@click="setPosition(0)">
Semua
</button>
<button v-for="position in data.positions" :key="position.id" type="button" class="result-chip"
:class="{ 'is-active': String(position.id) === String(position_id) }" @click="setPosition(position.id)">
{{ position.name }}
</button>
</div>
<div class="row">
<div class="col-md-7">
<div class="panel panel-default result-card">
<div class="panel-heading result-card__heading">
<div class="result-card__title">
{{ String(position_id) === '0' ? 'Pemimpin Mengikut Jawatan' : 'Kedudukan Calon' }}
</div>
<div class="result-card__meta">
{{
String(position_id) === '0' ? 'Siapa mendahului (dengan beza undi)' :
'Mengikut jawatan dipilih'
}}
</div>
</div>
<div class="panel-body result-card__body">
<template v-if="String(position_id) === '0'">
<div v-if="!leadersByPosition.length" class="result-empty">
Tiada data untuk dipaparkan.
</div>
<div v-else class="result-leaders">
<div v-for="p in leadersByPosition" :key="p.position_id" class="result-leader">
<div class="result-leader__top">
<div class="result-leader__position">{{ p.position_name }}</div>
<div class="result-leader__meta">
<span><b>{{ p.winner_votes }}</b> undi</span>
<span class="result-leader__delta">(+{{ p.delta }} beza)</span>
</div>
</div>
<div class="result-leader__name">{{ p.winner_name }}</div>
<div class="result-leader__bar">
<div class="result-leader__bar-fill" :style="{ width: p.pct + '%' }"></div>
</div>
</div>
</div>
</template>
<template v-else>
<div v-if="!rankedNominees.length" class="result-empty">
Tiada calon untuk jawatan ini.
</div>
<div v-else class="result-rank">
<div v-for="n in rankedNominees" :key="n.id" class="result-rank__row">
<div class="result-rank__top">
<div class="result-rank__name">{{ n.name }}</div>
<div class="result-rank__votes">
<b>{{ n.votes }}</b> undi
</div>
</div>
<div class="result-rank__bar">
<div class="result-rank__bar-fill" :style="{ width: n.pct + '%' }"></div>
</div>
<div class="result-rank__meta">{{ n.pct }}%</div>
</div>
</div>
</template>
</div>
</div>
</div>
<div class="col-md-5">
<div class="panel panel-default result-card">
<div class="panel-heading result-card__heading">
<div class="result-card__title">
{{ String(position_id) === '0' ? 'Analitik Ringkas' : 'Ringkasan Jawatan' }}
</div>
<div class="result-card__meta">
{{
String(position_id) === '0' ? 'Top calon & jumlah undi mengikut jawatan' : ('Jumlah undi:' +
totalVotes) }}
</div>
</div>
<div class="panel-body result-card__body">
<template v-if="String(position_id) === '0'">
<div class="result-mini-title">Top Calon (Overall)</div>
<div id="top-chart" class="result-top-chart"></div>
<div v-if="!topNominees.length" class="result-empty">Tiada data untuk dipaparkan.</div>
<div class="result-mini-title" style="margin-top: 14px;">Jumlah Undi Mengikut Jawatan</div>
<div id="votes-by-position-chart" class="result-top-chart"></div>
</template>
<template v-else>
<div class="result-empty">
Pilih Semua untuk melihat analitik keseluruhan. Untuk jawatan ini, rujuk senarai di
kiri.
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
created: function () {
this.refreshNominees();
this.$nextTick(function () {
this.initCharts();
});
},
watch: {
position_id: function () {
var vm = this;
this.$nextTick(function () {
vm.initCharts();
});
}
},
methods: {
setPosition: function (id) {
// Prevent Vue Router NavigationDuplicated when clicking active chip
var target = (!id || String(id) === '0') ? '0' : String(id);
if (String(this.position_id) === target) return;
var q = Object.assign({}, this.$route.query);
if (!id || String(id) === '0') delete q.position_id;
else q.position_id = String(id);
this.$router.replace({ query: q }).catch(function () { });
},
refreshNominees: function () {
var vm = this;
this.util.notify('Refreshing results', 'loading');
// Always fetch all nominees (needed for overall dashboards and name lookups)
axios.get(config.API + 'nominee')
.then(response => {
$.notifyClose();
vm.data.nominees = response.data;
vm.refreshResults();
})
.catch(error => {
$.notifyClose();
vm.util.showResult(error);
})
},
refreshResults: function () {
var vm = this;
this.util.notify('Refreshing results', 'loading');
axios.get(config.API + 'election/results')
.then(response => {
$.notifyClose();
vm.data.results = response.data;
vm.data.last_update = new Date();
vm.$nextTick(function () {
vm.initCharts();
});
})
.catch(error => {
$.notifyClose();
vm.util.showResult(error);
})
},
initCharts: function () {
// Clear any previously rendered plots to avoid overlap when switching views
if ($('#top-chart').length) $('#top-chart').empty();
if ($('#votes-by-position-chart').length) $('#votes-by-position-chart').empty();
// All positions summary
if (String(this.position_id) === '0') {
if ($('#top-chart').length) {
var ticks = this.topNominees.map(function (n, i) { return [i, n.label]; });
var series = [{
label: 'Undi',
data: this.topNominees.map(function (n, i) { return [i, n.votes]; }),
bars: { show: true, barWidth: 0.6, align: 'center', fill: 0.85, lineWidth: 0 }
}];
$.plot($('#top-chart'), series, {
xaxis: { ticks: ticks, rotateTicks: 45 },
yaxis: { min: 0, tickDecimals: 0 },
grid: { hoverable: true, borderColor: '#e5e7eb' }
});
}
if ($('#votes-by-position-chart').length) {
var pt = this.positionTotals;
var ptTicks = pt.map(function (p, i) { return [i, p.label]; });
var ptSeries = [{
label: 'Undi',
data: pt.map(function (p, i) { return [i, p.votes]; }),
bars: { show: true, barWidth: 0.6, align: 'center', fill: 0.55, lineWidth: 0 }
}];
$.plot($('#votes-by-position-chart'), ptSeries, {
xaxis: { ticks: ptTicks, rotateTicks: 45 },
yaxis: { min: 0, tickDecimals: 0 },
grid: { hoverable: true, borderColor: '#e5e7eb' }
});
}
return;
}
// For per-position view, we intentionally do not render charts (UI request).
},
getPosition: function (id) {
let positions = this.data.positions;
for (var i in positions)
if (id == positions[i]['id']) return positions[i]['name'];
return '';
},
getNominee: function (id) {
let nominees = this.data.nominees;
for (var i in nominees)
if (id == nominees[i]['id']) return nominees[i]['name'];
return '';
},
getVotes: function (id) {
let results = this.data.results;
for (var i in results) {
if (results[i]['nominee_id'] == id) return results[i]['votes'];
}
return 0;
}
},
computed: {
last_update: function () {
let x = this.data.last_update;
if (!x) return '-';
return x.toDateString() + ' ' + x.toLocaleTimeString();
},
position_id: function () {
return this.$route.query.position_id ? this.$route.query.position_id : 0;
},
datas: function () {
//var results =
var data = [];
var nominees = this.data.nominees;
for (var i in nominees) {
if (nominees[i]['position_id'] == this.position_id) {
let row = [];
row['label'] = nominees[i]['name'];
row['data'] = [[1, this.getVotes(nominees[i]['id'])]];
data.push(row);
}
}
return data;
},
positionTotals: function () {
// Total votes per position (for "Semua" dashboard)
var results = this.data.results || [];
var totals = {};
for (var i = 0; i < results.length; i++) {
var pid = results[i].position_id;
var v = Number(results[i].votes || 0);
totals[pid] = (totals[pid] || 0) + v;
}
var positions = this.data.positions || [];
var rows = positions.map(function (p) {
return { position_id: p.id, label: p.name, votes: totals[p.id] || 0 };
});
rows.sort(function (a, b) { return (b.votes || 0) - (a.votes || 0); });
return rows;
},
topNominees: function () {
// Highest vote nominees across all positions (sum by nominee_id)
var results = this.data.results || [];
var sum = {};
for (var i = 0; i < results.length; i++) {
var nid = results[i].nominee_id;
var v = Number(results[i].votes || 0);
sum[nid] = (sum[nid] || 0) + v;
}
var nominees = this.data.nominees || [];
var nameById = {};
for (var j = 0; j < nominees.length; j++) {
nameById[nominees[j].id] = nominees[j].name;
}
var rows = Object.keys(sum).map(function (nid) {
return { id: Number(nid), label: nameById[nid] || ('Nominee #' + nid), votes: sum[nid] };
});
rows.sort(function (a, b) { return (b.votes || 0) - (a.votes || 0); });
return rows.slice(0, 8);
},
topNomineeLabel: function () {
return this.topNominees && this.topNominees[0] ? this.topNominees[0].label : '-';
},
topNomineeVotes: function () {
return this.topNominees && this.topNominees[0] ? (this.topNominees[0].votes || 0) : 0;
},
leadersByPosition: function () {
// For each position: winner + runner-up + margin
var positions = this.data.positions || [];
var nominees = this.data.nominees || [];
var results = this.data.results || [];
var posName = {};
positions.forEach(function (p) { posName[p.id] = p.name; });
var nomineeName = {};
nominees.forEach(function (n) { nomineeName[n.id] = n.name; });
var byPos = {};
for (var i = 0; i < results.length; i++) {
var r = results[i];
var pid = r.position_id;
if (!byPos[pid]) byPos[pid] = [];
byPos[pid].push({ nominee_id: r.nominee_id, votes: Number(r.votes || 0) });
}
var rows = [];
for (var j = 0; j < positions.length; j++) {
var pid2 = positions[j].id;
var arr = (byPos[pid2] || []).slice().sort(function (a, b) { return (b.votes || 0) - (a.votes || 0); });
var w = arr[0] || { nominee_id: null, votes: 0 };
var r2 = arr[1] || { nominee_id: null, votes: 0 };
var total = arr.reduce(function (s, x) { return s + (x.votes || 0); }, 0);
var pct = total > 0 ? Math.round((w.votes / total) * 100) : 0;
rows.push({
position_id: pid2,
position_name: posName[pid2] || ('Jawatan #' + pid2),
winner_id: w.nominee_id,
winner_name: nomineeName[w.nominee_id] || '-',
winner_votes: w.votes || 0,
runnerup_id: r2.nominee_id,
runnerup_name: nomineeName[r2.nominee_id] || '-',
runnerup_votes: r2.votes || 0,
delta: Math.max(0, (w.votes || 0) - (r2.votes || 0)),
pct: pct
});
}
// Sort: show positions with most votes first
rows.sort(function (a, b) { return (b.winner_votes || 0) - (a.winner_votes || 0); });
return rows;
},
rankedNominees: function () {
var nominees = this.data.nominees || [];
var rows = [];
for (var i = 0; i < nominees.length; i++) {
if (String(nominees[i].position_id) !== String(this.position_id)) continue;
var votes = this.getVotes(nominees[i].id);
rows.push({
id: nominees[i].id,
name: nominees[i].name,
votes: votes
});
}
rows.sort(function (a, b) { return (b.votes || 0) - (a.votes || 0); });
var total = rows.reduce(function (s, r) { return s + (r.votes || 0); }, 0);
return rows.map(function (r) {
var pct = total > 0 ? Math.round((r.votes / total) * 100) : 0;
return Object.assign({}, r, { pct: pct });
});
},
totalVotes: function () {
// In "All" view use all results; otherwise use per-position ranking
if (String(this.position_id) === '0') {
var results = this.data.results || [];
return results.reduce(function (s, r) { return s + Number(r.votes || 0); }, 0);
}
var rows = this.rankedNominees;
return rows.reduce(function (s, r) { return s + (r.votes || 0); }, 0);
}
}
}
</script>
<style scoped>
.result-page {
margin-top: 12px;
}
.result-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.result-kicker {
color: #2f80ed;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
.result-title {
margin: 0 0 6px;
font-weight: 800;
color: #17324d;
}
.result-subtitle {
color: #6b7280;
font-size: 12px;
}
.result-actions {
flex-shrink: 0;
}
.result-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 10px 0 14px;
}
.result-stat {
border: 1px solid #e6edf5;
border-radius: 16px;
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
padding: 12px 14px;
box-shadow: 0 10px 22px rgba(35, 64, 97, 0.06);
}
.result-stat__label {
color: #6b7280;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.result-stat__value {
font-size: 22px;
font-weight: 900;
color: #17324d;
}
.result-stat__value--text {
font-size: 14px;
line-height: 1.25;
}
.result-stat__hint {
margin-top: 6px;
font-size: 12px;
color: #6b7280;
font-weight: 700;
}
.result-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin: 10px 0 16px;
}
.result-filters__label {
color: #6b7280;
font-size: 12px;
font-weight: 700;
margin-right: 4px;
}
.result-chip {
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
line-height: 1;
}
.result-chip:hover {
background: #f3f4f6;
}
.result-chip.is-active {
background: #1976d2;
border-color: #1976d2;
color: #fff;
}
.result-card {
border: 0;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 12px 28px rgba(35, 64, 97, 0.08);
}
.result-card__heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
background: linear-gradient(135deg, #1d4e89, #2f80ed);
color: #fff;
border: 0 !important;
}
.result-card__title {
font-weight: 800;
}
.result-card__meta {
opacity: 0.95;
font-size: 12px;
}
.result-card__body {
background: #fff;
}
.result-top-chart {
width: 100%;
height: 280px;
}
.result-mini-title {
font-size: 12px;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #17324d;
margin-bottom: 8px;
}
.result-empty {
margin-top: 10px;
text-align: center;
color: #6b7280;
font-style: italic;
}
.result-leaders {
display: flex;
flex-direction: column;
gap: 12px;
}
.result-leader {
padding: 12px 12px;
border: 1px solid #eef2f7;
border-radius: 14px;
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
}
.result-leader__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.result-leader__position {
font-weight: 900;
color: #17324d;
}
.result-leader__meta {
display: flex;
gap: 10px;
font-size: 12px;
color: #17324d;
white-space: nowrap;
}
.result-leader__delta {
color: #2f80ed;
}
.result-leader__name {
font-weight: 800;
color: #17324d;
margin-bottom: 8px;
}
.result-leader__bar {
height: 10px;
border-radius: 999px;
background: #e8f0fb;
overflow: hidden;
}
.result-leader__bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #2f80ed, #1d4e89);
}
.result-leader__sub {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 12px;
color: #6b7280;
}
.result-rank {
display: flex;
flex-direction: column;
gap: 12px;
}
.result-rank__row {
padding: 12px 12px;
border: 1px solid #eef2f7;
border-radius: 14px;
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
}
.result-rank__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.result-rank__name {
font-weight: 800;
color: #17324d;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-rank__votes {
color: #17324d;
font-size: 12px;
white-space: nowrap;
}
.result-rank__bar {
height: 10px;
border-radius: 999px;
background: #e8f0fb;
overflow: hidden;
}
.result-rank__bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #2f80ed, #1d4e89);
}
.result-rank__meta {
margin-top: 6px;
font-size: 12px;
color: #6b7280;
text-align: right;
}
@media (max-width: 991px) {
.result-header {
flex-direction: column;
}
.result-stats {
grid-template-columns: 1fr;
}
}
</style>