DONE: centralize theme color, interactive dashboard, increase winner result, refer TODO
This commit is contained in:
@@ -13,3 +13,16 @@
|
||||
[x] buang flow pengesahan
|
||||
[x] tambah siapa yang bagi duit (activity log)
|
||||
[ ] 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();
|
||||
|
||||
$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([
|
||||
'status' => 'success',
|
||||
'election_id' => $electionId,
|
||||
'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);
|
||||
|
||||
Util::ensureDraftElectionAfterCompletedCycle();
|
||||
|
||||
if ($this->hasNoNominee())
|
||||
return response()->json([
|
||||
'status' => 'failed',
|
||||
|
||||
@@ -49,7 +49,6 @@ class StopController extends Controller
|
||||
'name'=>$request->name,
|
||||
'end'=>date('Y-m-d H:i:s')
|
||||
]);
|
||||
Election::create();
|
||||
}
|
||||
|
||||
private function validateRequest($request)
|
||||
|
||||
@@ -13,6 +13,7 @@ class AddController extends Controller
|
||||
|
||||
public function __invoke (Request $request)
|
||||
{
|
||||
Util::ensureDraftElectionAfterCompletedCycle();
|
||||
$this->validateRequest($request);
|
||||
$nominee = $this->insertNominee($request);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class AddController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
Util::ensureDraftElectionAfterCompletedCycle();
|
||||
$this->validateRequest($request);
|
||||
$this->insertPartylist($request);
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Util;
|
||||
use App\Election;
|
||||
use App\Position;
|
||||
|
||||
class AddPositionController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
Util::ensureDraftElectionAfterCompletedCycle();
|
||||
$this->validateRequest($request);
|
||||
$this->insertPosition($request);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class AddController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
Util::ensureDraftElectionAfterCompletedCycle();
|
||||
$this->validateRequest($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 $q->paginate(1000);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class SyncApplicableVotersController extends Controller
|
||||
'selected_no_kp.*' => 'nullable|string|max:60',
|
||||
]);
|
||||
|
||||
Util::ensureDraftElectionAfterCompletedCycle();
|
||||
$electionId = Util::getCurrentElection();
|
||||
$selected = collect($request->input('selected_no_kp', []))
|
||||
->map(function ($v) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Election;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
@@ -37,6 +38,18 @@ class Util extends Controller
|
||||
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
|
||||
* @param {Request} $request
|
||||
|
||||
Generated
+19
@@ -8,6 +8,7 @@
|
||||
"@bachdgvn/vue-otp-input": "^1.0.8",
|
||||
"axios": "^1.13.2",
|
||||
"bootstrap": "^5.3.8",
|
||||
"chart.js": "^4.5.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"jsbarcode": "^3.12.3",
|
||||
"jspdf": "^4.2.1",
|
||||
@@ -2055,6 +2056,12 @@
|
||||
"@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": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
|
||||
@@ -3946,6 +3953,18 @@
|
||||
"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": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@bachdgvn/vue-otp-input": "^1.0.8",
|
||||
"axios": "^1.13.2",
|
||||
"bootstrap": "^5.3.8",
|
||||
"chart.js": "^4.5.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"jsbarcode": "^3.12.3",
|
||||
"jspdf": "^4.2.1",
|
||||
|
||||
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 149 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 899 KiB |
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
*
|
||||
|
||||
Vendored
+6
-1
@@ -1,8 +1,9 @@
|
||||
require('./bootstrap.js');
|
||||
import './styles/theme.css';
|
||||
require('./helper/autocloseMenu.js');
|
||||
//window.VueRouter = require('vue-router').default;
|
||||
//import VueRouter from 'vue-router';
|
||||
import Util from './util.js';
|
||||
import Util, { methods as utilMethods } from './util.js';
|
||||
import routes from './routesIndex.js';
|
||||
import OtpInput from "@bachdgvn/vue-otp-input";
|
||||
|
||||
@@ -19,6 +20,10 @@ const router = new VueRouter({
|
||||
});
|
||||
|
||||
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') {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -462,19 +462,20 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.admin-data-table__toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-data-table {
|
||||
--adt-header-bg: #1976d2;
|
||||
--adt-header-color: #fff;
|
||||
--adt-header-bg: var(--admin-table-header-bg);
|
||||
--adt-header-color: var(--admin-table-header-color);
|
||||
--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-row-hover: #e3f2fd;
|
||||
--adt-footer-bg: #fafafa;
|
||||
--adt-text-muted: #757575;
|
||||
--adt-row-hover: var(--admin-table-row-hover-bg);
|
||||
--adt-footer-bg: var(--admin-table-footer-bg);
|
||||
--adt-text-muted: var(--admin-table-text-muted);
|
||||
--adt-font-size: 16px;
|
||||
--adt-header-font-size: 16px;
|
||||
}
|
||||
@@ -484,7 +485,7 @@ export default {
|
||||
border: var(--adt-border);
|
||||
box-shadow: var(--adt-shadow);
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
background: var(--admin-table-shell-bg);
|
||||
}
|
||||
|
||||
.admin-data-table__scroll {
|
||||
@@ -582,12 +583,12 @@ export default {
|
||||
.admin-data-table__table tbody td {
|
||||
padding: 12px 16px;
|
||||
vertical-align: middle;
|
||||
border-top: 1px solid #eee;
|
||||
border-top: 1px solid var(--admin-table-row-border-color);
|
||||
font-size: var(--adt-font-size);
|
||||
}
|
||||
|
||||
.admin-data-table__table tbody tr:first-child:not(.admin-data-table__state-row) td {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
border-top: 1px solid var(--admin-table-border-color);
|
||||
}
|
||||
|
||||
.admin-data-table__table tbody tr.admin-data-table__state-row td {
|
||||
@@ -617,7 +618,7 @@ export default {
|
||||
.admin-data-table__state-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #424242;
|
||||
color: var(--admin-table-text-strong);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
@@ -663,7 +664,7 @@ export default {
|
||||
.admin-data-table__footer-mid {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #616161;
|
||||
color: var(--admin-table-footer-mid-text);
|
||||
}
|
||||
|
||||
.admin-data-table__footer-right {
|
||||
@@ -681,7 +682,7 @@ export default {
|
||||
}
|
||||
|
||||
.admin-data-table__pagination>li.disabled>a {
|
||||
color: #bbb;
|
||||
color: var(--admin-table-pagination-disabled);
|
||||
pointer-events: none;
|
||||
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"
|
||||
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-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Tutup">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<h4 class="modal-title" id="roulette-winner-title">
|
||||
<h3 class="modal-title roulette-winner-modal__title" id="roulette-winner-title">
|
||||
Tahniah
|
||||
</h4>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="modal-body text-center" v-if="modalWinnerDisplay">
|
||||
<p class="roulette-winner-modal-round text-muted">
|
||||
@@ -1273,21 +1273,84 @@ export default {
|
||||
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;
|
||||
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 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
font-size: clamp(1.5rem, 3.5vw, 3.25rem);
|
||||
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 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
font-size: clamp(3rem, 12vw, 8.5rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.12;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
letter-spacing: -0.03em;
|
||||
text-shadow: 0 2px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.roulette-winner-absent-hint {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="roulette-demo-page container-fluid">
|
||||
<header class="roulette-page-header">
|
||||
<div class="roulette-page-header-inner">
|
||||
<img src="/images/MyKoPKB-logo.png" alt="MyKoPKB" class="roulette-page-header-logo" />
|
||||
<Logo :href="'/admin/'" />
|
||||
<h1 class="roulette-demo-title">Sesi Cabutan Bertuah — Kehadiran Fizikal</h1>
|
||||
<button type="button" class="btn btn-default btn-sm roulette-bg-music-btn"
|
||||
:aria-pressed="bgMusicPlaying ? 'true' : 'false'"
|
||||
@@ -81,6 +81,7 @@
|
||||
|
||||
<script>
|
||||
import RouletteEliminationWheel from './RouletteEliminationWheel.vue';
|
||||
import Logo from '../common/Logo.vue';
|
||||
|
||||
function buildLabelsFromVoters(voters) {
|
||||
if (!voters || !voters.length) {
|
||||
@@ -134,6 +135,7 @@ export default {
|
||||
|
||||
components: {
|
||||
RouletteEliminationWheel,
|
||||
Logo,
|
||||
},
|
||||
|
||||
data: function () {
|
||||
|
||||
@@ -9,13 +9,16 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">E-mail</label>
|
||||
<input type="email" name="email" class="form-control" :value="data.user.email" required/>
|
||||
<label for="email">Emel</label>
|
||||
<input type="email" name="email" class="form-control" :value="data.user.email" required
|
||||
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">
|
||||
<input type="submit" value="Submit" class="btn btn-info"/>
|
||||
<router-link :to="{name: 'Update Password'}" class="btn btn-default">Tukar Kata Laluan</router-link>
|
||||
<input type="submit" value="Hantar" class="btn btn-info" />
|
||||
<router-link :to="{ name: 'Update Password' }" class="btn btn-default">Tukar Kata
|
||||
Laluan</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,40 @@
|
||||
|
||||
<div v-if="error" class="alert alert-danger">{{ error }}</div>
|
||||
|
||||
<div v-if="!error" class="allowance-section">
|
||||
<h5 class="allowance-section__title">Anggaran elaun yang perlu dibayar</h5>
|
||||
<div v-if="!loading" class="row allowance-stat-row">
|
||||
<div class="col-sm-4">
|
||||
<div class="allowance-stat-card">
|
||||
<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>
|
||||
|
||||
<div v-if="!error" class="allowance-section allowance-section--table">
|
||||
<h5 class="allowance-section__title">Bayaran mengikut pentadbir</h5>
|
||||
<admin-data-table :headers="headers" :items="items" :loading="loading" :show-pagination="false"
|
||||
index-title="Bil." empty-text="Tiada rekod bayaran elaun" :exportable="true">
|
||||
<template v-slot:item-admin_name="{ item }">
|
||||
@@ -38,14 +72,14 @@
|
||||
{{ 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) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -55,6 +89,11 @@ export default {
|
||||
loading: false,
|
||||
error: '',
|
||||
rows: [],
|
||||
outstandingCents: 0,
|
||||
outstandingCashCount: 0,
|
||||
outstandingBankCount: 0,
|
||||
defaultCashCents: 30000,
|
||||
defaultBankCents: 15000,
|
||||
methodFilter: '',
|
||||
headers: [
|
||||
{
|
||||
@@ -107,6 +146,14 @@ export default {
|
||||
grandTotalCents: function () {
|
||||
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: {
|
||||
@@ -121,11 +168,14 @@ export default {
|
||||
.then((response) => {
|
||||
if (!response || !response.data || response.data.status !== 'success') {
|
||||
this.error = (response && response.data && response.data.message) ? response.data.message : 'Gagal memuatkan ringkasan.';
|
||||
this.applySummaryMeta(null);
|
||||
return;
|
||||
}
|
||||
this.rows = response.data.rows || [];
|
||||
this.applySummaryMeta(response.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.applySummaryMeta(null);
|
||||
this.error =
|
||||
(error && error.response && error.response.data && 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) {
|
||||
var n = Number(cents || 0) / 100;
|
||||
return n.toLocaleString('en-MY', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
@@ -144,3 +211,71 @@ export default {
|
||||
},
|
||||
};
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
.final-dashboard {
|
||||
margin-top: 12px;
|
||||
padding-bottom: 28px;
|
||||
}
|
||||
|
||||
.final-dashboard__container {
|
||||
width: 100%;
|
||||
max-width: 1760px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.final-split-row {
|
||||
margin-left: -10px;
|
||||
margin-right: -10px;
|
||||
}
|
||||
|
||||
.final-split-col {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.final-split-col {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.final-dashboard__shell,
|
||||
.final-attendance-shell {
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.final-split-col--dashboard .final-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.final-split-col--attendance .final-attendance__charts--standalone {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.final-split-col--dashboard .final-chart-wrap {
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.final-split-col--dashboard .final-chart-wrap--doughnut {
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.final-split-col--attendance .final-attendance__chart-wrap--donut {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.final-dashboard__shell {
|
||||
padding: 22px 22px 26px;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
box-shadow: 0 14px 36px rgba(35, 64, 97, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.final-attendance-shell {
|
||||
padding: 22px 22px 26px;
|
||||
border: 1px solid #dce8e4;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f4faf7 100%);
|
||||
box-shadow: 0 14px 36px rgba(35, 64, 97, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.final-attendance-shell__head {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.final-attendance-shell__kicker {
|
||||
color: #00897b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.final-attendance-shell__title {
|
||||
margin: 0 0 6px;
|
||||
font-weight: 800;
|
||||
color: #17324d;
|
||||
}
|
||||
|
||||
.final-vote-summary {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.final-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #17324d;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.final-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.final-kicker {
|
||||
color: #2f80ed;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.final-title {
|
||||
margin: 0 0 6px;
|
||||
font-weight: 800;
|
||||
color: #17324d;
|
||||
}
|
||||
|
||||
.final-subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.final-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.final-stat {
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 10px 22px rgba(35, 64, 97, 0.06);
|
||||
}
|
||||
|
||||
.final-stat__label {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.final-stat__value {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
color: #17324d;
|
||||
}
|
||||
|
||||
.final-stat__value--text {
|
||||
font-size: 14px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.final-stat__hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.final-attendance__meta {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.final-attendance__meta-sep {
|
||||
margin: 0 6px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.final-attendance__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.final-attendance__charts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.final-attendance__charts--standalone {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.final-attendance__chart-caption {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.final-attendance__chart-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.final-attendance__chart-wrap--donut {
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.final-attendance__chart-wrap--bar {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.final-attendance__state {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.final-attendance__state--muted {
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.final-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 10px 0 16px;
|
||||
}
|
||||
|
||||
.final-filters__label {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.final-chip {
|
||||
border: 1px solid #d1d5db;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.final-chip:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.final-chip.is-active {
|
||||
background: #1976d2;
|
||||
border-color: #1976d2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.final-empty {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.final-charts-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.final-charts-row--split > .col-xs-12 + .col-xs-12 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.final-card {
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 28px rgba(35, 64, 97, 0.08);
|
||||
}
|
||||
|
||||
.final-card__heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: linear-gradient(135deg, #1d4e89, #2f80ed);
|
||||
color: #fff;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.final-card__heading--alt {
|
||||
background: linear-gradient(135deg, #0d47a1, #1976d2);
|
||||
}
|
||||
|
||||
.final-card__title {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.final-card__meta {
|
||||
opacity: 0.95;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.final-card__body {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.final-chart-wrap {
|
||||
position: relative;
|
||||
height: 320px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.final-chart-wrap--doughnut {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.final-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.final-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.final-attendance__charts--standalone {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.final-chart-wrap,
|
||||
.final-chart-wrap--doughnut {
|
||||
height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.final-dashboard__container {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.final-dashboard__shell,
|
||||
.final-attendance-shell {
|
||||
padding: 16px 14px 20px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,130 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div v-for="position in positions" :key="position.id">
|
||||
<h5>{{ position.name }}</h5>
|
||||
<admin-data-table
|
||||
:headers="finalTableHeaders"
|
||||
:items="finalRowsByPosition(position.id)"
|
||||
:loading="finalLoading"
|
||||
:items-per-page="10"
|
||||
:show-pagination="true"
|
||||
empty-text="Tiada keputusan"
|
||||
:exportable="true"
|
||||
:export-file-name="'keputusan-akhir-' + position.id"
|
||||
<div class="final-dashboard">
|
||||
<div class="container-fluid final-dashboard__container">
|
||||
<div class="row final-split-row">
|
||||
<div class="col-md-6 final-split-col final-split-col--dashboard">
|
||||
<div class="final-dashboard__shell">
|
||||
<div class="final-header">
|
||||
<div>
|
||||
<div class="final-kicker">Keputusan akhir</div>
|
||||
<h4 class="final-title">Dashboard keputusan</h4>
|
||||
<div class="final-subtitle">Carta interaktif mengikut jawatan</div>
|
||||
</div>
|
||||
<div class="final-actions">
|
||||
<button type="button" class="btn btn-info" :disabled="finalLoading" @click="loadData">
|
||||
<i class="fa" :class="finalLoading ? 'fa-refresh fa-spin' : 'fa-refresh'"></i>
|
||||
Kemaskini
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="final-vote-summary">
|
||||
<div class="final-section-title">Ringkasan undian</div>
|
||||
<div class="final-stats">
|
||||
<div class="final-stat">
|
||||
<div class="final-stat__label">Jumlah undi</div>
|
||||
<div class="final-stat__value">{{ dashboardTotalVotes }}</div>
|
||||
</div>
|
||||
<div class="final-stat">
|
||||
<div class="final-stat__label">Bil. jawatan</div>
|
||||
<div class="final-stat__value">{{ positions.length }}</div>
|
||||
</div>
|
||||
<div class="final-stat">
|
||||
<div class="final-stat__label">Calon terunggul</div>
|
||||
<div class="final-stat__value final-stat__value--text">{{ topOverallLabel }}</div>
|
||||
<div class="final-stat__hint">{{ topOverallVotes }} undi</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="final-filters">
|
||||
<span class="final-filters__label">Jawatan:</span>
|
||||
<button type="button" class="final-chip" :class="{ 'is-active': selectedPositionId === 0 }" @click="setPosition(0)">
|
||||
Semua
|
||||
</button>
|
||||
<button
|
||||
v-for="position in positions"
|
||||
:key="position.id"
|
||||
type="button"
|
||||
class="final-chip"
|
||||
:class="{ 'is-active': selectedPositionId === position.id }"
|
||||
@click="setPosition(position.id)"
|
||||
>
|
||||
<template v-slot:item-percentage="{ item }">
|
||||
{{ item.percentage }}
|
||||
</template>
|
||||
</admin-data-table>
|
||||
<hr />
|
||||
{{ position.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!finalLoading && positions.length === 0" class="final-empty">
|
||||
Tiada jawatan untuk pilihan raya ini.
|
||||
</div>
|
||||
|
||||
<div v-else class="row final-charts-row final-charts-row--split">
|
||||
<div class="col-xs-12">
|
||||
<div class="panel panel-default final-card">
|
||||
<div class="panel-heading final-card__heading">
|
||||
<div class="final-card__title">{{ mainChartTitle }}</div>
|
||||
<div class="final-card__meta">{{ mainChartSubtitle }}</div>
|
||||
</div>
|
||||
<div class="panel-body final-card__body">
|
||||
<div class="final-chart-wrap">
|
||||
<bar-chart v-if="mainChartReady" v-bind="mainBarChartProps" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12">
|
||||
<div class="panel panel-default final-card">
|
||||
<div class="panel-heading final-card__heading final-card__heading--alt">
|
||||
<div class="final-card__title">{{ sideChartTitle }}</div>
|
||||
<div class="final-card__meta">{{ sideChartSubtitle }}</div>
|
||||
</div>
|
||||
<div class="panel-body final-card__body">
|
||||
<div class="final-chart-wrap final-chart-wrap--doughnut">
|
||||
<pie-chart v-if="mainChartReady" v-bind="sidePieChartProps" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 final-split-col final-split-col--attendance">
|
||||
<div class="final-attendance-shell">
|
||||
<div class="final-attendance-shell__head">
|
||||
<div>
|
||||
<div class="final-attendance-shell__kicker">Kehadiran</div>
|
||||
<h4 class="final-attendance-shell__title">Ringkasan kehadiran</h4>
|
||||
<div v-if="attendanceSummary" class="final-attendance__meta">
|
||||
Kadar daftar: <b>{{ attendanceSummary.attendance_rate }}%</b>
|
||||
<span class="final-attendance__meta-sep">·</span>
|
||||
{{ attendanceSummary.marked_present }} / {{ attendanceSummary.total_registered }} pengundi
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!attendanceSummaryReady" class="final-attendance__state">Memuatkan kehadiran…</div>
|
||||
<div v-else-if="attendanceSummary" class="final-attendance__body">
|
||||
<div
|
||||
v-if="attendanceSummary.total_registered > 0"
|
||||
class="final-attendance__charts final-attendance__charts--standalone"
|
||||
>
|
||||
<div class="final-attendance__chart-block">
|
||||
<div class="final-attendance__chart-caption">Taburan kaedah kehadiran</div>
|
||||
<div class="final-attendance__chart-wrap final-attendance__chart-wrap--donut">
|
||||
<pie-chart v-bind="attendanceBreakdownPieProps" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showAttendanceFizikalChart" class="final-attendance__chart-block">
|
||||
<div class="final-attendance__chart-caption">Status pendaftaran fizikal</div>
|
||||
<div class="final-attendance__chart-wrap final-attendance__chart-wrap--bar">
|
||||
<bar-chart v-bind="attendanceFizikalBarProps" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="final-attendance__state final-attendance__state--muted">
|
||||
Ringkasan kehadiran tidak dapat dimuatkan. Semak sambungan atau log masuk pentadbir.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,125 +132,25 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BarChart from '../../../common/BarChart.vue';
|
||||
import PieChart from '../../../common/PieChart.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BarChart,
|
||||
PieChart
|
||||
},
|
||||
|
||||
data: function () {
|
||||
return {
|
||||
nominees: [],
|
||||
results: [],
|
||||
partylists: [],
|
||||
positions: [],
|
||||
finalLoading: false,
|
||||
finalTableHeaders: [
|
||||
{ title: 'No. Anggota', key: 'no_anggota', sortable: true },
|
||||
{ title: 'Nama', key: 'name', sortable: true },
|
||||
{ title: 'Unit', key: 'unit', sortable: true },
|
||||
{ title: 'Undian', key: 'votes', sortable: true },
|
||||
{ title: 'Peratus (%)', key: 'percentage', sortable: true }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
finalRowsByPosition: function (positionId) {
|
||||
var vm = this;
|
||||
var rows = [];
|
||||
|
||||
// rows with votes
|
||||
this.results.forEach(function (result) {
|
||||
if (result.position_id == positionId) {
|
||||
var n = vm.getNominee(result.nominee_id) || {};
|
||||
rows.push({
|
||||
no_anggota: n.no_anggota || '',
|
||||
name: n.name || '',
|
||||
unit: n.unit || '',
|
||||
votes: result.votes || 0,
|
||||
percentage: vm.calculatePercentage(result.votes || 0, positionId)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// rows without votes
|
||||
this.no_votes.forEach(function (nv) {
|
||||
if (nv.position_id == positionId) {
|
||||
rows.push({
|
||||
no_anggota: nv.no_anggota || '',
|
||||
name: nv.name || '',
|
||||
unit: nv.unit || '',
|
||||
votes: 0,
|
||||
percentage: '0.00'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// total row (kept as a normal row so it exports too)
|
||||
rows.push({
|
||||
no_anggota: '',
|
||||
name: 'Jumlah Undian',
|
||||
unit: '',
|
||||
votes: this.calculateTotalVotes(positionId),
|
||||
percentage: ''
|
||||
});
|
||||
|
||||
// Sort by votes desc, but keep total row last
|
||||
var total = rows.pop();
|
||||
rows.sort(function (a, b) {
|
||||
return (b.votes || 0) - (a.votes || 0);
|
||||
});
|
||||
rows.push(total);
|
||||
|
||||
return rows;
|
||||
},
|
||||
|
||||
getNominee: function (id) {
|
||||
let nominees = this.nominees;
|
||||
for (var i in nominees)
|
||||
if (id == nominees[i]['id'])
|
||||
return nominees[i];
|
||||
return {};
|
||||
},
|
||||
|
||||
getPartylist: function (id) {
|
||||
let partylists = this.partylists;
|
||||
for (var i in partylists)
|
||||
if (id == partylists[i]['id'])
|
||||
return partylists[i]['name'];
|
||||
return 'No Partylist';
|
||||
},
|
||||
|
||||
calculatePercentage(votes, positionId) {
|
||||
const totalVotes = this.results
|
||||
.filter(result => result.position_id === positionId)
|
||||
.reduce((total, result) => total + result.votes, 0);
|
||||
|
||||
return totalVotes === 0 ? '0.00' : ((votes / totalVotes) * 100).toFixed(2);
|
||||
},
|
||||
|
||||
calculateTotalVotes(positionId) {
|
||||
return this.results
|
||||
.filter(result => result.position_id === positionId)
|
||||
.reduce((total, result) => total + result.votes, 0);
|
||||
}
|
||||
},
|
||||
|
||||
created: function () {
|
||||
this.util.notify('Loading please wait...', 'loading');
|
||||
var vm = this;
|
||||
this.finalLoading = true;
|
||||
axios.get(config.API + 'election/result/' + this.election_id)
|
||||
.then(response => {
|
||||
$.notifyClose();
|
||||
vm.nominees = response.data.nominee;
|
||||
vm.results = response.data.result;
|
||||
vm.partylists = response.data.partylist;
|
||||
vm.positions = response.data.position;
|
||||
})
|
||||
.catch(error => {
|
||||
$.notifyClose();
|
||||
vm.util.showResult(error, 'error');
|
||||
})
|
||||
.finally(function () {
|
||||
vm.finalLoading = false;
|
||||
})
|
||||
selectedPositionId: 0,
|
||||
attendanceSummary: null,
|
||||
attendanceSummaryReady: false
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
@@ -153,21 +159,276 @@ export default {
|
||||
},
|
||||
|
||||
no_votes: function () {
|
||||
let nominees = this.nominees;
|
||||
let results = this.results;
|
||||
let no_votes = [];
|
||||
var nominees = this.nominees;
|
||||
var results = this.results;
|
||||
var no_votes = [];
|
||||
for (var i in nominees) {
|
||||
var hasvote = false;
|
||||
for (var y in results) {
|
||||
if (results[y]['nominee_id'] == nominees[i]['id'])
|
||||
if (results[y]['nominee_id'] == nominees[i]['id']) {
|
||||
hasvote = true;
|
||||
}
|
||||
if (!hasvote)
|
||||
}
|
||||
if (!hasvote) {
|
||||
no_votes.push(nominees[i]);
|
||||
}
|
||||
}
|
||||
return no_votes;
|
||||
}
|
||||
},
|
||||
|
||||
dashboardTotalVotes: function () {
|
||||
return this.results.reduce(function (s, r) {
|
||||
return s + Number(r.votes || 0);
|
||||
}, 0);
|
||||
},
|
||||
|
||||
topOverallLabel: function () {
|
||||
if (!this.results.length) {
|
||||
return '—';
|
||||
}
|
||||
var best = this.results.reduce(function (a, b) {
|
||||
return Number(b.votes || 0) > Number(a.votes || 0) ? b : a;
|
||||
});
|
||||
var n = this.getNominee(best.nominee_id);
|
||||
return (n && n.name) ? n.name : '—';
|
||||
},
|
||||
|
||||
topOverallVotes: function () {
|
||||
if (!this.results.length) {
|
||||
return 0;
|
||||
}
|
||||
return this.results.reduce(function (m, r) {
|
||||
return Math.max(m, Number(r.votes || 0));
|
||||
}, 0);
|
||||
},
|
||||
|
||||
mainChartTitle: function () {
|
||||
if (this.selectedPositionId === 0) {
|
||||
return 'Jumlah undi mengikut jawatan';
|
||||
}
|
||||
var p = this.positions.find(function (x) {
|
||||
return Number(x.id) === Number(this.selectedPositionId);
|
||||
}, this);
|
||||
return (p && p.name) ? 'Undi calon — ' + p.name : 'Undi calon';
|
||||
},
|
||||
|
||||
mainChartSubtitle: function () {
|
||||
return this.selectedPositionId === 0
|
||||
? 'Perbandingan antara jawatan'
|
||||
: 'Carta mengikut calon';
|
||||
},
|
||||
|
||||
sideChartTitle: function () {
|
||||
return this.selectedPositionId === 0 ? 'Taburan undi (jawatan)' : 'Peratus undi';
|
||||
},
|
||||
|
||||
sideChartSubtitle: function () {
|
||||
return this.selectedPositionId === 0 ? 'Perkongsian jumlah undi' : 'Bahagian setiap calon';
|
||||
},
|
||||
|
||||
showAttendanceFizikalChart: function () {
|
||||
if (!this.attendanceSummary) {
|
||||
return false;
|
||||
}
|
||||
return Number(this.attendanceSummary.fizikal || 0) > 0;
|
||||
},
|
||||
|
||||
mainChartReady: function () {
|
||||
return !this.finalLoading && this.positions.length > 0;
|
||||
},
|
||||
|
||||
positionTotalRows: function () {
|
||||
var vm = this;
|
||||
return this.positions.map(function (pos) {
|
||||
var t = vm.results
|
||||
.filter(function (r) {
|
||||
return Number(r.position_id) === Number(pos.id);
|
||||
})
|
||||
.reduce(function (s, r) {
|
||||
return s + Number(r.votes || 0);
|
||||
}, 0);
|
||||
return { id: pos.id, name: pos.name, total: t };
|
||||
});
|
||||
},
|
||||
|
||||
mainBarChartProps: function () {
|
||||
if (!this.mainChartReady) {
|
||||
return {};
|
||||
}
|
||||
if (this.selectedPositionId === 0) {
|
||||
var rows = this.positionTotalRows;
|
||||
return {
|
||||
labels: rows.map(function (r) {
|
||||
return r.name.length > 22 ? r.name.slice(0, 20) + '…' : r.name;
|
||||
}),
|
||||
values: rows.map(function (r) {
|
||||
return r.total;
|
||||
}),
|
||||
datasetLabel: 'Jumlah undi'
|
||||
};
|
||||
}
|
||||
var rows = this.getSinglePositionChartRows();
|
||||
return {
|
||||
labels: rows.map(function (r) {
|
||||
return r.label;
|
||||
}),
|
||||
values: rows.map(function (r) {
|
||||
return r.votes;
|
||||
}),
|
||||
datasetLabel: 'Undi'
|
||||
};
|
||||
},
|
||||
|
||||
sidePieChartProps: function () {
|
||||
if (!this.mainChartReady) {
|
||||
return {};
|
||||
}
|
||||
if (this.selectedPositionId === 0) {
|
||||
var rows = this.positionTotalRows;
|
||||
return {
|
||||
labels: rows.map(function (r) {
|
||||
return r.name;
|
||||
}),
|
||||
values: rows.map(function (r) {
|
||||
return r.total;
|
||||
})
|
||||
};
|
||||
}
|
||||
var rows = this.getSinglePositionChartRows();
|
||||
return {
|
||||
labels: rows.map(function (r) {
|
||||
return r.label;
|
||||
}),
|
||||
values: rows.map(function (r) {
|
||||
return r.votes;
|
||||
})
|
||||
};
|
||||
},
|
||||
|
||||
attendanceBreakdownPieProps: function () {
|
||||
if (!this.attendanceSummary || this.attendanceSummary.total_registered <= 0) {
|
||||
return { labels: [], values: [] };
|
||||
}
|
||||
var s = this.attendanceSummary;
|
||||
return {
|
||||
labels: ['Fizikal', 'Maya', 'Belum ditetapkan'],
|
||||
values: [
|
||||
Number(s.fizikal || 0),
|
||||
Number(s.maya || 0),
|
||||
Number(s.unset || 0)
|
||||
]
|
||||
};
|
||||
},
|
||||
|
||||
attendanceFizikalBarProps: function () {
|
||||
if (!this.showAttendanceFizikalChart || !this.attendanceSummary) {
|
||||
return { labels: [], values: [] };
|
||||
}
|
||||
var s = this.attendanceSummary;
|
||||
return {
|
||||
labels: ['Menunggu pengesahan', 'Disahkan'],
|
||||
values: [
|
||||
Number(s.fizikal_registration_pending || 0),
|
||||
Number(s.fizikal_registration_verified || 0)
|
||||
],
|
||||
datasetLabel: 'Pengundi'
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
setPosition: function (id) {
|
||||
this.selectedPositionId = id === 0 ? 0 : Number(id);
|
||||
},
|
||||
|
||||
getSinglePositionChartRows: function () {
|
||||
var pid = this.selectedPositionId;
|
||||
var vm = this;
|
||||
var rows = this.results
|
||||
.filter(function (r) {
|
||||
return Number(r.position_id) === Number(pid);
|
||||
})
|
||||
.map(function (r) {
|
||||
var n = vm.getNominee(r.nominee_id) || {};
|
||||
var label = n.name || ('#' + r.nominee_id);
|
||||
if (label.length > 28) {
|
||||
label = label.slice(0, 26) + '…';
|
||||
}
|
||||
return {
|
||||
label: label,
|
||||
votes: Number(r.votes || 0)
|
||||
};
|
||||
});
|
||||
this.no_votes.forEach(function (nv) {
|
||||
if (Number(nv.position_id) === Number(pid)) {
|
||||
var label = nv.name || '';
|
||||
if (label.length > 28) {
|
||||
label = label.slice(0, 26) + '…';
|
||||
}
|
||||
rows.push({
|
||||
label: label || 'Calon',
|
||||
votes: 0
|
||||
});
|
||||
}
|
||||
});
|
||||
rows.sort(function (a, b) {
|
||||
return b.votes - a.votes;
|
||||
});
|
||||
return rows;
|
||||
},
|
||||
|
||||
loadData: function () {
|
||||
var vm = this;
|
||||
this.util.notify('Loading please wait...', 'loading');
|
||||
this.finalLoading = true;
|
||||
this.attendanceSummaryReady = false;
|
||||
this.attendanceSummary = null;
|
||||
|
||||
axios
|
||||
.get(config.API + 'election/' + this.election_id + '/attendance-summary')
|
||||
.then(function (r) {
|
||||
vm.attendanceSummary = r.data;
|
||||
})
|
||||
.catch(function () {
|
||||
vm.attendanceSummary = null;
|
||||
})
|
||||
.finally(function () {
|
||||
vm.attendanceSummaryReady = true;
|
||||
});
|
||||
|
||||
axios
|
||||
.get(config.API + 'election/result/' + this.election_id)
|
||||
.then(function (response) {
|
||||
$.notifyClose();
|
||||
vm.nominees = response.data.nominee || [];
|
||||
vm.results = response.data.result || [];
|
||||
vm.positions = response.data.position || [];
|
||||
})
|
||||
.catch(function (error) {
|
||||
$.notifyClose();
|
||||
vm.util.showResult(error, 'error');
|
||||
})
|
||||
.finally(function () {
|
||||
vm.finalLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
getNominee: function (id) {
|
||||
var nominees = this.nominees;
|
||||
for (var i in nominees) {
|
||||
if (id == nominees[i]['id']) {
|
||||
return nominees[i];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
created: function () {
|
||||
this.util.setTitle('MyKoPKB - Keputusan akhir');
|
||||
this.loadData();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped src="./final.css"></style>
|
||||
|
||||
@@ -1,35 +1,76 @@
|
||||
<template>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
<h4>Undian Terkini</h4>
|
||||
<div class="form-group">
|
||||
<button class="btn btn-success" v-if="data.election.status == 1 && data.user.role == 1"
|
||||
@click="util.showModal('#start-election-modal')">Mula Undian</button>
|
||||
<ul class="nav nav-tabs election-tabs">
|
||||
<li :class="{ active: activeTab === 'current' }">
|
||||
<a href="#" @click.prevent="activeTab = 'current'">Terkini</a>
|
||||
</li>
|
||||
<li :class="{ active: activeTab === 'history' }">
|
||||
<a href="#" @click.prevent="activeTab = 'history'">Sejarah Undian</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<router-link :to="{ name: 'Current Result' }" class="btn btn-info" v-if="data.election.status == 2">
|
||||
Papar Keputusan
|
||||
<div class="tab-content election-tab-content">
|
||||
<div class="tab-pane" :class="{ active: activeTab === 'current' }">
|
||||
<div class="election-header">
|
||||
<div class="election-header__title">
|
||||
<div class="election-header__kicker">Undian Terkini</div>
|
||||
<div class="election-header__name">{{ electionLatestHeading }}</div>
|
||||
</div>
|
||||
<div class="election-header__meta">
|
||||
<span class="election-status" :class="electionStatusClass">{{ electionStatusLabel }}</span>
|
||||
<label v-if="data.election.status == 2" class="election-label">
|
||||
Undian bermula pada {{ data.election.start }}
|
||||
</label>
|
||||
<div class="election-header__meta-actions">
|
||||
<button class="btn btn-success btn-xs"
|
||||
v-if="Number(data.election.status) === 1 && data.user.role == 1"
|
||||
@click="openStartElectionModal">
|
||||
Mula Undian
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="election-actions form-group">
|
||||
<button class="btn btn-primary" v-if="Number(data.election.status) === 3 && data.user.role == 1"
|
||||
@click="util.showModal('#register-next-session-modal')">Daftar Sesi Mengundi Baru</button>
|
||||
|
||||
<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>
|
||||
|
||||
<label v-if="data.election.status == 2">Undian bermula {{ start_date }}</label>
|
||||
|
||||
</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">
|
||||
<div class="alert alert-info election-hint" v-if="Number(data.user.role) === 1">
|
||||
<b>Makluman:</b> Sila semak & 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">
|
||||
Keputusan Undian
|
||||
Ringkasan Keputusan
|
||||
</router-link>
|
||||
<a class="btn btn-warning" :href="getPDFurl(item.id)">Muat Turun PDF</a>
|
||||
</template>
|
||||
</admin-data-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="start" id="start_form">
|
||||
<form @submit.prevent="start">
|
||||
<div class="modal fade" id="start-election-modal" tabindex="-1" role="dialog"
|
||||
aria-labelledby="start-election-modal" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
@@ -42,19 +83,60 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="name">Nama Undian</label>
|
||||
<input type="text" name="name" autocomplete="on" class="form-control"
|
||||
placeholder="(Optional)" />
|
||||
<label for="start-election-name">Nama Undian</label>
|
||||
<input id="start-election-name" v-model.trim="startForm.name" type="text"
|
||||
name="name" autocomplete="off" class="form-control" placeholder="(Pilihan)"
|
||||
readonly tabindex="-1" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Kata Laluan</label>
|
||||
<input type="password" name="password" class="form-control" required />
|
||||
<label for="start-election-password">Kata Laluan</label>
|
||||
<input id="start-election-password" v-model="startForm.password" type="password"
|
||||
name="password" class="form-control" required autocomplete="current-password"
|
||||
:disabled="startSubmitting" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input type="submit" class="btn btn-info" value="Start" />
|
||||
<input type="button" data-dismiss="modal" class="btn btn-default" value="Cancel" />
|
||||
<button type="submit" class="btn btn-info" :disabled="startSubmitting">
|
||||
{{ startSubmitting ? 'Memproses…' : 'Mula' }}
|
||||
</button>
|
||||
<input type="button" data-dismiss="modal" class="btn btn-default" value="Batal" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form @submit.prevent="registerNextSession" id="register_next_form">
|
||||
<div class="modal fade" id="register-next-session-modal" tabindex="-1" role="dialog"
|
||||
aria-labelledby="register-next-session-modal" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="registerNextModalLabel">Daftar Sesi Mengundi Baru</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted">Sesi baharu akan dibuka untuk pusingan undian seterusnya. Undian
|
||||
yang
|
||||
telah tamat kekal dalam rekod.</p>
|
||||
<div class="form-group">
|
||||
<label for="register-next-name">Nama Undian</label>
|
||||
<input id="register-next-name" type="text" name="name" autocomplete="on"
|
||||
class="form-control" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="register-next-password">Kata Laluan</label>
|
||||
<input id="register-next-password" type="password" name="password"
|
||||
class="form-control" required autocomplete="current-password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input type="submit" class="btn btn-primary" value="Daftar" />
|
||||
<input type="button" data-dismiss="modal" class="btn btn-default" value="Batal" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,7 +158,7 @@
|
||||
<div class="form-group">
|
||||
<label for="name">Nama Undian</label>
|
||||
<input type="text" name="name" :value="data.election.name" autocomplete="on"
|
||||
class="form-control" placeholder="(Optional)" />
|
||||
class="form-control" placeholder="(Optional)" readonly tabindex="-1" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -86,19 +168,129 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input type="submit" class="btn btn-info" value="Stop Election" />
|
||||
<input type="button" data-dismiss="modal" class="btn btn-default" value="Cancel" />
|
||||
<input type="submit" class="btn btn-info" value="Tamat Undian" />
|
||||
<input type="button" data-dismiss="modal" class="btn btn-default" value="Batal" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.election-label {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
font-size: 13px;
|
||||
color: #ffffff;
|
||||
background-color: #0a4c9c;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.election-tabs {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.election-tab-content>.tab-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.election-tab-content>.tab-pane.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.election-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.election-header__kicker {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #777;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.election-header__name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.election-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.election-status--draft {
|
||||
background: #f5f5f5;
|
||||
color: #555;
|
||||
border-color: #e6e6e6;
|
||||
}
|
||||
|
||||
.election-status--active {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border-color: #c8e6c9;
|
||||
}
|
||||
|
||||
.election-status--done {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
border-color: #ffcdd2;
|
||||
}
|
||||
|
||||
.election-header__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.election-header__meta-actions .btn {
|
||||
padding: 4px 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.election-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.election-hint {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.election-history {
|
||||
margin-top: 4px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.election-history__title {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #666;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.center-header {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -116,25 +308,58 @@
|
||||
<script>
|
||||
export default {
|
||||
data: () => ({
|
||||
start_date: null,
|
||||
activeTab: 'current',
|
||||
electionLoading: false,
|
||||
startSubmitting: false,
|
||||
startForm: {
|
||||
name: '',
|
||||
password: ''
|
||||
},
|
||||
electionTableHeaders: [
|
||||
{ title: 'Nama Undian', key: 'name', sortable: true },
|
||||
{ title: 'Undian Mula', key: 'start', sortable: true },
|
||||
{ title: 'Undian Tamat', key: 'end', sortable: true },
|
||||
{ title: 'Papar', key: 'actions', sortable: false }
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false }
|
||||
]
|
||||
}),
|
||||
created: function () {
|
||||
this.refreshElection();
|
||||
var vm = this;
|
||||
setInterval(() => {
|
||||
vm.start_date = moment(vm.data.election.start).fromNow();
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
computed: {
|
||||
electionLatestHeading: function () {
|
||||
var e = this.data.election;
|
||||
var n = e && e.name != null ? String(e.name).trim() : '';
|
||||
return n !== '' ? n : 'Undian Terkini';
|
||||
},
|
||||
|
||||
electionStatusLabel: function () {
|
||||
var s = Number(this.data && this.data.election ? this.data.election.status : NaN);
|
||||
if (s === 1) return 'Draf';
|
||||
if (s === 2) return 'Sesi Mengundi Sedang Berjalan';
|
||||
if (s === 3) return 'Tamat';
|
||||
return '—';
|
||||
},
|
||||
|
||||
electionStatusClass: function () {
|
||||
var s = Number(this.data && this.data.election ? this.data.election.status : NaN);
|
||||
if (s === 1) return 'election-status--draft';
|
||||
if (s === 2) return 'election-status--active';
|
||||
if (s === 3) return 'election-status--done';
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
openStartElectionModal: function () {
|
||||
var e = this.data.election;
|
||||
this.startForm.name = e && e.name != null ? String(e.name) : '';
|
||||
this.startForm.password = '';
|
||||
this.startSubmitting = false;
|
||||
this.util.showModal('#start-election-modal');
|
||||
},
|
||||
|
||||
stop: function () {
|
||||
var vm = this;
|
||||
// this.util.hideModal('#stop-election-modal');
|
||||
@@ -145,11 +370,6 @@ export default {
|
||||
if (vm.util.showResult(response, 'success')) {
|
||||
vm.refreshElection();
|
||||
vm.data.election = response.data.election;
|
||||
vm.data.positions = [];
|
||||
vm.data.partylists = [];
|
||||
vm.data.voters = [];
|
||||
vm.data.nominees = [];
|
||||
vm.data.results = [];
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -160,15 +380,50 @@ export default {
|
||||
|
||||
start: function () {
|
||||
var vm = this;
|
||||
// this.util.hideModal('#start-election-modal');
|
||||
console.log('this');
|
||||
this.util.notify('Loading please wait', 'loading');
|
||||
axios.post(config.API + 'election/start', $('#start_form').serialize())
|
||||
if (vm.startSubmitting) {
|
||||
return;
|
||||
}
|
||||
vm.startSubmitting = true;
|
||||
this.util.notify('Memproses…', 'loading');
|
||||
axios.post(config.API + 'election/start', {
|
||||
name: vm.startForm.name,
|
||||
password: vm.startForm.password
|
||||
})
|
||||
.then(response => {
|
||||
$.notifyClose();
|
||||
if (vm.util.showResult(response, 'success')) {
|
||||
vm.refreshElection();
|
||||
vm.data.election = response.data.election;
|
||||
vm.startForm.password = '';
|
||||
vm.util.hideModal('#start-election-modal');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
$.notifyClose();
|
||||
vm.util.showResult(error, 'error');
|
||||
})
|
||||
.finally(function () {
|
||||
vm.startSubmitting = false;
|
||||
})
|
||||
},
|
||||
|
||||
registerNextSession: function () {
|
||||
var vm = this;
|
||||
this.util.notify('Memproses…', 'loading');
|
||||
axios.post(config.API + 'election/register-next', $('#register_next_form').serialize())
|
||||
.then(response => {
|
||||
$.notifyClose();
|
||||
if (vm.util.showResult(response, 'success')) {
|
||||
vm.refreshElection();
|
||||
vm.data.election = response.data.election;
|
||||
vm.util.hideModal('#register-next-session-modal');
|
||||
axios.get(config.API + 'admin/information').then(function (info) {
|
||||
vm.data.election = info.data.election;
|
||||
vm.data.partylists = info.data.partylist;
|
||||
vm.data.positions = info.data.position;
|
||||
}).catch(function (err) {
|
||||
vm.util.showResult(err, 'error');
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-7">
|
||||
<div class="col-md-12">
|
||||
<div class="panel panel-default result-card">
|
||||
<div class="panel-heading result-card__heading">
|
||||
<div class="result-card__title">
|
||||
@@ -99,7 +99,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<div class="col-md-12">
|
||||
<div class="panel panel-default result-card">
|
||||
<div class="panel-heading result-card__heading">
|
||||
<div class="result-card__title">
|
||||
@@ -114,11 +117,15 @@
|
||||
<div class="panel-body result-card__body">
|
||||
<template v-if="String(position_id) === '0'">
|
||||
<div class="result-mini-title">Top Calon (Overall)</div>
|
||||
<div id="top-chart" class="result-top-chart"></div>
|
||||
<BarChart class="result-top-chart" :labels="topNominees.map(n => n.label)"
|
||||
:values="topNominees.map(n => n.votes)" background-color="rgba(54, 162, 235, 0.75)"
|
||||
border-color="rgba(54, 162, 235, 1)" />
|
||||
<div v-if="!topNominees.length" class="result-empty">Tiada data untuk dipaparkan.</div>
|
||||
|
||||
<div class="result-mini-title" style="margin-top: 14px;">Jumlah Undi Mengikut Jawatan</div>
|
||||
<div id="votes-by-position-chart" class="result-top-chart"></div>
|
||||
<BarChart class="result-top-chart" :labels="positionTotals.map(p => p.label)"
|
||||
:values="positionTotals.map(p => p.votes)" background-color="rgba(75, 192, 192, 0.55)"
|
||||
border-color="rgba(75, 192, 192, 1)" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="result-empty">
|
||||
@@ -134,21 +141,18 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BarChart from '../../../common/BarChart.vue';
|
||||
|
||||
export default {
|
||||
components: { BarChart },
|
||||
|
||||
created: function () {
|
||||
this.refreshNominees();
|
||||
this.$nextTick(function () {
|
||||
this.initCharts();
|
||||
});
|
||||
},
|
||||
|
||||
watch: {
|
||||
position_id: function () {
|
||||
var vm = this;
|
||||
this.$nextTick(function () {
|
||||
vm.initCharts();
|
||||
});
|
||||
// charts are reactive via <BarChart>
|
||||
}
|
||||
},
|
||||
|
||||
@@ -188,9 +192,6 @@ export default {
|
||||
$.notifyClose();
|
||||
vm.data.results = response.data;
|
||||
vm.data.last_update = new Date();
|
||||
vm.$nextTick(function () {
|
||||
vm.initCharts();
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
$.notifyClose();
|
||||
@@ -198,47 +199,6 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
initCharts: function () {
|
||||
// Clear any previously rendered plots to avoid overlap when switching views
|
||||
if ($('#top-chart').length) $('#top-chart').empty();
|
||||
if ($('#votes-by-position-chart').length) $('#votes-by-position-chart').empty();
|
||||
|
||||
// All positions summary
|
||||
if (String(this.position_id) === '0') {
|
||||
if ($('#top-chart').length) {
|
||||
var ticks = this.topNominees.map(function (n, i) { return [i, n.label]; });
|
||||
var series = [{
|
||||
label: 'Undi',
|
||||
data: this.topNominees.map(function (n, i) { return [i, n.votes]; }),
|
||||
bars: { show: true, barWidth: 0.6, align: 'center', fill: 0.85, lineWidth: 0 }
|
||||
}];
|
||||
$.plot($('#top-chart'), series, {
|
||||
xaxis: { ticks: ticks, rotateTicks: 45 },
|
||||
yaxis: { min: 0, tickDecimals: 0 },
|
||||
grid: { hoverable: true, borderColor: '#e5e7eb' }
|
||||
});
|
||||
}
|
||||
|
||||
if ($('#votes-by-position-chart').length) {
|
||||
var pt = this.positionTotals;
|
||||
var ptTicks = pt.map(function (p, i) { return [i, p.label]; });
|
||||
var ptSeries = [{
|
||||
label: 'Undi',
|
||||
data: pt.map(function (p, i) { return [i, p.votes]; }),
|
||||
bars: { show: true, barWidth: 0.6, align: 'center', fill: 0.55, lineWidth: 0 }
|
||||
}];
|
||||
$.plot($('#votes-by-position-chart'), ptSeries, {
|
||||
xaxis: { ticks: ptTicks, rotateTicks: 45 },
|
||||
yaxis: { min: 0, tickDecimals: 0 },
|
||||
grid: { hoverable: true, borderColor: '#e5e7eb' }
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For per-position view, we intentionally do not render charts (UI request).
|
||||
},
|
||||
|
||||
getPosition: function (id) {
|
||||
let positions = this.data.positions;
|
||||
for (var i in positions)
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!loading">
|
||||
<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="#myNavbar">
|
||||
<span class="icon-bar"></span>
|
||||
<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">
|
||||
<FloatingNavbar logo-href="/admin" main-nav-class="admin-nav-main">
|
||||
<template #left>
|
||||
<router-link v-if="adminRole !== 3" :to="{ name: 'Admin Home' }" tag="li" exact>
|
||||
<a href="#"><b>Laman Utama</b></a>
|
||||
<a href="#"><b>Undian</b></a>
|
||||
</router-link>
|
||||
|
||||
<li class="dropdown" v-if="adminRole === 1">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button"
|
||||
aria-haspopup="true" aria-expanded="false">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true"
|
||||
aria-expanded="false">
|
||||
<b>Pengurusan</b> <span class="caret"></span>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
@@ -40,8 +26,8 @@
|
||||
</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">
|
||||
<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">
|
||||
@@ -52,8 +38,8 @@
|
||||
</li>
|
||||
|
||||
<li class="dropdown" v-if="adminRole === 1">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button"
|
||||
aria-haspopup="true" aria-expanded="false">
|
||||
<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">
|
||||
@@ -70,8 +56,8 @@
|
||||
</li>
|
||||
|
||||
<li class="dropdown" v-if="adminRole === 1">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button"
|
||||
aria-haspopup="true" aria-expanded="false">
|
||||
<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">
|
||||
@@ -83,17 +69,17 @@
|
||||
</router-link>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="nav navbar-right navbar-nav">
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<li class="dropdown">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button"
|
||||
aria-haspopup="true" aria-expanded="false">
|
||||
<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>
|
||||
<router-link v-if="Number(data.user.role) !== 3" :to="{ name: 'Update Account' }" tag="li"
|
||||
exact>
|
||||
<a href="#">Kemaskini Akaun</a>
|
||||
</router-link>
|
||||
|
||||
@@ -105,17 +91,14 @@
|
||||
<a href="#">Pengurusan Akaun</a>
|
||||
</router-link>
|
||||
|
||||
<li @click="isImpersonating() ? null : logout()"
|
||||
:class="{ disabled: isImpersonating() }"
|
||||
<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>
|
||||
</template>
|
||||
</FloatingNavbar>
|
||||
|
||||
<div class="admin-main">
|
||||
<router-view></router-view>
|
||||
@@ -130,7 +113,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FloatingNavbar from '../../common/FloatingNavbar.vue';
|
||||
|
||||
export default {
|
||||
components: { FloatingNavbar },
|
||||
|
||||
// data: () =>({
|
||||
// loading: true,
|
||||
@@ -145,6 +131,7 @@ export default {
|
||||
axios.get(config.API + 'admin/information')
|
||||
.then(response => {
|
||||
console.log(response);
|
||||
localStorage.setItem('admin_session', '1');
|
||||
vm.data.user = response.data.user;
|
||||
vm.data.election = response.data.election;
|
||||
vm.data.partylists = response.data.partylist;
|
||||
@@ -229,208 +216,11 @@ export default {
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
padding-top: 112px;
|
||||
}
|
||||
|
||||
@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 {
|
||||
padding-top: 102px;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ export default {
|
||||
vm.stopLoading();
|
||||
if (this.util.showResult(response, 'success')) {
|
||||
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) {
|
||||
localStorage.setItem('admin_role', String(response.data.user.role));
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
<button class="btn btn-default" @click="refreshVoter()">
|
||||
<i class="fa fa-refresh"></i> Kemaskini Pengundi
|
||||
</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
|
||||
</router-link>
|
||||
</router-link> -->
|
||||
</div>
|
||||
|
||||
<div class="kehadiran-search">
|
||||
|
||||
@@ -21,8 +21,12 @@
|
||||
<button class="btn btn-default" @click="clearSearch()" :disabled="!hasAnySearch">Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="kehadiran-filters">
|
||||
<span class="kehadiran-filters__label">Penapis:</span>
|
||||
<div class="kehadiran-filter-panel">
|
||||
<div class="kehadiran-filter-panel__title">Penapis</div>
|
||||
<div class="kehadiran-filter-groups">
|
||||
<div class="kehadiran-filter-group">
|
||||
<div class="kehadiran-filter-group__title">Kaedah kehadiran</div>
|
||||
<div class="kehadiran-filter-group__chips">
|
||||
<button type="button" class="kehadiran-chip" :class="{ 'is-active': !current_kehadiran }"
|
||||
@click="setKehadiranFilter(null)">
|
||||
Semua
|
||||
@@ -32,23 +36,27 @@
|
||||
@click="setKehadiranFilter('unset')">
|
||||
Belum Ditetapkan
|
||||
</button>
|
||||
<button type="button" class="kehadiran-chip" :class="{ 'is-active': Number(current_kehadiran) === 1 }"
|
||||
<button type="button" class="kehadiran-chip"
|
||||
:class="{ 'is-active': Number(current_kehadiran) === 1 }"
|
||||
@click="setKehadiranFilter(1)">
|
||||
Fizikal
|
||||
</button>
|
||||
<button type="button" class="kehadiran-chip" :class="{ 'is-active': Number(current_kehadiran) === 2 }"
|
||||
<button type="button" class="kehadiran-chip"
|
||||
:class="{ 'is-active': Number(current_kehadiran) === 2 }"
|
||||
@click="setKehadiranFilter(2)">
|
||||
Maya
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="kehadiran-filters kehadiran-filters--secondary">
|
||||
<span class="kehadiran-filters__label">Pendaftaran fizikal:</span>
|
||||
</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' }"
|
||||
<button type="button" class="kehadiran-chip"
|
||||
:class="{ 'is-active': current_fizikal_reg === 'pending' }"
|
||||
@click="setFizikalRegFilter('pending')">
|
||||
Menunggu pengesahan
|
||||
</button>
|
||||
@@ -58,7 +66,66 @@
|
||||
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>
|
||||
|
||||
<!-- Attendance stats -->
|
||||
<div class="kehadiran-stats">
|
||||
<div class="kehadiran-stat">
|
||||
<div class="kehadiran-stat__label">Fizikal</div>
|
||||
<div class="kehadiran-stat__value">{{ attendanceStats.fizikalCount }}</div>
|
||||
<div class="kehadiran-stat__meta">{{ attendanceStats.fizikalPct }}%</div>
|
||||
</div>
|
||||
<div class="kehadiran-stat">
|
||||
<div class="kehadiran-stat__label">Maya</div>
|
||||
<div class="kehadiran-stat__value">{{ attendanceStats.mayaCount }}</div>
|
||||
<div class="kehadiran-stat__meta">{{ attendanceStats.mayaPct }}%</div>
|
||||
</div>
|
||||
<div class="kehadiran-stat kehadiran-stat--muted">
|
||||
<div class="kehadiran-stat__label">Jumlah Kehadiran</div>
|
||||
<div class="kehadiran-stat__value">{{ attendanceStats.totalAttendance }}</div>
|
||||
<div class="kehadiran-stat__meta">{{ attendanceStats.totalPct }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <a class="btn btn-warning" :href="getPDFKehadiranurl(1)">Muat Turun PDF</a> -->
|
||||
|
||||
@@ -347,6 +414,46 @@
|
||||
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 {
|
||||
margin: 10px 0 12px;
|
||||
display: flex;
|
||||
@@ -366,23 +473,49 @@
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.kehadiran-filters {
|
||||
margin: 6px 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
.kehadiran-filter-panel {
|
||||
margin: 10px 0 14px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
background: #f9fafb;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.kehadiran-filters--secondary {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.kehadiran-filters__label {
|
||||
.kehadiran-filter-panel__title {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
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-weight: 600;
|
||||
margin-right: 4px;
|
||||
font-weight: 700;
|
||||
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 {
|
||||
@@ -556,6 +689,8 @@ export default {
|
||||
page: this.current_page,
|
||||
kehadiran: this.current_kehadiran || 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_kp: this.searchNoKp || undefined
|
||||
}
|
||||
@@ -605,10 +740,28 @@ export default {
|
||||
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) {
|
||||
var q = { page: pageNum };
|
||||
if (this.current_kehadiran) q.kehadiran = this.current_kehadiran;
|
||||
if (this.current_fizikal_reg) q.fizikal_reg = this.current_fizikal_reg;
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -856,6 +1009,14 @@ export default {
|
||||
'$route.query.fizikal_reg': function () {
|
||||
$.notifyClose();
|
||||
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;
|
||||
},
|
||||
|
||||
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 () {
|
||||
if (!this.verifyModalVoter) return false;
|
||||
var v = this.verifyModalVoter;
|
||||
@@ -927,12 +1104,12 @@ export default {
|
||||
attendancePercentage() {
|
||||
// Count total members who attended physically (Fizikal)
|
||||
const fizikalCount = this.voterListItems.reduce((total, voter) => {
|
||||
return voter.kehadiran === 1 ? total + 1 : total;
|
||||
return Number(voter && voter.kehadiran) === 1 ? total + 1 : total;
|
||||
}, 0);
|
||||
|
||||
// Count total members who attended virtually (Maya)
|
||||
const mayaCount = this.voterListItems.reduce((total, voter) => {
|
||||
return voter.kehadiran === 2 ? total + 1 : total;
|
||||
return Number(voter && voter.kehadiran) === 2 ? total + 1 : total;
|
||||
}, 0);
|
||||
|
||||
// Calculate total attendance percentage
|
||||
@@ -941,6 +1118,29 @@ export default {
|
||||
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 />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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>
|
||||
</template>
|
||||
@@ -526,8 +514,6 @@ export default {
|
||||
// })
|
||||
// },
|
||||
|
||||
|
||||
|
||||
edit: function () {
|
||||
if (this.loading) return;
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Kad Ahli Digital -->
|
||||
<div class="col-xs-12">
|
||||
<!-- <div class="col-xs-12">
|
||||
<div class="home-section-container home-section-container--kad">
|
||||
<h2 class="home-section-heading">
|
||||
<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> -->
|
||||
|
||||
<!-- Saham Vote Blocked Message -->
|
||||
<div v-if="showSahamVoteBlockedMessage" class="row home-saham-block-row">
|
||||
@@ -220,6 +220,13 @@ export default {
|
||||
},
|
||||
|
||||
computed: {
|
||||
etikaUrl: function () {
|
||||
var url = this.portalSettings && this.portalSettings.attendance_etika_url
|
||||
? String(this.portalSettings.attendance_etika_url).trim()
|
||||
: '';
|
||||
return url;
|
||||
},
|
||||
|
||||
bannerUrl() {
|
||||
const p = this.portalSettings && this.portalSettings.home_banner_image_path
|
||||
? this.portalSettings.home_banner_image_path
|
||||
@@ -296,7 +303,17 @@ export default {
|
||||
href: this.matLinkUrl,
|
||||
variant: "home-cta-link--violet",
|
||||
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 (
|
||||
@@ -328,15 +345,15 @@ export default {
|
||||
});
|
||||
}
|
||||
|
||||
links.push({
|
||||
key: "penyata",
|
||||
title: "Penyata Ahli",
|
||||
desc: "Lihat dan kemaskini penyata ahli",
|
||||
icon: "fa-file-text-o",
|
||||
route: { name: "penyata", params: { no_anggota: noAnggota } },
|
||||
variant: "home-cta-link--amber",
|
||||
external: false
|
||||
});
|
||||
// links.push({
|
||||
// key: "penyata",
|
||||
// title: "Penyata Ahli",
|
||||
// desc: "Lihat dan kemaskini penyata ahli",
|
||||
// icon: "fa-file-text-o",
|
||||
// route: { name: "penyata", params: { no_anggota: noAnggota } },
|
||||
// variant: "home-cta-link--amber",
|
||||
// external: false
|
||||
// });
|
||||
|
||||
return links;
|
||||
},
|
||||
@@ -884,6 +901,15 @@ export default {
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
width: 52px;
|
||||
|
||||
@@ -1,36 +1,19 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!loading">
|
||||
<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="#myNavbar">
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></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>
|
||||
</div>
|
||||
<div class="collapse navbar-collapse" id="myNavbar">
|
||||
|
||||
<ul class="nav navbar-nav voter-nav-main">
|
||||
</ul>
|
||||
<ul class="nav navbar-right navbar-nav">
|
||||
<FloatingNavbar :logo-href="data.baseURL" main-nav-class="voter-nav-main">
|
||||
<template #right>
|
||||
<li class="dropdown">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button"
|
||||
aria-haspopup="true" aria-expanded="false">
|
||||
<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">
|
||||
<li @click="logout()"><a>Log Keluar</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
</FloatingNavbar>
|
||||
|
||||
<div class="voter-main">
|
||||
<div class="voter-breadcrumb-wrap">
|
||||
@@ -55,7 +38,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FloatingNavbar from '../../common/FloatingNavbar.vue';
|
||||
|
||||
export default {
|
||||
components: { FloatingNavbar },
|
||||
data: () => ({
|
||||
loading: true
|
||||
}),
|
||||
@@ -132,154 +118,6 @@ export default {
|
||||
|
||||
|
||||
<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 {
|
||||
padding-top: 112px;
|
||||
}
|
||||
@@ -336,38 +174,6 @@ export default {
|
||||
}
|
||||
|
||||
@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 {
|
||||
padding-top: 102px;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
loading: false
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.util.isAdminPortalSession()) {
|
||||
this.$router.replace(this.util.getAdminEntryRoute());
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
login() {
|
||||
this.startLoading();
|
||||
|
||||
@@ -148,6 +148,8 @@ export default {
|
||||
.then(response => {
|
||||
vm.stopLoading();
|
||||
if (this.util.showResult(response, 'success')) {
|
||||
localStorage.removeItem('admin_role');
|
||||
localStorage.removeItem('admin_session');
|
||||
localStorage['Access Token'] = `Bearer ${response.data.token}`;
|
||||
this.util.setAuthorization();
|
||||
vm.$router.push({ name: 'Voter Home' });
|
||||
|
||||
Vendored
+72
@@ -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);
|
||||
}
|
||||
Vendored
+22
-4
@@ -17,6 +17,23 @@ const methods = {
|
||||
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
|
||||
* @param {String} Message
|
||||
@@ -100,8 +117,7 @@ const methods = {
|
||||
* @param {Int} Response Status
|
||||
* @return {String} Error Message
|
||||
*/
|
||||
getErrorMessage: function (data, status)
|
||||
{
|
||||
getErrorMessage: function (data, status) {
|
||||
var message = '';
|
||||
switch (status) {
|
||||
case 200:
|
||||
@@ -148,10 +164,10 @@ const methods = {
|
||||
}
|
||||
break;
|
||||
case 401:
|
||||
message = 'You need to login first.';
|
||||
message = 'Sila log masuk terlebih dahulu.';
|
||||
break;
|
||||
default:
|
||||
message = 'An error occured.';
|
||||
message = 'Ralat telah berlaku.';
|
||||
break;
|
||||
}
|
||||
return message;
|
||||
@@ -237,3 +253,5 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { methods };
|
||||
@@ -72,9 +72,11 @@ Route::prefix('v1')->group(function () { //Version 1 of my Rest API
|
||||
|
||||
Route::prefix('election')->group(function () {
|
||||
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::post('start', 'API\v1\Election\StartController');
|
||||
Route::post('stop', 'API\v1\Election\StopController');
|
||||
Route::post('register-next', 'API\v1\Election\RegisterNextSessionController');
|
||||
Route::get('', 'API\v1\Election\GetController');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
Reference in New Issue
Block a user