DONE: centralize theme color, interactive dashboard, increase winner result, refer TODO

This commit is contained in:
ISMAIL MASSERAN
2026-05-10 11:51:19 +08:00
parent 654a0c9fea
commit fd1baf34c8
47 changed files with 2680 additions and 932 deletions
+372
View File
@@ -0,0 +1,372 @@
.final-dashboard {
margin-top: 12px;
padding-bottom: 28px;
}
.final-dashboard__container {
width: 100%;
max-width: 1760px;
margin-left: auto;
margin-right: auto;
padding-left: 20px;
padding-right: 20px;
}
.final-split-row {
margin-left: -10px;
margin-right: -10px;
}
.final-split-col {
padding-left: 10px;
padding-right: 10px;
margin-bottom: 20px;
display: flex;
flex-direction: column;
}
@media (min-width: 992px) {
.final-split-col {
margin-bottom: 0;
}
.final-dashboard__shell,
.final-attendance-shell {
flex: 1;
min-height: 100%;
}
.final-split-col--dashboard .final-stats {
grid-template-columns: 1fr;
}
.final-split-col--attendance .final-attendance__charts--standalone {
grid-template-columns: 1fr;
}
.final-split-col--dashboard .final-chart-wrap {
height: 280px;
}
.final-split-col--dashboard .final-chart-wrap--doughnut {
height: 260px;
}
.final-split-col--attendance .final-attendance__chart-wrap--donut {
height: 200px;
}
}
.final-dashboard__shell {
padding: 22px 22px 26px;
border: 1px solid #e6edf5;
border-radius: 18px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
box-shadow: 0 14px 36px rgba(35, 64, 97, 0.08);
display: flex;
flex-direction: column;
}
.final-attendance-shell {
padding: 22px 22px 26px;
border: 1px solid #dce8e4;
border-radius: 18px;
background: linear-gradient(180deg, #ffffff 0%, #f4faf7 100%);
box-shadow: 0 14px 36px rgba(35, 64, 97, 0.08);
display: flex;
flex-direction: column;
}
.final-attendance-shell__head {
margin-bottom: 8px;
}
.final-attendance-shell__kicker {
color: #00897b;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
.final-attendance-shell__title {
margin: 0 0 6px;
font-weight: 800;
color: #17324d;
}
.final-vote-summary {
margin-bottom: 4px;
}
.final-section-title {
font-size: 12px;
font-weight: 900;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #17324d;
margin-bottom: 10px;
}
.final-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.final-kicker {
color: #2f80ed;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
.final-title {
margin: 0 0 6px;
font-weight: 800;
color: #17324d;
}
.final-subtitle {
color: #6b7280;
font-size: 12px;
}
.final-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 0 0 14px;
}
.final-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);
}
.final-stat__label {
color: #6b7280;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.final-stat__value {
font-size: 22px;
font-weight: 900;
color: #17324d;
}
.final-stat__value--text {
font-size: 14px;
line-height: 1.25;
}
.final-stat__hint {
margin-top: 6px;
font-size: 12px;
color: #6b7280;
font-weight: 700;
}
.final-attendance__meta {
font-size: 12px;
color: #6b7280;
}
.final-attendance__meta-sep {
margin: 0 6px;
opacity: 0.6;
}
.final-attendance__body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.final-attendance__charts {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 0;
}
.final-attendance__charts--standalone {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px 24px;
align-items: start;
}
.final-attendance__chart-caption {
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
margin-bottom: 8px;
}
.final-attendance__chart-wrap {
position: relative;
width: 100%;
}
.final-attendance__chart-wrap--donut {
height: 240px;
}
.final-attendance__chart-wrap--bar {
height: 120px;
}
.final-attendance__state {
font-size: 13px;
color: #374151;
font-weight: 600;
}
.final-attendance__state--muted {
color: #6b7280;
font-style: italic;
font-weight: 500;
}
.final-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin: 10px 0 16px;
}
.final-filters__label {
color: #6b7280;
font-size: 12px;
font-weight: 700;
margin-right: 4px;
}
.final-chip {
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
line-height: 1;
}
.final-chip:hover {
background: #f3f4f6;
}
.final-chip.is-active {
background: #1976d2;
border-color: #1976d2;
color: #fff;
}
.final-empty {
text-align: center;
color: #6b7280;
font-style: italic;
margin: 24px 0;
}
.final-charts-row {
margin-bottom: 8px;
}
.final-charts-row--split > .col-xs-12 + .col-xs-12 {
margin-top: 12px;
}
.final-card {
border: 0;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 12px 28px rgba(35, 64, 97, 0.08);
}
.final-card__heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
background: linear-gradient(135deg, #1d4e89, #2f80ed);
color: #fff;
border: 0 !important;
}
.final-card__heading--alt {
background: linear-gradient(135deg, #0d47a1, #1976d2);
}
.final-card__title {
font-weight: 800;
}
.final-card__meta {
opacity: 0.95;
font-size: 12px;
}
.final-card__body {
background: #fff;
}
.final-chart-wrap {
position: relative;
height: 320px;
padding: 4px 0;
}
.final-chart-wrap--doughnut {
height: 300px;
}
@media (max-width: 991px) {
.final-header {
flex-direction: column;
}
.final-stats {
grid-template-columns: 1fr;
}
.final-attendance__charts--standalone {
grid-template-columns: 1fr;
}
.final-chart-wrap,
.final-chart-wrap--doughnut {
height: 260px;
}
}
@media (max-width: 575px) {
.final-dashboard__container {
padding-left: 12px;
padding-right: 12px;
}
.final-dashboard__shell,
.final-attendance-shell {
padding: 16px 14px 20px;
border-radius: 14px;
}
}
@@ -1,24 +1,130 @@
<template>
<div class="container">
<div class="row">
<div class="col-md-12">
<div v-for="position in positions" :key="position.id">
<h5>{{ position.name }}</h5>
<admin-data-table
:headers="finalTableHeaders"
:items="finalRowsByPosition(position.id)"
:loading="finalLoading"
:items-per-page="10"
:show-pagination="true"
empty-text="Tiada keputusan"
:exportable="true"
:export-file-name="'keputusan-akhir-' + position.id"
>
<template v-slot:item-percentage="{ item }">
{{ item.percentage }}
</template>
</admin-data-table>
<hr />
<div class="final-dashboard">
<div class="container-fluid final-dashboard__container">
<div class="row final-split-row">
<div class="col-md-6 final-split-col final-split-col--dashboard">
<div class="final-dashboard__shell">
<div class="final-header">
<div>
<div class="final-kicker">Keputusan akhir</div>
<h4 class="final-title">Dashboard keputusan</h4>
<div class="final-subtitle">Carta interaktif mengikut jawatan</div>
</div>
<div class="final-actions">
<button type="button" class="btn btn-info" :disabled="finalLoading" @click="loadData">
<i class="fa" :class="finalLoading ? 'fa-refresh fa-spin' : 'fa-refresh'"></i>
Kemaskini
</button>
</div>
</div>
<div class="final-vote-summary">
<div class="final-section-title">Ringkasan undian</div>
<div class="final-stats">
<div class="final-stat">
<div class="final-stat__label">Jumlah undi</div>
<div class="final-stat__value">{{ dashboardTotalVotes }}</div>
</div>
<div class="final-stat">
<div class="final-stat__label">Bil. jawatan</div>
<div class="final-stat__value">{{ positions.length }}</div>
</div>
<div class="final-stat">
<div class="final-stat__label">Calon terunggul</div>
<div class="final-stat__value final-stat__value--text">{{ topOverallLabel }}</div>
<div class="final-stat__hint">{{ topOverallVotes }} undi</div>
</div>
</div>
</div>
<div class="final-filters">
<span class="final-filters__label">Jawatan:</span>
<button type="button" class="final-chip" :class="{ 'is-active': selectedPositionId === 0 }" @click="setPosition(0)">
Semua
</button>
<button
v-for="position in positions"
:key="position.id"
type="button"
class="final-chip"
:class="{ 'is-active': selectedPositionId === position.id }"
@click="setPosition(position.id)"
>
{{ position.name }}
</button>
</div>
<div v-if="!finalLoading && positions.length === 0" class="final-empty">
Tiada jawatan untuk pilihan raya ini.
</div>
<div v-else class="row final-charts-row final-charts-row--split">
<div class="col-xs-12">
<div class="panel panel-default final-card">
<div class="panel-heading final-card__heading">
<div class="final-card__title">{{ mainChartTitle }}</div>
<div class="final-card__meta">{{ mainChartSubtitle }}</div>
</div>
<div class="panel-body final-card__body">
<div class="final-chart-wrap">
<bar-chart v-if="mainChartReady" v-bind="mainBarChartProps" />
</div>
</div>
</div>
</div>
<div class="col-xs-12">
<div class="panel panel-default final-card">
<div class="panel-heading final-card__heading final-card__heading--alt">
<div class="final-card__title">{{ sideChartTitle }}</div>
<div class="final-card__meta">{{ sideChartSubtitle }}</div>
</div>
<div class="panel-body final-card__body">
<div class="final-chart-wrap final-chart-wrap--doughnut">
<pie-chart v-if="mainChartReady" v-bind="sidePieChartProps" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 final-split-col final-split-col--attendance">
<div class="final-attendance-shell">
<div class="final-attendance-shell__head">
<div>
<div class="final-attendance-shell__kicker">Kehadiran</div>
<h4 class="final-attendance-shell__title">Ringkasan kehadiran</h4>
<div v-if="attendanceSummary" class="final-attendance__meta">
Kadar daftar: <b>{{ attendanceSummary.attendance_rate }}%</b>
<span class="final-attendance__meta-sep">·</span>
{{ attendanceSummary.marked_present }} / {{ attendanceSummary.total_registered }} pengundi
</div>
</div>
</div>
<div v-if="!attendanceSummaryReady" class="final-attendance__state">Memuatkan kehadiran</div>
<div v-else-if="attendanceSummary" class="final-attendance__body">
<div
v-if="attendanceSummary.total_registered > 0"
class="final-attendance__charts final-attendance__charts--standalone"
>
<div class="final-attendance__chart-block">
<div class="final-attendance__chart-caption">Taburan kaedah kehadiran</div>
<div class="final-attendance__chart-wrap final-attendance__chart-wrap--donut">
<pie-chart v-bind="attendanceBreakdownPieProps" />
</div>
</div>
<div v-if="showAttendanceFizikalChart" class="final-attendance__chart-block">
<div class="final-attendance__chart-caption">Status pendaftaran fizikal</div>
<div class="final-attendance__chart-wrap final-attendance__chart-wrap--bar">
<bar-chart v-bind="attendanceFizikalBarProps" />
</div>
</div>
</div>
</div>
<div v-else class="final-attendance__state final-attendance__state--muted">
Ringkasan kehadiran tidak dapat dimuatkan. Semak sambungan atau log masuk pentadbir.
</div>
</div>
</div>
</div>
</div>
@@ -26,125 +132,25 @@
</template>
<script>
import BarChart from '../../../common/BarChart.vue';
import PieChart from '../../../common/PieChart.vue';
export default {
components: {
BarChart,
PieChart
},
data: function () {
return {
nominees: [],
results: [],
partylists: [],
positions: [],
finalLoading: false,
finalTableHeaders: [
{ title: 'No. Anggota', key: 'no_anggota', sortable: true },
{ title: 'Nama', key: 'name', sortable: true },
{ title: 'Unit', key: 'unit', sortable: true },
{ title: 'Undian', key: 'votes', sortable: true },
{ title: 'Peratus (%)', key: 'percentage', sortable: true }
]
}
},
methods: {
finalRowsByPosition: function (positionId) {
var vm = this;
var rows = [];
// rows with votes
this.results.forEach(function (result) {
if (result.position_id == positionId) {
var n = vm.getNominee(result.nominee_id) || {};
rows.push({
no_anggota: n.no_anggota || '',
name: n.name || '',
unit: n.unit || '',
votes: result.votes || 0,
percentage: vm.calculatePercentage(result.votes || 0, positionId)
});
}
});
// rows without votes
this.no_votes.forEach(function (nv) {
if (nv.position_id == positionId) {
rows.push({
no_anggota: nv.no_anggota || '',
name: nv.name || '',
unit: nv.unit || '',
votes: 0,
percentage: '0.00'
});
}
});
// total row (kept as a normal row so it exports too)
rows.push({
no_anggota: '',
name: 'Jumlah Undian',
unit: '',
votes: this.calculateTotalVotes(positionId),
percentage: ''
});
// Sort by votes desc, but keep total row last
var total = rows.pop();
rows.sort(function (a, b) {
return (b.votes || 0) - (a.votes || 0);
});
rows.push(total);
return rows;
},
getNominee: function (id) {
let nominees = this.nominees;
for (var i in nominees)
if (id == nominees[i]['id'])
return nominees[i];
return {};
},
getPartylist: function (id) {
let partylists = this.partylists;
for (var i in partylists)
if (id == partylists[i]['id'])
return partylists[i]['name'];
return 'No Partylist';
},
calculatePercentage(votes, positionId) {
const totalVotes = this.results
.filter(result => result.position_id === positionId)
.reduce((total, result) => total + result.votes, 0);
return totalVotes === 0 ? '0.00' : ((votes / totalVotes) * 100).toFixed(2);
},
calculateTotalVotes(positionId) {
return this.results
.filter(result => result.position_id === positionId)
.reduce((total, result) => total + result.votes, 0);
}
},
created: function () {
this.util.notify('Loading please wait...', 'loading');
var vm = this;
this.finalLoading = true;
axios.get(config.API + 'election/result/' + this.election_id)
.then(response => {
$.notifyClose();
vm.nominees = response.data.nominee;
vm.results = response.data.result;
vm.partylists = response.data.partylist;
vm.positions = response.data.position;
})
.catch(error => {
$.notifyClose();
vm.util.showResult(error, 'error');
})
.finally(function () {
vm.finalLoading = false;
})
selectedPositionId: 0,
attendanceSummary: null,
attendanceSummaryReady: false
};
},
computed: {
@@ -153,21 +159,276 @@ export default {
},
no_votes: function () {
let nominees = this.nominees;
let results = this.results;
let no_votes = [];
var nominees = this.nominees;
var results = this.results;
var no_votes = [];
for (var i in nominees) {
var hasvote = false;
for (var y in results) {
if (results[y]['nominee_id'] == nominees[i]['id'])
if (results[y]['nominee_id'] == nominees[i]['id']) {
hasvote = true;
}
}
if (!hasvote)
if (!hasvote) {
no_votes.push(nominees[i]);
}
}
return no_votes;
}
},
dashboardTotalVotes: function () {
return this.results.reduce(function (s, r) {
return s + Number(r.votes || 0);
}, 0);
},
topOverallLabel: function () {
if (!this.results.length) {
return '—';
}
var best = this.results.reduce(function (a, b) {
return Number(b.votes || 0) > Number(a.votes || 0) ? b : a;
});
var n = this.getNominee(best.nominee_id);
return (n && n.name) ? n.name : '—';
},
topOverallVotes: function () {
if (!this.results.length) {
return 0;
}
return this.results.reduce(function (m, r) {
return Math.max(m, Number(r.votes || 0));
}, 0);
},
mainChartTitle: function () {
if (this.selectedPositionId === 0) {
return 'Jumlah undi mengikut jawatan';
}
var p = this.positions.find(function (x) {
return Number(x.id) === Number(this.selectedPositionId);
}, this);
return (p && p.name) ? 'Undi calon — ' + p.name : 'Undi calon';
},
mainChartSubtitle: function () {
return this.selectedPositionId === 0
? 'Perbandingan antara jawatan'
: 'Carta mengikut calon';
},
sideChartTitle: function () {
return this.selectedPositionId === 0 ? 'Taburan undi (jawatan)' : 'Peratus undi';
},
sideChartSubtitle: function () {
return this.selectedPositionId === 0 ? 'Perkongsian jumlah undi' : 'Bahagian setiap calon';
},
showAttendanceFizikalChart: function () {
if (!this.attendanceSummary) {
return false;
}
return Number(this.attendanceSummary.fizikal || 0) > 0;
},
mainChartReady: function () {
return !this.finalLoading && this.positions.length > 0;
},
positionTotalRows: function () {
var vm = this;
return this.positions.map(function (pos) {
var t = vm.results
.filter(function (r) {
return Number(r.position_id) === Number(pos.id);
})
.reduce(function (s, r) {
return s + Number(r.votes || 0);
}, 0);
return { id: pos.id, name: pos.name, total: t };
});
},
mainBarChartProps: function () {
if (!this.mainChartReady) {
return {};
}
if (this.selectedPositionId === 0) {
var rows = this.positionTotalRows;
return {
labels: rows.map(function (r) {
return r.name.length > 22 ? r.name.slice(0, 20) + '…' : r.name;
}),
values: rows.map(function (r) {
return r.total;
}),
datasetLabel: 'Jumlah undi'
};
}
var rows = this.getSinglePositionChartRows();
return {
labels: rows.map(function (r) {
return r.label;
}),
values: rows.map(function (r) {
return r.votes;
}),
datasetLabel: 'Undi'
};
},
sidePieChartProps: function () {
if (!this.mainChartReady) {
return {};
}
if (this.selectedPositionId === 0) {
var rows = this.positionTotalRows;
return {
labels: rows.map(function (r) {
return r.name;
}),
values: rows.map(function (r) {
return r.total;
})
};
}
var rows = this.getSinglePositionChartRows();
return {
labels: rows.map(function (r) {
return r.label;
}),
values: rows.map(function (r) {
return r.votes;
})
};
},
attendanceBreakdownPieProps: function () {
if (!this.attendanceSummary || this.attendanceSummary.total_registered <= 0) {
return { labels: [], values: [] };
}
var s = this.attendanceSummary;
return {
labels: ['Fizikal', 'Maya', 'Belum ditetapkan'],
values: [
Number(s.fizikal || 0),
Number(s.maya || 0),
Number(s.unset || 0)
]
};
},
attendanceFizikalBarProps: function () {
if (!this.showAttendanceFizikalChart || !this.attendanceSummary) {
return { labels: [], values: [] };
}
var s = this.attendanceSummary;
return {
labels: ['Menunggu pengesahan', 'Disahkan'],
values: [
Number(s.fizikal_registration_pending || 0),
Number(s.fizikal_registration_verified || 0)
],
datasetLabel: 'Pengundi'
};
}
},
methods: {
setPosition: function (id) {
this.selectedPositionId = id === 0 ? 0 : Number(id);
},
getSinglePositionChartRows: function () {
var pid = this.selectedPositionId;
var vm = this;
var rows = this.results
.filter(function (r) {
return Number(r.position_id) === Number(pid);
})
.map(function (r) {
var n = vm.getNominee(r.nominee_id) || {};
var label = n.name || ('#' + r.nominee_id);
if (label.length > 28) {
label = label.slice(0, 26) + '…';
}
return {
label: label,
votes: Number(r.votes || 0)
};
});
this.no_votes.forEach(function (nv) {
if (Number(nv.position_id) === Number(pid)) {
var label = nv.name || '';
if (label.length > 28) {
label = label.slice(0, 26) + '…';
}
rows.push({
label: label || 'Calon',
votes: 0
});
}
});
rows.sort(function (a, b) {
return b.votes - a.votes;
});
return rows;
},
loadData: function () {
var vm = this;
this.util.notify('Loading please wait...', 'loading');
this.finalLoading = true;
this.attendanceSummaryReady = false;
this.attendanceSummary = null;
axios
.get(config.API + 'election/' + this.election_id + '/attendance-summary')
.then(function (r) {
vm.attendanceSummary = r.data;
})
.catch(function () {
vm.attendanceSummary = null;
})
.finally(function () {
vm.attendanceSummaryReady = true;
});
axios
.get(config.API + 'election/result/' + this.election_id)
.then(function (response) {
$.notifyClose();
vm.nominees = response.data.nominee || [];
vm.results = response.data.result || [];
vm.positions = response.data.position || [];
})
.catch(function (error) {
$.notifyClose();
vm.util.showResult(error, 'error');
})
.finally(function () {
vm.finalLoading = false;
});
},
getNominee: function (id) {
var nominees = this.nominees;
for (var i in nominees) {
if (id == nominees[i]['id']) {
return nominees[i];
}
}
return {};
}
},
created: function () {
this.util.setTitle('MyKoPKB - Keputusan akhir');
this.loadData();
}
}
</script>
};
</script>
<style scoped src="./final.css"></style>
@@ -1,35 +1,76 @@
<template>
<div class="panel panel-default">
<div class="panel-body">
<h4>Undian Terkini</h4>
<div class="form-group">
<button class="btn btn-success" v-if="data.election.status == 1 && data.user.role == 1"
@click="util.showModal('#start-election-modal')">Mula Undian</button>
<ul class="nav nav-tabs election-tabs">
<li :class="{ active: activeTab === 'current' }">
<a href="#" @click.prevent="activeTab = 'current'">Terkini</a>
</li>
<li :class="{ active: activeTab === 'history' }">
<a href="#" @click.prevent="activeTab = 'history'">Sejarah Undian</a>
</li>
</ul>
<router-link :to="{ name: 'Current Result' }" class="btn btn-info" v-if="data.election.status == 2">
Papar Keputusan
</router-link>
<div class="tab-content election-tab-content">
<div class="tab-pane" :class="{ active: activeTab === 'current' }">
<div class="election-header">
<div class="election-header__title">
<div class="election-header__kicker">Undian Terkini</div>
<div class="election-header__name">{{ electionLatestHeading }}</div>
</div>
<div class="election-header__meta">
<span class="election-status" :class="electionStatusClass">{{ electionStatusLabel }}</span>
<label v-if="data.election.status == 2" class="election-label">
Undian bermula pada {{ data.election.start }}
</label>
<div class="election-header__meta-actions">
<button class="btn btn-success btn-xs"
v-if="Number(data.election.status) === 1 && data.user.role == 1"
@click="openStartElectionModal">
Mula Undian
</button>
</div>
</div>
</div>
<button class="btn btn-danger" v-if="data.election.status == 2"
@click="util.showModal('#stop-election-modal')">Tamat Undian</button>
<div class="election-actions form-group">
<button class="btn btn-primary" v-if="Number(data.election.status) === 3 && data.user.role == 1"
@click="util.showModal('#register-next-session-modal')">Daftar Sesi Mengundi Baru</button>
<label v-if="data.election.status == 2">Undian bermula {{ start_date }}</label>
<router-link :to="{ name: 'Current Result' }" class="btn btn-info"
v-if="data.election.status == 2">
Keputusan Semasa
</router-link>
<button class="btn btn-danger" v-if="data.election.status == 2"
@click="util.showModal('#stop-election-modal')">Tamat Undian</button>
</div>
<div class="alert alert-info election-hint" v-if="Number(data.user.role) === 1">
<b>Makluman:</b> Sila semak &amp; kemaskini <b>Maklumat Calon</b> dan <b>Anggota Koperasi</b>
(butiran
pengundi) untuk sesi ini sebelum memulakan undian.
</div>
</div>
<div class="tab-pane" :class="{ active: activeTab === 'history' }">
<div class="election-history">
<div class="election-history__title">Sejarah Undian</div>
<admin-data-table :headers="electionTableHeaders" :items="data.elections"
:loading="electionLoading" :items-per-page="10" :show-pagination="true"
empty-text="Tiada undian" :exportable="true" export-file-name="undian">
<template v-slot:item-actions="{ item }">
<router-link :to="{ name: 'Election Result', params: { election_id: item.id } }"
class="btn btn-info">
Ringkasan Keputusan
</router-link>
<a class="btn btn-warning" :href="getPDFurl(item.id)">Muat Turun PDF</a>
</template>
</admin-data-table>
</div>
</div>
</div>
<admin-data-table :headers="electionTableHeaders" :items="data.elections" :loading="electionLoading"
:items-per-page="10" :show-pagination="true" empty-text="Tiada undian" :exportable="true"
export-file-name="undian">
<template v-slot:item-actions="{ item }">
<router-link :to="{ name: 'Election Result', params: { election_id: item.id } }"
class="btn btn-info">
Keputusan Undian
</router-link>
<a class="btn btn-warning" :href="getPDFurl(item.id)">Muat Turun PDF</a>
</template>
</admin-data-table>
<form @submit.prevent="start" id="start_form">
<form @submit.prevent="start">
<div class="modal fade" id="start-election-modal" tabindex="-1" role="dialog"
aria-labelledby="start-election-modal" aria-hidden="true">
<div class="modal-dialog" role="document">
@@ -42,19 +83,60 @@
</div>
<div class="modal-body">
<div class="form-group">
<label for="name">Nama Undian</label>
<input type="text" name="name" autocomplete="on" class="form-control"
placeholder="(Optional)" />
<label for="start-election-name">Nama Undian</label>
<input id="start-election-name" v-model.trim="startForm.name" type="text"
name="name" autocomplete="off" class="form-control" placeholder="(Pilihan)"
readonly tabindex="-1" />
</div>
<div class="form-group">
<label for="password">Kata Laluan</label>
<input type="password" name="password" class="form-control" required />
<label for="start-election-password">Kata Laluan</label>
<input id="start-election-password" v-model="startForm.password" type="password"
name="password" class="form-control" required autocomplete="current-password"
:disabled="startSubmitting" />
</div>
</div>
<div class="modal-footer">
<input type="submit" class="btn btn-info" value="Start" />
<input type="button" data-dismiss="modal" class="btn btn-default" value="Cancel" />
<button type="submit" class="btn btn-info" :disabled="startSubmitting">
{{ startSubmitting ? 'Memproses…' : 'Mula' }}
</button>
<input type="button" data-dismiss="modal" class="btn btn-default" value="Batal" />
</div>
</div>
</div>
</div>
</form>
<form @submit.prevent="registerNextSession" id="register_next_form">
<div class="modal fade" id="register-next-session-modal" tabindex="-1" role="dialog"
aria-labelledby="register-next-session-modal" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="registerNextModalLabel">Daftar Sesi Mengundi Baru</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p class="text-muted">Sesi baharu akan dibuka untuk pusingan undian seterusnya. Undian
yang
telah tamat kekal dalam rekod.</p>
<div class="form-group">
<label for="register-next-name">Nama Undian</label>
<input id="register-next-name" type="text" name="name" autocomplete="on"
class="form-control" />
</div>
<div class="form-group">
<label for="register-next-password">Kata Laluan</label>
<input id="register-next-password" type="password" name="password"
class="form-control" required autocomplete="current-password" />
</div>
</div>
<div class="modal-footer">
<input type="submit" class="btn btn-primary" value="Daftar" />
<input type="button" data-dismiss="modal" class="btn btn-default" value="Batal" />
</div>
</div>
</div>
@@ -76,7 +158,7 @@
<div class="form-group">
<label for="name">Nama Undian</label>
<input type="text" name="name" :value="data.election.name" autocomplete="on"
class="form-control" placeholder="(Optional)" />
class="form-control" placeholder="(Optional)" readonly tabindex="-1" />
</div>
<div class="form-group">
@@ -86,19 +168,129 @@
</div>
</div>
<div class="modal-footer">
<input type="submit" class="btn btn-info" value="Stop Election" />
<input type="button" data-dismiss="modal" class="btn btn-default" value="Cancel" />
<input type="submit" class="btn btn-info" value="Tamat Undian" />
<input type="button" data-dismiss="modal" class="btn btn-default" value="Batal" />
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</template>
<style>
.election-label {
display: inline-block;
margin-left: 10px;
font-size: 13px;
color: #ffffff;
background-color: #0a4c9c;
padding: 4px 8px;
border-radius: 4px;
}
.election-tabs {
margin: 0 0 12px 0;
}
.election-tab-content>.tab-pane {
display: none;
}
.election-tab-content>.tab-pane.active {
display: block;
}
.election-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 14px 15px;
border-bottom: 1px solid #eee;
}
.election-header__kicker {
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #777;
margin-bottom: 4px;
}
.election-header__name {
font-size: 18px;
font-weight: 700;
line-height: 1.2;
color: #222;
}
.election-status {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
white-space: nowrap;
}
.election-status--draft {
background: #f5f5f5;
color: #555;
border-color: #e6e6e6;
}
.election-status--active {
background: #e8f5e9;
color: #2e7d32;
border-color: #c8e6c9;
}
.election-status--done {
background: #ffebee;
color: #c62828;
border-color: #ffcdd2;
}
.election-header__meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.election-header__meta-actions .btn {
padding: 4px 10px;
font-weight: 700;
}
.election-actions {
margin-top: 12px;
}
.election-hint {
margin-top: 10px;
margin-bottom: 12px;
}
.election-history {
margin-top: 4px;
padding-top: 12px;
border-top: 1px solid #eee;
}
.election-history__title {
font-size: 13px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #666;
margin: 0 0 10px 0;
}
.center-header {
text-align: center;
}
@@ -116,25 +308,58 @@
<script>
export default {
data: () => ({
start_date: null,
activeTab: 'current',
electionLoading: false,
startSubmitting: false,
startForm: {
name: '',
password: ''
},
electionTableHeaders: [
{ title: 'Nama Undian', key: 'name', sortable: true },
{ title: 'Undian Mula', key: 'start', sortable: true },
{ title: 'Undian Tamat', key: 'end', sortable: true },
{ title: 'Papar', key: 'actions', sortable: false }
{ title: 'Tindakan', key: 'actions', sortable: false }
]
}),
created: function () {
this.refreshElection();
var vm = this;
setInterval(() => {
vm.start_date = moment(vm.data.election.start).fromNow();
}, 1000);
},
computed: {
electionLatestHeading: function () {
var e = this.data.election;
var n = e && e.name != null ? String(e.name).trim() : '';
return n !== '' ? n : 'Undian Terkini';
},
electionStatusLabel: function () {
var s = Number(this.data && this.data.election ? this.data.election.status : NaN);
if (s === 1) return 'Draf';
if (s === 2) return 'Sesi Mengundi Sedang Berjalan';
if (s === 3) return 'Tamat';
return '—';
},
electionStatusClass: function () {
var s = Number(this.data && this.data.election ? this.data.election.status : NaN);
if (s === 1) return 'election-status--draft';
if (s === 2) return 'election-status--active';
if (s === 3) return 'election-status--done';
return '';
}
},
methods: {
openStartElectionModal: function () {
var e = this.data.election;
this.startForm.name = e && e.name != null ? String(e.name) : '';
this.startForm.password = '';
this.startSubmitting = false;
this.util.showModal('#start-election-modal');
},
stop: function () {
var vm = this;
// this.util.hideModal('#stop-election-modal');
@@ -145,11 +370,6 @@ export default {
if (vm.util.showResult(response, 'success')) {
vm.refreshElection();
vm.data.election = response.data.election;
vm.data.positions = [];
vm.data.partylists = [];
vm.data.voters = [];
vm.data.nominees = [];
vm.data.results = [];
}
})
.catch(error => {
@@ -160,15 +380,50 @@ export default {
start: function () {
var vm = this;
// this.util.hideModal('#start-election-modal');
console.log('this');
this.util.notify('Loading please wait', 'loading');
axios.post(config.API + 'election/start', $('#start_form').serialize())
if (vm.startSubmitting) {
return;
}
vm.startSubmitting = true;
this.util.notify('Memproses…', 'loading');
axios.post(config.API + 'election/start', {
name: vm.startForm.name,
password: vm.startForm.password
})
.then(response => {
$.notifyClose();
if (vm.util.showResult(response, 'success')) {
vm.refreshElection();
vm.data.election = response.data.election;
vm.startForm.password = '';
vm.util.hideModal('#start-election-modal');
}
})
.catch(error => {
$.notifyClose();
vm.util.showResult(error, 'error');
})
.finally(function () {
vm.startSubmitting = false;
})
},
registerNextSession: function () {
var vm = this;
this.util.notify('Memproses…', 'loading');
axios.post(config.API + 'election/register-next', $('#register_next_form').serialize())
.then(response => {
$.notifyClose();
if (vm.util.showResult(response, 'success')) {
vm.refreshElection();
vm.data.election = response.data.election;
vm.util.hideModal('#register-next-session-modal');
axios.get(config.API + 'admin/information').then(function (info) {
vm.data.election = info.data.election;
vm.data.partylists = info.data.partylist;
vm.data.positions = info.data.position;
}).catch(function (err) {
vm.util.showResult(err, 'error');
});
}
})
.catch(error => {
@@ -43,7 +43,7 @@
</div>
<div class="row">
<div class="col-md-7">
<div class="col-md-12">
<div class="panel panel-default result-card">
<div class="panel-heading result-card__heading">
<div class="result-card__title">
@@ -99,7 +99,10 @@
</div>
</div>
</div>
<div class="col-md-5">
</div>
<div class="row" style="margin-top: 12px;">
<div class="col-md-12">
<div class="panel panel-default result-card">
<div class="panel-heading result-card__heading">
<div class="result-card__title">
@@ -114,11 +117,15 @@
<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>
<BarChart class="result-top-chart" :labels="topNominees.map(n => n.label)"
:values="topNominees.map(n => n.votes)" background-color="rgba(54, 162, 235, 0.75)"
border-color="rgba(54, 162, 235, 1)" />
<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>
<BarChart class="result-top-chart" :labels="positionTotals.map(p => p.label)"
:values="positionTotals.map(p => p.votes)" background-color="rgba(75, 192, 192, 0.55)"
border-color="rgba(75, 192, 192, 1)" />
</template>
<template v-else>
<div class="result-empty">
@@ -134,21 +141,18 @@
</template>
<script>
import BarChart from '../../../common/BarChart.vue';
export default {
components: { BarChart },
created: function () {
this.refreshNominees();
this.$nextTick(function () {
this.initCharts();
});
},
watch: {
position_id: function () {
var vm = this;
this.$nextTick(function () {
vm.initCharts();
});
// charts are reactive via <BarChart>
}
},
@@ -188,9 +192,6 @@ export default {
$.notifyClose();
vm.data.results = response.data;
vm.data.last_update = new Date();
vm.$nextTick(function () {
vm.initCharts();
});
})
.catch(error => {
$.notifyClose();
@@ -198,47 +199,6 @@ export default {
})
},
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)