Allowance check

This commit is contained in:
ISMAIL MASSERAN
2026-04-14 02:34:00 +00:00
parent 0c4d200839
commit 3775c4a876
100 changed files with 81493 additions and 3226 deletions
BIN
View File
Binary file not shown.
+14
View File
@@ -18,6 +18,20 @@ const router = new VueRouter({
routes
});
router.beforeEach(function (to, from, next) {
if (!to.path.startsWith('/admin') || to.name === 'Admin Login') {
return next();
}
if (!localStorage.getItem('Access Token')) {
return next();
}
var role = localStorage.getItem('admin_role');
if (role === '3' && to.name !== 'Kehadiran Calon') {
return next({ name: 'Kehadiran Calon', replace: true });
}
next();
});
const app = new Vue({
router
}).$mount('#app');
Binary file not shown.
@@ -0,0 +1,688 @@
<template>
<div class="admin-data-table">
<div v-if="exportable" class="clearfix admin-data-table__toolbar">
<div class="btn-group pull-right">
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<i class="fa fa-download"></i> Eksport <span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="#" @click.prevent="exportToCSV">Eksport CSV</a></li>
<li><a href="#" @click.prevent="exportToExcel">Eksport Excel</a></li>
<li><a href="#" @click.prevent="exportToPDF">Eksport PDF</a></li>
<li><a href="#" @click.prevent="exportToJSON">Eksport JSON</a></li>
</ul>
</div>
</div>
<div class="admin-data-table__shell">
<div class="admin-data-table__scroll">
<table class="admin-data-table__table table table-hover">
<thead>
<tr>
<th v-for="h in tableHeaders" :key="h.key" :class="{
'is-sortable': h.sortable,
'admin-data-table__col-index': h.key === '__adt_index'
}" @click="h.sortable && toggleSort(h.key)">
<div class="admin-data-table__th-inner">
<span class="admin-data-table__th-title">{{ h.title }}</span>
<span v-if="h.sortable" class="admin-data-table__sort" aria-hidden="true">
<template v-if="sortKey === h.key">
<svg v-if="sortDir === 'asc'" class="admin-data-table__sort-icon"
xmlns="http://www.w3.org/2000/svg" width="14" height="14"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M18 15l-6-6-6 6" />
</svg>
<svg v-else class="admin-data-table__sort-icon"
xmlns="http://www.w3.org/2000/svg" width="14" height="14"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6" />
</svg>
</template>
<span v-else class="admin-data-table__sort-hint">
<svg class="admin-data-table__sort-icon admin-data-table__sort-icon--faint"
xmlns="http://www.w3.org/2000/svg" width="10" height="10"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5">
<path d="M18 15l-6-6-6 6" />
</svg>
<svg class="admin-data-table__sort-icon admin-data-table__sort-icon--faint"
xmlns="http://www.w3.org/2000/svg" width="10" height="10"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5">
<path d="M6 9l6 6 6-6" />
</svg>
</span>
</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-if="loading" class="admin-data-table__state-row">
<td :colspan="tableHeaders.length">
<div class="admin-data-table__state">
<i class="fa fa-refresh fa-spin admin-data-table__state-icon"></i>
<div class="admin-data-table__state-title">Memuatkan</div>
</div>
</td>
</tr>
<tr v-else-if="!displayRows.length" class="admin-data-table__state-row">
<td :colspan="tableHeaders.length">
<div class="admin-data-table__state">
<i class="fa fa-inbox admin-data-table__state-icon"></i>
<div class="admin-data-table__state-title">{{ emptyText }}</div>
<div class="admin-data-table__state-hint">Tiada rekod untuk dipaparkan</div>
</div>
</td>
</tr>
<tr v-else v-for="(item, rowIdx) in displayRows" :key="String(item[itemKey])">
<td v-for="h in tableHeaders" :key="h.key" :class="cellTdClass(h)">
<template v-if="h.key === '__adt_index'">
{{ rowGlobalIndex(rowIdx) }}
</template>
<template v-else>
<slot :name="'item-' + h.key" :item="item" :value="cellValue(item, h.key)">
{{ formatCell(item, h.key) }}
</slot>
</template>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="showPagination && !loading && totalItems > 0" class="admin-data-table__footer">
<div class="admin-data-table__footer-left">
<label class="admin-data-table__footer-label">Baris setiap halaman</label>
<select v-model.number="localPerPage" class="form-control input-sm admin-data-table__page-size"
@change="onPerPageChange">
<option v-for="n in perPageOptions" :key="n" :value="n">{{ n }}</option>
</select>
</div>
<div class="admin-data-table__footer-mid">
{{ rangeFrom }}{{ rangeTo }} daripada {{ totalItems }}
</div>
<nav class="admin-data-table__footer-right" aria-label="Pagination">
<ul class="pagination pagination-sm admin-data-table__pagination">
<li :class="{ disabled: page <= 1 }">
<a href="#" @click.prevent="goPage(page - 1)">Sebelum</a>
</li>
<li :class="{ disabled: page >= totalPages }">
<a href="#" @click.prevent="goPage(page + 1)">Seterusnya</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</template>
<script>
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver';
export default {
name: 'AdminDataTable',
props: {
headers: {
type: Array,
default: function () {
return [];
}
},
items: {
type: Array,
default: function () {
return [];
}
},
itemsPerPage: {
type: Number,
default: 10
},
showPagination: {
type: Boolean,
default: true
},
loading: {
type: Boolean,
default: false
},
itemKey: {
type: String,
default: 'id'
},
emptyText: {
type: String,
default: 'Tiada data'
},
exportable: {
type: Boolean,
default: false
},
exportFileName: {
type: String,
default: 'table-data'
},
showIndex: {
type: Boolean,
default: true
},
indexTitle: {
type: String,
default: 'Bil'
}
},
data: function () {
return {
page: 1,
localPerPage: this.itemsPerPage,
sortKey: null,
sortDir: 'asc',
perPageOptions: [10, 25, 50, 100]
};
},
computed: {
totalItems: function () {
return this.sortedItems.length;
},
totalPages: function () {
var n = Math.ceil(this.totalItems / this.localPerPage) || 1;
return n;
},
sortedItems: function () {
var list = this.items.slice();
if (!this.sortKey) {
return list;
}
var key = this.sortKey;
var dir = this.sortDir === 'desc' ? -1 : 1;
list.sort(function (a, b) {
var va = a[key];
var vb = b[key];
if (va == null && vb == null) return 0;
if (va == null) return 1;
if (vb == null) return -1;
if (typeof va === 'number' && typeof vb === 'number') {
return (va - vb) * dir;
}
return String(va).localeCompare(String(vb), undefined, { numeric: true }) * dir;
});
return list;
},
displayRows: function () {
if (!this.showPagination) {
return this.sortedItems;
}
var start = (this.page - 1) * this.localPerPage;
return this.sortedItems.slice(start, start + this.localPerPage);
},
rangeFrom: function () {
if (!this.totalItems) return 0;
return (this.page - 1) * this.localPerPage + 1;
},
rangeTo: function () {
return Math.min(this.page * this.localPerPage, this.totalItems);
},
tableHeaders: function () {
if (!this.showIndex) {
return this.headers;
}
return [
{ title: this.indexTitle, key: '__adt_index', sortable: false }
].concat(this.headers);
}
},
watch: {
items: function () {
if (this.page > this.totalPages) {
this.page = Math.max(1, this.totalPages);
}
},
itemsPerPage: function (v) {
this.localPerPage = v;
}
},
methods: {
rowGlobalIndex: function (rowIdx) {
if (!this.showPagination) {
return rowIdx + 1;
}
return (this.page - 1) * this.localPerPage + rowIdx + 1;
},
cellTdClass: function (h) {
var o = {};
if (h.key === '__adt_index') o['admin-data-table__col-index'] = true;
return o;
},
cellValue: function (item, key) {
return item[key];
},
formatCell: function (item, key) {
var v = item[key];
return v == null ? '' : v;
},
toggleSort: function (key) {
if (this.sortKey === key) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sortKey = key;
this.sortDir = 'asc';
}
},
goPage: function (p) {
if (p < 1 || p > this.totalPages) return;
this.page = p;
},
onPerPageChange: function () {
this.page = 1;
},
isActionColumnKey: function (key) {
if (!key) return false;
var k = String(key).toLowerCase();
return k === 'actions' || k === 'action' || k === 'opsi' || k === 'tindakan';
},
resolveFieldValue: function (item, headerKey) {
if (headerKey === '#') return '';
var keys = String(headerKey).split('.');
var value = item;
for (var i = 0; i < keys.length; i++) {
if (value && typeof value === 'object' && keys[i] in value) {
value = value[keys[i]];
} else {
return null;
}
}
if (Array.isArray(value)) {
return value
.map(function (v) {
return v && typeof v === 'object' && v.name != null ? v.name : String(v);
})
.join(', ');
}
if (value && typeof value === 'object' && 'name' in value) {
return value.name;
}
return value;
},
cellStringForExport: function (header, item, rowIndex) {
if (typeof header.exportValue === 'function') {
return header.exportValue(item, rowIndex) || '';
}
if (header.key === '__adt_index' || header.key === '#') {
return String(rowIndex + 1);
}
if (this.isActionColumnKey(header.key)) {
return '';
}
var raw = this.resolveFieldValue(item, header.key);
if (raw == null) return '';
return String(raw);
},
prepareExportMatrix: function () {
var rows = [];
var headerTitles = this.tableHeaders.map(function (h) {
return h.title || h.key;
});
var vm = this;
this.sortedItems.forEach(function (item, index) {
var line = vm.tableHeaders.map(function (h) {
return vm.cellStringForExport(h, item, index);
});
rows.push(line);
});
return { headerTitles: headerTitles, rows: rows };
},
exportEmitError: function (err) {
this.$emit('export-error', err instanceof Error ? err : new Error(String(err)));
},
exportToCSV: function () {
try {
var m = this.prepareExportMatrix();
var csvContent = [m.headerTitles]
.concat(m.rows)
.map(function (row) {
return row
.map(function (cell) {
return '"' + String(cell).replace(/"/g, '""') + '"';
})
.join(',');
})
.join('\n');
var blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
saveAs(blob, this.exportFileName + '.csv');
} catch (e) {
this.exportEmitError(e);
}
},
exportToExcel: function () {
try {
var m = this.prepareExportMatrix();
var aoa = [m.headerTitles].concat(m.rows);
var worksheet = XLSX.utils.aoa_to_sheet(aoa);
var workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
var buf = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
var blob = new Blob([buf], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
saveAs(blob, this.exportFileName + '.xlsx');
} catch (e) {
this.exportEmitError(e);
}
},
exportToPDF: function () {
try {
var m = this.prepareExportMatrix();
var colCount = m.headerTitles.length;
var useLandscape = colCount > 6;
var doc = new jsPDF(useLandscape ? 'l' : 'p', 'pt', 'a4');
var fontSize = 8;
if (colCount > 12) fontSize = 6;
else if (colCount > 8) fontSize = 7;
var cellPadding = colCount > 8 ? 4 : 8;
var tableOptions = {
head: [m.headerTitles],
body: m.rows,
styles: { fontSize: fontSize, cellPadding: cellPadding },
headStyles: { fillColor: [25, 118, 210] },
margin: { top: 20 }
};
if (colCount > 8) {
tableOptions.tableWidth = 'wrap';
tableOptions.horizontalPageBreak = true;
tableOptions.styles = Object.assign({}, tableOptions.styles, {
minCellWidth: 36,
overflow: 'linebreak'
});
}
autoTable(doc, tableOptions);
doc.save(this.exportFileName + '.pdf');
} catch (e) {
this.exportEmitError(e);
}
},
exportToJSON: function () {
try {
var vm = this;
var jsonData = this.sortedItems.map(function (item, index) {
var obj = {};
vm.tableHeaders.forEach(function (h) {
var title = h.title || h.key;
if (vm.isActionColumnKey(h.key)) return;
if (h.key === '__adt_index') {
obj[title] = index + 1;
return;
}
if (typeof h.exportValue === 'function') {
obj[title] = h.exportValue(item, index);
return;
}
var keys = String(h.key).split('.');
var value = item;
for (var i = 0; i < keys.length; i++) {
if (value && typeof value === 'object' && keys[i] in value) {
value = value[keys[i]];
} else {
value = null;
break;
}
}
obj[title] = value;
});
return obj;
});
var jsonContent = JSON.stringify(jsonData, null, 2);
var blob = new Blob([jsonContent], { type: 'application/json' });
saveAs(blob, this.exportFileName + '.json');
} catch (e) {
this.exportEmitError(e);
}
}
}
};
</script>
<style scoped>
.admin-data-table__toolbar {
margin-bottom: 12px;
}
.admin-data-table {
--adt-header-bg: #1976d2;
--adt-header-color: #fff;
--adt-radius: 12px;
--adt-border: 1px solid #e0e0e0;
--adt-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--adt-row-hover: #e3f2fd;
--adt-footer-bg: #fafafa;
--adt-text-muted: #757575;
--adt-font-size: 16px;
--adt-header-font-size: 16px;
}
.admin-data-table__shell {
border-radius: var(--adt-radius);
border: var(--adt-border);
box-shadow: var(--adt-shadow);
overflow: hidden;
background: #fff;
}
.admin-data-table__scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.admin-data-table__table {
width: 100%;
margin-bottom: 0;
border-collapse: separate;
border-spacing: 0;
}
.admin-data-table__table thead tr:first-child th:first-child {
border-top-left-radius: var(--adt-radius);
}
.admin-data-table__table thead tr:first-child th:last-child {
border-top-right-radius: var(--adt-radius);
}
.admin-data-table__table thead th {
background-color: var(--adt-header-bg);
color: var(--adt-header-color);
font-weight: 600;
font-size: var(--adt-header-font-size);
text-transform: none;
letter-spacing: 0.02em;
border: none !important;
padding: 14px 16px;
vertical-align: middle;
}
.admin-data-table__table thead th.is-sortable {
cursor: pointer;
user-select: none;
}
.admin-data-table__table thead th.is-sortable:hover {
filter: brightness(1.06);
}
.admin-data-table__table thead th.admin-data-table__col-index,
.admin-data-table__table tbody td.admin-data-table__col-index {
width: 3.25rem;
max-width: 4rem;
text-align: center;
font-variant-numeric: tabular-nums;
vertical-align: middle !important;
}
.admin-data-table__th-inner {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
min-height: 20px;
}
.admin-data-table__th-title {
flex: 1;
min-width: 0;
}
.admin-data-table__sort {
display: inline-flex;
align-items: center;
flex-shrink: 0;
opacity: 0.95;
}
.admin-data-table__sort-hint {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1;
opacity: 0.45;
margin-top: 1px;
}
.admin-data-table__sort-icon {
display: block;
flex-shrink: 0;
}
.admin-data-table__sort-icon--faint {
margin-top: -3px;
}
.admin-data-table__sort-hint .admin-data-table__sort-icon--faint:first-child {
margin-top: 0;
}
.admin-data-table__table tbody td {
padding: 12px 16px;
vertical-align: middle;
border-top: 1px solid #eee;
font-size: var(--adt-font-size);
}
.admin-data-table__table tbody tr:first-child:not(.admin-data-table__state-row) td {
border-top: 1px solid #e0e0e0;
}
.admin-data-table__table tbody tr.admin-data-table__state-row td {
border-top: none;
padding: 0;
}
.admin-data-table__table tbody tr:not(.admin-data-table__state-row):hover td {
background-color: var(--adt-row-hover);
}
.admin-data-table__state {
text-align: center;
padding: 40px 24px;
color: var(--adt-text-muted);
}
.admin-data-table__state-icon {
font-size: 40px;
opacity: 0.45;
margin-bottom: 12px;
display: block;
margin-left: auto;
margin-right: auto;
}
.admin-data-table__state-title {
font-size: 16px;
font-weight: 600;
color: #424242;
margin-bottom: 4px;
}
.admin-data-table__state-hint {
font-size: 13px;
color: var(--adt-text-muted);
}
.admin-data-table__footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 16px;
padding: 12px 16px;
border-top: var(--adt-border);
background: var(--adt-footer-bg);
}
.admin-data-table__footer-left {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.admin-data-table__footer-label {
margin: 0;
font-size: 12px;
font-weight: normal;
color: var(--adt-text-muted);
}
.admin-data-table__page-size {
width: auto;
min-width: 64px;
display: inline-block;
height: 30px;
padding: 4px 8px;
font-size: 12px;
}
.admin-data-table__footer-mid {
font-size: 13px;
font-weight: 500;
color: #616161;
}
.admin-data-table__footer-right {
flex-shrink: 0;
}
.admin-data-table__pagination {
margin: 0;
}
.admin-data-table__pagination>li>a {
padding: 5px 12px;
font-size: 12px;
color: var(--adt-header-bg);
}
.admin-data-table__pagination>li.disabled>a {
color: #bbb;
pointer-events: none;
cursor: default;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -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>
@@ -0,0 +1,111 @@
<template>
<div class="panel panel-default">
<div class="panel-body">
<div class="clearfix">
<h4 class="pull-left" style="margin-top: 0;">Activity Log</h4>
<button class="btn btn-default btn-sm pull-right" @click="refresh" :disabled="loading">
<i class="fa fa-refresh" :class="{ 'fa-spin': loading }"></i> Refresh
</button>
</div>
<div class="row" style="margin-top: 12px; margin-bottom: 12px;">
<div class="col-sm-8 col-md-6">
<div class="input-group">
<input v-model.trim="searchText" type="text" class="form-control"
placeholder="Cari aktiviti (description)…" @keyup.enter="refresh" />
<span class="input-group-btn">
<button class="btn btn-primary" @click="refresh" :disabled="loading">
Cari
</button>
<button class="btn btn-default" @click="clearSearch" :disabled="loading || !searchText">
Clear
</button>
</span>
</div>
</div>
</div>
<admin-data-table :headers="headers" :items="items" :loading="loading" :items-per-page="25"
:show-pagination="true" empty-text="Tiada activity log" :exportable="true"
export-file-name="activity-log">
<template v-slot:item-id="{ value }">
<span style="white-space: nowrap;">{{ value }}</span>
</template>
<template v-slot:item-created_at="{ value }">
<span style="white-space: nowrap;">{{ value }}</span>
</template>
<template v-slot:item-properties="{ value }">
<pre
style="margin: 0; white-space: pre-wrap; word-break: break-word; background: transparent; border: 0; padding: 0;">{{ stringify(value) }}</pre>
</template>
</admin-data-table>
</div>
</div>
</template>
<script>
import AdminDataTable from '../../../AdminDataTable.vue';
export default {
components: { AdminDataTable },
data: function () {
return {
loading: false,
searchText: '',
items: [],
headers: [
{ title: 'Aktiviti', key: 'description', sortable: true },
{ title: 'Subject Type', key: 'subject_type', sortable: true },
{ title: 'Subject ID', key: 'subject_id', sortable: true },
{ title: 'Causer Type', key: 'causer_type', sortable: true },
{ title: 'Causer ID', key: 'causer_id', sortable: true },
{ title: 'Properties', key: 'properties', sortable: false },
{ title: 'Masa', key: 'created_at', sortable: true },
]
};
},
created: function () {
this.refresh();
},
methods: {
stringify: function (v) {
if (v == null) return '';
try {
return JSON.stringify(v, null, 2);
} catch (e) {
return String(v);
}
},
refresh: function () {
var vm = this;
vm.loading = true;
var params = { per_page: 200 };
if (vm.searchText) {
params.description = vm.searchText;
}
axios.get(config.API + 'admin/activity-log', { params: params })
.then(function (response) {
vm.items = (response && response.data && response.data.data) ? response.data.data : [];
})
.catch(function (error) {
vm.items = [];
if (vm.util && typeof vm.util.showResult === 'function') {
vm.util.showResult(error, 'error');
}
})
.finally(function () {
vm.loading = false;
});
},
clearSearch: function () {
this.searchText = '';
this.refresh();
}
}
};
</script>
@@ -29,6 +29,7 @@
value="" required>
<option value="1">Admin</option>
<option value="2">Jawatankuasa Audit</option>
<option value="3">Jawatankuasa Kehadiran</option>
</select>
</div>
@@ -1,36 +1,46 @@
<template>
<div class="panel panel-default">
<div class="panel-body table-responsive">
<div class="panel-body">
<div class="form-group">
<router-link :to="{name: 'Add Account'}" class="btn btn-success">
<i class="fa fa-plus"></i> Tambah Akaun Pengguna</router-link>
<button class="btn btn-default" @click="refreshAdmin()">
<button class="btn btn-default" @click="refreshAdmin()" :disabled="!canViewAdminList">
<i class="fa fa-refresh"></i> Refresh</button>
</div>
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Nama</th>
<th>Email</th>
<th>Padam</th>
</tr>
</thead>
<tbody>
<tr v-for="admin in data.admins">
<td>{{ admin.id }}</td>
<td>{{ admin.name }}</td>
<td>{{ admin.email }}</td>
<td>
<button class="btn btn-danger" @click="deleteAdmin(admin.id)">
<i class="fa fa-trash"></i> Padam
</button>
</td>
</tr>
</tbody>
</table>
<div v-if="!canViewAdminList" class="alert alert-warning" style="margin-bottom:12px;">
Akses ditolak: Pengurusan akaun hanya untuk <b>Main Admin</b> (id=1). Jika sedang impersonate, sila
<b>Kembali Akaun Asal</b>.
</div>
<admin-data-table
:headers="adminTableHeaders"
:items="adminItems"
:loading="loading"
:show-pagination="true"
:exportable="true"
export-file-name="admins"
:items-per-page="25"
:show-index="true"
index-title="Bil."
empty-text="Tiada akaun admin"
>
<template v-slot:item-impersonate="{ item }">
<button
class="btn btn-primary btn-sm"
@click="openImpersonate(item)"
:disabled="!canImpersonate(item)"
>
<i class="fa fa-user-secret"></i> Impersonate
</button>
</template>
<template v-slot:item-delete="{ item }">
<button class="btn btn-danger btn-sm" @click="deleteAdmin(item.id)">
<i class="fa fa-trash"></i> Padam
</button>
</template>
</admin-data-table>
</div>
<modal id="delete-admin-modal">
<modal-header>Delete Admin</modal-header>
@@ -42,21 +52,127 @@
<button @click="util.hideModal('#delete-admin-modal')" class="btn btn-default">Batal</button>
</modal-footer>
</modal>
<modal id="impersonate-admin-modal">
<modal-header>Impersonate Admin</modal-header>
<modal-body>
<h4 v-if="impersonateTarget && impersonateTarget.email">
Impersonate <b>{{ impersonateTarget.email }}</b> ?
</h4>
<p class="text-muted" style="margin-bottom:0;">
Anda akan login sebagai akaun tersebut.
</p>
</modal-body>
<modal-footer>
<button @click="startImpersonate()" class="btn btn-primary">Impersonate</button>
<button @click="util.hideModal('#impersonate-admin-modal')" class="btn btn-default">Batal</button>
</modal-footer>
</modal>
</div>
</template>
<script>
import AdminDataTable from '../../../AdminDataTable.vue';
export default{
components: { AdminDataTable },
data: () => ({
id:0
id:0,
impersonateTarget: null,
loading: false,
adminTableHeaders: [
{ title: 'ID', key: 'id', sortable: true },
{ title: 'Nama', key: 'name', sortable: true },
{ title: 'Email', key: 'email', sortable: true },
{ title: 'Impersonate', key: 'impersonate', sortable: false, exportValue: function () { return ''; } },
{ title: 'Padam', key: 'delete', sortable: false, exportValue: function () { return ''; } },
]
}),
computed: {
canViewAdminList: function () {
try {
if (!this.data || !this.data.user) return false;
if (Number(this.data.user.id) !== 1) return false;
return localStorage.getItem('is_impersonating') !== '1';
} catch (e) {
return false;
}
},
adminItems: function () {
var a = this.data && this.data.admins;
if (Array.isArray(a)) return a;
if (a && Array.isArray(a.data)) return a.data;
return [];
}
},
created: function () {
this.refreshAdmin();
if (this.canViewAdminList) {
this.refreshAdmin();
} else {
this.loading = false;
}
},
methods: {
canImpersonate: function (admin) {
// Backend currently limits to main admin (id=1) and blocks impersonating id=1.
try {
if (!this.data || !this.data.user) return false;
if (Number(this.data.user.id) !== 1) return false;
if (!admin || admin.id === undefined || admin.id === null) return false;
return Number(admin.id) !== 1;
} catch (e) {
return false;
}
},
openImpersonate: function (admin) {
this.impersonateTarget = admin;
if (this.util && this.util.showModal) {
this.util.showModal('#impersonate-admin-modal');
}
},
startImpersonate: function () {
var vm = this;
if (!vm.impersonateTarget || !vm.impersonateTarget.id) return;
vm.util.hideModal('#impersonate-admin-modal');
vm.util.notify('Impersonating admin', 'loading');
axios.post(config.API + 'admin/impersonate', { user_id: vm.impersonateTarget.id })
.then(function (response) {
$.notifyClose();
if (!response || !response.data || response.data.status !== 'success') {
vm.util.showResult(response, 'error');
return;
}
// Swap admin token + user in local state.
var token = response.data.token;
if (token) {
localStorage['Access Token'] = 'Bearer ' + token;
localStorage.setItem('is_impersonating', '1');
if (vm.util && vm.util.setAuthorization) vm.util.setAuthorization();
}
if (response.data.user) {
vm.data.user = response.data.user;
if (response.data.user.role !== undefined && response.data.user.role !== null) {
localStorage.setItem('admin_role', String(response.data.user.role));
}
}
vm.util.notify('Impersonate berjaya', 'success');
// Ensure admin shell is refreshed (nav header shows new user)
if (vm.$router) vm.$router.go(0);
})
.catch(function (error) {
$.notifyClose();
vm.util.showResult(error, 'error');
});
},
deleteAdmin: function () {
this.util.hideModal('#delete-admin-modal');
this.util.notify('Deleting admin', 'loading');
@@ -74,8 +190,12 @@ export default{
},
refreshAdmin: function () {
this.util.notify('Refreshing admin', 'loading');
var vm = this;
if (!vm.canViewAdminList) {
return;
}
vm.loading = true;
this.util.notify('Refreshing admin', 'loading');
axios.get(config.API+'admin')
.then(response=>{
$.notifyClose();
@@ -88,6 +208,9 @@ export default{
$.notifyClose();
vm.util.showResult(error, 'error');
})
.finally(function () {
vm.loading = false;
});
}
}
}
@@ -1,89 +1,24 @@
<template>
<div>
<!-- TABLE -->
<table class="table table-bordered table-striped">
<thead class="table-light">
<tr>
<th>No Anggota</th>
<th>Unit</th>
<th>Nama</th>
<th>Status</th>
<th>Tarikh Sah</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
<tr v-for="voter in penyata" :key="voter.id">
<td>{{ voter.no_anggota }}</td>
<td>{{ voter.unit }}</td>
<td>{{ voter.name }}</td>
<!-- STATUS -->
<!-- <td>
<span
class="badge"
:class="{
'bg-success': voter.status_penyata === 'DISAHKAN',
'bg-warning text-dark': voter.status_penyata === 'PERLU_SEMAK',
'bg-secondary': !voter.status_penyata
}"
>
{{ voter.status_penyata ?? 'BELUM SAH' }}
</span>
</td> -->
<!-- TARIKH SAH -->
<!-- <td>
{{ voter.tarikh_sah ?? '-' }}
</td> -->
<!-- PDF BUTTON -->
<td>
<button
class="btn btn-sm btn-success"
@click="cetakPDF(voter.no_anggota)"
:disabled="!bolehCetak(voter.status_penyata)"
>
PDF
</button>
</td>
</tr>
<!-- NO DATA -->
<tr v-if="!loading && penyata.length === 0">
<td colspan="6" class="text-center text-muted">
Tiada data penyata
</td>
</tr>
<!-- LOADING -->
<tr v-if="loading">
<td colspan="6" class="text-center">
Memuatkan data...
</td>
</tr>
</tbody>
</table>
<admin-data-table :headers="penyataTableHeaders" :items="penyata" :loading="loading" :show-pagination="true"
index-title="Bil." empty-text="Tiada data penyata" :exportable="true">
<template v-slot:item-aksi="{ item }">
<button type="button" class="btn btn-success btn-sm" @click="cetakPDF(item.no_anggota)"
:disabled="!bolehCetak(item.status_penyata)">
PDF
</button>
</template>
</admin-data-table>
<!-- PAGINATION -->
<div
class="d-flex justify-content-end gap-2"
v-if="meta.last_page > 1"
>
<button
class="btn btn-sm btn-outline-secondary"
:disabled="meta.current_page === 1"
@click="changePage(meta.current_page - 1)"
>
<div class="text-right" v-if="meta.last_page > 1">
<button class="btn btn-default btn-sm" :disabled="meta.current_page === 1"
@click="changePage(meta.current_page - 1)">
Sebelumnya
</button>
<button
class="btn btn-sm btn-outline-secondary"
:disabled="meta.current_page === meta.last_page"
@click="changePage(meta.current_page + 1)"
>
<button class="btn btn-default btn-sm" :disabled="meta.current_page === meta.last_page"
@click="changePage(meta.current_page + 1)">
Seterusnya
</button>
</div>
@@ -102,6 +37,12 @@ export default {
penyata: [],
loading: false,
searchNoAnggota: '',
penyataTableHeaders: [
{ title: 'No Anggota', key: 'no_anggota', sortable: true },
{ title: 'Unit', key: 'unit', sortable: true },
{ title: 'Nama', key: 'name', sortable: true },
{ title: 'Tindakan', key: 'aksi', sortable: false }
],
meta: {
current_page: 1,
last_page: 1
@@ -2,41 +2,22 @@
<div class="container">
<div class="row">
<div class="col-md-12">
<div v-for="position in positions">
<div v-for="position in positions" :key="position.id">
<h5>{{ position.name }}</h5>
<div class="table-responsive">
<table class="table table-striped table-condensed">
<thead>
<tr>
<th width="20%">No. Anggota</th>
<th width="30%">Nama</th>
<th width="20%">Unit</th>
<th width="10%">Undian</th>
<th width="20%">Peratus (%)</th>
</tr>
</thead>
<tbody>
<tr v-for="result in results" v-if="result.position_id == position.id">
<td>{{ getNominee(result.nominee_id)['no_anggota'] }}</td>
<td>{{ getNominee(result.nominee_id)['name'] }}</td>
<td>{{ getNominee(result.nominee_id)['unit'] }}</td>
<td>{{ result.votes }}</td>
<td>{{ calculatePercentage(result.votes, position.id) }}</td>
</tr>
<tr v-for="no_vote in no_votes" v-if="no_vote.position_id == position.id">
<td>{{ no_vote.no_anggota }}</td>
<td>{{ no_vote.name }}</td>
<td>{{ no_vote.unit }}</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td colspan="3"><b>Jumlah Undian</b></td>
<td><b>{{ calculateTotalVotes(position.id) }}</b></td>
</tr>
</tbody>
</table>
</div>
<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>
</div>
@@ -51,11 +32,69 @@ export default {
nominees: [],
results: [],
partylists: [],
positions: []
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)
@@ -77,7 +116,7 @@ export default {
.filter(result => result.position_id === positionId)
.reduce((total, result) => total + result.votes, 0);
return ((votes / totalVotes) * 100).toFixed(2);
return totalVotes === 0 ? '0.00' : ((votes / totalVotes) * 100).toFixed(2);
},
calculateTotalVotes(positionId) {
@@ -90,6 +129,7 @@ export default {
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();
@@ -102,6 +142,9 @@ export default {
$.notifyClose();
vm.util.showResult(error, 'error');
})
.finally(function () {
vm.finalLoading = false;
})
},
computed: {
@@ -17,37 +17,17 @@
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Nama Undian</th>
<th>Undian Mula</th>
<th>Undian Tamat</th>
<th>Papar</th>
</tr>
</thead>
<tbody>
<tr v-for="election in data.elections">
<td>{{ election.id }}</td>
<td>{{ election.name }}</td>
<td>{{ election.start }}</td>
<td>{{ election.end }}</td>
<td>
<router-link :to="{ name: 'Election Result', params: { election_id: election.id } }"
class="btn btn-info">Keputusan Undian</router-link>
<a class="btn btn-warning" :href="getPDFurl(election.id)">Muat Turun PDF</a>
</td>
</tr>
<tr v-if="data.elections.length < 1">
<td colspan="5">No elections yet</td>
</tr>
</tbody>
</table>
</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">
<div class="modal fade" id="start-election-modal" tabindex="-1" role="dialog"
@@ -119,15 +99,31 @@
</template>
<style>
.center-header {
text-align: center;
}
.center-header {
text-align: center;
}
/* Ensure Bootstrap modals appear above the admin floating header (z-index: 2000). */
.modal {
z-index: 2200;
}
.modal-backdrop {
z-index: 2190 !important;
}
</style>
<script>
export default {
data: () => ({
start_date: null
start_date: null,
electionLoading: false,
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 }
]
}),
created: function () {
this.refreshElection();
@@ -184,6 +180,7 @@ export default {
refreshElection: function () {
this.util.notify('Refreshing Election', 'loading');
var vm = this;
this.electionLoading = true;
axios.get(config.API + 'election')
.then(response => {
$.notifyClose();
@@ -193,6 +190,9 @@ export default {
$.notifyClose();
vm.util.showResult(error, 'error');
})
.finally(function () {
vm.electionLoading = false;
})
},
@@ -1,90 +1,242 @@
<template>
<div class="row">
<div class="col-md-4">
<h4>Jawatan</h4><hr/>
<ul class="list-group">
<router-link :key="position.id" v-for="position in data.positions" class="list-group-item" :class="{'active':position.id==position_id}" tag="li" :to="{query:{position_id:position.id}}" exact replace>
<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 }}
</router-link>
<li class="list-group-item">
<center>
<button class="btn btn-info" @click="refreshNominees()">
Kemaskini Keputusan<i class="fa fa-refresh"></i>
</button>
</center>
</li>
</ul>
</div>
<div class="col-md-8">
<h4>Keputusan</h4><hr/>
<div class="panel panel-default">
<div class="panel-heading">{{ getPosition(position_id) }} - Results as of : {{ last_update }}</div>
<div class="panel-body">
<div id="chart" style="height: 300px; width: 300px"></div>
</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>
</div>
</template>
<script>
export default{
export default {
created: function () {
console.log(this)
created: function () {
this.refreshNominees();
this.$nextTick(function(){
this.initChart();
this.$nextTick(function () {
this.initCharts();
});
},
watch: {
position_id : function () {
this.initChart();
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');
axios.get(config.API+'nominee')
.then(response=>{
// 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=>{
.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=>{
axios.get(config.API + 'election/results')
.then(response => {
$.notifyClose();
vm.data.results = response.data;
vm.data.last_update= new Date();
vm.initChart();
vm.data.last_update = new Date();
vm.$nextTick(function () {
vm.initCharts();
});
})
.catch(error=>{
.catch(error => {
$.notifyClose();
vm.util.showResult(error);
})
},
initChart: function () {
$.plot($('#chart'), this.datas, {
series: {
pie: {
show: true,
innerRadius: 0.5
}
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) {
@@ -113,13 +265,12 @@ export default{
computed: {
last_update: function () {
let x = this.data.last_update;
return x.toDateString() +' '+x.toLocaleTimeString();
if (!x) return '-';
return x.toDateString() + ' ' + x.toLocaleTimeString();
},
position_id: function () {
return this.$route.query.position_id ?
this.$route.query.position_id :
this.data.positions[0]['id'];
return this.$route.query.position_id ? this.$route.query.position_id : 0;
},
datas: function () {
@@ -127,7 +278,7 @@ export default{
var data = [];
var nominees = this.data.nominees;
for (var i in nominees) {
if (nominees[i]['position_id']== this.position_id){
if (nominees[i]['position_id'] == this.position_id) {
let row = [];
row['label'] = nominees[i]['name'];
row['data'] = [[1, this.getVotes(nominees[i]['id'])]];
@@ -135,7 +286,427 @@ export default{
}
}
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>
</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>
@@ -9,7 +9,7 @@
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" :href="data.baseURL"><img
<a class="navbar-brand" href="/admin"><img
src='https://i.postimg.cc/ZBDD0ZfQ/Whats-App-Image-2024-03-21-at-1-24-46-PM-2.jpg'
border='0' width="150" height="auto" alt="MyKoPKB Logo">
</a>
@@ -18,7 +18,8 @@
<ul class="nav navbar-nav">
<!-- {{ users }} -->
<router-link :to="{ name: 'Admin Home' }" tag="li" exact><a href="#"><b>Laman
<router-link v-if="Number(data.user.role) !== 3" :to="{ name: 'Admin Home' }" tag="li"
exact><a href="#"><b>Laman
Utama</b></a></router-link>
<router-link :to="{ name: 'Kemaskini Jawatan' }" v-if="data.user.role == 1" tag="li">
<a href="#"><b>Jawatan</b></a>
@@ -28,7 +29,7 @@
<a href="#">Manage Partylist</a>
</router-link> -->
<router-link :to="{ name: 'Manage Voter' }" v-if="data.user.role == 1" tag="li">
<router-link :to="{ name: 'Manage Voter' }" v-if="data.user.role == 1" tag="li" exact>
<a href="#"><b>Anggota Koperasi</b></a>
</router-link>
@@ -36,7 +37,8 @@
<a href="#"><b>Calon</b></a>
</router-link>
<router-link :to="{ name: 'Kehadiran Calon' }" v-if="data.user.role == 1" tag="li">
<router-link :to="{ name: 'Kehadiran Calon' }"
v-if="data.user.role == 1 || Number(data.user.role) === 3" tag="li">
<a href="#"><b>Kehadiran</b></a>
</router-link>
@@ -48,6 +50,10 @@
<a href="#"><b>Penyata Anggota</b></a>
</router-link>
<router-link :to="{ name: 'Activity Log' }" v-if="data.user.role == 1" tag="li">
<a href="#"><b>Log Aktiviti</b></a>
</router-link>
</ul>
<ul class="nav navbar-right navbar-nav">
@@ -58,15 +64,24 @@
</a>
<ul class="dropdown-menu">
<router-link :to="{ name: 'Update Account' }" tag="li" exact>
<router-link v-if="Number(data.user.role) !== 3" :to="{ name: 'Update Account' }"
tag="li" exact>
<a href="#">Kemaskini Akaun</a>
</router-link>
<li v-if="isImpersonating()" @click="leaveImpersonation()">
<a>Kembali Akaun Asal</a>
</li>
<router-link :to="{ name: 'Manage Account' }" tag="li" v-if="data.user.id == 1">
<a href="#">Pengurusan Akaun</a>
</router-link>
<li @click="logout()"><a>Log Keluar</a></li>
<li @click="isImpersonating() ? null : logout()"
:class="{ disabled: isImpersonating() }"
:style="isImpersonating() ? 'opacity:0.5; cursor:not-allowed; pointer-events:none;' : ''">
<a>Log Keluar</a>
</li>
</ul>
</li>
</ul>
@@ -106,6 +121,12 @@ export default {
vm.data.election = response.data.election;
vm.data.partylists = response.data.partylist;
vm.data.positions = response.data.position;
if (response.data.user && response.data.user.role !== undefined && response.data.user.role !== null) {
localStorage.setItem('admin_role', String(response.data.user.role));
}
if (Number(response.data.user.role) === 3 && vm.$route.name !== 'Kehadiran Calon') {
vm.$router.replace({ name: 'Kehadiran Calon' });
}
vm.loading = false;
})
.catch(error => {
@@ -120,7 +141,47 @@ export default {
},
methods: {
isImpersonating: function () {
try {
return localStorage.getItem('is_impersonating') === '1';
} catch (e) {
return false;
}
},
leaveImpersonation: function () {
var vm = this;
vm.util.notify('Leaving impersonation', 'loading');
axios.post(config.API + 'admin/impersonate/leave')
.then(function (response) {
$.notifyClose();
if (!response || !response.data || response.data.status !== 'success') {
vm.util.showResult(response, 'error');
return;
}
if (response.data.token) {
localStorage['Access Token'] = 'Bearer ' + response.data.token;
if (vm.util && vm.util.setAuthorization) vm.util.setAuthorization();
}
localStorage.removeItem('is_impersonating');
if (response.data.user) {
vm.data.user = response.data.user;
if (response.data.user.role !== undefined && response.data.user.role !== null) {
localStorage.setItem('admin_role', String(response.data.user.role));
}
}
vm.util.notify('Berjaya kembali', 'success');
if (vm.$router) vm.$router.go(0);
})
.catch(function (error) {
$.notifyClose();
vm.util.showResult(error, 'error');
});
},
logout: function () {
localStorage.removeItem('admin_role');
localStorage.removeItem('is_impersonating');
localStorage.clear();
this.$router.push({ name: 'Admin Login' });
}
@@ -62,8 +62,13 @@ export default {
},
created: function () {
if (this.util.isLogin())
return this.$router.push({ name: 'Admin Home' })
if (this.util.isLogin()) {
var ar = localStorage.getItem('admin_role');
if (ar === '3') {
return this.$router.push({ name: 'Kehadiran Calon' });
}
return this.$router.push({ name: 'Admin Home' });
}
this.util.setTitle('Log Masuk Admin ');
},
@@ -80,8 +85,15 @@ export default {
vm.stopLoading();
if (this.util.showResult(response, 'success')) {
localStorage['Access Token'] = `Bearer ${response.data.token}`;
if (response.data.user && response.data.user.role !== undefined && response.data.user.role !== null) {
localStorage.setItem('admin_role', String(response.data.user.role));
}
this.util.setAuthorization();
vm.$router.push({ name: 'Admin Home' });
if (Number(response.data.user && response.data.user.role) === 3) {
vm.$router.push({ name: 'Kehadiran Calon' });
} else {
vm.$router.push({ name: 'Admin Home' });
}
}
})
.catch(error => {
@@ -1,78 +1,193 @@
<template>
<div class="panel panel-default">
<div class="panel panel-default nominee-add-page">
<div class="panel-body">
<form class="row" method="POST" id="add_form" :action="data.API + 'nominee'" enctype="mutlipart/formdata"
@submit.prevent="add()">
<div class="col-md-8">
<div class="nominee-add-page__header clearfix">
<h4 class="pull-left nominee-add-page__title">
<i class="fa fa-user-plus text-muted"></i> Tambah Calon
</h4>
<router-link
:to="{ name: 'Maklumat Calon', query: { position_id: position_id } }"
class="btn btn-default pull-right"
>
<i class="fa fa-arrow-left"></i> Kembali
</router-link>
</div>
<div class="form-group">
<label for="name">Nama Calon</label>
<input type="text" name="name" class="form-control" required />
<p class="text-muted small nominee-add-page__intro">
Lengkapkan maklumat calon. Medan bertanda <span class="text-danger">*</span> adalah wajib.
</p>
<form
class="nominee-add-form"
method="POST"
id="add_form"
:action="data.API + 'nominee'"
enctype="multipart/form-data"
@submit.prevent="add()"
>
<div class="row">
<div class="col-md-8">
<div class="row">
<div class="col-sm-6">
<div class="form-group">
<label for="nominee-name">Nama Calon <span class="text-danger">*</span></label>
<input
id="nominee-name"
type="text"
name="name"
class="form-control"
placeholder="Nama penuh"
required
/>
</div>
</div>
<div class="col-sm-6">
<div class="form-group">
<label for="nominee-no-anggota">No. Anggota <span class="text-danger">*</span></label>
<input
id="nominee-no-anggota"
type="text"
name="no_anggota"
class="form-control"
placeholder="No. keahlian"
required
/>
</div>
</div>
</div>
<div class="row">
<div class="col-sm-6">
<div class="form-group">
<label for="nominee-unit">Unit <span class="text-danger">*</span></label>
<input
id="nominee-unit"
type="text"
name="unit"
class="form-control"
required
/>
</div>
</div>
<div class="col-sm-6">
<div class="form-group">
<label for="nominee-umur">Umur <span class="text-danger">*</span></label>
<input
id="nominee-umur"
type="number"
name="umur"
class="form-control"
min="1"
max="120"
placeholder="Tahun"
required
/>
</div>
</div>
</div>
<div class="form-group">
<label for="nominee-jawatan-sekarang">Jawatan Sekarang <span class="text-danger">*</span></label>
<input
id="nominee-jawatan-sekarang"
type="text"
name="jawatan_sekarang"
class="form-control"
required
/>
</div>
<div class="form-group">
<label for="nominee-education">Taraf Pendidikan</label>
<input
id="nominee-education"
type="text"
name="education"
class="form-control"
placeholder="Pilihan"
/>
</div>
<div class="form-group">
<label for="nominee-experience">Pengalaman Kerja</label>
<textarea
id="nominee-experience"
name="experience"
class="form-control"
rows="4"
placeholder="Pilihan — senaraikan pengalaman relevan"
></textarea>
</div>
<div class="form-group">
<label for="nominee-position-id">Jawatan Dicalonkan <span class="text-danger">*</span></label>
<select
id="nominee-position-id"
class="form-control"
v-model="position_id"
name="position_id"
required
>
<option value="0" disabled> Pilih jawatan </option>
<option
v-for="position in data.positions"
:key="position.id"
:value="position.id"
>
{{ position.name }}
</option>
</select>
</div>
</div>
<div class="form-group">
<label for="no_anggota">No. Anggota</label>
<input type="text" name="no_anggota" class="form-control" required />
<div class="col-md-4">
<div class="nominee-add-photo panel panel-default">
<div class="panel-heading">
<strong><i class="fa fa-camera"></i> Gambar calon</strong>
<span class="text-muted small nominee-add-photo__hint">Pilihan</span>
</div>
<div class="panel-body">
<div
class="nominee-add-photo__preview"
:class="{ 'nominee-add-photo__preview--empty': !imageUrl }"
>
<img v-if="imageUrl" :src="imageUrl" alt="Pratonton gambar calon" />
<div v-else class="nominee-add-photo__placeholder">
<i class="fa fa-picture-o"></i>
<span>Tiada gambar dipilih</span>
</div>
</div>
<label class="btn btn-default btn-block nominee-add-photo__browse" for="nominee-file-input">
<i class="fa fa-folder-open"></i> Pilih fail
</label>
<input
id="nominee-file-input"
name="photo"
type="file"
class="nominee-add-photo__input"
accept="image/*"
@change="handleImageChange"
/>
<p class="text-muted small nominee-add-photo__formats">PNG, JPG atau GIF</p>
</div>
</div>
</div>
</div>
<div class="form-group">
<label for="unit">Unit</label>
<input type="text" name="unit" class="form-control" required />
</div>
<hr class="nominee-add-form__rule" />
<div class="form-group">
<label for="unit">Umur</label>
<input type="text" name="umur" class="form-control" required />
</div>
<div class="form-group">
<label for="unit">Jawatan Sekarang</label>
<input type="text" name="jawatan_sekarang" class="form-control" required />
</div>
<div class="form-group">
<label for="education">Taraf Pendidikan</label>
<input type="text" name="education" class="form-control" placeholder="(Optional)" />
</div>
<div class="form-group">
<label for="pengalaman">Pengalaman Kerja</label>
<textarea name="pengalaman" class="form-control" placeholder="(Optional)"></textarea>
</div>
<div class="form-group">
<label for="position_id">Jawatan</label>
<select class="form-control" v-model="position_id" name="position_id" required>
<option value="0" disabled>--- Pilih Jawatan ---</option>
<option v-for="position in data.positions" :key="position.id" :value="position.id">{{ position.name }}</option>
</select>
</div>
<!-- <div class="form-group">
<label for="partylist_id">Partylist</label>
<select class="form-control" name="partylist_id">
<option value="">--- Select Partylist (Optional) ---</option>
<option v-for="partylist in data.partylists" :value="partylist.id">{{ partylist.name }}</option>
</select>
</div> -->
<div id="imagePreview">
<img :src="imageUrl" v-if="imageUrl" alt="Preview">
</div>
<div>
<label for="image">Muatnaik Gambar</label>
<input name="photo" type="file" accept="image/*" id="file-input" @change="handleImageChange">
</div>
<div class="form-group pull-right">
<router-link :to="{ name: 'Maklumat Calon', query: { position_id: position_id } }"
class="btn btn-default">
Cancel
</router-link>
<input type="submit" value="Submit" class="btn btn-info">
</div>
<div class="clearfix nominee-add-form__actions">
<router-link
:to="{ name: 'Maklumat Calon', query: { position_id: position_id } }"
class="btn btn-default"
>
<i class="fa fa-times"></i> Batal
</router-link>
<button type="submit" class="btn btn-primary" :disabled="loading">
<i v-if="loading" class="fa fa-spinner fa-spin"></i>
<i v-else class="fa fa-check"></i>
{{ loading ? 'Menghantar…' : 'Simpan calon' }}
</button>
</div>
</form>
</div>
@@ -83,7 +198,7 @@
export default {
data: function () {
return {
imageUrl: '',
imageUrl: '',
loading: false
}
},
@@ -102,8 +217,8 @@ export default {
vm.$router.push({ name: 'Maklumat Calon' });
},
error: function (error) {
alert('Nominee with the same name has already registered');
location.reload();
alert('Nominee with the same name has already registered');
location.reload();
$.notifyClose();
vm.loading = false;
vm.util.showResult(error, 'error', 'ajax');
@@ -114,27 +229,23 @@ export default {
})
},
handleImageChange(event) {
const file = event.target.files[0]; // Get the selected file
const imageType = /image.*/; // RegExp to check if the file is an image
handleImageChange(event) {
const file = event.target.files[0];
const imageType = /image.*/;
// Check if the selected file is an image
if (file && file.type.match(imageType)) {
const reader = new FileReader(); // Create a FileReader object
if (file && file.type.match(imageType)) {
const reader = new FileReader();
reader.onload = (e) => {
this.imageUrl = e.target.result; // Set the imageUrl to the data URL of the image
document.getElementById('imagePreview').style.display = 'block'; // Display the div
};
reader.onload = (e) => {
this.imageUrl = e.target.result;
};
reader.readAsDataURL(file); // Read the image data as a data URL
} else {
// Clear the file input and hide the div if the selected file is not an image
event.target.value = '';
this.imageUrl = '';
document.getElementById('imagePreview').style.display = 'none';
}
}
reader.readAsDataURL(file);
} else {
event.target.value = '';
this.imageUrl = '';
}
}
},
computed: {
@@ -152,16 +263,115 @@ export default {
</script>
<style scoped>
#imagePreview {
width: 200px;
height: 200px;
border: 1px solid #ccc;
margin-bottom: 10px;
display: none; /* Initially hide the div */
}
.nominee-add-page__header {
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
#imagePreview img {
max-width: 100%;
max-height: 100%;
}
.nominee-add-page__title {
margin-top: 0;
margin-bottom: 0;
font-weight: 600;
}
.nominee-add-page__title .fa {
margin-right: 6px;
}
.nominee-add-page__intro {
margin-bottom: 18px;
}
.nominee-add-photo .panel-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.nominee-add-photo__hint {
font-weight: normal;
}
.nominee-add-photo__preview {
width: 100%;
aspect-ratio: 1;
max-height: 240px;
border-radius: 4px;
overflow: hidden;
background: #f9f9f9;
border: 1px dashed #ccc;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.nominee-add-photo__preview--empty {
min-height: 180px;
}
.nominee-add-photo__preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.nominee-add-photo__placeholder {
text-align: center;
color: #999;
padding: 16px;
}
.nominee-add-photo__placeholder .fa {
font-size: 36px;
display: block;
margin-bottom: 8px;
opacity: 0.65;
}
.nominee-add-photo__placeholder span {
display: block;
font-size: 12px;
}
.nominee-add-photo__input {
position: absolute;
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
z-index: -1;
}
.nominee-add-photo__browse {
margin-bottom: 0;
}
.nominee-add-photo__formats {
margin: 8px 0 0;
text-align: center;
}
.nominee-add-form__rule {
margin-top: 8px;
margin-bottom: 16px;
border-top-color: #eee;
}
.nominee-add-form__actions {
text-align: right;
}
.nominee-add-form__actions .btn + .btn {
margin-left: 8px;
}
@media (max-width: 991px) {
.nominee-add-photo {
margin-top: 8px;
}
}
</style>
@@ -1,155 +1,323 @@
<template>
<div class="panel panel-default">
<div class="panel-body">
<form
method="POST"
class="row"
id="edit_form"
:action="data.API+'nominee/'+id"
enctype="multipart/form-data"
@submit.prevent="edit()">
<input type="hidden" name="_method" value="PUT"/>
<div class="col-md-8">
<div class="form-group">
<label for="name">Nama</label>
<input type="text" name="name" class="form-control" :value="nominee.name" required/>
</div>
<div class="form-group">
<label for="student_id">No. Anggota</label>
<input type="text" name="no_anggota" class="form-control" :value="nominee.no_anggota" required/>
</div>
<div class="form-group">
<label for="Unit">Unit</label>
<input type="text" name="unit" class="form-control" :value="nominee.unit" required/>
</div>
<div class="form-group">
<label for="umur">Umur</label>
<input type="text" name="umur" class="form-control" :value="nominee.umur" required/>
</div>
<div class="form-group">
<label for="jawatan_sekarang">Jawatan Sekarang</label>
<input type="text" name="jawatan_sekarang" class="form-control" :value="nominee.jawatan_sekarang" required/>
</div>
<div class="form-group">
<label for="education">Taraf Pendidikan</label>
<input type="text" name="education" class="form-control" :value="nominee.education" placeholder="(Optional)" />
</div>
<div class="form-group">
<label for="experience">Pengalaman Kerja </label>
<textarea name="experience" class="form-control" placeholder="(Optional)">{{ nominee.experience}}</textarea>
</div>
<div class="form-group">
<label for="position_id">Jawatan</label>
<select class="form-control" name="position_id" :value="nominee.position_id" required>
<option value="0" disabled>--- Pilih Jawatan ---</option>
<option v-for="position in data.positions" :value="position.id">{{ position.name }}</option>
</select>
</div>
<div class="form-group">
<label for="partylist_id">Partylist</label>
<select class="form-control" name="partylist_id" :value="nominee.partylist_id">
<option value="">--- Select Partylist (Optional) ---</option>
<option v-for="partylist in data.partylists" :key="partylist.id" :value="partylist.id">{{ partylist.name }}</option>
</select>
</div>
<div id="imagePreview">
<img :src="imageUrl" v-if="imageUrl" alt="Preview">
</div>
<div>
<label for="image">Muat Naik Gambar</label>
<input name="photo" type="file" accept="image/*" id="file-input" @change="handleImageChange">
</div>
<div class="form-group pull-right">
<input type="submit" value="Simpan" class="btn btn-info">
<router-link
:to="{name:'Maklumat Calon'}"
class="btn btn-default">
Kembali
</router-link>
</div>
<div class="panel panel-default nominee-add-page">
<div class="panel-body">
<div class="nominee-add-page__header clearfix">
<h4 class="pull-left nominee-add-page__title">
<i class="fa fa-pencil text-muted"></i> Kemaskini Calon
</h4>
<router-link :to="backToList" class="btn btn-default pull-right">
<i class="fa fa-arrow-left"></i> Kembali
</router-link>
</div>
</form>
<p class="text-muted small nominee-add-page__intro">
Kemas kini maklumat calon. Medan bertanda <span class="text-danger">*</span> adalah wajib.
</p>
<form
class="nominee-add-form"
method="POST"
id="edit_form"
:action="data.API + 'nominee/' + id"
enctype="multipart/form-data"
@submit.prevent="edit()"
>
<input type="hidden" name="_method" value="PUT" />
<div class="row">
<div class="col-md-8">
<div class="row">
<div class="col-sm-6">
<div class="form-group">
<label for="edit-nominee-name">Nama Calon <span class="text-danger">*</span></label>
<input
id="edit-nominee-name"
v-model="form.name"
type="text"
name="name"
class="form-control"
placeholder="Nama penuh"
required
/>
</div>
</div>
<div class="col-sm-6">
<div class="form-group">
<label for="edit-nominee-no-anggota">No. Anggota <span class="text-danger">*</span></label>
<input
id="edit-nominee-no-anggota"
v-model="form.no_anggota"
type="text"
name="no_anggota"
class="form-control"
placeholder="No. keahlian"
required
/>
</div>
</div>
</div>
<div class="row">
<div class="col-sm-6">
<div class="form-group">
<label for="edit-nominee-unit">Unit <span class="text-danger">*</span></label>
<input
id="edit-nominee-unit"
v-model="form.unit"
type="text"
name="unit"
class="form-control"
required
/>
</div>
</div>
<div class="col-sm-6">
<div class="form-group">
<label for="edit-nominee-umur">Umur <span class="text-danger">*</span></label>
<input
id="edit-nominee-umur"
v-model="form.umur"
type="number"
name="umur"
class="form-control"
min="1"
max="120"
placeholder="Tahun"
required
/>
</div>
</div>
</div>
<div class="form-group">
<label for="edit-nominee-jawatan-sekarang">Jawatan Sekarang <span class="text-danger">*</span></label>
<input
id="edit-nominee-jawatan-sekarang"
v-model="form.jawatan_sekarang"
type="text"
name="jawatan_sekarang"
class="form-control"
required
/>
</div>
<div class="form-group">
<label for="edit-nominee-education">Taraf Pendidikan</label>
<input
id="edit-nominee-education"
v-model="form.education"
type="text"
name="education"
class="form-control"
placeholder="Pilihan"
/>
</div>
<div class="form-group">
<label for="edit-nominee-experience">Pengalaman Kerja</label>
<textarea
id="edit-nominee-experience"
v-model="form.experience"
name="experience"
class="form-control"
rows="4"
placeholder="Pilihan — senaraikan pengalaman relevan"
></textarea>
</div>
<div class="form-group">
<label for="edit-nominee-position-id">Jawatan Dicalonkan <span class="text-danger">*</span></label>
<select
id="edit-nominee-position-id"
v-model="form.position_id"
class="form-control"
name="position_id"
required
>
<option value="" disabled> Pilih jawatan </option>
<option
v-for="position in data.positions"
:key="position.id"
:value="String(position.id)"
>
{{ position.name }}
</option>
</select>
</div>
<div class="form-group">
<label for="edit-nominee-partylist-id">Senarai parti</label>
<select
id="edit-nominee-partylist-id"
v-model="form.partylist_id"
class="form-control"
name="partylist_id"
>
<option value=""> Pilihan </option>
<option
v-for="partylist in data.partylists"
:key="partylist.id"
:value="String(partylist.id)"
>
{{ partylist.name }}
</option>
</select>
</div>
</div>
<div class="col-md-4">
<div class="nominee-add-photo panel panel-default">
<div class="panel-heading">
<strong><i class="fa fa-camera"></i> Gambar calon</strong>
<span class="text-muted small nominee-add-photo__hint">Pilihan</span>
</div>
<div class="panel-body">
<div
class="nominee-add-photo__preview"
:class="{ 'nominee-add-photo__preview--empty': !imageUrl }"
>
<img v-if="imageUrl" :src="imageUrl" alt="Pratonton gambar calon" />
<div v-else class="nominee-add-photo__placeholder">
<i class="fa fa-picture-o"></i>
<span>Tiada gambar</span>
</div>
</div>
<label class="btn btn-default btn-block nominee-add-photo__browse" for="edit-nominee-file-input">
<i class="fa fa-folder-open"></i> Pilih fail
</label>
<input
id="edit-nominee-file-input"
name="photo"
type="file"
class="nominee-add-photo__input"
accept="image/*"
@change="handleImageChange"
/>
<p class="text-muted small nominee-add-photo__formats">PNG, JPG atau GIF</p>
</div>
</div>
</div>
</div>
<hr class="nominee-add-form__rule" />
<div class="clearfix nominee-add-form__actions">
<router-link :to="backToList" class="btn btn-default">
<i class="fa fa-times"></i> Batal
</router-link>
<button type="submit" class="btn btn-primary" :disabled="loading">
<i v-if="loading" class="fa fa-spinner fa-spin"></i>
<i v-else class="fa fa-check"></i>
{{ loading ? 'Menghantar…' : 'Simpan perubahan' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script>
export default{
export default {
data: function () {
return {
imageUrl: '',
loading: false
imageUrl: '',
loading: false,
form: {
name: '',
no_anggota: '',
unit: '',
umur: '',
jawatan_sekarang: '',
education: '',
experience: '',
position_id: '',
partylist_id: ''
}
}
},
created: function () {
if (!this.nominee.id)
this.$router.push({name:'Maklumat Calon'});
if (!this.nominee.id) this.$router.push({ name: 'Maklumat Calon' })
},
mounted: function () {
if(this.nominee.id) {
this.imageUrl = this.createBase64ImageUrl(this.nominee.photo);
document.getElementById('imagePreview').style.display = 'block';
}
},
watch: {
nominee: {
immediate: true,
deep: true,
handler: function (n) {
if (!n || !n.id) return
this.form.name = n.name || ''
this.form.no_anggota = n.no_anggota || ''
this.form.unit = n.unit || ''
this.form.umur = n.umur != null && n.umur !== '' ? String(n.umur) : ''
this.form.jawatan_sekarang = n.jawatan_sekarang || ''
this.form.education = this.formatEducationForInput(n.education)
this.form.experience = this.formatExperienceForInput(n.experience)
this.form.position_id = n.position_id != null ? String(n.position_id) : ''
this.form.partylist_id =
n.partylist_id != null && n.partylist_id !== '' ? String(n.partylist_id) : ''
this.imageUrl = n.photo ? this.createBase64ImageUrl(n.photo) : ''
}
}
},
methods: {
createBase64ImageUrl: function(base64ImageData) {
return "data:image/png;base64," + base64ImageData;
},
handleImageChange(event) {
const file = event.target.files[0]; // Get the selected file
const imageType = /image.*/; // RegExp to check if the file is an image
createBase64ImageUrl: function (base64ImageData) {
return 'data:image/png;base64,' + base64ImageData
},
// Check if the selected file is an image
if (file && file.type.match(imageType)) {
const reader = new FileReader(); // Create a FileReader object
formatEducationForInput: function (val) {
if (val == null || val === '') return ''
if (Array.isArray(val)) return val.join('\n')
return String(val)
},
reader.onload = (e) => {
this.imageUrl = e.target.result; // Set the imageUrl to the data URL of the image
document.getElementById('imagePreview').style.display = 'block'; // Display the div
};
formatExperienceForInput: function (val) {
if (val == null || val === '') return ''
if (Array.isArray(val)) {
return val.map(function (x, i) {
return i + 1 + '. ' + x
}).join('\n')
}
return String(val)
},
handleImageChange: function (event) {
var file = event.target.files[0]
var imageType = /image.*/
var vm = this
if (file && file.type.match(imageType)) {
var reader = new FileReader()
reader.onload = function (e) {
vm.imageUrl = e.target.result
}
reader.readAsDataURL(file)
} else {
event.target.value = ''
vm.imageUrl = vm.nominee.photo ? vm.createBase64ImageUrl(vm.nominee.photo) : ''
}
},
reader.readAsDataURL(file); // Read the image data as a data URL
} else {
// Clear the file input and hide the div if the selected file is not an image
event.target.value = '';
this.imageUrl = '';
document.getElementById('imagePreview').style.display = 'none';
}
},
edit: function () {
if (this.loading) return;
var vm = this;
this.loading = true;
this.util.notify('Kemaskini undian', 'progress', 0);
if (this.loading) return
var vm = this
this.loading = true
this.util.notify('Kemaskini calon', 'progress', 0)
$('#edit_form').ajaxSubmit({
success: function (response) {
$.notifyClose();
vm.loading = false;
$.notifyClose()
vm.loading = false
if (vm.util.showResult(response, 'success', 'ajax'))
vm.$router.push({name: 'Maklumat Calon',query:{refresh:true}});
vm.$router.push({ name: 'Maklumat Calon', query: { refresh: true } })
},
error: function (error) {
$.notifyClose();
vm.loading = false;
vm.util.showResult(error, 'error', 'ajax');
$.notifyClose()
vm.loading = false
vm.util.showResult(error, 'error', 'ajax')
},
uploadProgress: function (a, b, c, progress) {
this.util.notify('Kemaskini undian', 'progress', progress);
vm.util.notify('Kemaskini calon', 'progress', progress)
}
})
}
@@ -157,30 +325,136 @@ export default{
computed: {
id: function () {
return this.$route.params.id;
return this.$route.params.id
},
nominee: function () {
for (var i in this.data.nominees)
if(this.data.nominees[i].id == this.id)
return this.data.nominees[i];
if (this.data.nominees[i].id == this.id) return this.data.nominees[i]
return {}
},
backToList: function () {
var q = {}
if (this.nominee && this.nominee.position_id != null && String(this.nominee.position_id) !== '0') {
q.position_id = this.nominee.position_id
}
return { name: 'Maklumat Calon', query: q }
}
}
}
</script>
<style scoped>
#imagePreview {
width: 200px;
height: 200px;
border: 1px solid #ccc;
margin-bottom: 10px;
display: none; /* Initially hide the div */
}
.nominee-add-page__header {
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
#imagePreview img {
max-width: 100%;
max-height: 100%;
}
.nominee-add-page__title {
margin-top: 0;
margin-bottom: 0;
font-weight: 600;
}
.nominee-add-page__title .fa {
margin-right: 6px;
}
.nominee-add-page__intro {
margin-bottom: 18px;
}
.nominee-add-photo .panel-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.nominee-add-photo__hint {
font-weight: normal;
}
.nominee-add-photo__preview {
width: 100%;
aspect-ratio: 1;
max-height: 240px;
border-radius: 4px;
overflow: hidden;
background: #f9f9f9;
border: 1px dashed #ccc;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.nominee-add-photo__preview--empty {
min-height: 180px;
}
.nominee-add-photo__preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.nominee-add-photo__placeholder {
text-align: center;
color: #999;
padding: 16px;
}
.nominee-add-photo__placeholder .fa {
font-size: 36px;
display: block;
margin-bottom: 8px;
opacity: 0.65;
}
.nominee-add-photo__placeholder span {
display: block;
font-size: 12px;
}
.nominee-add-photo__input {
position: absolute;
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
z-index: -1;
}
.nominee-add-photo__browse {
margin-bottom: 0;
}
.nominee-add-photo__formats {
margin: 8px 0 0;
text-align: center;
}
.nominee-add-form__rule {
margin-top: 8px;
margin-bottom: 16px;
border-top-color: #eee;
}
.nominee-add-form__actions {
text-align: right;
}
.nominee-add-form__actions .btn + .btn {
margin-left: 8px;
}
@media (max-width: 991px) {
.nominee-add-photo {
margin-top: 8px;
}
}
</style>
@@ -1,77 +1,71 @@
<template>
<div class="row">
<div class="col-md-3" style="max-width: 250px;">
<ul class="list-group">
<router-link tag="li" class="list-group-item" :to="{ name: 'Maklumat Calon' }" exact replace
:class="{ 'active': position_id == 0 }">
Senarai Jawatan
</router-link>
<router-link v-for="position in data.positions" :key="position.id" tag="li" class="list-group-item"
:to="{ query: { position_id: position.id } }" exact replace>
{{ position.name }}
</router-link>
</ul>
</div>
<div class="col-md-9 panel panel-default">
<div class="col-md-12 panel panel-default">
<div class="panel-body table-responsive">
<div class="nominee-filter-chips">
<button
type="button"
class="nominee-chip btn btn-xs"
:class="position_id == 0 ? 'btn-primary' : 'btn-default'"
@click="setPosition(0)"
>
Semua Jawatan
</button>
<button
v-for="position in data.positions"
:key="position.id"
type="button"
class="nominee-chip btn btn-xs"
:class="String(position_id) === String(position.id) ? 'btn-primary' : 'btn-default'"
@click="setPosition(position.id)"
>
{{ position.name }}
</button>
</div>
<div class="form-group">
<router-link :to="{ name: 'Tambah Calon', query: { position_id: position_id } }"
class="btn btn-success"><i class="fa fa-plus"></i> Tambah Calon</router-link>
</div>
<table class="table table-hover">
<thead>
<tr>
<th></th>
<th>Nama</th>
<th>No. Anggota</th>
<th>Unit</th>
<th>Jawatan</th>
<!-- <th>Partylist</th> -->
<th>Taraf Pendidikan </th>
<th>Pengalaman Kerja</th>
<th>Tindakan</th>
<admin-data-table
:headers="nomineeTableHeaders"
:items="nominees"
:loading="nomineeLoading"
:items-per-page="10"
:show-pagination="true"
empty-text="Tiada calon"
:exportable="true"
export-file-name="calon"
>
<template v-slot:item-photo="{ item }">
<img
:alt="item.name"
:src="createBase64ImageUrl(item.photo)"
class="thumbnail"
style="height: 60px; width: 60px;"
>
</template>
</tr>
</thead>
<tbody>
<tr v-for="nominee in nominees" :key="nominee.id">
<td>
<img :alt="nominee.name" :src="createBase64ImageUrl(nominee.photo)" class="thumbnail"
style="height: 60px;width: 60px;">
</td>
<!-- <td>{{ nominee.id }}</td> -->
<td>{{ nominee.name }}</td>
<td>{{ nominee.no_anggota }}</td>
<td>{{ nominee.unit }}</td>
<td>{{ nominee.position }}</td>
<td>
<ol>
<li v-for="(item, index) in nominee.education" :key="index">{{ item }}</li>
</ol>
</td>
<td>
<ol>
<li v-for="(item, index) in nominee.experience" :key="index">{{ item }}</li>
</ol>
</td>
<template v-slot:item-education="{ item }">
<ol style="margin: 0; padding-left: 18px;">
<li v-for="(x, idx) in (item.education || [])" :key="idx">{{ x }}</li>
</ol>
</template>
<template v-slot:item-experience="{ item }">
<ol style="margin: 0; padding-left: 18px;">
<li v-for="(x, idx) in (item.experience || [])" :key="idx">{{ x }}</li>
</ol>
</template>
<router-link :to="{ name: 'Edit Nominee', params: { id: nominee.id } }"
class="btn btn-primary">
<i class="fa fa-edit"></i>Kemaskini
</router-link>
<button class="btn btn-danger" @click="openDeleteModal(nominee)">
<i class="fa fa-trash"></i>Padam
</button>
</tr>
<tr v-if="nominees.length < 1">
<td colspan="7">No Calon</td>
</tr>
</tbody>
</table>
<template v-slot:item-actions="{ item }">
<router-link :to="{ name: 'Edit Nominee', params: { id: item.id } }" class="btn btn-primary">
<i class="fa fa-edit"></i>Kemaskini
</router-link>
<button type="button" class="btn btn-danger" @click="openDeleteModal(item)">
<i class="fa fa-trash"></i>Padam
</button>
</template>
</admin-data-table>
</div>
</div>
@@ -97,7 +91,32 @@ export default {
data: function () {
return {
id: 0
id: 0,
nomineeLoading: false,
nomineeTableHeaders: [
{ title: '', key: 'photo', sortable: false },
{ title: 'Nama', key: 'name', sortable: true },
{ title: 'No. Anggota', key: 'no_anggota', sortable: true },
{ title: 'Unit', key: 'unit', sortable: true },
{ title: 'Jawatan', key: 'position', sortable: true },
{
title: 'Taraf Pendidikan',
key: 'education',
sortable: false,
exportValue: function (item) {
return Array.isArray(item.education) ? item.education.join(' | ') : '';
}
},
{
title: 'Pengalaman Kerja',
key: 'experience',
sortable: false,
exportValue: function (item) {
return Array.isArray(item.experience) ? item.experience.join(' | ') : '';
}
},
{ title: 'Tindakan', key: 'actions', sortable: false }
]
}
},
@@ -106,6 +125,15 @@ export default {
},
methods: {
setPosition: function (id) {
var q = Object.assign({}, this.$route.query);
if (!id || String(id) === '0') {
delete q.position_id;
} else {
q.position_id = id;
}
this.$router.replace({ query: q });
},
openDeleteModal: function (nominee) {
this.id = nominee.id;
@@ -140,8 +168,13 @@ export default {
refreshNominee: function () {
var vm = this;
this.nomineeLoading = true;
this.util.notify('Refreshing Nominees', 'loading');
axios.get(config.API + 'nominee')
axios.get(config.API + 'nominee', {
params: {
position_id: this.position_id && String(this.position_id) !== '0' ? this.position_id : undefined
}
})
.then(response => {
$.notifyClose();
console.log(response);
@@ -151,6 +184,9 @@ export default {
$.notifyClose();
vm.showResult(error);
})
.finally(function () {
vm.nomineeLoading = false;
})
},
getPosition: function (id) {
@@ -179,7 +215,7 @@ export default {
var y = this.data.nominees;
for (var nominee in this.data.nominees) {
if (y[nominee].position_id == this.position_id || this.position_id == 0) {
var x = y[nominee];
var x = Object.assign({}, y[nominee]);
x.position = this.getPosition(y[nominee].position_id);
x.partylist = this.getPartylist(y[nominee].partylist_id);
nominees.push(x);
@@ -191,9 +227,28 @@ export default {
position_id: function () {
return this.$route.query.position_id ? this.$route.query.position_id : 0;
}
},
watch: {
position_id: function () {
this.refreshNominee();
}
}
}
</script>
<style></style>
<style>
.nominee-filter-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.nominee-chip {
border-radius: 999px;
padding: 6px 10px;
line-height: 1.1;
}
</style>
@@ -13,34 +13,19 @@
</button>
</div>
<div class="table-responsive">
<table class="table table-hover" id="position_table">
<thead>
<tr>
<th>ID</th>
<th>Nama</th>
<th>Tindakan</th>
</tr>
</thead>
<tbody>
<tr v-for="(position, i) in data.positions">
<td>{{ position.id }}</td>
<td>{{ position.name }}</td>
<td>
<button class="btn btn-info" @click="edit(i)">
<i class="fa fa-edit"></i> Edit
</button>
<button class="btn btn-danger" @click="util.showModal('#delete-position-modal'); id = position.id">
<i class="fa fa-trash"></i> Padam
</button>
</td>
</tr>
<tr v-if="data.positions.length < 1">
<td colspan="3">No Jawatan</td>
</tr>
</tbody>
</table>
</div>
<admin-data-table :headers="positionTableHeaders" :items="data.positions" :loading="positionLoading"
:items-per-page="10" :show-pagination="true" empty-text="Tiada jawatan" :exportable="true"
export-file-name="jawatan">
<template v-slot:item-actions="{ item }">
<button type="button" class="btn btn-info" @click="edit(item)">
<i class="fa fa-edit"></i> Edit
</button>
<button type="button" class="btn btn-danger"
@click="util.showModal('#delete-position-modal'); id = item.id">
<i class="fa fa-trash"></i> Padam
</button>
</template>
</admin-data-table>
</div>
</div>
@@ -62,7 +47,12 @@
export default {
data: function () {
return {
id: 0
id: 0,
positionLoading: false,
positionTableHeaders: [
{ title: 'Nama', key: 'name', sortable: true },
{ title: 'Tindakan', key: 'actions', sortable: false }
]
}
},
@@ -76,10 +66,10 @@ export default {
methods: {
refreshPosition: function () {
var vm = this;
this.positionLoading = true;
this.util.notify('Refreshing Position', 'loading');
axios.get(config.API + 'position')
.then(response => {
console.log(response)
$.notifyClose();
vm.data.positions = response.data;
})
@@ -87,22 +77,14 @@ export default {
$.notifyClose();
vm.util.showResult(error);
})
.finally(function () {
vm.positionLoading = false;
})
},
initDatatable: function () {
edit: function (position) {
var vm = this;
$('#position_table').DataTable({
destroy: true,
searching: false,
info: false,
autoWidth: false,
dom: 'Bfrtip'
});
},
edit: function (i) {
var vm = this;
this.data.position = this.data.positions[i];
this.data.position = position;
this.$router.push({ name: 'Edit Position', params: { id: vm.data.position.id } })
},
@@ -1,95 +1,468 @@
<template>
<div class="panel panel-default">
<div class="panel-body">
<h4>Tambah Pengundi</h4>
<form @submit.prevent="add()" id="add-form">
<div class="form-group">
<label for="name">Nama Anggota</label>
<input type="text" name="name" class="form-control" required>
<div class="panel panel-default voter-add-page">
<div class="panel-body">
<div class="voter-add-page__header clearfix">
<h4 class="pull-left">Tambah Pengundi</h4>
<div class="pull-right voter-add-page__header-actions">
<button type="button" class="btn btn-success" @click="openManualModal">
<i class="fa fa-user-plus"></i> Daftar pengundi baru
</button>
<router-link :to="{ name: 'Manage Voter' }" class="btn btn-default">
<i class="fa fa-arrow-left"></i> Kembali
</router-link>
</div>
</div>
<div class="form-group">
<label for="no_kp">No. Kad Pengenalan</label>
<input type="text" name="no_kp" class="form-control" required>
<p class="text-muted small voter-add-page__intro">
Kiri: keseluruhan pengundi (ikut No. KP, data terkini). Kanan: yang ditanda layak untuk pilihan raya
semasa.
Yang tidak berada di senarai kanan akan dipadam daripada pilihan raya ini apabila anda simpan.
</p>
<div class="voter-add-split row">
<div class="col-md-6 voter-add-split__col">
<div class="panel panel-default voter-add-split__panel">
<div class="panel-heading clearfix">
<strong class="pull-left">Semua pengundi</strong>
<span v-if="catalogLoading" class="text-muted small pull-left voter-add-heading__loading">
<i class="fa fa-spinner fa-spin"></i>
</span>
<span class="badge pull-right" title="Jumlah pengundi unik (No. KP)">{{ catalog.length
}}</span>
<span v-if="catalogFilter"
class="text-muted small pull-right voter-add-heading__filter-hint">
{{ filteredCatalog.length }} dipaparkan ·
</span>
</div>
<div class="panel-body">
<div class="form-inline voter-batch-toolbar">
<input type="text" class="form-control" v-model.trim="catalogFilter"
placeholder="Tapis nama / No. KP / No. anggota…" />
<button type="button" class="btn btn-default btn-sm" @click="selectAllFiltered">Tanda
semua (ditapis)</button>
<button type="button" class="btn btn-default btn-sm" @click="clearAllFiltered">Nyahtanda
(ditapis)</button>
</div>
<div v-if="catalogError" class="alert alert-danger">{{ catalogError }}</div>
<div class="table-responsive voter-add-table-wrap">
<table class="table table-bordered table-condensed table-striped"
v-if="!catalogLoading || catalog.length">
<thead>
<tr>
<th style="width:44px;">
<input type="checkbox" :checked="headerChecked"
@change="toggleHeader($event)" />
</th>
<th>Nama</th>
<th>No. KP</th>
<th>No. Anggota</th>
<th>Unit</th>
<th style="width:88px;">PR semasa</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filteredCatalog" :key="row.no_kp">
<td>
<input type="checkbox" :checked="isSelected(row.no_kp)"
@change="toggleRow(row.no_kp, $event)" />
</td>
<td>{{ row.name }}</td>
<td>{{ row.no_kp }}</td>
<td>{{ row.no_anggota }}</td>
<td>{{ row.unit }}</td>
<td>
<span v-if="row.in_current_election"
class="label label-success">Ya</span>
<span v-else class="label label-default"></span>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="!catalogLoading && !catalog.length" class="text-muted">Tiada rekod pengundi dalam
pangkalan data.</p>
</div>
</div>
</div>
<div class="col-md-6 voter-add-split__col">
<div class="panel panel-info voter-add-split__panel">
<div class="panel-heading clearfix">
<strong class="pull-left">Layak mengundi (pilihan raya semasa)</strong>
<span class="badge pull-right" title="Jumlah ditanda layak">{{ selectedNoKp.length }}</span>
<span v-if="selectedFilter"
class="text-muted small pull-right voter-add-heading__filter-hint">
{{ filteredSelectedRows.length }} dipaparkan ·
</span>
</div>
<div class="panel-body">
<div class="form-inline voter-batch-toolbar voter-batch-toolbar--right">
<input type="text" class="form-control" v-model.trim="selectedFilter"
placeholder="Tapis senarai dipilih…" />
<button type="button" class="btn btn-default btn-sm" @click="clearAllSelected"
:disabled="!selectedNoKp.length">
Kosongkan semua
</button>
<button type="button" class="btn btn-primary"
:disabled="catalogSaving || catalogLoading" @click="saveApplicable()">
Simpan pilihan
</button>
</div>
<div class="table-responsive voter-add-table-wrap voter-add-table-wrap--selected">
<table class="table table-bordered table-condensed table-striped"
v-if="filteredSelectedRows.length">
<thead>
<tr>
<th>Nama</th>
<th>No. KP</th>
<th>No. Anggota</th>
<th style="width:52px;"></th>
</tr>
</thead>
<tbody>
<tr v-for="row in filteredSelectedRows" :key="row.no_kp">
<td>{{ row.name }}</td>
<td>{{ row.no_kp }}</td>
<td>{{ row.no_anggota }}</td>
<td class="text-center">
<button type="button" class="btn btn-xs btn-danger" title="Buang"
@click="removeSelected(row.no_kp)">
<i class="fa fa-times"></i>
</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="text-muted voter-add-empty-selected">Tiada pengundi ditanda. Tandakan
pada jadual kiri.</p>
</div>
</div>
</div>
</div>
</div>
<div class="form-group">
<label for="no_anggota">No. Anggota</label>
<input type="text" name="no_anggota" class="form-control" required>
</div>
<div class="form-group">
<label for="unit">Unit</label>
<input type="text" name="unit" class="form-control" required>
</div>
<div class="form-group">
<label for="alamat">Alamat</label>
<input type="text" name="alamat" class="form-control" required>
</div>
<div class="form-group">
<label for="status_anggota">Status Anggota</label>
<select name="status_anggota" class="form-control" required>
<option value="inactive">-- Pilih Status --</option>
<option value="active">Aktif</option>
<option value="inactive">Berhenti</option>
</select>
</div>
<div class="form-group">
<label for="telefon">No. Telefon</label>
<input type="text" name="telefon" class="form-control" required>
</div>
<div class="form-group">
<label for="saham">Saham</label>
<input type="text" name="saham" class="form-control" required>
</div>
<div class="form-group">
<label for="yuran">Yuran</label>
<input type="text" name="yuran" class="form-control" required>
</div>
<div class="form-group">
<button type="submit" class="btn btn-success">Submit</button>
<router-link :to="{name: 'Manage Voter'}" class="btn btn-default">Back</router-link>
</div>
</form>
<!-- Modal for adding new voter -->
<modal id="add-voter-manual-modal">
<modal-header>Daftar pengundi baru</modal-header>
<modal-body>
<form @submit.prevent="add()" id="add-form">
<div class="form-group">
<label for="add-form-name">Nama Anggota</label>
<input id="add-form-name" type="text" name="name" class="form-control" required>
</div>
<div class="form-group">
<label for="add-form-no_kp">No. Kad Pengenalan</label>
<input id="add-form-no_kp" type="text" name="no_kp" class="form-control" required>
</div>
<div class="form-group">
<label for="add-form-no_anggota">No. Anggota</label>
<input id="add-form-no_anggota" type="text" name="no_anggota" class="form-control" required>
</div>
<div class="form-group">
<label for="add-form-unit">Unit</label>
<input id="add-form-unit" type="text" name="unit" class="form-control" required>
</div>
<div class="form-group">
<label for="add-form-alamat">Alamat</label>
<input id="add-form-alamat" type="text" name="alamat" class="form-control" required>
</div>
<div class="form-group">
<label for="add-form-status_anggota">Status Anggota</label>
<select id="add-form-status_anggota" name="status_anggota" class="form-control" required>
<option value="inactive">-- Pilih Status --</option>
<option value="active">Aktif</option>
<option value="inactive">Berhenti</option>
</select>
</div>
<div class="form-group">
<label for="add-form-telefon">No. Telefon</label>
<input id="add-form-telefon" type="text" name="telefon" class="form-control" required>
</div>
<div class="form-group">
<label for="add-form-saham">Saham</label>
<input id="add-form-saham" type="text" name="saham" class="form-control" required>
</div>
<div class="form-group">
<label for="add-form-yuran">Yuran</label>
<input id="add-form-yuran" type="text" name="yuran" class="form-control" required>
</div>
</form>
</modal-body>
<modal-footer>
<button type="button" class="btn btn-success" :disabled="loading" @click="add()">Hantar</button>
<button type="button" class="btn btn-default" @click="closeManualModal">Batal</button>
</modal-footer>
</modal>
</div>
</div>
</div>
</template>
<style>
.voter-add-page__header {
margin-bottom: 12px;
}
.voter-add-page__header h4 {
margin-top: 0;
}
.voter-add-page__header-actions .btn {
margin-left: 6px;
}
.voter-add-page__intro {
margin-bottom: 16px;
}
.voter-add-split__col {
margin-bottom: 16px;
}
.voter-add-split__panel {
margin-bottom: 0;
}
.voter-batch-toolbar .form-control {
min-width: 180px;
margin-right: 8px;
margin-bottom: 8px;
}
.voter-batch-toolbar .btn {
margin-right: 6px;
margin-bottom: 8px;
}
.voter-add-table-wrap {
max-height: min(520px, calc(100vh - 280px));
overflow: auto;
margin-top: 10px;
}
.voter-add-table-wrap--selected {
min-height: 120px;
}
.voter-add-empty-selected {
margin: 16px 0 0;
}
.voter-add-heading__loading {
margin-left: 8px;
margin-top: 3px;
}
.voter-add-heading__filter-hint {
margin-right: 8px;
margin-top: 4px;
}
</style>
<script>
export default{
export default {
data: function () {
return {
loading: false
loading: false,
catalog: [],
catalogLoading: false,
catalogSaving: false,
catalogError: '',
catalogFilter: '',
selectedFilter: '',
selectedNoKp: []
}
},
computed: {
catalogByKp: function () {
var map = {};
var i;
for (i = 0; i < this.catalog.length; i++) {
map[this.catalog[i].no_kp] = this.catalog[i];
}
return map;
},
selectedRows: function () {
var map = this.catalogByKp;
var out = [];
var i;
for (i = 0; i < this.selectedNoKp.length; i++) {
var kp = this.selectedNoKp[i];
if (map[kp]) out.push(map[kp]);
}
return out;
},
filteredCatalog: function () {
var q = (this.catalogFilter || '').toLowerCase();
if (!q) return this.catalog;
return this.catalog.filter(function (row) {
return (row.name && String(row.name).toLowerCase().indexOf(q) !== -1)
|| (row.no_kp && String(row.no_kp).toLowerCase().indexOf(q) !== -1)
|| (row.no_anggota && String(row.no_anggota).toLowerCase().indexOf(q) !== -1)
|| (row.unit && String(row.unit).toLowerCase().indexOf(q) !== -1);
});
},
filteredSelectedRows: function () {
var rows = this.selectedRows;
var q = (this.selectedFilter || '').toLowerCase();
if (!q) return rows;
return rows.filter(function (row) {
return (row.name && String(row.name).toLowerCase().indexOf(q) !== -1)
|| (row.no_kp && String(row.no_kp).toLowerCase().indexOf(q) !== -1)
|| (row.no_anggota && String(row.no_anggota).toLowerCase().indexOf(q) !== -1)
|| (row.unit && String(row.unit).toLowerCase().indexOf(q) !== -1);
});
},
headerChecked: function () {
var rows = this.filteredCatalog;
if (!rows.length) return false;
var vm = this;
return rows.every(function (r) { return vm.isSelected(r.no_kp); });
}
},
created: function () {
this.loadMemberCatalog();
},
methods: {
openManualModal: function () {
this.util.showModal('#add-voter-manual-modal');
},
closeManualModal: function () {
this.util.hideModal('#add-voter-manual-modal');
},
resetManualForm: function () {
var el = document.getElementById('add-form');
if (el) el.reset();
},
loadMemberCatalog: function () {
var vm = this;
this.catalogLoading = true;
this.catalogError = '';
axios.get(config.API + 'voter/member-catalog')
.then(function (res) {
vm.catalog = (res.data && res.data.catalog) ? res.data.catalog : [];
vm.selectedNoKp = vm.catalog.filter(function (r) { return r.in_current_election; }).map(function (r) { return r.no_kp; });
})
.catch(function (err) {
vm.catalogError = (err.response && err.response.data && err.response.data.message)
? err.response.data.message
: 'Gagal memuatkan senarai.';
vm.util.showResult(err);
})
.finally(function () {
vm.catalogLoading = false;
});
},
isSelected: function (noKp) {
return this.selectedNoKp.indexOf(noKp) !== -1;
},
toggleRow: function (noKp, evt) {
var arr = this.selectedNoKp.slice();
var i = arr.indexOf(noKp);
if (evt.target.checked) {
if (i === -1) arr.push(noKp);
} else {
if (i !== -1) arr.splice(i, 1);
}
this.selectedNoKp = arr;
},
toggleHeader: function (evt) {
var want = evt.target.checked;
var keys = this.filteredCatalog.map(function (r) { return r.no_kp; });
var set = {};
var i;
for (i = 0; i < this.selectedNoKp.length; i++) {
set[this.selectedNoKp[i]] = true;
}
if (want) {
for (i = 0; i < keys.length; i++) set[keys[i]] = true;
} else {
for (i = 0; i < keys.length; i++) delete set[keys[i]];
}
this.selectedNoKp = Object.keys(set);
},
selectAllFiltered: function () {
var set = {};
var i;
for (i = 0; i < this.selectedNoKp.length; i++) {
set[this.selectedNoKp[i]] = true;
}
var rows = this.filteredCatalog;
for (i = 0; i < rows.length; i++) set[rows[i].no_kp] = true;
this.selectedNoKp = Object.keys(set);
},
clearAllFiltered: function () {
var remove = {};
var i;
var rows = this.filteredCatalog;
for (i = 0; i < rows.length; i++) remove[rows[i].no_kp] = true;
this.selectedNoKp = this.selectedNoKp.filter(function (kp) { return !remove[kp]; });
},
removeSelected: function (noKp) {
this.selectedNoKp = this.selectedNoKp.filter(function (kp) { return kp !== noKp; });
},
clearAllSelected: function () {
this.selectedNoKp = [];
},
saveApplicable: function () {
if (this.catalogSaving) return;
var vm = this;
this.catalogSaving = true;
this.util.notify('Menyimpan…', 'loading');
axios.post(config.API + 'voter/sync-applicable', { selected_no_kp: this.selectedNoKp })
.then(function (res) {
$.notifyClose();
if (vm.util.showResult(res, 'success')) {
vm.$router.push({ name: 'Manage Voter' });
}
})
.catch(function (err) {
$.notifyClose();
vm.util.showResult(err);
})
.finally(function () {
vm.catalogSaving = false;
});
},
add: function () {
if (this.loading) return;
this.loading = true;
var vm = this;
this.util.notify('Adding Voter', 'loading')
axios.post(config.API+'voter', $('#add-form').serialize())
.then(response=>{
this.util.notify('Adding Voter', 'loading');
axios.post(config.API + 'voter', $('#add-form').serialize())
.then(function (response) {
$.notifyClose();
vm.loading = false;
if (vm.util.showResult(response, 'success')) {
vm.$router.push({name:'Manage Voter'});
vm.closeManualModal();
vm.resetManualForm();
vm.$router.push({ name: 'Manage Voter' });
}
})
.catch(error=>{
.catch(function (error) {
$.notifyClose();
vm.loading = false;
vm.util.showResult(error);
})
});
}
}
}
</script>
</script>
@@ -15,51 +15,36 @@
<i class="fa fa-list"></i> Database Anggota
</router-link>
</div>
<div class="table-responsive">
<table class="table table-hover" id="position_table">
<thead>
<tr>
<th>Bil.</th>
<th>Nama Anggota</th>
<th>No. Kad Pengenalan</th>
<th>No. Anggota</th>
<th>Unit</th>
<th>Saham</th>
<th>Yuran</th>
<th>Tindakan</th>
</tr>
</thead>
<tbody>
<tr v-for="(voter, i) in data.voters.data">
<td>{{ i + 1 }}</td>
<td>{{ voter.name }}</td>
<td>{{ voter.no_kp }}</td>
<td>{{ voter.no_anggota }}</td>
<td>{{ voter.unit }}</td>
<td>{{ voter.saham }}</td>
<td>{{ voter.yuran }}</td>
<td>
<button class="btn btn-info" @click="edit(i)">
<i class="fa fa-edit"></i> Set semula
</button>
<button class="btn btn-danger"
@click="util.showModal('#delete-voter-modal'); id = voter.id">
<i class="fa fa-trash"></i> Padam
</button>
</td>
</tr>
<tr v-if="data.voters.data && data.voters.data.length < 1">
<td colspan="3">No Voters</td>
</tr>
</tbody>
</table>
<div class="kehadiran-search">
<div class="kehadiran-search__label">Carian pantas:</div>
<input class="form-control kehadiran-search__input kehadiran-search__input--name"
v-model.trim="searchName" placeholder="Nama Anggota" />
<input class="form-control kehadiran-search__input" v-model.trim="searchNoAnggota"
placeholder="No. Anggota" />
<input class="form-control kehadiran-search__input" v-model.trim="searchNoKp"
placeholder="No. Kad Pengenalan" />
<button class="btn btn-primary" @click="applySearch()">Cari</button>
<button class="btn btn-default" @click="clearSearch()" :disabled="!hasAnySearch">Reset</button>
</div>
<admin-data-table :headers="voterTableHeaders" :items="voterListItems" :loading="voterLoading"
:show-pagination="true" index-title="Bil." empty-text="Tiada pengundi" :exportable="true">
<template v-slot:item-actions="{ item }">
<button type="button" class="btn btn-info" @click="edit(item)">
<i class="fa fa-edit"></i> Kemaskini
</button>
<button type="button" class="btn btn-danger"
@click="util.showModal('#delete-voter-modal'); id = item.id">
<i class="fa fa-trash"></i> Padam
</button>
</template>
</admin-data-table>
<ul class="pagination" v-if="pages.length > 1">
<router-link tag="li" v-for="page in pages" :key="page['pages']"
:to="{ query: { page: page['page'] } }" :class="{ 'active': current_page == page['page'] }"
exact>
<a href="#">{{ page['page'] }}</a>
<router-link tag="li" v-for="page in pages" :key="page.page" :to="{ query: { page: page.page } }"
:class="{ active: Number(current_page) === page.page }" exact>
<a href="#">{{ page.page }}</a>
</router-link>
</ul>
@@ -80,11 +65,82 @@
</div>
</template>
<style>
.kehadiran-search {
margin: 10px 0 12px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.kehadiran-search__label {
color: #6b7280;
font-size: 12px;
font-weight: 700;
}
.kehadiran-search__input {
max-width: 220px;
height: 34px;
}
.kehadiran-search__input--name {
max-width: 260px;
}
</style>
<script>
export default {
data: function () {
return {
id: 0
id: 0,
voterLoading: false,
searchName: '',
searchNoAnggota: '',
searchNoKp: '',
voterTableHeaders: [
{ title: 'Nama Anggota', key: 'name', sortable: true },
{ title: 'No. Kad Pengenalan', key: 'no_kp', sortable: true },
{ title: 'No. Anggota', key: 'no_anggota', sortable: true },
{ title: 'Unit', key: 'unit', sortable: true },
{ title: 'Saham', key: 'saham', sortable: true },
{ title: 'Yuran', key: 'yuran', sortable: true },
{ title: 'Tindakan', key: 'actions', sortable: false }
]
}
},
computed: {
hasAnySearch: function () {
return Boolean(
(this.searchName && this.searchName.length)
|| (this.searchNoAnggota && this.searchNoAnggota.length)
|| (this.searchNoKp && this.searchNoKp.length)
);
},
voterListItems: function () {
var v = this.data.voters;
if (v && Array.isArray(v.data)) {
return v.data;
}
return [];
},
pages: function () {
var pages = [];
var last = this.data.voters && this.data.voters.last_page;
if (last) {
for (var i = 1; i <= last; i++) {
pages.push({ page: i });
}
}
return pages;
},
current_page: function () {
return this.$route.query.page ? this.$route.query.page : 1;
}
},
@@ -92,17 +148,45 @@ export default {
this.refreshVoter();
},
watch: {
'$route.query.page': function () {
$.notifyClose();
this.refreshVoter();
}
},
methods: {
search: function () {
},
applySearch: function () {
var q = Object.assign({}, this.$route.query);
q.page = 1;
this.$router.replace({ query: q });
this.refreshVoter();
},
clearSearch: function () {
this.searchName = '';
this.searchNoAnggota = '';
this.searchNoKp = '';
this.applySearch();
},
refreshVoter: function () {
var vm = this;
this.voterLoading = true;
this.util.notify('Refreshing Voter', 'loading');
axios.get(config.API + 'voter?page=' + this.current_page)
axios.get(config.API + 'voter', {
params: {
page: this.current_page,
name: this.searchName || undefined,
no_anggota: this.searchNoAnggota || undefined,
no_kp: this.searchNoKp || undefined
}
})
.then(response => {
console.log(response)
$.notifyClose();
vm.data.voters = response.data;
})
@@ -110,11 +194,14 @@ export default {
$.notifyClose();
vm.util.showResult(error);
})
.finally(function () {
vm.voterLoading = false;
})
},
edit: function (i) {
edit: function (voter) {
var vm = this;
this.data.voter = this.data.voters.data[i];
this.data.voter = voter;
this.$router.push({ name: 'Edit Voter', params: { id: vm.data.voter.id } })
},
@@ -132,30 +219,6 @@ export default {
vm.util.showResult(error);
})
}
},
watch: {
'$route.query.page': function () {
$.notifyClose();
this.refreshVoter();
}
},
computed: {
pages: function () {
var pages = [];
if (this.data.voters.last_page)
for (var i = 1; i <= this.data.voters.last_page; i++) {
let x = {};
x['page'] = i;
pages.push(x);
}
return pages;
},
current_page: function () {
return this.$route.query.page ? this.$route.query.page : 1;
}
}
}
</script>
</script>
@@ -1,52 +1,292 @@
<template>
<div class="panel panel-default">
<div class="panel-body">
<button class="btn btn-success" @click="refreshVoter()">
<i class="fa fa-refresh"></i> Kemaskini Pengundi
</button>
<div class="kehadiran-actions clearfix">
<button class="btn btn-success" @click="refreshVoter()">
<i class="fa fa-refresh"></i> Kemaskini Pengundi
</button>
<router-link class="btn btn-info" :to="{ name: 'Fizikal' }">
Fizikal
</router-link>
<a class="btn btn-warning pull-right" :href="getPDFKehadiranurl(1)">
<i class="fa fa-download"></i> Muat Turun PDF
</a>
</div>
<router-link class="btn btn-info" :to="{ name: 'Maya' }">
Maya
</router-link>
<div class="kehadiran-search">
<div class="kehadiran-search__label">Carian pantas (kaunter):</div>
<input class="form-control kehadiran-search__input" v-model.trim="searchNoAnggota"
placeholder="No. Anggota" />
<input class="form-control kehadiran-search__input" v-model.trim="searchNoKp"
placeholder="No. Kad Pengenalan" />
<button class="btn btn-primary" @click="applySearch()">Cari</button>
<button class="btn btn-default" @click="clearSearch()" :disabled="!hasAnySearch">Reset</button>
</div>
<div class="kehadiran-filters">
<span class="kehadiran-filters__label">Penapis:</span>
<button type="button" class="kehadiran-chip" :class="{ 'is-active': !current_kehadiran }"
@click="setKehadiranFilter(null)">
Semua
</button>
<button type="button" class="kehadiran-chip"
:class="{ 'is-active': String(current_kehadiran) === 'unset' }"
@click="setKehadiranFilter('unset')">
Belum Ditetapkan
</button>
<button type="button" class="kehadiran-chip" :class="{ 'is-active': Number(current_kehadiran) === 1 }"
@click="setKehadiranFilter(1)">
Fizikal
</button>
<button type="button" class="kehadiran-chip" :class="{ 'is-active': Number(current_kehadiran) === 2 }"
@click="setKehadiranFilter(2)">
Maya
</button>
</div>
<a class="pdf-button-container btn btn-warning" :href="getPDFKehadiranurl(1)"><i class="fa fa-download"></i> Muat Turun PDF</a>
<div class="kehadiran-filters kehadiran-filters--secondary">
<span class="kehadiran-filters__label">Pendaftaran fizikal:</span>
<button type="button" class="kehadiran-chip" :class="{ 'is-active': !current_fizikal_reg }"
@click="setFizikalRegFilter(null)">
Semua
</button>
<button type="button" class="kehadiran-chip" :class="{ 'is-active': current_fizikal_reg === 'pending' }"
@click="setFizikalRegFilter('pending')">
Menunggu pengesahan
</button>
<button type="button" class="kehadiran-chip"
:class="{ 'is-active': current_fizikal_reg === 'verified' }"
@click="setFizikalRegFilter('verified')">
Disahkan
</button>
</div>
<!-- <a class="btn btn-warning" :href="getPDFKehadiranurl(1)">Muat Turun PDF</a> -->
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Bil.</th>
<th>Nama Anggota</th>
<th>No. Kad Pengenalan</th>
<th>No. Anggota</th>
<th>Unit</th>
<th>Kehadiran</th>
<th>Tarikh/Masa</th>
</tr>
</thead>
<tbody>
<!-- Display attendees -->
<template v-for="(voter, index) in data.voters.data">
<tr>
<td>{{ index + 1 }}</td> <!-- Display the serial number -->
<td>{{ voter.name }}</td>
<td>{{ voter.no_kp }}</td>
<td>{{ voter.no_anggota }}</td>
<td>{{ voter.unit }}</td>
<td>{{ getKehadiranString(voter.kehadiran) }}</td>
<td>{{ voter.updated_at }}</td>
</tr>
</template>
</tbody>
</table>
<admin-data-table :headers="kehadiranTableHeaders" :items="voterListItems" :loading="voterLoading"
:show-pagination="true" index-title="Bil." empty-text="Tiada rekod kehadiran" :exportable="true">
<template v-slot:item-kehadiran="{ item }">
{{ getKehadiranString(item.kehadiran) }}
</template>
<template v-slot:item-fizikal_reg="{ item }">
<span v-if="Number(item.kehadiran) !== 1" class="text-muted"></span>
<span v-else-if="item.fizikal_registration_verified_at"
class="kehadiran-pill kehadiran-pill--success">
<i class="fa fa-check-circle" aria-hidden="true"></i>
<span>Disahkan</span>
</span>
<span v-else class="kehadiran-pill kehadiran-pill--warn">
<i class="fa fa-clock-o" aria-hidden="true"></i>
<span>Menunggu</span>
</span>
</template>
<template v-slot:item-has_voted="{ item }">
<span class="kehadiran-pill"
:class="Number(item.votes_count) > 0 ? 'kehadiran-pill--success' : 'kehadiran-pill--muted'">
<i class="fa" :class="Number(item.votes_count) > 0 ? 'fa-check-circle' : 'fa-times-circle'"
aria-hidden="true"></i>
<span>{{ Number(item.votes_count) > 0 ? 'Undi' : 'Belum' }}</span>
</span>
</template>
<template v-slot:item-allowance="{ item }">
<span class="kehadiran-pill"
:class="isCashPaid(item) ? 'kehadiran-pill--success' : 'kehadiran-pill--warn'">
<i class="fa" :class="isCashPaid(item) ? 'fa-money' : 'fa-exclamation-circle'"
aria-hidden="true"></i>
<span>{{ isCashPaid(item) ? 'Sudah Bayar' : 'Belum Bayar' }}</span>
</span>
</template>
<template v-slot:item-action="{ item }">
<div class="kehadiran-action-cell">
<button type="button" class="btn btn-xs btn-primary kehadiran-action-btn"
@click.stop="openDetail(item)">
<i class="fa fa-eye" aria-hidden="true"></i>
<span>Detail</span>
</button>
<button v-if="canVerifyFizikalRegistration(item)" type="button"
class="btn btn-xs btn-success kehadiran-action-btn"
@click.stop="openFizikalVerifyModal(item)">
<i class="fa fa-check" aria-hidden="true"></i>
<span>Sahkan</span>
</button>
</div>
</template>
</admin-data-table>
<!-- Allowance Modal -->
<div class="modal fade app-modal" id="allowance-modal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Butiran Pembayaran Elaun</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" v-if="selectedVoter">
<div class="row">
<div class="col-md-6">
<table class="table table-condensed">
<tbody>
<tr>
<th style="width: 160px;">Nama</th>
<td>{{ selectedVoter.name }}</td>
</tr>
<tr>
<th>No. KP</th>
<td>{{ selectedVoter.no_kp }}</td>
</tr>
<tr>
<th>No. Anggota</th>
<td>{{ selectedVoter.no_anggota }}</td>
</tr>
<tr>
<th>Unit</th>
<td>{{ selectedVoter.unit }}</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-6">
<table class="table table-condensed">
<tbody>
<tr>
<th style="width: 160px;">Kehadiran</th>
<td><b>{{ getKehadiranString(selectedVoter.kehadiran) }}</b></td>
</tr>
<tr v-if="Number(selectedVoter.kehadiran) === 1">
<th>Pendaftaran</th>
<td>
<span v-if="selectedVoter.fizikal_registration_verified_at"
class="label label-success">Disahkan</span>
<span v-else class="label label-warning">Menunggu pengesahan</span>
</td>
</tr>
<tr>
<th>Status Undi</th>
<td>
<span class="label"
:class="Number(selectedVoter.votes_count) > 0 ? 'label-success' : 'label-default'">
{{ Number(selectedVoter.votes_count) > 0 ? 'Ya' : 'Tidak' }}
</span>
</td>
</tr>
<tr>
<th>Bayaran Tunai</th>
<td>
<span class="label"
:class="isCashPaid(selectedVoter) ? 'label-success' : 'label-warning'">
{{ isCashPaid(selectedVoter) ? ('Sudah (' +
selectedVoter.cash_paid_at +
')') : 'Belum' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<hr />
<div class="row">
<div class="col-md-4">
<label>Kod Tuntutan (Claim Code)</label>
<input type="text" class="form-control" v-model.trim="claimCodeInput"
placeholder="Contoh: 482913" />
<small class="text-muted">Voter akan tunjuk kod ini. Wajib untuk bayaran
tunai.</small>
</div>
<div class="col-md-4">
<label>Jumlah Tunai (RM)</label>
<input type="text" class="form-control kehadiran-tunai-readonly" readonly disabled
:value="allowanceTunaiDisplay" />
</div>
<div class="col-md-4">
<label>Rujukan (optional)</label>
<input type="text" class="form-control" v-model.trim="payoutReference"
maxlength="80" />
</div>
</div>
<div class="row" style="margin-top: 10px;">
<div class="col-md-12">
<label>Nota (optional)</label>
<input type="text" class="form-control" v-model.trim="payoutNote" />
</div>
</div>
<div class="alert alert-danger" v-if="detailError" style="margin-top: 12px;">
{{ detailError }}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Tutup</button>
<button type="button" class="btn btn-danger"
v-if="selectedVoter && isCashPaid(selectedVoter)" :disabled="detailLoading"
@click="voidCashPayout()">
Batal Bayaran Tunai
</button>
<button type="button" class="btn btn-success"
v-if="selectedVoter && canPayCash(selectedVoter)" :disabled="detailLoading"
@click="payCash()">
Bayar Tunai
</button>
</div>
</div>
</div>
</div>
<!-- Fizikal Verify Modal -->
<div class="modal fade app-modal" id="fizikal-verify-modal" tabindex="-1" role="dialog"
aria-labelledby="fizikal-verify-title" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="fizikal-verify-title">Pengesahan pendaftaran fizikal</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" v-if="verifyModalVoter">
<p class="kehadiran-verify-lead">
Sahkan bahawa pengundi ini hadir di kaunter. Selepas ini, pengundi boleh mengundi.
</p>
<table class="table table-condensed table-bordered kehadiran-verify-summary">
<tbody>
<tr>
<th style="width: 140px;">Nama</th>
<td>{{ verifyModalVoter.name }}</td>
</tr>
<tr>
<th>No. KP</th>
<td>{{ verifyModalVoter.no_kp }}</td>
</tr>
<tr>
<th>No. Anggota</th>
<td>{{ verifyModalVoter.no_anggota }}</td>
</tr>
<tr>
<th>Unit</th>
<td>{{ verifyModalVoter.unit }}</td>
</tr>
</tbody>
</table>
<div class="alert alert-danger" v-if="verifyModalError" style="margin-bottom: 0;">
{{ verifyModalError }}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Tutup</button>
<button type="button" class="btn btn-primary" :disabled="verifyModalLoading"
@click="submitFizikalVerify">
<i class="fa fa-check" aria-hidden="true"></i> Sahkan pendaftaran
</button>
</div>
</div>
</div>
</div>
<!-- Display attendance percentage -->
@@ -55,9 +295,10 @@
</div>
<ul class="pagination" v-if="pages.length > 1">
<router-link tag="li" v-for="page in pages" :key="page['pages']" :to="{ query: { page: page['page'] } }"
:class="{ 'active': current_page == page['page'] }" exact>
<a href="#">{{ page['page'] }}</a>
<router-link tag="li" v-for="page in pages" :key="page.page"
:to="{ query: paginationQueryForPage(page.page) }"
:class="{ active: Number(current_page) === page.page }" exact>
<a href="#">{{ page.page }}</a>
</router-link>
</ul>
</div>
@@ -65,17 +306,167 @@
</template>
<style>
.pdf-button-container {
text-align: right;
.kehadiran-actions {
margin-bottom: 10px;
}
.kehadiran-search {
margin: 10px 0 12px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.kehadiran-search__label {
color: #6b7280;
font-size: 12px;
font-weight: 700;
}
.kehadiran-search__input {
max-width: 220px;
height: 34px;
}
.kehadiran-filters {
margin: 6px 0 14px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.kehadiran-filters--secondary {
margin-top: 0;
}
.kehadiran-filters__label {
color: #6b7280;
font-size: 12px;
font-weight: 600;
margin-right: 4px;
}
.kehadiran-chip {
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.kehadiran-chip:hover {
background: #f3f4f6;
}
.kehadiran-chip.is-active {
background: #1976d2;
border-color: #1976d2;
color: #fff;
}
.kehadiran-action-cell {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.kehadiran-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 700;
}
.kehadiran-action-btn>i {
line-height: 1;
}
.kehadiran-verify-lead {
margin-bottom: 12px;
color: #374151;
line-height: 1.5;
}
.kehadiran-verify-summary {
margin-bottom: 0;
}
.kehadiran-tunai-readonly {
background-color: #f3f4f6 !important;
cursor: not-allowed;
color: #1f2937;
}
.kehadiran-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
line-height: 1.4;
border: 1px solid transparent;
white-space: nowrap;
}
.kehadiran-pill>i {
line-height: 1;
}
.kehadiran-pill--success {
color: #0f5132;
background: #d1e7dd;
border-color: #badbcc;
}
.kehadiran-pill--warn {
color: #664d03;
background: #fff3cd;
border-color: #ffecb5;
}
.kehadiran-pill--muted {
color: #374151;
background: #f3f4f6;
border-color: #e5e7eb;
}
</style>
<script>
export default {
data: function () {
return {
id: 0
id: 0,
voterLoading: false,
searchNoAnggota: '',
searchNoKp: '',
selectedVoter: null,
verifyModalVoter: null,
verifyModalLoading: false,
verifyModalError: '',
detailLoading: false,
detailError: '',
claimCodeInput: '',
payoutReference: '',
payoutNote: '',
kehadiranTableHeaders: [
{ title: 'Nama Anggota', key: 'name', sortable: true },
{ title: 'No. Kad Pengenalan', key: 'no_kp', sortable: true },
{ title: 'No. Anggota', key: 'no_anggota', sortable: true },
{ title: 'Unit', key: 'unit', sortable: true },
{ title: 'Kehadiran', key: 'kehadiran', sortable: true },
{ title: 'Pendaftaran', key: 'fizikal_reg', sortable: false },
{ title: 'Undi', key: 'has_voted', sortable: false },
{ title: 'Status', key: 'allowance', sortable: false },
{ title: 'Tindakan', key: 'action', sortable: false },
]
}
},
@@ -90,8 +481,17 @@ export default {
refreshVoter: function () {
var vm = this;
this.voterLoading = true;
this.util.notify('Refreshing Voter', 'loading');
axios.get(config.API + 'voter?page=' + this.current_page)
axios.get(config.API + 'voter', {
params: {
page: this.current_page,
kehadiran: this.current_kehadiran || undefined,
fizikal_reg: this.current_fizikal_reg || undefined,
no_anggota: this.searchNoAnggota || undefined,
no_kp: this.searchNoKp || undefined
}
})
.then(response => {
console.log(response)
$.notifyClose();
@@ -101,6 +501,94 @@ export default {
$.notifyClose();
vm.util.showResult(error);
})
.finally(function () {
vm.voterLoading = false;
})
},
applySearch: function () {
// Reset to first page when searching
var q = Object.assign({}, this.$route.query);
q.page = 1;
this.$router.replace({ query: q });
this.refreshVoter();
},
clearSearch: function () {
this.searchNoAnggota = '';
this.searchNoKp = '';
this.applySearch();
},
setKehadiranFilter: function (val) {
var q = Object.assign({}, this.$route.query);
if (val === 1 || val === 2) q.kehadiran = String(val);
else if (val === 'unset') q.kehadiran = 'unset';
else delete q.kehadiran;
q.page = 1;
this.$router.replace({ query: q });
},
setFizikalRegFilter: function (val) {
var q = Object.assign({}, this.$route.query);
if (val === 'pending' || val === 'verified') q.fizikal_reg = val;
else delete q.fizikal_reg;
q.page = 1;
this.$router.replace({ query: q });
},
paginationQueryForPage: function (pageNum) {
var q = { page: pageNum };
if (this.current_kehadiran) q.kehadiran = this.current_kehadiran;
if (this.current_fizikal_reg) q.fizikal_reg = this.current_fizikal_reg;
return q;
},
canVerifyFizikalRegistration: function (voter) {
if (!voter) return false;
return Number(voter.kehadiran) === 1 && !voter.fizikal_registration_verified_at;
},
openFizikalVerifyModal: function (item) {
if (!item || !this.canVerifyFizikalRegistration(item)) return;
this.verifyModalError = '';
this.verifyModalVoter = Object.assign({}, item);
this.util.showModal('#fizikal-verify-modal');
},
submitFizikalVerify: function () {
var vm = this;
if (!this.verifyModalVoter) return;
this.verifyModalError = '';
this.verifyModalLoading = true;
axios.post(config.API + 'voter/' + this.verifyModalVoter.id + '/verify-fizikal-registration')
.then(function (response) {
if (response && response.data && response.data.status && response.data.status !== 'success') {
vm.verifyModalError = response.data.message || 'Permintaan gagal.';
vm.util.notify(vm.verifyModalError, 'error');
return;
}
if (vm.util.showResult(response, 'success')) {
var v = response.data.voter;
if (v && vm.selectedVoter && vm.selectedVoter.id === v.id) {
vm.selectedVoter.fizikal_registration_verified_at = v.fizikal_registration_verified_at;
}
vm.util.hideModal('#fizikal-verify-modal');
vm.verifyModalVoter = null;
vm.refreshVoter();
}
})
.catch(function (error) {
vm.verifyModalError =
(error && error.response && error.response.data && error.response.data.message)
? error.response.data.message
: (error && error.message ? error.message : 'Gagal mengesahkan pendaftaran.');
if (error && error.response) vm.util.showResult(error, 'error');
else vm.util.notify(vm.verifyModalError, 'error');
})
.finally(function () {
vm.verifyModalLoading = false;
});
},
getKehadiranString(kehadiran) {
@@ -118,6 +606,116 @@ export default {
getPDFKehadiranurl(Id) {
// Construct and return the URL based on the item ID
return `/kehadiranpdf/${Id}`;
},
isCashPaid: function (voter) {
return Boolean(voter && voter.cash_paid_at);
},
canPayCash: function (voter) {
if (!voter) return false;
if (this.isCashPaid(voter)) return false;
// Must be fizikal, must have voted
return Number(voter.kehadiran) === 1 && Number(voter.votes_count) > 0;
},
openDetail: function (item) {
this.detailError = '';
this.selectedVoter = Object.assign({}, item);
this.claimCodeInput = '';
this.payoutReference = '';
this.payoutNote = '';
this.util.showModal('#allowance-modal');
},
payCash: function () {
if (!this.selectedVoter) return;
this.detailError = '';
this.detailLoading = true;
let payload = {
voter_id: this.selectedVoter.id,
method: 'cash',
claim_code: this.claimCodeInput || undefined,
reference: this.payoutReference || undefined,
note: this.payoutNote || undefined,
};
var rm = this.allowanceTunaiAmountRM;
if (rm !== null && rm !== undefined && !Number.isNaN(Number(rm))) {
payload.amount_cents = Math.round(Number(rm) * 100);
}
axios.post(config.API + 'allowance/payout', payload)
.then((response) => {
// Some endpoints return 200 with {status:'failed', message:'...'}
if (response && response.data && response.data.status && response.data.status !== 'success') {
this.detailError = response.data.message || 'Permintaan gagal.';
this.util.notify(this.detailError, 'error');
return;
}
if (this.util.showResult(response, 'success')) {
// refresh list so cash_paid_at & cash_payout_id updates
this.refreshVoter();
// keep modal open but reflect paid state (best effort)
if (response && response.data && response.data.payout) {
this.selectedVoter.cash_paid_at = response.data.payout.paid_at;
this.selectedVoter.cash_payout_id = response.data.payout.id;
} else {
this.selectedVoter.cash_paid_at = (new Date()).toISOString();
}
}
})
.catch((error) => {
this.detailError =
(error && error.response && error.response.data && error.response.data.message)
? error.response.data.message
: (error && error.message ? error.message : 'Gagal merekod bayaran.');
// Only pass actual axios error objects into showResult
if (error && error.response) this.util.showResult(error, 'error');
else this.util.notify(this.detailError, 'error');
})
.finally(() => {
this.detailLoading = false;
});
},
voidCashPayout: function () {
if (!this.selectedVoter || !this.selectedVoter.cash_payout_id) {
this.detailError = 'Rekod bayaran tidak dijumpai untuk dibatalkan.';
return;
}
this.detailError = '';
this.detailLoading = true;
axios.post(config.API + 'allowance/void/' + this.selectedVoter.cash_payout_id, {
note: this.payoutNote || undefined
})
.then((response) => {
if (response && response.data && response.data.status && response.data.status !== 'success') {
this.detailError = response.data.message || 'Permintaan gagal.';
this.util.notify(this.detailError, 'error');
return;
}
if (this.util.showResult(response, 'success')) {
this.refreshVoter();
this.selectedVoter.cash_paid_at = null;
this.selectedVoter.cash_payout_id = null;
}
})
.catch((error) => {
this.detailError =
(error && error.response && error.response.data && error.response.data.message)
? error.response.data.message
: (error && error.message ? error.message : 'Gagal membatalkan bayaran.');
if (error && error.response) this.util.showResult(error, 'error');
else this.util.notify(this.detailError, 'error');
})
.finally(() => {
this.detailLoading = false;
});
}
@@ -127,10 +725,45 @@ export default {
'$route.query.page': function () {
$.notifyClose();
this.refreshVoter();
},
'$route.query.kehadiran': function () {
$.notifyClose();
this.refreshVoter();
},
'$route.query.fizikal_reg': function () {
$.notifyClose();
this.refreshVoter();
}
},
computed: {
/** RM300 fizikal, RM150 Maya — used for tunai payout amount. */
allowanceTunaiAmountRM: function () {
if (!this.selectedVoter) return null;
var k = Number(this.selectedVoter.kehadiran);
if (k === 1) return 300;
if (k === 2) return 150;
return null;
},
allowanceTunaiDisplay: function () {
var rm = this.allowanceTunaiAmountRM;
if (rm === null || rm === undefined) return '';
return Number(rm).toFixed(2);
},
hasAnySearch: function () {
return Boolean((this.searchNoAnggota && this.searchNoAnggota.length) || (this.searchNoKp && this.searchNoKp.length));
},
voterListItems: function () {
var v = this.data.voters;
if (v && Array.isArray(v.data)) {
return v.data;
}
return [];
},
pages: function () {
var pages = [];
if (this.data.voters.last_page)
@@ -146,21 +779,29 @@ export default {
return this.$route.query.page ? this.$route.query.page : 1;
},
current_kehadiran: function () {
return this.$route.query.kehadiran ? this.$route.query.kehadiran : null;
},
current_fizikal_reg: function () {
return this.$route.query.fizikal_reg ? this.$route.query.fizikal_reg : null;
},
// Calculate total attendance percentage
attendancePercentage() {
// Count total members who attended physically (Fizikal)
const fizikalCount = this.data.voters.data.reduce((total, voter) => {
const fizikalCount = this.voterListItems.reduce((total, voter) => {
return voter.kehadiran === 1 ? total + 1 : total;
}, 0);
// Count total members who attended virtually (Maya)
const mayaCount = this.data.voters.data.reduce((total, voter) => {
const mayaCount = this.voterListItems.reduce((total, voter) => {
return voter.kehadiran === 2 ? total + 1 : total;
}, 0);
// Calculate total attendance percentage
const totalAttendance = fizikalCount + mayaCount;
const totalMembers = this.data.voters.data.length;
const totalMembers = this.voterListItems.length;
return totalMembers === 0 ? 0 : ((totalAttendance / totalMembers) * 100).toFixed(2);
},
@@ -1,14 +1,55 @@
<template>
<div class="container col-md-8 col-md-offset-2">
<h4>Maklumat Pengundi</h4>
<router-view></router-view>
</div>
<div :class="wrapperClass">
<h4 v-if="showHeader">{{ headerTitle }}</h4>
<router-view></router-view>
</div>
</template>
<script>
export default{
export default {
computed: {
showHeader: function () {
// Keep header hidden only for "Tambah Pengundi" (it has its own page header)
return this.$route.name !== 'Tambah Pengundi';
},
headerTitle: function () {
if (this.$route.name === 'Kehadiran Calon') return 'Kehadiran';
return 'Maklumat Pengundi';
},
isWideVoterRoute: function () {
// Give more room for heavy pages, but keep it boxed (not edge-to-edge)
return this.$route.name === 'Tambah Pengundi'
|| this.$route.name === 'Kehadiran Calon';
},
wrapperClass: function () {
if (this.isWideVoterRoute) {
return 'container-fluid voter-admin-wrap voter-admin-wrap--boxed voter-admin-wrap--wide';
}
return 'container col-md-8 col-md-offset-2 voter-admin-wrap voter-admin-wrap--boxed';
}
},
created: function () {
this.util.setTitle('MyKoPKB - Maklumat Pengundi');
}
}
</script>
</script>
<style>
/* Boxed card-style wrapper for all voter admin pages */
.voter-admin-wrap--boxed {
background: #fff;
border: 1px solid #e6e6e6;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
padding: 16px;
margin-top: 12px;
margin-bottom: 24px;
}
/* Wide pages: not full screen, but wider than centered 8-col layout */
.voter-admin-wrap--wide {
max-width: 1400px;
margin-left: auto;
margin-right: auto;
}
</style>
@@ -0,0 +1,224 @@
<template>
<div class="physical-gate-page">
<div v-if="!displayKey" class="physical-gate-setup">
<h1 class="physical-gate-title">Paparan kod kaunter</h1>
<p class="physical-gate-hint">
Masukkan kunci paparan (sama dengan <code>PHYSICAL_ATTENDANCE_GATE_DISPLAY_KEY</code> dalam tetapan pelayan),
atau buka URL dengan parameter <code>?k=...</code>
</p>
<div class="form-group physical-gate-key-form">
<input v-model="keyInput" type="password" class="form-control input-lg" placeholder="Kunci paparan"
@keyup.enter="applyKey" />
<button type="button" class="btn btn-primary btn-lg btn-block" @click="applyKey">Mula paparan</button>
</div>
</div>
<div v-else-if="error" class="physical-gate-error">
<h1 class="physical-gate-title">Ralat</h1>
<p>{{ error }}</p>
<button type="button" class="btn btn-default" @click="retry">Cuba lagi</button>
</div>
<div v-else class="physical-gate-active">
<div class="physical-gate-label">Kod kehadiran fizikal ({{ periodSeconds }}s)</div>
<div class="physical-gate-code">{{ code || '—' }}</div>
<div class="physical-gate-countdown">
<span class="physical-gate-countdown-bar" :style="{ width: countdownPercent + '%' }"></span>
</div>
<p class="physical-gate-sub">Kod akan bertukar secara automatik. Pastikan skrin sentiasa menyala.</p>
</div>
</div>
</template>
<script>
export default {
name: 'PhysicalGateDisplay',
data: function () {
return {
displayKey: '',
keyInput: '',
code: '',
secondsRemaining: 0,
periodSeconds: 60,
error: '',
pollTimer: null,
tickTimer: null,
};
},
computed: {
countdownPercent: function () {
if (!this.periodSeconds) return 0;
return Math.min(100, (this.secondsRemaining / this.periodSeconds) * 100);
},
},
created: function () {
this.util.setTitle('Kod kaunter — Kehadiran fizikal');
var q = this.$route.query.k || this.$route.query.key;
if (q) {
this.displayKey = String(q);
}
},
mounted: function () {
if (this.displayKey) {
this.startPolling();
}
},
beforeDestroy: function () {
this.stopTimers();
},
methods: {
applyKey: function () {
if (!this.keyInput) return;
this.displayKey = this.keyInput;
this.error = '';
this.startPolling();
},
retry: function () {
this.error = '';
this.fetchCode();
},
fetchCode: function () {
var vm = this;
axios
.get(config.API + 'physical-attendance-gate/current', {
headers: { 'X-Physical-Gate-Display-Key': vm.displayKey },
})
.then(function (res) {
vm.code = res.data.code;
vm.secondsRemaining = res.data.seconds_remaining;
vm.periodSeconds = res.data.period_seconds || 60;
vm.error = '';
})
.catch(function (err) {
var msg = 'Tidak dapat memuatkan kod.';
if (err.response && err.response.data && err.response.data.message) {
msg = err.response.data.message;
}
vm.error = msg;
vm.code = '';
});
},
startPolling: function () {
var vm = this;
this.stopTimers();
this.fetchCode();
this.pollTimer = setInterval(function () {
vm.fetchCode();
}, 5000);
this.tickTimer = setInterval(function () {
if (vm.secondsRemaining > 0) {
vm.secondsRemaining -= 1;
}
if (vm.secondsRemaining <= 0 && !vm.error) {
vm.fetchCode();
}
}, 1000);
},
stopTimers: function () {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
if (this.tickTimer) {
clearInterval(this.tickTimer);
this.tickTimer = null;
}
},
},
};
</script>
<style scoped>
.physical-gate-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: linear-gradient(160deg, #0f172a 0%, #1e3a5f 45%, #0f172a 100%);
color: #e2e8f0;
}
.physical-gate-setup,
.physical-gate-error {
max-width: 520px;
width: 100%;
}
.physical-gate-title {
font-size: 2rem;
font-weight: 800;
margin-bottom: 16px;
color: #f8fafc;
}
.physical-gate-hint {
font-size: 1.05rem;
line-height: 1.5;
margin-bottom: 20px;
color: #94a3b8;
}
.physical-gate-key-form .btn {
margin-top: 12px;
}
.physical-gate-active {
text-align: center;
width: 100%;
max-width: 920px;
}
.physical-gate-label {
font-size: 1.4rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #94a3b8;
margin-bottom: 20px;
}
.physical-gate-code {
font-size: clamp(3.5rem, 14vw, 8rem);
font-weight: 900;
letter-spacing: 0.12em;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
color: #fbbf24;
text-shadow: 0 0 40px rgba(251, 191, 36, 0.35);
margin-bottom: 28px;
word-break: break-all;
}
.physical-gate-countdown {
height: 12px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.25);
overflow: hidden;
margin: 0 auto 20px;
max-width: 560px;
}
.physical-gate-countdown-bar {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #38bdf8, #22d3ee);
transition: width 0.3s ease;
}
.physical-gate-sub {
font-size: 1.1rem;
color: #94a3b8;
margin: 0;
}
</style>
@@ -47,31 +47,49 @@
</div>
</div>
<div class="form-group attendance-choice-box">
<label for="kehadiran">Kehadiran</label>
<select class="form-control attendance-select" id="kehadiran" name="kehadiran" required>
<option value="0">Sila Pilih :</option>
<option value="1">Fizikal</option>
<option value="2">Maya</option>
</select>
<small class="form-text text-muted attendance-helper-text">
Sila pilih kehadiran (Maya/Fizikal) kemudian klik SAYA TERIMA.
</small>
</div>
<!-- Sembunyikan borang pendaftaran selepas kehadiran telah dihantar -->
<template v-if="Number(data.user.kehadiran) === 0">
<div class="form-group attendance-choice-box">
<label for="kehadiran">Kehadiran</label>
<select v-model="selectedKehadiran" class="form-control attendance-select" id="kehadiran"
name="kehadiran" required>
<option value="0">Sila Pilih :</option>
<option value="1">Fizikal</option>
<option value="2">Maya</option>
</select>
<small class="form-text text-muted attendance-helper-text">
Sila pilih kehadiran (Maya/Fizikal) kemudian klik SAYA TERIMA.
</small>
</div>
<div class="alert alert-info mt-4 small attendance-info-notice">
<p><strong>*</strong> Pendaftaran akan dibuka dari <strong>jam 7.00 pagi hingga 10.00 pagi (25 April
2025)</strong>.</p>
<p>
<strong>*</strong> Elaun kehadiran berjumlah <strong>RM300.00 (secara fizikal)</strong> akan
diberikan secara TUNAI selepas mesyuarat. <br />
Manakala <strong>RM150.00 (secara atas talian)</strong> akan dikreditkan ke akaun anggota yang
hadir penuh.
</p>
</div>
<div v-if="selectedKehadiran === '1'" class="form-group attendance-field">
<label for="physical_gate_code">Kod skrin kaunter pendaftaran</label>
<input type="text" name="physical_gate_code" id="physical_gate_code"
class="form-control attendance-input" autocomplete="off" autocapitalize="characters"
inputmode="text" placeholder="8 aksara, semak skrin di kaunter" maxlength="64"
@input="onPhysicalGateCodeInput" />
<small class="form-text text-muted attendance-helper-text">
Kod berputar setiap minit masukkan kod semasa yang dipaparkan di kaunter sebelum
menghantar.
</small>
</div>
<div class="alert alert-info mt-4 small attendance-info-notice">
<p><strong>*</strong> Pendaftaran akan dibuka dari <strong>jam 7.00 pagi hingga 10.00 pagi (25
April
2025)</strong>.</p>
<p>
<strong>*</strong> Elaun kehadiran berjumlah <strong>RM300.00 (secara fizikal)</strong> akan
diberikan secara TUNAI selepas mesyuarat. <br />
Manakala <strong>RM150.00 (secara atas talian)</strong> akan dikreditkan ke akaun anggota
yang
hadir penuh.
</p>
</div>
</template>
<div class="text-center mt-4 attendance-action-area">
<div v-if="data.user.kehadiran != 0" class="attendance-success-card">
<div v-if="data.user.kehadiran != 0 && canProceedToVote" class="attendance-success-card">
<div class="attendance-success-kicker">Kehadiran Disahkan</div>
<p class="attendance-success-text">Kehadiran anda telah disahkan secara</p>
<h5 class="attendance-success-mode">
@@ -83,10 +101,35 @@
</router-link>
</div>
<div v-else-if="data.user.kehadiran != 0 && !canProceedToVote" class="attendance-pending-card">
<div class="attendance-pending-kicker">Menunggu pengesahan</div>
<p class="attendance-pending-text">
Kehadiran <strong>fizikal</strong> anda telah direkod. Sila pergi ke kaunter pendaftaran
untuk
mengesahkan pendaftaran sebelum anda boleh mengundi.
</p>
<p class="attendance-pending-hint">
Selepas pengesahan, butang mengundi akan dipaparkan di sini. Semak semula halaman ini
selepas beberapa ketika.
</p>
<button type="button" class="btn btn-default attendance-refresh-button"
@click="reloadAttendanceState">
Muat semula
</button>
</div>
<button type="submit" class="btn btn-lg btn-success attendance-submit-button"
v-else-if="data.election.status == 2">
SAYA TERIMA
v-else-if="electionVotingActive">
DAFTAR KEHADIRAN
</button>
<div v-else class="alert alert-info text-left attendance-voting-notice">
<p class="attendance-voting-notice__title"><strong>Butang DAFTAR KEHADIRAN belum
tersedia</strong>
</p>
<p class="attendance-voting-notice__body">
Buat masa ini proses undian / mesyuarat <strong>belum bermula</strong>.
</p>
</div>
</div>
</form>
@@ -248,6 +291,43 @@
letter-spacing: 0.05em;
}
.attendance-pending-card {
padding: 24px;
border-radius: 18px;
background: linear-gradient(135deg, #fffbeb 0%, #ffffff 100%);
border: 1px solid #fde68a;
box-shadow: 0 14px 28px rgba(180, 83, 9, 0.1);
}
.attendance-pending-kicker {
margin-bottom: 10px;
color: #b45309;
font-size: 1.2rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.attendance-pending-text {
margin-bottom: 10px;
color: #43617a;
line-height: 1.55;
}
.attendance-pending-hint {
margin-bottom: 18px;
color: #6b7280;
font-size: 0.95rem;
line-height: 1.5;
}
.attendance-refresh-button {
min-width: 180px;
padding: 10px 20px;
border-radius: 999px;
font-weight: 700;
}
.attendance-submit-button,
.attendance-vote-button {
min-width: 220px;
@@ -258,6 +338,21 @@
box-shadow: 0 12px 24px rgba(35, 64, 97, 0.14);
}
.attendance-voting-notice {
max-width: 520px;
margin: 0 auto;
border-radius: 14px;
}
.attendance-voting-notice__title {
margin: 0 0 8px;
}
.attendance-voting-notice__body {
margin: 0 0 10px;
line-height: 1.55;
}
@media (max-width: 767px) {
.attendance-hero,
@@ -271,7 +366,8 @@
}
.attendance-submit-button,
.attendance-vote-button {
.attendance-vote-button,
.attendance-refresh-button {
width: 100%;
min-width: 0;
}
@@ -284,11 +380,55 @@ export default {
data: function () {
return {
loading: false,
userSubmitted: false // Add a flag to track if the user has already submitted
userSubmitted: false, // Add a flag to track if the user has already submitted
selectedKehadiran: '0',
}
},
mounted: function () {
if (this.data && this.data.user && this.data.user.kehadiran == 0) {
var el = document.getElementById('kehadiran');
if (el) this.selectedKehadiran = el.value || '0';
}
},
computed: {
/**
* Status 2 = pilihan raya sedang berjalan (butang SAYA TERIMA dibuka).
*/
electionVotingActive: function () {
var e = this.data && this.data.election;
if (!e || e.status === undefined || e.status === null) return false;
return Number(e.status) === 2;
},
/**
* Maya (2) can vote once attendance is set. Fizikal (1) needs admin verification timestamp.
*/
canProceedToVote: function () {
var u = this.data && this.data.user;
if (!u || Number(u.kehadiran) === 0) return false;
if (Number(u.kehadiran) !== 1) return true;
return Boolean(u.fizikal_registration_verified_at);
}
},
methods: {
onPhysicalGateCodeInput: function (e) {
var el = e.target;
var upper = el.value.toUpperCase();
if (el.value === upper) return;
var start = el.selectionStart;
var end = el.selectionEnd;
el.value = upper;
if (start != null && end != null) {
el.setSelectionRange(start, end);
}
},
reloadAttendanceState: function () {
location.reload();
},
// refreshUser: function () {
// var vm = this;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,475 @@
<template>
<div class="voter-nominees-page row col-md-10 col-md-offset-1">
<div class="col-xs-12 page-column">
<div class="page-section-heading">Maklumat Calon</div>
<div class="nominee-page-intro">
Tekan gambar atau baris calon untuk melihat maklumat penuh.
</div>
<div class="panel panel-primary nominee-panel" v-for="position in data.positions" :key="position.id">
<div class="panel-heading nominee-panel-heading">
<span>{{ position.name }}</span>
<span class="nominee-panel-badge">Senarai Calon</span>
</div>
<div class="panel-body table-responsive nominee-panel-body">
<table class="table table-hover nominee-table" id="table-nominee">
<thead>
<tr>
<th></th>
<th>Bil.</th>
<th>Nama</th>
<th>No. Anggota</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
<tr v-for="(nominee, i) in data.nominees" :key="nominee.id" v-if="nominee.position_id == position.id"
class="nominee-row" @click="viewNomineeDetails(nominee)">
<td>
<img :src="createBase64ImageUrl(nominee.photo)" class="thumbnail nominee-thumb">
</td>
<td>{{ nominee.no_calon }}</td>
<td>{{ nominee.name }}</td>
<td>{{ nominee.no_anggota }}</td>
<td>{{ nominee.unit }}</td>
</tr>
<tr v-if="!data.nominees.some(nominee => nominee.position_id == position.id)">
<td colspan="5" class="nominee-empty-state">Tiada calon untuk jawatan ini.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-if="showNomineeModal" class="modal-backdrop nominee-modal-backdrop" @click.self="closeNomineeModal">
<div class="modal-dialog" id="view-nominee-modal">
<div class="modal-content nominee-modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title">Maklumat Calon</h5>
</div>
</div>
<div class="modal-body nominee-modal-body">
<div class="photo-section">
<div class="nominee-photo-card">
<img :src="nomineeViewed.photo" class="thumbnail nominee-viewed-photo" />
<div class="nominee-photo-meta">
<div class="nominee-name">{{ nomineeViewed.name }}</div>
<div class="nominee-subtitle">
Calon No. {{ nomineeViewed.no_calon || '-' }}
</div>
</div>
</div>
</div>
<div class="detail-section">
<div class="nominee-summary-grid">
<div class="summary-item">
<span class="summary-label">Nama</span>
<span class="summary-value">{{ nomineeViewed.name || '-' }}</span>
</div>
<div class="summary-item">
<span class="summary-label">Unit</span>
<span class="summary-value">{{ nomineeViewed.unit || '-' }}</span>
</div>
<div class="summary-item">
<span class="summary-label">Umur</span>
<span class="summary-value">{{ nomineeViewed.umur || '-' }}</span>
</div>
<div class="summary-item">
<span class="summary-label">Jawatan Sekarang</span>
<span class="summary-value">{{ nomineeViewed.jawatan_sekarang || '-' }}</span>
</div>
</div>
<div class="nominee-section-card">
<div class="section-title">Taraf Pendidikan</div>
<ol v-if="nomineeViewed.education && nomineeViewed.education.length" class="detail-list">
<li v-for="(item, index) in nomineeViewed.education" :key="index">{{ item }}</li>
</ol>
<p v-else class="detail-empty">Tiada maklumat pendidikan.</p>
</div>
<div class="nominee-section-card">
<div class="section-title">Pengalaman Kerja</div>
<ol v-if="nomineeViewed.experience && nomineeViewed.experience.length" class="detail-list">
<li v-for="(item, index) in nomineeViewed.experience" :key="index">{{ item }}</li>
</ol>
<p v-else class="detail-empty">Tiada maklumat pengalaman kerja.</p>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-success" @click="closeNomineeModal">Tutup</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
nomineeViewed: {},
showNomineeModal: false
};
},
methods: {
viewNomineeDetails(nominee) {
this.nomineeViewed = { ...nominee };
this.nomineeViewed.photo = this.createBase64ImageUrl(nominee.photo);
this.showNomineeModal = true;
},
closeNomineeModal() {
this.showNomineeModal = false;
},
createBase64ImageUrl(photo) {
return `data:image/jpeg;base64,${photo}`;
}
}
};
</script>
<style>
.voter-nominees-page {
margin-top: 24px;
margin-bottom: 40px;
}
.page-column {
margin-bottom: 24px;
}
.page-section-heading {
margin-bottom: 14px;
padding: 14px 18px;
border-radius: 14px;
background: linear-gradient(135deg, #0d6efd, #2f80ed);
color: #fff;
font-size: 1.8rem;
font-weight: 700;
text-align: center;
box-shadow: 0 12px 28px rgba(13, 110, 253, 0.2);
}
.nominee-page-intro {
margin-bottom: 18px;
padding: 12px 16px;
border-radius: 12px;
background: #eef5ff;
border: 1px solid #dbe8ff;
color: #36506b;
font-size: 1.4rem;
}
.nominee-panel {
margin-bottom: 20px;
border: 0;
border-radius: 18px;
overflow: hidden;
box-shadow: 0 14px 28px rgba(35, 64, 97, 0.1);
}
.nominee-panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: linear-gradient(135deg, #1d4e89, #2563eb);
color: #fff;
font-size: 1.7rem;
font-weight: 700;
}
.nominee-panel-badge {
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
font-size: 1.2rem;
font-weight: 600;
}
.nominee-panel-body {
padding: 0;
background: #fff;
}
.nominee-table {
margin-bottom: 0;
}
.nominee-table thead {
background: #f4f8fd;
}
.nominee-table thead th {
border-bottom: 1px solid #dce7f5;
color: #48627e;
font-size: 1.25rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.nominee-table tbody td {
vertical-align: middle;
border-top: 1px solid #edf2f7;
color: #22384f;
}
.nominee-row {
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
}
.nominee-row:hover {
background: #f8fbff;
box-shadow: inset 4px 0 0 #2f80ed;
}
.nominee-thumb {
width: 64px;
height: 64px;
margin-bottom: 0;
border: 2px solid #dbe8ff;
border-radius: 16px;
object-fit: cover;
box-shadow: 0 8px 18px rgba(47, 128, 237, 0.12);
}
.nominee-empty-state {
padding: 18px !important;
text-align: center;
color: #6f8193;
}
.nominee-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: rgba(7, 25, 48, 0.72);
backdrop-filter: blur(3px);
}
#view-nominee-modal.modal-dialog {
width: 100%;
max-width: 960px;
margin: 0;
}
.nominee-modal-content {
border: 0;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.28);
}
.nominee-modal-content .modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid #e7edf5;
background: linear-gradient(135deg, #0d6efd, #2f80ed);
color: #fff;
}
.nominee-modal-content .modal-title {
margin: 4px 0 0;
font-size: 2.2rem;
font-weight: 700;
}
.nominee-modal-content .modal-header .close {
margin-top: -4px;
color: #fff;
opacity: 0.9;
text-shadow: none;
}
.nominee-modal-kicker {
font-size: 1.2rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
opacity: 0.85;
}
.nominee-modal-body {
display: flex;
gap: 24px;
padding: 24px;
background: #f5f8fc;
}
.photo-section {
flex: 0 0 300px;
}
.nominee-photo-card {
padding: 18px;
border-radius: 18px;
background: #fff;
border: 1px solid #e5ebf3;
box-shadow: 0 10px 24px rgba(35, 64, 97, 0.08);
}
.nominee-viewed-photo {
width: 100%;
height: 320px;
object-fit: cover;
border: 0;
border-radius: 14px;
margin: 0 0 16px;
background: #eef3f8;
}
.nominee-photo-meta {
text-align: center;
}
.nominee-name {
font-size: 2rem;
font-weight: 700;
color: #17324d;
}
.nominee-subtitle {
margin-top: 4px;
font-size: 1.4rem;
color: #5f7388;
}
.detail-section {
flex: 1;
min-width: 0;
}
.nominee-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.summary-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border-radius: 14px;
background: #fff;
border: 1px solid #e5ebf3;
box-shadow: 0 8px 22px rgba(35, 64, 97, 0.06);
}
.summary-label {
font-size: 1.2rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #6f8193;
}
.summary-value {
font-size: 1.6rem;
font-weight: 600;
line-height: 1.4;
color: #17324d;
}
.nominee-section-card {
margin-bottom: 16px;
padding: 18px 20px;
border-radius: 16px;
background: #fff;
border: 1px solid #e5ebf3;
box-shadow: 0 8px 22px rgba(35, 64, 97, 0.06);
}
.section-title {
margin-bottom: 12px;
font-size: 1.5rem;
font-weight: 700;
color: #17324d;
}
.detail-list {
margin: 0;
padding-left: 20px;
color: #30475f;
}
.detail-list li {
margin-bottom: 8px;
line-height: 1.6;
}
.detail-empty {
margin: 0;
color: #6f8193;
}
.nominee-modal-content .modal-footer {
padding: 16px 24px 22px;
border-top: 1px solid #e7edf5;
background: #fff;
}
@media (max-width: 767px) {
.nominee-modal-backdrop {
align-items: flex-start;
overflow-y: auto;
padding: 16px 10px;
}
.nominee-modal-body {
flex-direction: column;
padding: 18px;
}
.photo-section {
flex: 1 1 auto;
}
.nominee-viewed-photo {
height: 260px;
}
.nominee-summary-grid {
grid-template-columns: 1fr;
}
.page-section-heading {
font-size: 1.6rem;
}
.nominee-panel-heading {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.nominee-table thead th:nth-child(4),
.nominee-table tbody td:nth-child(4),
.nominee-table thead th:nth-child(5),
.nominee-table tbody td:nth-child(5) {
display: none;
}
}
@media (max-width: 991px) {
.voter-nominees-page {
margin-top: 16px;
}
}
</style>
@@ -12,8 +12,8 @@
<ul class="list-group vote-summary-list">
<li class="list-group-item vote-summary-item">
<div class="vote-position-name">Calon Dipilih</div>
<div v-if="selected[0] && selected[0].nominee_id.length" class="vote-selected-list">
<div class="vote-selected-item" v-for="nominee_id in selected[0].nominee_id" :key="nominee_id">
<div v-if="allSelectedNomineeIds.length" class="vote-selected-list">
<div class="vote-selected-item" v-for="nominee_id in allSelectedNomineeIds" :key="nominee_id">
<span class="vote-selected-badge">#{{ getNomineeNo(nominee_id) }}</span>
<span>{{ getSelectedNomineeName(nominee_id) }}</span>
</div>
@@ -73,7 +73,7 @@
</div>
</div>
<div class="modal vote-confirm-modal" id="vote-modal" tabindex="-2" role="dialog" aria-labelledby="exampleModalLabel"
<div class="modal fade app-modal vote-confirm-modal" id="vote-modal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel"
aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content vote-modal-content">
@@ -88,9 +88,9 @@
</div>
<div class="modal-body vote-modal-body">
<h5 class="vote-modal-note">Nota: Sila pastikan anda telah membuat undian dengan betul.</h5>
<div class="vote-modal-selection" v-if="selected[0] && selected[0].nominee_id.length">
<div class="vote-modal-selection" v-if="allSelectedNomineeIds.length">
<div class="vote-modal-selection-title">Calon dipilih</div>
<div class="vote-selected-item" v-for="nominee_id in selected[0].nominee_id" :key="nominee_id">
<div class="vote-selected-item" v-for="nominee_id in allSelectedNomineeIds" :key="nominee_id">
<span class="vote-selected-badge">#{{ getNomineeNo(nominee_id) }}</span>
<span>{{ getSelectedNomineeName(nominee_id) }}</span>
</div>
@@ -130,8 +130,20 @@ export default {
},
computed: {
/** All nominee ids chosen across every position (sidebar + modal must use this, not selected[0]). */
allSelectedNomineeIds() {
var ids = [];
for (var i = 0; i < this.selected.length; i++) {
var arr = this.selected[i] && Array.isArray(this.selected[i].nominee_id)
? this.selected[i].nominee_id
: [];
for (var j = 0; j < arr.length; j++) ids.push(arr[j]);
}
return ids;
},
disabledCheckboxes() {
const selectedNomineeIds = this.selected[0]['nominee_id'];
const selectedNomineeIds = this.allSelectedNomineeIds;
return this.data.nominees.map(nominee => {
return selectedNomineeIds.includes(nominee.id) ? false : this.checkDisabled();
});
@@ -139,12 +151,23 @@ export default {
},
methods: {
totalSelectedCount: function () {
return this.allSelectedNomineeIds.length;
},
selectedEntryForPosition: function (position_id) {
for (var i = 0; i < this.selected.length; i++) {
if (String(this.selected[i].position_id) === String(position_id)) return this.selected[i];
}
return null;
},
test: function () {
console.log('tab');
this.util.showModal('#vote-modal')
},
submit: function () {
if (this.selected[0]['nominee_id'].length !== this.max) {
if (this.totalSelectedCount() !== this.max) {
// No selections made
this.util.notify(`Sila pastikan anda memilih (${this.max}) orang calon.`, 'error');
return;
@@ -152,7 +175,7 @@ export default {
var vm = this;
this.util.notify('Submitting your vote, please wait...', 'loading');
console.log(this.selected)
console.log(this.selected);
// Prepare data to send to the server
let data = {
vote: this.selected
@@ -162,6 +185,10 @@ export default {
axios.post(config.API + 'election/vote', data)
.then(response => {
$.notifyClose();
// Some endpoints may not return a `message`; ensure we never notify "undefined".
if (response && response.data && typeof response.data.message === 'string' && response.data.message) {
vm.util.notify(response.data.message, 'success');
}
if (vm.util.showResult(response, 'success')) {
vm.data.result = response.data.result;
vm.$router.push({ name: 'Voter Home' });
@@ -194,22 +221,19 @@ export default {
},
vote: function (position_id, nominee_id) {
if (!this.selected[0]['position_id']) {
this.$set(this.selected[0], position_id, []);
}
const index = this.selected[0]['nominee_id'].indexOf(nominee_id);
if (index === -1) {
// Nominee not found, add it
if (this.selected[0]['nominee_id'].length < this.max) {
this.selected[0]['nominee_id'].push(nominee_id);
} else {
// Display warning message if more than 2 nominees are selected
var entry = this.selectedEntryForPosition(position_id);
if (!entry) return;
var idx = entry.nominee_id.indexOf(nominee_id);
if (idx === -1) {
// Enforce global max selection across all positions
if (this.totalSelectedCount() >= this.max) {
this.util.notify(`Boleh mengundi (${this.max}) calon sahaja!`, 'warning');
return;
}
entry.nominee_id.push(nominee_id);
} else {
// Nominee found, remove it
this.selected[0]['nominee_id'].splice(index, 1);
entry.nominee_id.splice(idx, 1);
}
},
@@ -264,7 +288,7 @@ export default {
},
checkUndi: function () {
if (this.selected[0]['nominee_id'].length <= 0) {
if (this.totalSelectedCount() <= 0) {
this.util.notify('Sila pastikan anda memilih (1) orang calon.', 'error');
} else {
this.util.showModal('#vote-modal');
@@ -273,7 +297,7 @@ export default {
},
checkDisabled: function () {
if (this.selected[0]['nominee_id'].length >= this.max) {
if (this.totalSelectedCount() >= this.max) {
return true;
}
}
@@ -15,38 +15,7 @@
</div>
<div class="collapse navbar-collapse" id="myNavbar">
<ul class="nav navbar-nav">
<router-link :to="{ name: 'Voter Home' }" tag="li" exact>
<a href="#"><b>LAMAN UTAMA</b></a>
</router-link>
<li>
<a :href="matLinkUrl" target="_blank" rel="noopener noreferrer">
<b>MAT KE-28</b> <i class="fa fa-external-link"></i>
</a>
</li>
<router-link :to="{ name: 'Attendance' }" tag="li" exact>
<a href="#"><b>DAFTAR MAT</b></a>
</router-link>
<router-link :to="{ name: 'Vote' }"
v-if="data.election.status == 2 && data.user.saham >= 500 && !hasVoted() && data.user.kehadiran != 0"
tag="li" exact>
<a href="#"><b>UNDIAN</b></a>
</router-link>
<router-link :to="{ name: 'Zoom' }" v-if="data.user.kehadiran != 0" tag="li" exact>
<a href="#"><b>PAUTAN MESYUARAT</b></a>
</router-link>
<router-link :to="{ name: 'penyata', params: { no_anggota: data.user.no_anggota } }"
tag="li" exact>
<a href="#"><b>PENYATA AHLI</b></a>
</router-link>
<ul class="nav navbar-nav voter-nav-main">
</ul>
<ul class="nav navbar-right navbar-nav">
<li class="dropdown">
@@ -64,6 +33,16 @@
</nav>
<div class="voter-main">
<div class="voter-breadcrumb-wrap">
<div class="container-fluid">
<ol class="breadcrumb voter-breadcrumb">
<li v-for="(crumb, idx) in breadcrumbs" :key="idx" :class="{ active: crumb.active }">
<router-link v-if="crumb.to" :to="crumb.to">{{ crumb.label }}</router-link>
<span v-else>{{ crumb.label }}</span>
</li>
</ol>
</div>
</div>
<router-view></router-view>
</div>
</div>
@@ -78,18 +57,47 @@
<script>
export default {
data: () => ({
loading: true,
matLinkUrl: 'https://linktr.ee/kopkb'
loading: true
}),
created: function () {
this.refreshInfo();
},
computed: {
breadcrumbs: function () {
var routeName = this.$route.name;
var labels = {
'Voter Home': 'Laman Utama',
'Voter Calon': 'Maklumat Calon',
'Mat': 'MAT',
'Vote': 'Undian',
'Result': 'Keputusan',
'Attendance': 'Daftar MAT',
'Zoom': 'Pautan Mesyuarat',
'penyata': 'Penyata Ahli'
};
var current = labels[routeName] || routeName || 'Halaman';
if (routeName === 'Voter Home') {
return [{ label: 'Laman Utama', to: null, active: true }];
}
return [
{ label: 'Laman Utama', to: { name: 'Voter Home' }, active: false },
{ label: current, to: null, active: true }
];
}
},
methods: {
logout: function () {
localStorage.clear();
this.$router.push({ name: 'Voter Login' });
var vm = this;
this.util.setAuthorization();
axios.post(config.API + 'voter/logout')
.catch(function () { })
.finally(function () {
localStorage.clear();
vm.$router.push({ name: 'Voter Login' });
});
},
hasVoted: function () {
@@ -276,6 +284,57 @@ export default {
padding-top: 112px;
}
.voter-breadcrumb-wrap {
margin: 0 16px 8px;
padding: 0;
position: relative;
z-index: 5;
}
.voter-breadcrumb-wrap .container-fluid {
padding-left: 15px;
padding-right: 15px;
}
.voter-breadcrumb {
margin-bottom: 16px;
padding: 14px 20px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid #e5ebf3;
box-shadow: 0 8px 22px rgba(35, 64, 97, 0.1);
font-size: 1.7rem;
line-height: 1.45;
}
.voter-breadcrumb>li {
padding-top: 2px;
padding-bottom: 2px;
}
.voter-breadcrumb>li+li:before {
color: #94a3b8;
padding: 0 12px;
font-size: 1.35rem;
font-weight: 600;
}
.voter-breadcrumb>li>a {
color: #2563eb;
font-weight: 600;
}
.voter-breadcrumb>li>a:hover {
color: #1d4ed8;
text-decoration: underline;
}
.voter-breadcrumb>li.active {
color: #17324d;
font-weight: 700;
font-size: 1.7rem;
}
@media (max-width: 767px) {
.floating-header {
top: 10px !important;
@@ -312,5 +371,26 @@ export default {
.voter-main {
padding-top: 102px;
}
.voter-breadcrumb-wrap {
margin-left: 10px;
margin-right: 10px;
}
.voter-breadcrumb {
font-size: 1.4rem;
line-height: 1.4;
padding: 12px 16px;
border-radius: 12px;
}
.voter-breadcrumb>li.active {
font-size: 1.4rem;
}
.voter-breadcrumb>li+li:before {
padding: 0 8px;
font-size: 1.2rem;
}
}
</style>
@@ -13,25 +13,15 @@
<br>-<b>+6 {{ $route.params.notel }}</b>
</div>
<div
v-if="showDebugOtp"
class="alert alert-info"
style="margin-bottom:20px;"
>
<div v-if="showDebugOtp" class="alert alert-info" style="margin-bottom:20px;">
OTP local development: <b>{{ $route.params.debug_otp }}</b>
</div>
<div class="justify-content-center d-flex" style="margin-top:20px;margin-bottom:20px;display: flex;flex-direction: row;justify-content: center;align-items: center;">
<v-otp-input
ref="otpInput"
input-classes="otp-input"
separator="-"
:num-inputs="6"
:should-auto-focus="true"
:is-input-num="true"
@on-change="handleOnChange"
@on-complete="handleOnComplete"
/>
<div class="justify-content-center d-flex"
style="margin-top:20px;margin-bottom:20px;display: flex;flex-direction: row;justify-content: center;align-items: center;">
<v-otp-input ref="otpInput" input-classes="otp-input" separator="-" :num-inputs="6"
:should-auto-focus="true" :is-input-num="true" @on-change="handleOnChange"
@on-complete="handleOnComplete" />
</div>
<div style="margin-bottom:20px;">
@@ -41,13 +31,8 @@
</div>
<div class="form-group">
<input
ref="submitbtn"
type="submit"
class="btn btn-primary form-control"
value="Sahkan & Teruskan"
disabled
/>
<input ref="submitbtn" type="submit" class="btn btn-primary form-control" value="Sahkan & Teruskan"
disabled />
</div>
</form>
</div>
@@ -55,24 +40,19 @@
</div>
</template>
<script>
import Countdown from '../../mycomponents/countdown.vue'
export default {
components:{
Countdown
},
data: function () {
return {
loading: false
}
},
return {
loading: false
}
},
computed: {
showDebugOtp: function () {
return process.env.NODE_ENV !== 'production' && !!this.$route.params.debug_otp;
}
},
created: function(){
created: function () {
console.log(this.$route.params.notel);
},
methods: {
@@ -87,59 +67,56 @@ export default {
handleClearInput() {
this.$refs.otpInput.clearInput();
},
resendVerify: function(){
axios.post(config.API+'voter/login', {
'no_kp' : this.user.no_kp
resendVerify: function () {
var vm = this;
axios.post(config.API + 'voter/login', {
no_kp: this.$route.params.nokp
})
.then(response => {
this.stopLoading();
if (this.util.showResult(response, 'success')) {
// this.$router.push({ name: 'Voter Verify',params: {'nokp' : response.data.nokp,'notel' : response.data.notel} })
this.$refs.countdown.restarttimer();
console.log('resend verify');
}
})
.catch(error => {
vm.stopLoading();
this.util.showResult(error, 'error');
})
.then(function (response) {
if (vm.util.showResult(response, 'success')) {
console.log('resend verify');
}
})
.catch(function (error) {
vm.util.showResult(error, 'error');
});
},
startLoading: function () {
this.util.notify('Logging in', 'loading');
this.loading = true;
},
this.util.notify('Logging in', 'loading');
this.loading = true;
},
stopLoading: function () {
$.notifyClose();
this.loading = false;
},
$.notifyClose();
this.loading = false;
},
login: function () {
if (this.loading) return;
if (this.loading) return;
let vm = this;
let vm = this;
this.startLoading();
console.log('OTP INPUT : ');
let otp = this.mergeOTP(this.$refs.otpInput.otp);
console.log(otp);
axios.post(config.API+'voter/verify', {
'no_kp' : this.$route.params.nokp,
'token' : otp
})
.then(response => {
vm.stopLoading();
if (this.util.showResult(response, 'success')) {
localStorage['Access Token'] = `Bearer ${response.data.token}`;
this.util.setAuthorization();
vm.$router.push({name: 'Voter Home'});
}
})
.catch(error => {
vm.stopLoading();
this.util.showResult(error, 'error');
})
this.startLoading();
console.log('OTP INPUT : ');
let otp = this.mergeOTP(this.$refs.otpInput.otp);
console.log(otp);
axios.post(config.API + 'voter/verify', {
'no_kp': this.$route.params.nokp,
'token': otp
})
.then(response => {
vm.stopLoading();
if (this.util.showResult(response, 'success')) {
localStorage['Access Token'] = `Bearer ${response.data.token}`;
this.util.setAuthorization();
vm.$router.push({ name: 'Voter Home' });
}
})
.catch(error => {
vm.stopLoading();
this.util.showResult(error, 'error');
})
},
mergeOTP: function(OTP){
},
mergeOTP: function (OTP) {
return OTP.join('');
},
},
@@ -155,10 +132,12 @@ export default {
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.3);
text-align: center;
&.error {
border: 1px solid red !important;
}
}
.otp-input.error {
border: 1px solid red !important;
}
.otp-input::-webkit-inner-spin-button,
.otp-input::-webkit-outer-spin-button {
-webkit-appearance: none;
+1
View File
@@ -10,3 +10,4 @@ Vue.component('modal-body', resolveComponent(require('./components/mycomponents/
Vue.component('modal-footer', resolveComponent(require('./components/mycomponents/modal/footer.vue')));
Vue.component('uploader', resolveComponent(require('./components/mycomponents/uploader.vue')));
Vue.component('admin-data-table', resolveComponent(require('./components/AdminDataTable.vue')));
+8
View File
@@ -38,6 +38,8 @@ import ManagePenyata from './components/demo/admin/penyata/penyata.vue';
import ManagePenyataIndex from './components/demo/admin/penyata/index.vue';
import ManagePenyataView from './components/demo/admin/penyata/view.vue';
import AdminActivityLogIndex from './components/demo/admin/activitylog/index.vue';
const default_component = {
template: "<div>Not found: {{ $route.path }}</div>",
@@ -185,6 +187,12 @@ const routes = [
],
},
{
path: "activity-log",
component: AdminActivityLogIndex,
name: "Activity Log",
},
{
path: "/account",
component: ManageAccount,
+29
View File
@@ -4,18 +4,23 @@ import VoterVerification from "./components/demo/voter/verification.vue";
import VoterHome from "./components/demo/voter/index.vue";
import Home from "./components/demo/voter/home/index.vue";
import VoterNominees from "./components/demo/voter/home/nominees.vue";
import Mat from "./components/demo/voter/home/mat.vue";
import Vote from "./components/demo/voter/home/vote.vue";
import Result from "./components/demo/voter/home/result.vue";
import Attendance from "./components/demo/voter/home/attendance.vue";
import Zoom from "./components/demo/voter/home/zoom.vue";
import Penyata from "./components/demo/voter/home/penyata.vue";
import PhysicalGateDisplay from "./components/demo/display/physical-gate-display.vue";
import RouletteEliminationWheelDemo from "./components/demo/RouletteEliminationWheelDemo.vue";
import AdminLogin from "./components/demo/admin/login.vue";
import AdminIndex from "./components/demo/admin/index.vue";
import FinalResult from "./components/demo/admin/home/final.vue";
import AdminActivityLogIndex from "./components/demo/admin/activitylog/index.vue";
import ManageElection from "./components/demo/admin/home/home.vue";
import ManageElectionIndex from "./components/demo/admin/home/index.vue";
import ManageElectionResult from "./components/demo/admin/home/result.vue";
@@ -71,6 +76,12 @@ export default [
name: "Voter Home",
},
{
path: "calon",
component: VoterNominees,
name: "Voter Calon",
},
{
path: "mat27",
component: Mat,
@@ -121,6 +132,18 @@ export default [
name: "Voter Verify",
},
{
path: "/display/kod-fizikal",
component: PhysicalGateDisplay,
name: "Physical Gate Display",
},
{
path: "/demo/roulette",
component: RouletteEliminationWheelDemo,
name: "Roulette Wheel Demo",
},
{
path: "/admin/login",
component: AdminLogin,
@@ -137,6 +160,12 @@ export default [
name: "Election Result",
},
{
path: "activity-log",
component: AdminActivityLogIndex,
name: "Activity Log",
},
{
path: "",
component: ManageElection,
+37 -2
View File
@@ -108,9 +108,44 @@ const methods = {
message = data.message;
break;
case 422:
// Laravel validation errors are usually:
// { message: "...", errors: { field: ["..."] } }
// but some endpoints may return { status, message }.
data = typeof data == 'string' ? JSON.parse(data) : data;
for (var i in data)
data[i].map(y=>{message+=y+'<br/>';});
if (!data) {
message = 'Sila semak semula input anda.';
break;
}
if (data.errors && typeof data.errors === 'object') {
for (var field in data.errors) {
var arr = data.errors[field];
if (Array.isArray(arr)) {
arr.forEach(function (y) { message += y + '<br/>'; });
} else if (typeof arr === 'string') {
message += arr + '<br/>';
}
}
break;
}
if (typeof data.message === 'string') {
message = data.message;
break;
}
// Fallback: if it's a plain object, join any string/array values
if (typeof data === 'object') {
for (var i in data) {
var v = data[i];
if (Array.isArray(v)) {
v.forEach(function (y) { message += y + '<br/>'; });
} else if (typeof v === 'string') {
message += v + '<br/>';
}
}
}
break;
case 401:
message = 'You need to login first.';
+8 -2
View File
@@ -8,7 +8,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
<link rel="stylesheet" href="/css/app.css">
@php
$assetV = function ($relativePublicPath) {
$path = public_path($relativePublicPath);
return is_file($path) ? filemtime($path) : time();
};
@endphp
<link rel="stylesheet" href="{{ asset('css/app.css') }}?v={{ $assetV('css/app.css') }}">
</head>
@@ -45,7 +51,7 @@
<script src="https://cdn.jsdelivr.net/npm/jquery-flot@0.8.3/jquery.flot.pie.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-router@3.4.9/dist/vue-router.min.js"></script>
<script src="/js/app.js"></script>
<script src="{{ asset('js/app.js') }}?v={{ $assetV('js/app.js') }}"></script>
<!-- Bootstrap CSS -->
<footer class="bg-body-tertiary text-center text-lg-start">