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)}
|
||||
{--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,9 @@ class UpdateVoterPelaburanFromJson extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
// JSON key "pelaburan" is the dividend amount → voter.dividen_pelaburan
|
||||
$map[$noAnggota] = [
|
||||
'pelaburan' => $this->cleanNumber($row['pelaburan'] ?? 0),
|
||||
'dividen_pelaburan' => $this->cleanNumber($row['pelaburan'] ?? 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("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}");
|
||||
@@ -80,7 +82,7 @@ class UpdateVoterPelaburanFromJson extends Command
|
||||
$bar->start();
|
||||
|
||||
DB::table('voter')
|
||||
->select(['id', 'no_anggota', 'pelaburan', 'accumulated_amount'])
|
||||
->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) {
|
||||
@@ -96,11 +98,11 @@ class UpdateVoterPelaburanFromJson extends Command
|
||||
$matched++;
|
||||
$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;
|
||||
|
||||
if (
|
||||
abs($oldPelaburan - $jsonRow['pelaburan']) < 0.00001
|
||||
abs($oldDividen - $jsonRow['dividen_pelaburan']) < 0.00001
|
||||
&& abs($oldAccumulated - $jsonRow['accumulated_amount']) < 0.00001
|
||||
) {
|
||||
$unchanged++;
|
||||
@@ -112,7 +114,7 @@ class UpdateVoterPelaburanFromJson extends Command
|
||||
DB::table('voter')
|
||||
->where('id', $voter->id)
|
||||
->update([
|
||||
'pelaburan' => $jsonRow['pelaburan'],
|
||||
'dividen_pelaburan' => $jsonRow['dividen_pelaburan'],
|
||||
'accumulated_amount' => $jsonRow['accumulated_amount'],
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
@@ -145,7 +147,7 @@ class UpdateVoterPelaburanFromJson extends Command
|
||||
|
||||
private function cleanNumber($value): float
|
||||
{
|
||||
if ($value === null) {
|
||||
if ($value === null || $value === '') {
|
||||
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'))
|
||||
// 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')
|
||||
|
||||
+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', '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 = [
|
||||
'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">
|
||||
<a href="#">Penyata Anggota</a>
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'Import Keanggotaan' }" tag="li">
|
||||
<a href="#">Import Keanggotaan</a>
|
||||
</router-link>
|
||||
</ul>
|
||||
</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",
|
||||
label: "Simpanan Khas (Tabung 1) - (Sehingga Dis 2025)",
|
||||
value: "RM " + fmt(u.accumulated_amount),
|
||||
subvalue: dividendSub(u.accumulated_amount, u.pelaburan, 12)
|
||||
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),
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -460,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();
|
||||
|
||||
@@ -51,7 +51,33 @@
|
||||
</div>
|
||||
</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">
|
||||
<svg viewBox="0 0 24 24" focusable="false">
|
||||
<path
|
||||
@@ -73,12 +99,15 @@
|
||||
<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: '',
|
||||
ssoAlert: {
|
||||
visible: false,
|
||||
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() {
|
||||
if (this.ssoRedirectTimer) {
|
||||
window.clearTimeout(this.ssoRedirectTimer);
|
||||
@@ -112,6 +148,45 @@ export default {
|
||||
},
|
||||
|
||||
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() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ssoError = params.get('sso_error');
|
||||
@@ -723,4 +798,4 @@ export default {
|
||||
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 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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user