DONE: add allowance payout trace for admin, improve UI on voting side, increase pagination number

This commit is contained in:
ISMAIL MASSERAN
2026-04-28 11:44:02 +08:00
parent b08232731e
commit 76ae46f8d9
17 changed files with 913 additions and 76578 deletions
@@ -1,90 +1,77 @@
<template>
<div class="panel panel-default">
<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()" :disabled="!canViewAdminList">
<i class="fa fa-refresh"></i> Refresh</button>
</div>
<div class="panel panel-default">
<div class="panel-body">
<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>
<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()" :disabled="!canViewAdminList">
<i class="fa fa-refresh"></i> Refresh</button>
</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 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>
<modal-body>
<h3>Are you sure to delete this admin?</h3>
</modal-body>
<modal-footer>
<button @click="deleteAdmin()" class="btn btn-danger">Padam</button>
<button @click="util.hideModal('#delete-admin-modal')" class="btn btn-default">Batal</button>
</modal-footer>
</modal>
<modal id="impersonate-admin-modal">
<modal-header>Menyamar Admin</modal-header>
<modal-body>
<h4 v-if="impersonateTarget && impersonateTarget.email">
Menyamar <b>{{ impersonateTarget.email }}</b> ?
</h4>
<p class="text-muted" style="margin-bottom:0;">
Anda akan log masuk sebagai akaun tersebut.
</p>
</modal-body>
<modal-footer>
<button @click="startImpersonate()" class="btn btn-primary">Menyamar</button>
<button @click="util.hideModal('#impersonate-admin-modal')" class="btn btn-default">Batal</button>
</modal-footer>
</modal>
</div>
<modal id="delete-admin-modal">
<modal-header>Delete Admin</modal-header>
<modal-body>
<h3>Are you sure to delete this admin?</h3>
</modal-body>
<modal-footer>
<button @click="deleteAdmin()" class="btn btn-danger">Padam</button>
<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{
export default {
components: { AdminDataTable },
data: () => ({
id:0,
data: () => ({
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: 'Menyamar', key: 'impersonate', sortable: false, exportValue: function () { return ''; } },
{ title: 'Padam', key: 'delete', sortable: false, exportValue: function () { return ''; } },
]
}),
@@ -139,7 +126,7 @@ export default{
var vm = this;
if (!vm.impersonateTarget || !vm.impersonateTarget.id) return;
vm.util.hideModal('#impersonate-admin-modal');
vm.util.notify('Impersonating admin', 'loading');
vm.util.notify('Menyamar admin', 'loading');
axios.post(config.API + 'admin/impersonate', { user_id: vm.impersonateTarget.id })
.then(function (response) {
@@ -163,7 +150,7 @@ export default{
}
}
vm.util.notify('Impersonate berjaya', 'success');
vm.util.notify('Menyamar berjaya', 'success');
// Ensure admin shell is refreshed (nav header shows new user)
if (vm.$router) vm.$router.go(0);
})
@@ -175,15 +162,15 @@ export default{
deleteAdmin: function () {
this.util.hideModal('#delete-admin-modal');
this.util.notify('Deleting admin', 'loading');
this.util.notify('Memadam admin', 'loading');
var vm = this;
axios.delete(config.API+'admin/'+this.id)
.then(response=>{
axios.delete(config.API + 'admin/' + this.id)
.then(response => {
$.notifyClose();
if (vm.util.showResult(response, 'success'))
vm.refreshAdmin();
})
.catch(error=>{
.catch(error => {
$.notifyClose();
vm.util.showResult(error, 'error');
})
@@ -195,16 +182,16 @@ export default{
return;
}
vm.loading = true;
this.util.notify('Refreshing admin', 'loading');
axios.get(config.API+'admin')
.then(response=>{
this.util.notify('Mengemaskini admin', 'loading');
axios.get(config.API + 'admin')
.then(response => {
$.notifyClose();
if (response.data.status == 'failed')
vm.util.showResult(response, 'success');
else
vm.data.admins = response.data;
})
.catch(error=>{
.catch(error => {
$.notifyClose();
vm.util.showResult(error, 'error');
})
@@ -0,0 +1,146 @@
<template>
<div class="panel panel-default">
<div class="panel-body">
<div class="clearfix" style="margin-bottom: 12px;">
<h4 style="margin: 0; display: inline-block;">Ringkasan Elaun</h4>
<!-- <div class="pull-right" style="display:flex; gap:8px; align-items:center;">
<select v-model="methodFilter" class="form-control input-sm" style="width: 160px;">
<option value="">Semua kaedah</option>
<option value="cash">Tunai</option>
<option value="bank">Bank</option>
</select>
<button class="btn btn-default btn-sm" @click="refresh()">
<i class="fa fa-refresh"></i> Refresh
</button>
</div> -->
</div>
<div v-if="error" class="alert alert-danger">{{ error }}</div>
<admin-data-table :headers="headers" :items="items" :loading="loading" :show-pagination="false"
index-title="Bil." empty-text="Tiada rekod bayaran elaun" :exportable="true">
<template v-slot:item-admin_name="{ item }">
<div style="font-weight: 800;">{{ item.admin_name || '—' }}</div>
<div class="text-muted" style="font-size: 12px;">{{ item.admin_email || '' }}</div>
</template>
<template v-slot:item-total_amount_cents="{ item }">
<b>{{ formatCentsToRM(item.total_amount_cents) }}</b>
<div class="text-muted" style="font-size: 12px;">{{ item.payouts_count }} bayaran</div>
</template>
<template v-slot:item-cash_amount_cents="{ item }">
{{ formatCentsToRM(item.cash_amount_cents) }}
<div class="text-muted" style="font-size: 12px;">{{ item.cash_count }} tunai</div>
</template>
<template v-slot:item-bank_amount_cents="{ item }">
{{ formatCentsToRM(item.bank_amount_cents) }}
<div class="text-muted" style="font-size: 12px;">{{ item.bank_count }}</div>
</template>
</admin-data-table>
<div v-if="!loading && items.length" class="well well-sm" style="margin-top: 12px;">
<b>Jumlah keseluruhan:</b> {{ formatCentsToRM(grandTotalCents) }}
</div>
</div>
</div>
</template>
<script>
export default {
data: function () {
return {
loading: false,
error: '',
rows: [],
methodFilter: '',
headers: [
{
title: 'Admin',
key: 'admin_name',
sortable: true,
exportValue: function (item) {
var name = item && item.admin_name ? String(item.admin_name) : '—';
var email = item && item.admin_email ? String(item.admin_email) : '';
return email ? (name + ' (' + email + ')') : name;
}
},
{
title: 'Tunai (RM300.00)',
key: 'cash_amount_cents',
sortable: false,
exportValue: function (item) {
return item && item.cash_amount_cents != null ? (Number(item.cash_amount_cents) / 100).toFixed(2) : '';
}
},
{
title: 'Maya (RM150.00)',
key: 'bank_amount_cents',
sortable: false,
exportValue: function (item) {
return item && item.bank_amount_cents != null ? (Number(item.bank_amount_cents) / 100).toFixed(2) : '';
}
},
{
title: 'Jumlah (RM)',
key: 'total_amount_cents',
sortable: true,
exportValue: function (item) {
return item && item.total_amount_cents != null ? (Number(item.total_amount_cents) / 100).toFixed(2) : '';
}
},
],
};
},
created: function () {
this.refresh();
},
computed: {
items: function () {
return Array.isArray(this.rows) ? this.rows : [];
},
grandTotalCents: function () {
return this.items.reduce((sum, r) => sum + Number(r.total_amount_cents || 0), 0);
},
},
methods: {
refresh: function () {
this.loading = true;
this.error = '';
var params = {};
if (this.methodFilter) params.method = this.methodFilter;
axios.get(config.API + 'allowance/summary', { params: params })
.then((response) => {
if (!response || !response.data || response.data.status !== 'success') {
this.error = (response && response.data && response.data.message) ? response.data.message : 'Gagal memuatkan ringkasan.';
return;
}
this.rows = response.data.rows || [];
})
.catch((error) => {
this.error =
(error && error.response && error.response.data && error.response.data.message)
? error.response.data.message
: (error && error.message ? error.message : 'Gagal memuatkan ringkasan.');
if (error && error.response) this.util.showResult(error, 'error');
})
.finally(() => {
this.loading = false;
});
},
formatCentsToRM: function (cents) {
var n = Number(cents || 0) / 100;
return n.toLocaleString('en-MY', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
},
},
};
</script>
@@ -16,49 +16,73 @@
</div>
<div class="collapse navbar-collapse" id="myNavbar">
<ul class="nav navbar-nav">
<!-- {{ users }} -->
<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 Sesi' }" v-if="data.user.role == 1" tag="li">
<a href="#"><b>Sesi</b></a>
<ul class="nav navbar-nav admin-nav-main">
<router-link v-if="adminRole !== 3" :to="{ name: 'Admin Home' }" tag="li" exact>
<a href="#"><b>Laman Utama</b></a>
</router-link>
<!-- <router-link :to="{name: 'Manage Partylist'}" v-if="data.user.role==1" tag="li">
<a href="#">Manage Partylist</a>
</router-link> -->
<li class="dropdown" v-if="adminRole === 1">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button"
aria-haspopup="true" aria-expanded="false">
<b>Pengurusan</b> <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<router-link :to="{ name: 'Kemaskini Sesi' }" tag="li">
<a href="#">Sesi</a>
</router-link>
<router-link :to="{ name: 'Maklumat Calon' }" tag="li">
<a href="#">Calon</a>
</router-link>
<router-link :to="{ name: 'Manage Voter' }" tag="li" exact>
<a href="#">Anggota Koperasi</a>
</router-link>
</ul>
</li>
<router-link :to="{ name: 'Maklumat Calon' }" v-if="data.user.role == 1" tag="li">
<a href="#"><b>Calon</b></a>
</router-link>
<router-link :to="{ name: 'Manage Voter' }" v-if="data.user.role == 1" tag="li" exact>
<a href="#"><b>Anggota Koperasi</b></a>
</router-link>
<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>
<router-link :to="{ name: 'Pembiayaan' }" v-if="data.user.role == 1" tag="li">
<a href="#"><b>Pembiayaan Anggota</b></a>
</router-link>
<router-link :to="{ name: 'Penyata Anggota' }" v-if="data.user.role == 1" tag="li">
<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>
<router-link :to="{ name: 'Tetapan Portal' }" v-if="data.user.role == 1" tag="li">
<a href="#"><b>Tetapan Portal</b></a>
</router-link>
<li class="dropdown" v-if="adminRole === 1 || adminRole === 3">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button"
aria-haspopup="true" aria-expanded="false">
<b>Operasi</b> <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<router-link :to="{ name: 'Kehadiran Calon' }" tag="li">
<a href="#">Kehadiran</a>
</router-link>
</ul>
</li>
<li class="dropdown" v-if="adminRole === 1">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button"
aria-haspopup="true" aria-expanded="false">
<b>Kewangan</b> <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<router-link :to="{ name: 'Ringkasan Elaun' }" tag="li">
<a href="#">Ringkasan Elaun</a>
</router-link>
<router-link :to="{ name: 'Pembiayaan' }" tag="li">
<a href="#">Pembiayaan Anggota</a>
</router-link>
<router-link :to="{ name: 'Penyata Anggota' }" tag="li">
<a href="#">Penyata Anggota</a>
</router-link>
</ul>
</li>
<li class="dropdown" v-if="adminRole === 1">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button"
aria-haspopup="true" aria-expanded="false">
<b>Sistem</b> <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<router-link :to="{ name: 'Activity Log' }" tag="li">
<a href="#">Log Aktiviti</a>
</router-link>
<router-link :to="{ name: 'Tetapan Portal' }" tag="li">
<a href="#">Tetapan Portal</a>
</router-link>
</ul>
</li>
</ul>
<ul class="nav navbar-right navbar-nav">
<li class="dropdown">
@@ -144,6 +168,17 @@ export default {
}
},
computed: {
adminRole: function () {
// `data` is populated asynchronously; keep this defensive.
try {
return Number(this.data && this.data.user ? this.data.user.role : null);
} catch (e) {
return NaN;
}
}
},
methods: {
isImpersonating: function () {
try {
@@ -288,7 +323,7 @@ export default {
#nav .nav>li.active>a:hover,
#nav .nav>li.active>a:focus,
#nav .navbar-brand:hover {
color: #fff;
color: #ffffff;
background: rgba(204, 0, 0, 0.833);
}
@@ -379,6 +414,23 @@ export default {
margin: 4px 10px;
}
/* Mobile submenu styling: keep dropdowns readable inside collapsed menu */
#nav .navbar-nav .open .dropdown-menu {
position: static;
float: none;
width: auto;
margin: 0 10px 10px;
padding: 6px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 10px 24px rgba(35, 64, 64, 0.12);
}
#nav .navbar-nav .open .dropdown-menu>li>a {
padding: 10px 12px;
border-radius: 10px;
}
.admin-main {
padding-top: 102px;
}
@@ -229,6 +229,17 @@
</div>
</div>
<div v-if="isCashPaid(selectedVoter)" class="well well-sm" style="margin-top: 14px;">
<div style="font-weight: 800; margin-bottom: 8px;">Maklumat Bayaran</div>
<div>
Dibayar oleh:
<b>{{ selectedVoter.cash_paid_by_admin_name || '—' }}</b>
<span v-if="selectedVoter.cash_paid_by_admin_email" class="text-muted">
({{ selectedVoter.cash_paid_by_admin_email }})
</span>
</div>
</div>
<div class="alert alert-danger" v-if="detailError" style="margin-top: 12px;">
{{ detailError }}
</div>
@@ -468,6 +479,7 @@
<script>
export default {
data: function () {
var vm = this;
return {
id: 0,
voterLoading: false,
@@ -487,10 +499,40 @@ export default {
{ 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: 'Kehadiran',
key: 'kehadiran',
sortable: true,
exportValue: function (item) {
return vm.getKehadiranString(item ? item.kehadiran : null);
}
},
{
title: 'Pendaftaran',
key: 'fizikal_reg',
sortable: false,
exportValue: function (item) {
if (!item) return '';
if (Number(item.kehadiran) !== 1) return '—';
return item.fizikal_registration_verified_at ? 'Disahkan' : 'Menunggu';
}
},
{
title: 'Undi',
key: 'has_voted',
sortable: false,
exportValue: function (item) {
return Number(item && item.votes_count) > 0 ? 'Undi' : 'Belum';
}
},
{
title: 'Status',
key: 'allowance',
sortable: false,
exportValue: function (item) {
return vm.isCashPaid(item) ? 'Sudah Bayar' : 'Belum Bayar';
}
},
{ title: 'Tindakan', key: 'action', sortable: false },
]
}
@@ -723,6 +765,14 @@ export default {
var p = response.data.payout;
this.selectedVoter.cash_paid_at = p.paid_at;
this.selectedVoter.cash_payout_id = p.id;
this.selectedVoter.cash_paid_by_admin_name =
(response && response.data && response.data.paid_by_admin_name)
? response.data.paid_by_admin_name
: this.selectedVoter.cash_paid_by_admin_name;
this.selectedVoter.cash_paid_by_admin_email =
(response && response.data && response.data.paid_by_admin_email)
? response.data.paid_by_admin_email
: this.selectedVoter.cash_paid_by_admin_email;
if (p.reference != null) {
this.selectedVoter.cash_payout_reference = p.reference;
this.payoutReference = p.reference ? String(p.reference) : '';