Feature/standardize ui display

This commit is contained in:
ISMAIL MASSERAN
2026-06-23 07:44:42 +00:00
parent a969d4bbcf
commit 378b00a1e6
25 changed files with 2883 additions and 54 deletions
@@ -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;
}
}
+1
View File
@@ -17,6 +17,7 @@ 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)
@@ -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()
+1
View File
@@ -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,
];
}
@@ -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);
}
}
+60
View File
@@ -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.
}
}
}
+1 -1
View File
@@ -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', 'dividen_saham', 'dividen_yuran', 'kehadiran','status_penyata','persetujuan','tarikh_sah','cadangan','pelaburan', 'accumulated_amount', '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', 'accumulated_amount', 'tergempar','barangan','peribadi','berjamin_yuran','roadtax','pelbagai'];
protected $casts = [
'fizikal_registration_verified_at' => 'datetime',