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
@@ -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>