Allowance check
This commit is contained in:
@@ -44,3 +44,8 @@ MAIL_ENCRYPTION=null
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_APP_KEY=
|
||||
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
|
||||
|
||||
@@ -49,3 +49,9 @@ ONEWAY_SMS_USERNAME=APIFKZQX6MN6N
|
||||
ONEWAY_SMS_PASSWORD=APIFKZQX6MN6N9YHXP
|
||||
ONEWAY_SMS_SENDERID=INFO
|
||||
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=
|
||||
@@ -12,3 +12,4 @@ yarn-error.log
|
||||
.env
|
||||
*sublime*
|
||||
/.vscode
|
||||
/credential.md
|
||||
Vendored
-15
@@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[ ] checking user can vote one or two based on number of nominees
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace App\Console\Commands;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ImportCsv extends Command
|
||||
class ImportCSV extends Command
|
||||
{
|
||||
protected $signature = 'csv:import';
|
||||
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)
|
||||
{
|
||||
$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([
|
||||
'status' => 'success',
|
||||
'message' => 'Admin added successfully'
|
||||
@@ -22,7 +35,7 @@ class AddController extends Controller
|
||||
{
|
||||
$admin = $request->all();
|
||||
$admin['password'] = bcrypt($request->password);
|
||||
User::create($admin);
|
||||
return User::create($admin);
|
||||
}
|
||||
|
||||
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['election_status'] = Util::getElectionStatus();
|
||||
$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 {
|
||||
$result['status'] = 'failed';
|
||||
$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);
|
||||
|
||||
@@ -8,8 +8,31 @@ use App\Http\Controllers\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('voter')->logout();
|
||||
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
|
||||
]);
|
||||
|
||||
$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([
|
||||
'status' => 'success',
|
||||
|
||||
@@ -21,6 +21,18 @@ class StopController extends Controller
|
||||
|
||||
$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([
|
||||
'status' => 'success',
|
||||
'message' => 'Election has finished.',
|
||||
|
||||
@@ -16,13 +16,38 @@ class VoteController extends Controller
|
||||
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
|
||||
if ($this->validateRequest($request)['status'] == 'failed') {
|
||||
return response()->json($this->validateRequest($request));
|
||||
$validation = $this->validateRequest($request);
|
||||
if (($validation['status'] ?? 'failed') === 'failed') {
|
||||
return response()->json($validation);
|
||||
}
|
||||
|
||||
$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([
|
||||
'status' => 'success',
|
||||
'message' => 'Voted successfully',
|
||||
@@ -32,34 +57,31 @@ class VoteController extends Controller
|
||||
|
||||
private function insertVote($request)
|
||||
{
|
||||
// dd($request->vote[0]['nominee_id']);
|
||||
$votes = array_map(function($nominee_id) use($request){
|
||||
return[
|
||||
$vote = $request->vote;
|
||||
if (!is_array($vote)) return;
|
||||
|
||||
foreach ($vote as $value) {
|
||||
$positionId = $value['position_id'] ?? null;
|
||||
$nomineeIds = $value['nominee_id'] ?? [];
|
||||
if (!$positionId || !is_array($nomineeIds) || !count($nomineeIds)) continue;
|
||||
|
||||
foreach ($nomineeIds as $nomineeId) {
|
||||
Result::updateOrCreate([
|
||||
'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($votes as $vote){
|
||||
Result::updateOrCreate([
|
||||
'voter_id' => $vote['voter_id'],
|
||||
'election_id' => $vote['election_id'],
|
||||
'position_id' => $vote['position_id'],
|
||||
'nominee_id' => $vote['nominee_id'],
|
||||
'position_id' => $positionId,
|
||||
'nominee_id' => $nomineeId,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private function validNominee($id, $position_id)
|
||||
{
|
||||
try{
|
||||
$nominee = Nominee::where('position_id', '=', $position_id)->whereIn('id',$id)->count();
|
||||
}catch(Exception $e){
|
||||
dd($id);
|
||||
}catch(\Exception $e){
|
||||
return 0;
|
||||
}
|
||||
return $nominee;
|
||||
}
|
||||
@@ -68,8 +90,8 @@ class VoteController extends Controller
|
||||
{
|
||||
try{
|
||||
return Position::where('id', $id)->count();
|
||||
}catch(Exception $e){
|
||||
dd($id);
|
||||
}catch(\Exception $e){
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,14 +109,8 @@ class VoteController extends Controller
|
||||
$result['status'] = 'failed';
|
||||
$vote = $request->vote;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Check if the user vote on all position
|
||||
*/
|
||||
if (!$this->voteAllPosition($request)) {
|
||||
dd('test');
|
||||
$result['message'] = 'You must vote on all position.';
|
||||
if (!is_array($vote) || !count($vote)) {
|
||||
$result['message'] = 'You must vote on at least one position.';
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -103,19 +119,25 @@ class VoteController extends Controller
|
||||
* Check if position_id and nominee_id has a value
|
||||
*/
|
||||
// dd($vote,$value);
|
||||
if (empty($value['position_id']) || empty($value['nominee_id'])) {
|
||||
|
||||
dd($value,empty($value['position_id']),empty($value['nominee_id']));
|
||||
$result['message'] = 'You must vote on all position.';
|
||||
$positionId = $value['position_id'] ?? null;
|
||||
$nomineeIds = $value['nominee_id'] ?? null;
|
||||
if (empty($positionId)) {
|
||||
$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;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the Position you vote exists
|
||||
*/
|
||||
if (!$this->isPositionExist($value['position_id'])) {
|
||||
dd('test');
|
||||
if (!$this->isPositionExist($positionId)) {
|
||||
$result['message'] = 'Invalid Position.';
|
||||
return $result;
|
||||
}
|
||||
@@ -124,14 +146,28 @@ class VoteController extends Controller
|
||||
* 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']) <= 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
$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([
|
||||
'status' => 'success',
|
||||
'message'=> 'Nominee added successfully'
|
||||
@@ -39,7 +53,11 @@ class AddController extends Controller
|
||||
'unit' => 'required',
|
||||
'position_id' => 'required|exists:position,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::create($nominee);
|
||||
return response()->json(['message' => 'Photo updated successfully']);
|
||||
|
||||
|
||||
return Nominee::create($nominee);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,23 @@ use App\Nominee;
|
||||
|
||||
class DeleteController extends Controller
|
||||
{
|
||||
public function __invoke ($id)
|
||||
public function __invoke (Request $request, $id)
|
||||
{
|
||||
$nominee = Nominee::findOrFail($id);
|
||||
Util::deleteImage($nominee->image);
|
||||
$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([
|
||||
'status' => 'success',
|
||||
'message'=> 'Nominee deleted successfully'
|
||||
|
||||
@@ -5,14 +5,21 @@ namespace App\Http\Controllers\API\v1\Nominee;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Util;
|
||||
use App\Nominee;
|
||||
|
||||
class GetController extends Controller
|
||||
{
|
||||
public function __invoke()
|
||||
{
|
||||
$nominee = \App\Nominee::where('election_id', Util::getCurrentElection())
|
||||
->orderBy('position_id')
|
||||
->get();
|
||||
return $nominee;
|
||||
$q = Nominee::query()
|
||||
->where('election_id', Util::getCurrentElection())
|
||||
->orderBy('position_id');
|
||||
|
||||
$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)
|
||||
{
|
||||
$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([
|
||||
'status' => 'success',
|
||||
'message' => 'Nominee updated successfully'
|
||||
@@ -43,7 +56,7 @@ class UpdateController extends Controller
|
||||
|
||||
private function updateNominee ($request, $id)
|
||||
{
|
||||
$nominee = Nominee::find($id);
|
||||
$nominee = Nominee::findOrFail($id);
|
||||
|
||||
$nominee->name = $request->name;
|
||||
$nominee->unit = $request->unit;
|
||||
@@ -52,6 +65,8 @@ class UpdateController extends Controller
|
||||
$nominee->partylist_id = $request->partylist_id;
|
||||
$nominee->education = $request->education;
|
||||
$nominee->experience = $request->experience;
|
||||
$nominee->umur = $request->umur;
|
||||
$nominee->jawatan_sekarang = $request->jawatan_sekarang;
|
||||
|
||||
if ($request->hasFile('photo')) {
|
||||
$imageData = file_get_contents($request->file('photo'));
|
||||
@@ -59,6 +74,7 @@ class UpdateController extends Controller
|
||||
}
|
||||
|
||||
$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)
|
||||
{
|
||||
$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([
|
||||
'status' => 'success',
|
||||
'message' => 'Voter added successfully'
|
||||
@@ -24,7 +39,7 @@ class AddController extends Controller
|
||||
{
|
||||
$voter = $request->all();
|
||||
$voter['election_id'] = Util::getCurrentElection();
|
||||
Voter::create($voter);
|
||||
return Voter::create($voter);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Validation\Rule;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Util;
|
||||
use App\Services\PhysicalAttendanceGateCode;
|
||||
use App\Voter;
|
||||
|
||||
class AttendanceController extends Controller
|
||||
@@ -14,6 +16,7 @@ class AttendanceController extends Controller
|
||||
public function __invoke(Request $request, $id)
|
||||
{
|
||||
$this->validateRequest($request, $id);
|
||||
$this->assertPhysicalGateCode($request);
|
||||
$this->updateAttendance($request, $id);
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
@@ -23,7 +26,7 @@ class AttendanceController extends Controller
|
||||
|
||||
private function validateRequest($request, $id)
|
||||
{
|
||||
$this->validate($request, [
|
||||
$rules = [
|
||||
'name' => [
|
||||
'required',
|
||||
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)
|
||||
{
|
||||
$voter = $request->all();
|
||||
Voter::find($id)->update($voter);
|
||||
$voter = Voter::findOrFail($id);
|
||||
$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 App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Util;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Voter;
|
||||
|
||||
class GetController extends Controller
|
||||
{
|
||||
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,8 +16,6 @@ class LoginController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
//return response()->json(Auth::guard('user'));
|
||||
|
||||
/* Request OTP */
|
||||
try {
|
||||
$currentDateTime = Carbon::now();
|
||||
@@ -48,6 +46,20 @@ class LoginController extends Controller
|
||||
$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',
|
||||
@@ -60,22 +72,31 @@ class LoginController extends Controller
|
||||
}
|
||||
|
||||
return response()->json($response);
|
||||
}
|
||||
|
||||
}else{
|
||||
return response()->json([
|
||||
'status' => 'failed',
|
||||
'message' => 'Does not found NO KP.'
|
||||
'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 response()->json([
|
||||
'status' => 'failed',
|
||||
'message' => 'Error: ' . $e->getMessage()
|
||||
'message' => 'Error: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkExpirationTAC($nokp) : Array{
|
||||
public function checkExpirationTAC($nokp): array
|
||||
{
|
||||
$currentDateTime = Carbon::now();
|
||||
|
||||
$expiretac = UserOTP::where('nokp', $nokp)
|
||||
@@ -88,45 +109,72 @@ class LoginController extends Controller
|
||||
|
||||
if ($expiretac) {
|
||||
$remaining = $this->getRemainingTime($expiretac->expired_at);
|
||||
|
||||
return ['notexpired' => true, 'remaining' => $remaining];
|
||||
}else{
|
||||
}
|
||||
|
||||
return ['notexpired' => false];
|
||||
}
|
||||
}
|
||||
|
||||
public function verifyTAC(Request $request){
|
||||
|
||||
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']
|
||||
'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
|
||||
'token' => Auth::guard('voter')->user()->createToken('My Token', ['vote'])->accessToken,
|
||||
]);
|
||||
} else {
|
||||
}
|
||||
|
||||
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'
|
||||
'message' => 'No. KP tidak wujud. Atau sesi mengundi telah tamat',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Login successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
private function getTACModel($no_kp,$token){
|
||||
private function getTACModel($no_kp, $token)
|
||||
{
|
||||
$currentDateTime = Carbon::now();
|
||||
|
||||
$result = UserOTP::where('nokp', $no_kp)
|
||||
@@ -143,45 +191,47 @@ class LoginController extends Controller
|
||||
|
||||
if ($result['token'] == $token) {
|
||||
return ['condition' => true];
|
||||
}else{
|
||||
return ['condition' => false, 'message' => 'Invalid Token'];
|
||||
}
|
||||
}
|
||||
|
||||
private function getTokenSMS($tele,$date){
|
||||
return ['condition' => false, 'message' => 'Invalid Token'];
|
||||
}
|
||||
|
||||
private function getTokenSMS($tele, $date)
|
||||
{
|
||||
$tac_token = $this->randum_number(6, 2, false);
|
||||
if(env("APP_ENV") == 'production'){
|
||||
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'),
|
||||
'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)
|
||||
'languagetype' => env('ONEWAY_SMS_LANG', 1),
|
||||
];
|
||||
|
||||
$client = new Client(['verify' => false]);
|
||||
$status = $client->get('http://gateway.onewaysms.com.my:10001/api.aspx', [
|
||||
'query' => $parameter
|
||||
'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)
|
||||
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);
|
||||
@@ -190,8 +240,7 @@ class LoginController extends Controller
|
||||
|
||||
if ($dup > 0) {
|
||||
$k = array_rand($num, 1);
|
||||
for($i=0;$i<($dup-1);$i++)
|
||||
{
|
||||
for ($i = 0; $i < ($dup - 1); $i++) {
|
||||
$num[] = $num[$k];
|
||||
}
|
||||
}
|
||||
@@ -203,14 +252,13 @@ class LoginController extends Controller
|
||||
return implode('', $num);
|
||||
}
|
||||
|
||||
private function getRemainingTime($expired_at){
|
||||
private function getRemainingTime($expired_at)
|
||||
{
|
||||
$currentDateTime = Carbon::now();
|
||||
$expiredAt = Carbon::parse($expired_at);
|
||||
|
||||
$remainingSeconds = $currentDateTime->diffInSeconds($expiredAt);
|
||||
|
||||
$remainingFormatted = gmdate('H:i:s', $remainingSeconds);
|
||||
|
||||
return $remainingFormatted;
|
||||
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)
|
||||
{
|
||||
$voter = $request->all();
|
||||
Voter::find($id)->update($voter);
|
||||
$voter = Voter::findOrFail($id);
|
||||
|
||||
$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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,9 @@ class Kernel extends HttpKernel
|
||||
'scope' => \Laravel\Passport\Http\Middleware\CheckForAnyScope::class,
|
||||
'isvoted' => \App\Http\Middleware\IsVotedMiddleware::class,
|
||||
'main_admin' => \App\Http\Middleware\MainAdminMiddleware::class,
|
||||
'attendance_committee_role' => \App\Http\Middleware\AttendanceCommitteeRoleMiddleware::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;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class ElectionMiddleware
|
||||
if ($this->isElection($request)) {
|
||||
return response()->json([
|
||||
'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)
|
||||
{
|
||||
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
@@ -8,7 +8,6 @@ class Nominee extends Model
|
||||
{
|
||||
protected $table = 'nominee';
|
||||
protected $fillable = [
|
||||
|
||||
'name',
|
||||
'unit',
|
||||
'no_anggota',
|
||||
@@ -18,6 +17,8 @@ class Nominee extends Model
|
||||
'experience',
|
||||
'education',
|
||||
'photo',
|
||||
'umur',
|
||||
'jawatan_sekarang',
|
||||
];
|
||||
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
@@ -5,10 +5,11 @@ namespace App;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Laravel\Passport\HasApiTokens;
|
||||
use Lab404\Impersonate\Models\Impersonate;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, Notifiable;
|
||||
use HasApiTokens, Notifiable, Impersonate;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
@@ -27,4 +28,16 @@ class User extends Authenticatable
|
||||
protected $hidden = [
|
||||
'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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,5 +10,22 @@ class Voter extends Authenticatable
|
||||
use HasApiTokens;
|
||||
|
||||
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 $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
@@ -10,11 +10,13 @@
|
||||
"cloudinary/cloudinary_php": "^2.3",
|
||||
"doctrine/dbal": "^2.5",
|
||||
"guzzlehttp/guzzle": "^6.5",
|
||||
"lab404/laravel-impersonate": "^1.5",
|
||||
"laravel/framework": "5.8.*",
|
||||
"laravel/passport": "^4.0",
|
||||
"laravel/tinker": "~1.0",
|
||||
"lcobucci/jwt": "3.3",
|
||||
"maatwebsite/excel": "^3.1"
|
||||
"maatwebsite/excel": "^3.1",
|
||||
"spatie/laravel-activitylog": "^3.9"
|
||||
},
|
||||
"require-dev": {
|
||||
"fzaninotto/faker": "~1.4",
|
||||
|
||||
Generated
+349
-4
@@ -4,8 +4,63 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "288ad761691b730aeaf754d9b3bff499",
|
||||
"content-hash": "4a6894c6aee2c861018fbf0cc9372342",
|
||||
"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",
|
||||
"version": "v0.8.4",
|
||||
@@ -1802,6 +1857,75 @@
|
||||
"abandoned": "php-parallel-lint/php-console-highlighter",
|
||||
"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",
|
||||
"version": "v5.8.38",
|
||||
@@ -3121,6 +3245,83 @@
|
||||
},
|
||||
"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",
|
||||
"version": "0.5.6",
|
||||
@@ -4041,6 +4242,150 @@
|
||||
},
|
||||
"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",
|
||||
"version": "v6.3.0",
|
||||
@@ -8156,12 +8501,12 @@
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": ">=5.6.4"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.3.0"
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
+43
@@ -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'));
|
||||
}
|
||||
}
|
||||
Generated
+355
-14
@@ -8,7 +8,11 @@
|
||||
"@bachdgvn/vue-otp-input": "^1.0.8",
|
||||
"axios": "^1.13.2",
|
||||
"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": {
|
||||
"babel-cli": "^6.26.0",
|
||||
@@ -1868,23 +1872,14 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.24.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz",
|
||||
"integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"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": {
|
||||
"version": "7.24.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz",
|
||||
@@ -2376,6 +2371,12 @@
|
||||
"@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": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||
@@ -2388,6 +2389,13 @@
|
||||
"integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==",
|
||||
"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": {
|
||||
"version": "1.2.7",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz",
|
||||
@@ -2783,6 +2798,15 @@
|
||||
"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": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@@ -3394,6 +3418,16 @@
|
||||
"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": {
|
||||
"version": "1.5.1",
|
||||
"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": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
|
||||
@@ -4047,6 +4133,15 @@
|
||||
"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": {
|
||||
"version": "4.36.1",
|
||||
"resolved": "https://registry.npmjs.org/collect.js/-/collect.js-4.36.1.tgz",
|
||||
@@ -4319,6 +4414,18 @@
|
||||
"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": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
|
||||
@@ -4437,6 +4544,16 @@
|
||||
"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": {
|
||||
"version": "7.1.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||
@@ -5572,6 +5699,23 @@
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"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": {
|
||||
"version": "1.0.16",
|
||||
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
|
||||
@@ -5602,6 +5746,12 @@
|
||||
"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": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz",
|
||||
@@ -5640,6 +5790,12 @@
|
||||
"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": {
|
||||
"version": "12.4.2",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-12.4.2.tgz",
|
||||
@@ -5806,6 +5962,15 @@
|
||||
"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": {
|
||||
"version": "4.3.7",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-4.1.0.tgz",
|
||||
@@ -6790,6 +6969,12 @@
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz",
|
||||
@@ -7184,6 +7369,44 @@
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz",
|
||||
@@ -8893,6 +9116,13 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.1.tgz",
|
||||
@@ -10399,6 +10639,16 @@
|
||||
"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": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
@@ -11295,6 +11545,18 @@
|
||||
"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": {
|
||||
"version": "0.1.8",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
|
||||
@@ -11521,6 +11793,16 @@
|
||||
"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": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz",
|
||||
@@ -11649,6 +11931,16 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
|
||||
@@ -12035,6 +12327,16 @@
|
||||
"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": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
@@ -12946,6 +13248,24 @@
|
||||
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
||||
"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": {
|
||||
"version": "7.0.0",
|
||||
"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": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
+5
-1
@@ -22,6 +22,10 @@
|
||||
"@bachdgvn/vue-otp-input": "^1.0.8",
|
||||
"axios": "^1.13.2",
|
||||
"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.
Vendored
+47091
-1239
File diff suppressed because one or more lines are too long
+13574
File diff suppressed because one or more lines are too long
+1415
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");
|
||||
|
||||
|
||||
/***/ })
|
||||
|
||||
}]);
|
||||
@@ -130,3 +130,15 @@ You can change it in .env file
|
||||
|
||||
### System Demo
|
||||
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
|
||||
```
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+14
@@ -18,6 +18,20 @@ const router = new VueRouter({
|
||||
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({
|
||||
router
|
||||
}).$mount('#app');
|
||||
|
||||
BIN
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>
|
||||
<option value="1">Admin</option>
|
||||
<option value="2">Jawatankuasa Audit</option>
|
||||
<option value="3">Jawatankuasa Kehadiran</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,36 +1,46 @@
|
||||
<template>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body table-responsive">
|
||||
<div class="panel-body">
|
||||
|
||||
<div class="form-group">
|
||||
<router-link :to="{name: 'Add Account'}" class="btn btn-success">
|
||||
<i class="fa fa-plus"></i> Tambah Akaun Pengguna</router-link>
|
||||
<button class="btn btn-default" @click="refreshAdmin()">
|
||||
<button class="btn btn-default" @click="refreshAdmin()" :disabled="!canViewAdminList">
|
||||
<i class="fa fa-refresh"></i> Refresh</button>
|
||||
</div>
|
||||
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nama</th>
|
||||
<th>Email</th>
|
||||
<th>Padam</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="admin in data.admins">
|
||||
<td>{{ admin.id }}</td>
|
||||
<td>{{ admin.name }}</td>
|
||||
<td>{{ admin.email }}</td>
|
||||
<td>
|
||||
<button class="btn btn-danger" @click="deleteAdmin(admin.id)">
|
||||
<div v-if="!canViewAdminList" class="alert alert-warning" style="margin-bottom:12px;">
|
||||
Akses ditolak: Pengurusan akaun hanya untuk <b>Main Admin</b> (id=1). Jika sedang impersonate, sila
|
||||
<b>Kembali Akaun Asal</b>.
|
||||
</div>
|
||||
|
||||
<admin-data-table
|
||||
:headers="adminTableHeaders"
|
||||
:items="adminItems"
|
||||
:loading="loading"
|
||||
:show-pagination="true"
|
||||
:exportable="true"
|
||||
export-file-name="admins"
|
||||
:items-per-page="25"
|
||||
:show-index="true"
|
||||
index-title="Bil."
|
||||
empty-text="Tiada akaun admin"
|
||||
>
|
||||
<template v-slot:item-impersonate="{ item }">
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
@click="openImpersonate(item)"
|
||||
:disabled="!canImpersonate(item)"
|
||||
>
|
||||
<i class="fa fa-user-secret"></i> Impersonate
|
||||
</button>
|
||||
</template>
|
||||
<template v-slot:item-delete="{ item }">
|
||||
<button class="btn btn-danger btn-sm" @click="deleteAdmin(item.id)">
|
||||
<i class="fa fa-trash"></i> Padam
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</admin-data-table>
|
||||
</div>
|
||||
<modal id="delete-admin-modal">
|
||||
<modal-header>Delete Admin</modal-header>
|
||||
@@ -42,21 +52,127 @@
|
||||
<button @click="util.hideModal('#delete-admin-modal')" class="btn btn-default">Batal</button>
|
||||
</modal-footer>
|
||||
</modal>
|
||||
<modal id="impersonate-admin-modal">
|
||||
<modal-header>Impersonate Admin</modal-header>
|
||||
<modal-body>
|
||||
<h4 v-if="impersonateTarget && impersonateTarget.email">
|
||||
Impersonate <b>{{ impersonateTarget.email }}</b> ?
|
||||
</h4>
|
||||
<p class="text-muted" style="margin-bottom:0;">
|
||||
Anda akan login sebagai akaun tersebut.
|
||||
</p>
|
||||
</modal-body>
|
||||
<modal-footer>
|
||||
<button @click="startImpersonate()" class="btn btn-primary">Impersonate</button>
|
||||
<button @click="util.hideModal('#impersonate-admin-modal')" class="btn btn-default">Batal</button>
|
||||
</modal-footer>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AdminDataTable from '../../../AdminDataTable.vue';
|
||||
|
||||
export default{
|
||||
components: { AdminDataTable },
|
||||
|
||||
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 () {
|
||||
if (this.canViewAdminList) {
|
||||
this.refreshAdmin();
|
||||
} else {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
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 () {
|
||||
this.util.hideModal('#delete-admin-modal');
|
||||
this.util.notify('Deleting admin', 'loading');
|
||||
@@ -74,8 +190,12 @@ export default{
|
||||
},
|
||||
|
||||
refreshAdmin: function () {
|
||||
this.util.notify('Refreshing admin', 'loading');
|
||||
var vm = this;
|
||||
if (!vm.canViewAdminList) {
|
||||
return;
|
||||
}
|
||||
vm.loading = true;
|
||||
this.util.notify('Refreshing admin', 'loading');
|
||||
axios.get(config.API+'admin')
|
||||
.then(response=>{
|
||||
$.notifyClose();
|
||||
@@ -88,6 +208,9 @@ export default{
|
||||
$.notifyClose();
|
||||
vm.util.showResult(error, 'error');
|
||||
})
|
||||
.finally(function () {
|
||||
vm.loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- TABLE -->
|
||||
<table class="table table-bordered table-striped">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>No Anggota</th>
|
||||
<th>Unit</th>
|
||||
<th>Nama</th>
|
||||
<th>Status</th>
|
||||
<th>Tarikh Sah</th>
|
||||
<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)"
|
||||
>
|
||||
<admin-data-table :headers="penyataTableHeaders" :items="penyata" :loading="loading" :show-pagination="true"
|
||||
index-title="Bil." empty-text="Tiada data penyata" :exportable="true">
|
||||
<template v-slot:item-aksi="{ item }">
|
||||
<button type="button" class="btn btn-success btn-sm" @click="cetakPDF(item.no_anggota)"
|
||||
:disabled="!bolehCetak(item.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>
|
||||
</template>
|
||||
</admin-data-table>
|
||||
|
||||
<!-- PAGINATION -->
|
||||
<div
|
||||
class="d-flex justify-content-end gap-2"
|
||||
v-if="meta.last_page > 1"
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
:disabled="meta.current_page === 1"
|
||||
@click="changePage(meta.current_page - 1)"
|
||||
>
|
||||
<div class="text-right" v-if="meta.last_page > 1">
|
||||
<button class="btn btn-default btn-sm" :disabled="meta.current_page === 1"
|
||||
@click="changePage(meta.current_page - 1)">
|
||||
Sebelumnya
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
:disabled="meta.current_page === meta.last_page"
|
||||
@click="changePage(meta.current_page + 1)"
|
||||
>
|
||||
<button class="btn btn-default btn-sm" :disabled="meta.current_page === meta.last_page"
|
||||
@click="changePage(meta.current_page + 1)">
|
||||
Seterusnya
|
||||
</button>
|
||||
</div>
|
||||
@@ -102,6 +37,12 @@ export default {
|
||||
penyata: [],
|
||||
loading: false,
|
||||
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: {
|
||||
current_page: 1,
|
||||
last_page: 1
|
||||
|
||||
@@ -2,41 +2,22 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div v-for="position in positions">
|
||||
<div v-for="position in positions" :key="position.id">
|
||||
<h5>{{ position.name }}</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="20%">No. Anggota</th>
|
||||
<th width="30%">Nama</th>
|
||||
<th width="20%">Unit</th>
|
||||
<th width="10%">Undian</th>
|
||||
<th width="20%">Peratus (%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="result in results" v-if="result.position_id == position.id">
|
||||
<td>{{ getNominee(result.nominee_id)['no_anggota'] }}</td>
|
||||
<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>
|
||||
<admin-data-table
|
||||
:headers="finalTableHeaders"
|
||||
:items="finalRowsByPosition(position.id)"
|
||||
:loading="finalLoading"
|
||||
:items-per-page="10"
|
||||
:show-pagination="true"
|
||||
empty-text="Tiada keputusan"
|
||||
:exportable="true"
|
||||
:export-file-name="'keputusan-akhir-' + position.id"
|
||||
>
|
||||
<template v-slot:item-percentage="{ item }">
|
||||
{{ item.percentage }}
|
||||
</template>
|
||||
</admin-data-table>
|
||||
<hr />
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,11 +32,69 @@ export default {
|
||||
nominees: [],
|
||||
results: [],
|
||||
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: {
|
||||
finalRowsByPosition: function (positionId) {
|
||||
var vm = this;
|
||||
var rows = [];
|
||||
|
||||
// rows with votes
|
||||
this.results.forEach(function (result) {
|
||||
if (result.position_id == positionId) {
|
||||
var n = vm.getNominee(result.nominee_id) || {};
|
||||
rows.push({
|
||||
no_anggota: n.no_anggota || '',
|
||||
name: n.name || '',
|
||||
unit: n.unit || '',
|
||||
votes: result.votes || 0,
|
||||
percentage: vm.calculatePercentage(result.votes || 0, positionId)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// rows without votes
|
||||
this.no_votes.forEach(function (nv) {
|
||||
if (nv.position_id == positionId) {
|
||||
rows.push({
|
||||
no_anggota: nv.no_anggota || '',
|
||||
name: nv.name || '',
|
||||
unit: nv.unit || '',
|
||||
votes: 0,
|
||||
percentage: '0.00'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// total row (kept as a normal row so it exports too)
|
||||
rows.push({
|
||||
no_anggota: '',
|
||||
name: 'Jumlah Undian',
|
||||
unit: '',
|
||||
votes: this.calculateTotalVotes(positionId),
|
||||
percentage: ''
|
||||
});
|
||||
|
||||
// Sort by votes desc, but keep total row last
|
||||
var total = rows.pop();
|
||||
rows.sort(function (a, b) {
|
||||
return (b.votes || 0) - (a.votes || 0);
|
||||
});
|
||||
rows.push(total);
|
||||
|
||||
return rows;
|
||||
},
|
||||
|
||||
getNominee: function (id) {
|
||||
let nominees = this.nominees;
|
||||
for (var i in nominees)
|
||||
@@ -77,7 +116,7 @@ export default {
|
||||
.filter(result => result.position_id === positionId)
|
||||
.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) {
|
||||
@@ -90,6 +129,7 @@ export default {
|
||||
created: function () {
|
||||
this.util.notify('Loading please wait...', 'loading');
|
||||
var vm = this;
|
||||
this.finalLoading = true;
|
||||
axios.get(config.API + 'election/result/' + this.election_id)
|
||||
.then(response => {
|
||||
$.notifyClose();
|
||||
@@ -102,6 +142,9 @@ export default {
|
||||
$.notifyClose();
|
||||
vm.util.showResult(error, 'error');
|
||||
})
|
||||
.finally(function () {
|
||||
vm.finalLoading = false;
|
||||
})
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
@@ -17,37 +17,17 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nama Undian</th>
|
||||
<th>Undian Mula</th>
|
||||
<th>Undian Tamat</th>
|
||||
<th>Papar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
<admin-data-table :headers="electionTableHeaders" :items="data.elections" :loading="electionLoading"
|
||||
:items-per-page="10" :show-pagination="true" empty-text="Tiada undian" :exportable="true"
|
||||
export-file-name="undian">
|
||||
<template v-slot:item-actions="{ item }">
|
||||
<router-link :to="{ name: 'Election Result', params: { election_id: item.id } }"
|
||||
class="btn btn-info">
|
||||
Keputusan Undian
|
||||
</router-link>
|
||||
<a class="btn btn-warning" :href="getPDFurl(item.id)">Muat Turun PDF</a>
|
||||
</template>
|
||||
</admin-data-table>
|
||||
|
||||
<form @submit.prevent="start" id="start_form">
|
||||
<div class="modal fade" id="start-election-modal" tabindex="-1" role="dialog"
|
||||
@@ -122,12 +102,28 @@
|
||||
.center-header {
|
||||
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>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
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 () {
|
||||
this.refreshElection();
|
||||
@@ -184,6 +180,7 @@ export default {
|
||||
refreshElection: function () {
|
||||
this.util.notify('Refreshing Election', 'loading');
|
||||
var vm = this;
|
||||
this.electionLoading = true;
|
||||
axios.get(config.API + 'election')
|
||||
.then(response => {
|
||||
$.notifyClose();
|
||||
@@ -193,6 +190,9 @@ export default {
|
||||
$.notifyClose();
|
||||
vm.util.showResult(error, 'error');
|
||||
})
|
||||
.finally(function () {
|
||||
vm.electionLoading = false;
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
|
||||
@@ -1,26 +1,132 @@
|
||||
<template>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<h4>Jawatan</h4><hr/>
|
||||
<ul class="list-group">
|
||||
<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>
|
||||
{{ position.name }}
|
||||
</router-link>
|
||||
<li class="list-group-item">
|
||||
<center>
|
||||
<button class="btn btn-info" @click="refreshNominees()">
|
||||
Kemaskini Keputusan<i class="fa fa-refresh"></i>
|
||||
</button>
|
||||
</center>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="result-page">
|
||||
<div class="result-header">
|
||||
<div>
|
||||
<div class="result-kicker">Keputusan Undian</div>
|
||||
<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 }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-7">
|
||||
<div class="panel panel-default result-card">
|
||||
<div class="panel-heading result-card__heading">
|
||||
<div class="result-card__title">
|
||||
{{ String(position_id) === '0' ? 'Pemimpin Mengikut Jawatan' : 'Kedudukan Calon' }}
|
||||
</div>
|
||||
<div class="result-card__meta">
|
||||
{{
|
||||
String(position_id) === '0' ? 'Siapa mendahului (dengan beza undi)' :
|
||||
'Mengikut jawatan dipilih'
|
||||
}}
|
||||
</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 class="col-md-8">
|
||||
<h4>Keputusan</h4><hr/>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">{{ getPosition(position_id) }} - Results as of : {{ last_update }}</div>
|
||||
<div class="panel-body">
|
||||
<div id="chart" style="height: 300px; width: 300px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,23 +137,37 @@
|
||||
export default {
|
||||
|
||||
created: function () {
|
||||
console.log(this)
|
||||
this.refreshNominees();
|
||||
this.$nextTick(function () {
|
||||
this.initChart();
|
||||
this.initCharts();
|
||||
});
|
||||
},
|
||||
|
||||
watch: {
|
||||
position_id: function () {
|
||||
this.initChart();
|
||||
var vm = this;
|
||||
this.$nextTick(function () {
|
||||
vm.initCharts();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
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 () {
|
||||
var vm = this;
|
||||
this.util.notify('Refreshing results', 'loading');
|
||||
// Always fetch all nominees (needed for overall dashboards and name lookups)
|
||||
axios.get(config.API + 'nominee')
|
||||
.then(response => {
|
||||
$.notifyClose();
|
||||
@@ -68,7 +188,9 @@ export default{
|
||||
$.notifyClose();
|
||||
vm.data.results = response.data;
|
||||
vm.data.last_update = new Date();
|
||||
vm.initChart();
|
||||
vm.$nextTick(function () {
|
||||
vm.initCharts();
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
$.notifyClose();
|
||||
@@ -76,15 +198,45 @@ export default{
|
||||
})
|
||||
},
|
||||
|
||||
initChart: function () {
|
||||
$.plot($('#chart'), this.datas, {
|
||||
series: {
|
||||
pie: {
|
||||
show: true,
|
||||
innerRadius: 0.5
|
||||
initCharts: function () {
|
||||
// Clear any previously rendered plots to avoid overlap when switching views
|
||||
if ($('#top-chart').length) $('#top-chart').empty();
|
||||
if ($('#votes-by-position-chart').length) $('#votes-by-position-chart').empty();
|
||||
|
||||
// All positions summary
|
||||
if (String(this.position_id) === '0') {
|
||||
if ($('#top-chart').length) {
|
||||
var ticks = this.topNominees.map(function (n, i) { return [i, n.label]; });
|
||||
var series = [{
|
||||
label: 'Undi',
|
||||
data: this.topNominees.map(function (n, i) { return [i, n.votes]; }),
|
||||
bars: { show: true, barWidth: 0.6, align: 'center', fill: 0.85, lineWidth: 0 }
|
||||
}];
|
||||
$.plot($('#top-chart'), series, {
|
||||
xaxis: { ticks: ticks, rotateTicks: 45 },
|
||||
yaxis: { min: 0, tickDecimals: 0 },
|
||||
grid: { hoverable: true, borderColor: '#e5e7eb' }
|
||||
});
|
||||
}
|
||||
|
||||
if ($('#votes-by-position-chart').length) {
|
||||
var pt = this.positionTotals;
|
||||
var ptTicks = pt.map(function (p, i) { return [i, p.label]; });
|
||||
var ptSeries = [{
|
||||
label: 'Undi',
|
||||
data: pt.map(function (p, i) { return [i, p.votes]; }),
|
||||
bars: { show: true, barWidth: 0.6, align: 'center', fill: 0.55, lineWidth: 0 }
|
||||
}];
|
||||
$.plot($('#votes-by-position-chart'), ptSeries, {
|
||||
xaxis: { ticks: ptTicks, rotateTicks: 45 },
|
||||
yaxis: { min: 0, tickDecimals: 0 },
|
||||
grid: { hoverable: true, borderColor: '#e5e7eb' }
|
||||
});
|
||||
}
|
||||
})
|
||||
return;
|
||||
}
|
||||
|
||||
// For per-position view, we intentionally do not render charts (UI request).
|
||||
},
|
||||
|
||||
getPosition: function (id) {
|
||||
@@ -113,13 +265,12 @@ export default{
|
||||
computed: {
|
||||
last_update: function () {
|
||||
let x = this.data.last_update;
|
||||
if (!x) return '-';
|
||||
return x.toDateString() + ' ' + x.toLocaleTimeString();
|
||||
},
|
||||
|
||||
position_id: function () {
|
||||
return this.$route.query.position_id ?
|
||||
this.$route.query.position_id :
|
||||
this.data.positions[0]['id'];
|
||||
return this.$route.query.position_id ? this.$route.query.position_id : 0;
|
||||
},
|
||||
|
||||
datas: function () {
|
||||
@@ -135,7 +286,427 @@ export default{
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
||||
<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>
|
||||
</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'
|
||||
border='0' width="150" height="auto" alt="MyKoPKB Logo">
|
||||
</a>
|
||||
@@ -18,7 +18,8 @@
|
||||
|
||||
<ul class="nav navbar-nav">
|
||||
<!-- {{ 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>
|
||||
<router-link :to="{ name: 'Kemaskini Jawatan' }" v-if="data.user.role == 1" tag="li">
|
||||
<a href="#"><b>Jawatan</b></a>
|
||||
@@ -28,7 +29,7 @@
|
||||
<a href="#">Manage Partylist</a>
|
||||
</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>
|
||||
</router-link>
|
||||
|
||||
@@ -36,7 +37,8 @@
|
||||
<a href="#"><b>Calon</b></a>
|
||||
</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>
|
||||
</router-link>
|
||||
|
||||
@@ -48,6 +50,10 @@
|
||||
<a href="#"><b>Penyata Anggota</b></a>
|
||||
</router-link>
|
||||
|
||||
<router-link :to="{ name: 'Activity Log' }" v-if="data.user.role == 1" tag="li">
|
||||
<a href="#"><b>Log Aktiviti</b></a>
|
||||
</router-link>
|
||||
|
||||
|
||||
</ul>
|
||||
<ul class="nav navbar-right navbar-nav">
|
||||
@@ -58,15 +64,24 @@
|
||||
</a>
|
||||
<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>
|
||||
</router-link>
|
||||
|
||||
<li v-if="isImpersonating()" @click="leaveImpersonation()">
|
||||
<a>Kembali Akaun Asal</a>
|
||||
</li>
|
||||
|
||||
<router-link :to="{ name: 'Manage Account' }" tag="li" v-if="data.user.id == 1">
|
||||
<a href="#">Pengurusan Akaun</a>
|
||||
</router-link>
|
||||
|
||||
<li @click="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>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -106,6 +121,12 @@ export default {
|
||||
vm.data.election = response.data.election;
|
||||
vm.data.partylists = response.data.partylist;
|
||||
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;
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -120,7 +141,47 @@ export default {
|
||||
},
|
||||
|
||||
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 () {
|
||||
localStorage.removeItem('admin_role');
|
||||
localStorage.removeItem('is_impersonating');
|
||||
localStorage.clear();
|
||||
this.$router.push({ name: 'Admin Login' });
|
||||
}
|
||||
|
||||
@@ -62,8 +62,13 @@ export default {
|
||||
},
|
||||
|
||||
created: function () {
|
||||
if (this.util.isLogin())
|
||||
return this.$router.push({ name: 'Admin Home' })
|
||||
if (this.util.isLogin()) {
|
||||
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 ');
|
||||
},
|
||||
|
||||
@@ -80,9 +85,16 @@ export default {
|
||||
vm.stopLoading();
|
||||
if (this.util.showResult(response, 'success')) {
|
||||
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();
|
||||
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 => {
|
||||
vm.stopLoading();
|
||||
|
||||
@@ -1,78 +1,193 @@
|
||||
<template>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel panel-default nominee-add-page">
|
||||
<div class="panel-body">
|
||||
<form class="row" method="POST" id="add_form" :action="data.API + 'nominee'" enctype="mutlipart/formdata"
|
||||
@submit.prevent="add()">
|
||||
<div class="col-md-8">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Nama Calon</label>
|
||||
<input type="text" name="name" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="no_anggota">No. Anggota</label>
|
||||
<input type="text" name="no_anggota" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="unit">Unit</label>
|
||||
<input type="text" name="unit" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="unit">Umur</label>
|
||||
<input type="text" name="umur" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="unit">Jawatan Sekarang</label>
|
||||
<input type="text" name="jawatan_sekarang" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="education">Taraf Pendidikan</label>
|
||||
<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
|
||||
<div class="nominee-add-page__header clearfix">
|
||||
<h4 class="pull-left nominee-add-page__title">
|
||||
<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>
|
||||
<input type="submit" value="Submit" class="btn btn-info">
|
||||
|
||||
</div>
|
||||
|
||||
<p class="text-muted small nominee-add-page__intro">
|
||||
Lengkapkan maklumat calon. Medan bertanda <span class="text-danger">*</span> adalah wajib.
|
||||
</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 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 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>
|
||||
|
||||
<hr class="nominee-add-form__rule" />
|
||||
|
||||
<div class="clearfix nominee-add-form__actions">
|
||||
<router-link
|
||||
:to="{ name: 'Maklumat Calon', query: { position_id: position_id } }"
|
||||
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 calon' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -115,24 +230,20 @@ export default {
|
||||
},
|
||||
|
||||
handleImageChange(event) {
|
||||
const file = event.target.files[0]; // Get the selected file
|
||||
const imageType = /image.*/; // RegExp to check if the file is an image
|
||||
const file = event.target.files[0];
|
||||
const imageType = /image.*/;
|
||||
|
||||
// Check if the selected file is an image
|
||||
if (file && file.type.match(imageType)) {
|
||||
const reader = new FileReader(); // Create a FileReader object
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
this.imageUrl = e.target.result; // Set the imageUrl to the data URL of the image
|
||||
document.getElementById('imagePreview').style.display = 'block'; // Display the div
|
||||
this.imageUrl = e.target.result;
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file); // Read the image data as a data URL
|
||||
reader.readAsDataURL(file);
|
||||
} 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';
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -152,16 +263,115 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#imagePreview {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border: 1px solid #ccc;
|
||||
margin-bottom: 10px;
|
||||
display: none; /* Initially hide the div */
|
||||
.nominee-add-page__header {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
#imagePreview img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
.nominee-add-page__title {
|
||||
margin-top: 0;
|
||||
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>
|
||||
|
||||
@@ -1,84 +1,215 @@
|
||||
<template>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel panel-default nominee-add-page">
|
||||
<div class="panel-body">
|
||||
<div class="nominee-add-page__header clearfix">
|
||||
<h4 class="pull-left nominee-add-page__title">
|
||||
<i class="fa fa-pencil text-muted"></i> Kemaskini Calon
|
||||
</h4>
|
||||
<router-link :to="backToList" class="btn btn-default pull-right">
|
||||
<i class="fa fa-arrow-left"></i> Kembali
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
class="row"
|
||||
id="edit_form"
|
||||
:action="data.API + 'nominee/' + id"
|
||||
enctype="multipart/form-data"
|
||||
@submit.prevent="edit()">
|
||||
@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="name">Nama</label>
|
||||
<input type="text" name="name" class="form-control" :value="nominee.name" required/>
|
||||
<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="student_id">No. Anggota</label>
|
||||
<input type="text" name="no_anggota" class="form-control" :value="nominee.no_anggota" required/>
|
||||
<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="Unit">Unit</label>
|
||||
<input type="text" name="unit" class="form-control" :value="nominee.unit" required/>
|
||||
<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="umur">Umur</label>
|
||||
<input type="text" name="umur" class="form-control" :value="nominee.umur" required/>
|
||||
<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="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>
|
||||
<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="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>
|
||||
<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 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
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
@@ -90,66 +221,103 @@ export default{
|
||||
data: function () {
|
||||
return {
|
||||
imageUrl: '',
|
||||
loading: false
|
||||
loading: false,
|
||||
form: {
|
||||
name: '',
|
||||
no_anggota: '',
|
||||
unit: '',
|
||||
umur: '',
|
||||
jawatan_sekarang: '',
|
||||
education: '',
|
||||
experience: '',
|
||||
position_id: '',
|
||||
partylist_id: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
created: function () {
|
||||
if (!this.nominee.id)
|
||||
this.$router.push({name:'Maklumat Calon'});
|
||||
if (!this.nominee.id) this.$router.push({ name: 'Maklumat Calon' })
|
||||
},
|
||||
|
||||
mounted: function () {
|
||||
if(this.nominee.id) {
|
||||
this.imageUrl = this.createBase64ImageUrl(this.nominee.photo);
|
||||
document.getElementById('imagePreview').style.display = 'block';
|
||||
watch: {
|
||||
nominee: {
|
||||
immediate: true,
|
||||
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: {
|
||||
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 (val == null || val === '') return ''
|
||||
if (Array.isArray(val)) return val.join('\n')
|
||||
return String(val)
|
||||
},
|
||||
|
||||
formatExperienceForInput: function (val) {
|
||||
if (val == null || val === '') return ''
|
||||
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)) {
|
||||
const reader = new FileReader(); // Create a FileReader object
|
||||
|
||||
reader.onload = (e) => {
|
||||
this.imageUrl = e.target.result; // Set the imageUrl to the data URL of the image
|
||||
document.getElementById('imagePreview').style.display = 'block'; // Display the div
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file); // Read the image data as a data URL
|
||||
var reader = new FileReader()
|
||||
reader.onload = function (e) {
|
||||
vm.imageUrl = e.target.result
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
} 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';
|
||||
event.target.value = ''
|
||||
vm.imageUrl = vm.nominee.photo ? vm.createBase64ImageUrl(vm.nominee.photo) : ''
|
||||
}
|
||||
},
|
||||
|
||||
edit: function () {
|
||||
if (this.loading) return;
|
||||
var vm = this;
|
||||
this.loading = true;
|
||||
this.util.notify('Kemaskini undian', 'progress', 0);
|
||||
if (this.loading) return
|
||||
var vm = this
|
||||
this.loading = true
|
||||
this.util.notify('Kemaskini calon', 'progress', 0)
|
||||
$('#edit_form').ajaxSubmit({
|
||||
success: function (response) {
|
||||
$.notifyClose();
|
||||
vm.loading = false;
|
||||
$.notifyClose()
|
||||
vm.loading = false
|
||||
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) {
|
||||
$.notifyClose();
|
||||
vm.loading = false;
|
||||
vm.util.showResult(error, 'error', 'ajax');
|
||||
$.notifyClose()
|
||||
vm.loading = false
|
||||
vm.util.showResult(error, 'error', 'ajax')
|
||||
},
|
||||
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: {
|
||||
id: function () {
|
||||
return this.$route.params.id;
|
||||
return this.$route.params.id
|
||||
},
|
||||
|
||||
nominee: function () {
|
||||
for (var i in this.data.nominees)
|
||||
if(this.data.nominees[i].id == this.id)
|
||||
return this.data.nominees[i];
|
||||
if (this.data.nominees[i].id == this.id) return this.data.nominees[i]
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
#imagePreview {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border: 1px solid #ccc;
|
||||
margin-bottom: 10px;
|
||||
display: none; /* Initially hide the div */
|
||||
.nominee-add-page__header {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
#imagePreview img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
.nominee-add-page__title {
|
||||
margin-top: 0;
|
||||
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>
|
||||
|
||||
@@ -1,77 +1,71 @@
|
||||
<template>
|
||||
<div class="row">
|
||||
<div class="col-md-3" style="max-width: 250px;">
|
||||
<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="col-md-12 panel panel-default">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Nama</th>
|
||||
<th>No. Anggota</th>
|
||||
<th>Unit</th>
|
||||
<th>Jawatan</th>
|
||||
<!-- <th>Partylist</th> -->
|
||||
<th>Taraf Pendidikan </th>
|
||||
<th>Pengalaman Kerja</th>
|
||||
<th>Tindakan</th>
|
||||
<admin-data-table
|
||||
:headers="nomineeTableHeaders"
|
||||
:items="nominees"
|
||||
:loading="nomineeLoading"
|
||||
:items-per-page="10"
|
||||
:show-pagination="true"
|
||||
empty-text="Tiada calon"
|
||||
:exportable="true"
|
||||
export-file-name="calon"
|
||||
>
|
||||
<template v-slot:item-photo="{ item }">
|
||||
<img
|
||||
:alt="item.name"
|
||||
:src="createBase64ImageUrl(item.photo)"
|
||||
class="thumbnail"
|
||||
style="height: 60px; width: 60px;"
|
||||
>
|
||||
</template>
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="nominee in nominees" :key="nominee.id">
|
||||
<td>
|
||||
<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>
|
||||
<template v-slot:item-education="{ item }">
|
||||
<ol style="margin: 0; padding-left: 18px;">
|
||||
<li v-for="(x, idx) in (item.education || [])" :key="idx">{{ x }}</li>
|
||||
</ol>
|
||||
</td>
|
||||
<td>
|
||||
<ol>
|
||||
<li v-for="(item, index) in nominee.experience" :key="index">{{ item }}</li>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
|
||||
<router-link :to="{ name: 'Edit Nominee', params: { id: nominee.id } }"
|
||||
class="btn btn-primary">
|
||||
<template v-slot:item-actions="{ item }">
|
||||
<router-link :to="{ name: 'Edit Nominee', params: { id: item.id } }" class="btn btn-primary">
|
||||
<i class="fa fa-edit"></i>Kemaskini
|
||||
</router-link>
|
||||
<button class="btn btn-danger" @click="openDeleteModal(nominee)">
|
||||
<button type="button" class="btn btn-danger" @click="openDeleteModal(item)">
|
||||
<i class="fa fa-trash"></i>Padam
|
||||
</button>
|
||||
|
||||
</tr>
|
||||
<tr v-if="nominees.length < 1">
|
||||
<td colspan="7">No Calon</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</admin-data-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,7 +91,32 @@ export default {
|
||||
|
||||
data: function () {
|
||||
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: {
|
||||
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) {
|
||||
this.id = nominee.id;
|
||||
@@ -140,8 +168,13 @@ export default {
|
||||
|
||||
refreshNominee: function () {
|
||||
var vm = this;
|
||||
this.nomineeLoading = true;
|
||||
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 => {
|
||||
$.notifyClose();
|
||||
console.log(response);
|
||||
@@ -151,6 +184,9 @@ export default {
|
||||
$.notifyClose();
|
||||
vm.showResult(error);
|
||||
})
|
||||
.finally(function () {
|
||||
vm.nomineeLoading = false;
|
||||
})
|
||||
},
|
||||
|
||||
getPosition: function (id) {
|
||||
@@ -179,7 +215,7 @@ export default {
|
||||
var y = this.data.nominees;
|
||||
for (var nominee in this.data.nominees) {
|
||||
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.partylist = this.getPartylist(y[nominee].partylist_id);
|
||||
nominees.push(x);
|
||||
@@ -191,9 +227,28 @@ export default {
|
||||
position_id: function () {
|
||||
return this.$route.query.position_id ? this.$route.query.position_id : 0;
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
position_id: function () {
|
||||
this.refreshNominee();
|
||||
}
|
||||
}
|
||||
}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" id="position_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nama</th>
|
||||
<th>Tindakan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(position, i) in data.positions">
|
||||
<td>{{ position.id }}</td>
|
||||
<td>{{ position.name }}</td>
|
||||
<td>
|
||||
<button class="btn btn-info" @click="edit(i)">
|
||||
<admin-data-table :headers="positionTableHeaders" :items="data.positions" :loading="positionLoading"
|
||||
:items-per-page="10" :show-pagination="true" empty-text="Tiada jawatan" :exportable="true"
|
||||
export-file-name="jawatan">
|
||||
<template v-slot:item-actions="{ item }">
|
||||
<button type="button" class="btn btn-info" @click="edit(item)">
|
||||
<i class="fa fa-edit"></i> Edit
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="util.showModal('#delete-position-modal'); id = position.id">
|
||||
<button type="button" class="btn btn-danger"
|
||||
@click="util.showModal('#delete-position-modal'); id = item.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>
|
||||
</template>
|
||||
</admin-data-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +47,12 @@
|
||||
export default {
|
||||
data: function () {
|
||||
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: {
|
||||
refreshPosition: function () {
|
||||
var vm = this;
|
||||
this.positionLoading = true;
|
||||
this.util.notify('Refreshing Position', 'loading');
|
||||
axios.get(config.API + 'position')
|
||||
.then(response => {
|
||||
console.log(response)
|
||||
$.notifyClose();
|
||||
vm.data.positions = response.data;
|
||||
})
|
||||
@@ -87,22 +77,14 @@ export default {
|
||||
$.notifyClose();
|
||||
vm.util.showResult(error);
|
||||
})
|
||||
.finally(function () {
|
||||
vm.positionLoading = false;
|
||||
})
|
||||
},
|
||||
|
||||
initDatatable: function () {
|
||||
edit: function (position) {
|
||||
var vm = this;
|
||||
$('#position_table').DataTable({
|
||||
destroy: true,
|
||||
searching: false,
|
||||
info: false,
|
||||
autoWidth: false,
|
||||
dom: 'Bfrtip'
|
||||
});
|
||||
},
|
||||
|
||||
edit: function (i) {
|
||||
var vm = this;
|
||||
this.data.position = this.data.positions[i];
|
||||
this.data.position = position;
|
||||
this.$router.push({ name: 'Edit Position', params: { id: vm.data.position.id } })
|
||||
},
|
||||
|
||||
|
||||
@@ -1,94 +1,467 @@
|
||||
<template>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel panel-default voter-add-page">
|
||||
<div class="panel-body">
|
||||
<h4>Tambah Pengundi</h4>
|
||||
<div class="voter-add-page__header clearfix">
|
||||
<h4 class="pull-left">Tambah Pengundi</h4>
|
||||
<div class="pull-right voter-add-page__header-actions">
|
||||
<button type="button" class="btn btn-success" @click="openManualModal">
|
||||
<i class="fa fa-user-plus"></i> Daftar pengundi baru
|
||||
</button>
|
||||
<router-link :to="{ name: 'Manage Voter' }" class="btn btn-default">
|
||||
<i class="fa fa-arrow-left"></i> Kembali
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted small voter-add-page__intro">
|
||||
Kiri: keseluruhan pengundi (ikut No. KP, data terkini). Kanan: yang ditanda layak untuk pilihan raya
|
||||
semasa.
|
||||
Yang tidak berada di senarai kanan akan dipadam daripada pilihan raya ini apabila anda simpan.
|
||||
</p>
|
||||
|
||||
<div class="voter-add-split row">
|
||||
<div class="col-md-6 voter-add-split__col">
|
||||
<div class="panel panel-default voter-add-split__panel">
|
||||
<div class="panel-heading clearfix">
|
||||
<strong class="pull-left">Semua pengundi</strong>
|
||||
<span v-if="catalogLoading" class="text-muted small pull-left voter-add-heading__loading">
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
<span class="badge pull-right" title="Jumlah pengundi unik (No. KP)">{{ catalog.length
|
||||
}}</span>
|
||||
<span v-if="catalogFilter"
|
||||
class="text-muted small pull-right voter-add-heading__filter-hint">
|
||||
{{ filteredCatalog.length }} dipaparkan ·
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="form-inline voter-batch-toolbar">
|
||||
<input type="text" class="form-control" v-model.trim="catalogFilter"
|
||||
placeholder="Tapis nama / No. KP / No. anggota…" />
|
||||
<button type="button" class="btn btn-default btn-sm" @click="selectAllFiltered">Tanda
|
||||
semua (ditapis)</button>
|
||||
<button type="button" class="btn btn-default btn-sm" @click="clearAllFiltered">Nyahtanda
|
||||
(ditapis)</button>
|
||||
</div>
|
||||
<div v-if="catalogError" class="alert alert-danger">{{ catalogError }}</div>
|
||||
<div class="table-responsive voter-add-table-wrap">
|
||||
<table class="table table-bordered table-condensed table-striped"
|
||||
v-if="!catalogLoading || catalog.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:44px;">
|
||||
<input type="checkbox" :checked="headerChecked"
|
||||
@change="toggleHeader($event)" />
|
||||
</th>
|
||||
<th>Nama</th>
|
||||
<th>No. KP</th>
|
||||
<th>No. Anggota</th>
|
||||
<th>Unit</th>
|
||||
<th style="width:88px;">PR semasa</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in filteredCatalog" :key="row.no_kp">
|
||||
<td>
|
||||
<input type="checkbox" :checked="isSelected(row.no_kp)"
|
||||
@change="toggleRow(row.no_kp, $event)" />
|
||||
</td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.no_kp }}</td>
|
||||
<td>{{ row.no_anggota }}</td>
|
||||
<td>{{ row.unit }}</td>
|
||||
<td>
|
||||
<span v-if="row.in_current_election"
|
||||
class="label label-success">Ya</span>
|
||||
<span v-else class="label label-default">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="!catalogLoading && !catalog.length" class="text-muted">Tiada rekod pengundi dalam
|
||||
pangkalan data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 voter-add-split__col">
|
||||
<div class="panel panel-info voter-add-split__panel">
|
||||
<div class="panel-heading clearfix">
|
||||
<strong class="pull-left">Layak mengundi (pilihan raya semasa)</strong>
|
||||
<span class="badge pull-right" title="Jumlah ditanda layak">{{ selectedNoKp.length }}</span>
|
||||
<span v-if="selectedFilter"
|
||||
class="text-muted small pull-right voter-add-heading__filter-hint">
|
||||
{{ filteredSelectedRows.length }} dipaparkan ·
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="form-inline voter-batch-toolbar voter-batch-toolbar--right">
|
||||
<input type="text" class="form-control" v-model.trim="selectedFilter"
|
||||
placeholder="Tapis senarai dipilih…" />
|
||||
<button type="button" class="btn btn-default btn-sm" @click="clearAllSelected"
|
||||
:disabled="!selectedNoKp.length">
|
||||
Kosongkan semua
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary"
|
||||
:disabled="catalogSaving || catalogLoading" @click="saveApplicable()">
|
||||
Simpan pilihan
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive voter-add-table-wrap voter-add-table-wrap--selected">
|
||||
<table class="table table-bordered table-condensed table-striped"
|
||||
v-if="filteredSelectedRows.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nama</th>
|
||||
<th>No. KP</th>
|
||||
<th>No. Anggota</th>
|
||||
<th style="width:52px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in filteredSelectedRows" :key="row.no_kp">
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.no_kp }}</td>
|
||||
<td>{{ row.no_anggota }}</td>
|
||||
<td class="text-center">
|
||||
<button type="button" class="btn btn-xs btn-danger" title="Buang"
|
||||
@click="removeSelected(row.no_kp)">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="text-muted voter-add-empty-selected">Tiada pengundi ditanda. Tandakan
|
||||
pada jadual kiri.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for adding new voter -->
|
||||
<modal id="add-voter-manual-modal">
|
||||
<modal-header>Daftar pengundi baru</modal-header>
|
||||
<modal-body>
|
||||
<form @submit.prevent="add()" id="add-form">
|
||||
<div class="form-group">
|
||||
<label for="name">Nama Anggota</label>
|
||||
<input type="text" name="name" class="form-control" required>
|
||||
<label for="add-form-name">Nama Anggota</label>
|
||||
<input id="add-form-name" type="text" name="name" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="no_kp">No. Kad Pengenalan</label>
|
||||
<input type="text" name="no_kp" class="form-control" required>
|
||||
<label for="add-form-no_kp">No. Kad Pengenalan</label>
|
||||
<input id="add-form-no_kp" type="text" name="no_kp" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="no_anggota">No. Anggota</label>
|
||||
<input type="text" name="no_anggota" class="form-control" required>
|
||||
<label for="add-form-no_anggota">No. Anggota</label>
|
||||
<input id="add-form-no_anggota" type="text" name="no_anggota" class="form-control" required>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<label for="unit">Unit</label>
|
||||
<input type="text" name="unit" class="form-control" required>
|
||||
<label for="add-form-unit">Unit</label>
|
||||
<input id="add-form-unit" type="text" name="unit" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="alamat">Alamat</label>
|
||||
<input type="text" name="alamat" class="form-control" required>
|
||||
<label for="add-form-alamat">Alamat</label>
|
||||
<input id="add-form-alamat" type="text" name="alamat" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="status_anggota">Status Anggota</label>
|
||||
<select name="status_anggota" class="form-control" required>
|
||||
<label for="add-form-status_anggota">Status Anggota</label>
|
||||
<select id="add-form-status_anggota" name="status_anggota" class="form-control" required>
|
||||
<option value="inactive">-- Pilih Status --</option>
|
||||
<option value="active">Aktif</option>
|
||||
<option value="inactive">Berhenti</option>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="telefon">No. Telefon</label>
|
||||
<input type="text" name="telefon" class="form-control" required>
|
||||
<label for="add-form-telefon">No. Telefon</label>
|
||||
<input id="add-form-telefon" type="text" name="telefon" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="saham">Saham</label>
|
||||
<input type="text" name="saham" class="form-control" required>
|
||||
<label for="add-form-saham">Saham</label>
|
||||
<input id="add-form-saham" type="text" name="saham" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="yuran">Yuran</label>
|
||||
<input type="text" name="yuran" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-success">Submit</button>
|
||||
<router-link :to="{name: 'Manage Voter'}" class="btn btn-default">Back</router-link>
|
||||
<label for="add-form-yuran">Yuran</label>
|
||||
<input id="add-form-yuran" type="text" name="yuran" class="form-control" required>
|
||||
</div>
|
||||
</form>
|
||||
</modal-body>
|
||||
<modal-footer>
|
||||
<button type="button" class="btn btn-success" :disabled="loading" @click="add()">Hantar</button>
|
||||
<button type="button" class="btn btn-default" @click="closeManualModal">Batal</button>
|
||||
</modal-footer>
|
||||
</modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.voter-add-page__header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.voter-add-page__header h4 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.voter-add-page__header-actions .btn {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.voter-add-page__intro {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.voter-add-split__col {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.voter-add-split__panel {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.voter-batch-toolbar .form-control {
|
||||
min-width: 180px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.voter-batch-toolbar .btn {
|
||||
margin-right: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.voter-add-table-wrap {
|
||||
max-height: min(520px, calc(100vh - 280px));
|
||||
overflow: auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.voter-add-table-wrap--selected {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.voter-add-empty-selected {
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
|
||||
.voter-add-heading__loading {
|
||||
margin-left: 8px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.voter-add-heading__filter-hint {
|
||||
margin-right: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data: function () {
|
||||
return {
|
||||
loading: false
|
||||
loading: false,
|
||||
catalog: [],
|
||||
catalogLoading: false,
|
||||
catalogSaving: false,
|
||||
catalogError: '',
|
||||
catalogFilter: '',
|
||||
selectedFilter: '',
|
||||
selectedNoKp: []
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
catalogByKp: function () {
|
||||
var map = {};
|
||||
var i;
|
||||
for (i = 0; i < this.catalog.length; i++) {
|
||||
map[this.catalog[i].no_kp] = this.catalog[i];
|
||||
}
|
||||
return map;
|
||||
},
|
||||
|
||||
selectedRows: function () {
|
||||
var map = this.catalogByKp;
|
||||
var out = [];
|
||||
var i;
|
||||
for (i = 0; i < this.selectedNoKp.length; i++) {
|
||||
var kp = this.selectedNoKp[i];
|
||||
if (map[kp]) out.push(map[kp]);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
filteredCatalog: function () {
|
||||
var q = (this.catalogFilter || '').toLowerCase();
|
||||
if (!q) return this.catalog;
|
||||
return this.catalog.filter(function (row) {
|
||||
return (row.name && String(row.name).toLowerCase().indexOf(q) !== -1)
|
||||
|| (row.no_kp && String(row.no_kp).toLowerCase().indexOf(q) !== -1)
|
||||
|| (row.no_anggota && String(row.no_anggota).toLowerCase().indexOf(q) !== -1)
|
||||
|| (row.unit && String(row.unit).toLowerCase().indexOf(q) !== -1);
|
||||
});
|
||||
},
|
||||
|
||||
filteredSelectedRows: function () {
|
||||
var rows = this.selectedRows;
|
||||
var q = (this.selectedFilter || '').toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter(function (row) {
|
||||
return (row.name && String(row.name).toLowerCase().indexOf(q) !== -1)
|
||||
|| (row.no_kp && String(row.no_kp).toLowerCase().indexOf(q) !== -1)
|
||||
|| (row.no_anggota && String(row.no_anggota).toLowerCase().indexOf(q) !== -1)
|
||||
|| (row.unit && String(row.unit).toLowerCase().indexOf(q) !== -1);
|
||||
});
|
||||
},
|
||||
|
||||
headerChecked: function () {
|
||||
var rows = this.filteredCatalog;
|
||||
if (!rows.length) return false;
|
||||
var vm = this;
|
||||
return rows.every(function (r) { return vm.isSelected(r.no_kp); });
|
||||
}
|
||||
},
|
||||
|
||||
created: function () {
|
||||
this.loadMemberCatalog();
|
||||
},
|
||||
|
||||
methods: {
|
||||
openManualModal: function () {
|
||||
this.util.showModal('#add-voter-manual-modal');
|
||||
},
|
||||
|
||||
closeManualModal: function () {
|
||||
this.util.hideModal('#add-voter-manual-modal');
|
||||
},
|
||||
|
||||
resetManualForm: function () {
|
||||
var el = document.getElementById('add-form');
|
||||
if (el) el.reset();
|
||||
},
|
||||
|
||||
loadMemberCatalog: function () {
|
||||
var vm = this;
|
||||
this.catalogLoading = true;
|
||||
this.catalogError = '';
|
||||
axios.get(config.API + 'voter/member-catalog')
|
||||
.then(function (res) {
|
||||
vm.catalog = (res.data && res.data.catalog) ? res.data.catalog : [];
|
||||
vm.selectedNoKp = vm.catalog.filter(function (r) { return r.in_current_election; }).map(function (r) { return r.no_kp; });
|
||||
})
|
||||
.catch(function (err) {
|
||||
vm.catalogError = (err.response && err.response.data && err.response.data.message)
|
||||
? err.response.data.message
|
||||
: 'Gagal memuatkan senarai.';
|
||||
vm.util.showResult(err);
|
||||
})
|
||||
.finally(function () {
|
||||
vm.catalogLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
isSelected: function (noKp) {
|
||||
return this.selectedNoKp.indexOf(noKp) !== -1;
|
||||
},
|
||||
|
||||
toggleRow: function (noKp, evt) {
|
||||
var arr = this.selectedNoKp.slice();
|
||||
var i = arr.indexOf(noKp);
|
||||
if (evt.target.checked) {
|
||||
if (i === -1) arr.push(noKp);
|
||||
} else {
|
||||
if (i !== -1) arr.splice(i, 1);
|
||||
}
|
||||
this.selectedNoKp = arr;
|
||||
},
|
||||
|
||||
toggleHeader: function (evt) {
|
||||
var want = evt.target.checked;
|
||||
var keys = this.filteredCatalog.map(function (r) { return r.no_kp; });
|
||||
var set = {};
|
||||
var i;
|
||||
for (i = 0; i < this.selectedNoKp.length; i++) {
|
||||
set[this.selectedNoKp[i]] = true;
|
||||
}
|
||||
if (want) {
|
||||
for (i = 0; i < keys.length; i++) set[keys[i]] = true;
|
||||
} else {
|
||||
for (i = 0; i < keys.length; i++) delete set[keys[i]];
|
||||
}
|
||||
this.selectedNoKp = Object.keys(set);
|
||||
},
|
||||
|
||||
selectAllFiltered: function () {
|
||||
var set = {};
|
||||
var i;
|
||||
for (i = 0; i < this.selectedNoKp.length; i++) {
|
||||
set[this.selectedNoKp[i]] = true;
|
||||
}
|
||||
var rows = this.filteredCatalog;
|
||||
for (i = 0; i < rows.length; i++) set[rows[i].no_kp] = true;
|
||||
this.selectedNoKp = Object.keys(set);
|
||||
},
|
||||
|
||||
clearAllFiltered: function () {
|
||||
var remove = {};
|
||||
var i;
|
||||
var rows = this.filteredCatalog;
|
||||
for (i = 0; i < rows.length; i++) remove[rows[i].no_kp] = true;
|
||||
this.selectedNoKp = this.selectedNoKp.filter(function (kp) { return !remove[kp]; });
|
||||
},
|
||||
|
||||
removeSelected: function (noKp) {
|
||||
this.selectedNoKp = this.selectedNoKp.filter(function (kp) { return kp !== noKp; });
|
||||
},
|
||||
|
||||
clearAllSelected: function () {
|
||||
this.selectedNoKp = [];
|
||||
},
|
||||
|
||||
saveApplicable: function () {
|
||||
if (this.catalogSaving) return;
|
||||
var vm = this;
|
||||
this.catalogSaving = true;
|
||||
this.util.notify('Menyimpan…', 'loading');
|
||||
axios.post(config.API + 'voter/sync-applicable', { selected_no_kp: this.selectedNoKp })
|
||||
.then(function (res) {
|
||||
$.notifyClose();
|
||||
if (vm.util.showResult(res, 'success')) {
|
||||
vm.$router.push({ name: 'Manage Voter' });
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
$.notifyClose();
|
||||
vm.util.showResult(err);
|
||||
})
|
||||
.finally(function () {
|
||||
vm.catalogSaving = false;
|
||||
});
|
||||
},
|
||||
|
||||
add: function () {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
var vm = this;
|
||||
this.util.notify('Adding Voter', 'loading')
|
||||
this.util.notify('Adding Voter', 'loading');
|
||||
axios.post(config.API + 'voter', $('#add-form').serialize())
|
||||
.then(response=>{
|
||||
.then(function (response) {
|
||||
$.notifyClose();
|
||||
vm.loading = false;
|
||||
if (vm.util.showResult(response, 'success')) {
|
||||
vm.closeManualModal();
|
||||
vm.resetManualForm();
|
||||
vm.$router.push({ name: 'Manage Voter' });
|
||||
}
|
||||
})
|
||||
.catch(error=>{
|
||||
.catch(function (error) {
|
||||
$.notifyClose();
|
||||
vm.loading = false;
|
||||
vm.util.showResult(error);
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,51 +15,36 @@
|
||||
<i class="fa fa-list"></i> Database Anggota
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" id="position_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bil.</th>
|
||||
<th>Nama Anggota</th>
|
||||
<th>No. Kad Pengenalan</th>
|
||||
<th>No. Anggota</th>
|
||||
<th>Unit</th>
|
||||
<th>Saham</th>
|
||||
<th>Yuran</th>
|
||||
<th>Tindakan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(voter, i) in data.voters.data">
|
||||
<td>{{ i + 1 }}</td>
|
||||
<td>{{ voter.name }}</td>
|
||||
<td>{{ voter.no_kp }}</td>
|
||||
<td>{{ voter.no_anggota }}</td>
|
||||
<td>{{ voter.unit }}</td>
|
||||
<td>{{ voter.saham }}</td>
|
||||
<td>{{ voter.yuran }}</td>
|
||||
<td>
|
||||
<button class="btn btn-info" @click="edit(i)">
|
||||
<i class="fa fa-edit"></i> Set semula
|
||||
</button>
|
||||
<button class="btn btn-danger"
|
||||
@click="util.showModal('#delete-voter-modal'); id = voter.id">
|
||||
<i class="fa fa-trash"></i> Padam
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="data.voters.data && data.voters.data.length < 1">
|
||||
<td colspan="3">No Voters</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="kehadiran-search">
|
||||
<div class="kehadiran-search__label">Carian pantas:</div>
|
||||
<input class="form-control kehadiran-search__input kehadiran-search__input--name"
|
||||
v-model.trim="searchName" placeholder="Nama Anggota" />
|
||||
<input class="form-control kehadiran-search__input" v-model.trim="searchNoAnggota"
|
||||
placeholder="No. Anggota" />
|
||||
<input class="form-control kehadiran-search__input" v-model.trim="searchNoKp"
|
||||
placeholder="No. Kad Pengenalan" />
|
||||
<button class="btn btn-primary" @click="applySearch()">Cari</button>
|
||||
<button class="btn btn-default" @click="clearSearch()" :disabled="!hasAnySearch">Reset</button>
|
||||
</div>
|
||||
|
||||
<admin-data-table :headers="voterTableHeaders" :items="voterListItems" :loading="voterLoading"
|
||||
:show-pagination="true" index-title="Bil." empty-text="Tiada pengundi" :exportable="true">
|
||||
<template v-slot:item-actions="{ item }">
|
||||
<button type="button" class="btn btn-info" @click="edit(item)">
|
||||
<i class="fa fa-edit"></i> Kemaskini
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger"
|
||||
@click="util.showModal('#delete-voter-modal'); id = item.id">
|
||||
<i class="fa fa-trash"></i> Padam
|
||||
</button>
|
||||
</template>
|
||||
</admin-data-table>
|
||||
|
||||
<ul class="pagination" v-if="pages.length > 1">
|
||||
<router-link tag="li" v-for="page in pages" :key="page['pages']"
|
||||
:to="{ query: { page: page['page'] } }" :class="{ 'active': current_page == page['page'] }"
|
||||
exact>
|
||||
<a href="#">{{ page['page'] }}</a>
|
||||
<router-link tag="li" v-for="page in pages" :key="page.page" :to="{ query: { page: page.page } }"
|
||||
:class="{ active: Number(current_page) === page.page }" exact>
|
||||
<a href="#">{{ page.page }}</a>
|
||||
</router-link>
|
||||
</ul>
|
||||
|
||||
@@ -80,11 +65,82 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.kehadiran-search {
|
||||
margin: 10px 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kehadiran-search__label {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.kehadiran-search__input {
|
||||
max-width: 220px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.kehadiran-search__input--name {
|
||||
max-width: 260px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data: function () {
|
||||
return {
|
||||
id: 0
|
||||
id: 0,
|
||||
voterLoading: false,
|
||||
searchName: '',
|
||||
searchNoAnggota: '',
|
||||
searchNoKp: '',
|
||||
voterTableHeaders: [
|
||||
{ title: 'Nama Anggota', key: 'name', sortable: true },
|
||||
{ title: 'No. Kad Pengenalan', key: 'no_kp', sortable: true },
|
||||
{ title: 'No. Anggota', key: 'no_anggota', sortable: true },
|
||||
{ title: 'Unit', key: 'unit', sortable: true },
|
||||
{ title: 'Saham', key: 'saham', sortable: true },
|
||||
{ title: 'Yuran', key: 'yuran', sortable: true },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasAnySearch: function () {
|
||||
return Boolean(
|
||||
(this.searchName && this.searchName.length)
|
||||
|| (this.searchNoAnggota && this.searchNoAnggota.length)
|
||||
|| (this.searchNoKp && this.searchNoKp.length)
|
||||
);
|
||||
},
|
||||
|
||||
voterListItems: function () {
|
||||
var v = this.data.voters;
|
||||
if (v && Array.isArray(v.data)) {
|
||||
return v.data;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
pages: function () {
|
||||
var pages = [];
|
||||
var last = this.data.voters && this.data.voters.last_page;
|
||||
if (last) {
|
||||
for (var i = 1; i <= last; i++) {
|
||||
pages.push({ page: i });
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
},
|
||||
|
||||
current_page: function () {
|
||||
return this.$route.query.page ? this.$route.query.page : 1;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -92,17 +148,45 @@ export default {
|
||||
this.refreshVoter();
|
||||
},
|
||||
|
||||
watch: {
|
||||
'$route.query.page': function () {
|
||||
$.notifyClose();
|
||||
this.refreshVoter();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
search: function () {
|
||||
|
||||
},
|
||||
|
||||
applySearch: function () {
|
||||
var q = Object.assign({}, this.$route.query);
|
||||
q.page = 1;
|
||||
this.$router.replace({ query: q });
|
||||
this.refreshVoter();
|
||||
},
|
||||
|
||||
clearSearch: function () {
|
||||
this.searchName = '';
|
||||
this.searchNoAnggota = '';
|
||||
this.searchNoKp = '';
|
||||
this.applySearch();
|
||||
},
|
||||
|
||||
refreshVoter: function () {
|
||||
var vm = this;
|
||||
this.voterLoading = true;
|
||||
this.util.notify('Refreshing Voter', 'loading');
|
||||
axios.get(config.API + 'voter?page=' + this.current_page)
|
||||
axios.get(config.API + 'voter', {
|
||||
params: {
|
||||
page: this.current_page,
|
||||
name: this.searchName || undefined,
|
||||
no_anggota: this.searchNoAnggota || undefined,
|
||||
no_kp: this.searchNoKp || undefined
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
console.log(response)
|
||||
$.notifyClose();
|
||||
vm.data.voters = response.data;
|
||||
})
|
||||
@@ -110,11 +194,14 @@ export default {
|
||||
$.notifyClose();
|
||||
vm.util.showResult(error);
|
||||
})
|
||||
.finally(function () {
|
||||
vm.voterLoading = false;
|
||||
})
|
||||
},
|
||||
|
||||
edit: function (i) {
|
||||
edit: function (voter) {
|
||||
var vm = this;
|
||||
this.data.voter = this.data.voters.data[i];
|
||||
this.data.voter = voter;
|
||||
this.$router.push({ name: 'Edit Voter', params: { id: vm.data.voter.id } })
|
||||
},
|
||||
|
||||
@@ -132,30 +219,6 @@ export default {
|
||||
vm.util.showResult(error);
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
'$route.query.page': function () {
|
||||
$.notifyClose();
|
||||
this.refreshVoter();
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
pages: function () {
|
||||
var pages = [];
|
||||
if (this.data.voters.last_page)
|
||||
for (var i = 1; i <= this.data.voters.last_page; i++) {
|
||||
let x = {};
|
||||
x['page'] = i;
|
||||
pages.push(x);
|
||||
}
|
||||
return pages;
|
||||
},
|
||||
|
||||
current_page: function () {
|
||||
return this.$route.query.page ? this.$route.query.page : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,63 +1,304 @@
|
||||
<template>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
<div class="kehadiran-actions clearfix">
|
||||
<button class="btn btn-success" @click="refreshVoter()">
|
||||
<i class="fa fa-refresh"></i> Kemaskini Pengundi
|
||||
</button>
|
||||
|
||||
<router-link class="btn btn-info" :to="{ name: 'Fizikal' }">
|
||||
<a class="btn btn-warning pull-right" :href="getPDFKehadiranurl(1)">
|
||||
<i class="fa fa-download"></i> Muat Turun PDF
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="kehadiran-search">
|
||||
<div class="kehadiran-search__label">Carian pantas (kaunter):</div>
|
||||
<input class="form-control kehadiran-search__input" v-model.trim="searchNoAnggota"
|
||||
placeholder="No. Anggota" />
|
||||
<input class="form-control kehadiran-search__input" v-model.trim="searchNoKp"
|
||||
placeholder="No. Kad Pengenalan" />
|
||||
<button class="btn btn-primary" @click="applySearch()">Cari</button>
|
||||
<button class="btn btn-default" @click="clearSearch()" :disabled="!hasAnySearch">Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="kehadiran-filters">
|
||||
<span class="kehadiran-filters__label">Penapis:</span>
|
||||
<button type="button" class="kehadiran-chip" :class="{ 'is-active': !current_kehadiran }"
|
||||
@click="setKehadiranFilter(null)">
|
||||
Semua
|
||||
</button>
|
||||
<button type="button" class="kehadiran-chip"
|
||||
:class="{ 'is-active': String(current_kehadiran) === 'unset' }"
|
||||
@click="setKehadiranFilter('unset')">
|
||||
Belum Ditetapkan
|
||||
</button>
|
||||
<button type="button" class="kehadiran-chip" :class="{ 'is-active': Number(current_kehadiran) === 1 }"
|
||||
@click="setKehadiranFilter(1)">
|
||||
Fizikal
|
||||
</router-link>
|
||||
|
||||
<router-link class="btn btn-info" :to="{ name: 'Maya' }">
|
||||
</button>
|
||||
<button type="button" class="kehadiran-chip" :class="{ 'is-active': Number(current_kehadiran) === 2 }"
|
||||
@click="setKehadiranFilter(2)">
|
||||
Maya
|
||||
</router-link>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<a class="pdf-button-container btn btn-warning" :href="getPDFKehadiranurl(1)"><i class="fa fa-download"></i> Muat Turun PDF</a>
|
||||
<div class="kehadiran-filters kehadiran-filters--secondary">
|
||||
<span class="kehadiran-filters__label">Pendaftaran fizikal:</span>
|
||||
<button type="button" class="kehadiran-chip" :class="{ 'is-active': !current_fizikal_reg }"
|
||||
@click="setFizikalRegFilter(null)">
|
||||
Semua
|
||||
</button>
|
||||
<button type="button" class="kehadiran-chip" :class="{ 'is-active': current_fizikal_reg === 'pending' }"
|
||||
@click="setFizikalRegFilter('pending')">
|
||||
Menunggu pengesahan
|
||||
</button>
|
||||
<button type="button" class="kehadiran-chip"
|
||||
:class="{ 'is-active': current_fizikal_reg === 'verified' }"
|
||||
@click="setFizikalRegFilter('verified')">
|
||||
Disahkan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- <a class="btn btn-warning" :href="getPDFKehadiranurl(1)">Muat Turun PDF</a> -->
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bil.</th>
|
||||
<th>Nama Anggota</th>
|
||||
<th>No. Kad Pengenalan</th>
|
||||
<th>No. Anggota</th>
|
||||
<th>Unit</th>
|
||||
<th>Kehadiran</th>
|
||||
<th>Tarikh/Masa</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Display attendees -->
|
||||
<template v-for="(voter, index) in data.voters.data">
|
||||
<tr>
|
||||
<td>{{ index + 1 }}</td> <!-- Display the serial number -->
|
||||
<td>{{ voter.name }}</td>
|
||||
<td>{{ voter.no_kp }}</td>
|
||||
<td>{{ voter.no_anggota }}</td>
|
||||
<td>{{ voter.unit }}</td>
|
||||
<td>{{ getKehadiranString(voter.kehadiran) }}</td>
|
||||
<td>{{ voter.updated_at }}</td>
|
||||
</tr>
|
||||
<admin-data-table :headers="kehadiranTableHeaders" :items="voterListItems" :loading="voterLoading"
|
||||
:show-pagination="true" index-title="Bil." empty-text="Tiada rekod kehadiran" :exportable="true">
|
||||
<template v-slot:item-kehadiran="{ item }">
|
||||
{{ getKehadiranString(item.kehadiran) }}
|
||||
</template>
|
||||
<template v-slot:item-fizikal_reg="{ item }">
|
||||
<span v-if="Number(item.kehadiran) !== 1" class="text-muted">—</span>
|
||||
<span v-else-if="item.fizikal_registration_verified_at"
|
||||
class="kehadiran-pill kehadiran-pill--success">
|
||||
<i class="fa fa-check-circle" aria-hidden="true"></i>
|
||||
<span>Disahkan</span>
|
||||
</span>
|
||||
<span v-else class="kehadiran-pill kehadiran-pill--warn">
|
||||
<i class="fa fa-clock-o" aria-hidden="true"></i>
|
||||
<span>Menunggu</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-slot:item-has_voted="{ item }">
|
||||
<span class="kehadiran-pill"
|
||||
:class="Number(item.votes_count) > 0 ? 'kehadiran-pill--success' : 'kehadiran-pill--muted'">
|
||||
<i class="fa" :class="Number(item.votes_count) > 0 ? 'fa-check-circle' : 'fa-times-circle'"
|
||||
aria-hidden="true"></i>
|
||||
<span>{{ Number(item.votes_count) > 0 ? 'Undi' : 'Belum' }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-slot:item-allowance="{ item }">
|
||||
<span class="kehadiran-pill"
|
||||
:class="isCashPaid(item) ? 'kehadiran-pill--success' : 'kehadiran-pill--warn'">
|
||||
<i class="fa" :class="isCashPaid(item) ? 'fa-money' : 'fa-exclamation-circle'"
|
||||
aria-hidden="true"></i>
|
||||
<span>{{ isCashPaid(item) ? 'Sudah Bayar' : 'Belum Bayar' }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-slot:item-action="{ item }">
|
||||
<div class="kehadiran-action-cell">
|
||||
<button type="button" class="btn btn-xs btn-primary kehadiran-action-btn"
|
||||
@click.stop="openDetail(item)">
|
||||
<i class="fa fa-eye" aria-hidden="true"></i>
|
||||
<span>Detail</span>
|
||||
</button>
|
||||
<button v-if="canVerifyFizikalRegistration(item)" type="button"
|
||||
class="btn btn-xs btn-success kehadiran-action-btn"
|
||||
@click.stop="openFizikalVerifyModal(item)">
|
||||
<i class="fa fa-check" aria-hidden="true"></i>
|
||||
<span>Sahkan</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</admin-data-table>
|
||||
|
||||
<!-- Allowance Modal -->
|
||||
<div class="modal fade app-modal" id="allowance-modal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">Butiran Pembayaran Elaun</h4>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</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">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" v-if="verifyModalVoter">
|
||||
<p class="kehadiran-verify-lead">
|
||||
Sahkan bahawa pengundi ini hadir di kaunter. Selepas ini, pengundi boleh mengundi.
|
||||
</p>
|
||||
<table class="table table-condensed table-bordered kehadiran-verify-summary">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style="width: 140px;">Nama</th>
|
||||
<td>{{ verifyModalVoter.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>No. KP</th>
|
||||
<td>{{ verifyModalVoter.no_kp }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>No. Anggota</th>
|
||||
<td>{{ verifyModalVoter.no_anggota }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Unit</th>
|
||||
<td>{{ verifyModalVoter.unit }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="alert alert-danger" v-if="verifyModalError" style="margin-bottom: 0;">
|
||||
{{ verifyModalError }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Tutup</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="verifyModalLoading"
|
||||
@click="submitFizikalVerify">
|
||||
<i class="fa fa-check" aria-hidden="true"></i> Sahkan pendaftaran
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display attendance percentage -->
|
||||
<div class="text-right">
|
||||
<h5><b>{{ attendancePercentage }}% Kehadiran Pengundi</b></h5>
|
||||
</div>
|
||||
|
||||
<ul class="pagination" v-if="pages.length > 1">
|
||||
<router-link tag="li" v-for="page in pages" :key="page['pages']" :to="{ query: { page: page['page'] } }"
|
||||
:class="{ 'active': current_page == page['page'] }" exact>
|
||||
<a href="#">{{ page['page'] }}</a>
|
||||
<router-link tag="li" v-for="page in pages" :key="page.page"
|
||||
:to="{ query: paginationQueryForPage(page.page) }"
|
||||
:class="{ active: Number(current_page) === page.page }" exact>
|
||||
<a href="#">{{ page.page }}</a>
|
||||
</router-link>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -65,17 +306,167 @@
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.pdf-button-container {
|
||||
text-align: right;
|
||||
.kehadiran-actions {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.kehadiran-search {
|
||||
margin: 10px 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kehadiran-search__label {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.kehadiran-search__input {
|
||||
max-width: 220px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.kehadiran-filters {
|
||||
margin: 6px 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kehadiran-filters--secondary {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.kehadiran-filters__label {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.kehadiran-chip {
|
||||
border: 1px solid #d1d5db;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kehadiran-chip:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.kehadiran-chip.is-active {
|
||||
background: #1976d2;
|
||||
border-color: #1976d2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kehadiran-action-cell {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.kehadiran-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.kehadiran-action-btn>i {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kehadiran-verify-lead {
|
||||
margin-bottom: 12px;
|
||||
color: #374151;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.kehadiran-verify-summary {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.kehadiran-tunai-readonly {
|
||||
background-color: #f3f4f6 !important;
|
||||
cursor: not-allowed;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.kehadiran-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kehadiran-pill>i {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kehadiran-pill--success {
|
||||
color: #0f5132;
|
||||
background: #d1e7dd;
|
||||
border-color: #badbcc;
|
||||
}
|
||||
|
||||
.kehadiran-pill--warn {
|
||||
color: #664d03;
|
||||
background: #fff3cd;
|
||||
border-color: #ffecb5;
|
||||
}
|
||||
|
||||
.kehadiran-pill--muted {
|
||||
color: #374151;
|
||||
background: #f3f4f6;
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
data: function () {
|
||||
return {
|
||||
id: 0
|
||||
id: 0,
|
||||
voterLoading: false,
|
||||
searchNoAnggota: '',
|
||||
searchNoKp: '',
|
||||
selectedVoter: null,
|
||||
verifyModalVoter: null,
|
||||
verifyModalLoading: false,
|
||||
verifyModalError: '',
|
||||
detailLoading: false,
|
||||
detailError: '',
|
||||
claimCodeInput: '',
|
||||
payoutReference: '',
|
||||
payoutNote: '',
|
||||
kehadiranTableHeaders: [
|
||||
{ title: 'Nama Anggota', key: 'name', sortable: true },
|
||||
{ title: 'No. Kad Pengenalan', key: 'no_kp', sortable: true },
|
||||
{ title: 'No. Anggota', key: 'no_anggota', sortable: true },
|
||||
{ title: 'Unit', key: 'unit', sortable: true },
|
||||
{ title: 'Kehadiran', key: 'kehadiran', sortable: true },
|
||||
{ title: 'Pendaftaran', key: 'fizikal_reg', sortable: false },
|
||||
{ title: 'Undi', key: 'has_voted', sortable: false },
|
||||
{ title: 'Status', key: 'allowance', sortable: false },
|
||||
{ title: 'Tindakan', key: 'action', sortable: false },
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -90,8 +481,17 @@ export default {
|
||||
|
||||
refreshVoter: function () {
|
||||
var vm = this;
|
||||
this.voterLoading = true;
|
||||
this.util.notify('Refreshing Voter', 'loading');
|
||||
axios.get(config.API + 'voter?page=' + this.current_page)
|
||||
axios.get(config.API + 'voter', {
|
||||
params: {
|
||||
page: this.current_page,
|
||||
kehadiran: this.current_kehadiran || undefined,
|
||||
fizikal_reg: this.current_fizikal_reg || undefined,
|
||||
no_anggota: this.searchNoAnggota || undefined,
|
||||
no_kp: this.searchNoKp || undefined
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
console.log(response)
|
||||
$.notifyClose();
|
||||
@@ -101,6 +501,94 @@ export default {
|
||||
$.notifyClose();
|
||||
vm.util.showResult(error);
|
||||
})
|
||||
.finally(function () {
|
||||
vm.voterLoading = false;
|
||||
})
|
||||
},
|
||||
|
||||
applySearch: function () {
|
||||
// Reset to first page when searching
|
||||
var q = Object.assign({}, this.$route.query);
|
||||
q.page = 1;
|
||||
this.$router.replace({ query: q });
|
||||
this.refreshVoter();
|
||||
},
|
||||
|
||||
clearSearch: function () {
|
||||
this.searchNoAnggota = '';
|
||||
this.searchNoKp = '';
|
||||
this.applySearch();
|
||||
},
|
||||
|
||||
setKehadiranFilter: function (val) {
|
||||
var q = Object.assign({}, this.$route.query);
|
||||
if (val === 1 || val === 2) q.kehadiran = String(val);
|
||||
else if (val === 'unset') q.kehadiran = 'unset';
|
||||
else delete q.kehadiran;
|
||||
q.page = 1;
|
||||
this.$router.replace({ query: q });
|
||||
},
|
||||
|
||||
setFizikalRegFilter: function (val) {
|
||||
var q = Object.assign({}, this.$route.query);
|
||||
if (val === 'pending' || val === 'verified') q.fizikal_reg = val;
|
||||
else delete q.fizikal_reg;
|
||||
q.page = 1;
|
||||
this.$router.replace({ query: q });
|
||||
},
|
||||
|
||||
paginationQueryForPage: function (pageNum) {
|
||||
var q = { page: pageNum };
|
||||
if (this.current_kehadiran) q.kehadiran = this.current_kehadiran;
|
||||
if (this.current_fizikal_reg) q.fizikal_reg = this.current_fizikal_reg;
|
||||
return q;
|
||||
},
|
||||
|
||||
canVerifyFizikalRegistration: function (voter) {
|
||||
if (!voter) return false;
|
||||
return Number(voter.kehadiran) === 1 && !voter.fizikal_registration_verified_at;
|
||||
},
|
||||
|
||||
openFizikalVerifyModal: function (item) {
|
||||
if (!item || !this.canVerifyFizikalRegistration(item)) return;
|
||||
this.verifyModalError = '';
|
||||
this.verifyModalVoter = Object.assign({}, item);
|
||||
this.util.showModal('#fizikal-verify-modal');
|
||||
},
|
||||
|
||||
submitFizikalVerify: function () {
|
||||
var vm = this;
|
||||
if (!this.verifyModalVoter) return;
|
||||
this.verifyModalError = '';
|
||||
this.verifyModalLoading = true;
|
||||
axios.post(config.API + 'voter/' + this.verifyModalVoter.id + '/verify-fizikal-registration')
|
||||
.then(function (response) {
|
||||
if (response && response.data && response.data.status && response.data.status !== 'success') {
|
||||
vm.verifyModalError = response.data.message || 'Permintaan gagal.';
|
||||
vm.util.notify(vm.verifyModalError, 'error');
|
||||
return;
|
||||
}
|
||||
if (vm.util.showResult(response, 'success')) {
|
||||
var v = response.data.voter;
|
||||
if (v && vm.selectedVoter && vm.selectedVoter.id === v.id) {
|
||||
vm.selectedVoter.fizikal_registration_verified_at = v.fizikal_registration_verified_at;
|
||||
}
|
||||
vm.util.hideModal('#fizikal-verify-modal');
|
||||
vm.verifyModalVoter = null;
|
||||
vm.refreshVoter();
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
vm.verifyModalError =
|
||||
(error && error.response && error.response.data && error.response.data.message)
|
||||
? error.response.data.message
|
||||
: (error && error.message ? error.message : 'Gagal mengesahkan pendaftaran.');
|
||||
if (error && error.response) vm.util.showResult(error, 'error');
|
||||
else vm.util.notify(vm.verifyModalError, 'error');
|
||||
})
|
||||
.finally(function () {
|
||||
vm.verifyModalLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
getKehadiranString(kehadiran) {
|
||||
@@ -118,6 +606,116 @@ export default {
|
||||
getPDFKehadiranurl(Id) {
|
||||
// Construct and return the URL based on the item ID
|
||||
return `/kehadiranpdf/${Id}`;
|
||||
},
|
||||
|
||||
isCashPaid: function (voter) {
|
||||
return Boolean(voter && voter.cash_paid_at);
|
||||
},
|
||||
|
||||
canPayCash: function (voter) {
|
||||
if (!voter) return false;
|
||||
if (this.isCashPaid(voter)) return false;
|
||||
// Must be fizikal, must have voted
|
||||
return Number(voter.kehadiran) === 1 && Number(voter.votes_count) > 0;
|
||||
},
|
||||
|
||||
openDetail: function (item) {
|
||||
this.detailError = '';
|
||||
this.selectedVoter = Object.assign({}, item);
|
||||
this.claimCodeInput = '';
|
||||
this.payoutReference = '';
|
||||
this.payoutNote = '';
|
||||
this.util.showModal('#allowance-modal');
|
||||
},
|
||||
|
||||
payCash: function () {
|
||||
if (!this.selectedVoter) return;
|
||||
this.detailError = '';
|
||||
this.detailLoading = true;
|
||||
|
||||
let payload = {
|
||||
voter_id: this.selectedVoter.id,
|
||||
method: 'cash',
|
||||
claim_code: this.claimCodeInput || undefined,
|
||||
reference: this.payoutReference || undefined,
|
||||
note: this.payoutNote || undefined,
|
||||
};
|
||||
|
||||
var rm = this.allowanceTunaiAmountRM;
|
||||
if (rm !== null && rm !== undefined && !Number.isNaN(Number(rm))) {
|
||||
payload.amount_cents = Math.round(Number(rm) * 100);
|
||||
}
|
||||
|
||||
axios.post(config.API + 'allowance/payout', payload)
|
||||
.then((response) => {
|
||||
// Some endpoints return 200 with {status:'failed', message:'...'}
|
||||
if (response && response.data && response.data.status && response.data.status !== 'success') {
|
||||
this.detailError = response.data.message || 'Permintaan gagal.';
|
||||
this.util.notify(this.detailError, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.util.showResult(response, 'success')) {
|
||||
// refresh list so cash_paid_at & cash_payout_id updates
|
||||
this.refreshVoter();
|
||||
// keep modal open but reflect paid state (best effort)
|
||||
if (response && response.data && response.data.payout) {
|
||||
this.selectedVoter.cash_paid_at = response.data.payout.paid_at;
|
||||
this.selectedVoter.cash_payout_id = response.data.payout.id;
|
||||
} else {
|
||||
this.selectedVoter.cash_paid_at = (new Date()).toISOString();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.detailError =
|
||||
(error && error.response && error.response.data && error.response.data.message)
|
||||
? error.response.data.message
|
||||
: (error && error.message ? error.message : 'Gagal merekod bayaran.');
|
||||
// Only pass actual axios error objects into showResult
|
||||
if (error && error.response) this.util.showResult(error, 'error');
|
||||
else this.util.notify(this.detailError, 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
this.detailLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
voidCashPayout: function () {
|
||||
if (!this.selectedVoter || !this.selectedVoter.cash_payout_id) {
|
||||
this.detailError = 'Rekod bayaran tidak dijumpai untuk dibatalkan.';
|
||||
return;
|
||||
}
|
||||
this.detailError = '';
|
||||
this.detailLoading = true;
|
||||
|
||||
axios.post(config.API + 'allowance/void/' + this.selectedVoter.cash_payout_id, {
|
||||
note: this.payoutNote || undefined
|
||||
})
|
||||
.then((response) => {
|
||||
if (response && response.data && response.data.status && response.data.status !== 'success') {
|
||||
this.detailError = response.data.message || 'Permintaan gagal.';
|
||||
this.util.notify(this.detailError, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.util.showResult(response, 'success')) {
|
||||
this.refreshVoter();
|
||||
this.selectedVoter.cash_paid_at = null;
|
||||
this.selectedVoter.cash_payout_id = null;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.detailError =
|
||||
(error && error.response && error.response.data && error.response.data.message)
|
||||
? error.response.data.message
|
||||
: (error && error.message ? error.message : 'Gagal membatalkan bayaran.');
|
||||
if (error && error.response) this.util.showResult(error, 'error');
|
||||
else this.util.notify(this.detailError, 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
this.detailLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -127,10 +725,45 @@ export default {
|
||||
'$route.query.page': function () {
|
||||
$.notifyClose();
|
||||
this.refreshVoter();
|
||||
},
|
||||
'$route.query.kehadiran': function () {
|
||||
$.notifyClose();
|
||||
this.refreshVoter();
|
||||
},
|
||||
'$route.query.fizikal_reg': function () {
|
||||
$.notifyClose();
|
||||
this.refreshVoter();
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
/** RM300 fizikal, RM150 Maya — used for tunai payout amount. */
|
||||
allowanceTunaiAmountRM: function () {
|
||||
if (!this.selectedVoter) return null;
|
||||
var k = Number(this.selectedVoter.kehadiran);
|
||||
if (k === 1) return 300;
|
||||
if (k === 2) return 150;
|
||||
return null;
|
||||
},
|
||||
|
||||
allowanceTunaiDisplay: function () {
|
||||
var rm = this.allowanceTunaiAmountRM;
|
||||
if (rm === null || rm === undefined) return '';
|
||||
return Number(rm).toFixed(2);
|
||||
},
|
||||
|
||||
hasAnySearch: function () {
|
||||
return Boolean((this.searchNoAnggota && this.searchNoAnggota.length) || (this.searchNoKp && this.searchNoKp.length));
|
||||
},
|
||||
|
||||
voterListItems: function () {
|
||||
var v = this.data.voters;
|
||||
if (v && Array.isArray(v.data)) {
|
||||
return v.data;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
pages: function () {
|
||||
var pages = [];
|
||||
if (this.data.voters.last_page)
|
||||
@@ -146,21 +779,29 @@ export default {
|
||||
return this.$route.query.page ? this.$route.query.page : 1;
|
||||
},
|
||||
|
||||
current_kehadiran: function () {
|
||||
return this.$route.query.kehadiran ? this.$route.query.kehadiran : null;
|
||||
},
|
||||
|
||||
current_fizikal_reg: function () {
|
||||
return this.$route.query.fizikal_reg ? this.$route.query.fizikal_reg : null;
|
||||
},
|
||||
|
||||
// Calculate total attendance percentage
|
||||
attendancePercentage() {
|
||||
// Count total members who attended physically (Fizikal)
|
||||
const fizikalCount = this.data.voters.data.reduce((total, voter) => {
|
||||
const fizikalCount = this.voterListItems.reduce((total, voter) => {
|
||||
return voter.kehadiran === 1 ? total + 1 : total;
|
||||
}, 0);
|
||||
|
||||
// Count total members who attended virtually (Maya)
|
||||
const mayaCount = this.data.voters.data.reduce((total, voter) => {
|
||||
const mayaCount = this.voterListItems.reduce((total, voter) => {
|
||||
return voter.kehadiran === 2 ? total + 1 : total;
|
||||
}, 0);
|
||||
|
||||
// Calculate total attendance percentage
|
||||
const totalAttendance = fizikalCount + mayaCount;
|
||||
const totalMembers = this.data.voters.data.length;
|
||||
const totalMembers = this.voterListItems.length;
|
||||
return totalMembers === 0 ? 0 : ((totalAttendance / totalMembers) * 100).toFixed(2);
|
||||
},
|
||||
|
||||
|
||||
@@ -1,14 +1,55 @@
|
||||
<template>
|
||||
<div class="container col-md-8 col-md-offset-2">
|
||||
<h4>Maklumat Pengundi</h4>
|
||||
<div :class="wrapperClass">
|
||||
<h4 v-if="showHeader">{{ headerTitle }}</h4>
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
computed: {
|
||||
showHeader: function () {
|
||||
// Keep header hidden only for "Tambah Pengundi" (it has its own page header)
|
||||
return this.$route.name !== 'Tambah Pengundi';
|
||||
},
|
||||
headerTitle: function () {
|
||||
if (this.$route.name === 'Kehadiran Calon') return 'Kehadiran';
|
||||
return 'Maklumat Pengundi';
|
||||
},
|
||||
isWideVoterRoute: function () {
|
||||
// Give more room for heavy pages, but keep it boxed (not edge-to-edge)
|
||||
return this.$route.name === 'Tambah Pengundi'
|
||||
|| this.$route.name === 'Kehadiran Calon';
|
||||
},
|
||||
wrapperClass: function () {
|
||||
if (this.isWideVoterRoute) {
|
||||
return 'container-fluid voter-admin-wrap voter-admin-wrap--boxed voter-admin-wrap--wide';
|
||||
}
|
||||
return 'container col-md-8 col-md-offset-2 voter-admin-wrap voter-admin-wrap--boxed';
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.util.setTitle('MyKoPKB - Maklumat Pengundi');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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,9 +47,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sembunyikan borang pendaftaran selepas kehadiran telah dihantar -->
|
||||
<template v-if="Number(data.user.kehadiran) === 0">
|
||||
<div class="form-group attendance-choice-box">
|
||||
<label for="kehadiran">Kehadiran</label>
|
||||
<select class="form-control attendance-select" id="kehadiran" name="kehadiran" required>
|
||||
<select v-model="selectedKehadiran" class="form-control attendance-select" id="kehadiran"
|
||||
name="kehadiran" required>
|
||||
<option value="0">Sila Pilih :</option>
|
||||
<option value="1">Fizikal</option>
|
||||
<option value="2">Maya</option>
|
||||
@@ -59,19 +62,34 @@
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedKehadiran === '1'" class="form-group attendance-field">
|
||||
<label for="physical_gate_code">Kod skrin kaunter pendaftaran</label>
|
||||
<input type="text" name="physical_gate_code" id="physical_gate_code"
|
||||
class="form-control attendance-input" autocomplete="off" autocapitalize="characters"
|
||||
inputmode="text" placeholder="8 aksara, semak skrin di kaunter" maxlength="64"
|
||||
@input="onPhysicalGateCodeInput" />
|
||||
<small class="form-text text-muted attendance-helper-text">
|
||||
Kod berputar setiap minit — masukkan kod semasa yang dipaparkan di kaunter sebelum
|
||||
menghantar.
|
||||
</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
|
||||
<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
|
||||
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 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>
|
||||
<p class="attendance-success-text">Kehadiran anda telah disahkan secara</p>
|
||||
<h5 class="attendance-success-mode">
|
||||
@@ -83,12 +101,37 @@
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-lg btn-success attendance-submit-button"
|
||||
v-else-if="data.election.status == 2">
|
||||
SAYA TERIMA
|
||||
<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"
|
||||
v-else-if="electionVotingActive">
|
||||
DAFTAR KEHADIRAN
|
||||
</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>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,6 +291,43 @@
|
||||
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-vote-button {
|
||||
min-width: 220px;
|
||||
@@ -258,6 +338,21 @@
|
||||
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) {
|
||||
|
||||
.attendance-hero,
|
||||
@@ -271,7 +366,8 @@
|
||||
}
|
||||
|
||||
.attendance-submit-button,
|
||||
.attendance-vote-button {
|
||||
.attendance-vote-button,
|
||||
.attendance-refresh-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -284,11 +380,55 @@ export default {
|
||||
data: function () {
|
||||
return {
|
||||
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: {
|
||||
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 () {
|
||||
|
||||
// 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">
|
||||
<li class="list-group-item vote-summary-item">
|
||||
<div class="vote-position-name">Calon Dipilih</div>
|
||||
<div v-if="selected[0] && selected[0].nominee_id.length" class="vote-selected-list">
|
||||
<div class="vote-selected-item" v-for="nominee_id in selected[0].nominee_id" :key="nominee_id">
|
||||
<div v-if="allSelectedNomineeIds.length" class="vote-selected-list">
|
||||
<div class="vote-selected-item" v-for="nominee_id in allSelectedNomineeIds" :key="nominee_id">
|
||||
<span class="vote-selected-badge">#{{ getNomineeNo(nominee_id) }}</span>
|
||||
<span>{{ getSelectedNomineeName(nominee_id) }}</span>
|
||||
</div>
|
||||
@@ -73,7 +73,7 @@
|
||||
</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">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content vote-modal-content">
|
||||
@@ -88,9 +88,9 @@
|
||||
</div>
|
||||
<div class="modal-body vote-modal-body">
|
||||
<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-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>{{ getSelectedNomineeName(nominee_id) }}</span>
|
||||
</div>
|
||||
@@ -130,8 +130,20 @@ export default {
|
||||
},
|
||||
|
||||
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() {
|
||||
const selectedNomineeIds = this.selected[0]['nominee_id'];
|
||||
const selectedNomineeIds = this.allSelectedNomineeIds;
|
||||
return this.data.nominees.map(nominee => {
|
||||
return selectedNomineeIds.includes(nominee.id) ? false : this.checkDisabled();
|
||||
});
|
||||
@@ -139,12 +151,23 @@ export default {
|
||||
},
|
||||
|
||||
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 () {
|
||||
console.log('tab');
|
||||
this.util.showModal('#vote-modal')
|
||||
},
|
||||
submit: function () {
|
||||
if (this.selected[0]['nominee_id'].length !== this.max) {
|
||||
if (this.totalSelectedCount() !== this.max) {
|
||||
// No selections made
|
||||
this.util.notify(`Sila pastikan anda memilih (${this.max}) orang calon.`, 'error');
|
||||
return;
|
||||
@@ -152,7 +175,7 @@ export default {
|
||||
|
||||
var vm = this;
|
||||
this.util.notify('Submitting your vote, please wait...', 'loading');
|
||||
console.log(this.selected)
|
||||
console.log(this.selected);
|
||||
// Prepare data to send to the server
|
||||
let data = {
|
||||
vote: this.selected
|
||||
@@ -162,6 +185,10 @@ export default {
|
||||
axios.post(config.API + 'election/vote', data)
|
||||
.then(response => {
|
||||
$.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')) {
|
||||
vm.data.result = response.data.result;
|
||||
vm.$router.push({ name: 'Voter Home' });
|
||||
@@ -194,22 +221,19 @@ export default {
|
||||
},
|
||||
vote: function (position_id, nominee_id) {
|
||||
|
||||
if (!this.selected[0]['position_id']) {
|
||||
this.$set(this.selected[0], position_id, []);
|
||||
}
|
||||
const index = this.selected[0]['nominee_id'].indexOf(nominee_id);
|
||||
if (index === -1) {
|
||||
// Nominee not found, add it
|
||||
if (this.selected[0]['nominee_id'].length < this.max) {
|
||||
this.selected[0]['nominee_id'].push(nominee_id);
|
||||
} else {
|
||||
// Display warning message if more than 2 nominees are selected
|
||||
var entry = this.selectedEntryForPosition(position_id);
|
||||
if (!entry) return;
|
||||
|
||||
var idx = entry.nominee_id.indexOf(nominee_id);
|
||||
if (idx === -1) {
|
||||
// Enforce global max selection across all positions
|
||||
if (this.totalSelectedCount() >= this.max) {
|
||||
this.util.notify(`Boleh mengundi (${this.max}) calon sahaja!`, 'warning');
|
||||
return;
|
||||
}
|
||||
entry.nominee_id.push(nominee_id);
|
||||
} else {
|
||||
// Nominee found, remove it
|
||||
this.selected[0]['nominee_id'].splice(index, 1);
|
||||
entry.nominee_id.splice(idx, 1);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -264,7 +288,7 @@ export default {
|
||||
},
|
||||
|
||||
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');
|
||||
} else {
|
||||
this.util.showModal('#vote-modal');
|
||||
@@ -273,7 +297,7 @@ export default {
|
||||
},
|
||||
|
||||
checkDisabled: function () {
|
||||
if (this.selected[0]['nominee_id'].length >= this.max) {
|
||||
if (this.totalSelectedCount() >= this.max) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,38 +15,7 @@
|
||||
</div>
|
||||
<div class="collapse navbar-collapse" id="myNavbar">
|
||||
|
||||
<ul class="nav navbar-nav">
|
||||
|
||||
<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 class="nav navbar-nav voter-nav-main">
|
||||
</ul>
|
||||
<ul class="nav navbar-right navbar-nav">
|
||||
<li class="dropdown">
|
||||
@@ -64,6 +33,16 @@
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,18 +57,47 @@
|
||||
<script>
|
||||
export default {
|
||||
data: () => ({
|
||||
loading: true,
|
||||
matLinkUrl: 'https://linktr.ee/kopkb'
|
||||
loading: true
|
||||
}),
|
||||
|
||||
created: function () {
|
||||
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: {
|
||||
logout: function () {
|
||||
var vm = this;
|
||||
this.util.setAuthorization();
|
||||
axios.post(config.API + 'voter/logout')
|
||||
.catch(function () { })
|
||||
.finally(function () {
|
||||
localStorage.clear();
|
||||
this.$router.push({ name: 'Voter Login' });
|
||||
vm.$router.push({ name: 'Voter Login' });
|
||||
});
|
||||
},
|
||||
|
||||
hasVoted: function () {
|
||||
@@ -276,6 +284,57 @@ export default {
|
||||
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) {
|
||||
.floating-header {
|
||||
top: 10px !important;
|
||||
@@ -312,5 +371,26 @@ export default {
|
||||
.voter-main {
|
||||
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>
|
||||
|
||||
@@ -13,25 +13,15 @@
|
||||
<br>-<b>+6 {{ $route.params.notel }}</b>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showDebugOtp"
|
||||
class="alert alert-info"
|
||||
style="margin-bottom:20px;"
|
||||
>
|
||||
<div v-if="showDebugOtp" class="alert alert-info" style="margin-bottom:20px;">
|
||||
OTP local development: <b>{{ $route.params.debug_otp }}</b>
|
||||
</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;">
|
||||
<v-otp-input
|
||||
ref="otpInput"
|
||||
input-classes="otp-input"
|
||||
separator="-"
|
||||
:num-inputs="6"
|
||||
:should-auto-focus="true"
|
||||
:is-input-num="true"
|
||||
@on-change="handleOnChange"
|
||||
@on-complete="handleOnComplete"
|
||||
/>
|
||||
<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;">
|
||||
<v-otp-input ref="otpInput" input-classes="otp-input" separator="-" :num-inputs="6"
|
||||
:should-auto-focus="true" :is-input-num="true" @on-change="handleOnChange"
|
||||
@on-complete="handleOnComplete" />
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:20px;">
|
||||
@@ -41,13 +31,8 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<input
|
||||
ref="submitbtn"
|
||||
type="submit"
|
||||
class="btn btn-primary form-control"
|
||||
value="Sahkan & Teruskan"
|
||||
disabled
|
||||
/>
|
||||
<input ref="submitbtn" type="submit" class="btn btn-primary form-control" value="Sahkan & Teruskan"
|
||||
disabled />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -55,12 +40,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import Countdown from '../../mycomponents/countdown.vue'
|
||||
|
||||
export default {
|
||||
components:{
|
||||
Countdown
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
loading: false
|
||||
@@ -88,21 +68,18 @@ export default {
|
||||
this.$refs.otpInput.clearInput();
|
||||
},
|
||||
resendVerify: function () {
|
||||
var vm = this;
|
||||
axios.post(config.API + 'voter/login', {
|
||||
'no_kp' : this.user.no_kp
|
||||
no_kp: this.$route.params.nokp
|
||||
})
|
||||
.then(response => {
|
||||
this.stopLoading();
|
||||
if (this.util.showResult(response, 'success')) {
|
||||
// this.$router.push({ name: 'Voter Verify',params: {'nokp' : response.data.nokp,'notel' : response.data.notel} })
|
||||
this.$refs.countdown.restarttimer();
|
||||
.then(function (response) {
|
||||
if (vm.util.showResult(response, 'success')) {
|
||||
console.log('resend verify');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
vm.stopLoading();
|
||||
this.util.showResult(error, 'error');
|
||||
})
|
||||
.catch(function (error) {
|
||||
vm.util.showResult(error, 'error');
|
||||
});
|
||||
},
|
||||
startLoading: function () {
|
||||
this.util.notify('Logging in', 'loading');
|
||||
@@ -155,10 +132,12 @@ export default {
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
&.error {
|
||||
}
|
||||
|
||||
.otp-input.error {
|
||||
border: 1px solid red !important;
|
||||
}
|
||||
}
|
||||
|
||||
.otp-input::-webkit-inner-spin-button,
|
||||
.otp-input::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
|
||||
+1
@@ -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('uploader', resolveComponent(require('./components/mycomponents/uploader.vue')));
|
||||
Vue.component('admin-data-table', resolveComponent(require('./components/AdminDataTable.vue')));
|
||||
|
||||
Vendored
+8
@@ -38,6 +38,8 @@ import ManagePenyata from './components/demo/admin/penyata/penyata.vue';
|
||||
import ManagePenyataIndex from './components/demo/admin/penyata/index.vue';
|
||||
import ManagePenyataView from './components/demo/admin/penyata/view.vue';
|
||||
|
||||
import AdminActivityLogIndex from './components/demo/admin/activitylog/index.vue';
|
||||
|
||||
|
||||
const default_component = {
|
||||
template: "<div>Not found: {{ $route.path }}</div>",
|
||||
@@ -185,6 +187,12 @@ const routes = [
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: "activity-log",
|
||||
component: AdminActivityLogIndex,
|
||||
name: "Activity Log",
|
||||
},
|
||||
|
||||
{
|
||||
path: "/account",
|
||||
component: ManageAccount,
|
||||
|
||||
Vendored
+29
@@ -4,18 +4,23 @@ import VoterVerification from "./components/demo/voter/verification.vue";
|
||||
import VoterHome from "./components/demo/voter/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 Vote from "./components/demo/voter/home/vote.vue";
|
||||
import Result from "./components/demo/voter/home/result.vue";
|
||||
import Attendance from "./components/demo/voter/home/attendance.vue";
|
||||
import Zoom from "./components/demo/voter/home/zoom.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 AdminIndex from "./components/demo/admin/index.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 ManageElectionIndex from "./components/demo/admin/home/index.vue";
|
||||
import ManageElectionResult from "./components/demo/admin/home/result.vue";
|
||||
@@ -71,6 +76,12 @@ export default [
|
||||
name: "Voter Home",
|
||||
},
|
||||
|
||||
{
|
||||
path: "calon",
|
||||
component: VoterNominees,
|
||||
name: "Voter Calon",
|
||||
},
|
||||
|
||||
{
|
||||
path: "mat27",
|
||||
component: Mat,
|
||||
@@ -121,6 +132,18 @@ export default [
|
||||
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",
|
||||
component: AdminLogin,
|
||||
@@ -137,6 +160,12 @@ export default [
|
||||
name: "Election Result",
|
||||
},
|
||||
|
||||
{
|
||||
path: "activity-log",
|
||||
component: AdminActivityLogIndex,
|
||||
name: "Activity Log",
|
||||
},
|
||||
|
||||
{
|
||||
path: "",
|
||||
component: ManageElection,
|
||||
|
||||
Vendored
+37
-2
@@ -108,9 +108,44 @@ const methods = {
|
||||
message = data.message;
|
||||
break;
|
||||
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;
|
||||
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;
|
||||
case 401:
|
||||
message = 'You need to login first.';
|
||||
|
||||
@@ -8,7 +8,13 @@
|
||||
<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" 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>
|
||||
@@ -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/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="/js/app.js"></script>
|
||||
<script src="{{ asset('js/app.js') }}?v={{ $assetV('js/app.js') }}"></script>
|
||||
<!-- Bootstrap CSS -->
|
||||
|
||||
<footer class="bg-body-tertiary text-center text-lg-start">
|
||||
|
||||
+21
-2
@@ -33,22 +33,32 @@ Route::prefix('v1')->group(function () { //Version 1 of my Rest API
|
||||
|
||||
Route::middleware(['auth:voterAPI', 'voter'])->group(function () {
|
||||
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::post('allowance/code', 'API\v1\Allowance\ClaimCodeController');
|
||||
Route::post('voter/logout', 'API\v1\Voter\LogoutController');
|
||||
});
|
||||
|
||||
Route::prefix('attendance')->group(function () {
|
||||
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
|
||||
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::get('information', 'API\v1\Admin\InformationController');
|
||||
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::put('{id}', 'API\v1\Admin\UpdateController');
|
||||
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::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::post('', 'API\v1\Nominee\AddController');
|
||||
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::post('', 'API\v1\Voter\AddController');
|
||||
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::put('{id}', 'API\v1\Voter\UpdateController');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user