Allowance check

This commit is contained in:
ISMAIL MASSERAN
2026-04-14 02:34:00 +00:00
parent 0c4d200839
commit 3775c4a876
100 changed files with 81493 additions and 3226 deletions
+5
View File
@@ -44,3 +44,8 @@ MAIL_ENCRYPTION=null
PUSHER_APP_ID= PUSHER_APP_ID=
PUSHER_APP_KEY= PUSHER_APP_KEY=
PUSHER_APP_SECRET= PUSHER_APP_SECRET=
PHYSICAL_ATTENDANCE_GATE_ENABLED=true
PHYSICAL_ATTENDANCE_GATE_SECRET=topazthegoat # long random string
PHYSICAL_ATTENDANCE_GATE_PERIOD=60
PHYSICAL_ATTENDANCE_GATE_DISPLAY_KEY=topazthegoat
+6
View File
@@ -49,3 +49,9 @@ ONEWAY_SMS_USERNAME=APIFKZQX6MN6N
ONEWAY_SMS_PASSWORD=APIFKZQX6MN6N9YHXP ONEWAY_SMS_PASSWORD=APIFKZQX6MN6N9YHXP
ONEWAY_SMS_SENDERID=INFO ONEWAY_SMS_SENDERID=INFO
ONEWAY_SMS_LANG=1 ONEWAY_SMS_LANG=1
# Rotating code for physical attendance (counter display). See config/physical_attendance_gate.php
PHYSICAL_ATTENDANCE_GATE_ENABLED=false
PHYSICAL_ATTENDANCE_GATE_SECRET=
PHYSICAL_ATTENDANCE_GATE_PERIOD=60
PHYSICAL_ATTENDANCE_GATE_DISPLAY_KEY=
+2 -1
View File
@@ -11,4 +11,5 @@ npm-debug.log
yarn-error.log yarn-error.log
.env .env
*sublime* *sublime*
/.vscode /.vscode
/credential.md
-15
View File
@@ -1,15 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:8080",
"webRoot": "${workspaceFolder}"
}
]
}
+1
View File
@@ -0,0 +1 @@
[ ] checking user can vote one or two based on number of nominees
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class AllowanceClaimCode extends Model
{
protected $table = 'allowance_claim_codes';
protected $fillable = [
'election_id',
'voter_id',
'method',
'code_hash',
'code_last4',
'expires_at',
'used_at',
'used_by_admin_id',
];
protected $casts = [
'expires_at' => 'datetime',
'used_at' => 'datetime',
];
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class AllowancePayout extends Model
{
protected $table = 'allowance_payouts';
protected $fillable = [
'election_id',
'voter_id',
'method',
'status',
'amount_cents',
'currency',
'paid_by_admin_id',
'paid_at',
'reference',
'note',
];
protected $casts = [
'paid_at' => 'datetime',
];
}
+1 -1
View File
@@ -5,7 +5,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class ImportCsv extends Command class ImportCSV extends Command
{ {
protected $signature = 'csv:import'; protected $signature = 'csv:import';
protected $description = 'Import kewangan anggota CSV'; protected $description = 'Import kewangan anggota CSV';
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\ActivityLog;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Spatie\Activitylog\Models\Activity;
class IndexController extends Controller
{
public function __invoke(Request $request)
{
$perPage = (int) $request->query('per_page', 25);
if ($perPage < 1) $perPage = 25;
if ($perPage > 200) $perPage = 200;
$q = Activity::query()
->orderByDesc('id');
if ($request->filled('description')) {
$q->where('description', 'like', '%' . $request->query('description') . '%');
}
if ($request->filled('subject_type')) {
$q->where('subject_type', (string) $request->query('subject_type'));
}
if ($request->filled('causer_id')) {
$q->where('causer_id', (int) $request->query('causer_id'));
}
$p = $q->paginate($perPage);
$items = collect($p->items())->map(function (Activity $a) {
return [
'id' => $a->id,
'log_name' => $a->log_name,
'description' => $a->description,
'subject_type' => $a->subject_type,
'subject_id' => $a->subject_id,
'causer_type' => $a->causer_type,
'causer_id' => $a->causer_id,
'properties' => $a->properties,
'created_at' => optional($a->created_at)->toDateTimeString(),
];
})->values();
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $p->currentPage(),
'per_page' => $p->perPage(),
'last_page' => $p->lastPage(),
'total' => $p->total(),
],
]);
}
}
@@ -11,7 +11,20 @@ class AddController extends Controller
public function __invoke (Request $request) public function __invoke (Request $request)
{ {
$this->validateRequest($request); $this->validateRequest($request);
$this->insertAdmin($request); $user = $this->insertAdmin($request);
activity()
->performedOn($user)
->withProperties([
'admin_id' => $user->id,
'admin_name' => $user->name,
'admin_email' => $user->email,
'admin_role' => $user->role,
'created_by_admin_id' => $request->user() ? (int) $request->user()->id : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("create admin: {$user->name}");
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
'message' => 'Admin added successfully' 'message' => 'Admin added successfully'
@@ -22,7 +35,7 @@ class AddController extends Controller
{ {
$admin = $request->all(); $admin = $request->all();
$admin['password'] = bcrypt($request->password); $admin['password'] = bcrypt($request->password);
User::create($admin); return User::create($admin);
} }
private function validateRequest($request) private function validateRequest($request)
@@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\Allowance;
use App\AllowanceClaimCode;
use App\AllowancePayout;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Result;
use App\Voter;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
class PayoutController extends Controller
{
public function __invoke(Request $request)
{
$this->validate($request, [
'voter_id' => 'required|integer',
'method' => 'nullable|string|in:cash,bank',
'amount_cents' => 'nullable|integer|min:0',
'reference' => 'nullable|string|max:80',
'note' => 'nullable|string|max:2000',
'claim_code' => 'nullable|string|min:4|max:20',
]);
$electionId = Util::getCurrentElection();
$method = $request->input('method', 'cash');
$voterId = (int) $request->input('voter_id');
$voter = Voter::where('election_id', $electionId)->where('id', $voterId)->first();
if (!$voter) {
return response()->json([
'status' => 'failed',
'message' => 'Voter not found for current election.',
], 404);
}
// Enforce method by attendance type (paperless replacement for coupon rules)
if ($method === 'cash' && (int) $voter->kehadiran !== 1) {
return response()->json([
'status' => 'failed',
'message' => 'Cash payout only allowed for Fizikal attendance.',
], 422);
}
if ($method === 'bank' && (int) $voter->kehadiran !== 2) {
return response()->json([
'status' => 'failed',
'message' => 'Bank payout only allowed for Maya attendance.',
], 422);
}
// Enforce "must have voted" (officer check)
$hasVoted = Result::where('election_id', $electionId)->where('voter_id', $voterId)->exists();
if (!$hasVoted) {
return response()->json([
'status' => 'failed',
'message' => 'Voter has not voted yet.',
], 422);
}
// Default amounts (can be overridden by passing amount_cents)
$defaultAmount = $method === 'cash' ? 30000 : 15000;
$amountCents = $request->filled('amount_cents') ? (int) $request->input('amount_cents') : $defaultAmount;
$now = Carbon::now();
$adminId = $request->user() ? (int) $request->user()->id : null;
$claimCode = $request->input('claim_code');
// For fizikal cash payout, require claim code (paperless coupon).
if ($method === 'cash' && (!$claimCode || trim($claimCode) === '')) {
return response()->json([
'status' => 'failed',
'message' => 'Sila masukkan kod tuntutan (claim code) sebelum bayaran tunai dibuat.',
], 422);
}
$payout = DB::transaction(function () use ($electionId, $voterId, $method, $claimCode, $amountCents, $adminId, $now, $request) {
// Consume claim code if provided
if ($claimCode && trim($claimCode) !== '') {
$row = AllowanceClaimCode::where('election_id', $electionId)
->where('voter_id', $voterId)
->where('method', $method)
->lockForUpdate()
->first();
if (!$row) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan tidak dijumpai.',
], 404);
}
if ($row->used_at) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan telah digunakan.',
], 422);
}
if ($row->expires_at && Carbon::now()->greaterThan($row->expires_at)) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan telah tamat tempoh.',
], 422);
}
if (!Hash::check(trim((string) $claimCode), $row->code_hash)) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan tidak sah.',
], 422);
}
$row->used_at = Carbon::now();
$row->used_by_admin_id = $adminId;
$row->save();
}
// Idempotent: if already exists (unique election_id+voter_id+method) we keep it as paid.
return AllowancePayout::updateOrCreate(
[
'election_id' => $electionId,
'voter_id' => $voterId,
'method' => $method,
],
[
'status' => 'paid',
'amount_cents' => $amountCents,
'currency' => 'MYR',
'paid_by_admin_id' => $adminId,
'paid_at' => $now,
'reference' => $request->input('reference'),
'note' => $request->input('note'),
]
);
});
// In case the transaction returned a JSON response (validation failure), return it directly.
if ($payout instanceof \Illuminate\Http\JsonResponse) {
return $payout;
}
activity()
->performedOn($payout)
->withProperties([
'election_id' => $electionId,
'voter_id' => $voterId,
'voter_name' => $voter->name,
'attendance' => (int) $voter->kehadiran,
'method' => $method,
'amount_cents' => (int) $payout->amount_cents,
'currency' => $payout->currency,
'paid_by_admin_id' => $adminId,
'reference' => $payout->reference,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("allowance payout ({$method}): {$voter->name}");
return response()->json([
'status' => 'success',
'message' => 'Allowance payout recorded.',
'payout' => $payout,
]);
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\Allowance;
use App\AllowanceClaimCode;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
class VerifyCodeController extends Controller
{
public function __invoke(Request $request)
{
$this->validate($request, [
'voter_id' => 'required|integer',
'method' => 'nullable|string|in:cash,bank',
'code' => 'required|string|min:4|max:20',
]);
$electionId = Util::getCurrentElection();
$method = $request->input('method', 'cash');
$voterId = (int) $request->input('voter_id');
$code = trim((string) $request->input('code'));
$row = AllowanceClaimCode::where('election_id', $electionId)
->where('voter_id', $voterId)
->where('method', $method)
->first();
if (!$row) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan tidak dijumpai.',
], 404);
}
if ($row->used_at) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan telah digunakan.',
], 422);
}
if ($row->expires_at && Carbon::now()->greaterThan($row->expires_at)) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan telah tamat tempoh.',
], 422);
}
if (!Hash::check($code, $row->code_hash)) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan tidak sah.',
], 422);
}
// Mark used (consumed) here so it cannot be reused
$row->used_at = Carbon::now();
$row->used_by_admin_id = $request->user() ? (int) $request->user()->id : null;
$row->save();
return response()->json([
'status' => 'success',
'message' => 'Kod tuntutan disahkan.',
'claim' => [
'id' => $row->id,
'voter_id' => $row->voter_id,
'method' => $row->method,
'expires_at' => $row->expires_at,
'used_at' => $row->used_at,
],
]);
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\Allowance;
use App\AllowancePayout;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class VoidController extends Controller
{
public function __invoke(Request $request, $id)
{
$this->validate($request, [
'note' => 'nullable|string|max:2000',
]);
$payout = AllowancePayout::find($id);
if (!$payout) {
return response()->json([
'status' => 'failed',
'message' => 'Payout not found.',
], 404);
}
$note = trim((string) $request->input('note', ''));
if ($note !== '') {
$payout->note = $payout->note ? ($payout->note . "\n\nVOID NOTE: " . $note) : ("VOID NOTE: " . $note);
}
$payout->status = 'void';
$payout->save();
return response()->json([
'status' => 'success',
'message' => 'Payout voided.',
'payout' => $payout,
'voided_at' => Carbon::now(),
]);
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\Impersonate;
use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Http\Request;
class LeaveController extends Controller
{
public function __invoke(Request $request)
{
$current = $request->user();
if (! $current) {
return response()->json(['error' => 'Unauthenticated.'], 401);
}
$token = null;
try {
if (method_exists($current, 'token')) {
$token = $current->token();
}
} catch (\Throwable $e) {
$token = null;
}
$tokenName = $token ? (string) $token->name : '';
if (strpos($tokenName, 'impersonate:') !== 0) {
return response()->json([
'status' => 'failed',
'message' => 'Anda tidak sedang impersonate.',
], 422);
}
$impersonatorId = (int) substr($tokenName, strlen('impersonate:'));
if ($impersonatorId < 1) {
return response()->json([
'status' => 'failed',
'message' => 'Token impersonate tidak sah.',
], 422);
}
$impersonator = User::find($impersonatorId);
if (! $impersonator) {
return response()->json([
'status' => 'failed',
'message' => 'Akaun admin asal tidak dijumpai.',
], 404);
}
// Revoke impersonated token.
try {
if ($token) {
$token->revoke();
}
} catch (\Throwable $e) {
// Best effort.
}
$accessToken = $impersonator->createToken('My app', ['admin'])->accessToken;
activity()
->performedOn($impersonator)
->withProperties([
'admin_id' => $impersonator->id,
'admin_name' => $impersonator->name,
'admin_email' => $impersonator->email,
'from_user_id' => $current->id,
'from_user_email' => $current->email,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("admin leave impersonation: {$impersonator->email}");
return response()->json([
'status' => 'success',
'message' => 'Berjaya kembali ke akaun asal.',
'user' => $impersonator,
'token' => $accessToken,
]);
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\Impersonate;
use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class StartController extends Controller
{
public function __invoke(Request $request)
{
$request->validate([
'user_id' => ['required', 'integer', 'exists:users,id'],
]);
$impersonator = Auth::user();
$target = User::findOrFail((int) $request->input('user_id'));
if (! $impersonator || ! method_exists($impersonator, 'tokenCan') || ! $impersonator->tokenCan('admin')) {
return response()->json(['error' => 'Unauthenticated.'], 401);
}
if ((int) $impersonator->id === (int) $target->id) {
return response()->json([
'status' => 'failed',
'message' => 'Tidak boleh impersonate akaun sendiri.',
], 422);
}
if (method_exists($impersonator, 'canImpersonate') && ! $impersonator->canImpersonate()) {
return response()->json([
'status' => 'failed',
'message' => 'Akses ditolak (impersonate).',
], 403);
}
if (method_exists($target, 'canBeImpersonated') && ! $target->canBeImpersonated()) {
return response()->json([
'status' => 'failed',
'message' => 'Akaun ini tidak boleh di-impersonate.',
], 403);
}
// Revoke current token to reduce risk of dual active sessions.
try {
if (method_exists($impersonator, 'token') && $impersonator->token()) {
$impersonator->token()->revoke();
}
} catch (\Throwable $e) {
// Best effort.
}
// Encode impersonator id in token name for later "leave" action.
$tokenName = 'impersonate:' . (int) $impersonator->id;
$accessToken = $target->createToken($tokenName, ['admin'])->accessToken;
activity()
->performedOn($target)
->withProperties([
'admin_id' => $impersonator->id,
'admin_name' => $impersonator->name,
'admin_email' => $impersonator->email,
'impersonated_user_id' => $target->id,
'impersonated_user_name' => $target->name,
'impersonated_user_email' => $target->email,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("admin impersonate: {$impersonator->email} -> {$target->email}");
return response()->json([
'status' => 'success',
'message' => 'Impersonate berjaya.',
'user' => $target,
'impersonator' => $impersonator,
'token' => $accessToken,
]);
}
}
@@ -26,9 +26,29 @@ class LoginController extends Controller
$result['user'] = $user; $result['user'] = $user;
$result['election_status'] = Util::getElectionStatus(); $result['election_status'] = Util::getElectionStatus();
$result['token'] = $user->createToken('My app', ['admin'])->accessToken; $result['token'] = $user->createToken('My app', ['admin'])->accessToken;
activity()
->performedOn($user)
->withProperties([
'admin_id' => $user->id,
'admin_name' => $user->name,
'admin_email' => $user->email,
'admin_role' => $user->role,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("admin login: {$user->email}");
} else { } else {
$result['status'] = 'failed'; $result['status'] = 'failed';
$result['message'] = 'Wrong email or password'; $result['message'] = 'Wrong email or password';
activity()
->withProperties([
'admin_email' => (string) $request->input('email'),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('admin login failed');
} }
return response()->json($result); return response()->json($result);
@@ -8,8 +8,31 @@ use App\Http\Controllers\Controller;
class LogoutController extends Controller class LogoutController extends Controller
{ {
public function __invoke() public function __invoke(Request $request)
{ {
$user = Auth::user();
if ($user) {
activity()
->performedOn($user)
->withProperties([
'admin_id' => $user->id,
'admin_name' => $user->name,
'admin_email' => $user->email,
'admin_role' => $user->role,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("admin logout: {$user->email}");
} else {
activity()
->withProperties([
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('admin logout');
}
Auth::guard('web')->logout(); Auth::guard('web')->logout();
Auth::guard('voter')->logout(); Auth::guard('voter')->logout();
return response()->json([ return response()->json([
@@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers\API\v1\Allowance;
use App\AllowanceClaimCode;
use App\AllowancePayout;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Result;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
class ClaimCodeController extends Controller
{
public function __invoke(Request $request)
{
$this->validate($request, [
'method' => 'nullable|string|in:cash,bank',
]);
$electionId = Util::getCurrentElection();
$method = $request->input('method', 'cash');
// voter auth guard; Auth::id() should be voter id under voterAPI
$voterId = Auth::id();
$voter = $request->user();
if (!$voter || (int) $voter->id !== (int) $voterId) {
return response()->json([
'status' => 'failed',
'message' => 'Unauthenticated.',
], 401);
}
// Enforce method by attendance
if ($method === 'cash' && (int) $voter->kehadiran !== 1) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan tunai hanya untuk kehadiran Fizikal.',
], 422);
}
if ($method === 'bank' && (int) $voter->kehadiran !== 2) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan bank hanya untuk kehadiran Maya.',
], 422);
}
// Must have voted
$hasVoted = Result::where('election_id', $electionId)->where('voter_id', $voterId)->exists();
if (!$hasVoted) {
return response()->json([
'status' => 'failed',
'message' => 'Sila undi terlebih dahulu sebelum menuntut elaun.',
], 422);
}
// If already paid, don't generate codes
$alreadyPaid = AllowancePayout::where('election_id', $electionId)
->where('voter_id', $voterId)
->where('method', $method)
->where('status', 'paid')
->exists();
if ($alreadyPaid) {
return response()->json([
'status' => 'failed',
'message' => 'Elaun anda telah direkodkan sebagai sudah dibayar.',
], 422);
}
// Generate a 6-digit numeric code
$codeInt = random_int(0, 999999);
$code = str_pad((string) $codeInt, 6, '0', STR_PAD_LEFT);
$expiresAt = Carbon::now()->addHours(6);
$row = AllowanceClaimCode::updateOrCreate(
[
'election_id' => $electionId,
'voter_id' => $voterId,
'method' => $method,
],
[
'code_hash' => Hash::make($code),
'code_last4' => substr($code, -4),
'expires_at' => $expiresAt,
'used_at' => null,
'used_by_admin_id' => null,
]
);
activity()
->performedOn($row)
->withProperties([
'election_id' => $electionId,
'voter_id' => (int) $voterId,
'voter_name' => $voter->name,
'attendance' => (int) $voter->kehadiran,
'method' => $method,
'expires_at' => $expiresAt->toDateTimeString(),
'code_last4' => $row->code_last4,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("generate claim code ({$method}): {$voter->name}");
return response()->json([
'status' => 'success',
'message' => 'Kod tuntutan berjaya dijana.',
'code' => $code, // return plaintext ONCE to voter
'expires_at' => $expiresAt,
'method' => $method,
]);
}
}
@@ -33,6 +33,17 @@ class StartController extends Controller
'name'=>$request->name 'name'=>$request->name
]); ]);
$user = $request->user();
activity()
->withProperties([
'election_id' => Util::getCurrentElection(),
'election_name' => (string) $request->input('name'),
'started_by_admin_id' => $user ? (int) $user->id : null,
'started_by_admin_email' => $user ? $user->email : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('election start');
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
@@ -21,6 +21,18 @@ class StopController extends Controller
$this->stopElection($request); $this->stopElection($request);
$user = $request->user();
activity()
->withProperties([
'election_id' => Util::getCurrentElection(),
'election_name' => (string) $request->input('name'),
'stopped_by_admin_id' => $user ? (int) $user->id : null,
'stopped_by_admin_email' => $user ? $user->email : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('election stop');
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
'message' => 'Election has finished.', 'message' => 'Election has finished.',
@@ -16,13 +16,38 @@ class VoteController extends Controller
public function __invoke(Request $request) public function __invoke(Request $request)
{ {
$validation = $this->validateRequest($request);
if ($this->validateRequest($request)['status'] == 'failed') { if (($validation['status'] ?? 'failed') === 'failed') {
return response()->json($this->validateRequest($request)); return response()->json($validation);
} }
$this->insertVote($request); $this->insertVote($request);
$user = Auth::guard('voter')->user() ?: $request->user();
$vote = is_array($request->vote) ? $request->vote : [];
$positionsVoted = 0;
$totalSelections = 0;
foreach ($vote as $value) {
$nomineeIds = $value['nominee_id'] ?? null;
if (is_array($nomineeIds) && count($nomineeIds)) {
$positionsVoted++;
$totalSelections += count($nomineeIds);
}
}
activity()
->performedOn($user)
->withProperties([
'election_id' => Util::getCurrentElection(),
'voter_id' => Auth::id(),
'voter_name' => $user ? $user->name : null,
'positions_voted' => $positionsVoted,
'total_selections' => $totalSelections,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('submit vote');
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
'message' => 'Voted successfully', 'message' => 'Voted successfully',
@@ -32,34 +57,31 @@ class VoteController extends Controller
private function insertVote($request) private function insertVote($request)
{ {
// dd($request->vote[0]['nominee_id']); $vote = $request->vote;
$votes = array_map(function($nominee_id) use($request){ if (!is_array($vote)) return;
return[
'voter_id' => Auth::id(),
'election_id' => Util::getCurrentElection(),
'position_id' => $request->vote[0]['position_id'],
'nominee_id' => $nominee_id
];
},$request->vote[0]['nominee_id']); foreach ($vote as $value) {
$positionId = $value['position_id'] ?? null;
$nomineeIds = $value['nominee_id'] ?? [];
if (!$positionId || !is_array($nomineeIds) || !count($nomineeIds)) continue;
foreach($votes as $vote){ foreach ($nomineeIds as $nomineeId) {
Result::updateOrCreate([ Result::updateOrCreate([
'voter_id' => $vote['voter_id'], 'voter_id' => Auth::id(),
'election_id' => $vote['election_id'], 'election_id' => Util::getCurrentElection(),
'position_id' => $vote['position_id'], 'position_id' => $positionId,
'nominee_id' => $vote['nominee_id'], 'nominee_id' => $nomineeId,
]); ]);
}
} }
} }
private function validNominee($id, $position_id) private function validNominee($id, $position_id)
{ {
try{ try{
$nominee = Nominee::where('position_id', '=', $position_id)->whereIn('id',$id)->count(); $nominee = Nominee::where('position_id', '=', $position_id)->whereIn('id',$id)->count();
}catch(Exception $e){ }catch(\Exception $e){
dd($id); return 0;
} }
return $nominee; return $nominee;
} }
@@ -68,8 +90,8 @@ class VoteController extends Controller
{ {
try{ try{
return Position::where('id', $id)->count(); return Position::where('id', $id)->count();
}catch(Exception $e){ }catch(\Exception $e){
dd($id); return 0;
} }
} }
@@ -87,14 +109,8 @@ class VoteController extends Controller
$result['status'] = 'failed'; $result['status'] = 'failed';
$vote = $request->vote; $vote = $request->vote;
if (!is_array($vote) || !count($vote)) {
$result['message'] = 'You must vote on at least one position.';
/**
* Check if the user vote on all position
*/
if (!$this->voteAllPosition($request)) {
dd('test');
$result['message'] = 'You must vote on all position.';
return $result; return $result;
} }
@@ -103,19 +119,25 @@ class VoteController extends Controller
* Check if position_id and nominee_id has a value * Check if position_id and nominee_id has a value
*/ */
// dd($vote,$value); // dd($vote,$value);
if (empty($value['position_id']) || empty($value['nominee_id'])) { $positionId = $value['position_id'] ?? null;
$nomineeIds = $value['nominee_id'] ?? null;
dd($value,empty($value['position_id']),empty($value['nominee_id'])); if (empty($positionId)) {
$result['message'] = 'You must vote on all position.'; $result['message'] = 'Invalid Position.';
return $result;
}
if (!is_array($nomineeIds) || !count($nomineeIds)) {
// Skip empty positions; allow partial voting setups (UI may send all positions with empty arrays)
continue;
}
if (count($nomineeIds) > 1) {
$result['message'] = 'You can only vote for one nominee.';
return $result; return $result;
} }
/** /**
* Check if the Position you vote exists * Check if the Position you vote exists
*/ */
if (!$this->isPositionExist($value['position_id'])) { if (!$this->isPositionExist($positionId)) {
dd('test');
$result['message'] = 'Invalid Position.'; $result['message'] = 'Invalid Position.';
return $result; return $result;
} }
@@ -124,14 +146,28 @@ class VoteController extends Controller
* Check if the Nominee you vote on certain position exists * Check if the Nominee you vote on certain position exists
*/ */
// elseif (!$this->validNominee($value['nominee_id'], $value['position_id'])) { // elseif (!$this->validNominee($value['nominee_id'], $value['position_id'])) {
elseif($this->validNominee($value['nominee_id'],$value['position_id']) <= 0){ elseif($this->validNominee($nomineeIds, $positionId) <= 0){
$result['message'] = 'Invalid Nominee for '.Position::find($value['position_id'])->name.' position.'; $result['message'] = 'Invalid Nominee for '.Position::find($positionId)->name.' position.';
return $result; return $result;
} }
} }
return ['status'=>'success']; // Ensure at least one actual vote exists
$hasAny = false;
foreach ($vote as $value) {
$nomineeIds = $value['nominee_id'] ?? null;
if (is_array($nomineeIds) && count($nomineeIds)) {
$hasAny = true;
break;
}
}
if (!$hasAny) {
$result['message'] = 'You must vote on at least one position.';
return $result;
}
return ['status' => 'success'];
} }
} }
@@ -14,7 +14,21 @@ class AddController extends Controller
public function __invoke (Request $request) public function __invoke (Request $request)
{ {
$this->validateRequest($request); $this->validateRequest($request);
$this->insertNominee($request); $nominee = $this->insertNominee($request);
activity()
->performedOn($nominee)
->withProperties([
'nominee_id' => $nominee->id,
'nominee_name' => $nominee->name,
'election_id' => $nominee->election_id,
'position_id' => $nominee->position_id,
'partylist_id' => $nominee->partylist_id,
'created_by_admin_id' => $request->user() ? (int) $request->user()->id : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("create nominee: {$nominee->name}");
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
'message'=> 'Nominee added successfully' 'message'=> 'Nominee added successfully'
@@ -39,7 +53,11 @@ class AddController extends Controller
'unit' => 'required', 'unit' => 'required',
'position_id' => 'required|exists:position,id', 'position_id' => 'required|exists:position,id',
'partylist_id' => 'nullable|exists:partylist,id', 'partylist_id' => 'nullable|exists:partylist,id',
'image' => 'nullable|image' 'photo' => 'nullable|image',
'umur' => 'nullable|integer|min:1|max:120',
'jawatan_sekarang' => 'nullable|string|max:255',
'experience' => 'nullable|string',
'education' => 'nullable|string',
]); ]);
} }
@@ -53,9 +71,6 @@ class AddController extends Controller
$nominee['photo'] = $imageData; $nominee['photo'] = $imageData;
} }
Nominee::create($nominee); return Nominee::create($nominee);
return response()->json(['message' => 'Photo updated successfully']);
} }
} }
@@ -9,12 +9,23 @@ use App\Nominee;
class DeleteController extends Controller class DeleteController extends Controller
{ {
public function __invoke ($id) public function __invoke (Request $request, $id)
{ {
$nominee = Nominee::findOrFail($id); $nominee = Nominee::findOrFail($id);
Util::deleteImage($nominee->image); Util::deleteImage($nominee->image);
$nominee->delete(); $nominee->delete();
activity()
->withProperties([
'nominee_id' => $nominee->id,
'nominee_name' => $nominee->name,
'election_id' => $nominee->election_id,
'deleted_by_admin_id' => $request->user() ? (int) $request->user()->id : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("delete nominee: {$nominee->name}");
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
'message'=> 'Nominee deleted successfully' 'message'=> 'Nominee deleted successfully'
@@ -5,14 +5,21 @@ namespace App\Http\Controllers\API\v1\Nominee;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Util; use App\Http\Controllers\Util;
use App\Nominee;
class GetController extends Controller class GetController extends Controller
{ {
public function __invoke() public function __invoke()
{ {
$nominee = \App\Nominee::where('election_id', Util::getCurrentElection()) $q = Nominee::query()
->orderBy('position_id') ->where('election_id', Util::getCurrentElection())
->get(); ->orderBy('position_id');
return $nominee;
$positionId = request('position_id');
if ($positionId !== null && $positionId !== '' && (int) $positionId !== 0) {
$q->where('position_id', (int) $positionId);
}
return $q->get();
} }
} }
@@ -13,7 +13,20 @@ class UpdateController extends Controller
public function __invoke (Request $request, $id) public function __invoke (Request $request, $id)
{ {
$this->validateRequest($request, $id); $this->validateRequest($request, $id);
$this->updateNominee($request, $id); $nominee = $this->updateNominee($request, $id);
activity()
->performedOn($nominee)
->withProperties([
'nominee_id' => $nominee->id,
'nominee_name' => $nominee->name,
'election_id' => $nominee->election_id,
'changed_keys' => array_keys($nominee->getChanges()),
'updated_by_admin_id' => $request->user() ? (int) $request->user()->id : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("update nominee: {$nominee->name}");
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
'message' => 'Nominee updated successfully' 'message' => 'Nominee updated successfully'
@@ -43,7 +56,7 @@ class UpdateController extends Controller
private function updateNominee ($request, $id) private function updateNominee ($request, $id)
{ {
$nominee = Nominee::find($id); $nominee = Nominee::findOrFail($id);
$nominee->name = $request->name; $nominee->name = $request->name;
$nominee->unit = $request->unit; $nominee->unit = $request->unit;
@@ -52,6 +65,8 @@ class UpdateController extends Controller
$nominee->partylist_id = $request->partylist_id; $nominee->partylist_id = $request->partylist_id;
$nominee->education = $request->education; $nominee->education = $request->education;
$nominee->experience = $request->experience; $nominee->experience = $request->experience;
$nominee->umur = $request->umur;
$nominee->jawatan_sekarang = $request->jawatan_sekarang;
if ($request->hasFile('photo')) { if ($request->hasFile('photo')) {
$imageData = file_get_contents($request->file('photo')); $imageData = file_get_contents($request->file('photo'));
@@ -59,6 +74,7 @@ class UpdateController extends Controller
} }
$nominee->save(); $nominee->save();
return $nominee;
} }
} }
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\API\v1;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Services\PhysicalAttendanceGateCode;
use Illuminate\Http\Request;
class PhysicalAttendanceGateController extends Controller
{
/**
* Current rotating code for the registration counter display.
*
* @param \Illuminate\Http\Request $request
* @param \App\Services\PhysicalAttendanceGateCode $gate
* @return \Illuminate\Http\JsonResponse
*/
public function __invoke(Request $request, PhysicalAttendanceGateCode $gate)
{
$electionId = Util::getCurrentElection();
return response()->json($gate->currentPayload($electionId));
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\API\v1;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Voter;
/**
* Unauthenticated list of physical (Fizikal) attendees for roulette / display.
*/
class RouletteFizikalVotersController extends Controller
{
public function __invoke()
{
$electionId = Util::getCurrentElection();
$voters = Voter::query()
->where('election_id', $electionId)
->where('kehadiran', 1)
->orderBy('no_anggota')
->get(['id', 'name', 'no_anggota']);
return response()->json([
'voters' => $voters,
]);
}
}
@@ -13,7 +13,22 @@ class AddController extends Controller
public function __invoke(Request $request) public function __invoke(Request $request)
{ {
$this->validateRequest($request); $this->validateRequest($request);
$this->insertVoter($request); $voter = $this->insertVoter($request);
activity()
->performedOn($voter)
->withProperties([
'election_id' => $voter->election_id,
'voter_id' => $voter->id,
'voter_name' => $voter->name,
'no_kp' => $voter->no_kp,
'no_anggota' => $voter->no_anggota,
'unit' => $voter->unit,
'created_by_admin_id' => $request->user() ? (int) $request->user()->id : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("create voter: {$voter->name}");
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
'message' => 'Voter added successfully' 'message' => 'Voter added successfully'
@@ -24,7 +39,7 @@ class AddController extends Controller
{ {
$voter = $request->all(); $voter = $request->all();
$voter['election_id'] = Util::getCurrentElection(); $voter['election_id'] = Util::getCurrentElection();
Voter::create($voter); return Voter::create($voter);
} }
private function validateRequest($request) private function validateRequest($request)
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\API\v1\Voter;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Voter;
use Illuminate\Support\Facades\DB;
/**
* Lists every distinct member (by no_kp) found anywhere in voter,
* for picking who is applicable to the current election.
*/
class AllMembersCatalogController extends Controller
{
public function __invoke()
{
$electionId = Util::getCurrentElection();
$maxIds = DB::table('voter')
->select(DB::raw('MAX(id) as id'))
->whereNotNull('no_kp')
->where('no_kp', '!=', '')
->groupBy('no_kp')
->pluck('id');
$rows = Voter::whereIn('id', $maxIds)
->orderBy('name')
->get(['no_kp', 'no_anggota', 'name', 'unit']);
$inCurrent = Voter::where('election_id', $electionId)
->pluck('no_kp')
->filter(function ($kp) {
return $kp !== null && $kp !== '';
})
->flip();
$catalog = $rows->map(function ($row) use ($inCurrent) {
return [
'no_kp' => $row->no_kp,
'no_anggota' => $row->no_anggota,
'name' => $row->name,
'unit' => $row->unit,
'in_current_election' => $inCurrent->has($row->no_kp),
];
});
return response()->json([
'election_id' => $electionId,
'catalog' => $catalog->values()->all(),
]);
}
}
@@ -3,9 +3,11 @@
namespace App\Http\Controllers\API\v1\Voter; namespace App\Http\Controllers\API\v1\Voter;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Util; use App\Http\Controllers\Util;
use App\Services\PhysicalAttendanceGateCode;
use App\Voter; use App\Voter;
class AttendanceController extends Controller class AttendanceController extends Controller
@@ -14,6 +16,7 @@ class AttendanceController extends Controller
public function __invoke(Request $request, $id) public function __invoke(Request $request, $id)
{ {
$this->validateRequest($request, $id); $this->validateRequest($request, $id);
$this->assertPhysicalGateCode($request);
$this->updateAttendance($request, $id); $this->updateAttendance($request, $id);
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
@@ -23,7 +26,7 @@ class AttendanceController extends Controller
private function validateRequest($request, $id) private function validateRequest($request, $id)
{ {
$this->validate($request, [ $rules = [
'name' => [ 'name' => [
'required', 'required',
Rule::unique('voter')->ignore($id)->where(function($query){ Rule::unique('voter')->ignore($id)->where(function($query){
@@ -46,15 +49,72 @@ class AttendanceController extends Controller
}) })
], ],
'kehadiran' => 'required' 'kehadiran' => 'required',
];
]); if (config('physical_attendance_gate.enabled')) {
$rules['physical_gate_code'] = 'required_if:kehadiran,1|string|max:64';
}
$this->validate($request, $rules);
}
/**
* When gate is enabled, Fizikal (kehadiran = 1) must match the rotating counter code.
*
* @param \Illuminate\Http\Request $request
* @return void
*/
private function assertPhysicalGateCode(Request $request)
{
if (!config('physical_attendance_gate.enabled')) {
return;
}
if ((int) $request->input('kehadiran') !== 1) {
return;
}
$gate = app(PhysicalAttendanceGateCode::class);
if (!$gate->isConfigured()) {
throw new HttpResponseException(response()->json([
'status' => 'failed',
'message' => 'Pengesahan kod fizikal tidak dikonfigurasi dengan betul.',
], 503));
}
if (!$gate->validates(Util::getCurrentElection(), (string) $request->input('physical_gate_code'))) {
throw new HttpResponseException(response()->json([
'status' => 'failed',
'message' => 'Kod kaunter tidak sah atau telah tamat tempoh. Sila semak skrin pendaftaran dan cuba lagi.',
], 422));
}
} }
private function updateAttendance($request, $id) private function updateAttendance($request, $id)
{ {
$voter = $request->all(); $voter = Voter::findOrFail($id);
Voter::find($id)->update($voter); $voter->fill($request->only(['name', 'no_kp', 'no_anggota', 'unit', 'kehadiran']));
if ((int) $voter->kehadiran === 1) {
$voter->fizikal_registration_verified_at = null;
}
$voter->save();
$mode = ((int) $voter->kehadiran === 1) ? 'physical' : 'online';
activity()
->performedOn($voter)
->withProperties([
'voter_id' => $voter->id,
'voter_name' => $voter->name,
'election_id' => Util::getCurrentElection(),
'attendance_mode' => $mode,
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
])
->log("confirm attendance ({$mode}): {$voter->name}");
} }
} }
@@ -5,12 +5,105 @@ namespace App\Http\Controllers\API\v1\Voter;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Util; use App\Http\Controllers\Util;
use Illuminate\Support\Facades\DB;
use App\Voter; use App\Voter;
class GetController extends Controller class GetController extends Controller
{ {
public function __invoke() public function __invoke()
{ {
return Voter::where('election_id', Util::getCurrentElection())->paginate(50); $electionId = Util::getCurrentElection();
$q = Voter::query()
->where('election_id', $electionId)
// Ensure we keep all voter columns when adding computed selects
->select('voter.*')
// Computed: whether voter has any vote record for this election
->selectSub(function ($sq) use ($electionId) {
$sq->from('result')
->selectRaw('COUNT(*)')
->whereColumn('result.voter_id', 'voter.id')
->where('result.election_id', $electionId);
}, 'votes_count');
// Computed: payout timestamps (paperless coupon replacement state)
$q->selectSub(function ($sq) use ($electionId) {
$sq->from('allowance_payouts')
->selectRaw('MAX(paid_at)')
->whereColumn('allowance_payouts.voter_id', 'voter.id')
->where('allowance_payouts.election_id', $electionId)
->where('allowance_payouts.method', 'cash')
->where('allowance_payouts.status', 'paid');
}, 'cash_paid_at');
$q->selectSub(function ($sq) use ($electionId) {
$sq->from('allowance_payouts')
->selectRaw('MAX(id)')
->whereColumn('allowance_payouts.voter_id', 'voter.id')
->where('allowance_payouts.election_id', $electionId)
->where('allowance_payouts.method', 'cash')
->where('allowance_payouts.status', 'paid');
}, 'cash_payout_id');
$q->selectSub(function ($sq) use ($electionId) {
$sq->from('allowance_payouts')
->selectRaw('MAX(paid_at)')
->whereColumn('allowance_payouts.voter_id', 'voter.id')
->where('allowance_payouts.election_id', $electionId)
->where('allowance_payouts.method', 'bank')
->where('allowance_payouts.status', 'paid');
}, 'bank_paid_at');
$q->selectSub(function ($sq) use ($electionId) {
$sq->from('allowance_payouts')
->selectRaw('MAX(id)')
->whereColumn('allowance_payouts.voter_id', 'voter.id')
->where('allowance_payouts.election_id', $electionId)
->where('allowance_payouts.method', 'bank')
->where('allowance_payouts.status', 'paid');
}, 'bank_payout_id');
// Optional search filters
$noAnggota = request('no_anggota');
if ($noAnggota !== null && $noAnggota !== '') {
$q->where('no_anggota', 'like', '%' . $noAnggota . '%');
}
$noKp = request('no_kp');
if ($noKp !== null && $noKp !== '') {
$q->where('no_kp', 'like', '%' . $noKp . '%');
}
$name = request('name');
if ($name !== null && $name !== '') {
$q->where('name', 'like', '%' . $name . '%');
}
// Optional attendance filter: 1 = Fizikal, 2 = Maya
$kehadiran = request('kehadiran');
if ($kehadiran !== null && $kehadiran !== '') {
if ($kehadiran === 'unset') {
$q->where(function ($qq) {
$qq->whereNull('kehadiran')->orWhere('kehadiran', 0);
});
} else {
$k = (int) $kehadiran;
if (in_array($k, [1, 2], true)) {
$q->where('kehadiran', $k);
}
}
}
// Fizikal registration queue: pending = fizikal but not admin-verified yet
$fizikalReg = request('fizikal_reg');
if ($fizikalReg !== null && $fizikalReg !== '') {
if ($fizikalReg === 'pending') {
$q->where('kehadiran', 1)->whereNull('fizikal_registration_verified_at');
} elseif ($fizikalReg === 'verified') {
$q->where('kehadiran', 1)->whereNotNull('fizikal_registration_verified_at');
}
}
return $q->paginate(50);
} }
} }
@@ -16,201 +16,249 @@ class LoginController extends Controller
{ {
public function __invoke(Request $request) public function __invoke(Request $request)
{ {
//return response()->json(Auth::guard('user')); /* Request OTP */
try {
$currentDateTime = Carbon::now();
/* Request OTP */ $expired_at = Carbon::now()->addMinutes(config('onewaysms.minutes'));
try{ $created_at = $currentDateTime->toDateTimeString();
$currentDateTime = Carbon::now();
$expired_at = Carbon::now()->addMinutes(config('onewaysms.minutes')); $voter = Voter::where('no_kp', $request->no_kp)->firstOrFail();
$created_at = $currentDateTime->toDateTimeString();
$voter = Voter::where('no_kp',$request->no_kp)->firstOrFail(); if ($voter) {
$expiration = $this->checkExpirationTAC($request->no_kp);
if($voter){ if ($expiration['notexpired'] == true) {
$expiration = $this->checkExpirationTAC($request->no_kp); throw new \Exception('Expired TAC : ' . $expiration['remaining']);
if($expiration['notexpired'] == true){
throw new \Exception('Expired TAC : ' . $expiration['remaining']);
}
$sms_token = $this->getTokenSMS($voter->telefon,$currentDateTime);
if($sms_token == 'error'){
throw new \Exception('error in getting the data from SMSToken');
}
$user_otp = new UserOTP();
$user_otp->nokp = $voter->no_kp;
$user_otp->telefon = $voter->telefon;
$user_otp->token = $sms_token;
$user_otp->created_at = $created_at;
$user_otp->expired_at = $expired_at;
$user_otp->save();
$response = [
'status' => 'success',
'message' => 'generated SMS Token',
'notel' => $voter->telefon,
'nokp' => $voter->no_kp,
];
if (app()->environment(['local', 'development'])) {
$response['debug_otp'] = $sms_token;
}
return response()->json($response);
}else{
return response()->json([
'status' => 'failed',
'message' => 'Does not found NO KP.'
]);
}
}catch(Exception $e){
return response()->json([
'status' => 'failed',
'message' => 'Error: ' . $e->getMessage()
]);
}
}
public function checkExpirationTAC($nokp) : Array{
$currentDateTime = Carbon::now();
$expiretac = UserOTP::where('nokp',$nokp)
->whereDate('created_at','=',$currentDateTime)
->whereDate('expired_at','=',$currentDateTime)
->whereTime('expired_at','>=',$currentDateTime->toTimeString())
->whereTime('created_at','<=',$currentDateTime->toTimeString())
->latest()
->first();
if($expiretac){
$remaining = $this->getRemainingTime($expiretac->expired_at);
return ['notexpired' => true,'remaining' => $remaining];
}else{
return ['notexpired' => false];
}
}
public function verifyTAC(Request $request){
$authenticateOTP = $this->getTACModel($request->no_kp,$request->token);
if(!$authenticateOTP['condition']){
return response()->json([
'status' => 'failed',
'message' => $authenticateOTP['message']
]);
}
if (Auth::guard('voter')->attempt(['no_kp' => $request->no_kp, 'password' => 'admin', 'election_id' => Util::getCurrentElection()])) {
return response()->json([
'status' => 'success',
'message' => 'Login successfully.',
'user' => Auth::guard('voter')->user(),
'election_status' => Util::getElectionStatus(),
'token' => Auth::guard('voter')->user()->createToken('My Token', ['vote'])->accessToken
]);
} else {
return response()->json([
'status' => 'failed',
'message' => 'No. KP tidak wujud. Atau sesi mengundi telah tamat'
]);
}
return response()->json([
'status' => 'success',
'message' => 'Login successfully.',
]);
}
private function getTACModel($no_kp,$token){
$currentDateTime = Carbon::now();
$result = UserOTP::where('nokp',$no_kp)
->whereDate('created_at','=',$currentDateTime)
->whereDate('expired_at','=',$currentDateTime)
->whereTime('expired_at','>=',$currentDateTime->toTimeString())
->whereTime('created_at','<=',$currentDateTime->toTimeString())
->latest()
->first();
if(!$result){
return ['condition' => false, 'message' => 'Token Expired'];
}
if($result['token'] == $token){
return ['condition' => true];
}else{
return ['condition' => false, 'message' => 'Invalid Token'];
}
}
private function getTokenSMS($tele,$date){
$tac_token = $this->randum_number(6,2,false);
if(env("APP_ENV") == 'production'){
$mobileno = '6' . $tele;
$messages = sprintf(config('onewaysms.message'),$tac_token,$date);
$parameter = [
'apiusername' => env("ONEWAY_SMS_USERNAME"),
'apipassword' => env("ONEWAY_SMS_PASSWORD"),
'senderid' => env("ONEWAY_SMS_SENDERID",'INFO'),
'mobileno' => $mobileno,
'message'=> $messages,
'languagetype'=> env("ONEWAY_SMS_LANG",1)
];
$client = new Client(['verify' => false]);
$status = $client->get('http://gateway.onewaysms.com.my:10001/api.aspx',[
'query' => $parameter
]);
if($status){
return $tac_token;
}else{
return 'error';
}
}else{
return $tac_token;
}
}
private function randum_number($len = 6,$dup = 1, $sort = false){
if($dup < 1)
throw new \InvalidArgumentException('Second argument is < 1');
$num = range(0,9);
shuffle($num);
$num = array_slice($num, 0, ($len-$dup) + 1);
if($dup > 0){
$k = array_rand($num, 1);
for($i=0;$i<($dup-1);$i++)
{
$num[] = $num[$k];
} }
$sms_token = $this->getTokenSMS($voter->telefon, $currentDateTime);
if ($sms_token == 'error') {
throw new \Exception('error in getting the data from SMSToken');
}
$user_otp = new UserOTP();
$user_otp->nokp = $voter->no_kp;
$user_otp->telefon = $voter->telefon;
$user_otp->token = $sms_token;
$user_otp->created_at = $created_at;
$user_otp->expired_at = $expired_at;
$user_otp->save();
activity()
->performedOn($voter)
->withProperties([
'election_id' => Util::getCurrentElection(),
'voter_id' => $voter->id,
'voter_name' => $voter->name,
'no_kp' => $voter->no_kp,
'telefon' => $voter->telefon,
'otp_expires_at' => $expired_at->toDateTimeString(),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('voter request tac');
$response = [
'status' => 'success',
'message' => 'generated SMS Token',
'notel' => $voter->telefon,
'nokp' => $voter->no_kp,
];
if (app()->environment(['local', 'development'])) {
$response['debug_otp'] = $sms_token;
}
return response()->json($response);
} }
if($sort){ return response()->json([
sort($num); 'status' => 'failed',
} 'message' => 'Does not found NO KP.',
]);
} catch (Exception $e) {
activity()
->withProperties([
'no_kp' => (string) $request->input('no_kp'),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'error' => $e->getMessage(),
])
->log('voter request tac failed');
return implode('',$num); return response()->json([
'status' => 'failed',
'message' => 'Error: ' . $e->getMessage(),
]);
}
} }
private function getRemainingTime($expired_at){ public function checkExpirationTAC($nokp): array
{
$currentDateTime = Carbon::now(); $currentDateTime = Carbon::now();
$expiredAt = Carbon::parse($expired_at);
$remainingSeconds = $currentDateTime->diffInSeconds($expiredAt); $expiretac = UserOTP::where('nokp', $nokp)
->whereDate('created_at', '=', $currentDateTime)
->whereDate('expired_at', '=', $currentDateTime)
->whereTime('expired_at', '>=', $currentDateTime->toTimeString())
->whereTime('created_at', '<=', $currentDateTime->toTimeString())
->latest()
->first();
$remainingFormatted = gmdate('H:i:s', $remainingSeconds); if ($expiretac) {
$remaining = $this->getRemainingTime($expiretac->expired_at);
return $remainingFormatted; return ['notexpired' => true, 'remaining' => $remaining];
} }
return ['notexpired' => false];
}
public function verifyTAC(Request $request)
{
$authenticateOTP = $this->getTACModel($request->no_kp, $request->token);
if (! $authenticateOTP['condition']) {
activity()
->withProperties([
'no_kp' => (string) $request->input('no_kp'),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'reason' => $authenticateOTP['message'] ?? 'invalid',
])
->log('voter verify tac failed');
return response()->json([
'status' => 'failed',
'message' => $authenticateOTP['message'],
]);
}
if (Auth::guard('voter')->attempt(['no_kp' => $request->no_kp, 'password' => 'admin', 'election_id' => Util::getCurrentElection()])) {
$user = Auth::guard('voter')->user();
activity()
->performedOn($user)
->withProperties([
'election_id' => Util::getCurrentElection(),
'voter_id' => $user->id,
'voter_name' => $user->name,
'no_kp' => $user->no_kp,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("voter login: {$user->name}");
return response()->json([
'status' => 'success',
'message' => 'Login successfully.',
'user' => Auth::guard('voter')->user(),
'election_status' => Util::getElectionStatus(),
'token' => Auth::guard('voter')->user()->createToken('My Token', ['vote'])->accessToken,
]);
}
activity()
->withProperties([
'no_kp' => (string) $request->input('no_kp'),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('voter login failed');
return response()->json([
'status' => 'failed',
'message' => 'No. KP tidak wujud. Atau sesi mengundi telah tamat',
]);
}
private function getTACModel($no_kp, $token)
{
$currentDateTime = Carbon::now();
$result = UserOTP::where('nokp', $no_kp)
->whereDate('created_at', '=', $currentDateTime)
->whereDate('expired_at', '=', $currentDateTime)
->whereTime('expired_at', '>=', $currentDateTime->toTimeString())
->whereTime('created_at', '<=', $currentDateTime->toTimeString())
->latest()
->first();
if (! $result) {
return ['condition' => false, 'message' => 'Token Expired'];
}
if ($result['token'] == $token) {
return ['condition' => true];
}
return ['condition' => false, 'message' => 'Invalid Token'];
}
private function getTokenSMS($tele, $date)
{
$tac_token = $this->randum_number(6, 2, false);
if (env('APP_ENV') == 'production') {
$mobileno = '6' . $tele;
$messages = sprintf(config('onewaysms.message'), $tac_token, $date);
$parameter = [
'apiusername' => env('ONEWAY_SMS_USERNAME'),
'apipassword' => env('ONEWAY_SMS_PASSWORD'),
'senderid' => env('ONEWAY_SMS_SENDERID', 'INFO'),
'mobileno' => $mobileno,
'message' => $messages,
'languagetype' => env('ONEWAY_SMS_LANG', 1),
];
$client = new Client(['verify' => false]);
$status = $client->get('http://gateway.onewaysms.com.my:10001/api.aspx', [
'query' => $parameter,
]);
if ($status) {
return $tac_token;
}
return 'error';
}
return $tac_token;
}
private function randum_number($len = 6, $dup = 1, $sort = false)
{
if ($dup < 1) {
throw new \InvalidArgumentException('Second argument is < 1');
}
$num = range(0, 9);
shuffle($num);
$num = array_slice($num, 0, ($len - $dup) + 1);
if ($dup > 0) {
$k = array_rand($num, 1);
for ($i = 0; $i < ($dup - 1); $i++) {
$num[] = $num[$k];
}
}
if ($sort) {
sort($num);
}
return implode('', $num);
}
private function getRemainingTime($expired_at)
{
$currentDateTime = Carbon::now();
$expiredAt = Carbon::parse($expired_at);
$remainingSeconds = $currentDateTime->diffInSeconds($expiredAt);
return gmdate('H:i:s', $remainingSeconds);
}
} }
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\API\v1\Voter;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use Illuminate\Http\Request;
class LogoutController extends Controller
{
public function __invoke(Request $request)
{
$user = $request->user();
if ($user) {
activity()
->performedOn($user)
->withProperties([
'election_id' => Util::getCurrentElection(),
'voter_id' => $user->id,
'voter_name' => $user->name,
'no_kp' => $user->no_kp,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("voter logout: {$user->name}");
// Revoke current access token if using Passport.
try {
if (method_exists($user, 'token') && $user->token()) {
$user->token()->revoke();
}
} catch (\Throwable $e) {
// Logging should not block logout.
}
} else {
activity()
->withProperties([
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('voter logout');
}
return response()->json([
'status' => 'success',
'message' => 'Logout successfully',
]);
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\API\v1\Voter;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Voter;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Applies admin selection: voters ticked exist for current election (copied from latest row per no_kp);
* unticked are removed from the current election only.
*/
class SyncApplicableVotersController extends Controller
{
public function __invoke(Request $request)
{
$this->validate($request, [
'selected_no_kp' => 'present|array',
'selected_no_kp.*' => 'nullable|string|max:60',
]);
$electionId = Util::getCurrentElection();
$selected = collect($request->input('selected_no_kp', []))
->map(function ($v) {
return trim((string) $v);
})
->filter(function ($v) {
return $v !== '';
})
->unique()
->values();
$stats = [
'deleted' => 0,
'added' => 0,
'selected_count' => $selected->count(),
];
DB::transaction(function () use ($electionId, $selected) {
$selectedSet = $selected->flip();
$current = Voter::where('election_id', $electionId)->get();
foreach ($current as $voter) {
$kp = $voter->no_kp;
if ($kp === null || $kp === '') {
continue;
}
if (!$selectedSet->has($kp)) {
$voter->delete();
}
}
$existingKp = Voter::where('election_id', $electionId)->pluck('no_kp')->all();
$existingFlip = array_flip($existingKp);
foreach ($selected as $noKp) {
if (isset($existingFlip[$noKp])) {
continue;
}
$template = Voter::where('no_kp', $noKp)->orderBy('id', 'desc')->first();
if (!$template) {
continue;
}
$new = $template->replicate();
$new->election_id = $electionId;
$new->kehadiran = null;
$new->fizikal_registration_verified_at = null;
$new->persetujuan = null;
$new->status_penyata = 'DRAF';
$new->tarikh_sah = null;
$new->cadangan = null;
$new->save();
}
});
// Log after transaction succeeds (no joins; data from request & current election).
activity()
->withProperties([
'election_id' => $electionId,
'selected_count' => $selected->count(),
'selected_no_kp' => $selected->take(50)->values()->all(),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('sync applicable voters');
return response()->json([
'status' => 'success',
'message' => 'Senarai pengundi untuk pilihan raya semasa dikemas kini.',
]);
}
}
@@ -41,7 +41,24 @@ class UpdateController extends Controller
private function updateVoter($request, $id) private function updateVoter($request, $id)
{ {
$voter = $request->all(); $voter = Voter::findOrFail($id);
Voter::find($id)->update($voter);
$old = $voter->toArray();
$voter->fill($request->all());
$voter->save();
$dirtyKeys = array_keys($voter->getChanges());
activity()
->performedOn($voter)
->withProperties([
'voter_id' => $voter->id,
'voter_name' => $voter->name,
'election_id' => Util::getCurrentElection(),
'changed_keys' => $dirtyKeys,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("update voter: {$voter->name}");
} }
} }
@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\API\v1\Voter;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Voter;
use Illuminate\Http\Request;
class VerifyFizikalRegistrationController extends Controller
{
/**
* Admin confirms physical registration so the voter may vote (fizikal_registration_verified_at).
*/
public function __invoke(Request $request, $id)
{
$electionId = Util::getCurrentElection();
$voter = Voter::where('election_id', $electionId)
->where('id', $id)
->firstOrFail();
if ((int) $voter->kehadiran !== 1) {
return response()->json([
'status' => 'failed',
'message' => 'Pengesahan pendaftaran fizikal hanya untuk pengundi Fizikal.',
], 422);
}
if ($voter->fizikal_registration_verified_at !== null) {
activity()
->performedOn($voter)
->withProperties([
'election_id' => $electionId,
'voter_id' => $voter->id,
'voter_name' => $voter->name,
'already_verified' => true,
'verified_at' => (string) $voter->fizikal_registration_verified_at,
'verified_by_admin_id' => $request->user() ? (int) $request->user()->id : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("verify fizikal registration: {$voter->name}");
return response()->json([
'status' => 'success',
'message' => 'Pendaftaran telah disahkan sebelum ini.',
'voter' => $voter,
]);
}
$voter->fizikal_registration_verified_at = now();
$voter->save();
activity()
->performedOn($voter)
->withProperties([
'election_id' => $electionId,
'voter_id' => $voter->id,
'voter_name' => $voter->name,
'already_verified' => false,
'verified_at' => (string) $voter->fizikal_registration_verified_at,
'verified_by_admin_id' => $request->user() ? (int) $request->user()->id : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("verify fizikal registration: {$voter->name}");
return response()->json([
'status' => 'success',
'message' => 'Pendaftaran fizikal telah disahkan.',
'voter' => $voter->fresh(),
]);
}
}
+3
View File
@@ -63,6 +63,9 @@ class Kernel extends HttpKernel
'scope' => \Laravel\Passport\Http\Middleware\CheckForAnyScope::class, 'scope' => \Laravel\Passport\Http\Middleware\CheckForAnyScope::class,
'isvoted' => \App\Http\Middleware\IsVotedMiddleware::class, 'isvoted' => \App\Http\Middleware\IsVotedMiddleware::class,
'main_admin' => \App\Http\Middleware\MainAdminMiddleware::class, 'main_admin' => \App\Http\Middleware\MainAdminMiddleware::class,
'attendance_committee_role' => \App\Http\Middleware\AttendanceCommitteeRoleMiddleware::class,
'has_voted' => \App\Http\Middleware\HasVotedMiddleware::class, 'has_voted' => \App\Http\Middleware\HasVotedMiddleware::class,
'physical_gate_display' => \App\Http\Middleware\PhysicalGateDisplayKeyMiddleware::class,
'fizikal_registration_verified' => \App\Http\Middleware\FizikalRegistrationVerifiedMiddleware::class,
]; ];
} }
@@ -0,0 +1,57 @@
<?php
namespace App\Http\Middleware;
use Closure;
/**
* Role 3 = Jawatankuasa Kehadiran: only counter/kehadiran-related APIs.
* Other roles pass through unchanged.
*/
class AttendanceCommitteeRoleMiddleware
{
public function handle($request, Closure $next)
{
$user = $request->user();
if (!$user || (int) $user->role !== 3) {
return $next($request);
}
if ($this->attendanceCommitteeMayAccess($request)) {
return $next($request);
}
return response()->json([
'status' => 'failed',
'message' => 'Akses ditolak. Akaun ini hanya untuk halaman kehadiran.',
], 403);
}
/**
* @param \Illuminate\Http\Request $request
* @return bool
*/
private function attendanceCommitteeMayAccess($request)
{
if ($request->isMethod('get') && $request->is('api/v1/admin/information')) {
return true;
}
if ($request->isMethod('get') && $request->is('api/v1/admin/logout')) {
return true;
}
if ($request->isMethod('post') && $request->is('api/v1/admin/impersonate/leave')) {
return true;
}
if ($request->isMethod('get') && $request->is('api/v1/voter')) {
return true;
}
if ($request->isMethod('post') && $request->is('api/v1/voter/*/verify-fizikal-registration')) {
return true;
}
if ($request->is('api/v1/allowance/*')) {
return true;
}
return false;
}
}
+17 -2
View File
@@ -20,7 +20,7 @@ class ElectionMiddleware
if ($this->isElection($request)) { if ($this->isElection($request)) {
return response()->json([ return response()->json([
'status' => 'failed', 'status' => 'failed',
'message' => 'You can not add, update, or delete during the election.' 'message' => 'Anda tidak boleh menambah, mengemas kini, atau menghapus semasa sesi mengundi.'
]); ]);
} }
@@ -29,6 +29,21 @@ class ElectionMiddleware
private function isElection($request) private function isElection($request)
{ {
return !$request->isMethod('get') && Util::getElectionStatus() == 2 && !$request->is('api/v1/election/*'); return
!$request->isMethod('get')
&& Util::getElectionStatus() == 2
&& !$request->is('api/v1/election/*')
// Allow paperless allowance flows during election (cash counter needs this)
&& !$request->is('api/v1/allowance/*')
// Allow admin to update voter info during election (edit screen uses PUT /api/v1/voter/{id})
&& !($request->is('api/v1/voter/*') && $request->isMethod('put'))
// Allow admin to add voter during election (manual add uses POST /api/v1/voter)
&& !($request->is('api/v1/voter') && $request->isMethod('post'))
// Allow updating applicable voters list during election (add page uses POST /api/v1/voter/sync-applicable)
&& !($request->is('api/v1/voter/sync-applicable') && $request->isMethod('post'))
&& !$request->is('api/v1/voter/*/verify-fizikal-registration')
// Pentadbir utama: urus akaun (tambah/kemaskini/padam) semasa mengundi
&& !$request->is('api/v1/admin')
&& !$request->is('api/v1/admin/*');
} }
} }
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use App\Voter;
use Closure;
use Illuminate\Support\Facades\Auth;
class FizikalRegistrationVerifiedMiddleware
{
/**
* Block voting until fizikal attendance is confirmed by admin (kehadiran = 1 + verified_at set).
*/
public function handle($request, Closure $next)
{
$user = Auth::user();
if (!$user instanceof Voter) {
return response()->json([
'status' => 'failed',
'message' => 'Sesi tidak sah.',
], 401);
}
if (!$user->isFizikalRegistrationVerifiedForVoting()) {
return response()->json([
'status' => 'failed',
'message' => 'Pendaftaran fizikal anda belum disahkan oleh pentadbir. Sila tunggu pengesahan di kaunter.',
], 403);
}
return $next($request);
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Middleware;
use Closure;
class PhysicalGateDisplayKeyMiddleware
{
/**
* Only clients that know the display key may read the current gate code.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if (!config('physical_attendance_gate.enabled')) {
return response()->json([
'status' => 'failed',
'message' => 'Ciri kod kaunter fizikal tidak diaktifkan.',
], 404);
}
$expected = (string) config('physical_attendance_gate.display_key');
if ($expected === '') {
return response()->json([
'status' => 'failed',
'message' => 'Kod paparan kaunter belum dikonfigurasi.',
], 503);
}
$provided = (string) $request->header('X-Physical-Gate-Display-Key', '');
if ($provided === '' && $request->bearerToken()) {
$provided = (string) $request->bearerToken();
}
if ($provided === '' || !hash_equals($expected, $provided)) {
return response()->json([
'status' => 'failed',
'message' => 'Akses ditolak.',
], 403);
}
$service = app(\App\Services\PhysicalAttendanceGateCode::class);
if (!$service->isConfigured()) {
return response()->json([
'status' => 'failed',
'message' => 'PHYSICAL_ATTENDANCE_GATE_SECRET belum ditetapkan.',
], 503);
}
return $next($request);
}
}
+2 -1
View File
@@ -8,7 +8,6 @@ class Nominee extends Model
{ {
protected $table = 'nominee'; protected $table = 'nominee';
protected $fillable = [ protected $fillable = [
'name', 'name',
'unit', 'unit',
'no_anggota', 'no_anggota',
@@ -18,6 +17,8 @@ class Nominee extends Model
'experience', 'experience',
'education', 'education',
'photo', 'photo',
'umur',
'jawatan_sekarang',
]; ];
public $timestamp = false; public $timestamp = false;
@@ -0,0 +1,96 @@
<?php
namespace App\Services;
class PhysicalAttendanceGateCode
{
/** @var string */
protected $secret;
/** @var int */
protected $period;
public function __construct()
{
$this->secret = (string) config('physical_attendance_gate.secret');
$this->period = max(30, (int) config('physical_attendance_gate.period_seconds', 60));
}
public function isConfigured()
{
return $this->secret !== '';
}
/**
* Current code and countdown for the counter display.
*
* @param int $electionId
* @return array{code:string,seconds_remaining:int,period_seconds:int}
*/
public function currentPayload($electionId)
{
$slot = $this->currentSlot();
$code = $this->codeForSlot((int) $electionId, $slot);
$expiresAt = ($slot + 1) * $this->period;
$secondsRemaining = max(0, $expiresAt - time());
return [
'code' => $code,
'seconds_remaining' => $secondsRemaining,
'period_seconds' => $this->period,
];
}
/**
* Accept current, previous, or next slot to tolerate minor clock skew.
*
* @param int $electionId
* @param string $input
* @return bool
*/
public function validates($electionId, $input)
{
if (!$this->isConfigured()) {
return false;
}
$normalized = strtoupper(preg_replace('/\s+/', '', (string) $input));
if (strlen($normalized) < 4) {
return false;
}
$slot = $this->currentSlot();
foreach ([$slot - 1, $slot, $slot + 1] as $s) {
if ($s < 0) {
continue;
}
if (hash_equals($this->codeForSlot((int) $electionId, $s), $normalized)) {
return true;
}
}
return false;
}
protected function currentSlot()
{
return intdiv(time(), $this->period);
}
/**
* Deterministic code for one election and time slot.
*
* @param int $electionId
* @param int $slot
* @return string
*/
protected function codeForSlot($electionId, $slot)
{
$material = (string) $electionId.'|'.(string) $slot;
$hex = hash_hmac('sha256', $material, $this->secret);
return strtoupper(substr($hex, 0, 8));
}
}
+14 -1
View File
@@ -5,10 +5,11 @@ namespace App;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Passport\HasApiTokens; use Laravel\Passport\HasApiTokens;
use Lab404\Impersonate\Models\Impersonate;
class User extends Authenticatable class User extends Authenticatable
{ {
use HasApiTokens, Notifiable; use HasApiTokens, Notifiable, Impersonate;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@@ -27,4 +28,16 @@ class User extends Authenticatable
protected $hidden = [ protected $hidden = [
'password', 'remember_token', 'password', 'remember_token',
]; ];
public function canImpersonate()
{
// Limit to main admin to avoid privilege escalation.
return (int) $this->id === 1;
}
public function canBeImpersonated()
{
// Do not allow impersonating main admin.
return (int) $this->id !== 1;
}
} }
+17
View File
@@ -10,5 +10,22 @@ class Voter extends Authenticatable
use HasApiTokens; use HasApiTokens;
protected $table = 'voter'; protected $table = 'voter';
protected $fillable = ['name', 'no_kp', 'no_anggota','unit', 'election_id', 'alamat', 'telefon', 'saham', 'yuran', 'kehadiran','status_penyata','persetujuan','tarikh_sah','cadangan','pelaburan','tergempar','barangan','peribadi','berjamin_yuran','roadtax','pelbagai']; protected $fillable = ['name', 'no_kp', 'no_anggota','unit', 'election_id', 'alamat', 'telefon', 'saham', 'yuran', 'kehadiran','status_penyata','persetujuan','tarikh_sah','cadangan','pelaburan','tergempar','barangan','peribadi','berjamin_yuran','roadtax','pelbagai'];
protected $casts = [
'fizikal_registration_verified_at' => 'datetime',
];
/**
* Fizikal attendees must be counter-verified by admin; Maya does not use this gate.
*/
public function isFizikalRegistrationVerifiedForVoting(): bool
{
if ((int) $this->kehadiran !== 1) {
return true;
}
return $this->fizikal_registration_verified_at !== null;
}
} }
+3 -1
View File
@@ -10,11 +10,13 @@
"cloudinary/cloudinary_php": "^2.3", "cloudinary/cloudinary_php": "^2.3",
"doctrine/dbal": "^2.5", "doctrine/dbal": "^2.5",
"guzzlehttp/guzzle": "^6.5", "guzzlehttp/guzzle": "^6.5",
"lab404/laravel-impersonate": "^1.5",
"laravel/framework": "5.8.*", "laravel/framework": "5.8.*",
"laravel/passport": "^4.0", "laravel/passport": "^4.0",
"laravel/tinker": "~1.0", "laravel/tinker": "~1.0",
"lcobucci/jwt": "3.3", "lcobucci/jwt": "3.3",
"maatwebsite/excel": "^3.1" "maatwebsite/excel": "^3.1",
"spatie/laravel-activitylog": "^3.9"
}, },
"require-dev": { "require-dev": {
"fzaninotto/faker": "~1.4", "fzaninotto/faker": "~1.4",
Generated
+349 -4
View File
@@ -4,8 +4,63 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "288ad761691b730aeaf754d9b3bff499", "content-hash": "4a6894c6aee2c861018fbf0cc9372342",
"packages": [ "packages": [
{
"name": "anahkiasen/underscore-php",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/Anahkiasen/underscore-php.git",
"reference": "48f97b295c82d99c1fe10d8b0684c43f051b5580"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Anahkiasen/underscore-php/zipball/48f97b295c82d99c1fe10d8b0684c43f051b5580",
"reference": "48f97b295c82d99c1fe10d8b0684c43f051b5580",
"shasum": ""
},
"require": {
"doctrine/inflector": "^1.0",
"patchwork/utf8": "^1.2",
"php": ">=5.4.0"
},
"require-dev": {
"fabpot/php-cs-fixer": "2.0.*@dev",
"phpunit/phpunit": "^4.6"
},
"type": "library",
"autoload": {
"psr-4": {
"Underscore\\": [
"src",
"tests"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maxime Fabre",
"email": "ehtnam6@gmail.com"
}
],
"description": "A redacted port of Underscore.js for PHP",
"keywords": [
"internals",
"laravel",
"toolkit"
],
"support": {
"issues": "https://github.com/Anahkiasen/underscore-php/issues",
"source": "https://github.com/Anahkiasen/underscore-php/tree/develop"
},
"abandoned": true,
"time": "2015-05-16T19:24:58+00:00"
},
{ {
"name": "barryvdh/laravel-dompdf", "name": "barryvdh/laravel-dompdf",
"version": "v0.8.4", "version": "v0.8.4",
@@ -1802,6 +1857,75 @@
"abandoned": "php-parallel-lint/php-console-highlighter", "abandoned": "php-parallel-lint/php-console-highlighter",
"time": "2018-09-29T18:48:56+00:00" "time": "2018-09-29T18:48:56+00:00"
}, },
{
"name": "lab404/laravel-impersonate",
"version": "1.5.1",
"source": {
"type": "git",
"url": "https://github.com/404labfr/laravel-impersonate.git",
"reference": "7c4d5345a76aedf58d38fc986ec933b10685909a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/404labfr/laravel-impersonate/zipball/7c4d5345a76aedf58d38fc986ec933b10685909a",
"reference": "7c4d5345a76aedf58d38fc986ec933b10685909a",
"shasum": ""
},
"require": {
"laravel/framework": "5.8.* | ^6.0",
"php": ">=7.1.3"
},
"require-dev": {
"orchestra/database": "^3.8 | ^4.0",
"orchestra/testbench": "^3.8 | ^4.0",
"phpunit/phpunit": "^7.5 | ^8.0",
"symfony/http-foundation": ">=4.3.8",
"symfony/mime": ">=4.3.8"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Lab404\\Impersonate\\ImpersonateServiceProvider"
]
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Lab404\\Impersonate\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "404lab",
"email": "web@404lab.fr"
}
],
"description": "Laravel Impersonate is a plugin that allows to you to authenticate as your users.",
"keywords": [
"auth",
"impersonate",
"impersonation",
"laravel",
"laravel-package",
"laravel-plugin",
"package",
"plugin",
"user"
],
"support": {
"issues": "https://github.com/404labfr/laravel-impersonate/issues",
"source": "https://github.com/404labfr/laravel-impersonate/tree/master"
},
"time": "2020-01-02T15:27:10+00:00"
},
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v5.8.38", "version": "v5.8.38",
@@ -3121,6 +3245,83 @@
}, },
"time": "2022-02-16T17:07:03+00:00" "time": "2022-02-16T17:07:03+00:00"
}, },
{
"name": "patchwork/utf8",
"version": "v1.3.3",
"source": {
"type": "git",
"url": "https://github.com/tchwork/utf8.git",
"reference": "e1fa4d4a57896d074c9a8d01742b688d5db4e9d5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/tchwork/utf8/zipball/e1fa4d4a57896d074c9a8d01742b688d5db4e9d5",
"reference": "e1fa4d4a57896d074c9a8d01742b688d5db4e9d5",
"shasum": ""
},
"require": {
"lib-pcre": ">=7.3",
"php": ">=5.3.0"
},
"require-dev": {
"symfony/phpunit-bridge": "^3.4|^4.4"
},
"suggest": {
"ext-iconv": "Use iconv for best performance",
"ext-intl": "Use Intl for best performance",
"ext-mbstring": "Use Mbstring for best performance",
"ext-wfio": "Use WFIO for UTF-8 filesystem access on Windows"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.3-dev"
}
},
"autoload": {
"psr-4": {
"Patchwork\\": "src/Patchwork/"
},
"classmap": [
"src/Normalizer.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"(Apache-2.0 or GPL-2.0)"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
}
],
"description": "Portable and performant UTF-8, Unicode and Grapheme Clusters for PHP",
"homepage": "https://github.com/tchwork/utf8",
"keywords": [
"grapheme",
"i18n",
"unicode",
"utf-8",
"utf8"
],
"support": {
"issues": "https://github.com/tchwork/utf8/issues",
"source": "https://github.com/tchwork/utf8/tree/v1.3.3"
},
"funding": [
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/patchwork/utf8",
"type": "tidelift"
}
],
"abandoned": "symfony/polyfill-mbstring or symfony/string",
"time": "2021-01-07T16:38:58+00:00"
},
{ {
"name": "phenx/php-font-lib", "name": "phenx/php-font-lib",
"version": "0.5.6", "version": "0.5.6",
@@ -4041,6 +4242,150 @@
}, },
"time": "2025-07-11T13:20:48+00:00" "time": "2025-07-11T13:20:48+00:00"
}, },
{
"name": "spatie/laravel-activitylog",
"version": "3.9.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-activitylog.git",
"reference": "659738573f8607191afbd2b794db8669a5b20951"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/659738573f8607191afbd2b794db8669a5b20951",
"reference": "659738573f8607191afbd2b794db8669a5b20951",
"shasum": ""
},
"require": {
"illuminate/config": "5.8.*|^6.0",
"illuminate/database": "5.8.*|^6.0",
"illuminate/support": "5.8.*|^6.0",
"php": "^7.2",
"spatie/string": "^2.1"
},
"require-dev": {
"ext-json": "*",
"orchestra/testbench": "3.8.*|^4.0",
"phpunit/phpunit": "^7.5|^8.0",
"scrutinizer/ocular": "^1.5"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\Activitylog\\ActivitylogServiceProvider"
]
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Spatie\\Activitylog\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
},
{
"name": "Sebastian De Deyne",
"email": "sebastian@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
},
{
"name": "Tom Witkowski",
"email": "dev.gummibeer@gmail.com",
"homepage": "https://gummibeer.de",
"role": "Developer"
}
],
"description": "A very simple activity logger to monitor the users of your website or application",
"homepage": "https://github.com/spatie/activitylog",
"keywords": [
"activity",
"laravel",
"log",
"spatie",
"user"
],
"support": {
"issues": "https://github.com/spatie/laravel-activitylog/issues",
"source": "https://github.com/spatie/laravel-activitylog/tree/master"
},
"time": "2019-10-15T07:39:07+00:00"
},
{
"name": "spatie/string",
"version": "2.2.3",
"source": {
"type": "git",
"url": "https://github.com/spatie/string.git",
"reference": "79ed501c8d624fb85bf71da4254e1878fb616c51"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/string/zipball/79ed501c8d624fb85bf71da4254e1878fb616c51",
"reference": "79ed501c8d624fb85bf71da4254e1878fb616c51",
"shasum": ""
},
"require": {
"anahkiasen/underscore-php": "^2.0",
"php": "^7.0|^8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.3"
},
"type": "library",
"autoload": {
"files": [
"src/string_functions.php"
],
"psr-4": {
"Spatie\\String\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "String handling evolved",
"homepage": "https://github.com/spatie/string",
"keywords": [
"handling",
"handy",
"spatie",
"string"
],
"support": {
"issues": "https://github.com/spatie/string/issues",
"source": "https://github.com/spatie/string/tree/2.2.3"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
}
],
"time": "2020-11-28T22:24:20+00:00"
},
{ {
"name": "swiftmailer/swiftmailer", "name": "swiftmailer/swiftmailer",
"version": "v6.3.0", "version": "v6.3.0",
@@ -8156,12 +8501,12 @@
], ],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": [], "stability-flags": {},
"prefer-stable": false, "prefer-stable": false,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": ">=5.6.4" "php": ">=5.6.4"
}, },
"platform-dev": [], "platform-dev": {},
"plugin-api-version": "2.3.0" "plugin-api-version": "2.9.0"
} }
+98
View File
@@ -0,0 +1,98 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
*/
'deprecations' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['single'],
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAYS', 14),
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => env('LOG_LEVEL', 'critical'),
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => SyslogUdpHandler::class,
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
],
],
'stderr' => [
'driver' => 'monolog',
'handler' => StreamHandler::class,
'with' => [
'stream' => 'php://stderr',
],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];
+30
View File
@@ -0,0 +1,30 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Physical attendance gate (rotating counter code)
|--------------------------------------------------------------------------
|
| When enabled, voters choosing Fizikal must submit a code that matches
| the value shown on the registration counter display. The code is derived
| from PHYSICAL_ATTENDANCE_GATE_SECRET, current election id, and a time slot
| (period_seconds), so all devices stay in sync without a database row.
|
*/
'enabled' => env('PHYSICAL_ATTENDANCE_GATE_ENABLED', false),
'secret' => env('PHYSICAL_ATTENDANCE_GATE_SECRET', ''),
'period_seconds' => (int) env('PHYSICAL_ATTENDANCE_GATE_PERIOD', 60),
/*
| Counter / TV browser sends this value (header X-Physical-Gate-Display-Key
| or Authorization: Bearer ...) to read the current code. Keep it long
| and random; do not expose it to voters.
*/
'display_key' => env('PHYSICAL_ATTENDANCE_GATE_DISPLAY_KEY', ''),
];
@@ -0,0 +1,68 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateAllowanceClaimCodesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('allowance_claim_codes', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedInteger('election_id');
$table->unsignedInteger('voter_id');
$table->string('method', 20)->default('cash'); // cash | bank
// Store hash only (never store plaintext code)
$table->string('code_hash', 255);
$table->string('code_last4', 10)->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('used_at')->nullable();
$table->unsignedInteger('used_by_admin_id')->nullable();
// Only one row per voter per election per method (regenerate by updating same row)
$table->unique(['election_id', 'voter_id', 'method'], 'allowance_claim_codes_unique');
$table->index(['election_id', 'method']);
$table->index(['voter_id', 'election_id']);
$table->index(['used_at']);
$table->foreign('election_id')
->references('id')
->on('election')
->onDelete('cascade');
$table->foreign('voter_id')
->references('id')
->on('voter')
->onDelete('cascade');
$table->foreign('used_by_admin_id')
->references('id')
->on('users')
->onDelete('set null');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('allowance_claim_codes');
}
}
@@ -0,0 +1,65 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateAllowancePayoutsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('allowance_payouts', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedInteger('election_id');
$table->unsignedInteger('voter_id');
// Allow future-proofing: cash for fizikal, bank for maya
$table->string('method', 20)->default('cash'); // cash | bank
$table->string('status', 20)->default('paid'); // paid | void | pending
// Store cents to avoid floating point issues
$table->unsignedInteger('amount_cents');
$table->char('currency', 3)->default('MYR');
// Officer/audit trail (admin user who performed the action)
$table->unsignedInteger('paid_by_admin_id')->nullable();
$table->timestamp('paid_at')->nullable();
$table->string('reference', 80)->nullable(); // optional bank ref / receipt no.
$table->text('note')->nullable();
// Prevent double-claim for the same election + method
$table->unique(['election_id', 'voter_id', 'method'], 'allowance_payouts_unique_claim');
$table->index(['election_id', 'method', 'status']);
$table->index(['voter_id', 'election_id']);
$table->foreign('election_id')
->references('id')
->on('election')
->onDelete('cascade');
$table->foreign('voter_id')
->references('id')
->on('voter')
->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('allowance_payouts');
}
}
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class AddFizikalRegistrationVerifiedAtToVoterTable extends Migration
{
/**
* Physical attendees (kehadiran = 1) need admin confirmation before voting.
* NULL = not verified yet; non-NULL = verified at that time.
* Maya (kehadiran = 2) does not use this column for gating.
*
* @return void
*/
public function up()
{
if (! Schema::hasColumn('voter', 'fizikal_registration_verified_at')) {
Schema::table('voter', function (Blueprint $table) {
$table->timestamp('fizikal_registration_verified_at')->nullable()->after('kehadiran');
});
}
// Existing physical registrations are treated as already verified.
DB::table('voter')
->where('kehadiran', 1)
->whereNull('fizikal_registration_verified_at')
->update(['fizikal_registration_verified_at' => now()]);
}
/**
* @return void
*/
public function down()
{
if (Schema::hasColumn('voter', 'fizikal_registration_verified_at')) {
Schema::table('voter', function (Blueprint $table) {
$table->dropColumn('fizikal_registration_verified_at');
});
}
}
}
@@ -0,0 +1,38 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateActivityLogTable extends Migration
{
/**
* Run the migrations.
*/
public function up()
{
Schema::connection(config('activitylog.database_connection'))->create(config('activitylog.table_name'), function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('log_name')->nullable();
$table->text('description');
$table->unsignedBigInteger('subject_id')->nullable();
$table->string('subject_type')->nullable();
$table->unsignedBigInteger('causer_id')->nullable();
$table->string('causer_type')->nullable();
$table->json('properties')->nullable();
$table->timestamps();
$table->index('log_name');
$table->index(['subject_id', 'subject_type'], 'subject');
$table->index(['causer_id', 'causer_type'], 'causer');
});
}
/**
* Reverse the migrations.
*/
public function down()
{
Schema::connection(config('activitylog.database_connection'))->dropIfExists(config('activitylog.table_name'));
}
}
+355 -14
View File
@@ -8,7 +8,11 @@
"@bachdgvn/vue-otp-input": "^1.0.8", "@bachdgvn/vue-otp-input": "^1.0.8",
"axios": "^1.13.2", "axios": "^1.13.2",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"jsbarcode": "^3.12.3" "file-saver": "^2.0.5",
"jsbarcode": "^3.12.3",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"babel-cli": "^6.26.0", "babel-cli": "^6.26.0",
@@ -1868,23 +1872,14 @@
"dev": true "dev": true
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.24.4", "version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"dev": true, "license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/runtime/node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"dev": true
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.24.0", "version": "7.24.0",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz",
@@ -2376,6 +2371,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/parse-json": { "node_modules/@types/parse-json": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -2388,6 +2389,13 @@
"integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==", "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==",
"dev": true "dev": true
}, },
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/range-parser": { "node_modules/@types/range-parser": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
@@ -2445,6 +2453,13 @@
"integrity": "sha512-AZU7vQcy/4WFEuwnwsNsJnFwupIpbllH1++LXScN6uxT1Z4zPzdrWG97w4/I7eFKFTvfy/bHFStWjdBAg2Vjug==", "integrity": "sha512-AZU7vQcy/4WFEuwnwsNsJnFwupIpbllH1++LXScN6uxT1Z4zPzdrWG97w4/I7eFKFTvfy/bHFStWjdBAg2Vjug==",
"dev": true "dev": true
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.5.10", "version": "8.5.10",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz",
@@ -2783,6 +2798,15 @@
"node": ">=8.9" "node": ">=8.9"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -3394,6 +3418,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -3835,6 +3869,58 @@
} }
] ]
}, },
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/canvg/node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/canvg/node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
@@ -4047,6 +4133,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/collect.js": { "node_modules/collect.js": {
"version": "4.36.1", "version": "4.36.1",
"resolved": "https://registry.npmjs.org/collect.js/-/collect.js-4.36.1.tgz", "resolved": "https://registry.npmjs.org/collect.js/-/collect.js-4.36.1.tgz",
@@ -4319,6 +4414,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-ecdh": { "node_modules/create-ecdh": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
@@ -4437,6 +4544,16 @@
"postcss": "^8.0.9" "postcss": "^8.0.9"
} }
}, },
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/css-loader": { "node_modules/css-loader": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.1.tgz", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.1.tgz",
@@ -4945,6 +5062,16 @@
"url": "https://github.com/fb55/domhandler?sponsor=1" "url": "https://github.com/fb55/domhandler?sponsor=1"
} }
}, },
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": { "node_modules/domutils": {
"version": "2.8.0", "version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@@ -5572,6 +5699,23 @@
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true "dev": true
}, },
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fast-png/node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/fastest-levenshtein": { "node_modules/fastest-levenshtein": {
"version": "1.0.16", "version": "1.0.16",
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
@@ -5602,6 +5746,12 @@
"node": ">=0.8.0" "node": ">=0.8.0"
} }
}, },
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-loader": { "node_modules/file-loader": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz",
@@ -5640,6 +5790,12 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/file-type": { "node_modules/file-type": {
"version": "12.4.2", "version": "12.4.2",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-12.4.2.tgz", "resolved": "https://registry.npmjs.org/file-type/-/file-type-12.4.2.tgz",
@@ -5806,6 +5962,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -6452,6 +6617,20 @@
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true "dev": true
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/htmlparser2": { "node_modules/htmlparser2": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-4.1.0.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-4.1.0.tgz",
@@ -6790,6 +6969,12 @@
"loose-envify": "^1.0.0" "loose-envify": "^1.0.0"
} }
}, },
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz",
@@ -7184,6 +7369,44 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jspdf-autotable": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.7.tgz",
"integrity": "sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw==",
"license": "MIT",
"peerDependencies": {
"jspdf": "^2 || ^3 || ^4"
}
},
"node_modules/jspdf/node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/junk": { "node_modules/junk": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz", "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz",
@@ -8893,6 +9116,13 @@
"node": ">=0.12" "node": ">=0.12"
} }
}, },
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@@ -9687,6 +9917,16 @@
} }
] ]
}, },
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/randomatic": { "node_modules/randomatic": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.1.tgz", "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.1.tgz",
@@ -10399,6 +10639,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rimraf": { "node_modules/rimraf": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -11295,6 +11545,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stable": { "node_modules/stable": {
"version": "0.1.8", "version": "0.1.8",
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
@@ -11302,6 +11564,16 @@
"deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility",
"dev": true "dev": true
}, },
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/static-extend": { "node_modules/static-extend": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
@@ -11521,6 +11793,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/svgo": { "node_modules/svgo": {
"version": "2.8.0", "version": "2.8.0",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz",
@@ -11649,6 +11931,16 @@
"source-map": "^0.6.0" "source-map": "^0.6.0"
} }
}, },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/thunky": { "node_modules/thunky": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
@@ -12035,6 +12327,16 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "8.3.2", "version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
@@ -12946,6 +13248,24 @@
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
"dev": true "dev": true
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -13044,6 +13364,27 @@
} }
} }
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+5 -1
View File
@@ -22,6 +22,10 @@
"@bachdgvn/vue-otp-input": "^1.0.8", "@bachdgvn/vue-otp-input": "^1.0.8",
"axios": "^1.13.2", "axios": "^1.13.2",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"jsbarcode": "^3.12.3" "file-saver": "^2.0.5",
"jsbarcode": "^3.12.3",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"xlsx": "^0.18.5"
} }
} }
Binary file not shown.
Binary file not shown.
+47187 -1335
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,486 @@
"use strict";
(self["webpackChunk"] = self["webpackChunk"] || []).push([["resources_assets_js_components_demo_LocalVueWheelSpinner_vue"],{
/***/ "./node_modules/babel-loader/lib/index.js??clonedRuleSet-5.use[0]!./node_modules/vue-loader/lib/index.js??vue-loader-options!./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=script&lang=js":
/*!**************************************************************************************************************************************************************************************************************************!*\
!*** ./node_modules/babel-loader/lib/index.js??clonedRuleSet-5.use[0]!./node_modules/vue-loader/lib/index.js??vue-loader-options!./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=script&lang=js ***!
\**************************************************************************************************************************************************************************************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "default": () => (__WEBPACK_DEFAULT_EXPORT__)
/* harmony export */ });
/**
* Vue 2 Options API port of vue-wheel-spinner (no script setup / second Vue runtime).
* Works with window.Vue from CDN; exposes drawWheel + spinWheel on component refs.
*/
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = ({
name: 'LocalVueWheelSpinner',
props: {
slices: {
type: Array,
required: true
},
sliceFontStyle: {
type: String,
"default": 'bold 16px Arial'
},
sliceTextPosition: {
type: String,
"default": 'edge'
},
winnerIndex: {
type: Number,
"default": 0
},
extraSpins: {
type: Number,
"default": 10
},
spinDuration: {
type: Number,
"default": 4000
},
cursorAngle: {
type: Number,
"default": 270
},
cursorPosition: {
type: String,
"default": 'center'
},
cursorDistance: {
type: Number,
"default": 50
},
sounds: {
type: Object,
"default": function _default() {
return {};
}
}
},
data: function data() {
return {
isSpinning: false,
currentAngle: 0,
spinningAudio: null,
wonAudio: null
};
},
watch: {
slices: function slices() {
this.drawWheel();
},
sliceTextPosition: function sliceTextPosition() {
this.drawWheel();
},
sliceFontStyle: function sliceFontStyle() {
this.drawWheel();
},
cursorAngle: function cursorAngle() {
this.positionCursor();
},
cursorPosition: function cursorPosition() {
this.positionCursor();
},
cursorDistance: function cursorDistance() {
this.positionCursor();
}
},
mounted: function mounted() {
var s = this.sounds;
if (s && s.spinning && typeof s.spinning === 'string') {
this.spinningAudio = new Audio(s.spinning);
}
if (s && s.won && typeof s.won === 'string') {
this.wonAudio = new Audio(s.won);
}
window.addEventListener('resize', this.handleResize);
this.$nextTick(this.drawWheel);
},
beforeDestroy: function beforeDestroy() {
window.removeEventListener('resize', this.handleResize);
},
methods: {
degreesToRadians: function degreesToRadians(degrees) {
return degrees * (Math.PI / 180);
},
getSlices: function getSlices() {
return this.slices;
},
getContrastingColor: function getContrastingColor(bgColor) {
var color = bgColor;
if (bgColor.charAt(0) === '#') {
color = bgColor.substring(1, 7);
}
var r = parseInt(color.substring(0, 2), 16);
var g = parseInt(color.substring(2, 4), 16);
var b = parseInt(color.substring(4, 6), 16);
var brightness = (r * 299 + g * 587 + b * 114) / 1000;
return brightness > 125 ? 'black' : 'white';
},
getAnglePerSlice: function getAnglePerSlice() {
return 360 / this.getSlices().length;
},
getCursorAngle: function getCursorAngle() {
return this.cursorAngle;
},
getRandomBetween: function getRandomBetween(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
},
getNormalizedAngle: function getNormalizedAngle(angle) {
return angle % 360;
},
getSliceAngles: function getSliceAngles(sliceIndex, currentCanvasAngle) {
var slices = this.getSlices();
var anglePerSlice = 360 / slices.length;
var startAngle = this.getNormalizedAngle(currentCanvasAngle + anglePerSlice * sliceIndex);
var endAngle = this.getNormalizedAngle(currentCanvasAngle + startAngle + anglePerSlice);
return {
startAngle: startAngle,
endAngle: endAngle
};
},
getEaseInOutQuart: function getEaseInOutQuart(progress) {
return progress < 0.5 ? 8 * Math.pow(progress, 4) : 1 - Math.pow(-2 * progress + 2, 4) / 2;
},
drawSlice: function drawSlice(context, centerX, centerY, radius, startAngle, endAngle, fillColor) {
context.beginPath();
context.moveTo(centerX, centerY);
context.arc(centerX, centerY, radius, this.degreesToRadians(startAngle), this.degreesToRadians(endAngle));
context.strokeStyle = fillColor;
context.stroke();
context.fillStyle = fillColor;
context.fill();
context.closePath();
},
drawLabel: function drawLabel(context, centerX, centerY, radius, startAngle, endAngle, fillColor, sliceLabel, sliceTextColor, sliceTextPosition, sliceFontStyle) {
var textRotateAngle = (endAngle - startAngle) / 2 + startAngle;
context.save();
context.translate(centerX, centerY);
context.rotate(this.degreesToRadians(textRotateAngle));
context.textAlign = 'right';
context.textBaseline = 'middle';
context.fillStyle = sliceTextColor || this.getContrastingColor(fillColor);
context.font = sliceFontStyle;
if (sliceTextPosition === 'edge') {
context.fillText(sliceLabel, radius - 10, 0);
} else if (sliceTextPosition === 'center') {
context.fillText(sliceLabel, 3 * radius / 4, 0);
} else if (sliceTextPosition === 'middle') {
context.fillText(sliceLabel, radius / 2, 0);
} else {
context.fillText(sliceLabel, radius - 10, 0);
}
context.restore();
},
drawWheel: function drawWheel() {
var container = this.$refs.playgroundContainer;
var canvas = this.$refs.playgroundCanvas;
if (!container || !canvas) {
return;
}
var slices = this.getSlices();
if (!slices.length) {
return;
}
var context = canvas.getContext('2d');
var containerWidth = container.clientWidth;
if (!containerWidth) {
return;
}
canvas.width = containerWidth;
canvas.height = containerWidth;
var width = containerWidth;
var height = containerWidth;
var centerX = width / 2;
var centerY = height / 2;
var radius = width / 2;
var anglePerSlice = 360 / slices.length;
var self = this;
slices.forEach(function (slice, sliceIndex) {
var startAngle = anglePerSlice * sliceIndex;
var endAngle = startAngle + anglePerSlice;
self.drawSlice(context, centerX, centerY, radius, startAngle, endAngle, slice.color);
self.drawLabel(context, centerX, centerY, radius, startAngle, endAngle, slice.color, slice.text, slice.textColor, self.sliceTextPosition, self.sliceFontStyle);
});
this.positionCursor();
},
playAudio: function playAudio(audio) {
if (audio) {
audio.volume = 0.5;
audio.play();
}
},
stopAudio: function stopAudio(audio) {
if (audio) {
audio.pause();
audio.currentTime = 0;
}
},
getCursorXY: function getCursorXY() {
var cursorAngle = this.getCursorAngle();
var cursorPosition = this.cursorPosition;
var cursorEl = this.$refs.cursor;
if (cursorPosition === 'edge') {
var rotate = this.getNormalizedAngle(cursorAngle + 90);
var cursorWidth = cursorEl ? cursorEl.clientWidth : 0;
var cursorHeight = cursorEl ? cursorEl.clientHeight : 0;
var top = Math.sin(this.degreesToRadians(cursorAngle)) * 50 + 50 + '%';
var left = Math.cos(this.degreesToRadians(cursorAngle)) * 50 + 50 + '%';
var additionalX = Math.cos(this.degreesToRadians(cursorAngle)) * (this.cursorDistance + cursorWidth / 2);
var additionalY = Math.sin(this.degreesToRadians(cursorAngle)) * (this.cursorDistance + cursorHeight / 2);
return {
top: top,
left: left,
translateX: 'calc(-50% - ' + additionalX + 'px)',
translateY: 'calc(-50% - ' + additionalY + 'px)',
rotate: rotate + 'deg'
};
}
var rotate = this.getNormalizedAngle(cursorAngle + 270);
var additionalX = Math.cos(this.degreesToRadians(cursorAngle)) * this.cursorDistance;
var additionalY = Math.sin(this.degreesToRadians(cursorAngle)) * this.cursorDistance;
return {
top: '50%',
left: '50%',
translateX: 'calc(-50% + ' + additionalX + 'px)',
translateY: 'calc(-50% + ' + additionalY + 'px)',
rotate: rotate + 'deg'
};
},
positionCursor: function positionCursor() {
var cursorEl = this.$refs.cursor;
if (!cursorEl) {
return;
}
var xy = this.getCursorXY();
cursorEl.style.top = xy.top;
cursorEl.style.left = xy.left;
cursorEl.style.transform = 'translate3d(' + xy.translateX + ', ' + xy.translateY + ', 0) rotate3d(0, 0, 1, ' + xy.rotate + ')';
},
handleResize: function handleResize() {
this.drawWheel();
},
spinWheel: function spinWheel(winnerIndex) {
var self = this;
if (this.isSpinning) {
return false;
}
this.isSpinning = true;
if (this.spinningAudio) {
this.playAudio(this.spinningAudio);
}
this.$emit('spin-start');
var canvas = this.$refs.playgroundCanvas;
if (!canvas) {
this.isSpinning = false;
return false;
}
var extraSpinsAngle = this.extraSpins * 360;
var winnerEndAngle = this.getSliceAngles(winnerIndex, this.currentAngle).endAngle;
var randomJitter = this.getRandomBetween(0, this.getAnglePerSlice());
var endAngle = this.currentAngle + extraSpinsAngle + (this.getCursorAngle() - winnerEndAngle) + randomJitter;
var startAngle = this.currentAngle;
var startTime = performance.now();
var animate = function animate(currentTime) {
var elapsedTime = currentTime - startTime;
var progress = Math.min(elapsedTime / self.spinDuration, 1);
var ease = self.getEaseInOutQuart(progress);
var rotationAngle = startAngle + (endAngle - startAngle) * ease;
canvas.style.transform = 'rotate3d(0, 0, 1, ' + rotationAngle + 'deg)';
if (progress < 1) {
requestAnimationFrame(animate);
} else {
rotationAngle = self.getNormalizedAngle(rotationAngle);
canvas.style.transform = 'rotate3d(0, 0, 1, ' + rotationAngle + 'deg)';
self.currentAngle = rotationAngle;
self.isSpinning = false;
if (self.wonAudio) {
self.wonAudio.play();
}
self.$emit('spin-end', winnerIndex);
if (self.spinningAudio) {
self.stopAudio(self.spinningAudio);
}
}
};
requestAnimationFrame(animate);
}
}
});
/***/ }),
/***/ "./node_modules/babel-loader/lib/index.js??clonedRuleSet-5.use[0]!./node_modules/vue-loader/lib/loaders/templateLoader.js??ruleSet[1].rules[2]!./node_modules/vue-loader/lib/index.js??vue-loader-options!./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=template&id=6e41843e&scoped=true":
/*!*************************************************************************************************************************************************************************************************************************************************************************************************************************!*\
!*** ./node_modules/babel-loader/lib/index.js??clonedRuleSet-5.use[0]!./node_modules/vue-loader/lib/loaders/templateLoader.js??ruleSet[1].rules[2]!./node_modules/vue-loader/lib/index.js??vue-loader-options!./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=template&id=6e41843e&scoped=true ***!
\*************************************************************************************************************************************************************************************************************************************************************************************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ render: () => (/* binding */ render),
/* harmony export */ staticRenderFns: () => (/* binding */ staticRenderFns)
/* harmony export */ });
var render = function render() {
var _vm = this,
_c = _vm._self._c;
return _c("div", {
ref: "playgroundContainer",
staticClass: "wheel-wrapper"
}, [_c("div", {
ref: "cursor",
staticClass: "cursor"
}, [_vm._t("cursor")], 2), _vm._v(" "), _c("canvas", {
ref: "playgroundCanvas"
}), _vm._v(" "), _c("div", {
staticClass: "centered"
}, [_vm._t("default")], 2)]);
};
var staticRenderFns = [];
render._withStripped = true;
/***/ }),
/***/ "./node_modules/laravel-mix/node_modules/css-loader/dist/cjs.js??clonedRuleSet-9.use[1]!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/postcss-loader/dist/cjs.js??clonedRuleSet-9.use[2]!./node_modules/vue-loader/lib/index.js??vue-loader-options!./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=style&index=0&id=6e41843e&scoped=true&lang=css":
/*!***********************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************!*\
!*** ./node_modules/laravel-mix/node_modules/css-loader/dist/cjs.js??clonedRuleSet-9.use[1]!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/postcss-loader/dist/cjs.js??clonedRuleSet-9.use[2]!./node_modules/vue-loader/lib/index.js??vue-loader-options!./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=style&index=0&id=6e41843e&scoped=true&lang=css ***!
\***********************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************/
/***/ ((module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "default": () => (__WEBPACK_DEFAULT_EXPORT__)
/* harmony export */ });
/* harmony import */ var _node_modules_laravel_mix_node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../../../../../node_modules/laravel-mix/node_modules/css-loader/dist/runtime/api.js */ "./node_modules/laravel-mix/node_modules/css-loader/dist/runtime/api.js");
/* harmony import */ var _node_modules_laravel_mix_node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_node_modules_laravel_mix_node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_0__);
// Imports
var ___CSS_LOADER_EXPORT___ = _node_modules_laravel_mix_node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_0___default()(function(i){return i[1]});
// Module
___CSS_LOADER_EXPORT___.push([module.id, "\n.wheel-wrapper[data-v-6e41843e] {\n max-width: 100vw;\n width: 100%;\n position: relative;\n aspect-ratio: 1 / 1;\n margin: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.cursor[data-v-6e41843e] {\n position: absolute;\n z-index: 10;\n}\n.centered[data-v-6e41843e] {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n z-index: 11;\n}\ncanvas[data-v-6e41843e] {\n will-change: transform, width, height;\n aspect-ratio: 1 / 1;\n max-width: 100%;\n}\n", ""]);
// Exports
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (___CSS_LOADER_EXPORT___);
/***/ }),
/***/ "./node_modules/style-loader/dist/cjs.js!./node_modules/laravel-mix/node_modules/css-loader/dist/cjs.js??clonedRuleSet-9.use[1]!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/postcss-loader/dist/cjs.js??clonedRuleSet-9.use[2]!./node_modules/vue-loader/lib/index.js??vue-loader-options!./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=style&index=0&id=6e41843e&scoped=true&lang=css":
/*!***************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************!*\
!*** ./node_modules/style-loader/dist/cjs.js!./node_modules/laravel-mix/node_modules/css-loader/dist/cjs.js??clonedRuleSet-9.use[1]!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/postcss-loader/dist/cjs.js??clonedRuleSet-9.use[2]!./node_modules/vue-loader/lib/index.js??vue-loader-options!./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=style&index=0&id=6e41843e&scoped=true&lang=css ***!
\***************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "default": () => (__WEBPACK_DEFAULT_EXPORT__)
/* harmony export */ });
/* harmony import */ var _node_modules_style_loader_dist_runtime_injectStylesIntoStyleTag_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! !../../../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js */ "./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js");
/* harmony import */ var _node_modules_style_loader_dist_runtime_injectStylesIntoStyleTag_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_node_modules_style_loader_dist_runtime_injectStylesIntoStyleTag_js__WEBPACK_IMPORTED_MODULE_0__);
/* harmony import */ var _node_modules_laravel_mix_node_modules_css_loader_dist_cjs_js_clonedRuleSet_9_use_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_dist_cjs_js_clonedRuleSet_9_use_2_node_modules_vue_loader_lib_index_js_vue_loader_options_LocalVueWheelSpinner_vue_vue_type_style_index_0_id_6e41843e_scoped_true_lang_css__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! !!../../../../../node_modules/laravel-mix/node_modules/css-loader/dist/cjs.js??clonedRuleSet-9.use[1]!../../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../../node_modules/postcss-loader/dist/cjs.js??clonedRuleSet-9.use[2]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./LocalVueWheelSpinner.vue?vue&type=style&index=0&id=6e41843e&scoped=true&lang=css */ "./node_modules/laravel-mix/node_modules/css-loader/dist/cjs.js??clonedRuleSet-9.use[1]!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/postcss-loader/dist/cjs.js??clonedRuleSet-9.use[2]!./node_modules/vue-loader/lib/index.js??vue-loader-options!./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=style&index=0&id=6e41843e&scoped=true&lang=css");
var options = {};
options.insert = "head";
options.singleton = false;
var update = _node_modules_style_loader_dist_runtime_injectStylesIntoStyleTag_js__WEBPACK_IMPORTED_MODULE_0___default()(_node_modules_laravel_mix_node_modules_css_loader_dist_cjs_js_clonedRuleSet_9_use_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_dist_cjs_js_clonedRuleSet_9_use_2_node_modules_vue_loader_lib_index_js_vue_loader_options_LocalVueWheelSpinner_vue_vue_type_style_index_0_id_6e41843e_scoped_true_lang_css__WEBPACK_IMPORTED_MODULE_1__["default"], options);
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (_node_modules_laravel_mix_node_modules_css_loader_dist_cjs_js_clonedRuleSet_9_use_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_dist_cjs_js_clonedRuleSet_9_use_2_node_modules_vue_loader_lib_index_js_vue_loader_options_LocalVueWheelSpinner_vue_vue_type_style_index_0_id_6e41843e_scoped_true_lang_css__WEBPACK_IMPORTED_MODULE_1__["default"].locals || {});
/***/ }),
/***/ "./resources/assets/js/components/demo/LocalVueWheelSpinner.vue":
/*!**********************************************************************!*\
!*** ./resources/assets/js/components/demo/LocalVueWheelSpinner.vue ***!
\**********************************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "default": () => (__WEBPACK_DEFAULT_EXPORT__)
/* harmony export */ });
/* harmony import */ var _LocalVueWheelSpinner_vue_vue_type_template_id_6e41843e_scoped_true__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./LocalVueWheelSpinner.vue?vue&type=template&id=6e41843e&scoped=true */ "./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=template&id=6e41843e&scoped=true");
/* harmony import */ var _LocalVueWheelSpinner_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./LocalVueWheelSpinner.vue?vue&type=script&lang=js */ "./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=script&lang=js");
/* harmony import */ var _LocalVueWheelSpinner_vue_vue_type_style_index_0_id_6e41843e_scoped_true_lang_css__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./LocalVueWheelSpinner.vue?vue&type=style&index=0&id=6e41843e&scoped=true&lang=css */ "./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=style&index=0&id=6e41843e&scoped=true&lang=css");
/* harmony import */ var _node_modules_vue_loader_lib_runtime_componentNormalizer_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! !../../../../../node_modules/vue-loader/lib/runtime/componentNormalizer.js */ "./node_modules/vue-loader/lib/runtime/componentNormalizer.js");
;
/* normalize component */
var component = (0,_node_modules_vue_loader_lib_runtime_componentNormalizer_js__WEBPACK_IMPORTED_MODULE_3__["default"])(
_LocalVueWheelSpinner_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__["default"],
_LocalVueWheelSpinner_vue_vue_type_template_id_6e41843e_scoped_true__WEBPACK_IMPORTED_MODULE_0__.render,
_LocalVueWheelSpinner_vue_vue_type_template_id_6e41843e_scoped_true__WEBPACK_IMPORTED_MODULE_0__.staticRenderFns,
false,
null,
"6e41843e",
null
)
/* hot reload */
if (false) { var api; }
component.options.__file = "resources/assets/js/components/demo/LocalVueWheelSpinner.vue"
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (component.exports);
/***/ }),
/***/ "./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=script&lang=js":
/*!**********************************************************************************************!*\
!*** ./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=script&lang=js ***!
\**********************************************************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "default": () => (__WEBPACK_DEFAULT_EXPORT__)
/* harmony export */ });
/* harmony import */ var _node_modules_babel_loader_lib_index_js_clonedRuleSet_5_use_0_node_modules_vue_loader_lib_index_js_vue_loader_options_LocalVueWheelSpinner_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! -!../../../../../node_modules/babel-loader/lib/index.js??clonedRuleSet-5.use[0]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./LocalVueWheelSpinner.vue?vue&type=script&lang=js */ "./node_modules/babel-loader/lib/index.js??clonedRuleSet-5.use[0]!./node_modules/vue-loader/lib/index.js??vue-loader-options!./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=script&lang=js");
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (_node_modules_babel_loader_lib_index_js_clonedRuleSet_5_use_0_node_modules_vue_loader_lib_index_js_vue_loader_options_LocalVueWheelSpinner_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_0__["default"]);
/***/ }),
/***/ "./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=template&id=6e41843e&scoped=true":
/*!****************************************************************************************************************!*\
!*** ./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=template&id=6e41843e&scoped=true ***!
\****************************************************************************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ render: () => (/* reexport safe */ _node_modules_babel_loader_lib_index_js_clonedRuleSet_5_use_0_node_modules_vue_loader_lib_loaders_templateLoader_js_ruleSet_1_rules_2_node_modules_vue_loader_lib_index_js_vue_loader_options_LocalVueWheelSpinner_vue_vue_type_template_id_6e41843e_scoped_true__WEBPACK_IMPORTED_MODULE_0__.render),
/* harmony export */ staticRenderFns: () => (/* reexport safe */ _node_modules_babel_loader_lib_index_js_clonedRuleSet_5_use_0_node_modules_vue_loader_lib_loaders_templateLoader_js_ruleSet_1_rules_2_node_modules_vue_loader_lib_index_js_vue_loader_options_LocalVueWheelSpinner_vue_vue_type_template_id_6e41843e_scoped_true__WEBPACK_IMPORTED_MODULE_0__.staticRenderFns)
/* harmony export */ });
/* harmony import */ var _node_modules_babel_loader_lib_index_js_clonedRuleSet_5_use_0_node_modules_vue_loader_lib_loaders_templateLoader_js_ruleSet_1_rules_2_node_modules_vue_loader_lib_index_js_vue_loader_options_LocalVueWheelSpinner_vue_vue_type_template_id_6e41843e_scoped_true__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! -!../../../../../node_modules/babel-loader/lib/index.js??clonedRuleSet-5.use[0]!../../../../../node_modules/vue-loader/lib/loaders/templateLoader.js??ruleSet[1].rules[2]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./LocalVueWheelSpinner.vue?vue&type=template&id=6e41843e&scoped=true */ "./node_modules/babel-loader/lib/index.js??clonedRuleSet-5.use[0]!./node_modules/vue-loader/lib/loaders/templateLoader.js??ruleSet[1].rules[2]!./node_modules/vue-loader/lib/index.js??vue-loader-options!./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=template&id=6e41843e&scoped=true");
/***/ }),
/***/ "./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=style&index=0&id=6e41843e&scoped=true&lang=css":
/*!******************************************************************************************************************************!*\
!*** ./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=style&index=0&id=6e41843e&scoped=true&lang=css ***!
\******************************************************************************************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _node_modules_style_loader_dist_cjs_js_node_modules_laravel_mix_node_modules_css_loader_dist_cjs_js_clonedRuleSet_9_use_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_dist_cjs_js_clonedRuleSet_9_use_2_node_modules_vue_loader_lib_index_js_vue_loader_options_LocalVueWheelSpinner_vue_vue_type_style_index_0_id_6e41843e_scoped_true_lang_css__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! -!../../../../../node_modules/style-loader/dist/cjs.js!../../../../../node_modules/laravel-mix/node_modules/css-loader/dist/cjs.js??clonedRuleSet-9.use[1]!../../../../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../../../../node_modules/postcss-loader/dist/cjs.js??clonedRuleSet-9.use[2]!../../../../../node_modules/vue-loader/lib/index.js??vue-loader-options!./LocalVueWheelSpinner.vue?vue&type=style&index=0&id=6e41843e&scoped=true&lang=css */ "./node_modules/style-loader/dist/cjs.js!./node_modules/laravel-mix/node_modules/css-loader/dist/cjs.js??clonedRuleSet-9.use[1]!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/postcss-loader/dist/cjs.js??clonedRuleSet-9.use[2]!./node_modules/vue-loader/lib/index.js??vue-loader-options!./resources/assets/js/components/demo/LocalVueWheelSpinner.vue?vue&type=style&index=0&id=6e41843e&scoped=true&lang=css");
/***/ })
}]);
+12
View File
@@ -130,3 +130,15 @@ You can change it in .env file
### System Demo ### System Demo
You can watch the System Demo [here](https://youtu.be/dsEoONiovdA). You can watch the System Demo [here](https://youtu.be/dsEoONiovdA).
### Roulette Wheel URL: http://localhost:8080/demo/roulette
### Display Code URL: http://localhost:8080/display/kod-fizikal?k={{PHYSICAL_ATTENDANCE_GATE_DISPLAY_KEY}}
### Role Code
- 1 = admin
- 2 = Jawatankuasa Audit
- 3 = Kaunter
```bash
php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag=migrations
```
BIN
View File
Binary file not shown.
+14
View File
@@ -18,6 +18,20 @@ const router = new VueRouter({
routes routes
}); });
router.beforeEach(function (to, from, next) {
if (!to.path.startsWith('/admin') || to.name === 'Admin Login') {
return next();
}
if (!localStorage.getItem('Access Token')) {
return next();
}
var role = localStorage.getItem('admin_role');
if (role === '3' && to.name !== 'Kehadiran Calon') {
return next({ name: 'Kehadiran Calon', replace: true });
}
next();
});
const app = new Vue({ const app = new Vue({
router router
}).$mount('#app'); }).$mount('#app');
Binary file not shown.
@@ -0,0 +1,688 @@
<template>
<div class="admin-data-table">
<div v-if="exportable" class="clearfix admin-data-table__toolbar">
<div class="btn-group pull-right">
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<i class="fa fa-download"></i> Eksport <span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="#" @click.prevent="exportToCSV">Eksport CSV</a></li>
<li><a href="#" @click.prevent="exportToExcel">Eksport Excel</a></li>
<li><a href="#" @click.prevent="exportToPDF">Eksport PDF</a></li>
<li><a href="#" @click.prevent="exportToJSON">Eksport JSON</a></li>
</ul>
</div>
</div>
<div class="admin-data-table__shell">
<div class="admin-data-table__scroll">
<table class="admin-data-table__table table table-hover">
<thead>
<tr>
<th v-for="h in tableHeaders" :key="h.key" :class="{
'is-sortable': h.sortable,
'admin-data-table__col-index': h.key === '__adt_index'
}" @click="h.sortable && toggleSort(h.key)">
<div class="admin-data-table__th-inner">
<span class="admin-data-table__th-title">{{ h.title }}</span>
<span v-if="h.sortable" class="admin-data-table__sort" aria-hidden="true">
<template v-if="sortKey === h.key">
<svg v-if="sortDir === 'asc'" class="admin-data-table__sort-icon"
xmlns="http://www.w3.org/2000/svg" width="14" height="14"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M18 15l-6-6-6 6" />
</svg>
<svg v-else class="admin-data-table__sort-icon"
xmlns="http://www.w3.org/2000/svg" width="14" height="14"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6" />
</svg>
</template>
<span v-else class="admin-data-table__sort-hint">
<svg class="admin-data-table__sort-icon admin-data-table__sort-icon--faint"
xmlns="http://www.w3.org/2000/svg" width="10" height="10"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5">
<path d="M18 15l-6-6-6 6" />
</svg>
<svg class="admin-data-table__sort-icon admin-data-table__sort-icon--faint"
xmlns="http://www.w3.org/2000/svg" width="10" height="10"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5">
<path d="M6 9l6 6 6-6" />
</svg>
</span>
</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-if="loading" class="admin-data-table__state-row">
<td :colspan="tableHeaders.length">
<div class="admin-data-table__state">
<i class="fa fa-refresh fa-spin admin-data-table__state-icon"></i>
<div class="admin-data-table__state-title">Memuatkan</div>
</div>
</td>
</tr>
<tr v-else-if="!displayRows.length" class="admin-data-table__state-row">
<td :colspan="tableHeaders.length">
<div class="admin-data-table__state">
<i class="fa fa-inbox admin-data-table__state-icon"></i>
<div class="admin-data-table__state-title">{{ emptyText }}</div>
<div class="admin-data-table__state-hint">Tiada rekod untuk dipaparkan</div>
</div>
</td>
</tr>
<tr v-else v-for="(item, rowIdx) in displayRows" :key="String(item[itemKey])">
<td v-for="h in tableHeaders" :key="h.key" :class="cellTdClass(h)">
<template v-if="h.key === '__adt_index'">
{{ rowGlobalIndex(rowIdx) }}
</template>
<template v-else>
<slot :name="'item-' + h.key" :item="item" :value="cellValue(item, h.key)">
{{ formatCell(item, h.key) }}
</slot>
</template>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="showPagination && !loading && totalItems > 0" class="admin-data-table__footer">
<div class="admin-data-table__footer-left">
<label class="admin-data-table__footer-label">Baris setiap halaman</label>
<select v-model.number="localPerPage" class="form-control input-sm admin-data-table__page-size"
@change="onPerPageChange">
<option v-for="n in perPageOptions" :key="n" :value="n">{{ n }}</option>
</select>
</div>
<div class="admin-data-table__footer-mid">
{{ rangeFrom }}{{ rangeTo }} daripada {{ totalItems }}
</div>
<nav class="admin-data-table__footer-right" aria-label="Pagination">
<ul class="pagination pagination-sm admin-data-table__pagination">
<li :class="{ disabled: page <= 1 }">
<a href="#" @click.prevent="goPage(page - 1)">Sebelum</a>
</li>
<li :class="{ disabled: page >= totalPages }">
<a href="#" @click.prevent="goPage(page + 1)">Seterusnya</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</template>
<script>
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver';
export default {
name: 'AdminDataTable',
props: {
headers: {
type: Array,
default: function () {
return [];
}
},
items: {
type: Array,
default: function () {
return [];
}
},
itemsPerPage: {
type: Number,
default: 10
},
showPagination: {
type: Boolean,
default: true
},
loading: {
type: Boolean,
default: false
},
itemKey: {
type: String,
default: 'id'
},
emptyText: {
type: String,
default: 'Tiada data'
},
exportable: {
type: Boolean,
default: false
},
exportFileName: {
type: String,
default: 'table-data'
},
showIndex: {
type: Boolean,
default: true
},
indexTitle: {
type: String,
default: 'Bil'
}
},
data: function () {
return {
page: 1,
localPerPage: this.itemsPerPage,
sortKey: null,
sortDir: 'asc',
perPageOptions: [10, 25, 50, 100]
};
},
computed: {
totalItems: function () {
return this.sortedItems.length;
},
totalPages: function () {
var n = Math.ceil(this.totalItems / this.localPerPage) || 1;
return n;
},
sortedItems: function () {
var list = this.items.slice();
if (!this.sortKey) {
return list;
}
var key = this.sortKey;
var dir = this.sortDir === 'desc' ? -1 : 1;
list.sort(function (a, b) {
var va = a[key];
var vb = b[key];
if (va == null && vb == null) return 0;
if (va == null) return 1;
if (vb == null) return -1;
if (typeof va === 'number' && typeof vb === 'number') {
return (va - vb) * dir;
}
return String(va).localeCompare(String(vb), undefined, { numeric: true }) * dir;
});
return list;
},
displayRows: function () {
if (!this.showPagination) {
return this.sortedItems;
}
var start = (this.page - 1) * this.localPerPage;
return this.sortedItems.slice(start, start + this.localPerPage);
},
rangeFrom: function () {
if (!this.totalItems) return 0;
return (this.page - 1) * this.localPerPage + 1;
},
rangeTo: function () {
return Math.min(this.page * this.localPerPage, this.totalItems);
},
tableHeaders: function () {
if (!this.showIndex) {
return this.headers;
}
return [
{ title: this.indexTitle, key: '__adt_index', sortable: false }
].concat(this.headers);
}
},
watch: {
items: function () {
if (this.page > this.totalPages) {
this.page = Math.max(1, this.totalPages);
}
},
itemsPerPage: function (v) {
this.localPerPage = v;
}
},
methods: {
rowGlobalIndex: function (rowIdx) {
if (!this.showPagination) {
return rowIdx + 1;
}
return (this.page - 1) * this.localPerPage + rowIdx + 1;
},
cellTdClass: function (h) {
var o = {};
if (h.key === '__adt_index') o['admin-data-table__col-index'] = true;
return o;
},
cellValue: function (item, key) {
return item[key];
},
formatCell: function (item, key) {
var v = item[key];
return v == null ? '' : v;
},
toggleSort: function (key) {
if (this.sortKey === key) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sortKey = key;
this.sortDir = 'asc';
}
},
goPage: function (p) {
if (p < 1 || p > this.totalPages) return;
this.page = p;
},
onPerPageChange: function () {
this.page = 1;
},
isActionColumnKey: function (key) {
if (!key) return false;
var k = String(key).toLowerCase();
return k === 'actions' || k === 'action' || k === 'opsi' || k === 'tindakan';
},
resolveFieldValue: function (item, headerKey) {
if (headerKey === '#') return '';
var keys = String(headerKey).split('.');
var value = item;
for (var i = 0; i < keys.length; i++) {
if (value && typeof value === 'object' && keys[i] in value) {
value = value[keys[i]];
} else {
return null;
}
}
if (Array.isArray(value)) {
return value
.map(function (v) {
return v && typeof v === 'object' && v.name != null ? v.name : String(v);
})
.join(', ');
}
if (value && typeof value === 'object' && 'name' in value) {
return value.name;
}
return value;
},
cellStringForExport: function (header, item, rowIndex) {
if (typeof header.exportValue === 'function') {
return header.exportValue(item, rowIndex) || '';
}
if (header.key === '__adt_index' || header.key === '#') {
return String(rowIndex + 1);
}
if (this.isActionColumnKey(header.key)) {
return '';
}
var raw = this.resolveFieldValue(item, header.key);
if (raw == null) return '';
return String(raw);
},
prepareExportMatrix: function () {
var rows = [];
var headerTitles = this.tableHeaders.map(function (h) {
return h.title || h.key;
});
var vm = this;
this.sortedItems.forEach(function (item, index) {
var line = vm.tableHeaders.map(function (h) {
return vm.cellStringForExport(h, item, index);
});
rows.push(line);
});
return { headerTitles: headerTitles, rows: rows };
},
exportEmitError: function (err) {
this.$emit('export-error', err instanceof Error ? err : new Error(String(err)));
},
exportToCSV: function () {
try {
var m = this.prepareExportMatrix();
var csvContent = [m.headerTitles]
.concat(m.rows)
.map(function (row) {
return row
.map(function (cell) {
return '"' + String(cell).replace(/"/g, '""') + '"';
})
.join(',');
})
.join('\n');
var blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
saveAs(blob, this.exportFileName + '.csv');
} catch (e) {
this.exportEmitError(e);
}
},
exportToExcel: function () {
try {
var m = this.prepareExportMatrix();
var aoa = [m.headerTitles].concat(m.rows);
var worksheet = XLSX.utils.aoa_to_sheet(aoa);
var workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
var buf = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
var blob = new Blob([buf], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
saveAs(blob, this.exportFileName + '.xlsx');
} catch (e) {
this.exportEmitError(e);
}
},
exportToPDF: function () {
try {
var m = this.prepareExportMatrix();
var colCount = m.headerTitles.length;
var useLandscape = colCount > 6;
var doc = new jsPDF(useLandscape ? 'l' : 'p', 'pt', 'a4');
var fontSize = 8;
if (colCount > 12) fontSize = 6;
else if (colCount > 8) fontSize = 7;
var cellPadding = colCount > 8 ? 4 : 8;
var tableOptions = {
head: [m.headerTitles],
body: m.rows,
styles: { fontSize: fontSize, cellPadding: cellPadding },
headStyles: { fillColor: [25, 118, 210] },
margin: { top: 20 }
};
if (colCount > 8) {
tableOptions.tableWidth = 'wrap';
tableOptions.horizontalPageBreak = true;
tableOptions.styles = Object.assign({}, tableOptions.styles, {
minCellWidth: 36,
overflow: 'linebreak'
});
}
autoTable(doc, tableOptions);
doc.save(this.exportFileName + '.pdf');
} catch (e) {
this.exportEmitError(e);
}
},
exportToJSON: function () {
try {
var vm = this;
var jsonData = this.sortedItems.map(function (item, index) {
var obj = {};
vm.tableHeaders.forEach(function (h) {
var title = h.title || h.key;
if (vm.isActionColumnKey(h.key)) return;
if (h.key === '__adt_index') {
obj[title] = index + 1;
return;
}
if (typeof h.exportValue === 'function') {
obj[title] = h.exportValue(item, index);
return;
}
var keys = String(h.key).split('.');
var value = item;
for (var i = 0; i < keys.length; i++) {
if (value && typeof value === 'object' && keys[i] in value) {
value = value[keys[i]];
} else {
value = null;
break;
}
}
obj[title] = value;
});
return obj;
});
var jsonContent = JSON.stringify(jsonData, null, 2);
var blob = new Blob([jsonContent], { type: 'application/json' });
saveAs(blob, this.exportFileName + '.json');
} catch (e) {
this.exportEmitError(e);
}
}
}
};
</script>
<style scoped>
.admin-data-table__toolbar {
margin-bottom: 12px;
}
.admin-data-table {
--adt-header-bg: #1976d2;
--adt-header-color: #fff;
--adt-radius: 12px;
--adt-border: 1px solid #e0e0e0;
--adt-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--adt-row-hover: #e3f2fd;
--adt-footer-bg: #fafafa;
--adt-text-muted: #757575;
--adt-font-size: 16px;
--adt-header-font-size: 16px;
}
.admin-data-table__shell {
border-radius: var(--adt-radius);
border: var(--adt-border);
box-shadow: var(--adt-shadow);
overflow: hidden;
background: #fff;
}
.admin-data-table__scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.admin-data-table__table {
width: 100%;
margin-bottom: 0;
border-collapse: separate;
border-spacing: 0;
}
.admin-data-table__table thead tr:first-child th:first-child {
border-top-left-radius: var(--adt-radius);
}
.admin-data-table__table thead tr:first-child th:last-child {
border-top-right-radius: var(--adt-radius);
}
.admin-data-table__table thead th {
background-color: var(--adt-header-bg);
color: var(--adt-header-color);
font-weight: 600;
font-size: var(--adt-header-font-size);
text-transform: none;
letter-spacing: 0.02em;
border: none !important;
padding: 14px 16px;
vertical-align: middle;
}
.admin-data-table__table thead th.is-sortable {
cursor: pointer;
user-select: none;
}
.admin-data-table__table thead th.is-sortable:hover {
filter: brightness(1.06);
}
.admin-data-table__table thead th.admin-data-table__col-index,
.admin-data-table__table tbody td.admin-data-table__col-index {
width: 3.25rem;
max-width: 4rem;
text-align: center;
font-variant-numeric: tabular-nums;
vertical-align: middle !important;
}
.admin-data-table__th-inner {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
min-height: 20px;
}
.admin-data-table__th-title {
flex: 1;
min-width: 0;
}
.admin-data-table__sort {
display: inline-flex;
align-items: center;
flex-shrink: 0;
opacity: 0.95;
}
.admin-data-table__sort-hint {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1;
opacity: 0.45;
margin-top: 1px;
}
.admin-data-table__sort-icon {
display: block;
flex-shrink: 0;
}
.admin-data-table__sort-icon--faint {
margin-top: -3px;
}
.admin-data-table__sort-hint .admin-data-table__sort-icon--faint:first-child {
margin-top: 0;
}
.admin-data-table__table tbody td {
padding: 12px 16px;
vertical-align: middle;
border-top: 1px solid #eee;
font-size: var(--adt-font-size);
}
.admin-data-table__table tbody tr:first-child:not(.admin-data-table__state-row) td {
border-top: 1px solid #e0e0e0;
}
.admin-data-table__table tbody tr.admin-data-table__state-row td {
border-top: none;
padding: 0;
}
.admin-data-table__table tbody tr:not(.admin-data-table__state-row):hover td {
background-color: var(--adt-row-hover);
}
.admin-data-table__state {
text-align: center;
padding: 40px 24px;
color: var(--adt-text-muted);
}
.admin-data-table__state-icon {
font-size: 40px;
opacity: 0.45;
margin-bottom: 12px;
display: block;
margin-left: auto;
margin-right: auto;
}
.admin-data-table__state-title {
font-size: 16px;
font-weight: 600;
color: #424242;
margin-bottom: 4px;
}
.admin-data-table__state-hint {
font-size: 13px;
color: var(--adt-text-muted);
}
.admin-data-table__footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 16px;
padding: 12px 16px;
border-top: var(--adt-border);
background: var(--adt-footer-bg);
}
.admin-data-table__footer-left {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.admin-data-table__footer-label {
margin: 0;
font-size: 12px;
font-weight: normal;
color: var(--adt-text-muted);
}
.admin-data-table__page-size {
width: auto;
min-width: 64px;
display: inline-block;
height: 30px;
padding: 4px 8px;
font-size: 12px;
}
.admin-data-table__footer-mid {
font-size: 13px;
font-weight: 500;
color: #616161;
}
.admin-data-table__footer-right {
flex-shrink: 0;
}
.admin-data-table__pagination {
margin: 0;
}
.admin-data-table__pagination>li>a {
padding: 5px 12px;
font-size: 12px;
color: var(--adt-header-bg);
}
.admin-data-table__pagination>li.disabled>a {
color: #bbb;
pointer-events: none;
cursor: default;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,579 @@
<template>
<div class="roulette-demo-page container-fluid">
<header class="roulette-page-header">
<div class="roulette-page-header-inner">
<img src="/images/MyKoPKB-logo.png" alt="MyKoPKB" class="roulette-page-header-logo" />
<h1 class="roulette-demo-title">Sesi Cabutan Bertuah Kehadiran Fizikal</h1>
<button type="button" class="btn btn-default btn-sm roulette-bg-music-btn"
:aria-pressed="bgMusicPlaying ? 'true' : 'false'"
:aria-label="bgMusicPlaying ? 'Pause Background Music' : 'Play Background Music'"
@click="toggleBgMusic">
{{ bgMusicPlaying ? 'Pause Background Music' : 'Play Background Music' }}
</button>
</div>
</header>
<div v-if="loadError" class="alert alert-danger">
{{ loadError }}
</div>
<div v-else-if="loading" class="text-muted roulette-demo-loading">
Memuat senarai pengundi Fizikal
</div>
<div v-else-if="!apiVoters.length" class="alert alert-info">
Tiada pengundi Fizikal direkod buat masa ini. Semak kehadiran atau tambah rekod.
</div>
<div v-if="!loading && !loadError && apiVoters.length" class="roulette-sync-bar">
<span v-if="lastSyncedAt" class="roulette-sync-time">
Senarai menunggu dikemas kini pada <strong>{{ lastSyncedFormatted }}</strong>
</span>
<span v-if="refreshing" class="roulette-sync-refreshing text-muted">Mengemas kini</span>
<span v-if="pollError" class="text-warning roulette-sync-poll-error">{{ pollError }}</span>
<button type="button" class="btn btn-default btn-sm roulette-sync-btn" :disabled="refreshing"
@click="fetchFizikalVoters(false)">
Segerakan sekarang
</button>
</div>
<div v-if="!loading && !loadError && apiVoters.length" class="row roulette-demo-layout">
<aside class="col-md-3 col-sm-12 roulette-pending-sidebar text-left">
<div class="panel panel-default roulette-pending-panel">
<div class="panel-heading clearfix">
<span>Menunggu sertai cabutan</span>
<span class="badge pull-right">{{ pendingVoters.length }}</span>
</div>
<ul class="list-group roulette-pending-list">
<li v-if="!pendingVoters.length" class="list-group-item text-muted roulette-pending-empty">
Tiada dalam senarai menunggu semua peserta telah diimport atau senarai kosong.
</li>
<template v-else>
<li v-for="v in pendingVoters" :key="'p-' + v.id"
class="list-group-item roulette-pending-row">
<span class="roulette-pending-name">{{ labelForVoter(v) }}</span>
<button type="button" class="btn btn-primary btn-xs roulette-pending-import"
@click="importVoter(v.id)">
Import
</button>
</li>
</template>
</ul>
<div v-if="pendingVoters.length > 1" class="panel-footer text-right">
<button type="button" class="btn btn-default btn-sm" @click="importAllPending">
Import semua
</button>
</div>
</div>
</aside>
<div class="col-md-9 col-sm-12 roulette-main-column">
<div v-if="cabutanCount < 2" class="alert alert-info roulette-cabutan-hint">
Import sekurang-kurangnya <strong>dua</strong> peserta daripada senarai menunggu untuk memulakan
cabutan. Senarai <strong>Peserta</strong> pada roda hanya berubah apabila anda menambah peserta di
sini; auto sync mengemas kini senarai menunggu sahaja.
</div>
<roulette-elimination-wheel v-if="cabutanCount >= 2" :items="cabutanItems" :full-width="true"
persist-key="roulette_fizikal_demo" @winner="onWinner" />
</div>
</div>
</div>
</template>
<script>
import RouletteEliminationWheel from './RouletteEliminationWheel.vue';
function buildLabelsFromVoters(voters) {
if (!voters || !voters.length) {
return [];
}
var nameCount = {};
for (var i = 0; i < voters.length; i++) {
var raw = voters[i].name;
var n = (raw != null ? String(raw) : '').trim();
nameCount[n] = (nameCount[n] || 0) + 1;
}
return voters.map(function (v) {
var n = (v.name != null ? String(v.name) : '').trim();
if (!n) {
n = '#' + v.id;
}
if (nameCount[n] > 1) {
var tag = v.no_anggota != null ? String(v.no_anggota).trim() : '';
return tag ? n + ' (' + tag + ')' : n + ' (#' + v.id + ')';
}
return n;
});
}
/** Persist which voter ids were added to the cabutan (separate from wheel game state). */
var IMPORTED_IDS_STORAGE_KEY = 'roulette_fizikal_imported_ids';
/** Looping background track (`public/audio/…`). */
var BG_MUSIC_SRC = '/audio/lucky-draw-bg.mp3';
/** Stable string so we detect new/removed/changed voters from API. */
function votersSnapshotSignature(voters) {
if (!voters || !voters.length) {
return 'empty';
}
var rows = voters.map(function (v) {
return (
String(v.id) +
'\t' +
String(v.name != null ? v.name : '') +
'\t' +
String(v.no_anggota != null ? v.no_anggota : '')
);
});
rows.sort();
return rows.join('|');
}
export default {
name: 'RouletteEliminationWheelDemo',
components: {
RouletteEliminationWheel,
},
data: function () {
return {
loading: true,
loadError: '',
/** Full list from GET …/fizikal-voters (updated by auto sync / manual refresh). */
apiVoters: [],
/** Voter ids explicitly imported into the cabutan / wheel; order = cabutan order. */
importedParticipantIds: [],
/** Last applied API snapshot — skip redundant Vue updates when unchanged. */
lastSnapshotSig: '',
refreshing: false,
lastSyncedAt: null,
pollError: '',
hasLoadedOnce: false,
pollTimerId: null,
pollStarted: false,
/** Interval in ms; set 0 to disable auto-refresh. */
pollIntervalMs: 10000,
/** True while background music is playing (user must click Main first — browser policy). */
bgMusicPlaying: false,
};
},
computed: {
lastSyncedFormatted: function () {
if (!this.lastSyncedAt) {
return '—';
}
try {
return this.lastSyncedAt.toLocaleString();
} catch (e) {
return String(this.lastSyncedAt);
}
},
/** Label string per voter id (duplicate-name disambiguation uses full API list). */
voterLabelsById: function () {
var list = this.apiVoters;
var labels = buildLabelsFromVoters(list);
var map = {};
for (var i = 0; i < list.length; i++) {
var vid = list[i].id;
var nid = typeof vid === 'number' && vid === vid ? vid : parseInt(vid, 10);
if (nid === nid) {
map[nid] = labels[i];
}
}
return map;
},
pendingVoters: function () {
var imported = this.importedParticipantIds;
return this.apiVoters.filter(function (v) {
var vid = typeof v.id === 'number' && v.id === v.id ? v.id : parseInt(v.id, 10);
return imported.indexOf(vid) < 0;
});
},
/** Wheel entries: stable voter id + label (order = import order). */
cabutanItems: function () {
var map = this.voterLabelsById;
var out = [];
for (var i = 0; i < this.importedParticipantIds.length; i++) {
var id = this.importedParticipantIds[i];
if (map[id] !== undefined) {
out.push({ id: id, label: map[id] });
}
}
return out;
},
cabutanCount: function () {
return this.cabutanItems.length;
},
},
created: function () {
this.loadImportedIdsFromStorage();
this.fetchFizikalVoters(false);
},
beforeDestroy: function () {
this.stopPolling();
this.disposeBgMusic();
},
methods: {
disposeBgMusic: function () {
this.bgMusicPlaying = false;
if (!this._bgMusicAudio) {
return;
}
try {
this._bgMusicAudio.pause();
this._bgMusicAudio.src = '';
} catch (e) {
/* ignore */
}
this._bgMusicAudio = null;
},
ensureBgMusicAudio: function () {
if (typeof Audio === 'undefined') {
return null;
}
if (!this._bgMusicAudio) {
this._bgMusicAudio = new Audio(BG_MUSIC_SRC);
this._bgMusicAudio.loop = true;
this._bgMusicAudio.volume = 0.32;
this._bgMusicAudio.preload = 'auto';
}
return this._bgMusicAudio;
},
toggleBgMusic: function () {
var vm = this;
var a = this.ensureBgMusicAudio();
if (!a) {
return;
}
if (this.bgMusicPlaying) {
a.pause();
this.bgMusicPlaying = false;
return;
}
var p = a.play();
if (p && typeof p.then === 'function') {
p.then(function () {
vm.bgMusicPlaying = true;
}).catch(function () {
vm.bgMusicPlaying = false;
});
} else {
this.bgMusicPlaying = true;
}
},
loadImportedIdsFromStorage: function () {
if (typeof localStorage === 'undefined') {
return;
}
try {
var raw = localStorage.getItem(IMPORTED_IDS_STORAGE_KEY);
if (!raw) {
return;
}
var parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return;
}
this.importedParticipantIds = parsed.filter(function (x) {
return typeof x === 'number' && x === x;
});
} catch (e) {
this.importedParticipantIds = [];
}
},
saveImportedIdsToStorage: function () {
if (typeof localStorage === 'undefined') {
return;
}
try {
localStorage.setItem(
IMPORTED_IDS_STORAGE_KEY,
JSON.stringify(this.importedParticipantIds)
);
} catch (e) {
// quota / private mode
}
},
/** Drop imported ids that no longer exist in the latest API list. */
pruneImportedIdsToApi: function (list) {
var ok = {};
for (var i = 0; i < list.length; i++) {
var vid = list[i].id;
var nid = typeof vid === 'number' && vid === vid ? vid : parseInt(vid, 10);
if (nid === nid) {
ok[nid] = true;
}
}
var next = this.importedParticipantIds.filter(function (id) {
return ok[id];
});
if (next.length !== this.importedParticipantIds.length) {
this.importedParticipantIds = next;
this.saveImportedIdsToStorage();
}
},
labelForVoter: function (v) {
var vid = typeof v.id === 'number' && v.id === v.id ? v.id : parseInt(v.id, 10);
var m = this.voterLabelsById;
return vid === vid && m[vid] != null ? m[vid] : '#' + v.id;
},
importVoter: function (id) {
var nid = typeof id === 'number' && id === id ? id : parseInt(id, 10);
if (nid !== nid) {
return;
}
if (this.importedParticipantIds.indexOf(nid) >= 0) {
return;
}
this.importedParticipantIds.push(nid);
this.saveImportedIdsToStorage();
},
importAllPending: function () {
var vm = this;
this.pendingVoters.forEach(function (v) {
var vid = typeof v.id === 'number' && v.id === v.id ? v.id : parseInt(v.id, 10);
if (vid === vid && vm.importedParticipantIds.indexOf(vid) < 0) {
vm.importedParticipantIds.push(vid);
}
});
this.saveImportedIdsToStorage();
},
stopPolling: function () {
if (this.pollTimerId !== null) {
clearInterval(this.pollTimerId);
this.pollTimerId = null;
}
this.pollStarted = false;
},
startPolling: function () {
if (this.pollIntervalMs <= 0) {
return;
}
if (this.pollStarted) {
return;
}
this.pollStarted = true;
var vm = this;
this.pollTimerId = setInterval(function () {
vm.fetchFizikalVoters(true);
}, this.pollIntervalMs);
},
/**
* @param {boolean} silent - true = background poll (no full-page loading state).
*/
fetchFizikalVoters: function (silent) {
var vm = this;
if (!silent) {
vm.loadError = '';
vm.pollError = '';
if (!vm.hasLoadedOnce) {
vm.loading = true;
} else {
vm.refreshing = true;
}
} else {
vm.pollError = '';
vm.refreshing = true;
}
return axios
.get(config.API + 'public/roulette/fizikal-voters')
.then(function (response) {
var list =
response.data && response.data.voters ? response.data.voters : [];
var sig = votersSnapshotSignature(list);
if (silent && sig === vm.lastSnapshotSig) {
vm.lastSyncedAt = new Date();
return;
}
vm.lastSnapshotSig = sig;
vm.apiVoters = list;
vm.pruneImportedIdsToApi(list);
vm.lastSyncedAt = new Date();
vm.hasLoadedOnce = true;
})
.catch(function (error) {
var msg =
error &&
error.response &&
error.response.data &&
error.response.data.message
? error.response.data.message
: 'Gagal memuat data.';
if (!silent && !vm.hasLoadedOnce) {
vm.loadError = msg;
vm.apiVoters = [];
} else {
vm.pollError =
'Tidak dapat mengemas kini: ' + (msg.length > 80 ? msg.slice(0, 80) + '…' : msg);
}
})
.finally(function () {
vm.loading = false;
vm.refreshing = false;
vm.startPolling();
});
},
onWinner: function (payload) {
if (typeof console !== 'undefined' && console.log) {
console.log('[roulette demo] winner:', payload);
}
},
},
};
</script>
<style scoped>
.roulette-demo-page {
padding-top: 2rem;
padding-bottom: 3rem;
width: 100%;
max-width: none;
}
.roulette-page-header {
display: flex;
justify-content: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #e7e7e7;
}
.roulette-page-header-inner {
display: inline-flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 14px 18px;
text-align: left;
}
.roulette-page-header-logo {
display: block;
height: 52px;
width: auto;
max-width: 140px;
object-fit: contain;
flex-shrink: 0;
}
.roulette-demo-title {
font-size: 1.65rem;
font-weight: 600;
margin: 0;
line-height: 1.3;
max-width: min(100%, 36rem);
}
.roulette-bg-music-btn {
flex-shrink: 0;
white-space: nowrap;
}
.roulette-demo-path {
margin-bottom: 1.25rem;
font-size: 0.9rem;
word-break: break-all;
}
.roulette-poll-help {
margin-bottom: 0.75rem;
line-height: 1.45;
}
.roulette-demo-loading {
margin-bottom: 1rem;
}
.roulette-sync-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
margin-bottom: 14px;
font-size: 13px;
}
.roulette-sync-refreshing {
font-style: italic;
}
.roulette-sync-poll-error {
font-size: 12px;
}
.roulette-sync-btn {
flex-shrink: 0;
}
.roulette-demo-layout {
align-items: flex-start;
}
.roulette-pending-sidebar {
margin-bottom: 16px;
}
@media (min-width: 992px) {
.roulette-pending-sidebar {
margin-bottom: 0;
}
.roulette-pending-panel {
position: sticky;
top: 12px;
}
}
.roulette-main-column {
min-width: 0;
}
.roulette-pending-panel {
margin-bottom: 0;
}
.roulette-pending-list {
margin-bottom: 0;
}
.roulette-pending-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 13px;
}
.roulette-pending-name {
flex: 1;
min-width: 0;
word-break: break-word;
}
.roulette-pending-import {
flex-shrink: 0;
}
.roulette-cabutan-hint {
margin-bottom: 16px;
line-height: 1.45;
}
</style>
@@ -0,0 +1,111 @@
<template>
<div class="panel panel-default">
<div class="panel-body">
<div class="clearfix">
<h4 class="pull-left" style="margin-top: 0;">Activity Log</h4>
<button class="btn btn-default btn-sm pull-right" @click="refresh" :disabled="loading">
<i class="fa fa-refresh" :class="{ 'fa-spin': loading }"></i> Refresh
</button>
</div>
<div class="row" style="margin-top: 12px; margin-bottom: 12px;">
<div class="col-sm-8 col-md-6">
<div class="input-group">
<input v-model.trim="searchText" type="text" class="form-control"
placeholder="Cari aktiviti (description)…" @keyup.enter="refresh" />
<span class="input-group-btn">
<button class="btn btn-primary" @click="refresh" :disabled="loading">
Cari
</button>
<button class="btn btn-default" @click="clearSearch" :disabled="loading || !searchText">
Clear
</button>
</span>
</div>
</div>
</div>
<admin-data-table :headers="headers" :items="items" :loading="loading" :items-per-page="25"
:show-pagination="true" empty-text="Tiada activity log" :exportable="true"
export-file-name="activity-log">
<template v-slot:item-id="{ value }">
<span style="white-space: nowrap;">{{ value }}</span>
</template>
<template v-slot:item-created_at="{ value }">
<span style="white-space: nowrap;">{{ value }}</span>
</template>
<template v-slot:item-properties="{ value }">
<pre
style="margin: 0; white-space: pre-wrap; word-break: break-word; background: transparent; border: 0; padding: 0;">{{ stringify(value) }}</pre>
</template>
</admin-data-table>
</div>
</div>
</template>
<script>
import AdminDataTable from '../../../AdminDataTable.vue';
export default {
components: { AdminDataTable },
data: function () {
return {
loading: false,
searchText: '',
items: [],
headers: [
{ title: 'Aktiviti', key: 'description', sortable: true },
{ title: 'Subject Type', key: 'subject_type', sortable: true },
{ title: 'Subject ID', key: 'subject_id', sortable: true },
{ title: 'Causer Type', key: 'causer_type', sortable: true },
{ title: 'Causer ID', key: 'causer_id', sortable: true },
{ title: 'Properties', key: 'properties', sortable: false },
{ title: 'Masa', key: 'created_at', sortable: true },
]
};
},
created: function () {
this.refresh();
},
methods: {
stringify: function (v) {
if (v == null) return '';
try {
return JSON.stringify(v, null, 2);
} catch (e) {
return String(v);
}
},
refresh: function () {
var vm = this;
vm.loading = true;
var params = { per_page: 200 };
if (vm.searchText) {
params.description = vm.searchText;
}
axios.get(config.API + 'admin/activity-log', { params: params })
.then(function (response) {
vm.items = (response && response.data && response.data.data) ? response.data.data : [];
})
.catch(function (error) {
vm.items = [];
if (vm.util && typeof vm.util.showResult === 'function') {
vm.util.showResult(error, 'error');
}
})
.finally(function () {
vm.loading = false;
});
},
clearSearch: function () {
this.searchText = '';
this.refresh();
}
}
};
</script>
@@ -29,6 +29,7 @@
value="" required> value="" required>
<option value="1">Admin</option> <option value="1">Admin</option>
<option value="2">Jawatankuasa Audit</option> <option value="2">Jawatankuasa Audit</option>
<option value="3">Jawatankuasa Kehadiran</option>
</select> </select>
</div> </div>
@@ -1,36 +1,46 @@
<template> <template>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-body table-responsive"> <div class="panel-body">
<div class="form-group"> <div class="form-group">
<router-link :to="{name: 'Add Account'}" class="btn btn-success"> <router-link :to="{name: 'Add Account'}" class="btn btn-success">
<i class="fa fa-plus"></i> Tambah Akaun Pengguna</router-link> <i class="fa fa-plus"></i> Tambah Akaun Pengguna</router-link>
<button class="btn btn-default" @click="refreshAdmin()"> <button class="btn btn-default" @click="refreshAdmin()" :disabled="!canViewAdminList">
<i class="fa fa-refresh"></i> Refresh</button> <i class="fa fa-refresh"></i> Refresh</button>
</div> </div>
<table class="table table-hover"> <div v-if="!canViewAdminList" class="alert alert-warning" style="margin-bottom:12px;">
<thead> Akses ditolak: Pengurusan akaun hanya untuk <b>Main Admin</b> (id=1). Jika sedang impersonate, sila
<tr> <b>Kembali Akaun Asal</b>.
<th>ID</th> </div>
<th>Nama</th>
<th>Email</th> <admin-data-table
<th>Padam</th> :headers="adminTableHeaders"
</tr> :items="adminItems"
</thead> :loading="loading"
<tbody> :show-pagination="true"
<tr v-for="admin in data.admins"> :exportable="true"
<td>{{ admin.id }}</td> export-file-name="admins"
<td>{{ admin.name }}</td> :items-per-page="25"
<td>{{ admin.email }}</td> :show-index="true"
<td> index-title="Bil."
<button class="btn btn-danger" @click="deleteAdmin(admin.id)"> empty-text="Tiada akaun admin"
<i class="fa fa-trash"></i> Padam >
</button> <template v-slot:item-impersonate="{ item }">
</td> <button
</tr> class="btn btn-primary btn-sm"
</tbody> @click="openImpersonate(item)"
</table> :disabled="!canImpersonate(item)"
>
<i class="fa fa-user-secret"></i> Impersonate
</button>
</template>
<template v-slot:item-delete="{ item }">
<button class="btn btn-danger btn-sm" @click="deleteAdmin(item.id)">
<i class="fa fa-trash"></i> Padam
</button>
</template>
</admin-data-table>
</div> </div>
<modal id="delete-admin-modal"> <modal id="delete-admin-modal">
<modal-header>Delete Admin</modal-header> <modal-header>Delete Admin</modal-header>
@@ -42,21 +52,127 @@
<button @click="util.hideModal('#delete-admin-modal')" class="btn btn-default">Batal</button> <button @click="util.hideModal('#delete-admin-modal')" class="btn btn-default">Batal</button>
</modal-footer> </modal-footer>
</modal> </modal>
<modal id="impersonate-admin-modal">
<modal-header>Impersonate Admin</modal-header>
<modal-body>
<h4 v-if="impersonateTarget && impersonateTarget.email">
Impersonate <b>{{ impersonateTarget.email }}</b> ?
</h4>
<p class="text-muted" style="margin-bottom:0;">
Anda akan login sebagai akaun tersebut.
</p>
</modal-body>
<modal-footer>
<button @click="startImpersonate()" class="btn btn-primary">Impersonate</button>
<button @click="util.hideModal('#impersonate-admin-modal')" class="btn btn-default">Batal</button>
</modal-footer>
</modal>
</div> </div>
</template> </template>
<script> <script>
import AdminDataTable from '../../../AdminDataTable.vue';
export default{ export default{
components: { AdminDataTable },
data: () => ({ data: () => ({
id:0 id:0,
impersonateTarget: null,
loading: false,
adminTableHeaders: [
{ title: 'ID', key: 'id', sortable: true },
{ title: 'Nama', key: 'name', sortable: true },
{ title: 'Email', key: 'email', sortable: true },
{ title: 'Impersonate', key: 'impersonate', sortable: false, exportValue: function () { return ''; } },
{ title: 'Padam', key: 'delete', sortable: false, exportValue: function () { return ''; } },
]
}), }),
computed: {
canViewAdminList: function () {
try {
if (!this.data || !this.data.user) return false;
if (Number(this.data.user.id) !== 1) return false;
return localStorage.getItem('is_impersonating') !== '1';
} catch (e) {
return false;
}
},
adminItems: function () {
var a = this.data && this.data.admins;
if (Array.isArray(a)) return a;
if (a && Array.isArray(a.data)) return a.data;
return [];
}
},
created: function () { created: function () {
this.refreshAdmin(); if (this.canViewAdminList) {
this.refreshAdmin();
} else {
this.loading = false;
}
}, },
methods: { methods: {
canImpersonate: function (admin) {
// Backend currently limits to main admin (id=1) and blocks impersonating id=1.
try {
if (!this.data || !this.data.user) return false;
if (Number(this.data.user.id) !== 1) return false;
if (!admin || admin.id === undefined || admin.id === null) return false;
return Number(admin.id) !== 1;
} catch (e) {
return false;
}
},
openImpersonate: function (admin) {
this.impersonateTarget = admin;
if (this.util && this.util.showModal) {
this.util.showModal('#impersonate-admin-modal');
}
},
startImpersonate: function () {
var vm = this;
if (!vm.impersonateTarget || !vm.impersonateTarget.id) return;
vm.util.hideModal('#impersonate-admin-modal');
vm.util.notify('Impersonating admin', 'loading');
axios.post(config.API + 'admin/impersonate', { user_id: vm.impersonateTarget.id })
.then(function (response) {
$.notifyClose();
if (!response || !response.data || response.data.status !== 'success') {
vm.util.showResult(response, 'error');
return;
}
// Swap admin token + user in local state.
var token = response.data.token;
if (token) {
localStorage['Access Token'] = 'Bearer ' + token;
localStorage.setItem('is_impersonating', '1');
if (vm.util && vm.util.setAuthorization) vm.util.setAuthorization();
}
if (response.data.user) {
vm.data.user = response.data.user;
if (response.data.user.role !== undefined && response.data.user.role !== null) {
localStorage.setItem('admin_role', String(response.data.user.role));
}
}
vm.util.notify('Impersonate berjaya', 'success');
// Ensure admin shell is refreshed (nav header shows new user)
if (vm.$router) vm.$router.go(0);
})
.catch(function (error) {
$.notifyClose();
vm.util.showResult(error, 'error');
});
},
deleteAdmin: function () { deleteAdmin: function () {
this.util.hideModal('#delete-admin-modal'); this.util.hideModal('#delete-admin-modal');
this.util.notify('Deleting admin', 'loading'); this.util.notify('Deleting admin', 'loading');
@@ -74,8 +190,12 @@ export default{
}, },
refreshAdmin: function () { refreshAdmin: function () {
this.util.notify('Refreshing admin', 'loading');
var vm = this; var vm = this;
if (!vm.canViewAdminList) {
return;
}
vm.loading = true;
this.util.notify('Refreshing admin', 'loading');
axios.get(config.API+'admin') axios.get(config.API+'admin')
.then(response=>{ .then(response=>{
$.notifyClose(); $.notifyClose();
@@ -88,6 +208,9 @@ export default{
$.notifyClose(); $.notifyClose();
vm.util.showResult(error, 'error'); vm.util.showResult(error, 'error');
}) })
.finally(function () {
vm.loading = false;
});
} }
} }
} }
@@ -1,89 +1,24 @@
<template> <template>
<div> <div>
<!-- TABLE --> <admin-data-table :headers="penyataTableHeaders" :items="penyata" :loading="loading" :show-pagination="true"
<table class="table table-bordered table-striped"> index-title="Bil." empty-text="Tiada data penyata" :exportable="true">
<thead class="table-light"> <template v-slot:item-aksi="{ item }">
<tr> <button type="button" class="btn btn-success btn-sm" @click="cetakPDF(item.no_anggota)"
<th>No Anggota</th> :disabled="!bolehCetak(item.status_penyata)">
<th>Unit</th> PDF
<th>Nama</th> </button>
<th>Status</th> </template>
<th>Tarikh Sah</th> </admin-data-table>
<th>Aksi</th>
</tr>
</thead>
<tbody>
<tr v-for="voter in penyata" :key="voter.id">
<td>{{ voter.no_anggota }}</td>
<td>{{ voter.unit }}</td>
<td>{{ voter.name }}</td>
<!-- STATUS -->
<!-- <td>
<span
class="badge"
:class="{
'bg-success': voter.status_penyata === 'DISAHKAN',
'bg-warning text-dark': voter.status_penyata === 'PERLU_SEMAK',
'bg-secondary': !voter.status_penyata
}"
>
{{ voter.status_penyata ?? 'BELUM SAH' }}
</span>
</td> -->
<!-- TARIKH SAH -->
<!-- <td>
{{ voter.tarikh_sah ?? '-' }}
</td> -->
<!-- PDF BUTTON -->
<td>
<button
class="btn btn-sm btn-success"
@click="cetakPDF(voter.no_anggota)"
:disabled="!bolehCetak(voter.status_penyata)"
>
PDF
</button>
</td>
</tr>
<!-- NO DATA -->
<tr v-if="!loading && penyata.length === 0">
<td colspan="6" class="text-center text-muted">
Tiada data penyata
</td>
</tr>
<!-- LOADING -->
<tr v-if="loading">
<td colspan="6" class="text-center">
Memuatkan data...
</td>
</tr>
</tbody>
</table>
<!-- PAGINATION --> <!-- PAGINATION -->
<div <div class="text-right" v-if="meta.last_page > 1">
class="d-flex justify-content-end gap-2" <button class="btn btn-default btn-sm" :disabled="meta.current_page === 1"
v-if="meta.last_page > 1" @click="changePage(meta.current_page - 1)">
>
<button
class="btn btn-sm btn-outline-secondary"
:disabled="meta.current_page === 1"
@click="changePage(meta.current_page - 1)"
>
Sebelumnya Sebelumnya
</button> </button>
<button <button class="btn btn-default btn-sm" :disabled="meta.current_page === meta.last_page"
class="btn btn-sm btn-outline-secondary" @click="changePage(meta.current_page + 1)">
:disabled="meta.current_page === meta.last_page"
@click="changePage(meta.current_page + 1)"
>
Seterusnya Seterusnya
</button> </button>
</div> </div>
@@ -102,6 +37,12 @@ export default {
penyata: [], penyata: [],
loading: false, loading: false,
searchNoAnggota: '', searchNoAnggota: '',
penyataTableHeaders: [
{ title: 'No Anggota', key: 'no_anggota', sortable: true },
{ title: 'Unit', key: 'unit', sortable: true },
{ title: 'Nama', key: 'name', sortable: true },
{ title: 'Tindakan', key: 'aksi', sortable: false }
],
meta: { meta: {
current_page: 1, current_page: 1,
last_page: 1 last_page: 1
@@ -2,41 +2,22 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div v-for="position in positions"> <div v-for="position in positions" :key="position.id">
<h5>{{ position.name }}</h5> <h5>{{ position.name }}</h5>
<div class="table-responsive"> <admin-data-table
<table class="table table-striped table-condensed"> :headers="finalTableHeaders"
<thead> :items="finalRowsByPosition(position.id)"
<tr> :loading="finalLoading"
<th width="20%">No. Anggota</th> :items-per-page="10"
<th width="30%">Nama</th> :show-pagination="true"
<th width="20%">Unit</th> empty-text="Tiada keputusan"
<th width="10%">Undian</th> :exportable="true"
<th width="20%">Peratus (%)</th> :export-file-name="'keputusan-akhir-' + position.id"
</tr> >
</thead> <template v-slot:item-percentage="{ item }">
<tbody> {{ item.percentage }}
<tr v-for="result in results" v-if="result.position_id == position.id"> </template>
<td>{{ getNominee(result.nominee_id)['no_anggota'] }}</td> </admin-data-table>
<td>{{ getNominee(result.nominee_id)['name'] }}</td>
<td>{{ getNominee(result.nominee_id)['unit'] }}</td>
<td>{{ result.votes }}</td>
<td>{{ calculatePercentage(result.votes, position.id) }}</td>
</tr>
<tr v-for="no_vote in no_votes" v-if="no_vote.position_id == position.id">
<td>{{ no_vote.no_anggota }}</td>
<td>{{ no_vote.name }}</td>
<td>{{ no_vote.unit }}</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td colspan="3"><b>Jumlah Undian</b></td>
<td><b>{{ calculateTotalVotes(position.id) }}</b></td>
</tr>
</tbody>
</table>
</div>
<hr /> <hr />
</div> </div>
</div> </div>
@@ -51,11 +32,69 @@ export default {
nominees: [], nominees: [],
results: [], results: [],
partylists: [], partylists: [],
positions: [] positions: [],
finalLoading: false,
finalTableHeaders: [
{ title: 'No. Anggota', key: 'no_anggota', sortable: true },
{ title: 'Nama', key: 'name', sortable: true },
{ title: 'Unit', key: 'unit', sortable: true },
{ title: 'Undian', key: 'votes', sortable: true },
{ title: 'Peratus (%)', key: 'percentage', sortable: true }
]
} }
}, },
methods: { 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) { getNominee: function (id) {
let nominees = this.nominees; let nominees = this.nominees;
for (var i in nominees) for (var i in nominees)
@@ -77,7 +116,7 @@ export default {
.filter(result => result.position_id === positionId) .filter(result => result.position_id === positionId)
.reduce((total, result) => total + result.votes, 0); .reduce((total, result) => total + result.votes, 0);
return ((votes / totalVotes) * 100).toFixed(2); return totalVotes === 0 ? '0.00' : ((votes / totalVotes) * 100).toFixed(2);
}, },
calculateTotalVotes(positionId) { calculateTotalVotes(positionId) {
@@ -90,6 +129,7 @@ export default {
created: function () { created: function () {
this.util.notify('Loading please wait...', 'loading'); this.util.notify('Loading please wait...', 'loading');
var vm = this; var vm = this;
this.finalLoading = true;
axios.get(config.API + 'election/result/' + this.election_id) axios.get(config.API + 'election/result/' + this.election_id)
.then(response => { .then(response => {
$.notifyClose(); $.notifyClose();
@@ -102,6 +142,9 @@ export default {
$.notifyClose(); $.notifyClose();
vm.util.showResult(error, 'error'); vm.util.showResult(error, 'error');
}) })
.finally(function () {
vm.finalLoading = false;
})
}, },
computed: { computed: {
@@ -17,37 +17,17 @@
</div> </div>
<div class="table-responsive"> <admin-data-table :headers="electionTableHeaders" :items="data.elections" :loading="electionLoading"
<table class="table table-hover"> :items-per-page="10" :show-pagination="true" empty-text="Tiada undian" :exportable="true"
<thead> export-file-name="undian">
<tr> <template v-slot:item-actions="{ item }">
<th>ID</th> <router-link :to="{ name: 'Election Result', params: { election_id: item.id } }"
<th>Nama Undian</th> class="btn btn-info">
<th>Undian Mula</th> Keputusan Undian
<th>Undian Tamat</th> </router-link>
<th>Papar</th> <a class="btn btn-warning" :href="getPDFurl(item.id)">Muat Turun PDF</a>
</tr> </template>
</thead> </admin-data-table>
<tbody>
<tr v-for="election in data.elections">
<td>{{ election.id }}</td>
<td>{{ election.name }}</td>
<td>{{ election.start }}</td>
<td>{{ election.end }}</td>
<td>
<router-link :to="{ name: 'Election Result', params: { election_id: election.id } }"
class="btn btn-info">Keputusan Undian</router-link>
<a class="btn btn-warning" :href="getPDFurl(election.id)">Muat Turun PDF</a>
</td>
</tr>
<tr v-if="data.elections.length < 1">
<td colspan="5">No elections yet</td>
</tr>
</tbody>
</table>
</div>
<form @submit.prevent="start" id="start_form"> <form @submit.prevent="start" id="start_form">
<div class="modal fade" id="start-election-modal" tabindex="-1" role="dialog" <div class="modal fade" id="start-election-modal" tabindex="-1" role="dialog"
@@ -119,15 +99,31 @@
</template> </template>
<style> <style>
.center-header { .center-header {
text-align: center; text-align: center;
} }
/* Ensure Bootstrap modals appear above the admin floating header (z-index: 2000). */
.modal {
z-index: 2200;
}
.modal-backdrop {
z-index: 2190 !important;
}
</style> </style>
<script> <script>
export default { export default {
data: () => ({ data: () => ({
start_date: null start_date: null,
electionLoading: false,
electionTableHeaders: [
{ title: 'Nama Undian', key: 'name', sortable: true },
{ title: 'Undian Mula', key: 'start', sortable: true },
{ title: 'Undian Tamat', key: 'end', sortable: true },
{ title: 'Papar', key: 'actions', sortable: false }
]
}), }),
created: function () { created: function () {
this.refreshElection(); this.refreshElection();
@@ -184,6 +180,7 @@ export default {
refreshElection: function () { refreshElection: function () {
this.util.notify('Refreshing Election', 'loading'); this.util.notify('Refreshing Election', 'loading');
var vm = this; var vm = this;
this.electionLoading = true;
axios.get(config.API + 'election') axios.get(config.API + 'election')
.then(response => { .then(response => {
$.notifyClose(); $.notifyClose();
@@ -193,6 +190,9 @@ export default {
$.notifyClose(); $.notifyClose();
vm.util.showResult(error, 'error'); vm.util.showResult(error, 'error');
}) })
.finally(function () {
vm.electionLoading = false;
})
}, },
@@ -1,90 +1,242 @@
<template> <template>
<div class="row"> <div class="result-page">
<div class="col-md-4"> <div class="result-header">
<h4>Jawatan</h4><hr/> <div>
<ul class="list-group"> <div class="result-kicker">Keputusan Undian</div>
<router-link :key="position.id" v-for="position in data.positions" class="list-group-item" :class="{'active':position.id==position_id}" tag="li" :to="{query:{position_id:position.id}}" exact replace> <h4 class="result-title">{{ String(position_id) === '0' ? 'Dashboard Keputusan' :
(getPosition(position_id) || 'Keputusan') }}</h4>
<div class="result-subtitle">Kemaskini terakhir: <b>{{ last_update }}</b></div>
</div>
<div class="result-actions">
<button class="btn btn-info" @click="refreshNominees()">
<i class="fa fa-refresh"></i> Kemaskini Keputusan
</button>
</div>
</div>
<div v-if="String(position_id) === '0'" class="result-stats">
<div class="result-stat">
<div class="result-stat__label">Jumlah Undi</div>
<div class="result-stat__value">{{ totalVotes }}</div>
</div>
<div class="result-stat">
<div class="result-stat__label">Bil. Jawatan</div>
<div class="result-stat__value">{{ (data.positions || []).length }}</div>
</div>
<div class="result-stat">
<div class="result-stat__label">Calon Tertinggi</div>
<div class="result-stat__value result-stat__value--text">{{ topNomineeLabel }}</div>
<div class="result-stat__hint">{{ topNomineeVotes }} undi</div>
</div>
</div>
<div class="result-filters">
<span class="result-filters__label">Jawatan:</span>
<button type="button" class="result-chip" :class="{ 'is-active': String(position_id) === '0' }"
@click="setPosition(0)">
Semua
</button>
<button v-for="position in data.positions" :key="position.id" type="button" class="result-chip"
:class="{ 'is-active': String(position.id) === String(position_id) }" @click="setPosition(position.id)">
{{ position.name }} {{ position.name }}
</router-link> </button>
<li class="list-group-item"> </div>
<center>
<button class="btn btn-info" @click="refreshNominees()"> <div class="row">
Kemaskini Keputusan<i class="fa fa-refresh"></i> <div class="col-md-7">
</button> <div class="panel panel-default result-card">
</center> <div class="panel-heading result-card__heading">
</li> <div class="result-card__title">
</ul> {{ String(position_id) === '0' ? 'Pemimpin Mengikut Jawatan' : 'Kedudukan Calon' }}
</div> </div>
<div class="col-md-8"> <div class="result-card__meta">
<h4>Keputusan</h4><hr/> {{
<div class="panel panel-default"> String(position_id) === '0' ? 'Siapa mendahului (dengan beza undi)' :
<div class="panel-heading">{{ getPosition(position_id) }} - Results as of : {{ last_update }}</div> 'Mengikut jawatan dipilih'
<div class="panel-body"> }}
<div id="chart" style="height: 300px; width: 300px"></div> </div>
</div>
<div class="panel-body result-card__body">
<template v-if="String(position_id) === '0'">
<div v-if="!leadersByPosition.length" class="result-empty">
Tiada data untuk dipaparkan.
</div>
<div v-else class="result-leaders">
<div v-for="p in leadersByPosition" :key="p.position_id" class="result-leader">
<div class="result-leader__top">
<div class="result-leader__position">{{ p.position_name }}</div>
<div class="result-leader__meta">
<span><b>{{ p.winner_votes }}</b> undi</span>
<span class="result-leader__delta">(+{{ p.delta }} beza)</span>
</div>
</div>
<div class="result-leader__name">{{ p.winner_name }}</div>
<div class="result-leader__bar">
<div class="result-leader__bar-fill" :style="{ width: p.pct + '%' }"></div>
</div>
</div>
</div>
</template>
<template v-else>
<div v-if="!rankedNominees.length" class="result-empty">
Tiada calon untuk jawatan ini.
</div>
<div v-else class="result-rank">
<div v-for="n in rankedNominees" :key="n.id" class="result-rank__row">
<div class="result-rank__top">
<div class="result-rank__name">{{ n.name }}</div>
<div class="result-rank__votes">
<b>{{ n.votes }}</b> undi
</div>
</div>
<div class="result-rank__bar">
<div class="result-rank__bar-fill" :style="{ width: n.pct + '%' }"></div>
</div>
<div class="result-rank__meta">{{ n.pct }}%</div>
</div>
</div>
</template>
</div>
</div>
</div>
<div class="col-md-5">
<div class="panel panel-default result-card">
<div class="panel-heading result-card__heading">
<div class="result-card__title">
{{ String(position_id) === '0' ? 'Analitik Ringkas' : 'Ringkasan Jawatan' }}
</div>
<div class="result-card__meta">
{{
String(position_id) === '0' ? 'Top calon & jumlah undi mengikut jawatan' : ('Jumlah undi:' +
totalVotes) }}
</div>
</div>
<div class="panel-body result-card__body">
<template v-if="String(position_id) === '0'">
<div class="result-mini-title">Top Calon (Overall)</div>
<div id="top-chart" class="result-top-chart"></div>
<div v-if="!topNominees.length" class="result-empty">Tiada data untuk dipaparkan.</div>
<div class="result-mini-title" style="margin-top: 14px;">Jumlah Undi Mengikut Jawatan</div>
<div id="votes-by-position-chart" class="result-top-chart"></div>
</template>
<template v-else>
<div class="result-empty">
Pilih Semua untuk melihat analitik keseluruhan. Untuk jawatan ini, rujuk senarai di
kiri.
</div>
</template>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script> <script>
export default{ export default {
created: function () { created: function () {
console.log(this)
this.refreshNominees(); this.refreshNominees();
this.$nextTick(function(){ this.$nextTick(function () {
this.initChart(); this.initCharts();
}); });
}, },
watch: { watch: {
position_id : function () { position_id: function () {
this.initChart(); var vm = this;
this.$nextTick(function () {
vm.initCharts();
});
} }
}, },
methods: { methods: {
setPosition: function (id) {
// Prevent Vue Router NavigationDuplicated when clicking active chip
var target = (!id || String(id) === '0') ? '0' : String(id);
if (String(this.position_id) === target) return;
var q = Object.assign({}, this.$route.query);
if (!id || String(id) === '0') delete q.position_id;
else q.position_id = String(id);
this.$router.replace({ query: q }).catch(function () { });
},
refreshNominees: function () { refreshNominees: function () {
var vm = this; var vm = this;
this.util.notify('Refreshing results', 'loading'); this.util.notify('Refreshing results', 'loading');
axios.get(config.API+'nominee') // Always fetch all nominees (needed for overall dashboards and name lookups)
.then(response=>{ axios.get(config.API + 'nominee')
.then(response => {
$.notifyClose(); $.notifyClose();
vm.data.nominees = response.data; vm.data.nominees = response.data;
vm.refreshResults(); vm.refreshResults();
}) })
.catch(error=>{ .catch(error => {
$.notifyClose(); $.notifyClose();
vm.util.showResult(error); vm.util.showResult(error);
}) })
}, },
refreshResults: function () { refreshResults: function () {
var vm = this; var vm = this;
this.util.notify('Refreshing results', 'loading'); this.util.notify('Refreshing results', 'loading');
axios.get(config.API+'election/results') axios.get(config.API + 'election/results')
.then(response=>{ .then(response => {
$.notifyClose(); $.notifyClose();
vm.data.results = response.data; vm.data.results = response.data;
vm.data.last_update= new Date(); vm.data.last_update = new Date();
vm.initChart(); vm.$nextTick(function () {
vm.initCharts();
});
}) })
.catch(error=>{ .catch(error => {
$.notifyClose(); $.notifyClose();
vm.util.showResult(error); vm.util.showResult(error);
}) })
}, },
initChart: function () { initCharts: function () {
$.plot($('#chart'), this.datas, { // Clear any previously rendered plots to avoid overlap when switching views
series: { if ($('#top-chart').length) $('#top-chart').empty();
pie: { if ($('#votes-by-position-chart').length) $('#votes-by-position-chart').empty();
show: true,
innerRadius: 0.5 // All positions summary
} if (String(this.position_id) === '0') {
if ($('#top-chart').length) {
var ticks = this.topNominees.map(function (n, i) { return [i, n.label]; });
var series = [{
label: 'Undi',
data: this.topNominees.map(function (n, i) { return [i, n.votes]; }),
bars: { show: true, barWidth: 0.6, align: 'center', fill: 0.85, lineWidth: 0 }
}];
$.plot($('#top-chart'), series, {
xaxis: { ticks: ticks, rotateTicks: 45 },
yaxis: { min: 0, tickDecimals: 0 },
grid: { hoverable: true, borderColor: '#e5e7eb' }
});
} }
})
if ($('#votes-by-position-chart').length) {
var pt = this.positionTotals;
var ptTicks = pt.map(function (p, i) { return [i, p.label]; });
var ptSeries = [{
label: 'Undi',
data: pt.map(function (p, i) { return [i, p.votes]; }),
bars: { show: true, barWidth: 0.6, align: 'center', fill: 0.55, lineWidth: 0 }
}];
$.plot($('#votes-by-position-chart'), ptSeries, {
xaxis: { ticks: ptTicks, rotateTicks: 45 },
yaxis: { min: 0, tickDecimals: 0 },
grid: { hoverable: true, borderColor: '#e5e7eb' }
});
}
return;
}
// For per-position view, we intentionally do not render charts (UI request).
}, },
getPosition: function (id) { getPosition: function (id) {
@@ -113,13 +265,12 @@ export default{
computed: { computed: {
last_update: function () { last_update: function () {
let x = this.data.last_update; let x = this.data.last_update;
return x.toDateString() +' '+x.toLocaleTimeString(); if (!x) return '-';
return x.toDateString() + ' ' + x.toLocaleTimeString();
}, },
position_id: function () { position_id: function () {
return this.$route.query.position_id ? return this.$route.query.position_id ? this.$route.query.position_id : 0;
this.$route.query.position_id :
this.data.positions[0]['id'];
}, },
datas: function () { datas: function () {
@@ -127,7 +278,7 @@ export default{
var data = []; var data = [];
var nominees = this.data.nominees; var nominees = this.data.nominees;
for (var i in nominees) { for (var i in nominees) {
if (nominees[i]['position_id']== this.position_id){ if (nominees[i]['position_id'] == this.position_id) {
let row = []; let row = [];
row['label'] = nominees[i]['name']; row['label'] = nominees[i]['name'];
row['data'] = [[1, this.getVotes(nominees[i]['id'])]]; row['data'] = [[1, this.getVotes(nominees[i]['id'])]];
@@ -135,7 +286,427 @@ export default{
} }
} }
return data; return data;
},
positionTotals: function () {
// Total votes per position (for "Semua" dashboard)
var results = this.data.results || [];
var totals = {};
for (var i = 0; i < results.length; i++) {
var pid = results[i].position_id;
var v = Number(results[i].votes || 0);
totals[pid] = (totals[pid] || 0) + v;
}
var positions = this.data.positions || [];
var rows = positions.map(function (p) {
return { position_id: p.id, label: p.name, votes: totals[p.id] || 0 };
});
rows.sort(function (a, b) { return (b.votes || 0) - (a.votes || 0); });
return rows;
},
topNominees: function () {
// Highest vote nominees across all positions (sum by nominee_id)
var results = this.data.results || [];
var sum = {};
for (var i = 0; i < results.length; i++) {
var nid = results[i].nominee_id;
var v = Number(results[i].votes || 0);
sum[nid] = (sum[nid] || 0) + v;
}
var nominees = this.data.nominees || [];
var nameById = {};
for (var j = 0; j < nominees.length; j++) {
nameById[nominees[j].id] = nominees[j].name;
}
var rows = Object.keys(sum).map(function (nid) {
return { id: Number(nid), label: nameById[nid] || ('Nominee #' + nid), votes: sum[nid] };
});
rows.sort(function (a, b) { return (b.votes || 0) - (a.votes || 0); });
return rows.slice(0, 8);
},
topNomineeLabel: function () {
return this.topNominees && this.topNominees[0] ? this.topNominees[0].label : '-';
},
topNomineeVotes: function () {
return this.topNominees && this.topNominees[0] ? (this.topNominees[0].votes || 0) : 0;
},
leadersByPosition: function () {
// For each position: winner + runner-up + margin
var positions = this.data.positions || [];
var nominees = this.data.nominees || [];
var results = this.data.results || [];
var posName = {};
positions.forEach(function (p) { posName[p.id] = p.name; });
var nomineeName = {};
nominees.forEach(function (n) { nomineeName[n.id] = n.name; });
var byPos = {};
for (var i = 0; i < results.length; i++) {
var r = results[i];
var pid = r.position_id;
if (!byPos[pid]) byPos[pid] = [];
byPos[pid].push({ nominee_id: r.nominee_id, votes: Number(r.votes || 0) });
}
var rows = [];
for (var j = 0; j < positions.length; j++) {
var pid2 = positions[j].id;
var arr = (byPos[pid2] || []).slice().sort(function (a, b) { return (b.votes || 0) - (a.votes || 0); });
var w = arr[0] || { nominee_id: null, votes: 0 };
var r2 = arr[1] || { nominee_id: null, votes: 0 };
var total = arr.reduce(function (s, x) { return s + (x.votes || 0); }, 0);
var pct = total > 0 ? Math.round((w.votes / total) * 100) : 0;
rows.push({
position_id: pid2,
position_name: posName[pid2] || ('Jawatan #' + pid2),
winner_id: w.nominee_id,
winner_name: nomineeName[w.nominee_id] || '-',
winner_votes: w.votes || 0,
runnerup_id: r2.nominee_id,
runnerup_name: nomineeName[r2.nominee_id] || '-',
runnerup_votes: r2.votes || 0,
delta: Math.max(0, (w.votes || 0) - (r2.votes || 0)),
pct: pct
});
}
// Sort: show positions with most votes first
rows.sort(function (a, b) { return (b.winner_votes || 0) - (a.winner_votes || 0); });
return rows;
},
rankedNominees: function () {
var nominees = this.data.nominees || [];
var rows = [];
for (var i = 0; i < nominees.length; i++) {
if (String(nominees[i].position_id) !== String(this.position_id)) continue;
var votes = this.getVotes(nominees[i].id);
rows.push({
id: nominees[i].id,
name: nominees[i].name,
votes: votes
});
}
rows.sort(function (a, b) { return (b.votes || 0) - (a.votes || 0); });
var total = rows.reduce(function (s, r) { return s + (r.votes || 0); }, 0);
return rows.map(function (r) {
var pct = total > 0 ? Math.round((r.votes / total) * 100) : 0;
return Object.assign({}, r, { pct: pct });
});
},
totalVotes: function () {
// In "All" view use all results; otherwise use per-position ranking
if (String(this.position_id) === '0') {
var results = this.data.results || [];
return results.reduce(function (s, r) { return s + Number(r.votes || 0); }, 0);
}
var rows = this.rankedNominees;
return rows.reduce(function (s, r) { return s + (r.votes || 0); }, 0);
} }
} }
} }
</script> </script>
<style scoped>
.result-page {
margin-top: 12px;
}
.result-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.result-kicker {
color: #2f80ed;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
.result-title {
margin: 0 0 6px;
font-weight: 800;
color: #17324d;
}
.result-subtitle {
color: #6b7280;
font-size: 12px;
}
.result-actions {
flex-shrink: 0;
}
.result-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 10px 0 14px;
}
.result-stat {
border: 1px solid #e6edf5;
border-radius: 16px;
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
padding: 12px 14px;
box-shadow: 0 10px 22px rgba(35, 64, 97, 0.06);
}
.result-stat__label {
color: #6b7280;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 6px;
}
.result-stat__value {
font-size: 22px;
font-weight: 900;
color: #17324d;
}
.result-stat__value--text {
font-size: 14px;
line-height: 1.25;
}
.result-stat__hint {
margin-top: 6px;
font-size: 12px;
color: #6b7280;
font-weight: 700;
}
.result-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin: 10px 0 16px;
}
.result-filters__label {
color: #6b7280;
font-size: 12px;
font-weight: 700;
margin-right: 4px;
}
.result-chip {
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
line-height: 1;
}
.result-chip:hover {
background: #f3f4f6;
}
.result-chip.is-active {
background: #1976d2;
border-color: #1976d2;
color: #fff;
}
.result-card {
border: 0;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 12px 28px rgba(35, 64, 97, 0.08);
}
.result-card__heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
background: linear-gradient(135deg, #1d4e89, #2f80ed);
color: #fff;
border: 0 !important;
}
.result-card__title {
font-weight: 800;
}
.result-card__meta {
opacity: 0.95;
font-size: 12px;
}
.result-card__body {
background: #fff;
}
.result-top-chart {
width: 100%;
height: 280px;
}
.result-mini-title {
font-size: 12px;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #17324d;
margin-bottom: 8px;
}
.result-empty {
margin-top: 10px;
text-align: center;
color: #6b7280;
font-style: italic;
}
.result-leaders {
display: flex;
flex-direction: column;
gap: 12px;
}
.result-leader {
padding: 12px 12px;
border: 1px solid #eef2f7;
border-radius: 14px;
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
}
.result-leader__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.result-leader__position {
font-weight: 900;
color: #17324d;
}
.result-leader__meta {
display: flex;
gap: 10px;
font-size: 12px;
color: #17324d;
white-space: nowrap;
}
.result-leader__delta {
color: #2f80ed;
}
.result-leader__name {
font-weight: 800;
color: #17324d;
margin-bottom: 8px;
}
.result-leader__bar {
height: 10px;
border-radius: 999px;
background: #e8f0fb;
overflow: hidden;
}
.result-leader__bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #2f80ed, #1d4e89);
}
.result-leader__sub {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 12px;
color: #6b7280;
}
.result-rank {
display: flex;
flex-direction: column;
gap: 12px;
}
.result-rank__row {
padding: 12px 12px;
border: 1px solid #eef2f7;
border-radius: 14px;
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
}
.result-rank__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.result-rank__name {
font-weight: 800;
color: #17324d;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-rank__votes {
color: #17324d;
font-size: 12px;
white-space: nowrap;
}
.result-rank__bar {
height: 10px;
border-radius: 999px;
background: #e8f0fb;
overflow: hidden;
}
.result-rank__bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #2f80ed, #1d4e89);
}
.result-rank__meta {
margin-top: 6px;
font-size: 12px;
color: #6b7280;
text-align: right;
}
@media (max-width: 991px) {
.result-header {
flex-direction: column;
}
.result-stats {
grid-template-columns: 1fr;
}
}
</style>
@@ -9,7 +9,7 @@
<span class="icon-bar"></span> <span class="icon-bar"></span>
<span class="icon-bar"></span> <span class="icon-bar"></span>
</button> </button>
<a class="navbar-brand" :href="data.baseURL"><img <a class="navbar-brand" href="/admin"><img
src='https://i.postimg.cc/ZBDD0ZfQ/Whats-App-Image-2024-03-21-at-1-24-46-PM-2.jpg' 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"> border='0' width="150" height="auto" alt="MyKoPKB Logo">
</a> </a>
@@ -18,7 +18,8 @@
<ul class="nav navbar-nav"> <ul class="nav navbar-nav">
<!-- {{ users }} --> <!-- {{ users }} -->
<router-link :to="{ name: 'Admin Home' }" tag="li" exact><a href="#"><b>Laman <router-link v-if="Number(data.user.role) !== 3" :to="{ name: 'Admin Home' }" tag="li"
exact><a href="#"><b>Laman
Utama</b></a></router-link> Utama</b></a></router-link>
<router-link :to="{ name: 'Kemaskini Jawatan' }" v-if="data.user.role == 1" tag="li"> <router-link :to="{ name: 'Kemaskini Jawatan' }" v-if="data.user.role == 1" tag="li">
<a href="#"><b>Jawatan</b></a> <a href="#"><b>Jawatan</b></a>
@@ -28,7 +29,7 @@
<a href="#">Manage Partylist</a> <a href="#">Manage Partylist</a>
</router-link> --> </router-link> -->
<router-link :to="{ name: 'Manage Voter' }" v-if="data.user.role == 1" tag="li"> <router-link :to="{ name: 'Manage Voter' }" v-if="data.user.role == 1" tag="li" exact>
<a href="#"><b>Anggota Koperasi</b></a> <a href="#"><b>Anggota Koperasi</b></a>
</router-link> </router-link>
@@ -36,7 +37,8 @@
<a href="#"><b>Calon</b></a> <a href="#"><b>Calon</b></a>
</router-link> </router-link>
<router-link :to="{ name: 'Kehadiran Calon' }" v-if="data.user.role == 1" tag="li"> <router-link :to="{ name: 'Kehadiran Calon' }"
v-if="data.user.role == 1 || Number(data.user.role) === 3" tag="li">
<a href="#"><b>Kehadiran</b></a> <a href="#"><b>Kehadiran</b></a>
</router-link> </router-link>
@@ -48,6 +50,10 @@
<a href="#"><b>Penyata Anggota</b></a> <a href="#"><b>Penyata Anggota</b></a>
</router-link> </router-link>
<router-link :to="{ name: 'Activity Log' }" v-if="data.user.role == 1" tag="li">
<a href="#"><b>Log Aktiviti</b></a>
</router-link>
</ul> </ul>
<ul class="nav navbar-right navbar-nav"> <ul class="nav navbar-right navbar-nav">
@@ -58,15 +64,24 @@
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<router-link :to="{ name: 'Update Account' }" tag="li" exact> <router-link v-if="Number(data.user.role) !== 3" :to="{ name: 'Update Account' }"
tag="li" exact>
<a href="#">Kemaskini Akaun</a> <a href="#">Kemaskini Akaun</a>
</router-link> </router-link>
<li v-if="isImpersonating()" @click="leaveImpersonation()">
<a>Kembali Akaun Asal</a>
</li>
<router-link :to="{ name: 'Manage Account' }" tag="li" v-if="data.user.id == 1"> <router-link :to="{ name: 'Manage Account' }" tag="li" v-if="data.user.id == 1">
<a href="#">Pengurusan Akaun</a> <a href="#">Pengurusan Akaun</a>
</router-link> </router-link>
<li @click="logout()"><a>Log Keluar</a></li> <li @click="isImpersonating() ? null : logout()"
:class="{ disabled: isImpersonating() }"
:style="isImpersonating() ? 'opacity:0.5; cursor:not-allowed; pointer-events:none;' : ''">
<a>Log Keluar</a>
</li>
</ul> </ul>
</li> </li>
</ul> </ul>
@@ -106,6 +121,12 @@ export default {
vm.data.election = response.data.election; vm.data.election = response.data.election;
vm.data.partylists = response.data.partylist; vm.data.partylists = response.data.partylist;
vm.data.positions = response.data.position; vm.data.positions = response.data.position;
if (response.data.user && response.data.user.role !== undefined && response.data.user.role !== null) {
localStorage.setItem('admin_role', String(response.data.user.role));
}
if (Number(response.data.user.role) === 3 && vm.$route.name !== 'Kehadiran Calon') {
vm.$router.replace({ name: 'Kehadiran Calon' });
}
vm.loading = false; vm.loading = false;
}) })
.catch(error => { .catch(error => {
@@ -120,7 +141,47 @@ export default {
}, },
methods: { methods: {
isImpersonating: function () {
try {
return localStorage.getItem('is_impersonating') === '1';
} catch (e) {
return false;
}
},
leaveImpersonation: function () {
var vm = this;
vm.util.notify('Leaving impersonation', 'loading');
axios.post(config.API + 'admin/impersonate/leave')
.then(function (response) {
$.notifyClose();
if (!response || !response.data || response.data.status !== 'success') {
vm.util.showResult(response, 'error');
return;
}
if (response.data.token) {
localStorage['Access Token'] = 'Bearer ' + response.data.token;
if (vm.util && vm.util.setAuthorization) vm.util.setAuthorization();
}
localStorage.removeItem('is_impersonating');
if (response.data.user) {
vm.data.user = response.data.user;
if (response.data.user.role !== undefined && response.data.user.role !== null) {
localStorage.setItem('admin_role', String(response.data.user.role));
}
}
vm.util.notify('Berjaya kembali', 'success');
if (vm.$router) vm.$router.go(0);
})
.catch(function (error) {
$.notifyClose();
vm.util.showResult(error, 'error');
});
},
logout: function () { logout: function () {
localStorage.removeItem('admin_role');
localStorage.removeItem('is_impersonating');
localStorage.clear(); localStorage.clear();
this.$router.push({ name: 'Admin Login' }); this.$router.push({ name: 'Admin Login' });
} }
@@ -62,8 +62,13 @@ export default {
}, },
created: function () { created: function () {
if (this.util.isLogin()) if (this.util.isLogin()) {
return this.$router.push({ name: 'Admin Home' }) var ar = localStorage.getItem('admin_role');
if (ar === '3') {
return this.$router.push({ name: 'Kehadiran Calon' });
}
return this.$router.push({ name: 'Admin Home' });
}
this.util.setTitle('Log Masuk Admin '); this.util.setTitle('Log Masuk Admin ');
}, },
@@ -80,8 +85,15 @@ export default {
vm.stopLoading(); vm.stopLoading();
if (this.util.showResult(response, 'success')) { if (this.util.showResult(response, 'success')) {
localStorage['Access Token'] = `Bearer ${response.data.token}`; localStorage['Access Token'] = `Bearer ${response.data.token}`;
if (response.data.user && response.data.user.role !== undefined && response.data.user.role !== null) {
localStorage.setItem('admin_role', String(response.data.user.role));
}
this.util.setAuthorization(); this.util.setAuthorization();
vm.$router.push({ name: 'Admin Home' }); if (Number(response.data.user && response.data.user.role) === 3) {
vm.$router.push({ name: 'Kehadiran Calon' });
} else {
vm.$router.push({ name: 'Admin Home' });
}
} }
}) })
.catch(error => { .catch(error => {
@@ -1,78 +1,193 @@
<template> <template>
<div class="panel panel-default"> <div class="panel panel-default nominee-add-page">
<div class="panel-body"> <div class="panel-body">
<form class="row" method="POST" id="add_form" :action="data.API + 'nominee'" enctype="mutlipart/formdata" <div class="nominee-add-page__header clearfix">
@submit.prevent="add()"> <h4 class="pull-left nominee-add-page__title">
<div class="col-md-8"> <i class="fa fa-user-plus text-muted"></i> Tambah Calon
</h4>
<router-link
:to="{ name: 'Maklumat Calon', query: { position_id: position_id } }"
class="btn btn-default pull-right"
>
<i class="fa fa-arrow-left"></i> Kembali
</router-link>
</div>
<div class="form-group"> <p class="text-muted small nominee-add-page__intro">
<label for="name">Nama Calon</label> Lengkapkan maklumat calon. Medan bertanda <span class="text-danger">*</span> adalah wajib.
<input type="text" name="name" class="form-control" required /> </p>
<form
class="nominee-add-form"
method="POST"
id="add_form"
:action="data.API + 'nominee'"
enctype="multipart/form-data"
@submit.prevent="add()"
>
<div class="row">
<div class="col-md-8">
<div class="row">
<div class="col-sm-6">
<div class="form-group">
<label for="nominee-name">Nama Calon <span class="text-danger">*</span></label>
<input
id="nominee-name"
type="text"
name="name"
class="form-control"
placeholder="Nama penuh"
required
/>
</div>
</div>
<div class="col-sm-6">
<div class="form-group">
<label for="nominee-no-anggota">No. Anggota <span class="text-danger">*</span></label>
<input
id="nominee-no-anggota"
type="text"
name="no_anggota"
class="form-control"
placeholder="No. keahlian"
required
/>
</div>
</div>
</div>
<div class="row">
<div class="col-sm-6">
<div class="form-group">
<label for="nominee-unit">Unit <span class="text-danger">*</span></label>
<input
id="nominee-unit"
type="text"
name="unit"
class="form-control"
required
/>
</div>
</div>
<div class="col-sm-6">
<div class="form-group">
<label for="nominee-umur">Umur <span class="text-danger">*</span></label>
<input
id="nominee-umur"
type="number"
name="umur"
class="form-control"
min="1"
max="120"
placeholder="Tahun"
required
/>
</div>
</div>
</div>
<div class="form-group">
<label for="nominee-jawatan-sekarang">Jawatan Sekarang <span class="text-danger">*</span></label>
<input
id="nominee-jawatan-sekarang"
type="text"
name="jawatan_sekarang"
class="form-control"
required
/>
</div>
<div class="form-group">
<label for="nominee-education">Taraf Pendidikan</label>
<input
id="nominee-education"
type="text"
name="education"
class="form-control"
placeholder="Pilihan"
/>
</div>
<div class="form-group">
<label for="nominee-experience">Pengalaman Kerja</label>
<textarea
id="nominee-experience"
name="experience"
class="form-control"
rows="4"
placeholder="Pilihan — senaraikan pengalaman relevan"
></textarea>
</div>
<div class="form-group">
<label for="nominee-position-id">Jawatan Dicalonkan <span class="text-danger">*</span></label>
<select
id="nominee-position-id"
class="form-control"
v-model="position_id"
name="position_id"
required
>
<option value="0" disabled> Pilih jawatan </option>
<option
v-for="position in data.positions"
:key="position.id"
:value="position.id"
>
{{ position.name }}
</option>
</select>
</div>
</div> </div>
<div class="form-group"> <div class="col-md-4">
<label for="no_anggota">No. Anggota</label> <div class="nominee-add-photo panel panel-default">
<input type="text" name="no_anggota" class="form-control" required /> <div class="panel-heading">
<strong><i class="fa fa-camera"></i> Gambar calon</strong>
<span class="text-muted small nominee-add-photo__hint">Pilihan</span>
</div>
<div class="panel-body">
<div
class="nominee-add-photo__preview"
:class="{ 'nominee-add-photo__preview--empty': !imageUrl }"
>
<img v-if="imageUrl" :src="imageUrl" alt="Pratonton gambar calon" />
<div v-else class="nominee-add-photo__placeholder">
<i class="fa fa-picture-o"></i>
<span>Tiada gambar dipilih</span>
</div>
</div>
<label class="btn btn-default btn-block nominee-add-photo__browse" for="nominee-file-input">
<i class="fa fa-folder-open"></i> Pilih fail
</label>
<input
id="nominee-file-input"
name="photo"
type="file"
class="nominee-add-photo__input"
accept="image/*"
@change="handleImageChange"
/>
<p class="text-muted small nominee-add-photo__formats">PNG, JPG atau GIF</p>
</div>
</div>
</div> </div>
</div>
<div class="form-group"> <hr class="nominee-add-form__rule" />
<label for="unit">Unit</label>
<input type="text" name="unit" class="form-control" required />
</div>
<div class="form-group"> <div class="clearfix nominee-add-form__actions">
<label for="unit">Umur</label> <router-link
<input type="text" name="umur" class="form-control" required /> :to="{ name: 'Maklumat Calon', query: { position_id: position_id } }"
</div> class="btn btn-default"
>
<div class="form-group"> <i class="fa fa-times"></i> Batal
<label for="unit">Jawatan Sekarang</label> </router-link>
<input type="text" name="jawatan_sekarang" class="form-control" required /> <button type="submit" class="btn btn-primary" :disabled="loading">
</div> <i v-if="loading" class="fa fa-spinner fa-spin"></i>
<i v-else class="fa fa-check"></i>
<div class="form-group"> {{ loading ? 'Menghantar…' : 'Simpan calon' }}
<label for="education">Taraf Pendidikan</label> </button>
<input type="text" name="education" class="form-control" placeholder="(Optional)" />
</div>
<div class="form-group">
<label for="pengalaman">Pengalaman Kerja</label>
<textarea name="pengalaman" class="form-control" placeholder="(Optional)"></textarea>
</div>
<div class="form-group">
<label for="position_id">Jawatan</label>
<select class="form-control" v-model="position_id" name="position_id" required>
<option value="0" disabled>--- Pilih Jawatan ---</option>
<option v-for="position in data.positions" :key="position.id" :value="position.id">{{ position.name }}</option>
</select>
</div>
<!-- <div class="form-group">
<label for="partylist_id">Partylist</label>
<select class="form-control" name="partylist_id">
<option value="">--- Select Partylist (Optional) ---</option>
<option v-for="partylist in data.partylists" :value="partylist.id">{{ partylist.name }}</option>
</select>
</div> -->
<div id="imagePreview">
<img :src="imageUrl" v-if="imageUrl" alt="Preview">
</div>
<div>
<label for="image">Muatnaik Gambar</label>
<input name="photo" type="file" accept="image/*" id="file-input" @change="handleImageChange">
</div>
<div class="form-group pull-right">
<router-link :to="{ name: 'Maklumat Calon', query: { position_id: position_id } }"
class="btn btn-default">
Cancel
</router-link>
<input type="submit" value="Submit" class="btn btn-info">
</div>
</div> </div>
</form> </form>
</div> </div>
@@ -83,7 +198,7 @@
export default { export default {
data: function () { data: function () {
return { return {
imageUrl: '', imageUrl: '',
loading: false loading: false
} }
}, },
@@ -102,8 +217,8 @@ export default {
vm.$router.push({ name: 'Maklumat Calon' }); vm.$router.push({ name: 'Maklumat Calon' });
}, },
error: function (error) { error: function (error) {
alert('Nominee with the same name has already registered'); alert('Nominee with the same name has already registered');
location.reload(); location.reload();
$.notifyClose(); $.notifyClose();
vm.loading = false; vm.loading = false;
vm.util.showResult(error, 'error', 'ajax'); vm.util.showResult(error, 'error', 'ajax');
@@ -114,27 +229,23 @@ export default {
}) })
}, },
handleImageChange(event) { handleImageChange(event) {
const file = event.target.files[0]; // Get the selected file const file = event.target.files[0];
const imageType = /image.*/; // RegExp to check if the file is an image const imageType = /image.*/;
// Check if the selected file is an image if (file && file.type.match(imageType)) {
if (file && file.type.match(imageType)) { const reader = new FileReader();
const reader = new FileReader(); // Create a FileReader object
reader.onload = (e) => { reader.onload = (e) => {
this.imageUrl = e.target.result; // Set the imageUrl to the data URL of the image this.imageUrl = e.target.result;
document.getElementById('imagePreview').style.display = 'block'; // Display the div };
};
reader.readAsDataURL(file); // Read the image data as a data URL reader.readAsDataURL(file);
} else { } else {
// Clear the file input and hide the div if the selected file is not an image event.target.value = '';
event.target.value = ''; this.imageUrl = '';
this.imageUrl = ''; }
document.getElementById('imagePreview').style.display = 'none'; }
}
}
}, },
computed: { computed: {
@@ -152,16 +263,115 @@ export default {
</script> </script>
<style scoped> <style scoped>
#imagePreview { .nominee-add-page__header {
width: 200px; margin-bottom: 12px;
height: 200px; padding-bottom: 10px;
border: 1px solid #ccc; border-bottom: 1px solid #eee;
margin-bottom: 10px; }
display: none; /* Initially hide the div */
}
#imagePreview img { .nominee-add-page__title {
max-width: 100%; margin-top: 0;
max-height: 100%; margin-bottom: 0;
} font-weight: 600;
}
.nominee-add-page__title .fa {
margin-right: 6px;
}
.nominee-add-page__intro {
margin-bottom: 18px;
}
.nominee-add-photo .panel-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.nominee-add-photo__hint {
font-weight: normal;
}
.nominee-add-photo__preview {
width: 100%;
aspect-ratio: 1;
max-height: 240px;
border-radius: 4px;
overflow: hidden;
background: #f9f9f9;
border: 1px dashed #ccc;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.nominee-add-photo__preview--empty {
min-height: 180px;
}
.nominee-add-photo__preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.nominee-add-photo__placeholder {
text-align: center;
color: #999;
padding: 16px;
}
.nominee-add-photo__placeholder .fa {
font-size: 36px;
display: block;
margin-bottom: 8px;
opacity: 0.65;
}
.nominee-add-photo__placeholder span {
display: block;
font-size: 12px;
}
.nominee-add-photo__input {
position: absolute;
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
z-index: -1;
}
.nominee-add-photo__browse {
margin-bottom: 0;
}
.nominee-add-photo__formats {
margin: 8px 0 0;
text-align: center;
}
.nominee-add-form__rule {
margin-top: 8px;
margin-bottom: 16px;
border-top-color: #eee;
}
.nominee-add-form__actions {
text-align: right;
}
.nominee-add-form__actions .btn + .btn {
margin-left: 8px;
}
@media (max-width: 991px) {
.nominee-add-photo {
margin-top: 8px;
}
}
</style> </style>
@@ -1,155 +1,323 @@
<template> <template>
<div class="panel panel-default"> <div class="panel panel-default nominee-add-page">
<div class="panel-body"> <div class="panel-body">
<form <div class="nominee-add-page__header clearfix">
method="POST" <h4 class="pull-left nominee-add-page__title">
class="row" <i class="fa fa-pencil text-muted"></i> Kemaskini Calon
id="edit_form" </h4>
:action="data.API+'nominee/'+id" <router-link :to="backToList" class="btn btn-default pull-right">
enctype="multipart/form-data" <i class="fa fa-arrow-left"></i> Kembali
@submit.prevent="edit()"> </router-link>
<input type="hidden" name="_method" value="PUT"/>
<div class="col-md-8">
<div class="form-group">
<label for="name">Nama</label>
<input type="text" name="name" class="form-control" :value="nominee.name" required/>
</div>
<div class="form-group">
<label for="student_id">No. Anggota</label>
<input type="text" name="no_anggota" class="form-control" :value="nominee.no_anggota" required/>
</div>
<div class="form-group">
<label for="Unit">Unit</label>
<input type="text" name="unit" class="form-control" :value="nominee.unit" required/>
</div>
<div class="form-group">
<label for="umur">Umur</label>
<input type="text" name="umur" class="form-control" :value="nominee.umur" required/>
</div>
<div class="form-group">
<label for="jawatan_sekarang">Jawatan Sekarang</label>
<input type="text" name="jawatan_sekarang" class="form-control" :value="nominee.jawatan_sekarang" required/>
</div>
<div class="form-group">
<label for="education">Taraf Pendidikan</label>
<input type="text" name="education" class="form-control" :value="nominee.education" placeholder="(Optional)" />
</div>
<div class="form-group">
<label for="experience">Pengalaman Kerja </label>
<textarea name="experience" class="form-control" placeholder="(Optional)">{{ nominee.experience}}</textarea>
</div>
<div class="form-group">
<label for="position_id">Jawatan</label>
<select class="form-control" name="position_id" :value="nominee.position_id" required>
<option value="0" disabled>--- Pilih Jawatan ---</option>
<option v-for="position in data.positions" :value="position.id">{{ position.name }}</option>
</select>
</div>
<div class="form-group">
<label for="partylist_id">Partylist</label>
<select class="form-control" name="partylist_id" :value="nominee.partylist_id">
<option value="">--- Select Partylist (Optional) ---</option>
<option v-for="partylist in data.partylists" :key="partylist.id" :value="partylist.id">{{ partylist.name }}</option>
</select>
</div>
<div id="imagePreview">
<img :src="imageUrl" v-if="imageUrl" alt="Preview">
</div>
<div>
<label for="image">Muat Naik Gambar</label>
<input name="photo" type="file" accept="image/*" id="file-input" @change="handleImageChange">
</div>
<div class="form-group pull-right">
<input type="submit" value="Simpan" class="btn btn-info">
<router-link
:to="{name:'Maklumat Calon'}"
class="btn btn-default">
Kembali
</router-link>
</div>
</div> </div>
</form>
<p class="text-muted small nominee-add-page__intro">
Kemas kini maklumat calon. Medan bertanda <span class="text-danger">*</span> adalah wajib.
</p>
<form
class="nominee-add-form"
method="POST"
id="edit_form"
:action="data.API + 'nominee/' + id"
enctype="multipart/form-data"
@submit.prevent="edit()"
>
<input type="hidden" name="_method" value="PUT" />
<div class="row">
<div class="col-md-8">
<div class="row">
<div class="col-sm-6">
<div class="form-group">
<label for="edit-nominee-name">Nama Calon <span class="text-danger">*</span></label>
<input
id="edit-nominee-name"
v-model="form.name"
type="text"
name="name"
class="form-control"
placeholder="Nama penuh"
required
/>
</div>
</div>
<div class="col-sm-6">
<div class="form-group">
<label for="edit-nominee-no-anggota">No. Anggota <span class="text-danger">*</span></label>
<input
id="edit-nominee-no-anggota"
v-model="form.no_anggota"
type="text"
name="no_anggota"
class="form-control"
placeholder="No. keahlian"
required
/>
</div>
</div>
</div>
<div class="row">
<div class="col-sm-6">
<div class="form-group">
<label for="edit-nominee-unit">Unit <span class="text-danger">*</span></label>
<input
id="edit-nominee-unit"
v-model="form.unit"
type="text"
name="unit"
class="form-control"
required
/>
</div>
</div>
<div class="col-sm-6">
<div class="form-group">
<label for="edit-nominee-umur">Umur <span class="text-danger">*</span></label>
<input
id="edit-nominee-umur"
v-model="form.umur"
type="number"
name="umur"
class="form-control"
min="1"
max="120"
placeholder="Tahun"
required
/>
</div>
</div>
</div>
<div class="form-group">
<label for="edit-nominee-jawatan-sekarang">Jawatan Sekarang <span class="text-danger">*</span></label>
<input
id="edit-nominee-jawatan-sekarang"
v-model="form.jawatan_sekarang"
type="text"
name="jawatan_sekarang"
class="form-control"
required
/>
</div>
<div class="form-group">
<label for="edit-nominee-education">Taraf Pendidikan</label>
<input
id="edit-nominee-education"
v-model="form.education"
type="text"
name="education"
class="form-control"
placeholder="Pilihan"
/>
</div>
<div class="form-group">
<label for="edit-nominee-experience">Pengalaman Kerja</label>
<textarea
id="edit-nominee-experience"
v-model="form.experience"
name="experience"
class="form-control"
rows="4"
placeholder="Pilihan — senaraikan pengalaman relevan"
></textarea>
</div>
<div class="form-group">
<label for="edit-nominee-position-id">Jawatan Dicalonkan <span class="text-danger">*</span></label>
<select
id="edit-nominee-position-id"
v-model="form.position_id"
class="form-control"
name="position_id"
required
>
<option value="" disabled> Pilih jawatan </option>
<option
v-for="position in data.positions"
:key="position.id"
:value="String(position.id)"
>
{{ position.name }}
</option>
</select>
</div>
<div class="form-group">
<label for="edit-nominee-partylist-id">Senarai parti</label>
<select
id="edit-nominee-partylist-id"
v-model="form.partylist_id"
class="form-control"
name="partylist_id"
>
<option value=""> Pilihan </option>
<option
v-for="partylist in data.partylists"
:key="partylist.id"
:value="String(partylist.id)"
>
{{ partylist.name }}
</option>
</select>
</div>
</div>
<div class="col-md-4">
<div class="nominee-add-photo panel panel-default">
<div class="panel-heading">
<strong><i class="fa fa-camera"></i> Gambar calon</strong>
<span class="text-muted small nominee-add-photo__hint">Pilihan</span>
</div>
<div class="panel-body">
<div
class="nominee-add-photo__preview"
:class="{ 'nominee-add-photo__preview--empty': !imageUrl }"
>
<img v-if="imageUrl" :src="imageUrl" alt="Pratonton gambar calon" />
<div v-else class="nominee-add-photo__placeholder">
<i class="fa fa-picture-o"></i>
<span>Tiada gambar</span>
</div>
</div>
<label class="btn btn-default btn-block nominee-add-photo__browse" for="edit-nominee-file-input">
<i class="fa fa-folder-open"></i> Pilih fail
</label>
<input
id="edit-nominee-file-input"
name="photo"
type="file"
class="nominee-add-photo__input"
accept="image/*"
@change="handleImageChange"
/>
<p class="text-muted small nominee-add-photo__formats">PNG, JPG atau GIF</p>
</div>
</div>
</div>
</div>
<hr class="nominee-add-form__rule" />
<div class="clearfix nominee-add-form__actions">
<router-link :to="backToList" class="btn btn-default">
<i class="fa fa-times"></i> Batal
</router-link>
<button type="submit" class="btn btn-primary" :disabled="loading">
<i v-if="loading" class="fa fa-spinner fa-spin"></i>
<i v-else class="fa fa-check"></i>
{{ loading ? 'Menghantar…' : 'Simpan perubahan' }}
</button>
</div>
</form>
</div>
</div> </div>
</div>
</template> </template>
<script> <script>
export default{ export default {
data: function () { data: function () {
return { return {
imageUrl: '', imageUrl: '',
loading: false loading: false,
form: {
name: '',
no_anggota: '',
unit: '',
umur: '',
jawatan_sekarang: '',
education: '',
experience: '',
position_id: '',
partylist_id: ''
}
} }
}, },
created: function () { created: function () {
if (!this.nominee.id) if (!this.nominee.id) this.$router.push({ name: 'Maklumat Calon' })
this.$router.push({name:'Maklumat Calon'});
}, },
mounted: function () { watch: {
if(this.nominee.id) { nominee: {
this.imageUrl = this.createBase64ImageUrl(this.nominee.photo); immediate: true,
document.getElementById('imagePreview').style.display = 'block'; deep: true,
} handler: function (n) {
}, if (!n || !n.id) return
this.form.name = n.name || ''
this.form.no_anggota = n.no_anggota || ''
this.form.unit = n.unit || ''
this.form.umur = n.umur != null && n.umur !== '' ? String(n.umur) : ''
this.form.jawatan_sekarang = n.jawatan_sekarang || ''
this.form.education = this.formatEducationForInput(n.education)
this.form.experience = this.formatExperienceForInput(n.experience)
this.form.position_id = n.position_id != null ? String(n.position_id) : ''
this.form.partylist_id =
n.partylist_id != null && n.partylist_id !== '' ? String(n.partylist_id) : ''
this.imageUrl = n.photo ? this.createBase64ImageUrl(n.photo) : ''
}
}
},
methods: { methods: {
createBase64ImageUrl: function(base64ImageData) { createBase64ImageUrl: function (base64ImageData) {
return "data:image/png;base64," + base64ImageData; return 'data:image/png;base64,' + base64ImageData
}, },
handleImageChange(event) {
const file = event.target.files[0]; // Get the selected file
const imageType = /image.*/; // RegExp to check if the file is an image
// Check if the selected file is an image formatEducationForInput: function (val) {
if (file && file.type.match(imageType)) { if (val == null || val === '') return ''
const reader = new FileReader(); // Create a FileReader object if (Array.isArray(val)) return val.join('\n')
return String(val)
},
reader.onload = (e) => { formatExperienceForInput: function (val) {
this.imageUrl = e.target.result; // Set the imageUrl to the data URL of the image if (val == null || val === '') return ''
document.getElementById('imagePreview').style.display = 'block'; // Display the div if (Array.isArray(val)) {
}; return val.map(function (x, i) {
return i + 1 + '. ' + x
}).join('\n')
}
return String(val)
},
handleImageChange: function (event) {
var file = event.target.files[0]
var imageType = /image.*/
var vm = this
if (file && file.type.match(imageType)) {
var reader = new FileReader()
reader.onload = function (e) {
vm.imageUrl = e.target.result
}
reader.readAsDataURL(file)
} else {
event.target.value = ''
vm.imageUrl = vm.nominee.photo ? vm.createBase64ImageUrl(vm.nominee.photo) : ''
}
},
reader.readAsDataURL(file); // Read the image data as a data URL
} else {
// Clear the file input and hide the div if the selected file is not an image
event.target.value = '';
this.imageUrl = '';
document.getElementById('imagePreview').style.display = 'none';
}
},
edit: function () { edit: function () {
if (this.loading) return; if (this.loading) return
var vm = this; var vm = this
this.loading = true; this.loading = true
this.util.notify('Kemaskini undian', 'progress', 0); this.util.notify('Kemaskini calon', 'progress', 0)
$('#edit_form').ajaxSubmit({ $('#edit_form').ajaxSubmit({
success: function (response) { success: function (response) {
$.notifyClose(); $.notifyClose()
vm.loading = false; vm.loading = false
if (vm.util.showResult(response, 'success', 'ajax')) if (vm.util.showResult(response, 'success', 'ajax'))
vm.$router.push({name: 'Maklumat Calon',query:{refresh:true}}); vm.$router.push({ name: 'Maklumat Calon', query: { refresh: true } })
}, },
error: function (error) { error: function (error) {
$.notifyClose(); $.notifyClose()
vm.loading = false; vm.loading = false
vm.util.showResult(error, 'error', 'ajax'); vm.util.showResult(error, 'error', 'ajax')
}, },
uploadProgress: function (a, b, c, progress) { uploadProgress: function (a, b, c, progress) {
this.util.notify('Kemaskini undian', 'progress', progress); vm.util.notify('Kemaskini calon', 'progress', progress)
} }
}) })
} }
@@ -157,30 +325,136 @@ export default{
computed: { computed: {
id: function () { id: function () {
return this.$route.params.id; return this.$route.params.id
}, },
nominee: function () { nominee: function () {
for (var i in this.data.nominees) for (var i in this.data.nominees)
if(this.data.nominees[i].id == this.id) if (this.data.nominees[i].id == this.id) return this.data.nominees[i]
return this.data.nominees[i];
return {} return {}
},
backToList: function () {
var q = {}
if (this.nominee && this.nominee.position_id != null && String(this.nominee.position_id) !== '0') {
q.position_id = this.nominee.position_id
}
return { name: 'Maklumat Calon', query: q }
} }
} }
} }
</script> </script>
<style scoped> <style scoped>
#imagePreview { .nominee-add-page__header {
width: 200px; margin-bottom: 12px;
height: 200px; padding-bottom: 10px;
border: 1px solid #ccc; border-bottom: 1px solid #eee;
margin-bottom: 10px; }
display: none; /* Initially hide the div */
}
#imagePreview img { .nominee-add-page__title {
max-width: 100%; margin-top: 0;
max-height: 100%; margin-bottom: 0;
} font-weight: 600;
}
.nominee-add-page__title .fa {
margin-right: 6px;
}
.nominee-add-page__intro {
margin-bottom: 18px;
}
.nominee-add-photo .panel-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.nominee-add-photo__hint {
font-weight: normal;
}
.nominee-add-photo__preview {
width: 100%;
aspect-ratio: 1;
max-height: 240px;
border-radius: 4px;
overflow: hidden;
background: #f9f9f9;
border: 1px dashed #ccc;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.nominee-add-photo__preview--empty {
min-height: 180px;
}
.nominee-add-photo__preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.nominee-add-photo__placeholder {
text-align: center;
color: #999;
padding: 16px;
}
.nominee-add-photo__placeholder .fa {
font-size: 36px;
display: block;
margin-bottom: 8px;
opacity: 0.65;
}
.nominee-add-photo__placeholder span {
display: block;
font-size: 12px;
}
.nominee-add-photo__input {
position: absolute;
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
z-index: -1;
}
.nominee-add-photo__browse {
margin-bottom: 0;
}
.nominee-add-photo__formats {
margin: 8px 0 0;
text-align: center;
}
.nominee-add-form__rule {
margin-top: 8px;
margin-bottom: 16px;
border-top-color: #eee;
}
.nominee-add-form__actions {
text-align: right;
}
.nominee-add-form__actions .btn + .btn {
margin-left: 8px;
}
@media (max-width: 991px) {
.nominee-add-photo {
margin-top: 8px;
}
}
</style> </style>
@@ -1,77 +1,71 @@
<template> <template>
<div class="row"> <div class="row">
<div class="col-md-3" style="max-width: 250px;"> <div class="col-md-12 panel panel-default">
<ul class="list-group">
<router-link tag="li" class="list-group-item" :to="{ name: 'Maklumat Calon' }" exact replace
:class="{ 'active': position_id == 0 }">
Senarai Jawatan
</router-link>
<router-link v-for="position in data.positions" :key="position.id" tag="li" class="list-group-item"
:to="{ query: { position_id: position.id } }" exact replace>
{{ position.name }}
</router-link>
</ul>
</div>
<div class="col-md-9 panel panel-default">
<div class="panel-body table-responsive"> <div class="panel-body table-responsive">
<div class="nominee-filter-chips">
<button
type="button"
class="nominee-chip btn btn-xs"
:class="position_id == 0 ? 'btn-primary' : 'btn-default'"
@click="setPosition(0)"
>
Semua Jawatan
</button>
<button
v-for="position in data.positions"
:key="position.id"
type="button"
class="nominee-chip btn btn-xs"
:class="String(position_id) === String(position.id) ? 'btn-primary' : 'btn-default'"
@click="setPosition(position.id)"
>
{{ position.name }}
</button>
</div>
<div class="form-group"> <div class="form-group">
<router-link :to="{ name: 'Tambah Calon', query: { position_id: position_id } }" <router-link :to="{ name: 'Tambah Calon', query: { position_id: position_id } }"
class="btn btn-success"><i class="fa fa-plus"></i> Tambah Calon</router-link> class="btn btn-success"><i class="fa fa-plus"></i> Tambah Calon</router-link>
</div> </div>
<table class="table table-hover"> <admin-data-table
<thead> :headers="nomineeTableHeaders"
<tr> :items="nominees"
<th></th> :loading="nomineeLoading"
<th>Nama</th> :items-per-page="10"
<th>No. Anggota</th> :show-pagination="true"
<th>Unit</th> empty-text="Tiada calon"
<th>Jawatan</th> :exportable="true"
<!-- <th>Partylist</th> --> export-file-name="calon"
<th>Taraf Pendidikan </th> >
<th>Pengalaman Kerja</th> <template v-slot:item-photo="{ item }">
<th>Tindakan</th> <img
:alt="item.name"
:src="createBase64ImageUrl(item.photo)"
class="thumbnail"
style="height: 60px; width: 60px;"
>
</template>
</tr> <template v-slot:item-education="{ item }">
</thead> <ol style="margin: 0; padding-left: 18px;">
<tbody> <li v-for="(x, idx) in (item.education || [])" :key="idx">{{ x }}</li>
<tr v-for="nominee in nominees" :key="nominee.id"> </ol>
<td> </template>
<img :alt="nominee.name" :src="createBase64ImageUrl(nominee.photo)" class="thumbnail"
style="height: 60px;width: 60px;">
</td>
<!-- <td>{{ nominee.id }}</td> -->
<td>{{ nominee.name }}</td>
<td>{{ nominee.no_anggota }}</td>
<td>{{ nominee.unit }}</td>
<td>{{ nominee.position }}</td>
<td>
<ol>
<li v-for="(item, index) in nominee.education" :key="index">{{ item }}</li>
</ol>
</td>
<td>
<ol>
<li v-for="(item, index) in nominee.experience" :key="index">{{ item }}</li>
</ol>
</td>
<template v-slot:item-experience="{ item }">
<ol style="margin: 0; padding-left: 18px;">
<li v-for="(x, idx) in (item.experience || [])" :key="idx">{{ x }}</li>
</ol>
</template>
<template v-slot:item-actions="{ item }">
<router-link :to="{ name: 'Edit Nominee', params: { id: item.id } }" class="btn btn-primary">
<router-link :to="{ name: 'Edit Nominee', params: { id: nominee.id } }" <i class="fa fa-edit"></i>Kemaskini
class="btn btn-primary"> </router-link>
<i class="fa fa-edit"></i>Kemaskini <button type="button" class="btn btn-danger" @click="openDeleteModal(item)">
</router-link> <i class="fa fa-trash"></i>Padam
<button class="btn btn-danger" @click="openDeleteModal(nominee)"> </button>
<i class="fa fa-trash"></i>Padam </template>
</button> </admin-data-table>
</tr>
<tr v-if="nominees.length < 1">
<td colspan="7">No Calon</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
@@ -97,7 +91,32 @@ export default {
data: function () { data: function () {
return { return {
id: 0 id: 0,
nomineeLoading: false,
nomineeTableHeaders: [
{ title: '', key: 'photo', sortable: false },
{ title: 'Nama', key: 'name', sortable: true },
{ title: 'No. Anggota', key: 'no_anggota', sortable: true },
{ title: 'Unit', key: 'unit', sortable: true },
{ title: 'Jawatan', key: 'position', sortable: true },
{
title: 'Taraf Pendidikan',
key: 'education',
sortable: false,
exportValue: function (item) {
return Array.isArray(item.education) ? item.education.join(' | ') : '';
}
},
{
title: 'Pengalaman Kerja',
key: 'experience',
sortable: false,
exportValue: function (item) {
return Array.isArray(item.experience) ? item.experience.join(' | ') : '';
}
},
{ title: 'Tindakan', key: 'actions', sortable: false }
]
} }
}, },
@@ -106,6 +125,15 @@ export default {
}, },
methods: { methods: {
setPosition: function (id) {
var q = Object.assign({}, this.$route.query);
if (!id || String(id) === '0') {
delete q.position_id;
} else {
q.position_id = id;
}
this.$router.replace({ query: q });
},
openDeleteModal: function (nominee) { openDeleteModal: function (nominee) {
this.id = nominee.id; this.id = nominee.id;
@@ -140,8 +168,13 @@ export default {
refreshNominee: function () { refreshNominee: function () {
var vm = this; var vm = this;
this.nomineeLoading = true;
this.util.notify('Refreshing Nominees', 'loading'); this.util.notify('Refreshing Nominees', 'loading');
axios.get(config.API + 'nominee') axios.get(config.API + 'nominee', {
params: {
position_id: this.position_id && String(this.position_id) !== '0' ? this.position_id : undefined
}
})
.then(response => { .then(response => {
$.notifyClose(); $.notifyClose();
console.log(response); console.log(response);
@@ -151,6 +184,9 @@ export default {
$.notifyClose(); $.notifyClose();
vm.showResult(error); vm.showResult(error);
}) })
.finally(function () {
vm.nomineeLoading = false;
})
}, },
getPosition: function (id) { getPosition: function (id) {
@@ -179,7 +215,7 @@ export default {
var y = this.data.nominees; var y = this.data.nominees;
for (var nominee in this.data.nominees) { for (var nominee in this.data.nominees) {
if (y[nominee].position_id == this.position_id || this.position_id == 0) { if (y[nominee].position_id == this.position_id || this.position_id == 0) {
var x = y[nominee]; var x = Object.assign({}, y[nominee]);
x.position = this.getPosition(y[nominee].position_id); x.position = this.getPosition(y[nominee].position_id);
x.partylist = this.getPartylist(y[nominee].partylist_id); x.partylist = this.getPartylist(y[nominee].partylist_id);
nominees.push(x); nominees.push(x);
@@ -191,9 +227,28 @@ export default {
position_id: function () { position_id: function () {
return this.$route.query.position_id ? this.$route.query.position_id : 0; return this.$route.query.position_id ? this.$route.query.position_id : 0;
} }
},
watch: {
position_id: function () {
this.refreshNominee();
}
} }
} }
</script> </script>
<style></style> <style>
.nominee-filter-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.nominee-chip {
border-radius: 999px;
padding: 6px 10px;
line-height: 1.1;
}
</style>
@@ -13,34 +13,19 @@
</button> </button>
</div> </div>
<div class="table-responsive"> <admin-data-table :headers="positionTableHeaders" :items="data.positions" :loading="positionLoading"
<table class="table table-hover" id="position_table"> :items-per-page="10" :show-pagination="true" empty-text="Tiada jawatan" :exportable="true"
<thead> export-file-name="jawatan">
<tr> <template v-slot:item-actions="{ item }">
<th>ID</th> <button type="button" class="btn btn-info" @click="edit(item)">
<th>Nama</th> <i class="fa fa-edit"></i> Edit
<th>Tindakan</th> </button>
</tr> <button type="button" class="btn btn-danger"
</thead> @click="util.showModal('#delete-position-modal'); id = item.id">
<tbody> <i class="fa fa-trash"></i> Padam
<tr v-for="(position, i) in data.positions"> </button>
<td>{{ position.id }}</td> </template>
<td>{{ position.name }}</td> </admin-data-table>
<td>
<button class="btn btn-info" @click="edit(i)">
<i class="fa fa-edit"></i> Edit
</button>
<button class="btn btn-danger" @click="util.showModal('#delete-position-modal'); id = position.id">
<i class="fa fa-trash"></i> Padam
</button>
</td>
</tr>
<tr v-if="data.positions.length < 1">
<td colspan="3">No Jawatan</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
</div> </div>
@@ -62,7 +47,12 @@
export default { export default {
data: function () { data: function () {
return { return {
id: 0 id: 0,
positionLoading: false,
positionTableHeaders: [
{ title: 'Nama', key: 'name', sortable: true },
{ title: 'Tindakan', key: 'actions', sortable: false }
]
} }
}, },
@@ -76,10 +66,10 @@ export default {
methods: { methods: {
refreshPosition: function () { refreshPosition: function () {
var vm = this; var vm = this;
this.positionLoading = true;
this.util.notify('Refreshing Position', 'loading'); this.util.notify('Refreshing Position', 'loading');
axios.get(config.API + 'position') axios.get(config.API + 'position')
.then(response => { .then(response => {
console.log(response)
$.notifyClose(); $.notifyClose();
vm.data.positions = response.data; vm.data.positions = response.data;
}) })
@@ -87,22 +77,14 @@ export default {
$.notifyClose(); $.notifyClose();
vm.util.showResult(error); vm.util.showResult(error);
}) })
.finally(function () {
vm.positionLoading = false;
})
}, },
initDatatable: function () { edit: function (position) {
var vm = this; var vm = this;
$('#position_table').DataTable({ this.data.position = position;
destroy: true,
searching: false,
info: false,
autoWidth: false,
dom: 'Bfrtip'
});
},
edit: function (i) {
var vm = this;
this.data.position = this.data.positions[i];
this.$router.push({ name: 'Edit Position', params: { id: vm.data.position.id } }) this.$router.push({ name: 'Edit Position', params: { id: vm.data.position.id } })
}, },
@@ -1,95 +1,468 @@
<template> <template>
<div class="panel panel-default"> <div class="panel panel-default voter-add-page">
<div class="panel-body"> <div class="panel-body">
<h4>Tambah Pengundi</h4> <div class="voter-add-page__header clearfix">
<form @submit.prevent="add()" id="add-form"> <h4 class="pull-left">Tambah Pengundi</h4>
<div class="form-group"> <div class="pull-right voter-add-page__header-actions">
<label for="name">Nama Anggota</label> <button type="button" class="btn btn-success" @click="openManualModal">
<input type="text" name="name" class="form-control" required> <i class="fa fa-user-plus"></i> Daftar pengundi baru
</button>
<router-link :to="{ name: 'Manage Voter' }" class="btn btn-default">
<i class="fa fa-arrow-left"></i> Kembali
</router-link>
</div>
</div> </div>
<div class="form-group"> <p class="text-muted small voter-add-page__intro">
<label for="no_kp">No. Kad Pengenalan</label> Kiri: keseluruhan pengundi (ikut No. KP, data terkini). Kanan: yang ditanda layak untuk pilihan raya
<input type="text" name="no_kp" class="form-control" required> semasa.
Yang tidak berada di senarai kanan akan dipadam daripada pilihan raya ini apabila anda simpan.
</p>
<div class="voter-add-split row">
<div class="col-md-6 voter-add-split__col">
<div class="panel panel-default voter-add-split__panel">
<div class="panel-heading clearfix">
<strong class="pull-left">Semua pengundi</strong>
<span v-if="catalogLoading" class="text-muted small pull-left voter-add-heading__loading">
<i class="fa fa-spinner fa-spin"></i>
</span>
<span class="badge pull-right" title="Jumlah pengundi unik (No. KP)">{{ catalog.length
}}</span>
<span v-if="catalogFilter"
class="text-muted small pull-right voter-add-heading__filter-hint">
{{ filteredCatalog.length }} dipaparkan ·
</span>
</div>
<div class="panel-body">
<div class="form-inline voter-batch-toolbar">
<input type="text" class="form-control" v-model.trim="catalogFilter"
placeholder="Tapis nama / No. KP / No. anggota…" />
<button type="button" class="btn btn-default btn-sm" @click="selectAllFiltered">Tanda
semua (ditapis)</button>
<button type="button" class="btn btn-default btn-sm" @click="clearAllFiltered">Nyahtanda
(ditapis)</button>
</div>
<div v-if="catalogError" class="alert alert-danger">{{ catalogError }}</div>
<div class="table-responsive voter-add-table-wrap">
<table class="table table-bordered table-condensed table-striped"
v-if="!catalogLoading || catalog.length">
<thead>
<tr>
<th style="width:44px;">
<input type="checkbox" :checked="headerChecked"
@change="toggleHeader($event)" />
</th>
<th>Nama</th>
<th>No. KP</th>
<th>No. Anggota</th>
<th>Unit</th>
<th style="width:88px;">PR semasa</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filteredCatalog" :key="row.no_kp">
<td>
<input type="checkbox" :checked="isSelected(row.no_kp)"
@change="toggleRow(row.no_kp, $event)" />
</td>
<td>{{ row.name }}</td>
<td>{{ row.no_kp }}</td>
<td>{{ row.no_anggota }}</td>
<td>{{ row.unit }}</td>
<td>
<span v-if="row.in_current_election"
class="label label-success">Ya</span>
<span v-else class="label label-default"></span>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="!catalogLoading && !catalog.length" class="text-muted">Tiada rekod pengundi dalam
pangkalan data.</p>
</div>
</div>
</div>
<div class="col-md-6 voter-add-split__col">
<div class="panel panel-info voter-add-split__panel">
<div class="panel-heading clearfix">
<strong class="pull-left">Layak mengundi (pilihan raya semasa)</strong>
<span class="badge pull-right" title="Jumlah ditanda layak">{{ selectedNoKp.length }}</span>
<span v-if="selectedFilter"
class="text-muted small pull-right voter-add-heading__filter-hint">
{{ filteredSelectedRows.length }} dipaparkan ·
</span>
</div>
<div class="panel-body">
<div class="form-inline voter-batch-toolbar voter-batch-toolbar--right">
<input type="text" class="form-control" v-model.trim="selectedFilter"
placeholder="Tapis senarai dipilih…" />
<button type="button" class="btn btn-default btn-sm" @click="clearAllSelected"
:disabled="!selectedNoKp.length">
Kosongkan semua
</button>
<button type="button" class="btn btn-primary"
:disabled="catalogSaving || catalogLoading" @click="saveApplicable()">
Simpan pilihan
</button>
</div>
<div class="table-responsive voter-add-table-wrap voter-add-table-wrap--selected">
<table class="table table-bordered table-condensed table-striped"
v-if="filteredSelectedRows.length">
<thead>
<tr>
<th>Nama</th>
<th>No. KP</th>
<th>No. Anggota</th>
<th style="width:52px;"></th>
</tr>
</thead>
<tbody>
<tr v-for="row in filteredSelectedRows" :key="row.no_kp">
<td>{{ row.name }}</td>
<td>{{ row.no_kp }}</td>
<td>{{ row.no_anggota }}</td>
<td class="text-center">
<button type="button" class="btn btn-xs btn-danger" title="Buang"
@click="removeSelected(row.no_kp)">
<i class="fa fa-times"></i>
</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="text-muted voter-add-empty-selected">Tiada pengundi ditanda. Tandakan
pada jadual kiri.</p>
</div>
</div>
</div>
</div>
</div> </div>
<div class="form-group"> <!-- Modal for adding new voter -->
<label for="no_anggota">No. Anggota</label> <modal id="add-voter-manual-modal">
<input type="text" name="no_anggota" class="form-control" required> <modal-header>Daftar pengundi baru</modal-header>
</div> <modal-body>
<form @submit.prevent="add()" id="add-form">
<div class="form-group">
<div class="form-group"> <label for="add-form-name">Nama Anggota</label>
<label for="unit">Unit</label> <input id="add-form-name" type="text" name="name" class="form-control" required>
<input type="text" name="unit" class="form-control" required> </div>
</div> <div class="form-group">
<label for="add-form-no_kp">No. Kad Pengenalan</label>
<div class="form-group"> <input id="add-form-no_kp" type="text" name="no_kp" class="form-control" required>
<label for="alamat">Alamat</label> </div>
<input type="text" name="alamat" class="form-control" required> <div class="form-group">
</div> <label for="add-form-no_anggota">No. Anggota</label>
<input id="add-form-no_anggota" type="text" name="no_anggota" class="form-control" required>
<div class="form-group"> </div>
<label for="status_anggota">Status Anggota</label> <div class="form-group">
<select name="status_anggota" class="form-control" required> <label for="add-form-unit">Unit</label>
<option value="inactive">-- Pilih Status --</option> <input id="add-form-unit" type="text" name="unit" class="form-control" required>
<option value="active">Aktif</option> </div>
<option value="inactive">Berhenti</option> <div class="form-group">
<label for="add-form-alamat">Alamat</label>
</select> <input id="add-form-alamat" type="text" name="alamat" class="form-control" required>
</div> </div>
<div class="form-group">
<div class="form-group"> <label for="add-form-status_anggota">Status Anggota</label>
<label for="telefon">No. Telefon</label> <select id="add-form-status_anggota" name="status_anggota" class="form-control" required>
<input type="text" name="telefon" class="form-control" required> <option value="inactive">-- Pilih Status --</option>
</div> <option value="active">Aktif</option>
<option value="inactive">Berhenti</option>
<div class="form-group"> </select>
<label for="saham">Saham</label> </div>
<input type="text" name="saham" class="form-control" required> <div class="form-group">
</div> <label for="add-form-telefon">No. Telefon</label>
<input id="add-form-telefon" type="text" name="telefon" class="form-control" required>
<div class="form-group"> </div>
<label for="yuran">Yuran</label> <div class="form-group">
<input type="text" name="yuran" class="form-control" required> <label for="add-form-saham">Saham</label>
</div> <input id="add-form-saham" type="text" name="saham" class="form-control" required>
</div>
<div class="form-group"> <div class="form-group">
<button type="submit" class="btn btn-success">Submit</button> <label for="add-form-yuran">Yuran</label>
<router-link :to="{name: 'Manage Voter'}" class="btn btn-default">Back</router-link> <input id="add-form-yuran" type="text" name="yuran" class="form-control" required>
</div> </div>
</form> </form>
</modal-body>
<modal-footer>
<button type="button" class="btn btn-success" :disabled="loading" @click="add()">Hantar</button>
<button type="button" class="btn btn-default" @click="closeManualModal">Batal</button>
</modal-footer>
</modal>
</div>
</div> </div>
</div>
</template> </template>
<style>
.voter-add-page__header {
margin-bottom: 12px;
}
.voter-add-page__header h4 {
margin-top: 0;
}
.voter-add-page__header-actions .btn {
margin-left: 6px;
}
.voter-add-page__intro {
margin-bottom: 16px;
}
.voter-add-split__col {
margin-bottom: 16px;
}
.voter-add-split__panel {
margin-bottom: 0;
}
.voter-batch-toolbar .form-control {
min-width: 180px;
margin-right: 8px;
margin-bottom: 8px;
}
.voter-batch-toolbar .btn {
margin-right: 6px;
margin-bottom: 8px;
}
.voter-add-table-wrap {
max-height: min(520px, calc(100vh - 280px));
overflow: auto;
margin-top: 10px;
}
.voter-add-table-wrap--selected {
min-height: 120px;
}
.voter-add-empty-selected {
margin: 16px 0 0;
}
.voter-add-heading__loading {
margin-left: 8px;
margin-top: 3px;
}
.voter-add-heading__filter-hint {
margin-right: 8px;
margin-top: 4px;
}
</style>
<script> <script>
export default{ export default {
data: function () { data: function () {
return { return {
loading: false loading: false,
catalog: [],
catalogLoading: false,
catalogSaving: false,
catalogError: '',
catalogFilter: '',
selectedFilter: '',
selectedNoKp: []
} }
}, },
computed: {
catalogByKp: function () {
var map = {};
var i;
for (i = 0; i < this.catalog.length; i++) {
map[this.catalog[i].no_kp] = this.catalog[i];
}
return map;
},
selectedRows: function () {
var map = this.catalogByKp;
var out = [];
var i;
for (i = 0; i < this.selectedNoKp.length; i++) {
var kp = this.selectedNoKp[i];
if (map[kp]) out.push(map[kp]);
}
return out;
},
filteredCatalog: function () {
var q = (this.catalogFilter || '').toLowerCase();
if (!q) return this.catalog;
return this.catalog.filter(function (row) {
return (row.name && String(row.name).toLowerCase().indexOf(q) !== -1)
|| (row.no_kp && String(row.no_kp).toLowerCase().indexOf(q) !== -1)
|| (row.no_anggota && String(row.no_anggota).toLowerCase().indexOf(q) !== -1)
|| (row.unit && String(row.unit).toLowerCase().indexOf(q) !== -1);
});
},
filteredSelectedRows: function () {
var rows = this.selectedRows;
var q = (this.selectedFilter || '').toLowerCase();
if (!q) return rows;
return rows.filter(function (row) {
return (row.name && String(row.name).toLowerCase().indexOf(q) !== -1)
|| (row.no_kp && String(row.no_kp).toLowerCase().indexOf(q) !== -1)
|| (row.no_anggota && String(row.no_anggota).toLowerCase().indexOf(q) !== -1)
|| (row.unit && String(row.unit).toLowerCase().indexOf(q) !== -1);
});
},
headerChecked: function () {
var rows = this.filteredCatalog;
if (!rows.length) return false;
var vm = this;
return rows.every(function (r) { return vm.isSelected(r.no_kp); });
}
},
created: function () {
this.loadMemberCatalog();
},
methods: { methods: {
openManualModal: function () {
this.util.showModal('#add-voter-manual-modal');
},
closeManualModal: function () {
this.util.hideModal('#add-voter-manual-modal');
},
resetManualForm: function () {
var el = document.getElementById('add-form');
if (el) el.reset();
},
loadMemberCatalog: function () {
var vm = this;
this.catalogLoading = true;
this.catalogError = '';
axios.get(config.API + 'voter/member-catalog')
.then(function (res) {
vm.catalog = (res.data && res.data.catalog) ? res.data.catalog : [];
vm.selectedNoKp = vm.catalog.filter(function (r) { return r.in_current_election; }).map(function (r) { return r.no_kp; });
})
.catch(function (err) {
vm.catalogError = (err.response && err.response.data && err.response.data.message)
? err.response.data.message
: 'Gagal memuatkan senarai.';
vm.util.showResult(err);
})
.finally(function () {
vm.catalogLoading = false;
});
},
isSelected: function (noKp) {
return this.selectedNoKp.indexOf(noKp) !== -1;
},
toggleRow: function (noKp, evt) {
var arr = this.selectedNoKp.slice();
var i = arr.indexOf(noKp);
if (evt.target.checked) {
if (i === -1) arr.push(noKp);
} else {
if (i !== -1) arr.splice(i, 1);
}
this.selectedNoKp = arr;
},
toggleHeader: function (evt) {
var want = evt.target.checked;
var keys = this.filteredCatalog.map(function (r) { return r.no_kp; });
var set = {};
var i;
for (i = 0; i < this.selectedNoKp.length; i++) {
set[this.selectedNoKp[i]] = true;
}
if (want) {
for (i = 0; i < keys.length; i++) set[keys[i]] = true;
} else {
for (i = 0; i < keys.length; i++) delete set[keys[i]];
}
this.selectedNoKp = Object.keys(set);
},
selectAllFiltered: function () {
var set = {};
var i;
for (i = 0; i < this.selectedNoKp.length; i++) {
set[this.selectedNoKp[i]] = true;
}
var rows = this.filteredCatalog;
for (i = 0; i < rows.length; i++) set[rows[i].no_kp] = true;
this.selectedNoKp = Object.keys(set);
},
clearAllFiltered: function () {
var remove = {};
var i;
var rows = this.filteredCatalog;
for (i = 0; i < rows.length; i++) remove[rows[i].no_kp] = true;
this.selectedNoKp = this.selectedNoKp.filter(function (kp) { return !remove[kp]; });
},
removeSelected: function (noKp) {
this.selectedNoKp = this.selectedNoKp.filter(function (kp) { return kp !== noKp; });
},
clearAllSelected: function () {
this.selectedNoKp = [];
},
saveApplicable: function () {
if (this.catalogSaving) return;
var vm = this;
this.catalogSaving = true;
this.util.notify('Menyimpan…', 'loading');
axios.post(config.API + 'voter/sync-applicable', { selected_no_kp: this.selectedNoKp })
.then(function (res) {
$.notifyClose();
if (vm.util.showResult(res, 'success')) {
vm.$router.push({ name: 'Manage Voter' });
}
})
.catch(function (err) {
$.notifyClose();
vm.util.showResult(err);
})
.finally(function () {
vm.catalogSaving = false;
});
},
add: function () { add: function () {
if (this.loading) return; if (this.loading) return;
this.loading = true; this.loading = true;
var vm = this; var vm = this;
this.util.notify('Adding Voter', 'loading') this.util.notify('Adding Voter', 'loading');
axios.post(config.API+'voter', $('#add-form').serialize()) axios.post(config.API + 'voter', $('#add-form').serialize())
.then(response=>{ .then(function (response) {
$.notifyClose(); $.notifyClose();
vm.loading = false; vm.loading = false;
if (vm.util.showResult(response, 'success')) { if (vm.util.showResult(response, 'success')) {
vm.$router.push({name:'Manage Voter'}); vm.closeManualModal();
vm.resetManualForm();
vm.$router.push({ name: 'Manage Voter' });
} }
}) })
.catch(error=>{ .catch(function (error) {
$.notifyClose(); $.notifyClose();
vm.loading = false; vm.loading = false;
vm.util.showResult(error); vm.util.showResult(error);
}) });
} }
} }
} }
</script> </script>
@@ -15,51 +15,36 @@
<i class="fa fa-list"></i> Database Anggota <i class="fa fa-list"></i> Database Anggota
</router-link> </router-link>
</div> </div>
<div class="table-responsive">
<table class="table table-hover" id="position_table"> <div class="kehadiran-search">
<thead> <div class="kehadiran-search__label">Carian pantas:</div>
<tr> <input class="form-control kehadiran-search__input kehadiran-search__input--name"
<th>Bil.</th> v-model.trim="searchName" placeholder="Nama Anggota" />
<th>Nama Anggota</th> <input class="form-control kehadiran-search__input" v-model.trim="searchNoAnggota"
<th>No. Kad Pengenalan</th> placeholder="No. Anggota" />
<th>No. Anggota</th> <input class="form-control kehadiran-search__input" v-model.trim="searchNoKp"
<th>Unit</th> placeholder="No. Kad Pengenalan" />
<th>Saham</th> <button class="btn btn-primary" @click="applySearch()">Cari</button>
<th>Yuran</th> <button class="btn btn-default" @click="clearSearch()" :disabled="!hasAnySearch">Reset</button>
<th>Tindakan</th>
</tr>
</thead>
<tbody>
<tr v-for="(voter, i) in data.voters.data">
<td>{{ i + 1 }}</td>
<td>{{ voter.name }}</td>
<td>{{ voter.no_kp }}</td>
<td>{{ voter.no_anggota }}</td>
<td>{{ voter.unit }}</td>
<td>{{ voter.saham }}</td>
<td>{{ voter.yuran }}</td>
<td>
<button class="btn btn-info" @click="edit(i)">
<i class="fa fa-edit"></i> Set semula
</button>
<button class="btn btn-danger"
@click="util.showModal('#delete-voter-modal'); id = voter.id">
<i class="fa fa-trash"></i> Padam
</button>
</td>
</tr>
<tr v-if="data.voters.data && data.voters.data.length < 1">
<td colspan="3">No Voters</td>
</tr>
</tbody>
</table>
</div> </div>
<admin-data-table :headers="voterTableHeaders" :items="voterListItems" :loading="voterLoading"
:show-pagination="true" index-title="Bil." empty-text="Tiada pengundi" :exportable="true">
<template v-slot:item-actions="{ item }">
<button type="button" class="btn btn-info" @click="edit(item)">
<i class="fa fa-edit"></i> Kemaskini
</button>
<button type="button" class="btn btn-danger"
@click="util.showModal('#delete-voter-modal'); id = item.id">
<i class="fa fa-trash"></i> Padam
</button>
</template>
</admin-data-table>
<ul class="pagination" v-if="pages.length > 1"> <ul class="pagination" v-if="pages.length > 1">
<router-link tag="li" v-for="page in pages" :key="page['pages']" <router-link tag="li" v-for="page in pages" :key="page.page" :to="{ query: { page: page.page } }"
:to="{ query: { page: page['page'] } }" :class="{ 'active': current_page == page['page'] }" :class="{ active: Number(current_page) === page.page }" exact>
exact> <a href="#">{{ page.page }}</a>
<a href="#">{{ page['page'] }}</a>
</router-link> </router-link>
</ul> </ul>
@@ -80,11 +65,82 @@
</div> </div>
</template> </template>
<style>
.kehadiran-search {
margin: 10px 0 12px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.kehadiran-search__label {
color: #6b7280;
font-size: 12px;
font-weight: 700;
}
.kehadiran-search__input {
max-width: 220px;
height: 34px;
}
.kehadiran-search__input--name {
max-width: 260px;
}
</style>
<script> <script>
export default { export default {
data: function () { data: function () {
return { return {
id: 0 id: 0,
voterLoading: false,
searchName: '',
searchNoAnggota: '',
searchNoKp: '',
voterTableHeaders: [
{ title: 'Nama Anggota', key: 'name', sortable: true },
{ title: 'No. Kad Pengenalan', key: 'no_kp', sortable: true },
{ title: 'No. Anggota', key: 'no_anggota', sortable: true },
{ title: 'Unit', key: 'unit', sortable: true },
{ title: 'Saham', key: 'saham', sortable: true },
{ title: 'Yuran', key: 'yuran', sortable: true },
{ title: 'Tindakan', key: 'actions', sortable: false }
]
}
},
computed: {
hasAnySearch: function () {
return Boolean(
(this.searchName && this.searchName.length)
|| (this.searchNoAnggota && this.searchNoAnggota.length)
|| (this.searchNoKp && this.searchNoKp.length)
);
},
voterListItems: function () {
var v = this.data.voters;
if (v && Array.isArray(v.data)) {
return v.data;
}
return [];
},
pages: function () {
var pages = [];
var last = this.data.voters && this.data.voters.last_page;
if (last) {
for (var i = 1; i <= last; i++) {
pages.push({ page: i });
}
}
return pages;
},
current_page: function () {
return this.$route.query.page ? this.$route.query.page : 1;
} }
}, },
@@ -92,17 +148,45 @@ export default {
this.refreshVoter(); this.refreshVoter();
}, },
watch: {
'$route.query.page': function () {
$.notifyClose();
this.refreshVoter();
}
},
methods: { methods: {
search: function () { search: function () {
}, },
applySearch: function () {
var q = Object.assign({}, this.$route.query);
q.page = 1;
this.$router.replace({ query: q });
this.refreshVoter();
},
clearSearch: function () {
this.searchName = '';
this.searchNoAnggota = '';
this.searchNoKp = '';
this.applySearch();
},
refreshVoter: function () { refreshVoter: function () {
var vm = this; var vm = this;
this.voterLoading = true;
this.util.notify('Refreshing Voter', 'loading'); this.util.notify('Refreshing Voter', 'loading');
axios.get(config.API + 'voter?page=' + this.current_page) axios.get(config.API + 'voter', {
params: {
page: this.current_page,
name: this.searchName || undefined,
no_anggota: this.searchNoAnggota || undefined,
no_kp: this.searchNoKp || undefined
}
})
.then(response => { .then(response => {
console.log(response)
$.notifyClose(); $.notifyClose();
vm.data.voters = response.data; vm.data.voters = response.data;
}) })
@@ -110,11 +194,14 @@ export default {
$.notifyClose(); $.notifyClose();
vm.util.showResult(error); vm.util.showResult(error);
}) })
.finally(function () {
vm.voterLoading = false;
})
}, },
edit: function (i) { edit: function (voter) {
var vm = this; var vm = this;
this.data.voter = this.data.voters.data[i]; this.data.voter = voter;
this.$router.push({ name: 'Edit Voter', params: { id: vm.data.voter.id } }) this.$router.push({ name: 'Edit Voter', params: { id: vm.data.voter.id } })
}, },
@@ -132,30 +219,6 @@ export default {
vm.util.showResult(error); vm.util.showResult(error);
}) })
} }
},
watch: {
'$route.query.page': function () {
$.notifyClose();
this.refreshVoter();
}
},
computed: {
pages: function () {
var pages = [];
if (this.data.voters.last_page)
for (var i = 1; i <= this.data.voters.last_page; i++) {
let x = {};
x['page'] = i;
pages.push(x);
}
return pages;
},
current_page: function () {
return this.$route.query.page ? this.$route.query.page : 1;
}
} }
} }
</script> </script>
@@ -1,52 +1,292 @@
<template> <template>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-body"> <div class="panel-body">
<button class="btn btn-success" @click="refreshVoter()"> <div class="kehadiran-actions clearfix">
<i class="fa fa-refresh"></i> Kemaskini Pengundi <button class="btn btn-success" @click="refreshVoter()">
</button> <i class="fa fa-refresh"></i> Kemaskini Pengundi
</button>
<router-link class="btn btn-info" :to="{ name: 'Fizikal' }"> <a class="btn btn-warning pull-right" :href="getPDFKehadiranurl(1)">
Fizikal <i class="fa fa-download"></i> Muat Turun PDF
</router-link> </a>
</div>
<router-link class="btn btn-info" :to="{ name: 'Maya' }"> <div class="kehadiran-search">
Maya <div class="kehadiran-search__label">Carian pantas (kaunter):</div>
</router-link> <input class="form-control kehadiran-search__input" v-model.trim="searchNoAnggota"
placeholder="No. Anggota" />
<input class="form-control kehadiran-search__input" v-model.trim="searchNoKp"
placeholder="No. Kad Pengenalan" />
<button class="btn btn-primary" @click="applySearch()">Cari</button>
<button class="btn btn-default" @click="clearSearch()" :disabled="!hasAnySearch">Reset</button>
</div>
<div class="kehadiran-filters">
<span class="kehadiran-filters__label">Penapis:</span>
<button type="button" class="kehadiran-chip" :class="{ 'is-active': !current_kehadiran }"
@click="setKehadiranFilter(null)">
Semua
</button>
<button type="button" class="kehadiran-chip"
:class="{ 'is-active': String(current_kehadiran) === 'unset' }"
@click="setKehadiranFilter('unset')">
Belum Ditetapkan
</button>
<button type="button" class="kehadiran-chip" :class="{ 'is-active': Number(current_kehadiran) === 1 }"
@click="setKehadiranFilter(1)">
Fizikal
</button>
<button type="button" class="kehadiran-chip" :class="{ 'is-active': Number(current_kehadiran) === 2 }"
@click="setKehadiranFilter(2)">
Maya
</button>
</div>
<a class="pdf-button-container btn btn-warning" :href="getPDFKehadiranurl(1)"><i class="fa fa-download"></i> Muat Turun PDF</a> <div class="kehadiran-filters kehadiran-filters--secondary">
<span class="kehadiran-filters__label">Pendaftaran fizikal:</span>
<button type="button" class="kehadiran-chip" :class="{ 'is-active': !current_fizikal_reg }"
@click="setFizikalRegFilter(null)">
Semua
</button>
<button type="button" class="kehadiran-chip" :class="{ 'is-active': current_fizikal_reg === 'pending' }"
@click="setFizikalRegFilter('pending')">
Menunggu pengesahan
</button>
<button type="button" class="kehadiran-chip"
:class="{ 'is-active': current_fizikal_reg === 'verified' }"
@click="setFizikalRegFilter('verified')">
Disahkan
</button>
</div>
<!-- <a class="btn btn-warning" :href="getPDFKehadiranurl(1)">Muat Turun PDF</a> --> <!-- <a class="btn btn-warning" :href="getPDFKehadiranurl(1)">Muat Turun PDF</a> -->
<div class="table-responsive"> <admin-data-table :headers="kehadiranTableHeaders" :items="voterListItems" :loading="voterLoading"
<table class="table table-hover"> :show-pagination="true" index-title="Bil." empty-text="Tiada rekod kehadiran" :exportable="true">
<thead> <template v-slot:item-kehadiran="{ item }">
<tr> {{ getKehadiranString(item.kehadiran) }}
<th>Bil.</th> </template>
<th>Nama Anggota</th> <template v-slot:item-fizikal_reg="{ item }">
<th>No. Kad Pengenalan</th> <span v-if="Number(item.kehadiran) !== 1" class="text-muted"></span>
<th>No. Anggota</th> <span v-else-if="item.fizikal_registration_verified_at"
<th>Unit</th> class="kehadiran-pill kehadiran-pill--success">
<th>Kehadiran</th> <i class="fa fa-check-circle" aria-hidden="true"></i>
<th>Tarikh/Masa</th> <span>Disahkan</span>
</tr> </span>
</thead> <span v-else class="kehadiran-pill kehadiran-pill--warn">
<tbody> <i class="fa fa-clock-o" aria-hidden="true"></i>
<!-- Display attendees --> <span>Menunggu</span>
<template v-for="(voter, index) in data.voters.data"> </span>
<tr> </template>
<td>{{ index + 1 }}</td> <!-- Display the serial number --> <template v-slot:item-has_voted="{ item }">
<td>{{ voter.name }}</td> <span class="kehadiran-pill"
<td>{{ voter.no_kp }}</td> :class="Number(item.votes_count) > 0 ? 'kehadiran-pill--success' : 'kehadiran-pill--muted'">
<td>{{ voter.no_anggota }}</td> <i class="fa" :class="Number(item.votes_count) > 0 ? 'fa-check-circle' : 'fa-times-circle'"
<td>{{ voter.unit }}</td> aria-hidden="true"></i>
<td>{{ getKehadiranString(voter.kehadiran) }}</td> <span>{{ Number(item.votes_count) > 0 ? 'Undi' : 'Belum' }}</span>
<td>{{ voter.updated_at }}</td> </span>
</tr> </template>
</template> <template v-slot:item-allowance="{ item }">
</tbody> <span class="kehadiran-pill"
</table> :class="isCashPaid(item) ? 'kehadiran-pill--success' : 'kehadiran-pill--warn'">
<i class="fa" :class="isCashPaid(item) ? 'fa-money' : 'fa-exclamation-circle'"
aria-hidden="true"></i>
<span>{{ isCashPaid(item) ? 'Sudah Bayar' : 'Belum Bayar' }}</span>
</span>
</template>
<template v-slot:item-action="{ item }">
<div class="kehadiran-action-cell">
<button type="button" class="btn btn-xs btn-primary kehadiran-action-btn"
@click.stop="openDetail(item)">
<i class="fa fa-eye" aria-hidden="true"></i>
<span>Detail</span>
</button>
<button v-if="canVerifyFizikalRegistration(item)" type="button"
class="btn btn-xs btn-success kehadiran-action-btn"
@click.stop="openFizikalVerifyModal(item)">
<i class="fa fa-check" aria-hidden="true"></i>
<span>Sahkan</span>
</button>
</div>
</template>
</admin-data-table>
<!-- Allowance Modal -->
<div class="modal fade app-modal" id="allowance-modal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Butiran Pembayaran Elaun</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" v-if="selectedVoter">
<div class="row">
<div class="col-md-6">
<table class="table table-condensed">
<tbody>
<tr>
<th style="width: 160px;">Nama</th>
<td>{{ selectedVoter.name }}</td>
</tr>
<tr>
<th>No. KP</th>
<td>{{ selectedVoter.no_kp }}</td>
</tr>
<tr>
<th>No. Anggota</th>
<td>{{ selectedVoter.no_anggota }}</td>
</tr>
<tr>
<th>Unit</th>
<td>{{ selectedVoter.unit }}</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-6">
<table class="table table-condensed">
<tbody>
<tr>
<th style="width: 160px;">Kehadiran</th>
<td><b>{{ getKehadiranString(selectedVoter.kehadiran) }}</b></td>
</tr>
<tr v-if="Number(selectedVoter.kehadiran) === 1">
<th>Pendaftaran</th>
<td>
<span v-if="selectedVoter.fizikal_registration_verified_at"
class="label label-success">Disahkan</span>
<span v-else class="label label-warning">Menunggu pengesahan</span>
</td>
</tr>
<tr>
<th>Status Undi</th>
<td>
<span class="label"
:class="Number(selectedVoter.votes_count) > 0 ? 'label-success' : 'label-default'">
{{ Number(selectedVoter.votes_count) > 0 ? 'Ya' : 'Tidak' }}
</span>
</td>
</tr>
<tr>
<th>Bayaran Tunai</th>
<td>
<span class="label"
:class="isCashPaid(selectedVoter) ? 'label-success' : 'label-warning'">
{{ isCashPaid(selectedVoter) ? ('Sudah (' +
selectedVoter.cash_paid_at +
')') : 'Belum' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<hr />
<div class="row">
<div class="col-md-4">
<label>Kod Tuntutan (Claim Code)</label>
<input type="text" class="form-control" v-model.trim="claimCodeInput"
placeholder="Contoh: 482913" />
<small class="text-muted">Voter akan tunjuk kod ini. Wajib untuk bayaran
tunai.</small>
</div>
<div class="col-md-4">
<label>Jumlah Tunai (RM)</label>
<input type="text" class="form-control kehadiran-tunai-readonly" readonly disabled
:value="allowanceTunaiDisplay" />
</div>
<div class="col-md-4">
<label>Rujukan (optional)</label>
<input type="text" class="form-control" v-model.trim="payoutReference"
maxlength="80" />
</div>
</div>
<div class="row" style="margin-top: 10px;">
<div class="col-md-12">
<label>Nota (optional)</label>
<input type="text" class="form-control" v-model.trim="payoutNote" />
</div>
</div>
<div class="alert alert-danger" v-if="detailError" style="margin-top: 12px;">
{{ detailError }}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Tutup</button>
<button type="button" class="btn btn-danger"
v-if="selectedVoter && isCashPaid(selectedVoter)" :disabled="detailLoading"
@click="voidCashPayout()">
Batal Bayaran Tunai
</button>
<button type="button" class="btn btn-success"
v-if="selectedVoter && canPayCash(selectedVoter)" :disabled="detailLoading"
@click="payCash()">
Bayar Tunai
</button>
</div>
</div>
</div>
</div>
<!-- Fizikal Verify Modal -->
<div class="modal fade app-modal" id="fizikal-verify-modal" tabindex="-1" role="dialog"
aria-labelledby="fizikal-verify-title" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="fizikal-verify-title">Pengesahan pendaftaran fizikal</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" v-if="verifyModalVoter">
<p class="kehadiran-verify-lead">
Sahkan bahawa pengundi ini hadir di kaunter. Selepas ini, pengundi boleh mengundi.
</p>
<table class="table table-condensed table-bordered kehadiran-verify-summary">
<tbody>
<tr>
<th style="width: 140px;">Nama</th>
<td>{{ verifyModalVoter.name }}</td>
</tr>
<tr>
<th>No. KP</th>
<td>{{ verifyModalVoter.no_kp }}</td>
</tr>
<tr>
<th>No. Anggota</th>
<td>{{ verifyModalVoter.no_anggota }}</td>
</tr>
<tr>
<th>Unit</th>
<td>{{ verifyModalVoter.unit }}</td>
</tr>
</tbody>
</table>
<div class="alert alert-danger" v-if="verifyModalError" style="margin-bottom: 0;">
{{ verifyModalError }}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Tutup</button>
<button type="button" class="btn btn-primary" :disabled="verifyModalLoading"
@click="submitFizikalVerify">
<i class="fa fa-check" aria-hidden="true"></i> Sahkan pendaftaran
</button>
</div>
</div>
</div>
</div> </div>
<!-- Display attendance percentage --> <!-- Display attendance percentage -->
@@ -55,9 +295,10 @@
</div> </div>
<ul class="pagination" v-if="pages.length > 1"> <ul class="pagination" v-if="pages.length > 1">
<router-link tag="li" v-for="page in pages" :key="page['pages']" :to="{ query: { page: page['page'] } }" <router-link tag="li" v-for="page in pages" :key="page.page"
:class="{ 'active': current_page == page['page'] }" exact> :to="{ query: paginationQueryForPage(page.page) }"
<a href="#">{{ page['page'] }}</a> :class="{ active: Number(current_page) === page.page }" exact>
<a href="#">{{ page.page }}</a>
</router-link> </router-link>
</ul> </ul>
</div> </div>
@@ -65,17 +306,167 @@
</template> </template>
<style> <style>
.pdf-button-container { .kehadiran-actions {
text-align: right; margin-bottom: 10px;
}
.kehadiran-search {
margin: 10px 0 12px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.kehadiran-search__label {
color: #6b7280;
font-size: 12px;
font-weight: 700;
}
.kehadiran-search__input {
max-width: 220px;
height: 34px;
}
.kehadiran-filters {
margin: 6px 0 14px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.kehadiran-filters--secondary {
margin-top: 0;
}
.kehadiran-filters__label {
color: #6b7280;
font-size: 12px;
font-weight: 600;
margin-right: 4px;
}
.kehadiran-chip {
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.kehadiran-chip:hover {
background: #f3f4f6;
}
.kehadiran-chip.is-active {
background: #1976d2;
border-color: #1976d2;
color: #fff;
}
.kehadiran-action-cell {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.kehadiran-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 700;
}
.kehadiran-action-btn>i {
line-height: 1;
}
.kehadiran-verify-lead {
margin-bottom: 12px;
color: #374151;
line-height: 1.5;
}
.kehadiran-verify-summary {
margin-bottom: 0;
}
.kehadiran-tunai-readonly {
background-color: #f3f4f6 !important;
cursor: not-allowed;
color: #1f2937;
}
.kehadiran-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
line-height: 1.4;
border: 1px solid transparent;
white-space: nowrap;
}
.kehadiran-pill>i {
line-height: 1;
}
.kehadiran-pill--success {
color: #0f5132;
background: #d1e7dd;
border-color: #badbcc;
}
.kehadiran-pill--warn {
color: #664d03;
background: #fff3cd;
border-color: #ffecb5;
}
.kehadiran-pill--muted {
color: #374151;
background: #f3f4f6;
border-color: #e5e7eb;
} }
</style> </style>
<script> <script>
export default { export default {
data: function () { data: function () {
return { return {
id: 0 id: 0,
voterLoading: false,
searchNoAnggota: '',
searchNoKp: '',
selectedVoter: null,
verifyModalVoter: null,
verifyModalLoading: false,
verifyModalError: '',
detailLoading: false,
detailError: '',
claimCodeInput: '',
payoutReference: '',
payoutNote: '',
kehadiranTableHeaders: [
{ title: 'Nama Anggota', key: 'name', sortable: true },
{ title: 'No. Kad Pengenalan', key: 'no_kp', sortable: true },
{ title: 'No. Anggota', key: 'no_anggota', sortable: true },
{ title: 'Unit', key: 'unit', sortable: true },
{ title: 'Kehadiran', key: 'kehadiran', sortable: true },
{ title: 'Pendaftaran', key: 'fizikal_reg', sortable: false },
{ title: 'Undi', key: 'has_voted', sortable: false },
{ title: 'Status', key: 'allowance', sortable: false },
{ title: 'Tindakan', key: 'action', sortable: false },
]
} }
}, },
@@ -90,8 +481,17 @@ export default {
refreshVoter: function () { refreshVoter: function () {
var vm = this; var vm = this;
this.voterLoading = true;
this.util.notify('Refreshing Voter', 'loading'); this.util.notify('Refreshing Voter', 'loading');
axios.get(config.API + 'voter?page=' + this.current_page) axios.get(config.API + 'voter', {
params: {
page: this.current_page,
kehadiran: this.current_kehadiran || undefined,
fizikal_reg: this.current_fizikal_reg || undefined,
no_anggota: this.searchNoAnggota || undefined,
no_kp: this.searchNoKp || undefined
}
})
.then(response => { .then(response => {
console.log(response) console.log(response)
$.notifyClose(); $.notifyClose();
@@ -101,6 +501,94 @@ export default {
$.notifyClose(); $.notifyClose();
vm.util.showResult(error); vm.util.showResult(error);
}) })
.finally(function () {
vm.voterLoading = false;
})
},
applySearch: function () {
// Reset to first page when searching
var q = Object.assign({}, this.$route.query);
q.page = 1;
this.$router.replace({ query: q });
this.refreshVoter();
},
clearSearch: function () {
this.searchNoAnggota = '';
this.searchNoKp = '';
this.applySearch();
},
setKehadiranFilter: function (val) {
var q = Object.assign({}, this.$route.query);
if (val === 1 || val === 2) q.kehadiran = String(val);
else if (val === 'unset') q.kehadiran = 'unset';
else delete q.kehadiran;
q.page = 1;
this.$router.replace({ query: q });
},
setFizikalRegFilter: function (val) {
var q = Object.assign({}, this.$route.query);
if (val === 'pending' || val === 'verified') q.fizikal_reg = val;
else delete q.fizikal_reg;
q.page = 1;
this.$router.replace({ query: q });
},
paginationQueryForPage: function (pageNum) {
var q = { page: pageNum };
if (this.current_kehadiran) q.kehadiran = this.current_kehadiran;
if (this.current_fizikal_reg) q.fizikal_reg = this.current_fizikal_reg;
return q;
},
canVerifyFizikalRegistration: function (voter) {
if (!voter) return false;
return Number(voter.kehadiran) === 1 && !voter.fizikal_registration_verified_at;
},
openFizikalVerifyModal: function (item) {
if (!item || !this.canVerifyFizikalRegistration(item)) return;
this.verifyModalError = '';
this.verifyModalVoter = Object.assign({}, item);
this.util.showModal('#fizikal-verify-modal');
},
submitFizikalVerify: function () {
var vm = this;
if (!this.verifyModalVoter) return;
this.verifyModalError = '';
this.verifyModalLoading = true;
axios.post(config.API + 'voter/' + this.verifyModalVoter.id + '/verify-fizikal-registration')
.then(function (response) {
if (response && response.data && response.data.status && response.data.status !== 'success') {
vm.verifyModalError = response.data.message || 'Permintaan gagal.';
vm.util.notify(vm.verifyModalError, 'error');
return;
}
if (vm.util.showResult(response, 'success')) {
var v = response.data.voter;
if (v && vm.selectedVoter && vm.selectedVoter.id === v.id) {
vm.selectedVoter.fizikal_registration_verified_at = v.fizikal_registration_verified_at;
}
vm.util.hideModal('#fizikal-verify-modal');
vm.verifyModalVoter = null;
vm.refreshVoter();
}
})
.catch(function (error) {
vm.verifyModalError =
(error && error.response && error.response.data && error.response.data.message)
? error.response.data.message
: (error && error.message ? error.message : 'Gagal mengesahkan pendaftaran.');
if (error && error.response) vm.util.showResult(error, 'error');
else vm.util.notify(vm.verifyModalError, 'error');
})
.finally(function () {
vm.verifyModalLoading = false;
});
}, },
getKehadiranString(kehadiran) { getKehadiranString(kehadiran) {
@@ -118,6 +606,116 @@ export default {
getPDFKehadiranurl(Id) { getPDFKehadiranurl(Id) {
// Construct and return the URL based on the item ID // Construct and return the URL based on the item ID
return `/kehadiranpdf/${Id}`; return `/kehadiranpdf/${Id}`;
},
isCashPaid: function (voter) {
return Boolean(voter && voter.cash_paid_at);
},
canPayCash: function (voter) {
if (!voter) return false;
if (this.isCashPaid(voter)) return false;
// Must be fizikal, must have voted
return Number(voter.kehadiran) === 1 && Number(voter.votes_count) > 0;
},
openDetail: function (item) {
this.detailError = '';
this.selectedVoter = Object.assign({}, item);
this.claimCodeInput = '';
this.payoutReference = '';
this.payoutNote = '';
this.util.showModal('#allowance-modal');
},
payCash: function () {
if (!this.selectedVoter) return;
this.detailError = '';
this.detailLoading = true;
let payload = {
voter_id: this.selectedVoter.id,
method: 'cash',
claim_code: this.claimCodeInput || undefined,
reference: this.payoutReference || undefined,
note: this.payoutNote || undefined,
};
var rm = this.allowanceTunaiAmountRM;
if (rm !== null && rm !== undefined && !Number.isNaN(Number(rm))) {
payload.amount_cents = Math.round(Number(rm) * 100);
}
axios.post(config.API + 'allowance/payout', payload)
.then((response) => {
// Some endpoints return 200 with {status:'failed', message:'...'}
if (response && response.data && response.data.status && response.data.status !== 'success') {
this.detailError = response.data.message || 'Permintaan gagal.';
this.util.notify(this.detailError, 'error');
return;
}
if (this.util.showResult(response, 'success')) {
// refresh list so cash_paid_at & cash_payout_id updates
this.refreshVoter();
// keep modal open but reflect paid state (best effort)
if (response && response.data && response.data.payout) {
this.selectedVoter.cash_paid_at = response.data.payout.paid_at;
this.selectedVoter.cash_payout_id = response.data.payout.id;
} else {
this.selectedVoter.cash_paid_at = (new Date()).toISOString();
}
}
})
.catch((error) => {
this.detailError =
(error && error.response && error.response.data && error.response.data.message)
? error.response.data.message
: (error && error.message ? error.message : 'Gagal merekod bayaran.');
// Only pass actual axios error objects into showResult
if (error && error.response) this.util.showResult(error, 'error');
else this.util.notify(this.detailError, 'error');
})
.finally(() => {
this.detailLoading = false;
});
},
voidCashPayout: function () {
if (!this.selectedVoter || !this.selectedVoter.cash_payout_id) {
this.detailError = 'Rekod bayaran tidak dijumpai untuk dibatalkan.';
return;
}
this.detailError = '';
this.detailLoading = true;
axios.post(config.API + 'allowance/void/' + this.selectedVoter.cash_payout_id, {
note: this.payoutNote || undefined
})
.then((response) => {
if (response && response.data && response.data.status && response.data.status !== 'success') {
this.detailError = response.data.message || 'Permintaan gagal.';
this.util.notify(this.detailError, 'error');
return;
}
if (this.util.showResult(response, 'success')) {
this.refreshVoter();
this.selectedVoter.cash_paid_at = null;
this.selectedVoter.cash_payout_id = null;
}
})
.catch((error) => {
this.detailError =
(error && error.response && error.response.data && error.response.data.message)
? error.response.data.message
: (error && error.message ? error.message : 'Gagal membatalkan bayaran.');
if (error && error.response) this.util.showResult(error, 'error');
else this.util.notify(this.detailError, 'error');
})
.finally(() => {
this.detailLoading = false;
});
} }
@@ -127,10 +725,45 @@ export default {
'$route.query.page': function () { '$route.query.page': function () {
$.notifyClose(); $.notifyClose();
this.refreshVoter(); this.refreshVoter();
},
'$route.query.kehadiran': function () {
$.notifyClose();
this.refreshVoter();
},
'$route.query.fizikal_reg': function () {
$.notifyClose();
this.refreshVoter();
} }
}, },
computed: { computed: {
/** RM300 fizikal, RM150 Maya — used for tunai payout amount. */
allowanceTunaiAmountRM: function () {
if (!this.selectedVoter) return null;
var k = Number(this.selectedVoter.kehadiran);
if (k === 1) return 300;
if (k === 2) return 150;
return null;
},
allowanceTunaiDisplay: function () {
var rm = this.allowanceTunaiAmountRM;
if (rm === null || rm === undefined) return '';
return Number(rm).toFixed(2);
},
hasAnySearch: function () {
return Boolean((this.searchNoAnggota && this.searchNoAnggota.length) || (this.searchNoKp && this.searchNoKp.length));
},
voterListItems: function () {
var v = this.data.voters;
if (v && Array.isArray(v.data)) {
return v.data;
}
return [];
},
pages: function () { pages: function () {
var pages = []; var pages = [];
if (this.data.voters.last_page) if (this.data.voters.last_page)
@@ -146,21 +779,29 @@ export default {
return this.$route.query.page ? this.$route.query.page : 1; return this.$route.query.page ? this.$route.query.page : 1;
}, },
current_kehadiran: function () {
return this.$route.query.kehadiran ? this.$route.query.kehadiran : null;
},
current_fizikal_reg: function () {
return this.$route.query.fizikal_reg ? this.$route.query.fizikal_reg : null;
},
// Calculate total attendance percentage // Calculate total attendance percentage
attendancePercentage() { attendancePercentage() {
// Count total members who attended physically (Fizikal) // Count total members who attended physically (Fizikal)
const fizikalCount = this.data.voters.data.reduce((total, voter) => { const fizikalCount = this.voterListItems.reduce((total, voter) => {
return voter.kehadiran === 1 ? total + 1 : total; return voter.kehadiran === 1 ? total + 1 : total;
}, 0); }, 0);
// Count total members who attended virtually (Maya) // Count total members who attended virtually (Maya)
const mayaCount = this.data.voters.data.reduce((total, voter) => { const mayaCount = this.voterListItems.reduce((total, voter) => {
return voter.kehadiran === 2 ? total + 1 : total; return voter.kehadiran === 2 ? total + 1 : total;
}, 0); }, 0);
// Calculate total attendance percentage // Calculate total attendance percentage
const totalAttendance = fizikalCount + mayaCount; const totalAttendance = fizikalCount + mayaCount;
const totalMembers = this.data.voters.data.length; const totalMembers = this.voterListItems.length;
return totalMembers === 0 ? 0 : ((totalAttendance / totalMembers) * 100).toFixed(2); return totalMembers === 0 ? 0 : ((totalAttendance / totalMembers) * 100).toFixed(2);
}, },
@@ -1,14 +1,55 @@
<template> <template>
<div class="container col-md-8 col-md-offset-2"> <div :class="wrapperClass">
<h4>Maklumat Pengundi</h4> <h4 v-if="showHeader">{{ headerTitle }}</h4>
<router-view></router-view> <router-view></router-view>
</div> </div>
</template> </template>
<script> <script>
export default{ export default {
computed: {
showHeader: function () {
// Keep header hidden only for "Tambah Pengundi" (it has its own page header)
return this.$route.name !== 'Tambah Pengundi';
},
headerTitle: function () {
if (this.$route.name === 'Kehadiran Calon') return 'Kehadiran';
return 'Maklumat Pengundi';
},
isWideVoterRoute: function () {
// Give more room for heavy pages, but keep it boxed (not edge-to-edge)
return this.$route.name === 'Tambah Pengundi'
|| this.$route.name === 'Kehadiran Calon';
},
wrapperClass: function () {
if (this.isWideVoterRoute) {
return 'container-fluid voter-admin-wrap voter-admin-wrap--boxed voter-admin-wrap--wide';
}
return 'container col-md-8 col-md-offset-2 voter-admin-wrap voter-admin-wrap--boxed';
}
},
created: function () { created: function () {
this.util.setTitle('MyKoPKB - Maklumat Pengundi'); this.util.setTitle('MyKoPKB - Maklumat Pengundi');
} }
} }
</script> </script>
<style>
/* Boxed card-style wrapper for all voter admin pages */
.voter-admin-wrap--boxed {
background: #fff;
border: 1px solid #e6e6e6;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
padding: 16px;
margin-top: 12px;
margin-bottom: 24px;
}
/* Wide pages: not full screen, but wider than centered 8-col layout */
.voter-admin-wrap--wide {
max-width: 1400px;
margin-left: auto;
margin-right: auto;
}
</style>
@@ -0,0 +1,224 @@
<template>
<div class="physical-gate-page">
<div v-if="!displayKey" class="physical-gate-setup">
<h1 class="physical-gate-title">Paparan kod kaunter</h1>
<p class="physical-gate-hint">
Masukkan kunci paparan (sama dengan <code>PHYSICAL_ATTENDANCE_GATE_DISPLAY_KEY</code> dalam tetapan pelayan),
atau buka URL dengan parameter <code>?k=...</code>
</p>
<div class="form-group physical-gate-key-form">
<input v-model="keyInput" type="password" class="form-control input-lg" placeholder="Kunci paparan"
@keyup.enter="applyKey" />
<button type="button" class="btn btn-primary btn-lg btn-block" @click="applyKey">Mula paparan</button>
</div>
</div>
<div v-else-if="error" class="physical-gate-error">
<h1 class="physical-gate-title">Ralat</h1>
<p>{{ error }}</p>
<button type="button" class="btn btn-default" @click="retry">Cuba lagi</button>
</div>
<div v-else class="physical-gate-active">
<div class="physical-gate-label">Kod kehadiran fizikal ({{ periodSeconds }}s)</div>
<div class="physical-gate-code">{{ code || '—' }}</div>
<div class="physical-gate-countdown">
<span class="physical-gate-countdown-bar" :style="{ width: countdownPercent + '%' }"></span>
</div>
<p class="physical-gate-sub">Kod akan bertukar secara automatik. Pastikan skrin sentiasa menyala.</p>
</div>
</div>
</template>
<script>
export default {
name: 'PhysicalGateDisplay',
data: function () {
return {
displayKey: '',
keyInput: '',
code: '',
secondsRemaining: 0,
periodSeconds: 60,
error: '',
pollTimer: null,
tickTimer: null,
};
},
computed: {
countdownPercent: function () {
if (!this.periodSeconds) return 0;
return Math.min(100, (this.secondsRemaining / this.periodSeconds) * 100);
},
},
created: function () {
this.util.setTitle('Kod kaunter — Kehadiran fizikal');
var q = this.$route.query.k || this.$route.query.key;
if (q) {
this.displayKey = String(q);
}
},
mounted: function () {
if (this.displayKey) {
this.startPolling();
}
},
beforeDestroy: function () {
this.stopTimers();
},
methods: {
applyKey: function () {
if (!this.keyInput) return;
this.displayKey = this.keyInput;
this.error = '';
this.startPolling();
},
retry: function () {
this.error = '';
this.fetchCode();
},
fetchCode: function () {
var vm = this;
axios
.get(config.API + 'physical-attendance-gate/current', {
headers: { 'X-Physical-Gate-Display-Key': vm.displayKey },
})
.then(function (res) {
vm.code = res.data.code;
vm.secondsRemaining = res.data.seconds_remaining;
vm.periodSeconds = res.data.period_seconds || 60;
vm.error = '';
})
.catch(function (err) {
var msg = 'Tidak dapat memuatkan kod.';
if (err.response && err.response.data && err.response.data.message) {
msg = err.response.data.message;
}
vm.error = msg;
vm.code = '';
});
},
startPolling: function () {
var vm = this;
this.stopTimers();
this.fetchCode();
this.pollTimer = setInterval(function () {
vm.fetchCode();
}, 5000);
this.tickTimer = setInterval(function () {
if (vm.secondsRemaining > 0) {
vm.secondsRemaining -= 1;
}
if (vm.secondsRemaining <= 0 && !vm.error) {
vm.fetchCode();
}
}, 1000);
},
stopTimers: function () {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
if (this.tickTimer) {
clearInterval(this.tickTimer);
this.tickTimer = null;
}
},
},
};
</script>
<style scoped>
.physical-gate-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: linear-gradient(160deg, #0f172a 0%, #1e3a5f 45%, #0f172a 100%);
color: #e2e8f0;
}
.physical-gate-setup,
.physical-gate-error {
max-width: 520px;
width: 100%;
}
.physical-gate-title {
font-size: 2rem;
font-weight: 800;
margin-bottom: 16px;
color: #f8fafc;
}
.physical-gate-hint {
font-size: 1.05rem;
line-height: 1.5;
margin-bottom: 20px;
color: #94a3b8;
}
.physical-gate-key-form .btn {
margin-top: 12px;
}
.physical-gate-active {
text-align: center;
width: 100%;
max-width: 920px;
}
.physical-gate-label {
font-size: 1.4rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #94a3b8;
margin-bottom: 20px;
}
.physical-gate-code {
font-size: clamp(3.5rem, 14vw, 8rem);
font-weight: 900;
letter-spacing: 0.12em;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
color: #fbbf24;
text-shadow: 0 0 40px rgba(251, 191, 36, 0.35);
margin-bottom: 28px;
word-break: break-all;
}
.physical-gate-countdown {
height: 12px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.25);
overflow: hidden;
margin: 0 auto 20px;
max-width: 560px;
}
.physical-gate-countdown-bar {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #38bdf8, #22d3ee);
transition: width 0.3s ease;
}
.physical-gate-sub {
font-size: 1.1rem;
color: #94a3b8;
margin: 0;
}
</style>
@@ -47,31 +47,49 @@
</div> </div>
</div> </div>
<div class="form-group attendance-choice-box"> <!-- Sembunyikan borang pendaftaran selepas kehadiran telah dihantar -->
<label for="kehadiran">Kehadiran</label> <template v-if="Number(data.user.kehadiran) === 0">
<select class="form-control attendance-select" id="kehadiran" name="kehadiran" required> <div class="form-group attendance-choice-box">
<option value="0">Sila Pilih :</option> <label for="kehadiran">Kehadiran</label>
<option value="1">Fizikal</option> <select v-model="selectedKehadiran" class="form-control attendance-select" id="kehadiran"
<option value="2">Maya</option> name="kehadiran" required>
</select> <option value="0">Sila Pilih :</option>
<small class="form-text text-muted attendance-helper-text"> <option value="1">Fizikal</option>
Sila pilih kehadiran (Maya/Fizikal) kemudian klik SAYA TERIMA. <option value="2">Maya</option>
</small> </select>
</div> <small class="form-text text-muted attendance-helper-text">
Sila pilih kehadiran (Maya/Fizikal) kemudian klik SAYA TERIMA.
</small>
</div>
<div class="alert alert-info mt-4 small attendance-info-notice"> <div v-if="selectedKehadiran === '1'" class="form-group attendance-field">
<p><strong>*</strong> Pendaftaran akan dibuka dari <strong>jam 7.00 pagi hingga 10.00 pagi (25 April <label for="physical_gate_code">Kod skrin kaunter pendaftaran</label>
2025)</strong>.</p> <input type="text" name="physical_gate_code" id="physical_gate_code"
<p> class="form-control attendance-input" autocomplete="off" autocapitalize="characters"
<strong>*</strong> Elaun kehadiran berjumlah <strong>RM300.00 (secara fizikal)</strong> akan inputmode="text" placeholder="8 aksara, semak skrin di kaunter" maxlength="64"
diberikan secara TUNAI selepas mesyuarat. <br /> @input="onPhysicalGateCodeInput" />
Manakala <strong>RM150.00 (secara atas talian)</strong> akan dikreditkan ke akaun anggota yang <small class="form-text text-muted attendance-helper-text">
hadir penuh. Kod berputar setiap minit masukkan kod semasa yang dipaparkan di kaunter sebelum
</p> menghantar.
</div> </small>
</div>
<div class="alert alert-info mt-4 small attendance-info-notice">
<p><strong>*</strong> Pendaftaran akan dibuka dari <strong>jam 7.00 pagi hingga 10.00 pagi (25
April
2025)</strong>.</p>
<p>
<strong>*</strong> Elaun kehadiran berjumlah <strong>RM300.00 (secara fizikal)</strong> akan
diberikan secara TUNAI selepas mesyuarat. <br />
Manakala <strong>RM150.00 (secara atas talian)</strong> akan dikreditkan ke akaun anggota
yang
hadir penuh.
</p>
</div>
</template>
<div class="text-center mt-4 attendance-action-area"> <div class="text-center mt-4 attendance-action-area">
<div v-if="data.user.kehadiran != 0" class="attendance-success-card"> <div v-if="data.user.kehadiran != 0 && canProceedToVote" class="attendance-success-card">
<div class="attendance-success-kicker">Kehadiran Disahkan</div> <div class="attendance-success-kicker">Kehadiran Disahkan</div>
<p class="attendance-success-text">Kehadiran anda telah disahkan secara</p> <p class="attendance-success-text">Kehadiran anda telah disahkan secara</p>
<h5 class="attendance-success-mode"> <h5 class="attendance-success-mode">
@@ -83,10 +101,35 @@
</router-link> </router-link>
</div> </div>
<div v-else-if="data.user.kehadiran != 0 && !canProceedToVote" class="attendance-pending-card">
<div class="attendance-pending-kicker">Menunggu pengesahan</div>
<p class="attendance-pending-text">
Kehadiran <strong>fizikal</strong> anda telah direkod. Sila pergi ke kaunter pendaftaran
untuk
mengesahkan pendaftaran sebelum anda boleh mengundi.
</p>
<p class="attendance-pending-hint">
Selepas pengesahan, butang mengundi akan dipaparkan di sini. Semak semula halaman ini
selepas beberapa ketika.
</p>
<button type="button" class="btn btn-default attendance-refresh-button"
@click="reloadAttendanceState">
Muat semula
</button>
</div>
<button type="submit" class="btn btn-lg btn-success attendance-submit-button" <button type="submit" class="btn btn-lg btn-success attendance-submit-button"
v-else-if="data.election.status == 2"> v-else-if="electionVotingActive">
SAYA TERIMA DAFTAR KEHADIRAN
</button> </button>
<div v-else class="alert alert-info text-left attendance-voting-notice">
<p class="attendance-voting-notice__title"><strong>Butang DAFTAR KEHADIRAN belum
tersedia</strong>
</p>
<p class="attendance-voting-notice__body">
Buat masa ini proses undian / mesyuarat <strong>belum bermula</strong>.
</p>
</div>
</div> </div>
</form> </form>
@@ -248,6 +291,43 @@
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.attendance-pending-card {
padding: 24px;
border-radius: 18px;
background: linear-gradient(135deg, #fffbeb 0%, #ffffff 100%);
border: 1px solid #fde68a;
box-shadow: 0 14px 28px rgba(180, 83, 9, 0.1);
}
.attendance-pending-kicker {
margin-bottom: 10px;
color: #b45309;
font-size: 1.2rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.attendance-pending-text {
margin-bottom: 10px;
color: #43617a;
line-height: 1.55;
}
.attendance-pending-hint {
margin-bottom: 18px;
color: #6b7280;
font-size: 0.95rem;
line-height: 1.5;
}
.attendance-refresh-button {
min-width: 180px;
padding: 10px 20px;
border-radius: 999px;
font-weight: 700;
}
.attendance-submit-button, .attendance-submit-button,
.attendance-vote-button { .attendance-vote-button {
min-width: 220px; min-width: 220px;
@@ -258,6 +338,21 @@
box-shadow: 0 12px 24px rgba(35, 64, 97, 0.14); box-shadow: 0 12px 24px rgba(35, 64, 97, 0.14);
} }
.attendance-voting-notice {
max-width: 520px;
margin: 0 auto;
border-radius: 14px;
}
.attendance-voting-notice__title {
margin: 0 0 8px;
}
.attendance-voting-notice__body {
margin: 0 0 10px;
line-height: 1.55;
}
@media (max-width: 767px) { @media (max-width: 767px) {
.attendance-hero, .attendance-hero,
@@ -271,7 +366,8 @@
} }
.attendance-submit-button, .attendance-submit-button,
.attendance-vote-button { .attendance-vote-button,
.attendance-refresh-button {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
} }
@@ -284,11 +380,55 @@ export default {
data: function () { data: function () {
return { return {
loading: false, loading: false,
userSubmitted: false // Add a flag to track if the user has already submitted userSubmitted: false, // Add a flag to track if the user has already submitted
selectedKehadiran: '0',
}
},
mounted: function () {
if (this.data && this.data.user && this.data.user.kehadiran == 0) {
var el = document.getElementById('kehadiran');
if (el) this.selectedKehadiran = el.value || '0';
}
},
computed: {
/**
* Status 2 = pilihan raya sedang berjalan (butang SAYA TERIMA dibuka).
*/
electionVotingActive: function () {
var e = this.data && this.data.election;
if (!e || e.status === undefined || e.status === null) return false;
return Number(e.status) === 2;
},
/**
* Maya (2) can vote once attendance is set. Fizikal (1) needs admin verification timestamp.
*/
canProceedToVote: function () {
var u = this.data && this.data.user;
if (!u || Number(u.kehadiran) === 0) return false;
if (Number(u.kehadiran) !== 1) return true;
return Boolean(u.fizikal_registration_verified_at);
} }
}, },
methods: { methods: {
onPhysicalGateCodeInput: function (e) {
var el = e.target;
var upper = el.value.toUpperCase();
if (el.value === upper) return;
var start = el.selectionStart;
var end = el.selectionEnd;
el.value = upper;
if (start != null && end != null) {
el.setSelectionRange(start, end);
}
},
reloadAttendanceState: function () {
location.reload();
},
// refreshUser: function () { // refreshUser: function () {
// var vm = this; // var vm = this;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,475 @@
<template>
<div class="voter-nominees-page row col-md-10 col-md-offset-1">
<div class="col-xs-12 page-column">
<div class="page-section-heading">Maklumat Calon</div>
<div class="nominee-page-intro">
Tekan gambar atau baris calon untuk melihat maklumat penuh.
</div>
<div class="panel panel-primary nominee-panel" v-for="position in data.positions" :key="position.id">
<div class="panel-heading nominee-panel-heading">
<span>{{ position.name }}</span>
<span class="nominee-panel-badge">Senarai Calon</span>
</div>
<div class="panel-body table-responsive nominee-panel-body">
<table class="table table-hover nominee-table" id="table-nominee">
<thead>
<tr>
<th></th>
<th>Bil.</th>
<th>Nama</th>
<th>No. Anggota</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
<tr v-for="(nominee, i) in data.nominees" :key="nominee.id" v-if="nominee.position_id == position.id"
class="nominee-row" @click="viewNomineeDetails(nominee)">
<td>
<img :src="createBase64ImageUrl(nominee.photo)" class="thumbnail nominee-thumb">
</td>
<td>{{ nominee.no_calon }}</td>
<td>{{ nominee.name }}</td>
<td>{{ nominee.no_anggota }}</td>
<td>{{ nominee.unit }}</td>
</tr>
<tr v-if="!data.nominees.some(nominee => nominee.position_id == position.id)">
<td colspan="5" class="nominee-empty-state">Tiada calon untuk jawatan ini.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-if="showNomineeModal" class="modal-backdrop nominee-modal-backdrop" @click.self="closeNomineeModal">
<div class="modal-dialog" id="view-nominee-modal">
<div class="modal-content nominee-modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title">Maklumat Calon</h5>
</div>
</div>
<div class="modal-body nominee-modal-body">
<div class="photo-section">
<div class="nominee-photo-card">
<img :src="nomineeViewed.photo" class="thumbnail nominee-viewed-photo" />
<div class="nominee-photo-meta">
<div class="nominee-name">{{ nomineeViewed.name }}</div>
<div class="nominee-subtitle">
Calon No. {{ nomineeViewed.no_calon || '-' }}
</div>
</div>
</div>
</div>
<div class="detail-section">
<div class="nominee-summary-grid">
<div class="summary-item">
<span class="summary-label">Nama</span>
<span class="summary-value">{{ nomineeViewed.name || '-' }}</span>
</div>
<div class="summary-item">
<span class="summary-label">Unit</span>
<span class="summary-value">{{ nomineeViewed.unit || '-' }}</span>
</div>
<div class="summary-item">
<span class="summary-label">Umur</span>
<span class="summary-value">{{ nomineeViewed.umur || '-' }}</span>
</div>
<div class="summary-item">
<span class="summary-label">Jawatan Sekarang</span>
<span class="summary-value">{{ nomineeViewed.jawatan_sekarang || '-' }}</span>
</div>
</div>
<div class="nominee-section-card">
<div class="section-title">Taraf Pendidikan</div>
<ol v-if="nomineeViewed.education && nomineeViewed.education.length" class="detail-list">
<li v-for="(item, index) in nomineeViewed.education" :key="index">{{ item }}</li>
</ol>
<p v-else class="detail-empty">Tiada maklumat pendidikan.</p>
</div>
<div class="nominee-section-card">
<div class="section-title">Pengalaman Kerja</div>
<ol v-if="nomineeViewed.experience && nomineeViewed.experience.length" class="detail-list">
<li v-for="(item, index) in nomineeViewed.experience" :key="index">{{ item }}</li>
</ol>
<p v-else class="detail-empty">Tiada maklumat pengalaman kerja.</p>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-success" @click="closeNomineeModal">Tutup</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
nomineeViewed: {},
showNomineeModal: false
};
},
methods: {
viewNomineeDetails(nominee) {
this.nomineeViewed = { ...nominee };
this.nomineeViewed.photo = this.createBase64ImageUrl(nominee.photo);
this.showNomineeModal = true;
},
closeNomineeModal() {
this.showNomineeModal = false;
},
createBase64ImageUrl(photo) {
return `data:image/jpeg;base64,${photo}`;
}
}
};
</script>
<style>
.voter-nominees-page {
margin-top: 24px;
margin-bottom: 40px;
}
.page-column {
margin-bottom: 24px;
}
.page-section-heading {
margin-bottom: 14px;
padding: 14px 18px;
border-radius: 14px;
background: linear-gradient(135deg, #0d6efd, #2f80ed);
color: #fff;
font-size: 1.8rem;
font-weight: 700;
text-align: center;
box-shadow: 0 12px 28px rgba(13, 110, 253, 0.2);
}
.nominee-page-intro {
margin-bottom: 18px;
padding: 12px 16px;
border-radius: 12px;
background: #eef5ff;
border: 1px solid #dbe8ff;
color: #36506b;
font-size: 1.4rem;
}
.nominee-panel {
margin-bottom: 20px;
border: 0;
border-radius: 18px;
overflow: hidden;
box-shadow: 0 14px 28px rgba(35, 64, 97, 0.1);
}
.nominee-panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: linear-gradient(135deg, #1d4e89, #2563eb);
color: #fff;
font-size: 1.7rem;
font-weight: 700;
}
.nominee-panel-badge {
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
font-size: 1.2rem;
font-weight: 600;
}
.nominee-panel-body {
padding: 0;
background: #fff;
}
.nominee-table {
margin-bottom: 0;
}
.nominee-table thead {
background: #f4f8fd;
}
.nominee-table thead th {
border-bottom: 1px solid #dce7f5;
color: #48627e;
font-size: 1.25rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.nominee-table tbody td {
vertical-align: middle;
border-top: 1px solid #edf2f7;
color: #22384f;
}
.nominee-row {
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
}
.nominee-row:hover {
background: #f8fbff;
box-shadow: inset 4px 0 0 #2f80ed;
}
.nominee-thumb {
width: 64px;
height: 64px;
margin-bottom: 0;
border: 2px solid #dbe8ff;
border-radius: 16px;
object-fit: cover;
box-shadow: 0 8px 18px rgba(47, 128, 237, 0.12);
}
.nominee-empty-state {
padding: 18px !important;
text-align: center;
color: #6f8193;
}
.nominee-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: rgba(7, 25, 48, 0.72);
backdrop-filter: blur(3px);
}
#view-nominee-modal.modal-dialog {
width: 100%;
max-width: 960px;
margin: 0;
}
.nominee-modal-content {
border: 0;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.28);
}
.nominee-modal-content .modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid #e7edf5;
background: linear-gradient(135deg, #0d6efd, #2f80ed);
color: #fff;
}
.nominee-modal-content .modal-title {
margin: 4px 0 0;
font-size: 2.2rem;
font-weight: 700;
}
.nominee-modal-content .modal-header .close {
margin-top: -4px;
color: #fff;
opacity: 0.9;
text-shadow: none;
}
.nominee-modal-kicker {
font-size: 1.2rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
opacity: 0.85;
}
.nominee-modal-body {
display: flex;
gap: 24px;
padding: 24px;
background: #f5f8fc;
}
.photo-section {
flex: 0 0 300px;
}
.nominee-photo-card {
padding: 18px;
border-radius: 18px;
background: #fff;
border: 1px solid #e5ebf3;
box-shadow: 0 10px 24px rgba(35, 64, 97, 0.08);
}
.nominee-viewed-photo {
width: 100%;
height: 320px;
object-fit: cover;
border: 0;
border-radius: 14px;
margin: 0 0 16px;
background: #eef3f8;
}
.nominee-photo-meta {
text-align: center;
}
.nominee-name {
font-size: 2rem;
font-weight: 700;
color: #17324d;
}
.nominee-subtitle {
margin-top: 4px;
font-size: 1.4rem;
color: #5f7388;
}
.detail-section {
flex: 1;
min-width: 0;
}
.nominee-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.summary-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border-radius: 14px;
background: #fff;
border: 1px solid #e5ebf3;
box-shadow: 0 8px 22px rgba(35, 64, 97, 0.06);
}
.summary-label {
font-size: 1.2rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #6f8193;
}
.summary-value {
font-size: 1.6rem;
font-weight: 600;
line-height: 1.4;
color: #17324d;
}
.nominee-section-card {
margin-bottom: 16px;
padding: 18px 20px;
border-radius: 16px;
background: #fff;
border: 1px solid #e5ebf3;
box-shadow: 0 8px 22px rgba(35, 64, 97, 0.06);
}
.section-title {
margin-bottom: 12px;
font-size: 1.5rem;
font-weight: 700;
color: #17324d;
}
.detail-list {
margin: 0;
padding-left: 20px;
color: #30475f;
}
.detail-list li {
margin-bottom: 8px;
line-height: 1.6;
}
.detail-empty {
margin: 0;
color: #6f8193;
}
.nominee-modal-content .modal-footer {
padding: 16px 24px 22px;
border-top: 1px solid #e7edf5;
background: #fff;
}
@media (max-width: 767px) {
.nominee-modal-backdrop {
align-items: flex-start;
overflow-y: auto;
padding: 16px 10px;
}
.nominee-modal-body {
flex-direction: column;
padding: 18px;
}
.photo-section {
flex: 1 1 auto;
}
.nominee-viewed-photo {
height: 260px;
}
.nominee-summary-grid {
grid-template-columns: 1fr;
}
.page-section-heading {
font-size: 1.6rem;
}
.nominee-panel-heading {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.nominee-table thead th:nth-child(4),
.nominee-table tbody td:nth-child(4),
.nominee-table thead th:nth-child(5),
.nominee-table tbody td:nth-child(5) {
display: none;
}
}
@media (max-width: 991px) {
.voter-nominees-page {
margin-top: 16px;
}
}
</style>
@@ -12,8 +12,8 @@
<ul class="list-group vote-summary-list"> <ul class="list-group vote-summary-list">
<li class="list-group-item vote-summary-item"> <li class="list-group-item vote-summary-item">
<div class="vote-position-name">Calon Dipilih</div> <div class="vote-position-name">Calon Dipilih</div>
<div v-if="selected[0] && selected[0].nominee_id.length" class="vote-selected-list"> <div v-if="allSelectedNomineeIds.length" class="vote-selected-list">
<div class="vote-selected-item" v-for="nominee_id in selected[0].nominee_id" :key="nominee_id"> <div class="vote-selected-item" v-for="nominee_id in allSelectedNomineeIds" :key="nominee_id">
<span class="vote-selected-badge">#{{ getNomineeNo(nominee_id) }}</span> <span class="vote-selected-badge">#{{ getNomineeNo(nominee_id) }}</span>
<span>{{ getSelectedNomineeName(nominee_id) }}</span> <span>{{ getSelectedNomineeName(nominee_id) }}</span>
</div> </div>
@@ -73,7 +73,7 @@
</div> </div>
</div> </div>
<div class="modal vote-confirm-modal" id="vote-modal" tabindex="-2" role="dialog" aria-labelledby="exampleModalLabel" <div class="modal fade app-modal vote-confirm-modal" id="vote-modal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel"
aria-hidden="true"> aria-hidden="true">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content vote-modal-content"> <div class="modal-content vote-modal-content">
@@ -88,9 +88,9 @@
</div> </div>
<div class="modal-body vote-modal-body"> <div class="modal-body vote-modal-body">
<h5 class="vote-modal-note">Nota: Sila pastikan anda telah membuat undian dengan betul.</h5> <h5 class="vote-modal-note">Nota: Sila pastikan anda telah membuat undian dengan betul.</h5>
<div class="vote-modal-selection" v-if="selected[0] && selected[0].nominee_id.length"> <div class="vote-modal-selection" v-if="allSelectedNomineeIds.length">
<div class="vote-modal-selection-title">Calon dipilih</div> <div class="vote-modal-selection-title">Calon dipilih</div>
<div class="vote-selected-item" v-for="nominee_id in selected[0].nominee_id" :key="nominee_id"> <div class="vote-selected-item" v-for="nominee_id in allSelectedNomineeIds" :key="nominee_id">
<span class="vote-selected-badge">#{{ getNomineeNo(nominee_id) }}</span> <span class="vote-selected-badge">#{{ getNomineeNo(nominee_id) }}</span>
<span>{{ getSelectedNomineeName(nominee_id) }}</span> <span>{{ getSelectedNomineeName(nominee_id) }}</span>
</div> </div>
@@ -130,8 +130,20 @@ export default {
}, },
computed: { computed: {
/** All nominee ids chosen across every position (sidebar + modal must use this, not selected[0]). */
allSelectedNomineeIds() {
var ids = [];
for (var i = 0; i < this.selected.length; i++) {
var arr = this.selected[i] && Array.isArray(this.selected[i].nominee_id)
? this.selected[i].nominee_id
: [];
for (var j = 0; j < arr.length; j++) ids.push(arr[j]);
}
return ids;
},
disabledCheckboxes() { disabledCheckboxes() {
const selectedNomineeIds = this.selected[0]['nominee_id']; const selectedNomineeIds = this.allSelectedNomineeIds;
return this.data.nominees.map(nominee => { return this.data.nominees.map(nominee => {
return selectedNomineeIds.includes(nominee.id) ? false : this.checkDisabled(); return selectedNomineeIds.includes(nominee.id) ? false : this.checkDisabled();
}); });
@@ -139,12 +151,23 @@ export default {
}, },
methods: { methods: {
totalSelectedCount: function () {
return this.allSelectedNomineeIds.length;
},
selectedEntryForPosition: function (position_id) {
for (var i = 0; i < this.selected.length; i++) {
if (String(this.selected[i].position_id) === String(position_id)) return this.selected[i];
}
return null;
},
test: function () { test: function () {
console.log('tab'); console.log('tab');
this.util.showModal('#vote-modal') this.util.showModal('#vote-modal')
}, },
submit: function () { submit: function () {
if (this.selected[0]['nominee_id'].length !== this.max) { if (this.totalSelectedCount() !== this.max) {
// No selections made // No selections made
this.util.notify(`Sila pastikan anda memilih (${this.max}) orang calon.`, 'error'); this.util.notify(`Sila pastikan anda memilih (${this.max}) orang calon.`, 'error');
return; return;
@@ -152,7 +175,7 @@ export default {
var vm = this; var vm = this;
this.util.notify('Submitting your vote, please wait...', 'loading'); this.util.notify('Submitting your vote, please wait...', 'loading');
console.log(this.selected) console.log(this.selected);
// Prepare data to send to the server // Prepare data to send to the server
let data = { let data = {
vote: this.selected vote: this.selected
@@ -162,6 +185,10 @@ export default {
axios.post(config.API + 'election/vote', data) axios.post(config.API + 'election/vote', data)
.then(response => { .then(response => {
$.notifyClose(); $.notifyClose();
// Some endpoints may not return a `message`; ensure we never notify "undefined".
if (response && response.data && typeof response.data.message === 'string' && response.data.message) {
vm.util.notify(response.data.message, 'success');
}
if (vm.util.showResult(response, 'success')) { if (vm.util.showResult(response, 'success')) {
vm.data.result = response.data.result; vm.data.result = response.data.result;
vm.$router.push({ name: 'Voter Home' }); vm.$router.push({ name: 'Voter Home' });
@@ -194,22 +221,19 @@ export default {
}, },
vote: function (position_id, nominee_id) { vote: function (position_id, nominee_id) {
if (!this.selected[0]['position_id']) { var entry = this.selectedEntryForPosition(position_id);
this.$set(this.selected[0], position_id, []); if (!entry) return;
}
const index = this.selected[0]['nominee_id'].indexOf(nominee_id); var idx = entry.nominee_id.indexOf(nominee_id);
if (index === -1) { if (idx === -1) {
// Nominee not found, add it // Enforce global max selection across all positions
if (this.selected[0]['nominee_id'].length < this.max) { if (this.totalSelectedCount() >= this.max) {
this.selected[0]['nominee_id'].push(nominee_id);
} else {
// Display warning message if more than 2 nominees are selected
this.util.notify(`Boleh mengundi (${this.max}) calon sahaja!`, 'warning'); this.util.notify(`Boleh mengundi (${this.max}) calon sahaja!`, 'warning');
return; return;
} }
entry.nominee_id.push(nominee_id);
} else { } else {
// Nominee found, remove it entry.nominee_id.splice(idx, 1);
this.selected[0]['nominee_id'].splice(index, 1);
} }
}, },
@@ -264,7 +288,7 @@ export default {
}, },
checkUndi: function () { checkUndi: function () {
if (this.selected[0]['nominee_id'].length <= 0) { if (this.totalSelectedCount() <= 0) {
this.util.notify('Sila pastikan anda memilih (1) orang calon.', 'error'); this.util.notify('Sila pastikan anda memilih (1) orang calon.', 'error');
} else { } else {
this.util.showModal('#vote-modal'); this.util.showModal('#vote-modal');
@@ -273,7 +297,7 @@ export default {
}, },
checkDisabled: function () { checkDisabled: function () {
if (this.selected[0]['nominee_id'].length >= this.max) { if (this.totalSelectedCount() >= this.max) {
return true; return true;
} }
} }
@@ -15,38 +15,7 @@
</div> </div>
<div class="collapse navbar-collapse" id="myNavbar"> <div class="collapse navbar-collapse" id="myNavbar">
<ul class="nav navbar-nav"> <ul class="nav navbar-nav voter-nav-main">
<router-link :to="{ name: 'Voter Home' }" tag="li" exact>
<a href="#"><b>LAMAN UTAMA</b></a>
</router-link>
<li>
<a :href="matLinkUrl" target="_blank" rel="noopener noreferrer">
<b>MAT KE-28</b> <i class="fa fa-external-link"></i>
</a>
</li>
<router-link :to="{ name: 'Attendance' }" tag="li" exact>
<a href="#"><b>DAFTAR MAT</b></a>
</router-link>
<router-link :to="{ name: 'Vote' }"
v-if="data.election.status == 2 && data.user.saham >= 500 && !hasVoted() && data.user.kehadiran != 0"
tag="li" exact>
<a href="#"><b>UNDIAN</b></a>
</router-link>
<router-link :to="{ name: 'Zoom' }" v-if="data.user.kehadiran != 0" tag="li" exact>
<a href="#"><b>PAUTAN MESYUARAT</b></a>
</router-link>
<router-link :to="{ name: 'penyata', params: { no_anggota: data.user.no_anggota } }"
tag="li" exact>
<a href="#"><b>PENYATA AHLI</b></a>
</router-link>
</ul> </ul>
<ul class="nav navbar-right navbar-nav"> <ul class="nav navbar-right navbar-nav">
<li class="dropdown"> <li class="dropdown">
@@ -64,6 +33,16 @@
</nav> </nav>
<div class="voter-main"> <div class="voter-main">
<div class="voter-breadcrumb-wrap">
<div class="container-fluid">
<ol class="breadcrumb voter-breadcrumb">
<li v-for="(crumb, idx) in breadcrumbs" :key="idx" :class="{ active: crumb.active }">
<router-link v-if="crumb.to" :to="crumb.to">{{ crumb.label }}</router-link>
<span v-else>{{ crumb.label }}</span>
</li>
</ol>
</div>
</div>
<router-view></router-view> <router-view></router-view>
</div> </div>
</div> </div>
@@ -78,18 +57,47 @@
<script> <script>
export default { export default {
data: () => ({ data: () => ({
loading: true, loading: true
matLinkUrl: 'https://linktr.ee/kopkb'
}), }),
created: function () { created: function () {
this.refreshInfo(); this.refreshInfo();
}, },
computed: {
breadcrumbs: function () {
var routeName = this.$route.name;
var labels = {
'Voter Home': 'Laman Utama',
'Voter Calon': 'Maklumat Calon',
'Mat': 'MAT',
'Vote': 'Undian',
'Result': 'Keputusan',
'Attendance': 'Daftar MAT',
'Zoom': 'Pautan Mesyuarat',
'penyata': 'Penyata Ahli'
};
var current = labels[routeName] || routeName || 'Halaman';
if (routeName === 'Voter Home') {
return [{ label: 'Laman Utama', to: null, active: true }];
}
return [
{ label: 'Laman Utama', to: { name: 'Voter Home' }, active: false },
{ label: current, to: null, active: true }
];
}
},
methods: { methods: {
logout: function () { logout: function () {
localStorage.clear(); var vm = this;
this.$router.push({ name: 'Voter Login' }); this.util.setAuthorization();
axios.post(config.API + 'voter/logout')
.catch(function () { })
.finally(function () {
localStorage.clear();
vm.$router.push({ name: 'Voter Login' });
});
}, },
hasVoted: function () { hasVoted: function () {
@@ -276,6 +284,57 @@ export default {
padding-top: 112px; padding-top: 112px;
} }
.voter-breadcrumb-wrap {
margin: 0 16px 8px;
padding: 0;
position: relative;
z-index: 5;
}
.voter-breadcrumb-wrap .container-fluid {
padding-left: 15px;
padding-right: 15px;
}
.voter-breadcrumb {
margin-bottom: 16px;
padding: 14px 20px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid #e5ebf3;
box-shadow: 0 8px 22px rgba(35, 64, 97, 0.1);
font-size: 1.7rem;
line-height: 1.45;
}
.voter-breadcrumb>li {
padding-top: 2px;
padding-bottom: 2px;
}
.voter-breadcrumb>li+li:before {
color: #94a3b8;
padding: 0 12px;
font-size: 1.35rem;
font-weight: 600;
}
.voter-breadcrumb>li>a {
color: #2563eb;
font-weight: 600;
}
.voter-breadcrumb>li>a:hover {
color: #1d4ed8;
text-decoration: underline;
}
.voter-breadcrumb>li.active {
color: #17324d;
font-weight: 700;
font-size: 1.7rem;
}
@media (max-width: 767px) { @media (max-width: 767px) {
.floating-header { .floating-header {
top: 10px !important; top: 10px !important;
@@ -312,5 +371,26 @@ export default {
.voter-main { .voter-main {
padding-top: 102px; padding-top: 102px;
} }
.voter-breadcrumb-wrap {
margin-left: 10px;
margin-right: 10px;
}
.voter-breadcrumb {
font-size: 1.4rem;
line-height: 1.4;
padding: 12px 16px;
border-radius: 12px;
}
.voter-breadcrumb>li.active {
font-size: 1.4rem;
}
.voter-breadcrumb>li+li:before {
padding: 0 8px;
font-size: 1.2rem;
}
} }
</style> </style>
@@ -13,25 +13,15 @@
<br>-<b>+6 {{ $route.params.notel }}</b> <br>-<b>+6 {{ $route.params.notel }}</b>
</div> </div>
<div <div v-if="showDebugOtp" class="alert alert-info" style="margin-bottom:20px;">
v-if="showDebugOtp"
class="alert alert-info"
style="margin-bottom:20px;"
>
OTP local development: <b>{{ $route.params.debug_otp }}</b> OTP local development: <b>{{ $route.params.debug_otp }}</b>
</div> </div>
<div class="justify-content-center d-flex" style="margin-top:20px;margin-bottom:20px;display: flex;flex-direction: row;justify-content: center;align-items: center;"> <div class="justify-content-center d-flex"
<v-otp-input style="margin-top:20px;margin-bottom:20px;display: flex;flex-direction: row;justify-content: center;align-items: center;">
ref="otpInput" <v-otp-input ref="otpInput" input-classes="otp-input" separator="-" :num-inputs="6"
input-classes="otp-input" :should-auto-focus="true" :is-input-num="true" @on-change="handleOnChange"
separator="-" @on-complete="handleOnComplete" />
:num-inputs="6"
:should-auto-focus="true"
:is-input-num="true"
@on-change="handleOnChange"
@on-complete="handleOnComplete"
/>
</div> </div>
<div style="margin-bottom:20px;"> <div style="margin-bottom:20px;">
@@ -41,13 +31,8 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<input <input ref="submitbtn" type="submit" class="btn btn-primary form-control" value="Sahkan & Teruskan"
ref="submitbtn" disabled />
type="submit"
class="btn btn-primary form-control"
value="Sahkan & Teruskan"
disabled
/>
</div> </div>
</form> </form>
</div> </div>
@@ -55,24 +40,19 @@
</div> </div>
</template> </template>
<script> <script>
import Countdown from '../../mycomponents/countdown.vue'
export default { export default {
components:{
Countdown
},
data: function () { data: function () {
return { return {
loading: false loading: false
} }
}, },
computed: { computed: {
showDebugOtp: function () { showDebugOtp: function () {
return process.env.NODE_ENV !== 'production' && !!this.$route.params.debug_otp; return process.env.NODE_ENV !== 'production' && !!this.$route.params.debug_otp;
} }
}, },
created: function(){ created: function () {
console.log(this.$route.params.notel); console.log(this.$route.params.notel);
}, },
methods: { methods: {
@@ -87,59 +67,56 @@ export default {
handleClearInput() { handleClearInput() {
this.$refs.otpInput.clearInput(); this.$refs.otpInput.clearInput();
}, },
resendVerify: function(){ resendVerify: function () {
axios.post(config.API+'voter/login', { var vm = this;
'no_kp' : this.user.no_kp axios.post(config.API + 'voter/login', {
no_kp: this.$route.params.nokp
}) })
.then(response => { .then(function (response) {
this.stopLoading(); if (vm.util.showResult(response, 'success')) {
if (this.util.showResult(response, 'success')) { console.log('resend verify');
// this.$router.push({ name: 'Voter Verify',params: {'nokp' : response.data.nokp,'notel' : response.data.notel} }) }
this.$refs.countdown.restarttimer(); })
console.log('resend verify'); .catch(function (error) {
} vm.util.showResult(error, 'error');
}) });
.catch(error => {
vm.stopLoading();
this.util.showResult(error, 'error');
})
}, },
startLoading: function () { startLoading: function () {
this.util.notify('Logging in', 'loading'); this.util.notify('Logging in', 'loading');
this.loading = true; this.loading = true;
}, },
stopLoading: function () { stopLoading: function () {
$.notifyClose(); $.notifyClose();
this.loading = false; this.loading = false;
}, },
login: function () { login: function () {
if (this.loading) return; if (this.loading) return;
let vm = this; let vm = this;
this.startLoading(); this.startLoading();
console.log('OTP INPUT : '); console.log('OTP INPUT : ');
let otp = this.mergeOTP(this.$refs.otpInput.otp); let otp = this.mergeOTP(this.$refs.otpInput.otp);
console.log(otp); console.log(otp);
axios.post(config.API+'voter/verify', { axios.post(config.API + 'voter/verify', {
'no_kp' : this.$route.params.nokp, 'no_kp': this.$route.params.nokp,
'token' : otp 'token': otp
}) })
.then(response => { .then(response => {
vm.stopLoading(); vm.stopLoading();
if (this.util.showResult(response, 'success')) { if (this.util.showResult(response, 'success')) {
localStorage['Access Token'] = `Bearer ${response.data.token}`; localStorage['Access Token'] = `Bearer ${response.data.token}`;
this.util.setAuthorization(); this.util.setAuthorization();
vm.$router.push({name: 'Voter Home'}); vm.$router.push({ name: 'Voter Home' });
} }
}) })
.catch(error => { .catch(error => {
vm.stopLoading(); vm.stopLoading();
this.util.showResult(error, 'error'); this.util.showResult(error, 'error');
}) })
}, },
mergeOTP: function(OTP){ mergeOTP: function (OTP) {
return OTP.join(''); return OTP.join('');
}, },
}, },
@@ -155,10 +132,12 @@ export default {
border-radius: 4px; border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.3); border: 1px solid rgba(0, 0, 0, 0.3);
text-align: center; text-align: center;
&.error {
border: 1px solid red !important;
}
} }
.otp-input.error {
border: 1px solid red !important;
}
.otp-input::-webkit-inner-spin-button, .otp-input::-webkit-inner-spin-button,
.otp-input::-webkit-outer-spin-button { .otp-input::-webkit-outer-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
+1
View File
@@ -10,3 +10,4 @@ Vue.component('modal-body', resolveComponent(require('./components/mycomponents/
Vue.component('modal-footer', resolveComponent(require('./components/mycomponents/modal/footer.vue'))); Vue.component('modal-footer', resolveComponent(require('./components/mycomponents/modal/footer.vue')));
Vue.component('uploader', resolveComponent(require('./components/mycomponents/uploader.vue'))); Vue.component('uploader', resolveComponent(require('./components/mycomponents/uploader.vue')));
Vue.component('admin-data-table', resolveComponent(require('./components/AdminDataTable.vue')));
+8
View File
@@ -38,6 +38,8 @@ import ManagePenyata from './components/demo/admin/penyata/penyata.vue';
import ManagePenyataIndex from './components/demo/admin/penyata/index.vue'; import ManagePenyataIndex from './components/demo/admin/penyata/index.vue';
import ManagePenyataView from './components/demo/admin/penyata/view.vue'; import ManagePenyataView from './components/demo/admin/penyata/view.vue';
import AdminActivityLogIndex from './components/demo/admin/activitylog/index.vue';
const default_component = { const default_component = {
template: "<div>Not found: {{ $route.path }}</div>", template: "<div>Not found: {{ $route.path }}</div>",
@@ -185,6 +187,12 @@ const routes = [
], ],
}, },
{
path: "activity-log",
component: AdminActivityLogIndex,
name: "Activity Log",
},
{ {
path: "/account", path: "/account",
component: ManageAccount, component: ManageAccount,
+29
View File
@@ -4,18 +4,23 @@ import VoterVerification from "./components/demo/voter/verification.vue";
import VoterHome from "./components/demo/voter/index.vue"; import VoterHome from "./components/demo/voter/index.vue";
import Home from "./components/demo/voter/home/index.vue"; import Home from "./components/demo/voter/home/index.vue";
import VoterNominees from "./components/demo/voter/home/nominees.vue";
import Mat from "./components/demo/voter/home/mat.vue"; import Mat from "./components/demo/voter/home/mat.vue";
import Vote from "./components/demo/voter/home/vote.vue"; import Vote from "./components/demo/voter/home/vote.vue";
import Result from "./components/demo/voter/home/result.vue"; import Result from "./components/demo/voter/home/result.vue";
import Attendance from "./components/demo/voter/home/attendance.vue"; import Attendance from "./components/demo/voter/home/attendance.vue";
import Zoom from "./components/demo/voter/home/zoom.vue"; import Zoom from "./components/demo/voter/home/zoom.vue";
import Penyata from "./components/demo/voter/home/penyata.vue"; import Penyata from "./components/demo/voter/home/penyata.vue";
import PhysicalGateDisplay from "./components/demo/display/physical-gate-display.vue";
import RouletteEliminationWheelDemo from "./components/demo/RouletteEliminationWheelDemo.vue";
import AdminLogin from "./components/demo/admin/login.vue"; import AdminLogin from "./components/demo/admin/login.vue";
import AdminIndex from "./components/demo/admin/index.vue"; import AdminIndex from "./components/demo/admin/index.vue";
import FinalResult from "./components/demo/admin/home/final.vue"; import FinalResult from "./components/demo/admin/home/final.vue";
import AdminActivityLogIndex from "./components/demo/admin/activitylog/index.vue";
import ManageElection from "./components/demo/admin/home/home.vue"; import ManageElection from "./components/demo/admin/home/home.vue";
import ManageElectionIndex from "./components/demo/admin/home/index.vue"; import ManageElectionIndex from "./components/demo/admin/home/index.vue";
import ManageElectionResult from "./components/demo/admin/home/result.vue"; import ManageElectionResult from "./components/demo/admin/home/result.vue";
@@ -71,6 +76,12 @@ export default [
name: "Voter Home", name: "Voter Home",
}, },
{
path: "calon",
component: VoterNominees,
name: "Voter Calon",
},
{ {
path: "mat27", path: "mat27",
component: Mat, component: Mat,
@@ -121,6 +132,18 @@ export default [
name: "Voter Verify", name: "Voter Verify",
}, },
{
path: "/display/kod-fizikal",
component: PhysicalGateDisplay,
name: "Physical Gate Display",
},
{
path: "/demo/roulette",
component: RouletteEliminationWheelDemo,
name: "Roulette Wheel Demo",
},
{ {
path: "/admin/login", path: "/admin/login",
component: AdminLogin, component: AdminLogin,
@@ -137,6 +160,12 @@ export default [
name: "Election Result", name: "Election Result",
}, },
{
path: "activity-log",
component: AdminActivityLogIndex,
name: "Activity Log",
},
{ {
path: "", path: "",
component: ManageElection, component: ManageElection,
+37 -2
View File
@@ -108,9 +108,44 @@ const methods = {
message = data.message; message = data.message;
break; break;
case 422: case 422:
// Laravel validation errors are usually:
// { message: "...", errors: { field: ["..."] } }
// but some endpoints may return { status, message }.
data = typeof data == 'string' ? JSON.parse(data) : data; data = typeof data == 'string' ? JSON.parse(data) : data;
for (var i in data)
data[i].map(y=>{message+=y+'<br/>';}); if (!data) {
message = 'Sila semak semula input anda.';
break;
}
if (data.errors && typeof data.errors === 'object') {
for (var field in data.errors) {
var arr = data.errors[field];
if (Array.isArray(arr)) {
arr.forEach(function (y) { message += y + '<br/>'; });
} else if (typeof arr === 'string') {
message += arr + '<br/>';
}
}
break;
}
if (typeof data.message === 'string') {
message = data.message;
break;
}
// Fallback: if it's a plain object, join any string/array values
if (typeof data === 'object') {
for (var i in data) {
var v = data[i];
if (Array.isArray(v)) {
v.forEach(function (y) { message += y + '<br/>'; });
} else if (typeof v === 'string') {
message += v + '<br/>';
}
}
}
break; break;
case 401: case 401:
message = 'You need to login first.'; message = 'You need to login first.';
+8 -2
View File
@@ -8,7 +8,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"> <link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous"> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
<link rel="stylesheet" href="/css/app.css"> @php
$assetV = function ($relativePublicPath) {
$path = public_path($relativePublicPath);
return is_file($path) ? filemtime($path) : time();
};
@endphp
<link rel="stylesheet" href="{{ asset('css/app.css') }}?v={{ $assetV('css/app.css') }}">
</head> </head>
@@ -45,7 +51,7 @@
<script src="https://cdn.jsdelivr.net/npm/jquery-flot@0.8.3/jquery.flot.pie.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/jquery-flot@0.8.3/jquery.flot.pie.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-router@3.4.9/dist/vue-router.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/vue-router@3.4.9/dist/vue-router.min.js"></script>
<script src="/js/app.js"></script> <script src="{{ asset('js/app.js') }}?v={{ $assetV('js/app.js') }}"></script>
<!-- Bootstrap CSS --> <!-- Bootstrap CSS -->
<footer class="bg-body-tertiary text-center text-lg-start"> <footer class="bg-body-tertiary text-center text-lg-start">
+21 -2
View File
@@ -33,22 +33,32 @@ Route::prefix('v1')->group(function () { //Version 1 of my Rest API
Route::middleware(['auth:voterAPI', 'voter'])->group(function () { Route::middleware(['auth:voterAPI', 'voter'])->group(function () {
Route::get('election/information', 'API\v1\Election\InformationController'); Route::get('election/information', 'API\v1\Election\InformationController');
Route::post('election/vote', 'API\v1\Election\VoteController')->middleware('isvoted'); Route::post('election/vote', 'API\v1\Election\VoteController')->middleware(['fizikal_registration_verified', 'isvoted']);
Route::get('election/result', 'API\v1\Election\ResultController')->middleware('has_voted'); Route::get('election/result', 'API\v1\Election\ResultController')->middleware('has_voted');
Route::post('allowance/code', 'API\v1\Allowance\ClaimCodeController');
Route::post('voter/logout', 'API\v1\Voter\LogoutController');
}); });
Route::prefix('attendance')->group(function () { Route::prefix('attendance')->group(function () {
Route::put('{id}', 'API\v1\Voter\AttendanceController'); Route::put('{id}', 'API\v1\Voter\AttendanceController');
}); });
Route::get('physical-attendance-gate/current', 'API\v1\PhysicalAttendanceGateController')
->middleware('physical_gate_display');
Route::get('public/roulette/fizikal-voters', 'API\v1\RouletteFizikalVotersController');
//Admins API //Admins API
Route::post('admin/login', 'API\v1\Admin\LoginController'); //Excluding Login for auth middleware Route::post('admin/login', 'API\v1\Admin\LoginController'); //Excluding Login for auth middleware
Route::middleware(['auth:api', 'admin', 'election'])->group(function () { Route::middleware(['auth:api', 'admin', 'election', 'attendance_committee_role'])->group(function () {
Route::prefix('admin')->group(function () { //Route /api/v1/admin Route::prefix('admin')->group(function () { //Route /api/v1/admin
Route::get('information', 'API\v1\Admin\InformationController'); Route::get('information', 'API\v1\Admin\InformationController');
Route::get('logout', 'API\v1\Admin\LogoutController'); Route::get('logout', 'API\v1\Admin\LogoutController');
Route::get('activity-log', 'API\v1\Admin\ActivityLog\IndexController');
Route::post('impersonate', 'API\v1\Admin\Impersonate\StartController')->middleware('main_admin');
Route::post('impersonate/leave', 'API\v1\Admin\Impersonate\LeaveController');
Route::get('{id}', 'API\v1\Admin\GetController@show'); Route::get('{id}', 'API\v1\Admin\GetController@show');
Route::put('{id}', 'API\v1\Admin\UpdateController'); Route::put('{id}', 'API\v1\Admin\UpdateController');
Route::put('password/{id}', 'API\v1\Admin\UpdateController@updatePassword'); Route::put('password/{id}', 'API\v1\Admin\UpdateController@updatePassword');
@@ -79,6 +89,12 @@ Route::prefix('v1')->group(function () { //Version 1 of my Rest API
Route::put('{id}', 'API\v1\Partylist\UpdateController'); Route::put('{id}', 'API\v1\Partylist\UpdateController');
}); });
Route::prefix('allowance')->group(function () {
Route::post('payout', 'API\v1\Admin\Allowance\PayoutController');
Route::post('verify-code', 'API\v1\Admin\Allowance\VerifyCodeController');
Route::post('void/{id}', 'API\v1\Admin\Allowance\VoidController');
});
Route::prefix('nominee')->group(function () { Route::prefix('nominee')->group(function () {
Route::post('', 'API\v1\Nominee\AddController'); Route::post('', 'API\v1\Nominee\AddController');
Route::get('', 'API\v1\Nominee\GetController'); Route::get('', 'API\v1\Nominee\GetController');
@@ -89,6 +105,9 @@ Route::prefix('v1')->group(function () { //Version 1 of my Rest API
Route::prefix('voter')->group(function () { Route::prefix('voter')->group(function () {
Route::post('', 'API\v1\Voter\AddController'); Route::post('', 'API\v1\Voter\AddController');
Route::get('', 'API\v1\Voter\GetController'); Route::get('', 'API\v1\Voter\GetController');
Route::get('member-catalog', 'API\v1\Voter\AllMembersCatalogController');
Route::post('sync-applicable', 'API\v1\Voter\SyncApplicableVotersController');
Route::post('{id}/verify-fizikal-registration', 'API\v1\Voter\VerifyFizikalRegistrationController');
Route::delete('{id}', 'API\v1\Voter\DeleteController'); Route::delete('{id}', 'API\v1\Voter\DeleteController');
Route::put('{id}', 'API\v1\Voter\UpdateController'); Route::put('{id}', 'API\v1\Voter\UpdateController');
}); });