DONE: add flexible json update saham, yuran, and pelaburan ui
This commit is contained in:
@@ -12,7 +12,7 @@ class UpdateVoterPelaburanFromJson extends Command
|
|||||||
{--election_id= : Override election_id (default: latest in voter table)}
|
{--election_id= : Override election_id (default: latest in voter table)}
|
||||||
{--dry-run : Show what would change without updating}';
|
{--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()
|
public function handle()
|
||||||
{
|
{
|
||||||
@@ -51,8 +51,9 @@ class UpdateVoterPelaburanFromJson extends Command
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JSON key "pelaburan" is the dividend amount → voter.dividen_pelaburan
|
||||||
$map[$noAnggota] = [
|
$map[$noAnggota] = [
|
||||||
'pelaburan' => $this->cleanNumber($row['pelaburan'] ?? 0),
|
'dividen_pelaburan' => $this->cleanNumber($row['pelaburan'] ?? 0),
|
||||||
'accumulated_amount' => $this->cleanNumber($row['accumulated_amount'] ?? 0),
|
'accumulated_amount' => $this->cleanNumber($row['accumulated_amount'] ?? 0),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -67,6 +68,7 @@ class UpdateVoterPelaburanFromJson extends Command
|
|||||||
$this->info("Target election_id: {$electionId}");
|
$this->info("Target election_id: {$electionId}");
|
||||||
$this->info("JSON members loaded: " . count($map));
|
$this->info("JSON members loaded: " . count($map));
|
||||||
$this->info($dryRun ? "Mode: DRY RUN (no DB updates)" : "Mode: UPDATE");
|
$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();
|
$totalInElection = (int) DB::table('voter')->where('election_id', $electionId)->count();
|
||||||
$this->info("Voters in election: {$totalInElection}");
|
$this->info("Voters in election: {$totalInElection}");
|
||||||
@@ -80,7 +82,7 @@ class UpdateVoterPelaburanFromJson extends Command
|
|||||||
$bar->start();
|
$bar->start();
|
||||||
|
|
||||||
DB::table('voter')
|
DB::table('voter')
|
||||||
->select(['id', 'no_anggota', 'pelaburan', 'accumulated_amount'])
|
->select(['id', 'no_anggota', 'dividen_pelaburan', 'accumulated_amount'])
|
||||||
->where('election_id', $electionId)
|
->where('election_id', $electionId)
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->chunkById(500, function ($chunk) use ($map, $dryRun, &$updated, &$matched, &$missingInJson, &$unchanged, $bar) {
|
->chunkById(500, function ($chunk) use ($map, $dryRun, &$updated, &$matched, &$missingInJson, &$unchanged, $bar) {
|
||||||
@@ -96,11 +98,11 @@ class UpdateVoterPelaburanFromJson extends Command
|
|||||||
$matched++;
|
$matched++;
|
||||||
$jsonRow = $map[$noAnggota];
|
$jsonRow = $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;
|
$oldAccumulated = $voter->accumulated_amount !== null ? (float) $voter->accumulated_amount : 0.0;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
abs($oldPelaburan - $jsonRow['pelaburan']) < 0.00001
|
abs($oldDividen - $jsonRow['dividen_pelaburan']) < 0.00001
|
||||||
&& abs($oldAccumulated - $jsonRow['accumulated_amount']) < 0.00001
|
&& abs($oldAccumulated - $jsonRow['accumulated_amount']) < 0.00001
|
||||||
) {
|
) {
|
||||||
$unchanged++;
|
$unchanged++;
|
||||||
@@ -112,7 +114,7 @@ class UpdateVoterPelaburanFromJson extends Command
|
|||||||
DB::table('voter')
|
DB::table('voter')
|
||||||
->where('id', $voter->id)
|
->where('id', $voter->id)
|
||||||
->update([
|
->update([
|
||||||
'pelaburan' => $jsonRow['pelaburan'],
|
'dividen_pelaburan' => $jsonRow['dividen_pelaburan'],
|
||||||
'accumulated_amount' => $jsonRow['accumulated_amount'],
|
'accumulated_amount' => $jsonRow['accumulated_amount'],
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
@@ -145,7 +147,7 @@ class UpdateVoterPelaburanFromJson extends Command
|
|||||||
|
|
||||||
private function cleanNumber($value): float
|
private function cleanNumber($value): float
|
||||||
{
|
{
|
||||||
if ($value === null) {
|
if ($value === null || $value === '') {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ class ElectionMiddleware
|
|||||||
&& !($request->is('api/v1/voter') && $request->isMethod('post'))
|
&& !($request->is('api/v1/voter') && $request->isMethod('post'))
|
||||||
// Allow updating applicable voters list during election (add page uses POST /api/v1/voter/sync-applicable)
|
// Allow updating applicable voters list during election (add page uses POST /api/v1/voter/sync-applicable)
|
||||||
&& !($request->is('api/v1/voter/sync-applicable') && $request->isMethod('post'))
|
&& !($request->is('api/v1/voter/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')
|
&& !$request->is('api/v1/voter/*/verify-fizikal-registration')
|
||||||
// Pentadbir utama: urus akaun (tambah/kemaskini/padam) semasa mengundi
|
// Pentadbir utama: urus akaun (tambah/kemaskini/padam) semasa mengundi
|
||||||
&& !$request->is('api/v1/admin')
|
&& !$request->is('api/v1/admin')
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ class Voter extends Authenticatable
|
|||||||
|
|
||||||
protected $table = 'voter';
|
protected $table = 'voter';
|
||||||
|
|
||||||
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 $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 = [
|
protected $casts = [
|
||||||
'fizikal_registration_verified_at' => 'datetime',
|
'fizikal_registration_verified_at' => 'datetime',
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
+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
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -52,6 +52,9 @@
|
|||||||
<router-link :to="{ name: 'Penyata Anggota' }" tag="li">
|
<router-link :to="{ name: 'Penyata Anggota' }" tag="li">
|
||||||
<a href="#">Penyata Anggota</a>
|
<a href="#">Penyata Anggota</a>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<router-link :to="{ name: 'Import Keanggotaan' }" tag="li">
|
||||||
|
<a href="#">Import Keanggotaan</a>
|
||||||
|
</router-link>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -439,10 +439,16 @@ export default {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "pelaburan",
|
||||||
icon: "fa-bank",
|
icon: "fa-bank",
|
||||||
label: "Simpanan Khas (Tabung 1) - (Sehingga Dis 2025)",
|
label: "Pelaburan Simpanan Khas (Tabung 1) - (Sehingga Dis 2025)",
|
||||||
value: "RM " + fmt(u.accumulated_amount),
|
value: "RM " + fmt(u.pelaburan),
|
||||||
subvalue: dividendSub(u.accumulated_amount, u.pelaburan, 12)
|
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),
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -460,6 +466,20 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
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() {
|
async fetchPortalSettings() {
|
||||||
try {
|
try {
|
||||||
this.util.setAuthorization();
|
this.util.setAuthorization();
|
||||||
|
|||||||
@@ -51,7 +51,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<div class="sso-only-notice">
|
<!-- 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">
|
||||||
|
<span class="login-field-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" focusable="false">
|
||||||
|
<path
|
||||||
|
d="M4 7.5A3.5 3.5 0 0 1 7.5 4h9A3.5 3.5 0 0 1 20 7.5v9A3.5 3.5 0 0 1 16.5 20h-9A3.5 3.5 0 0 1 4 16.5v-9Zm3.5-1.5A1.5 1.5 0 0 0 6 7.5v9A1.5 1.5 0 0 0 7.5 18h9a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 16.5 6h-9Zm1.5 3h6v2H9V9Zm0 4h10v2H9v-2Z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input v-model.trim="no_kp" @input="onNoKpInput" :disabled="loading" type="text" id="no_kp"
|
||||||
|
name="no_kp" class="form-control login-input" placeholder="Contoh: 901010101010"
|
||||||
|
autocomplete="off" inputmode="numeric" enterkeyhint="go" required />
|
||||||
|
</div>
|
||||||
|
<small class="login-help">Masukkan tanpa dash (-) atau jarak.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn login-btn" :disabled="loading || !no_kp">
|
||||||
|
<span v-if="loading" class="spinner-border spinner-border-sm mr-2" role="status"
|
||||||
|
aria-hidden="true"></span>
|
||||||
|
<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">
|
<div class="sso-only-notice__icon" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24" focusable="false">
|
<svg viewBox="0 0 24 24" focusable="false">
|
||||||
<path
|
<path
|
||||||
@@ -73,12 +99,15 @@
|
|||||||
<script>
|
<script>
|
||||||
const SSO_LOADING_MS = 2500;
|
const SSO_LOADING_MS = 2500;
|
||||||
const SSO_SUCCESS_MS = 2500;
|
const SSO_SUCCESS_MS = 2500;
|
||||||
|
const LOCAL_ENVS = ['local', 'development'];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
SSO_LOADING_MS,
|
SSO_LOADING_MS,
|
||||||
SSO_SUCCESS_MS,
|
SSO_SUCCESS_MS,
|
||||||
|
loading: false,
|
||||||
|
no_kp: '',
|
||||||
ssoAlert: {
|
ssoAlert: {
|
||||||
visible: false,
|
visible: false,
|
||||||
title: '',
|
title: '',
|
||||||
@@ -96,6 +125,13 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
isLocalDev() {
|
||||||
|
const env = (window.config && window.config.env) || '';
|
||||||
|
return LOCAL_ENVS.indexOf(String(env).toLowerCase()) !== -1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (this.ssoRedirectTimer) {
|
if (this.ssoRedirectTimer) {
|
||||||
window.clearTimeout(this.ssoRedirectTimer);
|
window.clearTimeout(this.ssoRedirectTimer);
|
||||||
@@ -112,6 +148,45 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
onNoKpInput() {
|
||||||
|
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
|
||||||
|
.post(config.API + 'voter/login', {
|
||||||
|
no_kp: noKp,
|
||||||
|
send_otp: false
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
this.stopLoading();
|
||||||
|
if (this.util.showResult(response, 'success')) {
|
||||||
|
this.$router.push({
|
||||||
|
name: 'Voter Verify',
|
||||||
|
params: {
|
||||||
|
nokp: response.data.nokp,
|
||||||
|
notel: response.data.notel,
|
||||||
|
debug_otp: response.data.debug_otp || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.stopLoading();
|
||||||
|
this.util.showResult(error, 'error');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
startLoading() {
|
||||||
|
this.loading = true;
|
||||||
|
},
|
||||||
|
stopLoading() {
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
handleSsoCallback() {
|
handleSsoCallback() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const ssoError = params.get('sso_error');
|
const ssoError = params.get('sso_error');
|
||||||
@@ -723,4 +798,4 @@ export default {
|
|||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
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 KehadiranCalonFizikal from "./components/demo/admin/voter/fizikal.vue";
|
||||||
import KehadiranCalonMaya from "./components/demo/admin/voter/maya.vue";
|
import KehadiranCalonMaya from "./components/demo/admin/voter/maya.vue";
|
||||||
import ViewKehadiranCalon from "./components/demo/admin/voter/viewkehadiran.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 AllowanceSummary from "./components/demo/admin/allowance/summary.vue";
|
||||||
|
|
||||||
import ManageNominee from "./components/demo/admin/nominee/nominee.vue";
|
import ManageNominee from "./components/demo/admin/nominee/nominee.vue";
|
||||||
@@ -294,6 +295,12 @@ export default [
|
|||||||
name: "Ringkasan Elaun",
|
name: "Ringkasan Elaun",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: "import-keanggotaan",
|
||||||
|
component: ImportKeanggotaan,
|
||||||
|
name: "Import Keanggotaan",
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "nominee",
|
path: "nominee",
|
||||||
component: ManageNominee,
|
component: ManageNominee,
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
"API": "/api/v1/", //URL OF YOUR API LOCATED
|
"API": "/api/v1/", //URL OF YOUR API LOCATED
|
||||||
"baseURL": "/", //URL OF YOUR WEBSITE
|
"baseURL": "/", //URL OF YOUR WEBSITE
|
||||||
"storageURL": "{{ config('app.cloudinary_enabled') ? '' : '/storage/' }}", //URL WHERE YOUR IMAGES and OTHER FILEs stored
|
"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>
|
||||||
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
|
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ Route::prefix('v1')->group(function () { //Version 1 of my Rest API
|
|||||||
Route::get('', 'API\v1\Voter\GetController');
|
Route::get('', 'API\v1\Voter\GetController');
|
||||||
Route::get('member-catalog', 'API\v1\Voter\AllMembersCatalogController');
|
Route::get('member-catalog', 'API\v1\Voter\AllMembersCatalogController');
|
||||||
Route::post('sync-applicable', 'API\v1\Voter\SyncApplicableVotersController');
|
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::post('{id}/verify-fizikal-registration', 'API\v1\Voter\VerifyFizikalRegistrationController');
|
||||||
Route::delete('{id}', 'API\v1\Voter\DeleteController');
|
Route::delete('{id}', 'API\v1\Voter\DeleteController');
|
||||||
Route::put('{id}', 'API\v1\Voter\UpdateController');
|
Route::put('{id}', 'API\v1\Voter\UpdateController');
|
||||||
|
|||||||
Reference in New Issue
Block a user