Compare commits
10 Commits
e8d4f03627
...
e6e4ba7dbe
| Author | SHA1 | Date | |
|---|---|---|---|
| e6e4ba7dbe | |||
| cd1dc4b9d8 | |||
| fa2eaef6c4 | |||
| d5563d11c9 | |||
| 213ac73e07 | |||
| 378b00a1e6 | |||
| a969d4bbcf | |||
| 11cbededf6 | |||
| 8bbd64a91f | |||
| fe9223cae1 |
+9
-1
@@ -54,4 +54,12 @@ ONEWAY_SMS_LANG=1
|
||||
PHYSICAL_ATTENDANCE_GATE_ENABLED=false
|
||||
PHYSICAL_ATTENDANCE_GATE_SECRET=
|
||||
PHYSICAL_ATTENDANCE_GATE_PERIOD=60
|
||||
PHYSICAL_ATTENDANCE_GATE_DISPLAY_KEY=
|
||||
PHYSICAL_ATTENDANCE_GATE_DISPLAY_KEY=
|
||||
|
||||
# API token idle lifetime in minutes (enforced server-side for Passport tokens)
|
||||
VOTER_TOKEN_LIFETIME=120
|
||||
ADMIN_TOKEN_LIFETIME=480
|
||||
|
||||
MYKOPKB_SSO_SECRET=
|
||||
MYKOPKB_SSO_ISSUER="MYKOPKB 1.0"
|
||||
MYKOPKB_SSO_AUDIENCE=e-vote
|
||||
@@ -12,7 +12,7 @@
|
||||
[x] hardcode on daftar kehadiran
|
||||
[x] buang flow pengesahan
|
||||
[x] tambah siapa yang bagi duit (activity log)
|
||||
[ ] there is certain part/files that is hardcoded
|
||||
[x] there is certain part/files that is hardcoded
|
||||
|
||||
Penambahan Untuk Sistem Mengundi
|
||||
[x] Pendaftaran kehadiran (fizikal/maya)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateVoterDividenFromJson extends Command
|
||||
{
|
||||
protected $signature = 'voter:update-dividen-from-json
|
||||
{--file=database/seeds/inject/dividen_yuransaham.json : Relative path from project root}
|
||||
{--election_id= : Override election_id (default: latest in voter table)}
|
||||
{--dry-run : Show what would change without updating}';
|
||||
|
||||
protected $description = 'Update voter saham/yuran and populate dividen_saham/dividen_yuran from JSON for latest election only';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$fileOpt = (string) $this->option('file');
|
||||
$filePath = $this->resolvePath($fileOpt);
|
||||
|
||||
if (!is_file($filePath)) {
|
||||
$this->error("❌ JSON file not found: {$filePath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$electionId = $this->option('election_id');
|
||||
$electionId = $electionId !== null && $electionId !== '' ? (int) $electionId : (int) DB::table('voter')->max('election_id');
|
||||
|
||||
if ($electionId <= 0) {
|
||||
$this->error("❌ Cannot determine latest election_id (got {$electionId}).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$json = file_get_contents($filePath);
|
||||
$rows = json_decode($json, true);
|
||||
|
||||
if (!is_array($rows)) {
|
||||
$this->error("❌ Invalid JSON: expected array of rows.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$noAnggota = isset($row['no_anggota']) ? trim((string) $row['no_anggota']) : '';
|
||||
if ($noAnggota === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$map[$noAnggota] = [
|
||||
'saham' => $this->cleanNumber($row['saham'] ?? 0),
|
||||
'yuran' => $this->cleanNumber($row['yuran'] ?? 0),
|
||||
'dividen_saham' => $this->cleanNumber($row['dividen_saham'] ?? 0),
|
||||
'dividen_yuran' => $this->cleanNumber($row['dividen_yuran'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
if (count($map) === 0) {
|
||||
$this->error("❌ No usable rows found in JSON (missing no_anggota?).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$this->info("Target election_id: {$electionId}");
|
||||
$this->info("JSON members loaded: " . count($map));
|
||||
$this->info($dryRun ? "Mode: DRY RUN (no DB updates)" : "Mode: UPDATE");
|
||||
|
||||
$totalInElection = (int) DB::table('voter')->where('election_id', $electionId)->count();
|
||||
$this->info("Voters in election: {$totalInElection}");
|
||||
|
||||
$updated = 0;
|
||||
$matched = 0;
|
||||
$missingInJson = 0;
|
||||
$unchanged = 0;
|
||||
|
||||
$bar = $this->output->createProgressBar(max(1, $totalInElection));
|
||||
$bar->start();
|
||||
|
||||
DB::table('voter')
|
||||
->select(['id', 'no_anggota', 'saham', 'yuran', 'dividen_saham', 'dividen_yuran'])
|
||||
->where('election_id', $electionId)
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($chunk) use ($map, $dryRun, &$updated, &$matched, &$missingInJson, &$unchanged, $bar) {
|
||||
foreach ($chunk as $voter) {
|
||||
$noAnggota = $voter->no_anggota !== null ? trim((string) $voter->no_anggota) : '';
|
||||
|
||||
if ($noAnggota === '' || !array_key_exists($noAnggota, $map)) {
|
||||
$missingInJson++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
$matched++;
|
||||
$jsonRow = $map[$noAnggota];
|
||||
|
||||
$oldSaham = $voter->saham !== null ? (float) $voter->saham : 0.0;
|
||||
$oldYuran = $voter->yuran !== null ? (float) $voter->yuran : 0.0;
|
||||
$oldDividenSaham = $voter->dividen_saham !== null ? (float) $voter->dividen_saham : 0.0;
|
||||
$oldDividenYuran = $voter->dividen_yuran !== null ? (float) $voter->dividen_yuran : 0.0;
|
||||
|
||||
if (
|
||||
abs($oldSaham - $jsonRow['saham']) < 0.00001
|
||||
&& abs($oldYuran - $jsonRow['yuran']) < 0.00001
|
||||
&& abs($oldDividenSaham - $jsonRow['dividen_saham']) < 0.00001
|
||||
&& abs($oldDividenYuran - $jsonRow['dividen_yuran']) < 0.00001
|
||||
) {
|
||||
$unchanged++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
DB::table('voter')
|
||||
->where('id', $voter->id)
|
||||
->update([
|
||||
'saham' => $jsonRow['saham'],
|
||||
'yuran' => $jsonRow['yuran'],
|
||||
'dividen_saham' => $jsonRow['dividen_saham'],
|
||||
'dividen_yuran' => $jsonRow['dividen_yuran'],
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$updated++;
|
||||
$bar->advance();
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$bar->finish();
|
||||
$this->line('');
|
||||
|
||||
$this->info("✅ Matched in JSON: {$matched}");
|
||||
$this->info("✅ Updated: {$updated}" . ($dryRun ? " (would update)" : ""));
|
||||
$this->info("✅ Unchanged: {$unchanged}");
|
||||
$this->info("⚠️ Missing in JSON: {$missingInJson}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function resolvePath(string $path): string
|
||||
{
|
||||
if (strpos($path, DIRECTORY_SEPARATOR) === 0) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return base_path($path);
|
||||
}
|
||||
|
||||
private function cleanNumber($value): float
|
||||
{
|
||||
if ($value === null) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$value = (string) $value;
|
||||
$value = str_replace(['RM', ',', ' '], '', $value);
|
||||
|
||||
if (preg_match('/^\((.*)\)$/', $value, $matches)) {
|
||||
$value = $matches[1];
|
||||
}
|
||||
|
||||
$value = preg_replace('/[^0-9\.\-]/', '', $value);
|
||||
|
||||
return is_numeric($value) ? (float) $value : 0.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateVoterLatestYuranSahamFromJson extends Command
|
||||
{
|
||||
protected $signature = 'voter:update-latest-yuransaham-from-json
|
||||
{--file=database/seeds/inject/latest_yuransaham.json : Relative path from project root}
|
||||
{--election_id= : Override election_id (default: latest in voter table)}
|
||||
{--dry-run : Show what would change without updating}';
|
||||
|
||||
protected $description = 'Update voter saham_terkini/yuran_terkini from JSON for latest election only';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$fileOpt = (string) $this->option('file');
|
||||
$filePath = $this->resolvePath($fileOpt);
|
||||
|
||||
if (!is_file($filePath)) {
|
||||
$this->error("❌ JSON file not found: {$filePath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$electionId = $this->option('election_id');
|
||||
$electionId = $electionId !== null && $electionId !== '' ? (int) $electionId : (int) DB::table('voter')->max('election_id');
|
||||
|
||||
if ($electionId <= 0) {
|
||||
$this->error("❌ Cannot determine latest election_id (got {$electionId}).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$json = file_get_contents($filePath);
|
||||
$rows = json_decode($json, true);
|
||||
|
||||
if (!is_array($rows)) {
|
||||
$this->error("❌ Invalid JSON: expected array of rows.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$noAnggota = isset($row['no_anggota']) ? trim((string) $row['no_anggota']) : '';
|
||||
if ($noAnggota === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$map[$noAnggota] = [
|
||||
'saham_terkini' => $this->cleanNumber($row['saham_terkini'] ?? 0),
|
||||
'yuran_terkini' => $this->cleanNumber($row['yuran_terkini'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
if (count($map) === 0) {
|
||||
$this->error("❌ No usable rows found in JSON (missing no_anggota?).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$this->info("Target election_id: {$electionId}");
|
||||
$this->info("JSON members loaded: " . count($map));
|
||||
$this->info($dryRun ? "Mode: DRY RUN (no DB updates)" : "Mode: UPDATE");
|
||||
|
||||
$totalInElection = (int) DB::table('voter')->where('election_id', $electionId)->count();
|
||||
$this->info("Voters in election: {$totalInElection}");
|
||||
|
||||
$updated = 0;
|
||||
$matched = 0;
|
||||
$missingInJson = 0;
|
||||
$unchanged = 0;
|
||||
|
||||
$bar = $this->output->createProgressBar(max(1, $totalInElection));
|
||||
$bar->start();
|
||||
|
||||
DB::table('voter')
|
||||
->select(['id', 'no_anggota', 'saham_terkini', 'yuran_terkini'])
|
||||
->where('election_id', $electionId)
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($chunk) use ($map, $dryRun, &$updated, &$matched, &$missingInJson, &$unchanged, $bar) {
|
||||
foreach ($chunk as $voter) {
|
||||
$noAnggota = $voter->no_anggota !== null ? trim((string) $voter->no_anggota) : '';
|
||||
|
||||
if ($noAnggota === '' || !array_key_exists($noAnggota, $map)) {
|
||||
$missingInJson++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
$matched++;
|
||||
$jsonRow = $map[$noAnggota];
|
||||
|
||||
$oldSahamTerkini = $voter->saham_terkini !== null ? (float) $voter->saham_terkini : 0.0;
|
||||
$oldYuranTerkini = $voter->yuran_terkini !== null ? (float) $voter->yuran_terkini : 0.0;
|
||||
|
||||
if (
|
||||
abs($oldSahamTerkini - $jsonRow['saham_terkini']) < 0.00001
|
||||
&& abs($oldYuranTerkini - $jsonRow['yuran_terkini']) < 0.00001
|
||||
) {
|
||||
$unchanged++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
DB::table('voter')
|
||||
->where('id', $voter->id)
|
||||
->update([
|
||||
'saham_terkini' => $jsonRow['saham_terkini'],
|
||||
'yuran_terkini' => $jsonRow['yuran_terkini'],
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$updated++;
|
||||
$bar->advance();
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$bar->finish();
|
||||
$this->line('');
|
||||
|
||||
$this->info("✅ Matched in JSON: {$matched}");
|
||||
$this->info("✅ Updated: {$updated}" . ($dryRun ? " (would update)" : ""));
|
||||
$this->info("✅ Unchanged: {$unchanged}");
|
||||
$this->info("⚠️ Missing in JSON: {$missingInJson}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function resolvePath(string $path): string
|
||||
{
|
||||
if (strpos($path, DIRECTORY_SEPARATOR) === 0) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return base_path($path);
|
||||
}
|
||||
|
||||
private function cleanNumber($value): float
|
||||
{
|
||||
if ($value === null) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$value = (string) $value;
|
||||
$value = str_replace(['RM', ',', ' '], '', $value);
|
||||
|
||||
if (preg_match('/^\((.*)\)$/', $value, $matches)) {
|
||||
$value = $matches[1];
|
||||
}
|
||||
|
||||
$value = preg_replace('/[^0-9\.\-]/', '', $value);
|
||||
|
||||
return is_numeric($value) ? (float) $value : 0.0;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class UpdateVoterPelaburanFromJson extends Command
|
||||
{--election_id= : Override election_id (default: latest in voter table)}
|
||||
{--dry-run : Show what would change without updating}';
|
||||
|
||||
protected $description = 'Update voter pelaburan (dividen) from list-pelabur.json for latest election only';
|
||||
protected $description = 'Update voter dividen_pelaburan (from JSON pelaburan) and accumulated_amount from list-pelabur.json for latest election only';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
@@ -51,8 +51,11 @@ class UpdateVoterPelaburanFromJson extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
$pelaburanRaw = $row['pelaburan'] ?? 0;
|
||||
$map[$noAnggota] = $this->cleanNumber($pelaburanRaw);
|
||||
// JSON key "pelaburan" is the dividend amount → voter.dividen_pelaburan
|
||||
$map[$noAnggota] = [
|
||||
'dividen_pelaburan' => $this->cleanNumber($row['pelaburan'] ?? 0),
|
||||
'accumulated_amount' => $this->cleanNumber($row['accumulated_amount'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
if (count($map) === 0) {
|
||||
@@ -65,6 +68,7 @@ class UpdateVoterPelaburanFromJson extends Command
|
||||
$this->info("Target election_id: {$electionId}");
|
||||
$this->info("JSON members loaded: " . count($map));
|
||||
$this->info($dryRun ? "Mode: DRY RUN (no DB updates)" : "Mode: UPDATE");
|
||||
$this->info("Mapping: JSON.pelaburan → voter.dividen_pelaburan, JSON.accumulated_amount → voter.accumulated_amount");
|
||||
|
||||
$totalInElection = (int) DB::table('voter')->where('election_id', $electionId)->count();
|
||||
$this->info("Voters in election: {$totalInElection}");
|
||||
@@ -78,7 +82,7 @@ class UpdateVoterPelaburanFromJson extends Command
|
||||
$bar->start();
|
||||
|
||||
DB::table('voter')
|
||||
->select(['id', 'no_anggota', 'pelaburan'])
|
||||
->select(['id', 'no_anggota', 'dividen_pelaburan', 'accumulated_amount'])
|
||||
->where('election_id', $electionId)
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($chunk) use ($map, $dryRun, &$updated, &$matched, &$missingInJson, &$unchanged, $bar) {
|
||||
@@ -92,11 +96,15 @@ class UpdateVoterPelaburanFromJson extends Command
|
||||
}
|
||||
|
||||
$matched++;
|
||||
$jsonRow = $map[$noAnggota];
|
||||
|
||||
$newPelaburan = $map[$noAnggota];
|
||||
$oldPelaburan = $voter->pelaburan !== null ? (float) $voter->pelaburan : 0.0;
|
||||
$oldDividen = $voter->dividen_pelaburan !== null ? (float) $voter->dividen_pelaburan : 0.0;
|
||||
$oldAccumulated = $voter->accumulated_amount !== null ? (float) $voter->accumulated_amount : 0.0;
|
||||
|
||||
if (abs($oldPelaburan - $newPelaburan) < 0.00001) {
|
||||
if (
|
||||
abs($oldDividen - $jsonRow['dividen_pelaburan']) < 0.00001
|
||||
&& abs($oldAccumulated - $jsonRow['accumulated_amount']) < 0.00001
|
||||
) {
|
||||
$unchanged++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
@@ -106,7 +114,8 @@ class UpdateVoterPelaburanFromJson extends Command
|
||||
DB::table('voter')
|
||||
->where('id', $voter->id)
|
||||
->update([
|
||||
'pelaburan' => $newPelaburan,
|
||||
'dividen_pelaburan' => $jsonRow['dividen_pelaburan'],
|
||||
'accumulated_amount' => $jsonRow['accumulated_amount'],
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
@@ -129,7 +138,6 @@ class UpdateVoterPelaburanFromJson extends Command
|
||||
|
||||
private function resolvePath(string $path): string
|
||||
{
|
||||
// absolute path
|
||||
if (strpos($path, DIRECTORY_SEPARATOR) === 0) {
|
||||
return $path;
|
||||
}
|
||||
@@ -139,7 +147,7 @@ class UpdateVoterPelaburanFromJson extends Command
|
||||
|
||||
private function cleanNumber($value): float
|
||||
{
|
||||
if ($value === null) {
|
||||
if ($value === null || $value === '') {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
@@ -155,4 +163,3 @@ class UpdateVoterPelaburanFromJson extends Command
|
||||
return is_numeric($value) ? (float) $value : 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ class Kernel extends ConsoleKernel
|
||||
//
|
||||
Commands\ImportCSV::class,
|
||||
Commands\UpdateVoterPelaburanFromJson::class,
|
||||
Commands\UpdateVoterDividenFromJson::class,
|
||||
Commands\UpdateVoterLatestYuranSahamFromJson::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\API\v1\Admin\Impersonate;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\ApiTokenLifetime;
|
||||
use App\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -72,12 +73,12 @@ class LeaveController extends Controller
|
||||
])
|
||||
->log("admin leave impersonation: {$impersonator->email}");
|
||||
|
||||
return response()->json([
|
||||
return response()->json(array_merge([
|
||||
'status' => 'success',
|
||||
'message' => 'Berjaya kembali ke akaun asal.',
|
||||
'user' => $impersonator,
|
||||
'token' => $accessToken,
|
||||
]);
|
||||
], ApiTokenLifetime::loginMeta('admin')));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\API\v1\Admin\Impersonate;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\ApiTokenLifetime;
|
||||
use App\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -70,13 +71,13 @@ class StartController extends Controller
|
||||
])
|
||||
->log("admin impersonate: {$impersonator->email} -> {$target->email}");
|
||||
|
||||
return response()->json([
|
||||
return response()->json(array_merge([
|
||||
'status' => 'success',
|
||||
'message' => 'Impersonate berjaya.',
|
||||
'user' => $target,
|
||||
'impersonator' => $impersonator,
|
||||
'token' => $accessToken,
|
||||
]);
|
||||
], ApiTokenLifetime::loginMeta('admin')));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Util;
|
||||
use App\Support\ApiTokenLifetime;
|
||||
use App\User;
|
||||
|
||||
class LoginController extends Controller
|
||||
@@ -21,11 +22,13 @@ class LoginController extends Controller
|
||||
|
||||
if ($this->checkLogin($request)) {
|
||||
$user = Auth::user();
|
||||
ApiTokenLifetime::revokeActiveTokens($user);
|
||||
$result['status'] = 'success';
|
||||
$result['message'] = 'Login Successfully';
|
||||
$result['user'] = $user;
|
||||
$result['election_status'] = Util::getElectionStatus();
|
||||
$result['token'] = $user->createToken('My app', ['admin'])->accessToken;
|
||||
$result = array_merge($result, ApiTokenLifetime::loginMeta('admin'));
|
||||
|
||||
activity()
|
||||
->performedOn($user)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API\v1\Voter;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Util;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Import keanggotaan JSON (match by no_anggota) into voter money columns for the current election.
|
||||
* Expected row keys: no_anggota, pelaburan, pelaburan_2, saham, yuran. Null/empty → 0.
|
||||
*/
|
||||
class ImportKeanggotaanJsonController extends Controller
|
||||
{
|
||||
private const UPDATABLE_FIELDS = [
|
||||
'pelaburan',
|
||||
'pelaburan_2',
|
||||
'saham',
|
||||
'yuran',
|
||||
];
|
||||
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'file' => 'required|file|max:10240',
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$extension = strtolower($file->getClientOriginalExtension() ?: '');
|
||||
if ($extension !== 'json') {
|
||||
return response()->json([
|
||||
'status' => 'failed',
|
||||
'message' => 'Fail mestilah format .json.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$raw = file_get_contents($file->getRealPath());
|
||||
$rows = json_decode($raw, true);
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return response()->json([
|
||||
'status' => 'failed',
|
||||
'message' => 'JSON tidak sah. Jangkaan array objek.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$noAnggota = isset($row['no_anggota']) ? trim((string) $row['no_anggota']) : '';
|
||||
if ($noAnggota === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = [];
|
||||
foreach (self::UPDATABLE_FIELDS as $field) {
|
||||
if (array_key_exists($field, $row)) {
|
||||
$payload[$field] = $this->cleanNumber($row[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
if (count($payload) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$map[$noAnggota] = $payload;
|
||||
}
|
||||
|
||||
if (count($map) === 0) {
|
||||
return response()->json([
|
||||
'status' => 'failed',
|
||||
'message' => 'Tiada baris sah dalam JSON (perlu no_anggota dan sekurang-kurangnya satu medan).',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$electionId = (int) Util::getCurrentElection();
|
||||
if ($electionId <= 0) {
|
||||
return response()->json([
|
||||
'status' => 'failed',
|
||||
'message' => 'Tidak dapat mengenal pasti election semasa.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'election_id' => $electionId,
|
||||
'json_rows' => count($map),
|
||||
'matched' => 0,
|
||||
'updated' => 0,
|
||||
'unchanged' => 0,
|
||||
'missing_in_voter' => 0,
|
||||
'missing_in_json' => 0,
|
||||
];
|
||||
|
||||
$matchedKeys = [];
|
||||
|
||||
DB::table('voter')
|
||||
->select(array_merge(['id', 'no_anggota'], self::UPDATABLE_FIELDS))
|
||||
->where('election_id', $electionId)
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($chunk) use ($map, &$stats, &$matchedKeys) {
|
||||
foreach ($chunk as $voter) {
|
||||
$noAnggota = $voter->no_anggota !== null ? trim((string) $voter->no_anggota) : '';
|
||||
|
||||
if ($noAnggota === '' || !array_key_exists($noAnggota, $map)) {
|
||||
$stats['missing_in_json']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$stats['matched']++;
|
||||
$matchedKeys[$noAnggota] = true;
|
||||
$jsonRow = $map[$noAnggota];
|
||||
|
||||
$update = [];
|
||||
foreach ($jsonRow as $field => $newValue) {
|
||||
$oldValue = $voter->{$field} !== null ? (float) $voter->{$field} : 0.0;
|
||||
if (abs($oldValue - $newValue) >= 0.00001) {
|
||||
$update[$field] = $newValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($update) === 0) {
|
||||
$stats['unchanged']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$update['updated_at'] = now();
|
||||
DB::table('voter')->where('id', $voter->id)->update($update);
|
||||
$stats['updated']++;
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
foreach ($map as $noAnggota => $_payload) {
|
||||
if (!isset($matchedKeys[$noAnggota])) {
|
||||
$stats['missing_in_voter']++;
|
||||
}
|
||||
}
|
||||
|
||||
activity()
|
||||
->withProperties([
|
||||
'election_id' => $electionId,
|
||||
'filename' => $file->getClientOriginalName(),
|
||||
'stats' => $stats,
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
])
|
||||
->log('import keanggotaan json');
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Import keanggotaan berjaya.',
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
private function cleanNumber($value): float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$value = (string) $value;
|
||||
$value = str_replace(['RM', ',', ' '], '', $value);
|
||||
|
||||
if (preg_match('/^\((.*)\)$/', $value, $matches)) {
|
||||
$value = $matches[1];
|
||||
}
|
||||
|
||||
$value = preg_replace('/[^0-9\.\-]/', '', $value);
|
||||
|
||||
return is_numeric($value) ? (float) $value : 0.0;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\API\v1\Voter;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Util;
|
||||
use App\Support\ApiTokenLifetime;
|
||||
use App\UserOTP;
|
||||
use App\Voter;
|
||||
use Carbon\Carbon;
|
||||
@@ -219,6 +220,7 @@ class LoginController extends Controller
|
||||
|
||||
if (Auth::guard('voter')->attempt(['no_kp' => $request->no_kp, 'password' => 'admin', 'election_id' => Util::getCurrentElection()])) {
|
||||
$user = Auth::guard('voter')->user();
|
||||
ApiTokenLifetime::revokeActiveTokens($user);
|
||||
activity()
|
||||
->performedOn($user)
|
||||
->withProperties([
|
||||
@@ -231,13 +233,13 @@ class LoginController extends Controller
|
||||
])
|
||||
->log("voter login: {$user->name}");
|
||||
|
||||
return response()->json([
|
||||
return response()->json(array_merge([
|
||||
'status' => 'success',
|
||||
'message' => 'Login successfully.',
|
||||
'user' => Auth::guard('voter')->user(),
|
||||
'election_status' => Util::getElectionStatus(),
|
||||
'token' => Auth::guard('voter')->user()->createToken('My Token', ['vote'])->accessToken,
|
||||
]);
|
||||
], ApiTokenLifetime::loginMeta('voter')));
|
||||
}
|
||||
|
||||
activity()
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API\v1\Voter;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Util;
|
||||
use App\Support\ApiTokenLifetime;
|
||||
use App\Support\JwtVerifier;
|
||||
use App\Voter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class SsoLoginController extends Controller
|
||||
{
|
||||
public function login(Request $request)
|
||||
{
|
||||
$token = $request->query('token');
|
||||
|
||||
if (! $token) {
|
||||
return $this->redirectToLogin('Token SSO tidak sah.', 'token_invalid');
|
||||
}
|
||||
|
||||
$secret = config('services.mykopkb.sso_secret');
|
||||
if (! $secret) {
|
||||
return $this->redirectToLogin(
|
||||
app()->environment('local', 'development')
|
||||
? 'MYKOPKB_SSO_SECRET belum dikonfigurasi dalam .env.'
|
||||
: 'Token SSO tamat tempoh atau tidak sah.',
|
||||
'token_invalid'
|
||||
);
|
||||
}
|
||||
|
||||
$payload = JwtVerifier::verify($token, $secret, [
|
||||
'issuer' => config('services.mykopkb.sso_issuer', 'mykopkb'),
|
||||
'audience' => config('services.mykopkb.sso_audience', 'e-vote'),
|
||||
]);
|
||||
|
||||
if (! $payload) {
|
||||
return $this->redirectToLogin('Token SSO tamat tempoh atau tidak sah.', 'token_invalid');
|
||||
}
|
||||
|
||||
$jti = $payload['jti'] ?? null;
|
||||
if (! $jti || Cache::has("sso_jti:$jti")) {
|
||||
return $this->redirectToLogin('Token SSO telah digunakan.', 'token_used');
|
||||
}
|
||||
|
||||
$icNumber = preg_replace('/[\s-]+/', '', (string) ($payload['ic_number'] ?? ''));
|
||||
$memberNumber = trim((string) ($payload['member_number'] ?? ''));
|
||||
|
||||
if ($icNumber === '' || $memberNumber === '') {
|
||||
return $this->redirectToLogin('Token SSO tidak sah.', 'token_invalid');
|
||||
}
|
||||
|
||||
$voter = Voter::query()
|
||||
->where('no_kp', $icNumber)
|
||||
->where('no_anggota', $memberNumber)
|
||||
->where('election_id', Util::getCurrentElection())
|
||||
->first();
|
||||
|
||||
if (! $voter) {
|
||||
activity()
|
||||
->withProperties([
|
||||
'ic_number' => $icNumber,
|
||||
'member_number' => $memberNumber,
|
||||
'election_id' => Util::getCurrentElection(),
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
])
|
||||
->log('voter sso login rejected: not in current election');
|
||||
|
||||
return $this->redirectToLogin(
|
||||
'Akaun pengundi tidak dijumpai untuk pilihan raya semasa. Sila daftar di kaunter IT atau log masuk secara manual.',
|
||||
'voter_not_found'
|
||||
);
|
||||
}
|
||||
|
||||
Cache::put("sso_jti:$jti", true, now()->addMinutes(5));
|
||||
|
||||
ApiTokenLifetime::revokeActiveTokens($voter);
|
||||
|
||||
$passportToken = $voter->createToken('SSO Login', ['vote'])->accessToken;
|
||||
$meta = ApiTokenLifetime::loginMeta('voter');
|
||||
|
||||
activity()
|
||||
->performedOn($voter)
|
||||
->withProperties([
|
||||
'election_id' => Util::getCurrentElection(),
|
||||
'voter_id' => $voter->id,
|
||||
'voter_name' => $voter->name,
|
||||
'no_kp' => $voter->no_kp,
|
||||
'no_anggota' => $voter->no_anggota,
|
||||
'sso_sub' => $payload['sub'] ?? null,
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
])
|
||||
->log("voter sso login: {$voter->name}");
|
||||
|
||||
$query = http_build_query([
|
||||
'sso_token' => $passportToken,
|
||||
'token_expires_at' => $meta['token_expires_at'],
|
||||
]);
|
||||
|
||||
return redirect('/login?'.$query);
|
||||
}
|
||||
|
||||
private function redirectToLogin(string $message, string $code = 'sso_error')
|
||||
{
|
||||
return redirect('/login?'.http_build_query([
|
||||
'sso_error' => $message,
|
||||
'sso_error_code' => $code,
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -67,5 +67,6 @@ class Kernel extends HttpKernel
|
||||
'has_voted' => \App\Http\Middleware\HasVotedMiddleware::class,
|
||||
'physical_gate_display' => \App\Http\Middleware\PhysicalGateDisplayKeyMiddleware::class,
|
||||
'fizikal_registration_verified' => \App\Http\Middleware\FizikalRegistrationVerifiedMiddleware::class,
|
||||
'api_token_lifetime' => \App\Http\Middleware\EnforceApiTokenLifetimeMiddleware::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ class ElectionMiddleware
|
||||
&& !($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'))
|
||||
// Allow keanggotaan JSON import during election
|
||||
&& !($request->is('api/v1/voter/import-keanggotaan-json') && $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')
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\ApiTokenLifetime;
|
||||
use Closure;
|
||||
|
||||
class EnforceApiTokenLifetimeMiddleware
|
||||
{
|
||||
/**
|
||||
* Enforce application session lifetime for Passport personal access tokens.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Closure $next
|
||||
* @param string $portal voter|admin
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle($request, Closure $next, $portal = 'voter')
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user || ! ApiTokenLifetime::isExpired($user, $portal)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
try {
|
||||
if (method_exists($user, 'token') && $user->token()) {
|
||||
$user->token()->revoke();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Continue with 401 even if revoke fails.
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'failed',
|
||||
'message' => 'Sesi tamat. Sila log masuk semula.',
|
||||
], 401);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class ApiTokenLifetime
|
||||
{
|
||||
public static function minutesForPortal(string $portal): int
|
||||
{
|
||||
$key = $portal . '_lifetime';
|
||||
|
||||
return (int) config('api_tokens.' . $key, config('session.lifetime', 120));
|
||||
}
|
||||
|
||||
public static function expiresAtForPortal(string $portal): Carbon
|
||||
{
|
||||
return Carbon::now()->addMinutes(self::minutesForPortal($portal));
|
||||
}
|
||||
|
||||
public static function loginMeta(string $portal): array
|
||||
{
|
||||
$expiresAt = self::expiresAtForPortal($portal);
|
||||
|
||||
return [
|
||||
'token_expires_at' => $expiresAt->toIso8601String(),
|
||||
'token_lifetime_minutes' => self::minutesForPortal($portal),
|
||||
];
|
||||
}
|
||||
|
||||
public static function isExpired(Authenticatable $user, string $portal): bool
|
||||
{
|
||||
if (! method_exists($user, 'token')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$token = $user->token();
|
||||
if (! $token || ! $token->created_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Carbon::now()->greaterThanOrEqualTo(
|
||||
Carbon::parse($token->created_at)->addMinutes(self::minutesForPortal($portal))
|
||||
);
|
||||
}
|
||||
|
||||
public static function revokeActiveTokens(Authenticatable $user): void
|
||||
{
|
||||
if (! method_exists($user, 'tokens')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$user->tokens()->where('revoked', false)->update(['revoked' => true]);
|
||||
} catch (\Throwable $e) {
|
||||
// Login should not fail if revocation fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class JwtVerifier
|
||||
{
|
||||
/**
|
||||
* Verify an HS256 JWT and return the payload, or null if invalid.
|
||||
*
|
||||
* @param array{issuer?: string, audience?: string, leeway?: int} $options
|
||||
*/
|
||||
public static function verify(string $token, string $secret, array $options = []): ?array
|
||||
{
|
||||
$result = self::diagnose($token, $secret, $options);
|
||||
|
||||
return $result['valid'] ? $result['payload'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{issuer?: string, audience?: string, leeway?: int} $options
|
||||
* @return array{
|
||||
* valid: bool,
|
||||
* reason: string|null,
|
||||
* payload: array|null,
|
||||
* claims: array<string, mixed>|null
|
||||
* }
|
||||
*/
|
||||
public static function diagnose(string $token, string $secret, array $options = []): array
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 3) {
|
||||
return self::failure('malformed_token');
|
||||
}
|
||||
|
||||
[$headerB64, $payloadB64, $signatureB64] = $parts;
|
||||
|
||||
$expected = self::base64UrlEncode(
|
||||
hash_hmac('sha256', "$headerB64.$payloadB64", $secret, true)
|
||||
);
|
||||
|
||||
if (! hash_equals($expected, $signatureB64)) {
|
||||
return self::failure('invalid_signature', null, self::decodePayload($payloadB64));
|
||||
}
|
||||
|
||||
$header = json_decode(self::base64UrlDecode($headerB64), true);
|
||||
$payload = json_decode(self::base64UrlDecode($payloadB64), true);
|
||||
|
||||
if (! is_array($header) || ! is_array($payload)) {
|
||||
return self::failure('invalid_json');
|
||||
}
|
||||
|
||||
if (($header['alg'] ?? '') !== 'HS256') {
|
||||
return self::failure('unsupported_algorithm', $payload);
|
||||
}
|
||||
|
||||
$issuer = $options['issuer'] ?? null;
|
||||
if ($issuer !== null && ($payload['iss'] ?? '') !== $issuer) {
|
||||
return self::failure('issuer_mismatch', $payload);
|
||||
}
|
||||
|
||||
$audience = $options['audience'] ?? null;
|
||||
if ($audience !== null && ($payload['aud'] ?? '') !== $audience) {
|
||||
return self::failure('audience_mismatch', $payload);
|
||||
}
|
||||
|
||||
$leeway = (int) ($options['leeway'] ?? 0);
|
||||
$now = time();
|
||||
|
||||
if (($payload['exp'] ?? 0) < ($now - $leeway)) {
|
||||
return self::failure('token_expired', $payload);
|
||||
}
|
||||
|
||||
if (($payload['nbf'] ?? 0) > ($now + $leeway)) {
|
||||
return self::failure('token_not_yet_valid', $payload);
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'reason' => null,
|
||||
'payload' => $payload,
|
||||
'claims' => self::summarizeClaims($payload),
|
||||
];
|
||||
}
|
||||
|
||||
public static function isLaravelEncryptedSecret(?string $secret): bool
|
||||
{
|
||||
if (! is_string($secret) || $secret === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$decoded = base64_decode($secret, true);
|
||||
if ($decoded === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$json = json_decode($decoded, true);
|
||||
|
||||
return is_array($json)
|
||||
&& array_key_exists('iv', $json)
|
||||
&& array_key_exists('value', $json)
|
||||
&& array_key_exists('mac', $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
protected static function decodePayload(string $payloadB64): ?array
|
||||
{
|
||||
$payload = json_decode(self::base64UrlDecode($payloadB64), true);
|
||||
|
||||
return is_array($payload) ? $payload : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $payload
|
||||
* @return array{valid: false, reason: string, payload: null, claims: array<string, mixed>|null}
|
||||
*/
|
||||
protected static function failure(string $reason, ?array $payload = null, ?array $claims = null): array
|
||||
{
|
||||
return [
|
||||
'valid' => false,
|
||||
'reason' => $reason,
|
||||
'payload' => null,
|
||||
'claims' => $claims ?? ($payload ? self::summarizeClaims($payload) : null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected static function summarizeClaims(array $payload): array
|
||||
{
|
||||
return [
|
||||
'iss' => $payload['iss'] ?? null,
|
||||
'aud' => $payload['aud'] ?? null,
|
||||
'exp' => $payload['exp'] ?? null,
|
||||
'nbf' => $payload['nbf'] ?? null,
|
||||
'ic_number' => $payload['ic_number'] ?? null,
|
||||
'member_number' => $payload['member_number'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function base64UrlDecode(string $data): string
|
||||
{
|
||||
$remainder = strlen($data) % 4;
|
||||
if ($remainder) {
|
||||
$data .= str_repeat('=', 4 - $remainder);
|
||||
}
|
||||
|
||||
return base64_decode(strtr($data, '-_', '+/'), true) ?: '';
|
||||
}
|
||||
|
||||
protected static function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ class Voter extends Authenticatable
|
||||
|
||||
protected $table = 'voter';
|
||||
|
||||
protected $fillable = ['name', 'no_kp', 'no_anggota','unit', 'election_id', 'alamat', 'telefon', 'saham', 'yuran', 'kehadiran','status_penyata','persetujuan','tarikh_sah','cadangan','pelaburan','tergempar','barangan','peribadi','berjamin_yuran','roadtax','pelbagai'];
|
||||
protected $fillable = ['name', 'no_kp', 'no_anggota','unit', 'election_id', 'alamat', 'telefon', 'saham', 'yuran', 'dividen_saham', 'dividen_yuran', 'saham_terkini', 'yuran_terkini', 'kehadiran','status_penyata','persetujuan','tarikh_sah','cadangan','pelaburan', 'dividen_pelaburan', 'pelaburan_terkini', 'pelaburan_2', 'dividen_pelaburan_2', 'pelaburan_terkini_2', 'accumulated_amount', 'tergempar','barangan','peribadi','berjamin_yuran','roadtax','pelbagai'];
|
||||
|
||||
protected $casts = [
|
||||
'fizikal_registration_verified_at' => 'datetime',
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| API token idle lifetimes (minutes)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Passport 4 personal access tokens are issued with a one-year JWT TTL.
|
||||
| These values are enforced in middleware based on oauth_access_tokens.created_at.
|
||||
|
|
||||
*/
|
||||
|
||||
'voter_lifetime' => (int) env('VOTER_TOKEN_LIFETIME', env('SESSION_LIFETIME', 120)),
|
||||
|
||||
'admin_lifetime' => (int) env('ADMIN_TOKEN_LIFETIME', 480),
|
||||
|
||||
];
|
||||
@@ -35,4 +35,10 @@ return [
|
||||
'secret' => env('STRIPE_SECRET'),
|
||||
],
|
||||
|
||||
'mykopkb' => [
|
||||
'sso_secret' => env('MYKOPKB_SSO_SECRET'),
|
||||
'sso_issuer' => env('MYKOPKB_SSO_ISSUER'),
|
||||
'sso_audience' => env('MYKOPKB_SSO_AUDIENCE', 'e-vote'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
*.sqlite
|
||||
*.sqlite
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class AddAccumulatedAmountToVoterTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('voter', function (Blueprint $table) {
|
||||
$table->decimal('accumulated_amount', 12, 2)->default(0)->after('pelaburan');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('voter', function (Blueprint $table) {
|
||||
$table->dropColumn('accumulated_amount');
|
||||
});
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class AddDividenSahamAndDividenYuranToVoterTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('voter', function (Blueprint $table) {
|
||||
$table->decimal('dividen_saham', 12, 2)->default(0)->after('yuran');
|
||||
$table->decimal('dividen_yuran', 12, 2)->default(0)->after('dividen_saham');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('voter', function (Blueprint $table) {
|
||||
$table->dropColumn(['dividen_saham', 'dividen_yuran']);
|
||||
});
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class AddSahamTerkiniAndYuranTerkiniToVoterTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('voter', function (Blueprint $table) {
|
||||
$table->decimal('saham_terkini', 12, 2)->default(0)->after('dividen_yuran');
|
||||
$table->decimal('yuran_terkini', 12, 2)->default(0)->after('saham_terkini');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('voter', function (Blueprint $table) {
|
||||
$table->dropColumn(['saham_terkini', 'yuran_terkini']);
|
||||
});
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class AddDividenPelaburanAndPelaburanTerkiniToVoterTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('voter', function (Blueprint $table) {
|
||||
$table->decimal('dividen_pelaburan', 12, 2)->default(0)->after('pelaburan');
|
||||
$table->decimal('pelaburan_terkini', 12, 2)->default(0)->after('dividen_pelaburan');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('voter', function (Blueprint $table) {
|
||||
$table->dropColumn(['dividen_pelaburan', 'pelaburan_terkini']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class AddPelaburan2ColumnsToVoterTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('voter', function (Blueprint $table) {
|
||||
$table->decimal('pelaburan_2', 12, 2)->default(0)->after('pelaburan_terkini');
|
||||
$table->decimal('dividen_pelaburan_2', 12, 2)->default(0)->after('pelaburan_2');
|
||||
$table->decimal('pelaburan_terkini_2', 12, 2)->default(0)->after('dividen_pelaburan_2');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('voter', function (Blueprint $table) {
|
||||
$table->dropColumn(['pelaburan_2', 'dividen_pelaburan_2', 'pelaburan_terkini_2']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
BIN
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Vendored
BIN
Binary file not shown.
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -143,4 +143,4 @@ You can watch the System Demo [here](https://youtu.be/dsEoONiovdA).
|
||||
php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag=migrations
|
||||
```
|
||||
|
||||
formula kiraan = ((Bulan masuk/12) * jumlah pelaburan) * 0.12
|
||||
formula kiraan (purate) = ((Bulan masuk/12) * jumlah pelaburan) * 0.12
|
||||
Vendored
+30
-3
@@ -19,16 +19,34 @@ const router = new VueRouter({
|
||||
routes
|
||||
});
|
||||
|
||||
var publicVoterRouteNames = {
|
||||
'Voter Login': true,
|
||||
'Voter Verify': true,
|
||||
'Physical Gate Display': true,
|
||||
'Roulette Wheel Demo': true,
|
||||
'penyata': true
|
||||
};
|
||||
|
||||
router.beforeEach(function (to, from, next) {
|
||||
if (to.name === 'Voter Login' && utilMethods.isAdminPortalSession()) {
|
||||
var dest = utilMethods.getAdminEntryRoute();
|
||||
return next(Object.assign({}, dest, { replace: true }));
|
||||
}
|
||||
if (!to.path.startsWith('/admin') || to.name === 'Admin Login') {
|
||||
|
||||
var isAdminRoute = to.path.indexOf('/admin') === 0;
|
||||
var isPublicVoterRoute = !!publicVoterRouteNames[to.name]
|
||||
|| to.path.indexOf('/display/') === 0
|
||||
|| to.path.indexOf('/demo/') === 0;
|
||||
|
||||
if (!isAdminRoute && !isPublicVoterRoute && !utilMethods.isLogin()) {
|
||||
return next({ name: 'Voter Login', replace: true });
|
||||
}
|
||||
|
||||
if (!isAdminRoute || to.name === 'Admin Login') {
|
||||
return next();
|
||||
}
|
||||
if (!localStorage.getItem('Access Token')) {
|
||||
return next();
|
||||
if (!utilMethods.isLogin()) {
|
||||
return next({ name: 'Admin Login', replace: true });
|
||||
}
|
||||
var role = localStorage.getItem('admin_role');
|
||||
if (role === '3' && to.name !== 'Kehadiran Calon') {
|
||||
@@ -37,6 +55,15 @@ router.beforeEach(function (to, from, next) {
|
||||
next();
|
||||
});
|
||||
|
||||
axios.interceptors.response.use(function (response) {
|
||||
return response;
|
||||
}, function (error) {
|
||||
if (error.response && error.response.status === 401) {
|
||||
utilMethods.handleUnauthorized(router);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
const app = new Vue({
|
||||
router
|
||||
}).$mount('#app');
|
||||
|
||||
@@ -139,15 +139,14 @@ export default {
|
||||
// Swap admin token + user in local state.
|
||||
var token = response.data.token;
|
||||
if (token) {
|
||||
localStorage['Access Token'] = 'Bearer ' + token;
|
||||
vm.util.persistLoginSession(token, response.data.token_expires_at, {
|
||||
adminSession: true,
|
||||
adminRole: response.data.user && response.data.user.role
|
||||
});
|
||||
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('Menyamar berjaya', 'success');
|
||||
|
||||
@@ -52,6 +52,9 @@
|
||||
<router-link :to="{ name: 'Penyata Anggota' }" tag="li">
|
||||
<a href="#">Penyata Anggota</a>
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'Import Keanggotaan' }" tag="li">
|
||||
<a href="#">Import Keanggotaan</a>
|
||||
</router-link>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -146,9 +149,14 @@ export default {
|
||||
})
|
||||
.catch(error => {
|
||||
$.notifyClose();
|
||||
if (this.util.showResult(error) == 401) {
|
||||
vm.logout();
|
||||
var status = error.response ? error.response.status : 500;
|
||||
if (status === 401) {
|
||||
vm.util.clearSession();
|
||||
vm.$router.replace({ name: 'Admin Login' });
|
||||
return;
|
||||
}
|
||||
vm.util.showResult(error, 'error');
|
||||
vm.loading = false;
|
||||
})
|
||||
} else {
|
||||
this.$router.push({ name: 'Admin Login' });
|
||||
@@ -186,15 +194,14 @@ export default {
|
||||
return;
|
||||
}
|
||||
if (response.data.token) {
|
||||
localStorage['Access Token'] = 'Bearer ' + response.data.token;
|
||||
if (vm.util && vm.util.setAuthorization) vm.util.setAuthorization();
|
||||
vm.util.persistLoginSession(response.data.token, response.data.token_expires_at, {
|
||||
adminSession: true,
|
||||
adminRole: response.data.user && response.data.user.role
|
||||
});
|
||||
}
|
||||
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);
|
||||
@@ -206,9 +213,7 @@ export default {
|
||||
},
|
||||
|
||||
logout: function () {
|
||||
localStorage.removeItem('admin_role');
|
||||
localStorage.removeItem('is_impersonating');
|
||||
localStorage.clear();
|
||||
this.util.clearSession();
|
||||
this.$router.push({ name: 'Admin Login' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,12 +84,10 @@ export default {
|
||||
.then(response => {
|
||||
vm.stopLoading();
|
||||
if (this.util.showResult(response, 'success')) {
|
||||
localStorage['Access Token'] = `Bearer ${response.data.token}`;
|
||||
localStorage.setItem('admin_session', '1');
|
||||
if (response.data.user && response.data.user.role !== undefined && response.data.user.role !== null) {
|
||||
localStorage.setItem('admin_role', String(response.data.user.role));
|
||||
}
|
||||
this.util.setAuthorization();
|
||||
this.util.persistLoginSession(response.data.token, response.data.token_expires_at, {
|
||||
adminSession: true,
|
||||
adminRole: response.data.user && response.data.user.role
|
||||
});
|
||||
if (Number(response.data.user && response.data.user.role) === 3) {
|
||||
vm.$router.push({ name: 'Kehadiran Calon' });
|
||||
} else {
|
||||
|
||||
@@ -1,49 +1,60 @@
|
||||
<template>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
<h4><b>Kemaskini Maklumat</b></h4>
|
||||
<form @submit.prevent="edit()" id="edit-form">
|
||||
<div class="form-group">
|
||||
<label for="name">Nama</label>
|
||||
<input type="text" name="name" class="form-control" :value="data.voter.name" required/>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
<h4><b>Kemaskini Maklumat</b></h4>
|
||||
<form @submit.prevent="edit()" id="edit-form">
|
||||
<div class="form-group">
|
||||
<label for="name">Nama</label>
|
||||
<input type="text" name="name" class="form-control" :value="data.voter.name" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="no_kp">No. Kad Pengenalan</label>
|
||||
<input type="text" name="no_kp" class="form-control" :value="data.voter.no_kp" required/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="no_kp">No. Kad Pengenalan</label>
|
||||
<input type="text" name="no_kp" class="form-control" :value="data.voter.no_kp" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="telefon">No. Telefon</label>
|
||||
<input type="text" name="telefon" class="form-control" :value="data.voter.telefon" required/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="telefon">No. Telefon</label>
|
||||
<input type="text" name="telefon" class="form-control" :value="data.voter.telefon" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="Unit">Unit</label>
|
||||
<input type="text" name="unit" class="form-control" :value="data.voter.unit" required/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="Unit">Unit</label>
|
||||
<input type="text" name="unit" class="form-control" :value="data.voter.unit" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="saham">Saham</label>
|
||||
<input type="text" name="saham" class="form-control" :value="data.voter.saham" required/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="saham">Saham</label>
|
||||
<input type="text" name="saham" class="form-control" :value="data.voter.saham" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="yuran">Yuran</label>
|
||||
<input type="text" name="yuran" class="form-control" :value="data.voter.yuran" required/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="yuran">Yuran</label>
|
||||
<input type="text" name="yuran" class="form-control" :value="data.voter.yuran" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<input type="submit" value="Hantar" class="btn btn-success"/>
|
||||
<router-link :to="{name: 'Manage Voter'}" class="btn btn-default">Kembali</router-link>
|
||||
</div>
|
||||
</form>
|
||||
<div class="form-group">
|
||||
<label for="accumulated_amount">Jumlah Pelaburan (hingga Dis 2025)</label>
|
||||
<input type="text" name="accumulated_amount" class="form-control"
|
||||
:value="data.voter.accumulated_amount" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="pelaburan">Dividen Simpanan Khas (Tabung 1) Tahun 2025</label>
|
||||
<input type="text" name="pelaburan" class="form-control" :value="data.voter.pelaburan" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<input type="submit" value="Hantar" class="btn btn-success" />
|
||||
<router-link :to="{ name: 'Manage Voter' }" class="btn btn-default">Kembali</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default{
|
||||
export default {
|
||||
data: function () {
|
||||
return {
|
||||
loading: false
|
||||
@@ -52,7 +63,7 @@ export default{
|
||||
|
||||
created: function () {
|
||||
if (!this.data.voter.id) {
|
||||
this.$router.push({name:'Manage Voter'});
|
||||
this.$router.push({ name: 'Manage Voter' });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -62,15 +73,15 @@ export default{
|
||||
this.loading = true;
|
||||
var vm = this;
|
||||
this.util.notify('Updating voter', 'loading');
|
||||
axios.put(config.API+'voter/'+this.data.voter.id, $('#edit-form').serialize())
|
||||
.then(response=>{
|
||||
axios.put(config.API + 'voter/' + this.data.voter.id, $('#edit-form').serialize())
|
||||
.then(response => {
|
||||
vm.loading = false;
|
||||
$.notifyClose();
|
||||
if (vm.util.showResult(response, 'success')) {
|
||||
vm.$router.push({name:'Manage Voter'});
|
||||
vm.$router.push({ name: 'Manage Voter' });
|
||||
}
|
||||
})
|
||||
.catch(error=>{
|
||||
.catch(error => {
|
||||
$.notifyClose();
|
||||
vm.loading = false;
|
||||
vm.util.showResult(error);
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div class="container col-md-10 col-md-offset-1">
|
||||
<h4>Import Keanggotaan (JSON)</h4>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
<p class="help-block">
|
||||
Muat naik fail JSON dengan format yang sama seperti
|
||||
<code>keanggotaan_jun-2026.json</code>. Data dikemas kini mengikut
|
||||
<code>no_anggota</code> untuk pilihan raya semasa. Medan kosong/null diganti dengan
|
||||
<code>0</code>.
|
||||
</p>
|
||||
|
||||
<div class="panel panel-default" style="margin-bottom: 20px;">
|
||||
<div class="panel-heading">Contoh format JSON</div>
|
||||
<div class="panel-body">
|
||||
<p class="help-block" style="margin-top: 0;">
|
||||
Fail mestilah <strong>array objek</strong> dengan medan di bawah.
|
||||
Nilai kosong (<code>""</code>) atau <code>null</code> akan diganti dengan <code>0</code>.
|
||||
</p>
|
||||
|
||||
<div class="table-responsive" style="margin-bottom: 12px;">
|
||||
<table class="table table-striped table-bordered table-condensed" style="margin-bottom: 0;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="col in sampleColumns" :key="col">{{ col }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in sampleRows" :key="idx">
|
||||
<td v-for="col in sampleColumns" :key="col">
|
||||
{{ formatPreviewCell(row[col]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
style="margin: 0; max-height: 220px; overflow: auto; background: #f5f5f5; padding: 10px; border-radius: 3px;">{{ sampleJson }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Fail JSON</label>
|
||||
<input ref="fileInput" type="file" class="form-control" accept=".json,application/json"
|
||||
@change="onFileChange">
|
||||
<small class="help-block">
|
||||
Medan yang dikemas kini: <code>pelaburan</code>, <code>pelaburan_2</code>,
|
||||
<code>saham</code>, <code>yuran</code>.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedFileName" class="alert alert-info" style="margin-bottom: 15px;">
|
||||
Fail dipilih: <strong>{{ selectedFileName }}</strong>
|
||||
<span v-if="previewLoading"> — membaca fail...</span>
|
||||
</div>
|
||||
|
||||
<div v-if="previewError" class="alert alert-danger">
|
||||
{{ previewError }}
|
||||
</div>
|
||||
|
||||
<div v-if="previewRows.length" class="panel panel-info" style="margin-bottom: 15px;">
|
||||
<div class="panel-heading">
|
||||
Pratonton JSON
|
||||
<span class="pull-right">
|
||||
{{ previewTotal }} baris
|
||||
<span v-if="previewTotal > previewLimit">
|
||||
(menunjukkan {{ previewLimit }} pertama)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-body" style="padding: 0;">
|
||||
<div class="table-responsive" style="max-height: 360px; overflow: auto;">
|
||||
<table class="table table-striped table-bordered table-condensed" style="margin-bottom: 0;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th v-for="col in previewColumns" :key="col">{{ col }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in previewRows" :key="idx">
|
||||
<td>{{ idx + 1 }}</td>
|
||||
<td v-for="col in previewColumns" :key="col">
|
||||
{{ formatPreviewCell(row[col]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<a href="#" @click.prevent="showRaw = !showRaw">
|
||||
{{ showRaw ? 'Sembunyikan' : 'Tunjuk' }} raw JSON
|
||||
</a>
|
||||
<pre v-if="showRaw"
|
||||
style="margin: 10px 0 0; max-height: 280px; overflow: auto; background: #f5f5f5; padding: 10px; border-radius: 3px;">{{ previewRaw }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="!canImport" @click="upload">
|
||||
<i class="fa fa-upload"></i>
|
||||
{{ uploading ? 'Mengimport...' : 'Import' }}
|
||||
</button>
|
||||
<button v-if="file" class="btn btn-default" style="margin-left: 8px;" :disabled="uploading" @click="clearFile">
|
||||
Kosongkan
|
||||
</button>
|
||||
|
||||
<div v-if="result" class="panel panel-success" style="margin-top: 20px;">
|
||||
<div class="panel-heading">Hasil Import</div>
|
||||
<div class="panel-body">
|
||||
<ul class="list-unstyled" style="margin-bottom: 0;">
|
||||
<li>Election ID: <strong>{{ result.election_id }}</strong></li>
|
||||
<li>Baris JSON: <strong>{{ result.json_rows }}</strong></li>
|
||||
<li>Padanan (matched): <strong>{{ result.matched }}</strong></li>
|
||||
<li>Dikemas kini: <strong>{{ result.updated }}</strong></li>
|
||||
<li>Tiada perubahan: <strong>{{ result.unchanged }}</strong></li>
|
||||
<li>Dalam JSON tetapi tiada dalam voter: <strong>{{ result.missing_in_voter }}</strong></li>
|
||||
<li>Dalam voter tetapi tiada dalam JSON: <strong>{{ result.missing_in_json }}</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const PREVIEW_LIMIT = 50;
|
||||
|
||||
const SAMPLE_ROWS = [
|
||||
{
|
||||
no_anggota: "7",
|
||||
pelaburan: "473776.8",
|
||||
pelaburan_2: "",
|
||||
saham: "6600",
|
||||
yuran: "4671.7"
|
||||
},
|
||||
{
|
||||
no_anggota: "12",
|
||||
pelaburan: "527090.7",
|
||||
pelaburan_2: "",
|
||||
saham: "10000",
|
||||
yuran: "19463.9"
|
||||
},
|
||||
{
|
||||
no_anggota: "22",
|
||||
pelaburan: "",
|
||||
pelaburan_2: "",
|
||||
saham: "5000",
|
||||
yuran: "5260"
|
||||
}
|
||||
];
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
file: null,
|
||||
selectedFileName: "",
|
||||
uploading: false,
|
||||
result: null,
|
||||
previewLoading: false,
|
||||
previewError: "",
|
||||
previewRows: [],
|
||||
previewColumns: [],
|
||||
previewTotal: 0,
|
||||
previewLimit: PREVIEW_LIMIT,
|
||||
previewRaw: "",
|
||||
showRaw: false,
|
||||
sampleColumns: ["no_anggota", "pelaburan", "pelaburan_2", "saham", "yuran"],
|
||||
sampleRows: SAMPLE_ROWS,
|
||||
sampleJson: JSON.stringify(SAMPLE_ROWS, null, 2)
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
canImport() {
|
||||
return !!this.file && !this.uploading && !this.previewLoading && !this.previewError && this.previewRows.length > 0;
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.util.setTitle("MyKoPKB - Import Keanggotaan");
|
||||
},
|
||||
|
||||
methods: {
|
||||
onFileChange(e) {
|
||||
const files = e && e.target ? e.target.files : null;
|
||||
this.file = files && files.length ? files[0] : null;
|
||||
this.selectedFileName = this.file ? this.file.name : "";
|
||||
this.result = null;
|
||||
this.resetPreview();
|
||||
|
||||
if (this.file) {
|
||||
this.loadPreview(this.file);
|
||||
}
|
||||
},
|
||||
|
||||
resetPreview() {
|
||||
this.previewLoading = false;
|
||||
this.previewError = "";
|
||||
this.previewRows = [];
|
||||
this.previewColumns = [];
|
||||
this.previewTotal = 0;
|
||||
this.previewRaw = "";
|
||||
this.showRaw = false;
|
||||
},
|
||||
|
||||
loadPreview(file) {
|
||||
this.previewLoading = true;
|
||||
this.previewError = "";
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const text = event.target.result;
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("JSON mestilah array objek.");
|
||||
}
|
||||
|
||||
if (parsed.length === 0) {
|
||||
throw new Error("JSON kosong (tiada baris).");
|
||||
}
|
||||
|
||||
const columns = [];
|
||||
parsed.forEach((row) => {
|
||||
if (row && typeof row === "object" && !Array.isArray(row)) {
|
||||
Object.keys(row).forEach((key) => {
|
||||
if (columns.indexOf(key) === -1) {
|
||||
columns.push(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (columns.indexOf("no_anggota") === -1) {
|
||||
throw new Error("JSON tiada medan no_anggota.");
|
||||
}
|
||||
|
||||
this.previewColumns = columns;
|
||||
this.previewTotal = parsed.length;
|
||||
this.previewRows = parsed.slice(0, PREVIEW_LIMIT);
|
||||
this.previewRaw = JSON.stringify(parsed.slice(0, PREVIEW_LIMIT), null, 2);
|
||||
this.previewError = "";
|
||||
} catch (err) {
|
||||
this.resetPreview();
|
||||
this.previewError = err && err.message
|
||||
? "Gagal membaca JSON: " + err.message
|
||||
: "Gagal membaca JSON.";
|
||||
} finally {
|
||||
this.previewLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
this.resetPreview();
|
||||
this.previewError = "Gagal membaca fail.";
|
||||
this.previewLoading = false;
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
},
|
||||
|
||||
formatPreviewCell(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "0";
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
clearFile() {
|
||||
this.file = null;
|
||||
this.selectedFileName = "";
|
||||
this.result = null;
|
||||
this.resetPreview();
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.value = "";
|
||||
}
|
||||
},
|
||||
|
||||
async upload() {
|
||||
if (!this.canImport) return;
|
||||
|
||||
this.uploading = true;
|
||||
this.result = null;
|
||||
|
||||
try {
|
||||
this.util.setAuthorization();
|
||||
const fd = new FormData();
|
||||
fd.append("file", this.file);
|
||||
|
||||
const res = await axios.post(config.API + "voter/import-keanggotaan-json", fd, {
|
||||
headers: { "Content-Type": "multipart/form-data" }
|
||||
});
|
||||
|
||||
if (res && res.data && res.data.status === "success") {
|
||||
this.util.notify(res.data.message || "Import berjaya.", "success");
|
||||
this.result = res.data.data || null;
|
||||
} else {
|
||||
this.util.showResult(res, "error");
|
||||
}
|
||||
} catch (e) {
|
||||
this.util.showResult(e, "error");
|
||||
} finally {
|
||||
this.uploading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -106,6 +106,8 @@ export default {
|
||||
{ title: 'Unit', key: 'unit', sortable: true },
|
||||
{ title: 'Saham', key: 'saham', sortable: true },
|
||||
{ title: 'Yuran', key: 'yuran', sortable: true },
|
||||
{ title: 'Jumlah Pelaburan (hingga Dis 2025)', key: 'accumulated_amount', sortable: true },
|
||||
{ title: 'Dividen Simpanan Khas (Tabung 1) Tahun 2025', key: 'pelaburan', sortable: true },
|
||||
{ title: 'Tindakan', key: 'actions', sortable: false }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -53,11 +53,22 @@
|
||||
</div>
|
||||
|
||||
<div class="member-info-card home-member-card home-member-card--in-section">
|
||||
<div v-for="row in memberRows" :key="row.label" class="member-field">
|
||||
<div v-for="row in memberRows" :key="row.key || row.label" class="member-field">
|
||||
<span class="member-field-icon" aria-hidden="true"><i class="fa" :class="row.icon"></i></span>
|
||||
<div class="member-field-body">
|
||||
<span class="member-field-label">{{ row.label }}</span>
|
||||
<span class="member-field-value">{{ row.value }}</span>
|
||||
<div class="member-field-value-wrap">
|
||||
<template v-if="row.entries">
|
||||
<div v-for="(entry, entryIdx) in row.entries" :key="entryIdx" class="member-field-entry">
|
||||
<span class="member-field-value">{{ entry.period }}: {{ entry.value }}</span>
|
||||
<span v-if="entry.subvalue" class="member-field-subvalue">{{ entry.subvalue }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="member-field-value">{{ row.value }}</span>
|
||||
<span v-if="row.subvalue" class="member-field-subvalue">{{ row.subvalue }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -386,14 +397,59 @@ export default {
|
||||
if (n === null || n === undefined || n === "") return "—";
|
||||
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
};
|
||||
const dividendSub = (balance, dividend, percent) => {
|
||||
if (balance === null || balance === undefined || balance === "") return null;
|
||||
return "Dividen (" + percent + "%): RM " + fmt(dividend);
|
||||
};
|
||||
return [
|
||||
{ icon: "fa-user", label: "Nama", value: u.name || "—" },
|
||||
{ icon: "fa-credit-card", label: "No. Kad Pengenalan", value: u.no_kp || "—" },
|
||||
{ icon: "fa-barcode", label: "No. Anggota", value: u.no_anggota || "—" },
|
||||
{ icon: "fa-building", label: "Unit", value: u.unit || "—" },
|
||||
{ icon: "fa-line-chart", label: "Saham", value: "RM " + fmt(u.saham) },
|
||||
{ icon: "fa-money", label: "Yuran", value: "RM " + fmt(u.yuran) },
|
||||
{ icon: "fa-percent", label: "Dividen Simpanan Khas (Tabung 1) Tahun 2025", value: "RM " + fmt(u.pelaburan) }
|
||||
{
|
||||
key: "saham",
|
||||
icon: "fa-line-chart",
|
||||
label: "Saham",
|
||||
entries: [
|
||||
{
|
||||
period: "Sehingga Jun 2025",
|
||||
value: "RM " + fmt(u.saham),
|
||||
subvalue: dividendSub(u.saham, u.dividen_saham, 40)
|
||||
},
|
||||
{
|
||||
period: "Terkumpul Sehingga Mei 2026",
|
||||
value: "RM " + fmt(u.saham_terkini)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "yuran",
|
||||
icon: "fa-money",
|
||||
label: "Yuran",
|
||||
entries: [
|
||||
{
|
||||
period: "Sehingga Jun 2025",
|
||||
value: "RM " + fmt(u.yuran),
|
||||
subvalue: dividendSub(u.yuran, u.dividen_yuran, 8)
|
||||
},
|
||||
{
|
||||
period: "Terkumpul Sehingga Mei 2026",
|
||||
value: "RM " + fmt(u.yuran_terkini)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "pelaburan",
|
||||
icon: "fa-bank",
|
||||
label: "Pelaburan Simpanan Khas (Tabung 1) - (Sehingga Dis 2025)",
|
||||
value: "RM " + fmt(u.pelaburan),
|
||||
subvalue: dividendSub(u.pelaburan, u.dividen_pelaburan, 12)
|
||||
},
|
||||
{
|
||||
icon: "fa-bank",
|
||||
label: "Pelaburan Simpanan Khas (Tabung 2) - (Sehingga " + this.formatMalayMonthYear() + ")",
|
||||
value: "RM " + fmt(u.pelaburan_2),
|
||||
}
|
||||
];
|
||||
}
|
||||
},
|
||||
@@ -410,6 +466,20 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Malay short month + year for "Sehingga …" labels (defaults to previous month).
|
||||
*/
|
||||
formatMalayMonthYear(date) {
|
||||
const d = date ? new Date(date) : new Date();
|
||||
// End of previous calendar month
|
||||
d.setDate(0);
|
||||
const months = [
|
||||
"Jan", "Feb", "Mac", "Apr", "Mei", "Jun",
|
||||
"Jul", "Ogo", "Sep", "Okt", "Nov", "Dis"
|
||||
];
|
||||
return months[d.getMonth()] + " " + d.getFullYear();
|
||||
},
|
||||
|
||||
async fetchPortalSettings() {
|
||||
try {
|
||||
this.util.setAuthorization();
|
||||
@@ -1016,8 +1086,31 @@ export default {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.member-field-value {
|
||||
.member-field-value-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.member-field-entry {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.member-field-entry+.member-field-entry {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.member-field-value {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
@@ -1026,6 +1119,16 @@ export default {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.member-field-subvalue {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: #2563eb;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* —— Claim —— */
|
||||
.home-claim-card {
|
||||
padding: 20px 22px;
|
||||
@@ -1386,6 +1489,13 @@ export default {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.home-member-card--in-section .member-field-value-wrap {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.home-member-card--in-section .member-field-value {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
@@ -1398,6 +1508,12 @@ export default {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.home-member-card--in-section .member-field-subvalue {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.home-claim-code {
|
||||
font-size: 2.2rem;
|
||||
letter-spacing: 0.14em;
|
||||
|
||||
@@ -47,6 +47,10 @@ export default {
|
||||
}),
|
||||
|
||||
created: function () {
|
||||
if (!this.util.isLogin()) {
|
||||
this.$router.replace({ name: 'Voter Login' });
|
||||
return;
|
||||
}
|
||||
this.refreshInfo();
|
||||
},
|
||||
|
||||
@@ -81,7 +85,7 @@ export default {
|
||||
axios.post(config.API + 'voter/logout')
|
||||
.catch(function () { })
|
||||
.finally(function () {
|
||||
localStorage.clear();
|
||||
vm.util.clearSession();
|
||||
vm.$router.push({ name: 'Voter Login' });
|
||||
});
|
||||
},
|
||||
@@ -105,10 +109,14 @@ export default {
|
||||
vm.data.result = response.data.result;
|
||||
})
|
||||
.catch(error => {
|
||||
vm.loading = false;
|
||||
if (vm.util.showResult(error, 'error') == 401) {
|
||||
vm.$router.push({ name: 'Voter Login' });
|
||||
var status = error.response ? error.response.status : 500;
|
||||
if (status === 401) {
|
||||
vm.util.clearSession();
|
||||
vm.$router.replace({ name: 'Voter Login' });
|
||||
return;
|
||||
}
|
||||
vm.loading = false;
|
||||
vm.util.showResult(error, 'error');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,58 @@
|
||||
<template>
|
||||
<div class="login-shell">
|
||||
<div class="login-card">
|
||||
<transition name="sso-overlay">
|
||||
<div v-if="ssoSuccess.active" class="sso-overlay" role="status" aria-live="polite">
|
||||
<div class="sso-overlay__card" :class="'sso-overlay__card--' + ssoSuccess.phase">
|
||||
<div class="sso-overlay__icon" aria-hidden="true">
|
||||
<div v-if="ssoSuccess.phase === 'loading'" class="sso-overlay__spinner"></div>
|
||||
<svg v-else class="sso-overlay__check" viewBox="0 0 24 24" focusable="false">
|
||||
<path
|
||||
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm-1.35 13.15-3.4-3.35a1 1 0 1 1 1.4-1.42l2.68 2.64 5.28-5.22a1 1 0 0 1 1.4 1.42l-6 5.93a1 1 0 0 1-1.36 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="sso-overlay__title">{{ ssoSuccess.title }}</p>
|
||||
<p class="sso-overlay__message">{{ ssoSuccess.message }}</p>
|
||||
<div v-if="ssoSuccess.phase === 'loading'" class="sso-overlay__progress">
|
||||
<div class="sso-overlay__progress-bar" :style="{ animationDuration: SSO_LOADING_MS + 'ms' }">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="login-card"
|
||||
:class="{ 'login-card--shake': ssoAlert.shake, 'login-card--dimmed': ssoSuccess.active }">
|
||||
<div class="login-header">
|
||||
<img class="login-logo" src="/images/MyKoPKB-logo.png" alt="MyKoPKB" />
|
||||
<h1 class="login-title">Selamat Datang</h1>
|
||||
<p class="login-subtitle">Log masuk untuk meneruskan ke Portal MyKoPKB</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="login" id="login_form" class="login-form" novalidate>
|
||||
<transition name="sso-alert">
|
||||
<div v-if="ssoAlert.visible" class="sso-alert" :class="'sso-alert--' + ssoAlert.code" role="alert"
|
||||
aria-live="assertive">
|
||||
<div class="sso-alert__icon" aria-hidden="true">
|
||||
<svg v-if="ssoAlert.code === 'voter_not_found'" viewBox="0 0 24 24" focusable="false">
|
||||
<path
|
||||
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 5a1.25 1.25 0 1 1 0 2.5A1.25 1.25 0 0 1 12 7Zm-1 4.25a1 1 0 0 1 2 0V16a1 1 0 1 1-2 0v-4.75Z" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" focusable="false">
|
||||
<path
|
||||
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm-.75 5.5a.75.75 0 0 1 1.5 0v6a.75.75 0 0 1-1.5 0v-6Zm.75 9.25a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="sso-alert__body">
|
||||
<p class="sso-alert__title">{{ ssoAlert.title }}</p>
|
||||
<p class="sso-alert__message">{{ ssoAlert.message }}</p>
|
||||
</div>
|
||||
<button type="button" class="sso-alert__close" aria-label="Tutup" @click="dismissSsoAlert">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Local / development: OTP login via IC number -->
|
||||
<form v-if="isLocalDev" @submit.prevent="login" id="login_form" class="login-form" novalidate>
|
||||
<div class="form-group mb-3">
|
||||
<label class="login-label" for="no_kp">No. Kad Pengenalan</label>
|
||||
<div class="login-field">
|
||||
@@ -30,31 +75,87 @@
|
||||
<span>{{ loading ? 'Sedang log masuk…' : 'Masuk' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Production: SSO only -->
|
||||
<div v-else class="sso-only-notice">
|
||||
<div class="sso-only-notice__icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" focusable="false">
|
||||
<path
|
||||
d="M12 2a5 5 0 0 0-5 5v2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-1V7a5 5 0 0 0-5-5Zm-3 7V7a3 3 0 1 1 6 0v2H9Zm3 4a1.5 1.5 0 0 1 .75 2.8V18h-1.5v-2.2A1.5 1.5 0 0 1 12 13Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="sso-only-notice__title">
|
||||
Sila log masuk melalui
|
||||
<a href="https://mykopkb.koppkb.com" class="sso-only-notice__link" target="_blank"
|
||||
rel="noopener noreferrer">MyKoPKB</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const SSO_LOADING_MS = 2500;
|
||||
const SSO_SUCCESS_MS = 2500;
|
||||
const LOCAL_ENVS = ['local', 'development'];
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
SSO_LOADING_MS,
|
||||
SSO_SUCCESS_MS,
|
||||
loading: false,
|
||||
no_kp: ''
|
||||
no_kp: '',
|
||||
ssoAlert: {
|
||||
visible: false,
|
||||
title: '',
|
||||
message: '',
|
||||
code: 'sso_error',
|
||||
shake: false
|
||||
},
|
||||
ssoSuccess: {
|
||||
active: false,
|
||||
phase: 'loading',
|
||||
title: 'Mengesahkan log masuk SSO',
|
||||
message: 'Sila tunggu sebentar…'
|
||||
},
|
||||
ssoRedirectTimer: null
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
isLocalDev() {
|
||||
const env = (window.config && window.config.env) || '';
|
||||
return LOCAL_ENVS.indexOf(String(env).toLowerCase()) !== -1;
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
if (this.ssoRedirectTimer) {
|
||||
window.clearTimeout(this.ssoRedirectTimer);
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
if (this.util.isAdminPortalSession()) {
|
||||
this.$router.replace(this.util.getAdminEntryRoute());
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleSsoCallback();
|
||||
},
|
||||
|
||||
methods: {
|
||||
onNoKpInput() {
|
||||
// keep input forgiving: strip dash/spaces and non-digits
|
||||
this.no_kp = (this.no_kp || '').replace(/[\s-]+/g, '').replace(/[^\d]/g, '');
|
||||
},
|
||||
login() {
|
||||
if (!this.isLocalDev) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.startLoading();
|
||||
const noKp = (this.no_kp || '').trim();
|
||||
axios
|
||||
@@ -68,9 +169,9 @@ export default {
|
||||
this.$router.push({
|
||||
name: 'Voter Verify',
|
||||
params: {
|
||||
'nokp': response.data.nokp,
|
||||
'notel': response.data.notel,
|
||||
'debug_otp': response.data.debug_otp || null
|
||||
nokp: response.data.nokp,
|
||||
notel: response.data.notel,
|
||||
debug_otp: response.data.debug_otp || null
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -85,6 +186,87 @@ export default {
|
||||
},
|
||||
stopLoading() {
|
||||
this.loading = false;
|
||||
},
|
||||
handleSsoCallback() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ssoError = params.get('sso_error');
|
||||
|
||||
if (ssoError) {
|
||||
const code = params.get('sso_error_code') || 'sso_error';
|
||||
this.showSsoError(ssoError, code);
|
||||
this.clearSsoQueryParams();
|
||||
return;
|
||||
}
|
||||
|
||||
const ssoToken = params.get('sso_token');
|
||||
const tokenExpiresAt = params.get('token_expires_at');
|
||||
|
||||
if (!ssoToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearSsoQueryParams();
|
||||
this.completeSsoLogin(ssoToken, tokenExpiresAt);
|
||||
},
|
||||
completeSsoLogin(ssoToken, tokenExpiresAt) {
|
||||
this.ssoSuccess = {
|
||||
active: true,
|
||||
phase: 'loading',
|
||||
title: 'Mengesahkan log masuk SSO',
|
||||
message: 'Sila tunggu sebentar…'
|
||||
};
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.util.persistLoginSession(ssoToken, tokenExpiresAt, {
|
||||
clearAdminSession: true
|
||||
});
|
||||
|
||||
this.ssoSuccess = {
|
||||
active: true,
|
||||
phase: 'success',
|
||||
title: 'Log masuk berjaya',
|
||||
message: 'Anda akan dialihkan ke portal…'
|
||||
};
|
||||
|
||||
this.util.notify('Log masuk SSO berjaya.', 'success');
|
||||
|
||||
this.ssoRedirectTimer = window.setTimeout(() => {
|
||||
this.$router.replace({ name: 'Voter Home' });
|
||||
}, SSO_SUCCESS_MS);
|
||||
}, SSO_LOADING_MS);
|
||||
},
|
||||
clearSsoQueryParams() {
|
||||
if (!window.location.search) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
},
|
||||
showSsoError(message, code) {
|
||||
const titles = {
|
||||
voter_not_found: 'Akaun pengundi tidak dijumpai',
|
||||
token_invalid: 'Log masuk SSO gagal',
|
||||
token_used: 'Token SSO telah digunakan',
|
||||
sso_error: 'Log masuk SSO gagal'
|
||||
};
|
||||
|
||||
this.ssoAlert = {
|
||||
visible: true,
|
||||
title: titles[code] || titles.sso_error,
|
||||
message: message,
|
||||
code: code,
|
||||
shake: true
|
||||
};
|
||||
|
||||
this.util.notify(message, 'error');
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.ssoAlert.shake = false;
|
||||
}, 650);
|
||||
},
|
||||
dismissSsoAlert() {
|
||||
this.ssoAlert.visible = false;
|
||||
this.ssoAlert.shake = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -107,8 +289,8 @@ export default {
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 20px;
|
||||
box-shadow:
|
||||
0 18px 55px rgba(2, 6, 23, 0.12),
|
||||
0 2px 10px rgba(2, 6, 23, 0.06);
|
||||
0 18px 55px rgba(2, 6, 23, 0.12),
|
||||
0 2px 10px rgba(2, 6, 23, 0.06);
|
||||
padding: 28px 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -150,6 +332,374 @@ export default {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sso-only-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(37, 99, 235, 0.16);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%);
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.sso-only-notice__icon {
|
||||
flex: 0 0 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.sso-only-notice__icon svg {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.sso-only-notice__title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.sso-only-notice__link {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.sso-only-notice__link:hover,
|
||||
.sso-only-notice__link:focus {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.sso-only-notice__message {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.sso-alert-enter-active,
|
||||
.sso-alert-leave-active {
|
||||
transition: opacity 280ms ease, transform 280ms ease, max-height 280ms ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sso-alert-enter,
|
||||
.sso-alert-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.sso-alert-enter-to,
|
||||
.sso-alert-leave {
|
||||
max-height: 160px;
|
||||
}
|
||||
|
||||
.sso-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin: 0 0 16px;
|
||||
padding: 14px 14px 14px 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
animation: sso-alert-pulse 2.4s ease-in-out 1;
|
||||
}
|
||||
|
||||
.sso-alert--voter_not_found {
|
||||
background: linear-gradient(180deg, #fff7ed 0%, #ffedd5 100%);
|
||||
border-color: rgba(234, 88, 12, 0.22);
|
||||
color: #9a3412;
|
||||
animation-name: sso-alert-pulse-warn;
|
||||
}
|
||||
|
||||
.sso-alert--token_invalid,
|
||||
.sso-alert--token_used,
|
||||
.sso-alert--sso_error {
|
||||
background: linear-gradient(180deg, #fef2f2 0%, #fee2e2 100%);
|
||||
border-color: rgba(220, 38, 38, 0.2);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.sso-alert__icon {
|
||||
flex: 0 0 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: 1px;
|
||||
animation: sso-icon-pop 420ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
.sso-alert__icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
fill: currentColor;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sso-alert__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sso-alert__title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.sso-alert__message {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.sso-alert__close {
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
padding: 0 0 0 8px;
|
||||
cursor: pointer;
|
||||
opacity: 0.65;
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.sso-alert__close:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.login-card--shake {
|
||||
animation: login-card-shake 560ms cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||
}
|
||||
|
||||
.login-card--dimmed {
|
||||
opacity: 0.35;
|
||||
filter: blur(1px);
|
||||
transform: scale(0.985);
|
||||
transition: opacity 320ms ease, filter 320ms ease, transform 320ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sso-overlay-enter-active,
|
||||
.sso-overlay-leave-active {
|
||||
transition: opacity 280ms ease;
|
||||
}
|
||||
|
||||
.sso-overlay-enter,
|
||||
.sso-overlay-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sso-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.sso-overlay__card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
padding: 28px 24px 24px;
|
||||
text-align: center;
|
||||
box-shadow:
|
||||
0 24px 60px rgba(2, 6, 23, 0.22),
|
||||
0 4px 14px rgba(2, 6, 23, 0.08);
|
||||
animation: sso-overlay-rise 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.sso-overlay__card--success {
|
||||
animation: sso-overlay-rise 420ms cubic-bezier(0.22, 1, 0.36, 1) both,
|
||||
sso-overlay-success-pop 360ms ease 80ms both;
|
||||
}
|
||||
|
||||
.sso-overlay__icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sso-overlay__spinner {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid rgba(37, 99, 235, 0.16);
|
||||
border-top-color: #2563eb;
|
||||
animation: sso-spinner 760ms linear infinite;
|
||||
}
|
||||
|
||||
.sso-overlay__check {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
fill: #16a34a;
|
||||
animation: sso-check-pop 520ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
.sso-overlay__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sso-overlay__message {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: rgba(15, 23, 42, 0.72);
|
||||
}
|
||||
|
||||
.sso-overlay__progress {
|
||||
margin-top: 18px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sso-overlay__progress-bar {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #2563eb 0%, #3b82f6 100%);
|
||||
animation: sso-progress ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes sso-overlay-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px) scale(0.96);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sso-overlay-success-pop {
|
||||
from {
|
||||
box-shadow: 0 24px 60px rgba(2, 6, 23, 0.22);
|
||||
}
|
||||
|
||||
to {
|
||||
box-shadow: 0 24px 60px rgba(22, 163, 74, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sso-spinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sso-check-pop {
|
||||
0% {
|
||||
transform: scale(0.35);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sso-progress {
|
||||
from {
|
||||
width: 8%;
|
||||
}
|
||||
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes login-card-shake {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
15% {
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translateX(7px);
|
||||
}
|
||||
|
||||
45% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sso-alert-pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0);
|
||||
}
|
||||
|
||||
35% {
|
||||
box-shadow: 0 0 0 6px rgba(220, 38, 38, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sso-alert-pulse-warn {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(234, 88, 12, 0);
|
||||
}
|
||||
|
||||
35% {
|
||||
box-shadow: 0 0 0 6px rgba(234, 88, 12, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sso-icon-pop {
|
||||
0% {
|
||||
transform: scale(0.4);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-top: 14px;
|
||||
}
|
||||
@@ -233,10 +783,10 @@ export default {
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.login-shell {
|
||||
align-items: center;
|
||||
padding: 20px 14px;
|
||||
}
|
||||
.login-shell {
|
||||
align-items: center;
|
||||
padding: 20px 14px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
max-width: none;
|
||||
@@ -248,4 +798,4 @@ export default {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -157,10 +157,9 @@ export default {
|
||||
.then(response => {
|
||||
vm.stopLoading();
|
||||
if (this.util.showResult(response, 'success')) {
|
||||
localStorage.removeItem('admin_role');
|
||||
localStorage.removeItem('admin_session');
|
||||
localStorage['Access Token'] = `Bearer ${response.data.token}`;
|
||||
this.util.setAuthorization();
|
||||
this.util.persistLoginSession(response.data.token, response.data.token_expires_at, {
|
||||
clearAdminSession: true
|
||||
});
|
||||
vm.$router.push({ name: 'Voter Home' });
|
||||
}
|
||||
})
|
||||
|
||||
Vendored
+7
@@ -43,6 +43,7 @@ import KehadiranCalon from "./components/demo/admin/voter/kehadiran.vue";
|
||||
import KehadiranCalonFizikal from "./components/demo/admin/voter/fizikal.vue";
|
||||
import KehadiranCalonMaya from "./components/demo/admin/voter/maya.vue";
|
||||
import ViewKehadiranCalon from "./components/demo/admin/voter/viewkehadiran.vue";
|
||||
import ImportKeanggotaan from "./components/demo/admin/voter/import-keanggotaan.vue";
|
||||
import AllowanceSummary from "./components/demo/admin/allowance/summary.vue";
|
||||
|
||||
import ManageNominee from "./components/demo/admin/nominee/nominee.vue";
|
||||
@@ -294,6 +295,12 @@ export default [
|
||||
name: "Ringkasan Elaun",
|
||||
},
|
||||
|
||||
{
|
||||
path: "import-keanggotaan",
|
||||
component: ImportKeanggotaan,
|
||||
name: "Import Keanggotaan",
|
||||
},
|
||||
|
||||
{
|
||||
path: "nominee",
|
||||
component: ManageNominee,
|
||||
|
||||
Vendored
+72
-1
@@ -14,7 +14,78 @@ const methods = {
|
||||
* @return {Boolean} isLogin
|
||||
*/
|
||||
isLogin: function () {
|
||||
return localStorage['Access Token'] ? true : false;
|
||||
if (!localStorage['Access Token']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isSessionExpired()) {
|
||||
this.clearSession();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
isSessionExpired: function () {
|
||||
var expiresAt = localStorage.getItem('token_expires_at');
|
||||
if (!expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Date.now() >= new Date(expiresAt).getTime();
|
||||
},
|
||||
|
||||
clearSession: function () {
|
||||
localStorage.removeItem('Access Token');
|
||||
localStorage.removeItem('token_expires_at');
|
||||
localStorage.removeItem('admin_role');
|
||||
localStorage.removeItem('admin_session');
|
||||
localStorage.removeItem('is_impersonating');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
},
|
||||
|
||||
persistLoginSession: function (token, expiresAt, options) {
|
||||
options = options || {};
|
||||
var bearer = token.indexOf('Bearer ') === 0 ? token : 'Bearer ' + token;
|
||||
localStorage['Access Token'] = bearer;
|
||||
|
||||
if (expiresAt) {
|
||||
localStorage.setItem('token_expires_at', expiresAt);
|
||||
} else {
|
||||
localStorage.removeItem('token_expires_at');
|
||||
}
|
||||
|
||||
if (options.adminRole !== undefined && options.adminRole !== null) {
|
||||
localStorage.setItem('admin_role', String(options.adminRole));
|
||||
}
|
||||
|
||||
if (options.adminSession) {
|
||||
localStorage.setItem('admin_session', '1');
|
||||
}
|
||||
|
||||
if (options.clearAdminSession) {
|
||||
localStorage.removeItem('admin_role');
|
||||
localStorage.removeItem('admin_session');
|
||||
}
|
||||
|
||||
this.setAuthorization();
|
||||
},
|
||||
|
||||
handleUnauthorized: function (router) {
|
||||
var isAdminPath = window.location.pathname.indexOf('/admin') === 0;
|
||||
this.clearSession();
|
||||
|
||||
if (!router || !router.currentRoute) {
|
||||
window.location.href = isAdminPath ? '/admin/login' : '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
var routeName = router.currentRoute.name;
|
||||
if (routeName === 'Voter Login' || routeName === 'Admin Login') {
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace({ name: isAdminPath ? 'Admin Login' : 'Voter Login' });
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"API": "/api/v1/", //URL OF YOUR API LOCATED
|
||||
"baseURL": "/", //URL OF YOUR WEBSITE
|
||||
"storageURL": "{{ config('app.cloudinary_enabled') ? '' : '/storage/' }}", //URL WHERE YOUR IMAGES and OTHER FILEs stored
|
||||
"debug": {{ env('APP_DEBUG') }}
|
||||
"debug": {{ env('APP_DEBUG') ? 'true' : 'false' }},
|
||||
"env": @json(config('app.env'))
|
||||
}
|
||||
</script>
|
||||
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
|
||||
|
||||
+3
-2
@@ -31,7 +31,7 @@ Route::prefix('v1')->group(function () { //Version 1 of my Rest API
|
||||
Route::post('voter/login', 'API\v1\Voter\LoginController');
|
||||
Route::post('voter/verify', 'API\v1\Voter\LoginController@verifyTAC');
|
||||
|
||||
Route::middleware(['auth:voterAPI', 'voter'])->group(function () {
|
||||
Route::middleware(['auth:voterAPI', 'api_token_lifetime:voter', 'voter'])->group(function () {
|
||||
Route::get('election/information', 'API\v1\Election\InformationController');
|
||||
Route::post('election/vote', 'API\v1\Election\VoteController')->middleware(['fizikal_registration_verified', 'isvoted']);
|
||||
Route::get('election/result', 'API\v1\Election\ResultController')->middleware('has_voted');
|
||||
@@ -52,7 +52,7 @@ Route::prefix('v1')->group(function () { //Version 1 of my Rest API
|
||||
//Admins API
|
||||
Route::post('admin/login', 'API\v1\Admin\LoginController'); //Excluding Login for auth middleware
|
||||
|
||||
Route::middleware(['auth:api', 'admin', 'election', 'attendance_committee_role'])->group(function () {
|
||||
Route::middleware(['auth:api', 'api_token_lifetime:admin', 'admin', 'election', 'attendance_committee_role'])->group(function () {
|
||||
|
||||
Route::prefix('admin')->group(function () { //Route /api/v1/admin
|
||||
Route::get('information', 'API\v1\Admin\InformationController');
|
||||
@@ -113,6 +113,7 @@ Route::prefix('v1')->group(function () { //Version 1 of my Rest API
|
||||
Route::get('', 'API\v1\Voter\GetController');
|
||||
Route::get('member-catalog', 'API\v1\Voter\AllMembersCatalogController');
|
||||
Route::post('sync-applicable', 'API\v1\Voter\SyncApplicableVotersController');
|
||||
Route::post('import-keanggotaan-json', 'API\v1\Voter\ImportKeanggotaanJsonController');
|
||||
Route::post('{id}/verify-fizikal-registration', 'API\v1\Voter\VerifyFizikalRegistrationController');
|
||||
Route::delete('{id}', 'API\v1\Voter\DeleteController');
|
||||
Route::put('{id}', 'API\v1\Voter\UpdateController');
|
||||
|
||||
@@ -43,6 +43,8 @@ Route::get('/fizikalpdf/{id}', [FizikalController::class, 'index']);
|
||||
|
||||
Route::get('/mayapdf/{id}', [MayaController::class, 'index']);
|
||||
|
||||
Route::get('/sso/login', 'API\v1\Voter\SsoLoginController@login');
|
||||
|
||||
Route::view('/{any}', 'index')->where('any', '.*');
|
||||
|
||||
Route::get('/penyata/{id}', [App\Http\Controllers\PenyataController::class, 'show']);
|
||||
|
||||
Reference in New Issue
Block a user