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

This commit is contained in:
ISMAIL MASSERAN
2026-05-10 11:51:19 +08:00
parent 654a0c9fea
commit fd1baf34c8
47 changed files with 2680 additions and 932 deletions
+14 -1
View File
@@ -12,4 +12,17 @@
[x] hardcode on daftar kehadiran [x] hardcode on daftar kehadiran
[x] buang flow pengesahan [x] buang flow pengesahan
[x] tambah siapa yang bagi duit (activity log) [x] tambah siapa yang bagi duit (activity log)
[ ] there is certain part/files that is hardcoded [ ] there is certain part/files that is hardcoded
Penambahan Untuk Sistem Mengundi
[x] Pendaftaran kehadiran (fizikal/maya)
[x] elaun kehadiran untuk pendaftaran (kupon pendaftaran)
[x] buang pautan pantas yang tidak perlu
[x] roulette wheel
[x] online zoom (jika ada)
[ ] tambah checking bajet tally atau tak
[x] saiful adli tukar jadi fizikal
[x] tambah total yang kena bayar untuk (maya/fizikal)
[ ] tidak boleh hantar wakil untuk tebus elaun
[x] tambah statistik untuk bilangan mengundi di tab kehadiran
[x] guna data dummy (sekiranya ada)
@@ -37,10 +37,65 @@ class SummaryController extends Controller
$rows = $q->get(); $rows = $q->get();
$defaultCashCents = 30000;
$defaultBankCents = 15000;
$outstandingCash = (int) DB::table('voter as v')
->where('v.election_id', $electionId)
->where('v.kehadiran', 1)
->where(function ($outer) use ($electionId) {
$outer->whereExists(function ($sub) use ($electionId) {
$sub->select(DB::raw(1))
->from('result as r')
->whereColumn('r.voter_id', 'v.id')
->where('r.election_id', $electionId);
})->orWhere(function ($q) {
$q->whereNotNull('v.fizikal_registration_verified_at')
->whereRaw('COALESCE(v.saham, 0) < 500');
});
})
->whereNotExists(function ($sub) use ($electionId) {
$sub->select(DB::raw(1))
->from('allowance_payouts as ap')
->whereColumn('ap.voter_id', 'v.id')
->where('ap.election_id', $electionId)
->where('ap.method', 'cash')
->where('ap.status', 'paid');
})
->count();
$outstandingBank = (int) DB::table('voter as v')
->where('v.election_id', $electionId)
->where('v.kehadiran', 2)
->whereExists(function ($sub) use ($electionId) {
$sub->select(DB::raw(1))
->from('result as r')
->whereColumn('r.voter_id', 'v.id')
->where('r.election_id', $electionId);
})
->whereNotExists(function ($sub) use ($electionId) {
$sub->select(DB::raw(1))
->from('allowance_payouts as ap')
->whereColumn('ap.voter_id', 'v.id')
->where('ap.election_id', $electionId)
->where('ap.method', 'bank')
->where('ap.status', 'paid');
})
->count();
$outstandingAllowanceCents = ($outstandingCash * $defaultCashCents) + ($outstandingBank * $defaultBankCents);
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
'election_id' => $electionId, 'election_id' => $electionId,
'rows' => $rows, 'rows' => $rows,
'outstanding_allowance' => [
'cents' => $outstandingAllowanceCents,
'cash_count' => $outstandingCash,
'bank_count' => $outstandingBank,
'default_cash_cents' => $defaultCashCents,
'default_bank_cents' => $defaultBankCents,
],
]); ]);
} }
} }
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\API\v1\Election;
use App\Http\Controllers\Controller;
use App\Voter;
use Illuminate\Support\Facades\DB;
class AttendanceSummaryController extends Controller
{
/**
* Aggregate attendance for a specific election (by primary key), for admin reporting.
* Kehadiran: null/0 = belum ditetapkan, 1 = fizikal, 2 = maya.
*/
public function __invoke($id)
{
$electionId = (int) $id;
$row = Voter::query()
->where('election_id', $electionId)
->selectRaw('COUNT(*) as total_registered')
->selectRaw('COALESCE(SUM(CASE WHEN kehadiran IS NULL OR kehadiran = 0 THEN 1 ELSE 0 END), 0) as unset')
->selectRaw('COALESCE(SUM(CASE WHEN kehadiran = 1 THEN 1 ELSE 0 END), 0) as fizikal')
->selectRaw('COALESCE(SUM(CASE WHEN kehadiran = 2 THEN 1 ELSE 0 END), 0) as maya')
->selectRaw('COALESCE(SUM(CASE WHEN kehadiran IN (1, 2) THEN 1 ELSE 0 END), 0) as marked_present')
->selectRaw('COALESCE(SUM(CASE WHEN kehadiran = 1 AND fizikal_registration_verified_at IS NULL THEN 1 ELSE 0 END), 0) as fizikal_reg_pending')
->selectRaw('COALESCE(SUM(CASE WHEN kehadiran = 1 AND fizikal_registration_verified_at IS NOT NULL THEN 1 ELSE 0 END), 0) as fizikal_reg_verified')
->first();
$total = (int) ($row ? $row->total_registered : 0);
$marked = (int) ($row ? $row->marked_present : 0);
$votersWhoVoted = (int) DB::table('result')
->where('election_id', $electionId)
->select(DB::raw('COUNT(DISTINCT voter_id) as c'))
->value('c');
return response()->json([
'election_id' => $electionId,
'total_registered' => $total,
'unset' => (int) ($row ? $row->unset : 0),
'fizikal' => (int) ($row ? $row->fizikal : 0),
'maya' => (int) ($row ? $row->maya : 0),
'marked_present' => $marked,
'attendance_rate' => $total > 0 ? round(($marked / $total) * 100, 2) : 0.0,
'fizikal_registration_pending' => (int) ($row ? $row->fizikal_reg_pending : 0),
'fizikal_registration_verified' => (int) ($row ? $row->fizikal_reg_verified : 0),
'voters_who_voted' => $votersWhoVoted,
]);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\API\v1\Election;
use App\Election;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class RegisterNextSessionController extends Controller
{
public function __invoke(Request $request)
{
$this->validateRequest($request);
if (Util::getElectionStatus() !== 3) {
return response()->json([
'status' => 'failed',
'message' => 'Daftar sesi baharu hanya tersedia selepas undian telah tamat.',
], 422);
}
Util::ensureDraftElectionAfterCompletedCycle();
$election = Election::find(Util::getCurrentElection());
if ($request->filled('name')) {
$election->update(['name' => $request->name]);
}
$user = $request->user();
activity()
->withProperties([
'election_id' => (int) $election->id,
'election_name' => (string) $request->input('name', ''),
'registered_by_admin_id' => $user ? (int) $user->id : null,
'registered_by_admin_email' => $user ? $user->email : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('election register next session');
return response()->json([
'status' => 'success',
'message' => 'Sesi mengundi baharu didaftarkan. Sila lengkapkan maklumat calon sebelum memulakan undian.',
'election' => $election->fresh(),
]);
}
private function validateRequest($request)
{
$id = Auth::id();
$this->validate($request, [
'password' => 'required|password:users,password,' . $id,
'name' => 'nullable|string|max:60',
]);
}
}
@@ -14,6 +14,8 @@ class StartController extends Controller
{ {
$this->validateRequest($request); $this->validateRequest($request);
Util::ensureDraftElectionAfterCompletedCycle();
if ($this->hasNoNominee()) if ($this->hasNoNominee())
return response()->json([ return response()->json([
'status' => 'failed', 'status' => 'failed',
@@ -48,8 +48,7 @@ class StopController extends Controller
'status'=>3, 'status'=>3,
'name'=>$request->name, 'name'=>$request->name,
'end'=>date('Y-m-d H:i:s') 'end'=>date('Y-m-d H:i:s')
]); ]);
Election::create();
} }
private function validateRequest($request) private function validateRequest($request)
@@ -13,6 +13,7 @@ class AddController extends Controller
public function __invoke (Request $request) public function __invoke (Request $request)
{ {
Util::ensureDraftElectionAfterCompletedCycle();
$this->validateRequest($request); $this->validateRequest($request);
$nominee = $this->insertNominee($request); $nominee = $this->insertNominee($request);
@@ -12,6 +12,7 @@ class AddController extends Controller
{ {
public function __invoke(Request $request) public function __invoke(Request $request)
{ {
Util::ensureDraftElectionAfterCompletedCycle();
$this->validateRequest($request); $this->validateRequest($request);
$this->insertPartylist($request); $this->insertPartylist($request);
@@ -6,13 +6,13 @@ use Illuminate\Http\Request;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Util; use App\Http\Controllers\Util;
use App\Election;
use App\Position; use App\Position;
class AddPositionController extends Controller class AddPositionController extends Controller
{ {
public function __invoke(Request $request) public function __invoke(Request $request)
{ {
Util::ensureDraftElectionAfterCompletedCycle();
$this->validateRequest($request); $this->validateRequest($request);
$this->insertPosition($request); $this->insertPosition($request);
@@ -12,6 +12,7 @@ class AddController extends Controller
{ {
public function __invoke(Request $request) public function __invoke(Request $request)
{ {
Util::ensureDraftElectionAfterCompletedCycle();
$this->validateRequest($request); $this->validateRequest($request);
$voter = $this->insertVoter($request); $voter = $this->insertVoter($request);
@@ -113,6 +113,47 @@ class GetController extends Controller
} }
} }
// Vote status: 1 = has voted, 0 = has not voted (for this election)
$hasVoted = request('has_voted');
if ($hasVoted !== null && $hasVoted !== '') {
$hv = (string) $hasVoted;
if ($hv === '1' || $hv === 'true' || $hv === 'yes') {
$q->whereExists(function ($sub) use ($electionId) {
$sub->from('result')
->whereColumn('result.voter_id', 'voter.id')
->where('result.election_id', $electionId);
});
} elseif ($hv === '0' || $hv === 'false' || $hv === 'no') {
$q->whereNotExists(function ($sub) use ($electionId) {
$sub->from('result')
->whereColumn('result.voter_id', 'voter.id')
->where('result.election_id', $electionId);
});
}
}
// Cash allowance payment status (matches admin "Status" column: cash_paid_at)
$cashStatus = request('cash_status');
if ($cashStatus !== null && $cashStatus !== '') {
if ($cashStatus === 'paid') {
$q->whereExists(function ($sub) use ($electionId) {
$sub->from('allowance_payouts')
->whereColumn('allowance_payouts.voter_id', 'voter.id')
->where('allowance_payouts.election_id', $electionId)
->where('allowance_payouts.method', 'cash')
->where('allowance_payouts.status', 'paid');
});
} elseif ($cashStatus === 'unpaid') {
$q->whereNotExists(function ($sub) use ($electionId) {
$sub->from('allowance_payouts')
->whereColumn('allowance_payouts.voter_id', 'voter.id')
->where('allowance_payouts.election_id', $electionId)
->where('allowance_payouts.method', 'cash')
->where('allowance_payouts.status', 'paid');
});
}
}
// Return all voters with pagination // Return all voters with pagination
return $q->paginate(1000); return $q->paginate(1000);
} }
@@ -21,6 +21,7 @@ class SyncApplicableVotersController extends Controller
'selected_no_kp.*' => 'nullable|string|max:60', 'selected_no_kp.*' => 'nullable|string|max:60',
]); ]);
Util::ensureDraftElectionAfterCompletedCycle();
$electionId = Util::getCurrentElection(); $electionId = Util::getCurrentElection();
$selected = collect($request->input('selected_no_kp', [])) $selected = collect($request->input('selected_no_kp', []))
->map(function ($v) { ->map(function ($v) {
+13
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Election;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -37,6 +38,18 @@ class Util extends Controller
return \App\Election::find(Util::getCurrentElection())->status; return \App\Election::find(Util::getCurrentElection())->status;
} }
/**
* When the latest election has finished (status 3), the next setup actions
* must attach to a new draft row. Call before writes that bind to getCurrentElection().
*/
public static function ensureDraftElectionAfterCompletedCycle(): void
{
if (self::getElectionStatus() !== 3) {
return;
}
Election::create();
}
/** /**
* Upload image and return it's path * Upload image and return it's path
* @param {Request} $request * @param {Request} $request
+19
View File
@@ -8,6 +8,7 @@
"@bachdgvn/vue-otp-input": "^1.0.8", "@bachdgvn/vue-otp-input": "^1.0.8",
"axios": "^1.13.2", "axios": "^1.13.2",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"chart.js": "^4.5.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jsbarcode": "^3.12.3", "jsbarcode": "^3.12.3",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
@@ -2055,6 +2056,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@leichtgewicht/ip-codec": { "node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
@@ -3946,6 +3953,18 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz",
+2 -1
View File
@@ -22,10 +22,11 @@
"@bachdgvn/vue-otp-input": "^1.0.8", "@bachdgvn/vue-otp-input": "^1.0.8",
"axios": "^1.13.2", "axios": "^1.13.2",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"chart.js": "^4.5.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jsbarcode": "^3.12.3", "jsbarcode": "^3.12.3",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
} }
} }

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 KiB

+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -1,3 +1,17 @@
/*!
* @kurkle/color v0.3.4
* https://github.com/kurkle/color#readme
* (c) 2024 Jukka Kurkela
* Released under the MIT License
*/
/*!
* Chart.js v4.5.1
* https://www.chartjs.org
* (c) 2025 Chart.js Contributors
* Released under the MIT License
*/
/*! /*!
* The buffer module from node.js, for the browser. * The buffer module from node.js, for the browser.
* *
+6 -1
View File
@@ -1,8 +1,9 @@
require('./bootstrap.js'); require('./bootstrap.js');
import './styles/theme.css';
require('./helper/autocloseMenu.js'); require('./helper/autocloseMenu.js');
//window.VueRouter = require('vue-router').default; //window.VueRouter = require('vue-router').default;
//import VueRouter from 'vue-router'; //import VueRouter from 'vue-router';
import Util from './util.js'; import Util, { methods as utilMethods } from './util.js';
import routes from './routesIndex.js'; import routes from './routesIndex.js';
import OtpInput from "@bachdgvn/vue-otp-input"; import OtpInput from "@bachdgvn/vue-otp-input";
@@ -19,6 +20,10 @@ const router = new VueRouter({
}); });
router.beforeEach(function (to, from, next) { router.beforeEach(function (to, from, next) {
if (to.name === 'Voter Login' && utilMethods.isAdminPortalSession()) {
var dest = utilMethods.getAdminEntryRoute();
return next(Object.assign({}, dest, { replace: true }));
}
if (!to.path.startsWith('/admin') || to.name === 'Admin Login') { if (!to.path.startsWith('/admin') || to.name === 'Admin Login') {
return next(); return next();
} }
@@ -462,19 +462,20 @@ export default {
</script> </script>
<style scoped> <style scoped>
.admin-data-table__toolbar { .admin-data-table__toolbar {
margin-bottom: 12px; margin-bottom: 12px;
} }
.admin-data-table { .admin-data-table {
--adt-header-bg: #1976d2; --adt-header-bg: var(--admin-table-header-bg);
--adt-header-color: #fff; --adt-header-color: var(--admin-table-header-color);
--adt-radius: 12px; --adt-radius: 12px;
--adt-border: 1px solid #e0e0e0; --adt-border: 1px solid var(--admin-table-border-color);
--adt-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); --adt-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--adt-row-hover: #e3f2fd; --adt-row-hover: var(--admin-table-row-hover-bg);
--adt-footer-bg: #fafafa; --adt-footer-bg: var(--admin-table-footer-bg);
--adt-text-muted: #757575; --adt-text-muted: var(--admin-table-text-muted);
--adt-font-size: 16px; --adt-font-size: 16px;
--adt-header-font-size: 16px; --adt-header-font-size: 16px;
} }
@@ -484,7 +485,7 @@ export default {
border: var(--adt-border); border: var(--adt-border);
box-shadow: var(--adt-shadow); box-shadow: var(--adt-shadow);
overflow: hidden; overflow: hidden;
background: #fff; background: var(--admin-table-shell-bg);
} }
.admin-data-table__scroll { .admin-data-table__scroll {
@@ -582,12 +583,12 @@ export default {
.admin-data-table__table tbody td { .admin-data-table__table tbody td {
padding: 12px 16px; padding: 12px 16px;
vertical-align: middle; vertical-align: middle;
border-top: 1px solid #eee; border-top: 1px solid var(--admin-table-row-border-color);
font-size: var(--adt-font-size); font-size: var(--adt-font-size);
} }
.admin-data-table__table tbody tr:first-child:not(.admin-data-table__state-row) td { .admin-data-table__table tbody tr:first-child:not(.admin-data-table__state-row) td {
border-top: 1px solid #e0e0e0; border-top: 1px solid var(--admin-table-border-color);
} }
.admin-data-table__table tbody tr.admin-data-table__state-row td { .admin-data-table__table tbody tr.admin-data-table__state-row td {
@@ -617,7 +618,7 @@ export default {
.admin-data-table__state-title { .admin-data-table__state-title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #424242; color: var(--admin-table-text-strong);
margin-bottom: 4px; margin-bottom: 4px;
} }
@@ -663,7 +664,7 @@ export default {
.admin-data-table__footer-mid { .admin-data-table__footer-mid {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: #616161; color: var(--admin-table-footer-mid-text);
} }
.admin-data-table__footer-right { .admin-data-table__footer-right {
@@ -681,7 +682,7 @@ export default {
} }
.admin-data-table__pagination>li.disabled>a { .admin-data-table__pagination>li.disabled>a {
color: #bbb; color: var(--admin-table-pagination-disabled);
pointer-events: none; pointer-events: none;
cursor: default; cursor: default;
} }
@@ -0,0 +1,113 @@
<template>
<div class="bar-chart" :style="wrapperStyle">
<canvas ref="canvas"></canvas>
</div>
</template>
<script>
import Chart from 'chart.js/auto';
export default {
name: 'BarChart',
props: {
labels: { type: Array, default: () => [] },
values: { type: Array, default: () => [] },
datasetLabel: { type: String, default: 'Undi' },
height: { type: [Number, String], default: 220 },
backgroundColor: { type: [String, Array], default: 'rgba(54, 162, 235, 0.75)' },
borderColor: { type: [String, Array], default: 'rgba(54, 162, 235, 1)' },
borderWidth: { type: Number, default: 0 },
borderRadius: { type: [Number, Array], default: 0 },
options: { type: Object, default: () => ({}) }
},
data: () => ({
chart: null
}),
computed: {
wrapperStyle: function () {
var h = this.height;
var px = (typeof h === 'number') ? (h + 'px') : String(h);
return { height: px };
}
},
watch: {
labels: function () { this.renderChart(); },
values: function () { this.renderChart(); },
options: {
handler: function () { this.renderChart(); },
deep: true
},
backgroundColor: function () { this.renderChart(); },
borderColor: function () { this.renderChart(); },
borderWidth: function () { this.renderChart(); },
borderRadius: function () { this.renderChart(); }
},
mounted: function () {
this.renderChart();
},
beforeDestroy: function () {
this.destroyChart();
},
methods: {
destroyChart: function () {
if (!this.chart) return;
try { this.chart.destroy(); } catch (e) { }
this.chart = null;
},
renderChart: function () {
this.destroyChart();
if (!this.$refs || !this.$refs.canvas) return;
var ctx = this.$refs.canvas.getContext('2d');
var labels = (this.labels || []).map(function (x) { return String(x); });
var values = (this.values || []).map(function (v) { return Number(v) || 0; });
var baseOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { enabled: true }
},
scales: {
x: { ticks: { maxRotation: 45, minRotation: 45, autoSkip: false } },
y: { beginAtZero: true, ticks: { precision: 0 } }
}
};
// Shallow merge is enough for our use-cases (caller can override nested keys).
var mergedOptions = Object.assign({}, baseOptions, this.options || {});
this.chart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: this.datasetLabel,
data: values,
backgroundColor: this.backgroundColor,
borderColor: this.borderColor,
borderWidth: this.borderWidth,
borderRadius: this.borderRadius
}]
},
options: mergedOptions
});
}
}
};
</script>
<style scoped>
.bar-chart {
width: 100%;
}
</style>
@@ -0,0 +1,238 @@
<template>
<nav class="navbar navbar-default navbar-fixed-top floating-header logo-header" id="nav">
<div class="container">
<div class="navbar-header logo-section">
<button type="button" class="navbar-toggle" data-toggle="collapse" :data-target="'#' + collapseId">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<Logo :href="logoHref" />
</div>
<div class="collapse navbar-collapse" :id="collapseId">
<ul class="nav navbar-nav" :class="mainNavClass">
<slot name="left" />
</ul>
<ul class="nav navbar-right navbar-nav">
<slot name="right" />
</ul>
</div>
</div>
</nav>
</template>
<script>
import Logo from './Logo.vue';
export default {
name: 'FloatingNavbar',
components: { Logo },
props: {
logoHref: { type: String, required: true },
collapseId: { type: String, default: 'myNavbar' },
mainNavClass: { type: [String, Object, Array], default: null }
}
};
</script>
<style>
/* Layout & structure — color tokens from theme.css (:root), loaded in app.js */
.floating-header {
position: fixed !important;
top: 16px !important;
left: 16px !important;
right: 16px !important;
width: calc(100% - 32px) !important;
margin: 0;
border-radius: 16px !important;
z-index: 2000 !important;
transition: all 0.3s ease !important;
}
.logo-header {
background: linear-gradient(
135deg,
var(--floating-header-bg-1) 0%,
var(--floating-header-bg-2) 38%,
var(--floating-header-bg-3) 72%,
var(--floating-header-bg-4) 100%
) !important;
border: 1px solid var(--floating-header-border) !important;
box-shadow:
0 8px 32px var(--floating-header-shadow-1),
0 4px 16px var(--floating-header-shadow-2) !important;
backdrop-filter: blur(12px) !important;
position: fixed;
overflow: visible;
}
.logo-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
135deg,
var(--floating-header-overlay-1) 0%,
transparent 50%,
var(--floating-header-overlay-2) 100%
);
pointer-events: none;
z-index: 0;
}
.logo-header > * {
position: relative;
z-index: 1;
}
#nav.navbar {
border: none;
min-height: 78px;
}
#nav .container {
width: 100%;
padding-left: 22px;
padding-right: 22px;
overflow: visible;
}
#nav .navbar-nav {
margin-left: 10px;
}
#nav .navbar-brand {
display: flex;
align-items: center;
gap: 12px;
height: auto;
padding: 14px 0;
color: var(--floating-header-brand-text);
font-weight: 700;
text-shadow: 0 1px 2px var(--floating-header-brand-shadow);
}
.logo-title {
display: inline-block;
color: var(--floating-header-brand-text) !important;
font-size: 1.8rem;
font-weight: 700;
text-shadow: 0 1px 2px var(--floating-header-brand-shadow);
}
#nav .nav > li > a,
#nav .navbar-toggle {
color: var(--floating-header-nav-text);
font-weight: 700;
letter-spacing: 0.02em;
}
#nav .nav > li > a:hover,
#nav .nav > li.active > a,
#nav .nav > li.active > a:hover,
#nav .nav > li.active > a:focus,
#nav .navbar-brand:hover {
color: var(--floating-header-brand-text);
background: var(--floating-header-hover-bg);
}
#nav .nav > li > a {
border-radius: 10px;
margin-top: 18px;
margin-bottom: 18px;
padding-top: 12px;
padding-bottom: 12px;
}
#nav .navbar-right {
margin-right: 8px;
}
#nav .navbar-nav > li,
#nav .dropdown {
position: relative;
}
#nav .dropdown-menu {
margin-top: 6px;
border: 0;
border-radius: 12px;
box-shadow: 0 12px 28px var(--floating-header-dropdown-menu-shadow);
z-index: 2101;
}
#nav .dropdown-menu > li > a {
color: var(--admin-table-text-strong);
}
#nav .dropdown-menu > li > a:hover {
background-color: var(--floating-header-dropdown-hover-bg);
color: var(--floating-header-dropdown-hover-text);
}
#nav .navbar-toggle {
margin-top: 20px;
margin-right: 18px;
border-color: var(--floating-header-toggle-border);
background: var(--floating-header-toggle-bg);
}
#nav .navbar-toggle .icon-bar {
background-color: var(--floating-header-icon-bar);
}
.navbar-brand img {
background: transparent !important;
box-shadow: none;
}
@media (max-width: 767px) {
.floating-header {
top: 10px !important;
left: 10px !important;
right: 10px !important;
width: calc(100% - 20px) !important;
border-radius: 14px !important;
}
.logo-title {
font-size: 1.4rem;
}
#nav .navbar-brand img {
width: 120px;
}
#nav .navbar-collapse {
border-top: 0;
box-shadow: none;
padding-bottom: 12px;
overflow: visible;
}
#nav .nav > li > a {
margin: 4px 10px;
}
#nav .navbar-nav .open .dropdown-menu {
position: static;
float: none;
width: auto;
margin: 0 10px 10px;
padding: 6px;
border-radius: 12px;
background: var(--floating-header-mobile-submenu-bg);
box-shadow: 0 10px 24px var(--floating-header-mobile-submenu-shadow);
}
#nav .navbar-nav .open .dropdown-menu > li > a {
padding: 10px 12px;
border-radius: 10px;
}
}
</style>
@@ -0,0 +1,27 @@
<template>
<a class="navbar-brand" :href="href">
<img class="app-logo" :src="src" :alt="alt" :width="width" :height="height" />
</a>
</template>
<script>
export default {
name: 'Logo',
props: {
href: { type: String, default: '/' },
src: { type: String, default: '/images/logo-kopkb.png' },
alt: { type: String, default: 'MyKoPKB Logo' },
width: { type: [Number, String], default: 150 },
height: { type: [Number, String], default: 44 },
},
};
</script>
<style>
.app-logo {
display: block;
width: 100px;
height: 55px;
object-fit: contain;
}
</style>
@@ -0,0 +1,125 @@
<template>
<div class="pie-chart" :style="wrapperStyle">
<canvas ref="canvas"></canvas>
</div>
</template>
<script>
import Chart from 'chart.js/auto';
export default {
name: 'PieChart',
props: {
labels: { type: Array, default: () => [] },
values: { type: Array, default: () => [] },
datasetLabel: { type: String, default: '' },
height: { type: [Number, String], default: 240 },
colors: { type: Array, default: () => [] },
/** When true, renders a doughnut (ring) chart; `cutout` is applied. */
doughnut: { type: Boolean, default: false },
cutout: { type: [String, Number], default: '58%' },
options: { type: Object, default: () => ({}) }
},
data: () => ({
chart: null
}),
computed: {
wrapperStyle: function () {
var h = this.height;
var px = (typeof h === 'number') ? (h + 'px') : String(h);
return { height: px };
},
resolvedColors: function () {
var base = this.colors || [];
// Fallback palette (Chart.js-ish defaults) if caller doesn't provide colors.
var palette = [
'#4dc9f6', '#f67019', '#f53794', '#537bc4', '#acc236',
'#166a8f', '#00a950', '#58595b', '#8549ba',
];
var out = [];
var n = Math.max((this.labels || []).length, (this.values || []).length);
for (var i = 0; i < n; i++) {
out.push(base[i] || palette[i % palette.length]);
}
return out;
}
},
watch: {
labels: function () { this.renderChart(); },
values: function () { this.renderChart(); },
colors: function () { this.renderChart(); },
doughnut: function () { this.renderChart(); },
cutout: function () { this.renderChart(); },
options: {
handler: function () { this.renderChart(); },
deep: true
}
},
mounted: function () {
this.renderChart();
},
beforeDestroy: function () {
this.destroyChart();
},
methods: {
destroyChart: function () {
if (!this.chart) return;
try { this.chart.destroy(); } catch (e) { }
this.chart = null;
},
renderChart: function () {
this.destroyChart();
if (!this.$refs || !this.$refs.canvas) return;
var ctx = this.$refs.canvas.getContext('2d');
var labels = (this.labels || []).map(function (x) { return String(x); });
var values = (this.values || []).map(function (v) { return Number(v) || 0; });
var baseOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' },
tooltip: { enabled: true }
}
};
if (this.doughnut) {
baseOptions.cutout = this.cutout;
}
var mergedOptions = Object.assign({}, baseOptions, this.options || {});
this.chart = new Chart(ctx, {
type: this.doughnut ? 'doughnut' : 'pie',
data: {
labels: labels,
datasets: [{
label: this.datasetLabel,
data: values,
backgroundColor: this.resolvedColors,
borderColor: '#ffffff',
borderWidth: 1
}]
},
options: mergedOptions
});
}
}
};
</script>
<style scoped>
.pie-chart {
width: 100%;
}
</style>
@@ -72,15 +72,15 @@
<div class="modal fade app-modal roulette-winner-modal" id="roulette-winner-modal" tabindex="-1" role="dialog" <div class="modal fade app-modal roulette-winner-modal" id="roulette-winner-modal" tabindex="-1" role="dialog"
aria-labelledby="roulette-winner-title" aria-hidden="true"> aria-labelledby="roulette-winner-title" aria-hidden="true">
<div class="modal-dialog" role="document"> <div class="modal-dialog modal-lg roulette-winner-modal__dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Tutup"> <button type="button" class="close" data-dismiss="modal" aria-label="Tutup">
<span aria-hidden="true">&times;</span> <span aria-hidden="true">&times;</span>
</button> </button>
<h4 class="modal-title" id="roulette-winner-title"> <h3 class="modal-title roulette-winner-modal__title" id="roulette-winner-title">
Tahniah Tahniah
</h4> </h3>
</div> </div>
<div class="modal-body text-center" v-if="modalWinnerDisplay"> <div class="modal-body text-center" v-if="modalWinnerDisplay">
<p class="roulette-winner-modal-round text-muted"> <p class="roulette-winner-modal-round text-muted">
@@ -1273,21 +1273,84 @@ export default {
font-size: 13px; font-size: 13px;
} }
.roulette-winner-modal .modal-title { /* Hall / projection: wide dialog, very large type */
.roulette-winner-modal__dialog {
width: 96%;
max-width: min(96vw, 1320px);
margin: 12px auto;
}
@media (min-width: 768px) {
.roulette-winner-modal__dialog {
width: 94%;
max-width: min(94vw, 1400px);
}
}
@media (min-width: 1200px) {
.roulette-winner-modal__dialog {
max-width: 1440px;
}
}
.roulette-winner-modal .modal-content {
border-radius: 16px;
overflow: hidden;
border-width: 2px;
}
.roulette-winner-modal .modal-header {
padding: clamp(20px, 3vw, 36px) clamp(18px, 3vw, 40px);
position: relative;
}
.roulette-winner-modal .modal-header .close {
font-size: clamp(2rem, 4vw, 3.25rem);
font-weight: 300;
line-height: 1;
padding: 8px 12px;
margin-top: -4px;
opacity: 0.55;
}
.roulette-winner-modal .modal-body {
padding: clamp(32px, 5vw, 72px) clamp(20px, 4vw, 48px);
}
.roulette-winner-modal .modal-footer {
padding: clamp(16px, 2.5vw, 28px) clamp(18px, 3vw, 40px);
}
.roulette-winner-modal .modal-footer .btn {
font-size: clamp(1.15rem, 2.2vw, 1.65rem);
font-weight: 700; font-weight: 700;
padding: clamp(10px, 1.5vw, 16px) clamp(18px, 2.5vw, 28px);
}
.roulette-winner-modal__title {
font-weight: 800;
font-size: clamp(2.25rem, 5.5vw, 5rem);
line-height: 1.15;
margin: 0;
padding-right: 2.5rem;
} }
.roulette-winner-modal-round { .roulette-winner-modal-round {
font-size: 14px; font-size: clamp(1.5rem, 3.5vw, 3.25rem);
margin-bottom: 8px; margin-bottom: clamp(16px, 2.5vw, 28px);
font-weight: 700;
/* Stronger than .text-muted for projection / rear seats */
color: #4b5563 !important;
} }
.roulette-winner-modal-name { .roulette-winner-modal-name {
font-size: 1.75rem; font-size: clamp(3rem, 12vw, 8.5rem);
font-weight: 700; font-weight: 800;
line-height: 1.3; line-height: 1.12;
margin: 0; margin: 0;
word-break: break-word; word-break: break-word;
letter-spacing: -0.03em;
text-shadow: 0 2px 0 rgba(0, 0, 0, 0.06);
} }
.roulette-winner-absent-hint { .roulette-winner-absent-hint {
@@ -2,7 +2,7 @@
<div class="roulette-demo-page container-fluid"> <div class="roulette-demo-page container-fluid">
<header class="roulette-page-header"> <header class="roulette-page-header">
<div class="roulette-page-header-inner"> <div class="roulette-page-header-inner">
<img src="/images/MyKoPKB-logo.png" alt="MyKoPKB" class="roulette-page-header-logo" /> <Logo :href="'/admin/'" />
<h1 class="roulette-demo-title">Sesi Cabutan Bertuah Kehadiran Fizikal</h1> <h1 class="roulette-demo-title">Sesi Cabutan Bertuah Kehadiran Fizikal</h1>
<button type="button" class="btn btn-default btn-sm roulette-bg-music-btn" <button type="button" class="btn btn-default btn-sm roulette-bg-music-btn"
:aria-pressed="bgMusicPlaying ? 'true' : 'false'" :aria-pressed="bgMusicPlaying ? 'true' : 'false'"
@@ -81,6 +81,7 @@
<script> <script>
import RouletteEliminationWheel from './RouletteEliminationWheel.vue'; import RouletteEliminationWheel from './RouletteEliminationWheel.vue';
import Logo from '../common/Logo.vue';
function buildLabelsFromVoters(voters) { function buildLabelsFromVoters(voters) {
if (!voters || !voters.length) { if (!voters || !voters.length) {
@@ -134,6 +135,7 @@ export default {
components: { components: {
RouletteEliminationWheel, RouletteEliminationWheel,
Logo,
}, },
data: function () { data: function () {
@@ -1,40 +1,43 @@
<template> <template>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-body"> <div class="panel-body">
<h4>Kemaskini Akaun</h4> <h4>Kemaskini Akaun</h4>
<form @submit.prevent="edit()" id="edit_form"> <form @submit.prevent="edit()" id="edit_form">
<div class="form-group"> <div class="form-group">
<label for="name">Nama</label> <label for="name">Nama</label>
<input type="text" name="name" class="form-control" :value="data.user.name" required/> <input type="text" name="name" class="form-control" :value="data.user.name" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="email">E-mail</label> <label for="email">Emel</label>
<input type="email" name="email" class="form-control" :value="data.user.email" required/> <input type="email" name="email" class="form-control" :value="data.user.email" required
</div> pattern="[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" title="Emel tidak sah" />
<small class="text-muted">Contoh: admin@example.com</small>
</div>
<div class="form-group"> <div class="form-group">
<input type="submit" value="Submit" class="btn btn-info"/> <input type="submit" value="Hantar" class="btn btn-info" />
<router-link :to="{name: 'Update Password'}" class="btn btn-default">Tukar Kata Laluan</router-link> <router-link :to="{ name: 'Update Password' }" class="btn btn-default">Tukar Kata
</div> Laluan</router-link>
</form> </div>
</form>
</div>
</div> </div>
</div>
</template> </template>
<script> <script>
export default{ export default {
data: function () { data: function () {
return { return {
loading: false loading: false
} }
}, },
methods:{ methods: {
refreshUser: function () { refreshUser: function () {
var vm = this; var vm = this;
axios.get(config.API+'admin/'+this.data.user.id) axios.get(config.API + 'admin/' + this.data.user.id)
.then(response=>{ .then(response => {
vm.data.user = response.data; vm.data.user = response.data;
}) })
}, },
@@ -44,19 +47,19 @@ export default{
this.loading = true; this.loading = true;
var vm = this; var vm = this;
this.util.notify('Updating account', 'loading'); this.util.notify('Updating account', 'loading');
axios.put(config.API+'admin/'+this.data.user.id, $('#edit_form').serialize()) axios.put(config.API + 'admin/' + this.data.user.id, $('#edit_form').serialize())
.then(response=>{ .then(response => {
$.notifyClose(); $.notifyClose();
vm.loading = false; vm.loading = false;
if (vm.util.showResult(response, 'success')) { if (vm.util.showResult(response, 'success')) {
vm.refreshUser(); vm.refreshUser();
vm.$router.push({name:'Admin Home'}); vm.$router.push({ name: 'Admin Home' });
} }
}) })
.catch(error=>{ .catch(error => {
$.notifyClose(); $.notifyClose();
vm.loading = false; vm.loading = false;
vm.util.showResult(error.response, 'error'); vm.util.showResult(error.response, 'error');
}) })
} }
} }
@@ -17,32 +17,66 @@
<div v-if="error" class="alert alert-danger">{{ error }}</div> <div v-if="error" class="alert alert-danger">{{ error }}</div>
<admin-data-table :headers="headers" :items="items" :loading="loading" :show-pagination="false" <div v-if="!error" class="allowance-section">
index-title="Bil." empty-text="Tiada rekod bayaran elaun" :exportable="true"> <h5 class="allowance-section__title">Anggaran elaun yang perlu dibayar</h5>
<template v-slot:item-admin_name="{ item }"> <div v-if="!loading" class="row allowance-stat-row">
<div style="font-weight: 800;">{{ item.admin_name || '—' }}</div> <div class="col-sm-4">
<div class="text-muted" style="font-size: 12px;">{{ item.admin_email || '' }}</div> <div class="allowance-stat-card">
</template> <div class="allowance-stat-card__label">Fizikal (tunai RM300.00)</div>
<div class="allowance-stat-card__value">RM {{ formatCentsToRM(outstandingFizikalCents) }}
</div>
<div class="allowance-stat-card__hint">
<b>{{ outstandingCashCount }}</b> belum dibayar
</div>
</div>
</div>
<div class="col-sm-4">
<div class="allowance-stat-card">
<div class="allowance-stat-card__label">Maya (RM150.00)</div>
<div class="allowance-stat-card__value">RM {{ formatCentsToRM(outstandingMayaCents) }}</div>
<div class="allowance-stat-card__hint">
<b>{{ outstandingBankCount }}</b> belum dibayar
</div>
</div>
</div>
<div class="col-sm-4">
<div class="allowance-stat-card allowance-stat-card--primary">
<div class="allowance-stat-card__label">Jumlah baki</div>
<div class="allowance-stat-card__value">RM {{ formatCentsToRM(outstandingCents) }}</div>
<div class="allowance-stat-card__hint">Fizikal + Maya</div>
</div>
</div>
</div>
</div>
<template v-slot:item-total_amount_cents="{ item }"> <div v-if="!error" class="allowance-section allowance-section--table">
<b>{{ formatCentsToRM(item.total_amount_cents) }}</b> <h5 class="allowance-section__title">Bayaran mengikut pentadbir</h5>
<div class="text-muted" style="font-size: 12px;">{{ item.payouts_count }} bayaran</div> <admin-data-table :headers="headers" :items="items" :loading="loading" :show-pagination="false"
</template> 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-cash_amount_cents="{ item }"> <template v-slot:item-total_amount_cents="{ item }">
{{ formatCentsToRM(item.cash_amount_cents) }} <b>{{ formatCentsToRM(item.total_amount_cents) }}</b>
<div class="text-muted" style="font-size: 12px;">{{ item.cash_count }} tunai</div> <div class="text-muted" style="font-size: 12px;">{{ item.payouts_count }} bayaran</div>
</template> </template>
<template v-slot:item-bank_amount_cents="{ item }"> <template v-slot:item-cash_amount_cents="{ item }">
{{ formatCentsToRM(item.bank_amount_cents) }} {{ formatCentsToRM(item.cash_amount_cents) }}
<div class="text-muted" style="font-size: 12px;">{{ item.bank_count }}</div> <div class="text-muted" style="font-size: 12px;">{{ item.cash_count }} tunai</div>
</template> </template>
</admin-data-table> <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;"> <div v-if="!loading && items.length" class="well well-sm allowance-section__footer-total">
<b>Jumlah keseluruhan:</b> {{ formatCentsToRM(grandTotalCents) }} <b>Jumlah keseluruhan:</b> {{ formatCentsToRM(grandTotalCents) }}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -55,6 +89,11 @@ export default {
loading: false, loading: false,
error: '', error: '',
rows: [], rows: [],
outstandingCents: 0,
outstandingCashCount: 0,
outstandingBankCount: 0,
defaultCashCents: 30000,
defaultBankCents: 15000,
methodFilter: '', methodFilter: '',
headers: [ headers: [
{ {
@@ -107,6 +146,14 @@ export default {
grandTotalCents: function () { grandTotalCents: function () {
return this.items.reduce((sum, r) => sum + Number(r.total_amount_cents || 0), 0); return this.items.reduce((sum, r) => sum + Number(r.total_amount_cents || 0), 0);
}, },
outstandingFizikalCents: function () {
return Number(this.outstandingCashCount || 0) * Number(this.defaultCashCents || 30000);
},
outstandingMayaCents: function () {
return Number(this.outstandingBankCount || 0) * Number(this.defaultBankCents || 15000);
},
}, },
methods: { methods: {
@@ -121,11 +168,14 @@ export default {
.then((response) => { .then((response) => {
if (!response || !response.data || response.data.status !== 'success') { if (!response || !response.data || response.data.status !== 'success') {
this.error = (response && response.data && response.data.message) ? response.data.message : 'Gagal memuatkan ringkasan.'; this.error = (response && response.data && response.data.message) ? response.data.message : 'Gagal memuatkan ringkasan.';
this.applySummaryMeta(null);
return; return;
} }
this.rows = response.data.rows || []; this.rows = response.data.rows || [];
this.applySummaryMeta(response.data);
}) })
.catch((error) => { .catch((error) => {
this.applySummaryMeta(null);
this.error = this.error =
(error && error.response && error.response.data && error.response.data.message) (error && error.response && error.response.data && error.response.data.message)
? error.response.data.message ? error.response.data.message
@@ -137,6 +187,23 @@ export default {
}); });
}, },
applySummaryMeta: function (payload) {
if (!payload) {
this.outstandingCents = 0;
this.outstandingCashCount = 0;
this.outstandingBankCount = 0;
this.defaultCashCents = 30000;
this.defaultBankCents = 15000;
return;
}
var o = payload.outstanding_allowance || {};
this.outstandingCents = Number(o.cents || 0);
this.outstandingCashCount = Number(o.cash_count || 0);
this.outstandingBankCount = Number(o.bank_count || 0);
if (o.default_cash_cents != null) this.defaultCashCents = Number(o.default_cash_cents);
if (o.default_bank_cents != null) this.defaultBankCents = Number(o.default_bank_cents);
},
formatCentsToRM: function (cents) { formatCentsToRM: function (cents) {
var n = Number(cents || 0) / 100; var n = Number(cents || 0) / 100;
return n.toLocaleString('en-MY', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString('en-MY', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -144,3 +211,71 @@ export default {
}, },
}; };
</script> </script>
<style scoped>
.allowance-stat-card {
border: 1px solid #ddd;
border-radius: 6px;
padding: 14px 16px;
background: #fafafa;
min-height: 118px;
}
.allowance-stat-card--primary {
border-color: #bce8f1;
background: #f4fafc;
}
.allowance-stat-card__label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #777;
margin-bottom: 6px;
}
.allowance-stat-card__value {
font-size: 22px;
font-weight: 700;
color: #222;
line-height: 1.2;
}
.allowance-stat-card--primary .allowance-stat-card__value {
color: #31708f;
}
.allowance-stat-card__hint {
font-size: 12px;
color: #888;
margin-top: 8px;
line-height: 1.35;
}
.allowance-stat-card__breakdown {
font-size: 12px;
color: #555;
margin-top: 8px;
}
.allowance-stat-row {
margin-bottom: 0;
}
.allowance-section__title {
margin: 0 0 6px;
font-size: 15px;
font-weight: 700;
color: #333;
}
.allowance-section--table {
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid #e8e8e8;
}
.allowance-section__footer-total {
margin-top: 12px;
}
</style>
+372
View File
@@ -0,0 +1,372 @@
.final-dashboard {
margin-top: 12px;
padding-bottom: 28px;
}
.final-dashboard__container {
width: 100%;
max-width: 1760px;
margin-left: auto;
margin-right: auto;
padding-left: 20px;
padding-right: 20px;
}
.final-split-row {
margin-left: -10px;
margin-right: -10px;
}
.final-split-col {
padding-left: 10px;
padding-right: 10px;
margin-bottom: 20px;
display: flex;
flex-direction: column;
}
@media (min-width: 992px) {
.final-split-col {
margin-bottom: 0;
}
.final-dashboard__shell,
.final-attendance-shell {
flex: 1;
min-height: 100%;
}
.final-split-col--dashboard .final-stats {
grid-template-columns: 1fr;
}
.final-split-col--attendance .final-attendance__charts--standalone {
grid-template-columns: 1fr;
}
.final-split-col--dashboard .final-chart-wrap {
height: 280px;
}
.final-split-col--dashboard .final-chart-wrap--doughnut {
height: 260px;
}
.final-split-col--attendance .final-attendance__chart-wrap--donut {
height: 200px;
}
}
.final-dashboard__shell {
padding: 22px 22px 26px;
border: 1px solid #e6edf5;
border-radius: 18px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
box-shadow: 0 14px 36px rgba(35, 64, 97, 0.08);
display: flex;
flex-direction: column;
}
.final-attendance-shell {
padding: 22px 22px 26px;
border: 1px solid #dce8e4;
border-radius: 18px;
background: linear-gradient(180deg, #ffffff 0%, #f4faf7 100%);
box-shadow: 0 14px 36px rgba(35, 64, 97, 0.08);
display: flex;
flex-direction: column;
}
.final-attendance-shell__head {
margin-bottom: 8px;
}
.final-attendance-shell__kicker {
color: #00897b;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
.final-attendance-shell__title {
margin: 0 0 6px;
font-weight: 800;
color: #17324d;
}
.final-vote-summary {
margin-bottom: 4px;
}
.final-section-title {
font-size: 12px;
font-weight: 900;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #17324d;
margin-bottom: 10px;
}
.final-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.final-kicker {
color: #2f80ed;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
.final-title {
margin: 0 0 6px;
font-weight: 800;
color: #17324d;
}
.final-subtitle {
color: #6b7280;
font-size: 12px;
}
.final-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 0 0 14px;
}
.final-stat {
border: 1px solid #e6edf5;
border-radius: 16px;
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
padding: 12px 14px;
box-shadow: 0 10px 22px rgba(35, 64, 97, 0.06);
}
.final-stat__label {
color: #6b7280;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.final-stat__value {
font-size: 22px;
font-weight: 900;
color: #17324d;
}
.final-stat__value--text {
font-size: 14px;
line-height: 1.25;
}
.final-stat__hint {
margin-top: 6px;
font-size: 12px;
color: #6b7280;
font-weight: 700;
}
.final-attendance__meta {
font-size: 12px;
color: #6b7280;
}
.final-attendance__meta-sep {
margin: 0 6px;
opacity: 0.6;
}
.final-attendance__body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.final-attendance__charts {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 0;
}
.final-attendance__charts--standalone {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px 24px;
align-items: start;
}
.final-attendance__chart-caption {
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
margin-bottom: 8px;
}
.final-attendance__chart-wrap {
position: relative;
width: 100%;
}
.final-attendance__chart-wrap--donut {
height: 240px;
}
.final-attendance__chart-wrap--bar {
height: 120px;
}
.final-attendance__state {
font-size: 13px;
color: #374151;
font-weight: 600;
}
.final-attendance__state--muted {
color: #6b7280;
font-style: italic;
font-weight: 500;
}
.final-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin: 10px 0 16px;
}
.final-filters__label {
color: #6b7280;
font-size: 12px;
font-weight: 700;
margin-right: 4px;
}
.final-chip {
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
line-height: 1;
}
.final-chip:hover {
background: #f3f4f6;
}
.final-chip.is-active {
background: #1976d2;
border-color: #1976d2;
color: #fff;
}
.final-empty {
text-align: center;
color: #6b7280;
font-style: italic;
margin: 24px 0;
}
.final-charts-row {
margin-bottom: 8px;
}
.final-charts-row--split > .col-xs-12 + .col-xs-12 {
margin-top: 12px;
}
.final-card {
border: 0;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 12px 28px rgba(35, 64, 97, 0.08);
}
.final-card__heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
background: linear-gradient(135deg, #1d4e89, #2f80ed);
color: #fff;
border: 0 !important;
}
.final-card__heading--alt {
background: linear-gradient(135deg, #0d47a1, #1976d2);
}
.final-card__title {
font-weight: 800;
}
.final-card__meta {
opacity: 0.95;
font-size: 12px;
}
.final-card__body {
background: #fff;
}
.final-chart-wrap {
position: relative;
height: 320px;
padding: 4px 0;
}
.final-chart-wrap--doughnut {
height: 300px;
}
@media (max-width: 991px) {
.final-header {
flex-direction: column;
}
.final-stats {
grid-template-columns: 1fr;
}
.final-attendance__charts--standalone {
grid-template-columns: 1fr;
}
.final-chart-wrap,
.final-chart-wrap--doughnut {
height: 260px;
}
}
@media (max-width: 575px) {
.final-dashboard__container {
padding-left: 12px;
padding-right: 12px;
}
.final-dashboard__shell,
.final-attendance-shell {
padding: 16px 14px 20px;
border-radius: 14px;
}
}
@@ -1,24 +1,130 @@
<template> <template>
<div class="container"> <div class="final-dashboard">
<div class="row"> <div class="container-fluid final-dashboard__container">
<div class="col-md-12"> <div class="row final-split-row">
<div v-for="position in positions" :key="position.id"> <div class="col-md-6 final-split-col final-split-col--dashboard">
<h5>{{ position.name }}</h5> <div class="final-dashboard__shell">
<admin-data-table <div class="final-header">
:headers="finalTableHeaders" <div>
:items="finalRowsByPosition(position.id)" <div class="final-kicker">Keputusan akhir</div>
:loading="finalLoading" <h4 class="final-title">Dashboard keputusan</h4>
:items-per-page="10" <div class="final-subtitle">Carta interaktif mengikut jawatan</div>
:show-pagination="true" </div>
empty-text="Tiada keputusan" <div class="final-actions">
:exportable="true" <button type="button" class="btn btn-info" :disabled="finalLoading" @click="loadData">
:export-file-name="'keputusan-akhir-' + position.id" <i class="fa" :class="finalLoading ? 'fa-refresh fa-spin' : 'fa-refresh'"></i>
> Kemaskini
<template v-slot:item-percentage="{ item }"> </button>
{{ item.percentage }} </div>
</template> </div>
</admin-data-table>
<hr /> <div class="final-vote-summary">
<div class="final-section-title">Ringkasan undian</div>
<div class="final-stats">
<div class="final-stat">
<div class="final-stat__label">Jumlah undi</div>
<div class="final-stat__value">{{ dashboardTotalVotes }}</div>
</div>
<div class="final-stat">
<div class="final-stat__label">Bil. jawatan</div>
<div class="final-stat__value">{{ positions.length }}</div>
</div>
<div class="final-stat">
<div class="final-stat__label">Calon terunggul</div>
<div class="final-stat__value final-stat__value--text">{{ topOverallLabel }}</div>
<div class="final-stat__hint">{{ topOverallVotes }} undi</div>
</div>
</div>
</div>
<div class="final-filters">
<span class="final-filters__label">Jawatan:</span>
<button type="button" class="final-chip" :class="{ 'is-active': selectedPositionId === 0 }" @click="setPosition(0)">
Semua
</button>
<button
v-for="position in positions"
:key="position.id"
type="button"
class="final-chip"
:class="{ 'is-active': selectedPositionId === position.id }"
@click="setPosition(position.id)"
>
{{ position.name }}
</button>
</div>
<div v-if="!finalLoading && positions.length === 0" class="final-empty">
Tiada jawatan untuk pilihan raya ini.
</div>
<div v-else class="row final-charts-row final-charts-row--split">
<div class="col-xs-12">
<div class="panel panel-default final-card">
<div class="panel-heading final-card__heading">
<div class="final-card__title">{{ mainChartTitle }}</div>
<div class="final-card__meta">{{ mainChartSubtitle }}</div>
</div>
<div class="panel-body final-card__body">
<div class="final-chart-wrap">
<bar-chart v-if="mainChartReady" v-bind="mainBarChartProps" />
</div>
</div>
</div>
</div>
<div class="col-xs-12">
<div class="panel panel-default final-card">
<div class="panel-heading final-card__heading final-card__heading--alt">
<div class="final-card__title">{{ sideChartTitle }}</div>
<div class="final-card__meta">{{ sideChartSubtitle }}</div>
</div>
<div class="panel-body final-card__body">
<div class="final-chart-wrap final-chart-wrap--doughnut">
<pie-chart v-if="mainChartReady" v-bind="sidePieChartProps" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 final-split-col final-split-col--attendance">
<div class="final-attendance-shell">
<div class="final-attendance-shell__head">
<div>
<div class="final-attendance-shell__kicker">Kehadiran</div>
<h4 class="final-attendance-shell__title">Ringkasan kehadiran</h4>
<div v-if="attendanceSummary" class="final-attendance__meta">
Kadar daftar: <b>{{ attendanceSummary.attendance_rate }}%</b>
<span class="final-attendance__meta-sep">·</span>
{{ attendanceSummary.marked_present }} / {{ attendanceSummary.total_registered }} pengundi
</div>
</div>
</div>
<div v-if="!attendanceSummaryReady" class="final-attendance__state">Memuatkan kehadiran</div>
<div v-else-if="attendanceSummary" class="final-attendance__body">
<div
v-if="attendanceSummary.total_registered > 0"
class="final-attendance__charts final-attendance__charts--standalone"
>
<div class="final-attendance__chart-block">
<div class="final-attendance__chart-caption">Taburan kaedah kehadiran</div>
<div class="final-attendance__chart-wrap final-attendance__chart-wrap--donut">
<pie-chart v-bind="attendanceBreakdownPieProps" />
</div>
</div>
<div v-if="showAttendanceFizikalChart" class="final-attendance__chart-block">
<div class="final-attendance__chart-caption">Status pendaftaran fizikal</div>
<div class="final-attendance__chart-wrap final-attendance__chart-wrap--bar">
<bar-chart v-bind="attendanceFizikalBarProps" />
</div>
</div>
</div>
</div>
<div v-else class="final-attendance__state final-attendance__state--muted">
Ringkasan kehadiran tidak dapat dimuatkan. Semak sambungan atau log masuk pentadbir.
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -26,125 +132,25 @@
</template> </template>
<script> <script>
import BarChart from '../../../common/BarChart.vue';
import PieChart from '../../../common/PieChart.vue';
export default { export default {
components: {
BarChart,
PieChart
},
data: function () { data: function () {
return { return {
nominees: [], nominees: [],
results: [], results: [],
partylists: [],
positions: [], positions: [],
finalLoading: false, finalLoading: false,
finalTableHeaders: [ selectedPositionId: 0,
{ title: 'No. Anggota', key: 'no_anggota', sortable: true }, attendanceSummary: null,
{ title: 'Nama', key: 'name', sortable: true }, attendanceSummaryReady: false
{ title: 'Unit', key: 'unit', sortable: true }, };
{ title: 'Undian', key: 'votes', sortable: true },
{ title: 'Peratus (%)', key: 'percentage', sortable: true }
]
}
},
methods: {
finalRowsByPosition: function (positionId) {
var vm = this;
var rows = [];
// rows with votes
this.results.forEach(function (result) {
if (result.position_id == positionId) {
var n = vm.getNominee(result.nominee_id) || {};
rows.push({
no_anggota: n.no_anggota || '',
name: n.name || '',
unit: n.unit || '',
votes: result.votes || 0,
percentage: vm.calculatePercentage(result.votes || 0, positionId)
});
}
});
// rows without votes
this.no_votes.forEach(function (nv) {
if (nv.position_id == positionId) {
rows.push({
no_anggota: nv.no_anggota || '',
name: nv.name || '',
unit: nv.unit || '',
votes: 0,
percentage: '0.00'
});
}
});
// total row (kept as a normal row so it exports too)
rows.push({
no_anggota: '',
name: 'Jumlah Undian',
unit: '',
votes: this.calculateTotalVotes(positionId),
percentage: ''
});
// Sort by votes desc, but keep total row last
var total = rows.pop();
rows.sort(function (a, b) {
return (b.votes || 0) - (a.votes || 0);
});
rows.push(total);
return rows;
},
getNominee: function (id) {
let nominees = this.nominees;
for (var i in nominees)
if (id == nominees[i]['id'])
return nominees[i];
return {};
},
getPartylist: function (id) {
let partylists = this.partylists;
for (var i in partylists)
if (id == partylists[i]['id'])
return partylists[i]['name'];
return 'No Partylist';
},
calculatePercentage(votes, positionId) {
const totalVotes = this.results
.filter(result => result.position_id === positionId)
.reduce((total, result) => total + result.votes, 0);
return totalVotes === 0 ? '0.00' : ((votes / totalVotes) * 100).toFixed(2);
},
calculateTotalVotes(positionId) {
return this.results
.filter(result => result.position_id === positionId)
.reduce((total, result) => total + result.votes, 0);
}
},
created: function () {
this.util.notify('Loading please wait...', 'loading');
var vm = this;
this.finalLoading = true;
axios.get(config.API + 'election/result/' + this.election_id)
.then(response => {
$.notifyClose();
vm.nominees = response.data.nominee;
vm.results = response.data.result;
vm.partylists = response.data.partylist;
vm.positions = response.data.position;
})
.catch(error => {
$.notifyClose();
vm.util.showResult(error, 'error');
})
.finally(function () {
vm.finalLoading = false;
})
}, },
computed: { computed: {
@@ -153,21 +159,276 @@ export default {
}, },
no_votes: function () { no_votes: function () {
let nominees = this.nominees; var nominees = this.nominees;
let results = this.results; var results = this.results;
let no_votes = []; var no_votes = [];
for (var i in nominees) { for (var i in nominees) {
var hasvote = false; var hasvote = false;
for (var y in results) { for (var y in results) {
if (results[y]['nominee_id'] == nominees[i]['id']) if (results[y]['nominee_id'] == nominees[i]['id']) {
hasvote = true; hasvote = true;
}
} }
if (!hasvote) if (!hasvote) {
no_votes.push(nominees[i]); no_votes.push(nominees[i]);
}
} }
return no_votes; return no_votes;
} },
dashboardTotalVotes: function () {
return this.results.reduce(function (s, r) {
return s + Number(r.votes || 0);
}, 0);
},
topOverallLabel: function () {
if (!this.results.length) {
return '—';
}
var best = this.results.reduce(function (a, b) {
return Number(b.votes || 0) > Number(a.votes || 0) ? b : a;
});
var n = this.getNominee(best.nominee_id);
return (n && n.name) ? n.name : '—';
},
topOverallVotes: function () {
if (!this.results.length) {
return 0;
}
return this.results.reduce(function (m, r) {
return Math.max(m, Number(r.votes || 0));
}, 0);
},
mainChartTitle: function () {
if (this.selectedPositionId === 0) {
return 'Jumlah undi mengikut jawatan';
}
var p = this.positions.find(function (x) {
return Number(x.id) === Number(this.selectedPositionId);
}, this);
return (p && p.name) ? 'Undi calon — ' + p.name : 'Undi calon';
},
mainChartSubtitle: function () {
return this.selectedPositionId === 0
? 'Perbandingan antara jawatan'
: 'Carta mengikut calon';
},
sideChartTitle: function () {
return this.selectedPositionId === 0 ? 'Taburan undi (jawatan)' : 'Peratus undi';
},
sideChartSubtitle: function () {
return this.selectedPositionId === 0 ? 'Perkongsian jumlah undi' : 'Bahagian setiap calon';
},
showAttendanceFizikalChart: function () {
if (!this.attendanceSummary) {
return false;
}
return Number(this.attendanceSummary.fizikal || 0) > 0;
},
mainChartReady: function () {
return !this.finalLoading && this.positions.length > 0;
},
positionTotalRows: function () {
var vm = this;
return this.positions.map(function (pos) {
var t = vm.results
.filter(function (r) {
return Number(r.position_id) === Number(pos.id);
})
.reduce(function (s, r) {
return s + Number(r.votes || 0);
}, 0);
return { id: pos.id, name: pos.name, total: t };
});
},
mainBarChartProps: function () {
if (!this.mainChartReady) {
return {};
}
if (this.selectedPositionId === 0) {
var rows = this.positionTotalRows;
return {
labels: rows.map(function (r) {
return r.name.length > 22 ? r.name.slice(0, 20) + '…' : r.name;
}),
values: rows.map(function (r) {
return r.total;
}),
datasetLabel: 'Jumlah undi'
};
}
var rows = this.getSinglePositionChartRows();
return {
labels: rows.map(function (r) {
return r.label;
}),
values: rows.map(function (r) {
return r.votes;
}),
datasetLabel: 'Undi'
};
},
sidePieChartProps: function () {
if (!this.mainChartReady) {
return {};
}
if (this.selectedPositionId === 0) {
var rows = this.positionTotalRows;
return {
labels: rows.map(function (r) {
return r.name;
}),
values: rows.map(function (r) {
return r.total;
})
};
}
var rows = this.getSinglePositionChartRows();
return {
labels: rows.map(function (r) {
return r.label;
}),
values: rows.map(function (r) {
return r.votes;
})
};
},
attendanceBreakdownPieProps: function () {
if (!this.attendanceSummary || this.attendanceSummary.total_registered <= 0) {
return { labels: [], values: [] };
}
var s = this.attendanceSummary;
return {
labels: ['Fizikal', 'Maya', 'Belum ditetapkan'],
values: [
Number(s.fizikal || 0),
Number(s.maya || 0),
Number(s.unset || 0)
]
};
},
attendanceFizikalBarProps: function () {
if (!this.showAttendanceFizikalChart || !this.attendanceSummary) {
return { labels: [], values: [] };
}
var s = this.attendanceSummary;
return {
labels: ['Menunggu pengesahan', 'Disahkan'],
values: [
Number(s.fizikal_registration_pending || 0),
Number(s.fizikal_registration_verified || 0)
],
datasetLabel: 'Pengundi'
};
}
},
methods: {
setPosition: function (id) {
this.selectedPositionId = id === 0 ? 0 : Number(id);
},
getSinglePositionChartRows: function () {
var pid = this.selectedPositionId;
var vm = this;
var rows = this.results
.filter(function (r) {
return Number(r.position_id) === Number(pid);
})
.map(function (r) {
var n = vm.getNominee(r.nominee_id) || {};
var label = n.name || ('#' + r.nominee_id);
if (label.length > 28) {
label = label.slice(0, 26) + '…';
}
return {
label: label,
votes: Number(r.votes || 0)
};
});
this.no_votes.forEach(function (nv) {
if (Number(nv.position_id) === Number(pid)) {
var label = nv.name || '';
if (label.length > 28) {
label = label.slice(0, 26) + '…';
}
rows.push({
label: label || 'Calon',
votes: 0
});
}
});
rows.sort(function (a, b) {
return b.votes - a.votes;
});
return rows;
},
loadData: function () {
var vm = this;
this.util.notify('Loading please wait...', 'loading');
this.finalLoading = true;
this.attendanceSummaryReady = false;
this.attendanceSummary = null;
axios
.get(config.API + 'election/' + this.election_id + '/attendance-summary')
.then(function (r) {
vm.attendanceSummary = r.data;
})
.catch(function () {
vm.attendanceSummary = null;
})
.finally(function () {
vm.attendanceSummaryReady = true;
});
axios
.get(config.API + 'election/result/' + this.election_id)
.then(function (response) {
$.notifyClose();
vm.nominees = response.data.nominee || [];
vm.results = response.data.result || [];
vm.positions = response.data.position || [];
})
.catch(function (error) {
$.notifyClose();
vm.util.showResult(error, 'error');
})
.finally(function () {
vm.finalLoading = false;
});
},
getNominee: function (id) {
var nominees = this.nominees;
for (var i in nominees) {
if (id == nominees[i]['id']) {
return nominees[i];
}
}
return {};
}
},
created: function () {
this.util.setTitle('MyKoPKB - Keputusan akhir');
this.loadData();
} }
} };
</script> </script>
<style scoped src="./final.css"></style>
@@ -1,35 +1,76 @@
<template> <template>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-body"> <div class="panel-body">
<h4>Undian Terkini</h4> <ul class="nav nav-tabs election-tabs">
<div class="form-group"> <li :class="{ active: activeTab === 'current' }">
<button class="btn btn-success" v-if="data.election.status == 1 && data.user.role == 1" <a href="#" @click.prevent="activeTab = 'current'">Terkini</a>
@click="util.showModal('#start-election-modal')">Mula Undian</button> </li>
<li :class="{ active: activeTab === 'history' }">
<a href="#" @click.prevent="activeTab = 'history'">Sejarah Undian</a>
</li>
</ul>
<router-link :to="{ name: 'Current Result' }" class="btn btn-info" v-if="data.election.status == 2"> <div class="tab-content election-tab-content">
Papar Keputusan <div class="tab-pane" :class="{ active: activeTab === 'current' }">
</router-link> <div class="election-header">
<div class="election-header__title">
<div class="election-header__kicker">Undian Terkini</div>
<div class="election-header__name">{{ electionLatestHeading }}</div>
</div>
<div class="election-header__meta">
<span class="election-status" :class="electionStatusClass">{{ electionStatusLabel }}</span>
<label v-if="data.election.status == 2" class="election-label">
Undian bermula pada {{ data.election.start }}
</label>
<div class="election-header__meta-actions">
<button class="btn btn-success btn-xs"
v-if="Number(data.election.status) === 1 && data.user.role == 1"
@click="openStartElectionModal">
Mula Undian
</button>
</div>
</div>
</div>
<button class="btn btn-danger" v-if="data.election.status == 2" <div class="election-actions form-group">
@click="util.showModal('#stop-election-modal')">Tamat Undian</button> <button class="btn btn-primary" v-if="Number(data.election.status) === 3 && data.user.role == 1"
@click="util.showModal('#register-next-session-modal')">Daftar Sesi Mengundi Baru</button>
<label v-if="data.election.status == 2">Undian bermula {{ start_date }}</label> <router-link :to="{ name: 'Current Result' }" class="btn btn-info"
v-if="data.election.status == 2">
Keputusan Semasa
</router-link>
<button class="btn btn-danger" v-if="data.election.status == 2"
@click="util.showModal('#stop-election-modal')">Tamat Undian</button>
</div>
<div class="alert alert-info election-hint" v-if="Number(data.user.role) === 1">
<b>Makluman:</b> Sila semak &amp; kemaskini <b>Maklumat Calon</b> dan <b>Anggota Koperasi</b>
(butiran
pengundi) untuk sesi ini sebelum memulakan undian.
</div>
</div>
<div class="tab-pane" :class="{ active: activeTab === 'history' }">
<div class="election-history">
<div class="election-history__title">Sejarah Undian</div>
<admin-data-table :headers="electionTableHeaders" :items="data.elections"
:loading="electionLoading" :items-per-page="10" :show-pagination="true"
empty-text="Tiada undian" :exportable="true" export-file-name="undian">
<template v-slot:item-actions="{ item }">
<router-link :to="{ name: 'Election Result', params: { election_id: item.id } }"
class="btn btn-info">
Ringkasan Keputusan
</router-link>
<a class="btn btn-warning" :href="getPDFurl(item.id)">Muat Turun PDF</a>
</template>
</admin-data-table>
</div>
</div>
</div> </div>
<admin-data-table :headers="electionTableHeaders" :items="data.elections" :loading="electionLoading" <form @submit.prevent="start">
: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" <div class="modal fade" id="start-election-modal" tabindex="-1" role="dialog"
aria-labelledby="start-election-modal" aria-hidden="true"> aria-labelledby="start-election-modal" aria-hidden="true">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
@@ -42,19 +83,60 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">
<label for="name">Nama Undian</label> <label for="start-election-name">Nama Undian</label>
<input type="text" name="name" autocomplete="on" class="form-control" <input id="start-election-name" v-model.trim="startForm.name" type="text"
placeholder="(Optional)" /> name="name" autocomplete="off" class="form-control" placeholder="(Pilihan)"
readonly tabindex="-1" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Kata Laluan</label> <label for="start-election-password">Kata Laluan</label>
<input type="password" name="password" class="form-control" required /> <input id="start-election-password" v-model="startForm.password" type="password"
name="password" class="form-control" required autocomplete="current-password"
:disabled="startSubmitting" />
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<input type="submit" class="btn btn-info" value="Start" /> <button type="submit" class="btn btn-info" :disabled="startSubmitting">
<input type="button" data-dismiss="modal" class="btn btn-default" value="Cancel" /> {{ startSubmitting ? 'Memproses…' : 'Mula' }}
</button>
<input type="button" data-dismiss="modal" class="btn btn-default" value="Batal" />
</div>
</div>
</div>
</div>
</form>
<form @submit.prevent="registerNextSession" id="register_next_form">
<div class="modal fade" id="register-next-session-modal" tabindex="-1" role="dialog"
aria-labelledby="register-next-session-modal" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="registerNextModalLabel">Daftar Sesi Mengundi Baru</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p class="text-muted">Sesi baharu akan dibuka untuk pusingan undian seterusnya. Undian
yang
telah tamat kekal dalam rekod.</p>
<div class="form-group">
<label for="register-next-name">Nama Undian</label>
<input id="register-next-name" type="text" name="name" autocomplete="on"
class="form-control" />
</div>
<div class="form-group">
<label for="register-next-password">Kata Laluan</label>
<input id="register-next-password" type="password" name="password"
class="form-control" required autocomplete="current-password" />
</div>
</div>
<div class="modal-footer">
<input type="submit" class="btn btn-primary" value="Daftar" />
<input type="button" data-dismiss="modal" class="btn btn-default" value="Batal" />
</div> </div>
</div> </div>
</div> </div>
@@ -76,7 +158,7 @@
<div class="form-group"> <div class="form-group">
<label for="name">Nama Undian</label> <label for="name">Nama Undian</label>
<input type="text" name="name" :value="data.election.name" autocomplete="on" <input type="text" name="name" :value="data.election.name" autocomplete="on"
class="form-control" placeholder="(Optional)" /> class="form-control" placeholder="(Optional)" readonly tabindex="-1" />
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -86,19 +168,129 @@
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<input type="submit" class="btn btn-info" value="Stop Election" /> <input type="submit" class="btn btn-info" value="Tamat Undian" />
<input type="button" data-dismiss="modal" class="btn btn-default" value="Cancel" /> <input type="button" data-dismiss="modal" class="btn btn-default" value="Batal" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</template> </template>
<style> <style>
.election-label {
display: inline-block;
margin-left: 10px;
font-size: 13px;
color: #ffffff;
background-color: #0a4c9c;
padding: 4px 8px;
border-radius: 4px;
}
.election-tabs {
margin: 0 0 12px 0;
}
.election-tab-content>.tab-pane {
display: none;
}
.election-tab-content>.tab-pane.active {
display: block;
}
.election-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 14px 15px;
border-bottom: 1px solid #eee;
}
.election-header__kicker {
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #777;
margin-bottom: 4px;
}
.election-header__name {
font-size: 18px;
font-weight: 700;
line-height: 1.2;
color: #222;
}
.election-status {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
white-space: nowrap;
}
.election-status--draft {
background: #f5f5f5;
color: #555;
border-color: #e6e6e6;
}
.election-status--active {
background: #e8f5e9;
color: #2e7d32;
border-color: #c8e6c9;
}
.election-status--done {
background: #ffebee;
color: #c62828;
border-color: #ffcdd2;
}
.election-header__meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.election-header__meta-actions .btn {
padding: 4px 10px;
font-weight: 700;
}
.election-actions {
margin-top: 12px;
}
.election-hint {
margin-top: 10px;
margin-bottom: 12px;
}
.election-history {
margin-top: 4px;
padding-top: 12px;
border-top: 1px solid #eee;
}
.election-history__title {
font-size: 13px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #666;
margin: 0 0 10px 0;
}
.center-header { .center-header {
text-align: center; text-align: center;
} }
@@ -116,25 +308,58 @@
<script> <script>
export default { export default {
data: () => ({ data: () => ({
start_date: null, activeTab: 'current',
electionLoading: false, electionLoading: false,
startSubmitting: false,
startForm: {
name: '',
password: ''
},
electionTableHeaders: [ electionTableHeaders: [
{ title: 'Nama Undian', key: 'name', sortable: true }, { title: 'Nama Undian', key: 'name', sortable: true },
{ title: 'Undian Mula', key: 'start', sortable: true }, { title: 'Undian Mula', key: 'start', sortable: true },
{ title: 'Undian Tamat', key: 'end', sortable: true }, { title: 'Undian Tamat', key: 'end', sortable: true },
{ title: 'Papar', key: 'actions', sortable: false } { title: 'Tindakan', key: 'actions', sortable: false }
] ]
}), }),
created: function () { created: function () {
this.refreshElection(); this.refreshElection();
var vm = this; },
setInterval(() => {
vm.start_date = moment(vm.data.election.start).fromNow(); computed: {
}, 1000); electionLatestHeading: function () {
var e = this.data.election;
var n = e && e.name != null ? String(e.name).trim() : '';
return n !== '' ? n : 'Undian Terkini';
},
electionStatusLabel: function () {
var s = Number(this.data && this.data.election ? this.data.election.status : NaN);
if (s === 1) return 'Draf';
if (s === 2) return 'Sesi Mengundi Sedang Berjalan';
if (s === 3) return 'Tamat';
return '—';
},
electionStatusClass: function () {
var s = Number(this.data && this.data.election ? this.data.election.status : NaN);
if (s === 1) return 'election-status--draft';
if (s === 2) return 'election-status--active';
if (s === 3) return 'election-status--done';
return '';
}
}, },
methods: { methods: {
openStartElectionModal: function () {
var e = this.data.election;
this.startForm.name = e && e.name != null ? String(e.name) : '';
this.startForm.password = '';
this.startSubmitting = false;
this.util.showModal('#start-election-modal');
},
stop: function () { stop: function () {
var vm = this; var vm = this;
// this.util.hideModal('#stop-election-modal'); // this.util.hideModal('#stop-election-modal');
@@ -145,11 +370,6 @@ export default {
if (vm.util.showResult(response, 'success')) { if (vm.util.showResult(response, 'success')) {
vm.refreshElection(); vm.refreshElection();
vm.data.election = response.data.election; vm.data.election = response.data.election;
vm.data.positions = [];
vm.data.partylists = [];
vm.data.voters = [];
vm.data.nominees = [];
vm.data.results = [];
} }
}) })
.catch(error => { .catch(error => {
@@ -160,15 +380,50 @@ export default {
start: function () { start: function () {
var vm = this; var vm = this;
// this.util.hideModal('#start-election-modal'); if (vm.startSubmitting) {
console.log('this'); return;
this.util.notify('Loading please wait', 'loading'); }
axios.post(config.API + 'election/start', $('#start_form').serialize()) vm.startSubmitting = true;
this.util.notify('Memproses…', 'loading');
axios.post(config.API + 'election/start', {
name: vm.startForm.name,
password: vm.startForm.password
})
.then(response => { .then(response => {
$.notifyClose(); $.notifyClose();
if (vm.util.showResult(response, 'success')) { if (vm.util.showResult(response, 'success')) {
vm.refreshElection(); vm.refreshElection();
vm.data.election = response.data.election; vm.data.election = response.data.election;
vm.startForm.password = '';
vm.util.hideModal('#start-election-modal');
}
})
.catch(error => {
$.notifyClose();
vm.util.showResult(error, 'error');
})
.finally(function () {
vm.startSubmitting = false;
})
},
registerNextSession: function () {
var vm = this;
this.util.notify('Memproses…', 'loading');
axios.post(config.API + 'election/register-next', $('#register_next_form').serialize())
.then(response => {
$.notifyClose();
if (vm.util.showResult(response, 'success')) {
vm.refreshElection();
vm.data.election = response.data.election;
vm.util.hideModal('#register-next-session-modal');
axios.get(config.API + 'admin/information').then(function (info) {
vm.data.election = info.data.election;
vm.data.partylists = info.data.partylist;
vm.data.positions = info.data.position;
}).catch(function (err) {
vm.util.showResult(err, 'error');
});
} }
}) })
.catch(error => { .catch(error => {
@@ -43,7 +43,7 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-7"> <div class="col-md-12">
<div class="panel panel-default result-card"> <div class="panel panel-default result-card">
<div class="panel-heading result-card__heading"> <div class="panel-heading result-card__heading">
<div class="result-card__title"> <div class="result-card__title">
@@ -99,7 +99,10 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-5"> </div>
<div class="row" style="margin-top: 12px;">
<div class="col-md-12">
<div class="panel panel-default result-card"> <div class="panel panel-default result-card">
<div class="panel-heading result-card__heading"> <div class="panel-heading result-card__heading">
<div class="result-card__title"> <div class="result-card__title">
@@ -114,11 +117,15 @@
<div class="panel-body result-card__body"> <div class="panel-body result-card__body">
<template v-if="String(position_id) === '0'"> <template v-if="String(position_id) === '0'">
<div class="result-mini-title">Top Calon (Overall)</div> <div class="result-mini-title">Top Calon (Overall)</div>
<div id="top-chart" class="result-top-chart"></div> <BarChart class="result-top-chart" :labels="topNominees.map(n => n.label)"
:values="topNominees.map(n => n.votes)" background-color="rgba(54, 162, 235, 0.75)"
border-color="rgba(54, 162, 235, 1)" />
<div v-if="!topNominees.length" class="result-empty">Tiada data untuk dipaparkan.</div> <div 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 class="result-mini-title" style="margin-top: 14px;">Jumlah Undi Mengikut Jawatan</div>
<div id="votes-by-position-chart" class="result-top-chart"></div> <BarChart class="result-top-chart" :labels="positionTotals.map(p => p.label)"
:values="positionTotals.map(p => p.votes)" background-color="rgba(75, 192, 192, 0.55)"
border-color="rgba(75, 192, 192, 1)" />
</template> </template>
<template v-else> <template v-else>
<div class="result-empty"> <div class="result-empty">
@@ -134,21 +141,18 @@
</template> </template>
<script> <script>
import BarChart from '../../../common/BarChart.vue';
export default { export default {
components: { BarChart },
created: function () { created: function () {
this.refreshNominees(); this.refreshNominees();
this.$nextTick(function () {
this.initCharts();
});
}, },
watch: { watch: {
position_id: function () { position_id: function () {
var vm = this; // charts are reactive via <BarChart>
this.$nextTick(function () {
vm.initCharts();
});
} }
}, },
@@ -188,9 +192,6 @@ export default {
$.notifyClose(); $.notifyClose();
vm.data.results = response.data; vm.data.results = response.data;
vm.data.last_update = new Date(); vm.data.last_update = new Date();
vm.$nextTick(function () {
vm.initCharts();
});
}) })
.catch(error => { .catch(error => {
$.notifyClose(); $.notifyClose();
@@ -198,47 +199,6 @@ export default {
}) })
}, },
initCharts: function () {
// Clear any previously rendered plots to avoid overlap when switching views
if ($('#top-chart').length) $('#top-chart').empty();
if ($('#votes-by-position-chart').length) $('#votes-by-position-chart').empty();
// All positions summary
if (String(this.position_id) === '0') {
if ($('#top-chart').length) {
var ticks = this.topNominees.map(function (n, i) { return [i, n.label]; });
var series = [{
label: 'Undi',
data: this.topNominees.map(function (n, i) { return [i, n.votes]; }),
bars: { show: true, barWidth: 0.6, align: 'center', fill: 0.85, lineWidth: 0 }
}];
$.plot($('#top-chart'), series, {
xaxis: { ticks: ticks, rotateTicks: 45 },
yaxis: { min: 0, tickDecimals: 0 },
grid: { hoverable: true, borderColor: '#e5e7eb' }
});
}
if ($('#votes-by-position-chart').length) {
var pt = this.positionTotals;
var ptTicks = pt.map(function (p, i) { return [i, p.label]; });
var ptSeries = [{
label: 'Undi',
data: pt.map(function (p, i) { return [i, p.votes]; }),
bars: { show: true, barWidth: 0.6, align: 'center', fill: 0.55, lineWidth: 0 }
}];
$.plot($('#votes-by-position-chart'), ptSeries, {
xaxis: { ticks: ptTicks, rotateTicks: 45 },
yaxis: { min: 0, tickDecimals: 0 },
grid: { hoverable: true, borderColor: '#e5e7eb' }
});
}
return;
}
// For per-position view, we intentionally do not render charts (UI request).
},
getPosition: function (id) { getPosition: function (id) {
let positions = this.data.positions; let positions = this.data.positions;
for (var i in positions) for (var i in positions)
@@ -1,121 +1,104 @@
<template> <template>
<div> <div>
<div v-if="!loading"> <div v-if="!loading">
<nav class="navbar navbar-default navbar-fixed-top floating-header logo-header" id="nav"> <FloatingNavbar logo-href="/admin" main-nav-class="admin-nav-main">
<div class="container"> <template #left>
<div class="navbar-header logo-section"> <router-link v-if="adminRole !== 3" :to="{ name: 'Admin Home' }" tag="li" exact>
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#myNavbar"> <a href="#"><b>Undian</b></a>
<span class="icon-bar"></span> </router-link>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<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>
</div>
<div class="collapse navbar-collapse" id="myNavbar">
<ul class="nav navbar-nav admin-nav-main"> <li class="dropdown" v-if="adminRole === 1">
<router-link v-if="adminRole !== 3" :to="{ name: 'Admin Home' }" tag="li" exact> <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true"
<a href="#"><b>Laman Utama</b></a> 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>
<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>
</template>
<template #right>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true"
aria-expanded="false">
<span class="fa fa-user"></span> {{ data.user.name }} <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<router-link v-if="Number(data.user.role) !== 3" :to="{ name: 'Update Account' }" tag="li"
exact>
<a href="#">Kemaskini Akaun</a>
</router-link> </router-link>
<li class="dropdown" v-if="adminRole === 1"> <li v-if="isImpersonating()" @click="leaveImpersonation()">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" <a>Kembali Akaun Asal</a>
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> </li>
<li class="dropdown" v-if="adminRole === 1 || adminRole === 3"> <router-link :to="{ name: 'Manage Account' }" tag="li" v-if="data.user.id == 1">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" <a href="#">Pengurusan Akaun</a>
aria-haspopup="true" aria-expanded="false"> </router-link>
<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"> <li @click="isImpersonating() ? null : logout()" :class="{ disabled: isImpersonating() }"
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" :style="isImpersonating() ? 'opacity:0.5; cursor:not-allowed; pointer-events:none;' : ''">
aria-haspopup="true" aria-expanded="false"> <a>Log Keluar</a>
<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> </li>
</ul> </ul>
<ul class="nav navbar-right navbar-nav"> </li>
<li class="dropdown"> </template>
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" </FloatingNavbar>
aria-haspopup="true" aria-expanded="false">
<span class="fa fa-user"></span> {{ data.user.name }} <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<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="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>
</div>
</div>
</nav>
<div class="admin-main"> <div class="admin-main">
<router-view></router-view> <router-view></router-view>
@@ -130,7 +113,10 @@
</template> </template>
<script> <script>
import FloatingNavbar from '../../common/FloatingNavbar.vue';
export default { export default {
components: { FloatingNavbar },
// data: () =>({ // data: () =>({
// loading: true, // loading: true,
@@ -145,6 +131,7 @@ export default {
axios.get(config.API + 'admin/information') axios.get(config.API + 'admin/information')
.then(response => { .then(response => {
console.log(response); console.log(response);
localStorage.setItem('admin_session', '1');
vm.data.user = response.data.user; vm.data.user = response.data.user;
vm.data.election = response.data.election; vm.data.election = response.data.election;
vm.data.partylists = response.data.partylist; vm.data.partylists = response.data.partylist;
@@ -229,208 +216,11 @@ export default {
</script> </script>
<style scoped> <style scoped>
.floating-header {
position: fixed !important;
top: 16px !important;
left: 16px !important;
right: 16px !important;
width: calc(100% - 32px) !important;
margin: 0;
border-radius: 16px !important;
z-index: 2000 !important;
transition: all 0.3s ease !important;
}
.logo-header {
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.96) 0%,
rgb(8, 0, 126) 48%,
rgb(255, 0, 0) 100%) !important;
border: 1px solid rgba(255, 255, 255, 0.25) !important;
box-shadow:
0 8px 32px rgba(33, 150, 243, 0.35),
0 4px 16px rgba(2, 19, 36, 0.28) !important;
backdrop-filter: blur(12px) !important;
position: fixed;
overflow: visible;
}
.logo-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, transparent 50%, rgba(255, 255, 255, 0.05) 100%);
pointer-events: none;
z-index: 0;
}
.logo-header>* {
position: relative;
z-index: 1;
}
.logo-section {
display: flex;
align-items: center;
margin-right: 24px;
margin-left: 24px;
flex-shrink: 0;
}
#nav.navbar {
border: none;
min-height: 78px;
}
#nav .container {
width: 100%;
padding-left: 12px;
padding-right: 12px;
overflow: visible;
}
#nav .navbar-brand {
display: flex;
align-items: center;
gap: 12px;
height: auto;
padding: 14px 0;
color: #1f2937;
font-weight: 700;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
}
.logo-title {
display: inline-block;
color: #1f2937 !important;
font-size: 1.8rem;
font-weight: 700;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
}
#nav .nav>li>a,
#nav .navbar-toggle {
color: #f8fbff;
font-weight: 700;
letter-spacing: 0.02em;
}
#nav .nav>li>a:hover,
#nav .nav>li.active>a,
#nav .nav>li.active>a:hover,
#nav .nav>li.active>a:focus,
#nav .navbar-brand:hover {
color: #ffffff;
background: rgba(204, 0, 0, 0.833);
}
#nav .nav>li>a {
border-radius: 10px;
margin-top: 18px;
margin-bottom: 18px;
padding-top: 12px;
padding-bottom: 12px;
}
#nav .navbar-right {
margin-right: 8px;
}
#nav .navbar-nav>li,
#nav .dropdown {
position: relative;
}
#nav .dropdown-menu {
margin-top: 6px;
border: 0;
border-radius: 12px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.16);
z-index: 2101;
}
#nav .dropdown-menu>li>a {
color: #2c3e50;
}
#nav .dropdown-menu>li>a:hover {
background-color: #f39c12;
color: white;
}
#nav .navbar-toggle {
margin-top: 20px;
margin-right: 18px;
border-color: rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.08);
}
#nav .navbar-toggle .icon-bar {
background-color: #fff;
}
.navbar-brand img {
background: transparent !important;
box-shadow: none;
}
.admin-main { .admin-main {
padding-top: 112px; padding-top: 112px;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.floating-header {
top: 10px !important;
left: 10px !important;
right: 10px !important;
width: calc(100% - 20px) !important;
border-radius: 14px !important;
}
.logo-section {
margin-left: 16px;
margin-right: 8px;
}
.logo-title {
font-size: 1.4rem;
}
#nav .navbar-brand img {
width: 120px;
}
#nav .navbar-collapse {
border-top: 0;
box-shadow: none;
padding-bottom: 12px;
overflow: visible;
}
#nav .nav>li>a {
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 { .admin-main {
padding-top: 102px; padding-top: 102px;
} }
@@ -85,6 +85,7 @@ export default {
vm.stopLoading(); vm.stopLoading();
if (this.util.showResult(response, 'success')) { if (this.util.showResult(response, 'success')) {
localStorage['Access Token'] = `Bearer ${response.data.token}`; localStorage['Access Token'] = `Bearer ${response.data.token}`;
localStorage.setItem('admin_session', '1');
if (response.data.user && response.data.user.role !== undefined && response.data.user.role !== null) { if (response.data.user && response.data.user.role !== undefined && response.data.user.role !== null) {
localStorage.setItem('admin_role', String(response.data.user.role)); localStorage.setItem('admin_role', String(response.data.user.role));
} }
@@ -11,9 +11,9 @@
<button class="btn btn-default" @click="refreshVoter()"> <button class="btn btn-default" @click="refreshVoter()">
<i class="fa fa-refresh"></i> Kemaskini Pengundi <i class="fa fa-refresh"></i> Kemaskini Pengundi
</button> </button>
<router-link class="btn btn-info" :to="{ name: 'Penyata Anggota' }"> <!-- <router-link class="btn btn-info" :to="{ name: 'Penyata Anggota' }">
<i class="fa fa-list"></i> Database Anggota <i class="fa fa-list"></i> Database Anggota
</router-link> </router-link> -->
</div> </div>
<div class="kehadiran-search"> <div class="kehadiran-search">
@@ -21,45 +21,112 @@
<button class="btn btn-default" @click="clearSearch()" :disabled="!hasAnySearch">Reset</button> <button class="btn btn-default" @click="clearSearch()" :disabled="!hasAnySearch">Reset</button>
</div> </div>
<div class="kehadiran-filters"> <div class="kehadiran-filter-panel">
<span class="kehadiran-filters__label">Penapis:</span> <div class="kehadiran-filter-panel__title">Penapis</div>
<button type="button" class="kehadiran-chip" :class="{ 'is-active': !current_kehadiran }" <div class="kehadiran-filter-groups">
@click="setKehadiranFilter(null)"> <div class="kehadiran-filter-group">
Semua <div class="kehadiran-filter-group__title">Kaedah kehadiran</div>
</button> <div class="kehadiran-filter-group__chips">
<button type="button" class="kehadiran-chip" <button type="button" class="kehadiran-chip" :class="{ 'is-active': !current_kehadiran }"
:class="{ 'is-active': String(current_kehadiran) === 'unset' }" @click="setKehadiranFilter(null)">
@click="setKehadiranFilter('unset')"> Semua
Belum Ditetapkan </button>
</button> <button type="button" class="kehadiran-chip"
<button type="button" class="kehadiran-chip" :class="{ 'is-active': Number(current_kehadiran) === 1 }" :class="{ 'is-active': String(current_kehadiran) === 'unset' }"
@click="setKehadiranFilter(1)"> @click="setKehadiranFilter('unset')">
Fizikal Belum Ditetapkan
</button> </button>
<button type="button" class="kehadiran-chip" :class="{ 'is-active': Number(current_kehadiran) === 2 }" <button type="button" class="kehadiran-chip"
@click="setKehadiranFilter(2)"> :class="{ 'is-active': Number(current_kehadiran) === 1 }"
Maya @click="setKehadiranFilter(1)">
</button> Fizikal
</button>
<button type="button" class="kehadiran-chip"
:class="{ 'is-active': Number(current_kehadiran) === 2 }"
@click="setKehadiranFilter(2)">
Maya
</button>
</div>
</div>
<div class="kehadiran-filter-group">
<div class="kehadiran-filter-group__title">Pendaftaran fizikal</div>
<div class="kehadiran-filter-group__chips">
<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>
</div>
<div class="kehadiran-filter-group">
<div class="kehadiran-filter-group__title">Undi</div>
<div class="kehadiran-filter-group__chips">
<button type="button" class="kehadiran-chip" :class="{ 'is-active': !current_has_voted }"
@click="setHasVotedFilter(null)">
Semua
</button>
<button type="button" class="kehadiran-chip"
:class="{ 'is-active': current_has_voted === '1' }"
@click="setHasVotedFilter('1')">
Undi
</button>
<button type="button" class="kehadiran-chip"
:class="{ 'is-active': current_has_voted === '0' }"
@click="setHasVotedFilter('0')">
Belum
</button>
</div>
</div>
<div class="kehadiran-filter-group">
<div class="kehadiran-filter-group__title">Bayaran tunai (elaun)</div>
<div class="kehadiran-filter-group__chips">
<button type="button" class="kehadiran-chip" :class="{ 'is-active': !current_cash_status }"
@click="setCashStatusFilter(null)">
Semua
</button>
<button type="button" class="kehadiran-chip"
:class="{ 'is-active': current_cash_status === 'paid' }"
@click="setCashStatusFilter('paid')">
Sudah Bayar
</button>
<button type="button" class="kehadiran-chip"
:class="{ 'is-active': current_cash_status === 'unpaid' }"
@click="setCashStatusFilter('unpaid')">
Belum Bayar
</button>
</div>
</div>
</div>
</div> </div>
<div class="kehadiran-filters kehadiran-filters--secondary"> <!-- Attendance stats -->
<span class="kehadiran-filters__label">Pendaftaran fizikal:</span> <div class="kehadiran-stats">
<button type="button" class="kehadiran-chip" :class="{ 'is-active': !current_fizikal_reg }" <div class="kehadiran-stat">
@click="setFizikalRegFilter(null)"> <div class="kehadiran-stat__label">Fizikal</div>
Semua <div class="kehadiran-stat__value">{{ attendanceStats.fizikalCount }}</div>
</button> <div class="kehadiran-stat__meta">{{ attendanceStats.fizikalPct }}%</div>
<button type="button" class="kehadiran-chip" :class="{ 'is-active': current_fizikal_reg === 'pending' }" </div>
@click="setFizikalRegFilter('pending')"> <div class="kehadiran-stat">
Menunggu pengesahan <div class="kehadiran-stat__label">Maya</div>
</button> <div class="kehadiran-stat__value">{{ attendanceStats.mayaCount }}</div>
<button type="button" class="kehadiran-chip" <div class="kehadiran-stat__meta">{{ attendanceStats.mayaPct }}%</div>
:class="{ 'is-active': current_fizikal_reg === 'verified' }" </div>
@click="setFizikalRegFilter('verified')"> <div class="kehadiran-stat kehadiran-stat--muted">
Disahkan <div class="kehadiran-stat__label">Jumlah Kehadiran</div>
</button> <div class="kehadiran-stat__value">{{ attendanceStats.totalAttendance }}</div>
<div class="kehadiran-stat__meta">{{ attendanceStats.totalPct }}%</div>
</div>
</div> </div>
<!-- <a class="btn btn-warning" :href="getPDFKehadiranurl(1)">Muat Turun PDF</a> --> <!-- <a class="btn btn-warning" :href="getPDFKehadiranurl(1)">Muat Turun PDF</a> -->
<admin-data-table :headers="kehadiranTableHeaders" :items="voterListItems" :loading="voterLoading" <admin-data-table :headers="kehadiranTableHeaders" :items="voterListItems" :loading="voterLoading"
@@ -347,6 +414,46 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.kehadiran-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 10px 0 14px;
}
.kehadiran-stat {
border: 1px solid #e5e7eb;
background: #fff;
border-radius: 10px;
padding: 10px 12px;
}
.kehadiran-stat--muted {
background: #f9fafb;
}
.kehadiran-stat__label {
color: #6b7280;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.2px;
}
.kehadiran-stat__value {
margin-top: 4px;
font-size: 22px;
font-weight: 900;
color: #111827;
line-height: 1.1;
}
.kehadiran-stat__meta {
margin-top: 4px;
color: #374151;
font-size: 12px;
font-weight: 700;
}
.kehadiran-search { .kehadiran-search {
margin: 10px 0 12px; margin: 10px 0 12px;
display: flex; display: flex;
@@ -366,23 +473,49 @@
height: 34px; height: 34px;
} }
.kehadiran-filters { .kehadiran-filter-panel {
margin: 6px 0 14px; margin: 10px 0 14px;
display: flex; border: 1px solid #e5e7eb;
align-items: center; border-radius: 10px;
flex-wrap: wrap; background: #f9fafb;
gap: 8px; padding: 12px 14px;
} }
.kehadiran-filters--secondary { .kehadiran-filter-panel__title {
margin-top: 0; font-size: 11px;
} font-weight: 800;
text-transform: uppercase;
.kehadiran-filters__label { letter-spacing: 0.06em;
color: #6b7280; color: #6b7280;
margin-bottom: 10px;
}
.kehadiran-filter-groups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.kehadiran-filter-group {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px 12px;
}
.kehadiran-filter-group__title {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 700;
margin-right: 4px; color: #374151;
margin-bottom: 8px;
line-height: 1.3;
}
.kehadiran-filter-group__chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
} }
.kehadiran-chip { .kehadiran-chip {
@@ -556,6 +689,8 @@ export default {
page: this.current_page, page: this.current_page,
kehadiran: this.current_kehadiran || undefined, kehadiran: this.current_kehadiran || undefined,
fizikal_reg: this.current_fizikal_reg || undefined, fizikal_reg: this.current_fizikal_reg || undefined,
has_voted: this.current_has_voted || undefined,
cash_status: this.current_cash_status || undefined,
no_anggota: this.searchNoAnggota || undefined, no_anggota: this.searchNoAnggota || undefined,
no_kp: this.searchNoKp || undefined no_kp: this.searchNoKp || undefined
} }
@@ -605,10 +740,28 @@ export default {
this.$router.replace({ query: q }); this.$router.replace({ query: q });
}, },
setHasVotedFilter: function (val) {
var q = Object.assign({}, this.$route.query);
if (val === '1' || val === '0') q.has_voted = val;
else delete q.has_voted;
q.page = 1;
this.$router.replace({ query: q });
},
setCashStatusFilter: function (val) {
var q = Object.assign({}, this.$route.query);
if (val === 'paid' || val === 'unpaid') q.cash_status = val;
else delete q.cash_status;
q.page = 1;
this.$router.replace({ query: q });
},
paginationQueryForPage: function (pageNum) { paginationQueryForPage: function (pageNum) {
var q = { page: pageNum }; var q = { page: pageNum };
if (this.current_kehadiran) q.kehadiran = this.current_kehadiran; if (this.current_kehadiran) q.kehadiran = this.current_kehadiran;
if (this.current_fizikal_reg) q.fizikal_reg = this.current_fizikal_reg; if (this.current_fizikal_reg) q.fizikal_reg = this.current_fizikal_reg;
if (this.current_has_voted) q.has_voted = this.current_has_voted;
if (this.current_cash_status) q.cash_status = this.current_cash_status;
return q; return q;
}, },
@@ -856,6 +1009,14 @@ export default {
'$route.query.fizikal_reg': function () { '$route.query.fizikal_reg': function () {
$.notifyClose(); $.notifyClose();
this.refreshVoter(); this.refreshVoter();
},
'$route.query.has_voted': function () {
$.notifyClose();
this.refreshVoter();
},
'$route.query.cash_status': function () {
$.notifyClose();
this.refreshVoter();
} }
}, },
@@ -910,6 +1071,22 @@ export default {
return this.$route.query.fizikal_reg ? this.$route.query.fizikal_reg : null; return this.$route.query.fizikal_reg ? this.$route.query.fizikal_reg : null;
}, },
current_has_voted: function () {
var v = this.$route.query.has_voted;
if (v === '1' || v === '0') {
return v;
}
return null;
},
current_cash_status: function () {
var v = this.$route.query.cash_status;
if (v === 'paid' || v === 'unpaid') {
return v;
}
return null;
},
verifyModalVoterNeedsLowSahamNote: function () { verifyModalVoterNeedsLowSahamNote: function () {
if (!this.verifyModalVoter) return false; if (!this.verifyModalVoter) return false;
var v = this.verifyModalVoter; var v = this.verifyModalVoter;
@@ -927,12 +1104,12 @@ export default {
attendancePercentage() { attendancePercentage() {
// Count total members who attended physically (Fizikal) // Count total members who attended physically (Fizikal)
const fizikalCount = this.voterListItems.reduce((total, voter) => { const fizikalCount = this.voterListItems.reduce((total, voter) => {
return voter.kehadiran === 1 ? total + 1 : total; return Number(voter && voter.kehadiran) === 1 ? total + 1 : total;
}, 0); }, 0);
// Count total members who attended virtually (Maya) // Count total members who attended virtually (Maya)
const mayaCount = this.voterListItems.reduce((total, voter) => { const mayaCount = this.voterListItems.reduce((total, voter) => {
return voter.kehadiran === 2 ? total + 1 : total; return Number(voter && voter.kehadiran) === 2 ? total + 1 : total;
}, 0); }, 0);
// Calculate total attendance percentage // Calculate total attendance percentage
@@ -941,6 +1118,29 @@ export default {
return totalMembers === 0 ? 0 : ((totalAttendance / totalMembers) * 100).toFixed(2); return totalMembers === 0 ? 0 : ((totalAttendance / totalMembers) * 100).toFixed(2);
}, },
attendanceStats: function () {
var items = this.voterListItems || [];
var fizikalCount = items.reduce(function (total, voter) {
return Number(voter && voter.kehadiran) === 1 ? total + 1 : total;
}, 0);
var mayaCount = items.reduce(function (total, voter) {
return Number(voter && voter.kehadiran) === 2 ? total + 1 : total;
}, 0);
var totalAttendance = fizikalCount + mayaCount;
var totalMembers = items.length;
var denom = totalMembers === 0 ? 1 : totalMembers;
return {
fizikalCount: fizikalCount,
mayaCount: mayaCount,
totalAttendance: totalAttendance,
fizikalPct: totalMembers === 0 ? '0.00' : ((fizikalCount / denom) * 100).toFixed(2),
mayaPct: totalMembers === 0 ? '0.00' : ((mayaCount / denom) * 100).toFixed(2),
totalPct: totalMembers === 0 ? '0.00' : ((totalAttendance / denom) * 100).toFixed(2),
};
},
} }
@@ -133,19 +133,7 @@
:value="data.user.unit" required /> :value="data.user.unit" required />
</div> </div>
</div> </div>
</form> </form>
<div class="attendance-hero">
<div class="attendance-section-kicker">Pengesahan Kehadiran</div>
<h4 class="attendance-page-title">Sahkan Kehadiran Mesyuarat Agung</h4>
<p class="attendance-page-subtitle">
Sila semak maklumat anda, pilih kaedah kehadiran, dan sahkan penyertaan sebelum meneruskan.
</p>
<a :href="etikaUrl" target="_blank" class="attendance-download-button">
Muat Turun Etika Mesyuarat Agung
</a>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -526,8 +514,6 @@ export default {
// }) // })
// }, // },
edit: function () { edit: function () {
if (this.loading) return; if (this.loading) return;
@@ -144,7 +144,7 @@
</div> </div>
<!-- Kad Ahli Digital --> <!-- Kad Ahli Digital -->
<div class="col-xs-12"> <!-- <div class="col-xs-12">
<div class="home-section-container home-section-container--kad"> <div class="home-section-container home-section-container--kad">
<h2 class="home-section-heading"> <h2 class="home-section-heading">
<span class="home-section-heading-icon" aria-hidden="true"><i class="fa fa-credit-card"></i></span> <span class="home-section-heading-icon" aria-hidden="true"><i class="fa fa-credit-card"></i></span>
@@ -188,7 +188,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div> -->
<!-- Saham Vote Blocked Message --> <!-- Saham Vote Blocked Message -->
<div v-if="showSahamVoteBlockedMessage" class="row home-saham-block-row"> <div v-if="showSahamVoteBlockedMessage" class="row home-saham-block-row">
@@ -220,6 +220,13 @@ export default {
}, },
computed: { computed: {
etikaUrl: function () {
var url = this.portalSettings && this.portalSettings.attendance_etika_url
? String(this.portalSettings.attendance_etika_url).trim()
: '';
return url;
},
bannerUrl() { bannerUrl() {
const p = this.portalSettings && this.portalSettings.home_banner_image_path const p = this.portalSettings && this.portalSettings.home_banner_image_path
? this.portalSettings.home_banner_image_path ? this.portalSettings.home_banner_image_path
@@ -296,7 +303,17 @@ export default {
href: this.matLinkUrl, href: this.matLinkUrl,
variant: "home-cta-link--violet", variant: "home-cta-link--violet",
external: true external: true
} },
// Muat Turun Etika Mesyuarat Agung
{
key: "etika-mesyuarat",
title: "Etika Mesyuarat Agung",
desc: "Muat Turun etika mesyuarat agung",
icon: "fa-file-pdf-o",
href: this.etikaUrl,
variant: "home-cta-link--grey",
external: true
},
]; ];
if ( if (
@@ -328,15 +345,15 @@ export default {
}); });
} }
links.push({ // links.push({
key: "penyata", // key: "penyata",
title: "Penyata Ahli", // title: "Penyata Ahli",
desc: "Lihat dan kemaskini penyata ahli", // desc: "Lihat dan kemaskini penyata ahli",
icon: "fa-file-text-o", // icon: "fa-file-text-o",
route: { name: "penyata", params: { no_anggota: noAnggota } }, // route: { name: "penyata", params: { no_anggota: noAnggota } },
variant: "home-cta-link--amber", // variant: "home-cta-link--amber",
external: false // external: false
}); // });
return links; return links;
}, },
@@ -884,6 +901,15 @@ export default {
box-shadow: 0 18px 44px rgba(245, 158, 11, 0.45); box-shadow: 0 18px 44px rgba(245, 158, 11, 0.45);
} }
.home-cta-link--grey {
background: linear-gradient(135deg, #64748b 0%, #94a3b8 50%, #64748b 100%);
box-shadow: 0 14px 36px rgba(100, 116, 139, 0.3);
}
.home-cta-link--grey:hover {
box-shadow: 0 18px 44px rgba(100, 116, 139, 0.42);
}
.home-cta-icon { .home-cta-icon {
flex-shrink: 0; flex-shrink: 0;
width: 52px; width: 52px;
@@ -1,36 +1,19 @@
<template> <template>
<div> <div>
<div v-if="!loading"> <div v-if="!loading">
<nav class="navbar navbar-default navbar-fixed-top floating-header logo-header" id="nav"> <FloatingNavbar :logo-href="data.baseURL" main-nav-class="voter-nav-main">
<div class="container"> <template #right>
<div class="navbar-header logo-section"> <li class="dropdown">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#myNavbar"> <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true"
<span class="icon-bar"></span> aria-expanded="false">
<span class="icon-bar"></span> <span class="fa fa-user"></span> {{ data.user.name }} <span class="caret"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" :href="data.baseURL">
<img src='/images/mykopkb.png' border='0' width="150" height="auto" alt="MyKoPKB Logo">
</a> </a>
</div> <ul class="dropdown-menu">
<div class="collapse navbar-collapse" id="myNavbar"> <li @click="logout()"><a>Log Keluar</a></li>
<ul class="nav navbar-nav voter-nav-main">
</ul> </ul>
<ul class="nav navbar-right navbar-nav"> </li>
<li class="dropdown"> </template>
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" </FloatingNavbar>
aria-haspopup="true" aria-expanded="false">
<span class="fa fa-user"></span> {{ data.user.name }} <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li @click="logout()"><a>Log Keluar</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<div class="voter-main"> <div class="voter-main">
<div class="voter-breadcrumb-wrap"> <div class="voter-breadcrumb-wrap">
@@ -55,7 +38,10 @@
</template> </template>
<script> <script>
import FloatingNavbar from '../../common/FloatingNavbar.vue';
export default { export default {
components: { FloatingNavbar },
data: () => ({ data: () => ({
loading: true loading: true
}), }),
@@ -132,154 +118,6 @@ export default {
<style scoped> <style scoped>
.floating-header {
position: fixed !important;
top: 16px !important;
left: 16px !important;
right: 16px !important;
width: calc(100% - 32px) !important;
margin: 0;
border-radius: 16px !important;
z-index: 2000 !important;
transition: all 0.3s ease !important;
}
.logo-header {
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.96) 0%,
rgb(8, 0, 126) 48%,
rgb(255, 0, 0) 100%) !important;
border: 1px solid rgba(255, 255, 255, 0.25) !important;
box-shadow:
0 8px 32px rgba(33, 150, 243, 0.35),
0 4px 16px rgba(2, 19, 36, 0.28) !important;
backdrop-filter: blur(12px) !important;
position: fixed;
overflow: visible;
}
.logo-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, transparent 50%, rgba(255, 255, 255, 0.05) 100%);
pointer-events: none;
z-index: 0;
}
.logo-header>* {
position: relative;
z-index: 1;
}
.logo-section {
display: flex;
align-items: center;
margin-right: 24px;
margin-left: 24px;
flex-shrink: 0;
}
#nav.navbar {
border: none;
min-height: 78px;
}
#nav .container {
width: 100%;
padding-left: 12px;
padding-right: 12px;
overflow: visible;
}
#nav .navbar-brand {
display: flex;
align-items: center;
gap: 12px;
height: auto;
padding: 14px 0;
color: #1f2937;
font-weight: 700;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
}
.logo-title {
display: inline-block;
color: #1f2937 !important;
font-size: 1.8rem;
font-weight: 700;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
}
#nav .nav>li>a,
#nav .navbar-toggle {
color: #f8fbff;
font-weight: 700;
letter-spacing: 0.02em;
}
#nav .nav>li>a:hover,
#nav .nav>li.active>a,
#nav .nav>li.active>a:hover,
#nav .nav>li.active>a:focus,
#nav .navbar-brand:hover {
color: #fff;
background: rgba(204, 0, 0, 0.833);
}
#nav .nav>li>a {
border-radius: 10px;
margin-top: 18px;
margin-bottom: 18px;
padding-top: 12px;
padding-bottom: 12px;
}
#nav .navbar-right {
margin-right: 8px;
}
#nav .navbar-nav>li,
#nav .dropdown {
position: relative;
}
#nav .dropdown-menu {
margin-top: 6px;
border: 0;
border-radius: 12px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.16);
z-index: 2101;
}
#nav .dropdown-menu>li>a {
color: #2c3e50;
}
#nav .dropdown-menu>li>a:hover {
background-color: #f39c12;
color: white;
}
#nav .navbar-toggle {
margin-top: 20px;
margin-right: 18px;
border-color: rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.08);
}
#nav .navbar-toggle .icon-bar {
background-color: #fff;
}
.navbar-brand img {
background: transparent !important;
box-shadow: none;
}
.voter-main { .voter-main {
padding-top: 112px; padding-top: 112px;
} }
@@ -336,38 +174,6 @@ export default {
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.floating-header {
top: 10px !important;
left: 10px !important;
right: 10px !important;
width: calc(100% - 20px) !important;
border-radius: 14px !important;
}
.logo-section {
margin-left: 16px;
margin-right: 8px;
}
.logo-title {
font-size: 1.4rem;
}
#nav .navbar-brand img {
width: 120px;
}
#nav .navbar-collapse {
border-top: 0;
box-shadow: none;
padding-bottom: 12px;
overflow: visible;
}
#nav .nav>li>a {
margin: 4px 10px;
}
.voter-main { .voter-main {
padding-top: 102px; padding-top: 102px;
} }
@@ -41,6 +41,13 @@
loading: false loading: false
}; };
}, },
created() {
if (this.util.isAdminPortalSession()) {
this.$router.replace(this.util.getAdminEntryRoute());
}
},
methods: { methods: {
login() { login() {
this.startLoading(); this.startLoading();
@@ -148,6 +148,8 @@ export default {
.then(response => { .then(response => {
vm.stopLoading(); vm.stopLoading();
if (this.util.showResult(response, 'success')) { if (this.util.showResult(response, 'success')) {
localStorage.removeItem('admin_role');
localStorage.removeItem('admin_session');
localStorage['Access Token'] = `Bearer ${response.data.token}`; localStorage['Access Token'] = `Bearer ${response.data.token}`;
this.util.setAuthorization(); this.util.setAuthorization();
vm.$router.push({ name: 'Voter Home' }); vm.$router.push({ name: 'Voter Home' });
+72
View File
@@ -0,0 +1,72 @@
:root {
/*
* Centralized semantic palette — literal colors live here only.
* Primary / Secondary / Accent / Background / Surface / Text / Border
*/
--color-primary: #4F7DB8;
--color-secondary: #2F5D9A;
--color-accent: #C62828;
--color-background: #F5F9FD;
--color-surface: #ffffff;
--color-text: #1F2937;
--color-border: #D6E0EA;
/* Content on filled regions (no extra literals) */
--color-on-primary: var(--color-surface);
--color-on-accent: var(--color-surface);
/* Derived tones — color-mix + semantic vars only */
--color-accent-strong: color-mix(in srgb, var(--color-accent) 78%, var(--color-text) 22%);
--color-accent-deep: color-mix(in srgb, var(--color-accent) 52%, var(--color-text) 48%);
--color-primary-soft-glow: color-mix(in srgb, var(--color-primary) 22%, transparent);
--color-accent-soft-glow: color-mix(in srgb, var(--color-accent) 18%, transparent);
--color-secondary-soft-glow: color-mix(in srgb, var(--color-secondary) 12%, transparent);
--color-text-soft-shadow: color-mix(in srgb, var(--color-text) 16%, transparent);
--color-surface-veil-08: color-mix(in srgb, var(--color-surface) 8%, transparent);
--color-surface-veil-10: color-mix(in srgb, var(--color-surface) 10%, transparent);
--color-surface-veil-14: color-mix(in srgb, var(--color-surface) 14%, transparent);
--color-surface-veil-25: color-mix(in srgb, var(--color-surface) 25%, transparent);
--color-surface-veil-35: color-mix(in srgb, var(--color-surface) 35%, transparent);
--color-surface-veil-92: color-mix(in srgb, var(--color-surface) 92%, transparent);
--color-accent-veil-08: color-mix(in srgb, var(--color-accent) 8%, transparent);
--color-accent-veil-92: color-mix(in srgb, var(--color-accent) 92%, transparent);
--color-nav-ink: color-mix(in srgb, var(--color-surface) 97%, var(--color-border) 3%);
/* Admin data table — maps to semantic palette */
--admin-table-header-bg: color-mix(in srgb, var(--color-secondary) 42%, var(--color-text) 58%);
--admin-table-header-color: var(--color-on-primary);
--admin-table-border-color: var(--color-border);
--admin-table-row-border-color: color-mix(in srgb, var(--color-border) 52%, var(--color-surface) 48%);
--admin-table-shell-bg: var(--color-surface);
--admin-table-row-hover-bg: color-mix(in srgb, var(--color-primary) 9%, var(--color-surface) 91%);
--admin-table-footer-bg: var(--color-background);
--admin-table-text-muted: color-mix(in srgb, var(--color-text) 58%, var(--color-border) 42%);
--admin-table-text-strong: var(--color-text);
--admin-table-footer-mid-text: color-mix(in srgb, var(--color-text) 74%, var(--color-border) 26%);
--admin-table-pagination-disabled: color-mix(in srgb, var(--color-border) 66%, var(--color-background) 34%);
/* Floating header — gradient + glass (semantic + derived only) */
--floating-header-bg-1: var(--color-primary);
--floating-header-bg-2: var(--color-secondary);
--floating-header-bg-3: var(--color-accent-strong);
--floating-header-bg-4: var(--color-accent-deep);
--floating-header-border: var(--color-surface-veil-14);
--floating-header-shadow-1: var(--color-primary-soft-glow);
--floating-header-shadow-2: var(--color-accent-soft-glow);
--floating-header-overlay-1: var(--color-surface-veil-10);
--floating-header-overlay-2: var(--color-accent-veil-08);
--floating-header-hover-bg: var(--color-accent-veil-92);
--floating-header-nav-text: var(--color-nav-ink);
--floating-header-brand-text: var(--color-on-primary);
--floating-header-brand-shadow: var(--color-surface-veil-25);
/* Floating navbar — dropdowns & chrome */
--floating-header-dropdown-hover-bg: var(--color-accent);
--floating-header-dropdown-hover-text: var(--color-on-accent);
--floating-header-dropdown-menu-shadow: var(--color-text-soft-shadow);
--floating-header-toggle-border: var(--color-surface-veil-35);
--floating-header-toggle-bg: var(--color-surface-veil-08);
--floating-header-icon-bar: var(--color-surface);
--floating-header-mobile-submenu-bg: var(--color-surface-veil-92);
--floating-header-mobile-submenu-shadow: var(--color-secondary-soft-glow);
}
+38 -20
View File
@@ -17,6 +17,23 @@ const methods = {
return localStorage['Access Token'] ? true : false; return localStorage['Access Token'] ? true : false;
}, },
/**
* True when the browser session is for the admin portal (not a voter-only token).
*/
isAdminPortalSession: function () {
return localStorage.getItem('admin_role') != null || localStorage.getItem('admin_session') === '1';
},
/**
* Route name to open for the current admin session (role 3 is restricted to kehadiran).
*/
getAdminEntryRoute: function () {
if (localStorage.getItem('admin_role') === '3') {
return { name: 'Kehadiran Calon' };
}
return { name: 'Admin Home' };
},
/** /**
* Log a messages * Log a messages
* @param {String} Message * @param {String} Message
@@ -60,7 +77,7 @@ const methods = {
<div class="progress-bar progress-bar-green" role="progressbar" aria-valuenow="${progress}" aria-valuemin="0" aria-valuemax="100" style="width: ${progress}%"> <div class="progress-bar progress-bar-green" role="progressbar" aria-valuenow="${progress}" aria-valuemin="0" aria-valuemax="100" style="width: ${progress}%">
</div> </div>
</div>`; </div>`;
icon = 'fa fa-refresh fa-spin'; icon = 'fa fa-refresh fa-spin';
break; break;
} }
@@ -70,7 +87,7 @@ const methods = {
}, { }, {
type: type, type: type,
delay: delay, delay: delay,
placement: {from: 'top', align: 'center'} placement: { from: 'top', align: 'center' }
}); });
}, },
@@ -81,7 +98,7 @@ const methods = {
*/ */
setAuthorization: function () { setAuthorization: function () {
axios.defaults.headers.common['Authorization'] = localStorage['Access Token']; axios.defaults.headers.common['Authorization'] = localStorage['Access Token'];
$.ajaxSetup({headers:{'Authorization': localStorage['Access Token']}}); $.ajaxSetup({ headers: { 'Authorization': localStorage['Access Token'] } });
}, },
@@ -100,14 +117,13 @@ const methods = {
* @param {Int} Response Status * @param {Int} Response Status
* @return {String} Error Message * @return {String} Error Message
*/ */
getErrorMessage: function (data, status) getErrorMessage: function (data, status) {
{
var message = ''; var message = '';
switch (status) { switch (status) {
case 200: case 200:
message = data.message; message = data.message;
break; break;
case 422: case 422:
// Laravel validation errors are usually: // Laravel validation errors are usually:
// { message: "...", errors: { field: ["..."] } } // { message: "...", errors: { field: ["..."] } }
// but some endpoints may return { status, message }. // but some endpoints may return { status, message }.
@@ -148,10 +164,10 @@ const methods = {
} }
break; break;
case 401: case 401:
message = 'You need to login first.'; message = 'Sila log masuk terlebih dahulu.';
break; break;
default: default:
message = 'An error occured.'; message = 'Ralat telah berlaku.';
break; break;
} }
return message; return message;
@@ -173,8 +189,8 @@ const methods = {
* @param {library} Library used (ajax|axios) Default : axios * @param {library} Library used (ajax|axios) Default : axios
*/ */
showResult: function (response, type, library = 'axios') { showResult: function (response, type, library = 'axios') {
this.log (response); this.log(response);
var isSuccess = data => data.status == 'success'; var isSuccess = data => data.status == 'success';
if (library == 'axios') { if (library == 'axios') {
if (type == 'success') { if (type == 'success') {
if (isSuccess(response.data)) { if (isSuccess(response.data)) {
@@ -184,11 +200,11 @@ const methods = {
this.notify(response.data.message, 'error'); this.notify(response.data.message, 'error');
} }
} else { } else {
var status = response.response ? response.response.status : 500; var status = response.response ? response.response.status : 500;
var message = this.getErrorMessage(response.response.data, status); var message = this.getErrorMessage(response.response.data, status);
this.notify(message, 'error'); this.notify(message, 'error');
return status; return status;
} }
} else if (library == 'ajax') { } else if (library == 'ajax') {
if (type == 'success') { if (type == 'success') {
if (isSuccess(response)) { if (isSuccess(response)) {
@@ -198,7 +214,7 @@ const methods = {
this.notify(response.message, 'error'); this.notify(response.message, 'error');
} }
} else if (type == 'error') { } else if (type == 'error') {
var status = response.status; var status = response.status;
var message = this.getErrorMessage(response.responseText, status); var message = this.getErrorMessage(response.responseText, status);
this.notify(message, 'error'); this.notify(message, 'error');
return status; return status;
@@ -217,12 +233,12 @@ const data = {
positions: [], positions: [],
position: {}, position: {},
partylists: [], partylists: [],
partylist:{}, partylist: {},
voters:{}, voters: {},
voter:{}, voter: {},
nominees:[], nominees: [],
nominee:{}, nominee: {},
admins:[], admins: [],
information: {}, information: {},
result: [], result: [],
results: [], results: [],
@@ -236,4 +252,6 @@ export default {
data: data data: data
} }
} }
} }
export { methods };
+2
View File
@@ -72,9 +72,11 @@ Route::prefix('v1')->group(function () { //Version 1 of my Rest API
Route::prefix('election')->group(function () { Route::prefix('election')->group(function () {
Route::get('result/{id}', 'API\v1\Election\ResultController@finalResult'); Route::get('result/{id}', 'API\v1\Election\ResultController@finalResult');
Route::get('{id}/attendance-summary', 'API\v1\Election\AttendanceSummaryController');
Route::get('results', 'API\v1\Election\ResultController'); Route::get('results', 'API\v1\Election\ResultController');
Route::post('start', 'API\v1\Election\StartController'); Route::post('start', 'API\v1\Election\StartController');
Route::post('stop', 'API\v1\Election\StopController'); Route::post('stop', 'API\v1\Election\StopController');
Route::post('register-next', 'API\v1\Election\RegisterNextSessionController');
Route::get('', 'API\v1\Election\GetController'); Route::get('', 'API\v1\Election\GetController');
}); });
+8
View File
@@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}