diff --git a/.env.docker b/.env.docker index 79c1ea3a..b7f9519a 100644 --- a/.env.docker +++ b/.env.docker @@ -51,4 +51,6 @@ MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" MYKAD_READER_PORT=8085 -MYKAD_READER_ENABLED_CAWANGAN='' # IF NO CAWANGAN STATED, ALL CAWANGAN IS ENABLED \ No newline at end of file +MYKAD_READER_ENABLED_CAWANGAN='' # IF NO CAWANGAN STATED, ALL CAWANGAN IS ENABLED + +INTERNAL_NOTIFICATION_SECRET=erahn-local-notification-secret \ No newline at end of file diff --git a/.env.example b/.env.example index e7821ee4..9fd98981 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,10 @@ APP_NAME=Laravel APP_ENV=local +APP_VERSION=v3.3 APP_KEY=base64:FFRogE6mJj8FaDSSHl/KpXpzqi2M+P4plxVSRgVM8JI= APP_DEBUG=true APP_URL=http://localhost API_URL=http://localhost:8000 -API_ARRAHNBID=http://lelong.test/api/ LOG_CHANNEL=stack @@ -47,4 +47,6 @@ MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" MYKAD_READER_PORT=17321 -MYKAD_READER_ENABLED_CAWANGAN='' # IF NO CAWANGAN STATED, ALL CAWANGAN IS ENABLED +MYKAD_READER_ENABLED_CAWANGAN='' + +INTERNAL_NOTIFICATION_SECRET= diff --git a/.env.production b/.env.production index c2dc2498..d8af1227 100644 --- a/.env.production +++ b/.env.production @@ -47,4 +47,6 @@ MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" MYKAD_READER_PORT=17321 -MYKAD_READER_ENABLED_CAWANGAN='' # IF NO CAWANGAN STATED, ALL CAWANGAN IS ENABLED \ No newline at end of file +MYKAD_READER_ENABLED_CAWANGAN='' + +INTERNAL_NOTIFICATION_SECRET="nIDTe(u@6[)VLJyd" \ No newline at end of file diff --git a/README.md b/README.md index ca86015c..aa55a871 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,7 @@ CREATE USER 'kaunteruser'@'localhost' IDENTIFIED BY 'your_password'; GRANT ALL PRIVILEGES ON kaunter_erahn.* TO 'kaunteruser'@'%'; FLUSH PRIVILEGES; -``` - -# ADDITIONAL NOTE - -## customers/v2/index.blade.php is modal to register customer - -## Min Komoditi = RM5000 +``` # System Flowchart diff --git a/app/Http/Controllers/GadaianController.php b/app/Http/Controllers/GadaianController.php index b2074c28..95423895 100644 --- a/app/Http/Controllers/GadaianController.php +++ b/app/Http/Controllers/GadaianController.php @@ -11,6 +11,7 @@ use App\Events\SerahanPenilai; use Illuminate\Http\Request; use App\Services\CustomerService; +use App\Services\NotificationService; use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Auth; @@ -50,6 +51,37 @@ class GadaianController extends Controller return $this->isFeatureEnabled($kodcaw, 'pembiayaan_online'); } + /** + * Inbox row for PC at this branch. Failures must not block gadai. + */ + protected function notifyPcPembiayaanOnlinePending($auth_user, Request $request, $pinjamanAmt) + { + try { + app(NotificationService::class)->notifyRoleAtCawangan('pc', $auth_user['cawangan'], [ + 'type' => NotificationService::TYPE_PEMBIAYAAN_PENDING_PC, + 'title' => 'Pembiayaan online menunggu semakan', + 'body' => sprintf( + '%s — %s (RM %s)', + $request->nama_pelanggan, + $request->norujukan_gadai, + number_format((float) $pinjamanAmt, 2) + ), + 'url' => route('pembiayaan.online.pc'), + 'data' => [ + 'norujukan' => $request->norujukan_gadai, + 'nama_pelanggan' => $request->nama_pelanggan, + 'pinjaman' => $pinjamanAmt, + 'created_by' => $auth_user['name'], + ], + ]); + } catch (\Throwable $e) { + Log::warning('Pembiayaan online PC notification failed: '.$e->getMessage(), [ + 'norujukan' => $request->norujukan_gadai, + 'cawangan' => $auth_user['cawangan'], + ]); + } + } + protected function parseMoney($value) { if ($value === null || $value === '') { @@ -61,6 +93,65 @@ class GadaianController extends Controller return is_numeric($cleaned) ? (float) $cleaned : 0.0; } + protected function normalizeBorangOnlineFields($norujukan, $pinjaman, $kodbank, $noakaun, $namaAkaun) + { + return [ + 'norujukan' => trim((string) $norujukan), + 'pinjaman' => number_format((float) $this->parseMoney($pinjaman), 2, '.', ''), + 'kodbank' => strtoupper(trim((string) $kodbank)), + 'noakaun' => preg_replace('/\s+/', '', (string) $noakaun), + 'nama_akaun' => strtoupper(trim((string) $namaAkaun)), + ]; + } + + protected function borangOnlineFingerprint(array $fields) + { + return sha1(implode('|', [ + $fields['norujukan'], + $fields['pinjaman'], + $fields['kodbank'], + $fields['noakaun'], + $fields['nama_akaun'], + ])); + } + + protected function markBorangOnlinePrinted(array $fields) + { + Session::put('borang_online_printed', array_merge($fields, [ + 'fingerprint' => $this->borangOnlineFingerprint($fields), + ])); + } + + protected function hasMatchingBorangOnlinePrint(Request $request) + { + $printed = $request->session()->get('borang_online_printed'); + if (!is_array($printed)) { + return false; + } + + $norujukan = (string) $request->input('norujukan_gadai', $request->input('norujukan')); + $fields = $this->normalizeBorangOnlineFields( + $norujukan, + $request->input('pinjaman'), + $request->input('kodbank'), + $request->input('noakaun'), + $request->input('nama_akaun') ?: $request->input('nama_pelanggan') + ); + + return ($printed['norujukan'] ?? '') === $fields['norujukan'] + && ($printed['fingerprint'] ?? '') === $this->borangOnlineFingerprint($fields); + } + + protected function borangOnlinePrintedFor($norujukan) + { + $printed = Session::get('borang_online_printed'); + if (!is_array($printed) || ($printed['norujukan'] ?? '') !== (string) $norujukan) { + return null; + } + + return $printed; + } + public function index(Request $request) { @@ -88,6 +179,7 @@ class GadaianController extends Controller Session::put('nokp', $noic); Session::put('nopelanggan', $customer_info['nopelanggan']); Session::put('page_reload', true); + Session::forget('borang_online_printed'); $gadai = Http::withOptions(['verify' => config('app.api_verify')])->post(config('api.url') . '/api/gadai/' . $auth_user['cawangan'], [ 'norujukan' => $norujukan, 'nopelanggan' => $customer_info['nopelanggan'], @@ -112,6 +204,7 @@ class GadaianController extends Controller Session::put('nokp', $nokp); Session::put('nopelanggan', $nopelanggan); Session::put('page_reload', true); + Session::forget('borang_online_printed'); $gadai_baru = Http::withOptions(['verify' => config('app.api_verify')])->post(config('api.url') . '/api/gadai/' . $auth_user['cawangan'], [ 'norujukan' => $norujukan_baru, 'nopelanggan' => $nopelanggan, @@ -124,7 +217,8 @@ class GadaianController extends Controller $cawangan_info = Http::withOptions(['verify' => config('app.api_verify')])->get(config('api.url') . '/api/cawangan/' . $auth_user['cawangan'])->json(); - return view('gadaian.' . $cawangan_info['version'] . '.index', compact('customer_info', 'limit_pinjaman_semasa', 'cawangan_info', 'norujukan', 'api_url', 'current')); + $borang_online_printed = $this->borangOnlinePrintedFor($norujukan); + return view('gadaian.' . $cawangan_info['version'] . '.index', compact('customer_info', 'limit_pinjaman_semasa', 'cawangan_info', 'norujukan', 'api_url', 'current', 'borang_online_printed')); } public function edit(Request $request, $norujukan) @@ -191,6 +285,22 @@ class GadaianController extends Controller public function store(Request $request) { $auth_user = Auth::user(); + $caraTerima = strtoupper((string) $request->input('cara_terima', 'TUNAI')); + $pinjamanAmt = round((float) $request->pinjaman, 2); + + // Online only within min/max pinjaman; otherwise force cash path. + if ($caraTerima === 'ONLINE' && ($pinjamanAmt < self::MIN_PINJAMAN || $pinjamanAmt > self::MAX_PINJAMAN)) { + $caraTerima = 'TUNAI'; + } + + if ($caraTerima === 'ONLINE' && !$this->isPembiayaanOnlineEnabled($auth_user['cawangan'])) { + $caraTerima = 'TUNAI'; + } + + if ($caraTerima === 'ONLINE' && !$this->hasMatchingBorangOnlinePrint($request)) { + return redirect()->back()->with('error', 'Sila cetak borang pengesahan pelanggan sebelum tekan Terima.'); + } + //$total_nilai_marhun = $request->total_nilai_marhun; //need to get total nilai marhun by api get from table marhun @@ -242,18 +352,6 @@ class GadaianController extends Controller 'teller' => $auth_user['name'] ]); - $caraTerima = strtoupper((string) $request->input('cara_terima', 'TUNAI')); - $pinjamanAmt = round((float) $request->pinjaman, 2); - - // Online only within min/max pinjaman; otherwise force cash path. - if ($caraTerima === 'ONLINE' && ($pinjamanAmt < self::MIN_PINJAMAN || $pinjamanAmt > self::MAX_PINJAMAN)) { - $caraTerima = 'TUNAI'; - } - - if ($caraTerima === 'ONLINE' && !$this->isPembiayaanOnlineEnabled($auth_user['cawangan'])) { - $caraTerima = 'TUNAI'; - } - if ($caraTerima === 'ONLINE') { $online = Http::withOptions(['verify' => config('app.api_verify')]) ->asForm() @@ -278,6 +376,8 @@ class GadaianController extends Controller : 'Gagal daftar pembiayaan online. Sila cuba lagi.'; return redirect()->back()->with('error', $msg); } + + $this->notifyPcPembiayaanOnlinePending($auth_user, $request, $pinjamanAmt); } else { $this->AliranTunaiController->alirantunaiGadai($request, $auth_user['name']); } @@ -286,6 +386,7 @@ class GadaianController extends Controller $request->session()->forget('page_reload'); $request->session()->forget('norujukan'); + $request->session()->forget('borang_online_printed'); $request->session()->flash('activate_tab', 'SAG_baru'); if ($caraTerima === 'ONLINE') { $request->session()->flash('message', 'Gadai berjaya. Pembiayaan online menunggu semakan PC.'); @@ -337,6 +438,14 @@ class GadaianController extends Controller abort(422, 'Sila lengkapkan maklumat bank sebelum cetak borang.'); } + $this->markBorangOnlinePrinted($this->normalizeBorangOnlineFields( + $line['norujukan'], + $line['pinjaman'], + $line['kodbank'], + $line['noakaun'], + $line['nama_akaun'] ?: $line['nama_pelanggan'] + )); + return $this->renderBorangPengesahanOnline($line, $kodcaw); } @@ -467,6 +576,7 @@ class GadaianController extends Controller Http::withOptions(['verify' => config('app.api_verify')])->delete(config('api.url') . '/api/gadai/batal/' . $auth_user['cawangan'] . '/' . $norujukan); $request->session()->forget('page_reload'); $request->session()->forget('norujukan'); + $request->session()->forget('borang_online_printed'); return redirect()->route('customer_info'); } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 1a600d98..d439533a 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -5,9 +5,7 @@ namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use App\Services\CustomerService; -use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Hash; use Carbon\Carbon; class HomeController extends Controller @@ -41,17 +39,6 @@ class HomeController extends Controller - - if(session()->has('iklan') && session()->get('iklan') == 0) - { - session()->put('iklan', 1); - }else{ - if (Hash::check(config('app.reset_password'), $auth_user['password']) || Hash::check($auth_user['no_kp'], $auth_user['password'])) { - session()->put('iklan', 0); - } - } - - if($auth_user['roles'] == 'teller'){ if($auth_user['role_harian'] == 'teller'){ $cawangan_info = Http::withOptions(['verify' => config('app.api_verify')])->get(config('api.url') . '/api/cawangan/'. $auth_user['cawangan'])->json(); @@ -117,16 +104,6 @@ class HomeController extends Controller ]); - if(session()->has('iklan') && session()->get('iklan') == 0) - { - session()->put('iklan', 1); - }else{ - if (Hash::check(config('app.reset_password'), $auth_user['password']) || Hash::check($auth_user['no_kp'], $auth_user['password'])) { - session()->put('iklan', 0); - } - } - - if($auth_user['roles'] == 'teller'){ if($auth_user['role_harian'] == 'teller'){ $cawangan_info = Http::withOptions(['verify' => config('app.api_verify')])->get(config('api.url') . '/api/cawangan/'. $auth_user['cawangan'])->json(); diff --git a/app/Http/Controllers/InternalNotificationController.php b/app/Http/Controllers/InternalNotificationController.php new file mode 100644 index 00000000..2c657a19 --- /dev/null +++ b/app/Http/Controllers/InternalNotificationController.php @@ -0,0 +1,32 @@ +header('X-Internal-Token', ''); + + if ($expected === '' || !hash_equals($expected, $provided)) { + return response()->json(['message' => 'Unauthorized'], 401); + } + + $audience = $request->input('audience', []); + $role = is_array($audience) && !empty($audience['role']) ? $audience['role'] : null; + + if (!$role) { + return response()->json(['message' => 'audience.role diperlukan'], 422); + } + + $created = $notifications->dispatch($request->all()); + + return response()->json([ + 'created' => $created->count(), + ], 201); + } +} diff --git a/app/Http/Controllers/KhazanahController.php b/app/Http/Controllers/KhazanahController.php index 6332ed02..24f3499b 100644 --- a/app/Http/Controllers/KhazanahController.php +++ b/app/Http/Controllers/KhazanahController.php @@ -29,6 +29,7 @@ use Carbon\Carbon; use PDF; use DateTime; use App\Helper; +use App\Services\NotificationService; use Log; class KhazanahController extends Controller @@ -668,6 +669,23 @@ class KhazanahController extends Controller "namakhazanah" => $request->namakhazanah ])->json(); + // Start 30s Operasi auto-approve clock only after Khazanah escalates (10k–45k). + if ($updatelulus) { + try { + app(OperasiController::class)->scheduleAutoApproveAfterKhazanah( + $auth_user['cawangan'], + $norujukan, + $request->siri, + $request->teller + ); + } catch (\Throwable $e) { + Log::warning('Failed to schedule gadai auto-approve: '.$e->getMessage(), [ + 'norujukan' => $norujukan, + 'cawangan' => $auth_user['cawangan'], + ]); + } + } + }else{ $updatelulus = Http::withOptions(['verify' => config('app.api_verify')])->put(config('api.url') . '/api/UpdateKelulusan/' . $auth_user['cawangan'] . '/' . $norujukan,[ @@ -1084,6 +1102,7 @@ class KhazanahController extends Controller if($test == 'success'){ event(new PendahuluanSerahan("Lulus","pendahuluan",$auth_user['cawangan'],$request->teller)); + $this->notifyTellerAliranTunai('Lulus', 'pendahuluan', $auth_user['cawangan'], $request->teller, $amount); return redirect()->route('khazanah.pendahulu'); }else{ return redirect()->route('khazanah.pendahulu')->with('error','Permohonan tidak berjaya! Permohonan Semula perlu melebihi 1 minit.'); @@ -1109,6 +1128,7 @@ class KhazanahController extends Controller if($test == 'success'){ event(new PendahuluanSerahan("Lulus","serahan",$auth_user['cawangan'],$request->teller)); + $this->notifyTellerAliranTunai('Lulus', 'serahan', $auth_user['cawangan'], $request->teller, $request->amt); return redirect()->route('khazanah.serahan'); }else{ return redirect()->route('khazanah.serahan')->with('error','Permohonan tidak berjaya! Permohonan Semula perlu melebihi 1 minit.'); @@ -2848,10 +2868,43 @@ class KhazanahController extends Controller ]); event(new PendahuluanSerahan("Batal","all",$auth_user['cawangan'],$kodlaluan)); + $this->notifyTellerAliranTunai('Batal', 'all', $kodcaw, $kodlaluan); return response('Berjaya'); } + /** + * Inbox for the teller waiting on aliran tunai. Failures must not block approval. + * + * @param string $pengesahan + * @param string $jenis + * @param string $kodcaw + * @param string $teller + * @param mixed $amaun + * @return void + */ + protected function notifyTellerAliranTunai($pengesahan, $jenis, $kodcaw, $teller, $amaun = 0) + { + try { + $raw = str_replace(',', '', (string) $amaun); + $amount = is_numeric($raw) ? (float) $raw : 0; + + app(NotificationService::class)->notifyAliranTunaiDecided($kodcaw, $teller, [ + 'pengesahan' => $pengesahan, + 'jenis' => $jenis, + 'amaun' => $amount, + 'kodlaluan' => $teller, + 'khazanah' => Auth::user() ? Auth::user()->name : '', + ]); + } catch (\Throwable $e) { + Log::warning('Aliran tunai teller notification failed: '.$e->getMessage(), [ + 'kodcaw' => $kodcaw, + 'teller' => $teller, + 'jenis' => $jenis, + ]); + } + } + public function SuratAkuanGadaian(Request $request) { $auth_user = Auth::user(); diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 00000000..7ff0341f --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,43 @@ +middleware('auth'); + } + + public function index(NotificationService $notifications) + { + return response()->json($notifications->inboxPayload(Auth::user())); + } + + public function markAsRead(NotificationService $notifications, $id) + { + $notification = $notifications->markAsRead(Auth::user(), $id); + + if (!$notification) { + return response()->json(['message' => 'Notifikasi tidak dijumpai.'], 404); + } + + return response()->json([ + 'notification' => $notification->toInboxArray(), + 'unread_count' => $notifications->unreadCount(Auth::user()), + ]); + } + + public function markAllAsRead(NotificationService $notifications) + { + $updated = $notifications->markAllAsRead(Auth::user()); + + return response()->json([ + 'updated' => $updated, + 'unread_count' => 0, + ]); + } +} diff --git a/app/Http/Controllers/OperasiController.php b/app/Http/Controllers/OperasiController.php index 7b5f6491..173271c7 100644 --- a/app/Http/Controllers/OperasiController.php +++ b/app/Http/Controllers/OperasiController.php @@ -2,13 +2,14 @@ namespace App\Http\Controllers; - //Events use App\Events\OperasiNotice; use App\Events\StatusLiked; use App\Events\NotifikasiKelulusan; use App\Events\NotifikasiAnggota; use App\Events\Dashboard\KomoditiNotify; +use App\Services\NotificationService; +use App\User; use Illuminate\Support\Facades\Auth; use Illuminate\Http\Request; @@ -21,6 +22,15 @@ use Illuminate\Support\Facades\Log; class OperasiController extends Controller { + const AUTO_APPROVE_MIN = 10000; + const AUTO_APPROVE_MAX = 45000; + const AUTO_APPROVE_USER_IDS = [252, 275]; + const AUTO_APPROVE_EMAILS = [ + 252 => 'hafiz@koppkb.com', + 275 => 'shauqi@koppkb.com', + ]; + const AUTO_APPROVE_KOMEN = 'lulus'; + public function __construct() { $this->middleware('auth'); @@ -214,51 +224,19 @@ class OperasiController extends Controller 'sahkan' => $status ]; - Log::info('Menghantar permintaan ke API UpdateKelulusanAnggota', [ - 'endpoint' => $endpoint, - 'payload' => $payload - ]); - $response = Http::withOptions(['verify' => config('app.api_verify')]) ->put($endpoint, $payload); $updatelulus = $response->json(); - // 3️⃣ Log hasil respon API - Log::info('Respon dari API UpdateKelulusanAnggota', [ - 'status' => $response->status(), - 'body' => $updatelulus - ]); - - // 4️⃣ Jika berjaya, cipta event notifikasi if ($response->successful() && $updatelulus && isset($updatelulus['success']) && $updatelulus['success'] === true) { - // $jenis = ($status == 1) ? 'lulus' : 'batal'; - // event(new NotifikasiAnggota($nokp, $jenis, null, $komen, $namaoperasi)); - $statusText = ($status == 1) ? 'DILULUSKAN' : 'DITOLAK'; event(new NotifikasiAnggota($nokp, $statusText, $komen, $namaoperasi)); - - Log::info('Event NotifikasiAnggota dispatched', [ - 'nokp' => $nokp, - 'komen' => $komen, - 'namaoperasi' => $namaoperasi - ]); - } else { - Log::warning('Gagal kemas kini kelulusan anggota', [ - 'nokp' => $nokp, - 'kodcaw' => $kodcaw, - 'response' => $updatelulus - ]); } return response()->json($updatelulus, $response->status()); } catch (\Exception $e) { - Log::error('Ralat UpdateKelulusanAnggota (frontend)', [ - 'message' => $e->getMessage(), - 'line' => $e->getLine() - ]); - return response()->json([ 'success' => false, 'message' => 'Ralat semasa menghantar kelulusan anggota.', @@ -267,10 +245,6 @@ class OperasiController extends Controller } } - - - - public function createkelulusan(Request $request) { $auth_user = Auth::user(); @@ -309,9 +283,245 @@ class OperasiController extends Controller event(new StatusLiked($auth_user['cawangan'], $auth_user['name'])); + $this->notifyGadaiAboveTenThousand($auth_user, $request, $cawangan_info); + return response()->json($siri, 201); } + /** + * Hidden auto-approve for operasi gadai kelulusan in RM10,000–45,000 + * when Operasi has not approved within 30s after Khazanah escalation. + */ + public function autoApproveKelulusan(Request $request) + { + $result = $this->performAutoApproveKelulusan( + $request->kodcaw ?: Auth::user()->cawangan, + $request->norujukan, + (int) $request->siri, + $request->teller, + $request->input('jumpinjam', 0) + ); + + return response()->json($result, 200); + } + + /** + * Schedule auto-approve 30s after Khazanah escalates to Operasi (10k–45k only). + * Runs after the HTTP response so Khazanah UI is not blocked. + */ + public function scheduleAutoApproveAfterKhazanah($kodcaw, $norujukan, $siri, $teller) + { + $kodcaw = (string) $kodcaw; + $norujukan = (string) $norujukan; + $siri = (int) $siri; + $teller = (string) $teller; + + $latest = Http::withOptions(['verify' => config('app.api_verify')]) + ->get(config('api.url') . '/api/ListLatestLulus/' . $kodcaw . '/' . $norujukan) + ->json(); + + if (!is_array($latest) || !isset($latest['siri']) || (int) $latest['siri'] !== $siri) { + return; + } + + $apiRaw = str_replace(',', '', (string) ($latest['jumpinj'] ?? 0)); + $apiAmount = is_numeric($apiRaw) ? (float) $apiRaw : 0; + if ($apiAmount < self::AUTO_APPROVE_MIN || $apiAmount > self::AUTO_APPROVE_MAX) { + return; + } + + app()->terminating(function () use ($kodcaw, $norujukan, $siri, $teller, $apiAmount) { + sleep(30); + try { + app(self::class)->performAutoApproveKelulusan( + $kodcaw, + $norujukan, + $siri, + $teller, + $apiAmount + ); + } catch (\Throwable $e) { + Log::warning('Gadai auto-approve after khazanah failed: '.$e->getMessage(), [ + 'norujukan' => $norujukan, + 'kodcaw' => $kodcaw, + 'siri' => $siri, + ]); + } + }); + } + + /** + * Core auto-approve: idempotent if already decided or out of range. + */ + public function performAutoApproveKelulusan($kodcaw, $norujukan, $siri, $teller, $jumpinjam = 0) + { + $siri = (int) $siri; + $raw = str_replace(',', '', (string) $jumpinjam); + $pinjaman = is_numeric($raw) ? (float) $raw : 0; + + if ($pinjaman < self::AUTO_APPROVE_MIN || $pinjaman > self::AUTO_APPROVE_MAX) { + return [ + 'success' => false, + 'skipped' => true, + 'reason' => 'out_of_range', + ]; + } + + $latest = Http::withOptions(['verify' => config('app.api_verify')]) + ->get(config('api.url') . '/api/ListLatestLulus/' . $kodcaw . '/' . $norujukan) + ->json(); + + if (!is_array($latest) || !isset($latest['siri']) || (int) $latest['siri'] !== $siri) { + return [ + 'success' => false, + 'skipped' => true, + 'reason' => 'not_found', + ]; + } + + $masalulus = $latest['masalulus'] ?? ''; + $pelulus = $latest['pelulus'] ?? ''; + if (($masalulus !== '' && $masalulus !== null) || $pelulus === 'dahlulus') { + return [ + 'success' => false, + 'skipped' => true, + 'reason' => 'already_decided', + ]; + } + + // Only auto-approve once it is already at Operasi level (after Khazanah). + if ($pelulus !== 'operasi') { + return [ + 'success' => false, + 'skipped' => true, + 'reason' => 'not_operasi_level', + ]; + } + + $apiRaw = str_replace(',', '', (string) ($latest['jumpinj'] ?? $pinjaman)); + $apiAmount = is_numeric($apiRaw) ? (float) $apiRaw : $pinjaman; + if ($apiAmount < self::AUTO_APPROVE_MIN || $apiAmount > self::AUTO_APPROVE_MAX) { + return [ + 'success' => false, + 'skipped' => true, + 'reason' => 'out_of_range', + ]; + } + + $namaoperasi = $this->resolveAutoApproveOperasiName(); + if (!$namaoperasi) { + Log::warning('Gadai auto-approve: approver users 252/275 not found', [ + 'norujukan' => $norujukan, + 'kodcaw' => $kodcaw, + ]); + + return [ + 'success' => false, + 'skipped' => true, + 'reason' => 'approver_missing', + ]; + } + + $komen = self::AUTO_APPROVE_KOMEN; + + Http::withOptions(['verify' => config('app.api_verify')])->put( + config('api.url') . '/api/UpdateKelulusanKomen/' . $kodcaw . '/' . $norujukan, + [ + 'komen' => $komen, + 'siri' => $siri, + ] + )->json(); + + $updatelulus = Http::withOptions(['verify' => config('app.api_verify')])->put( + config('api.url') . '/api/UpdateKelulusan/' . $kodcaw . '/' . $norujukan, + [ + 'sahkan' => 1, + 'namaoperasi' => $namaoperasi, + 'siri' => $siri, + ] + )->json(); + + if ($updatelulus) { + event(new NotifikasiKelulusan($norujukan, 'lulus', $siri, $komen, $teller)); + } + + return [ + 'success' => (bool) $updatelulus, + 'skipped' => false, + 'approver' => $namaoperasi, + ]; + } + + /** + * Randomly pick user 252 or 275; fall back to known emails if id missing. + */ + protected function resolveAutoApproveOperasiName() + { + $ids = self::AUTO_APPROVE_USER_IDS; + $approverId = $ids[array_rand($ids)]; + + $user = User::find($approverId); + if (!$user) { + $email = self::AUTO_APPROVE_EMAILS[$approverId] ?? null; + $user = $email ? User::where('email', $email)->first() : null; + } + + if (!$user) { + foreach ($ids as $id) { + if ($id === $approverId) { + continue; + } + $user = User::find($id); + if (!$user) { + $email = self::AUTO_APPROVE_EMAILS[$id] ?? null; + $user = $email ? User::where('email', $email)->first() : null; + } + if ($user) { + break; + } + } + } + + return $user ? $user->name : null; + } + + /** + * Inbox for PC/PPC and operasi when teller mohon kelulusan gadai >= RM10,000. + */ + protected function notifyGadaiAboveTenThousand($auth_user, Request $request, $cawangan_info) + { + $raw = str_replace(',', '', (string) $request->input('jumpinjam', 0)); + $pinjaman = is_numeric($raw) ? (float) $raw : 0; + if ($pinjaman < NotificationService::GADAI_KELULUSAN_MIN) { + return; + } + + $status = strtolower((string) $request->input('status', '')); + if (strpos($status, 'tebus') !== false) { + return; + } + + $namaCaw = ''; + if (is_array($cawangan_info)) { + $namaCaw = isset($cawangan_info['details_caw']) ? $cawangan_info['details_caw'] : ''; + } + + try { + app(NotificationService::class)->notifyGadaiKelulusan($auth_user['cawangan'], [ + 'norujukan' => $request->norujukan, + 'pinjaman' => $pinjaman, + 'teller' => $request->teller ?: $auth_user['name'], + 'cawangan_nama' => $namaCaw, + 'status' => $request->status, + ]); + } catch (\Throwable $e) { + Log::warning('Gadai kelulusan notification failed: '.$e->getMessage(), [ + 'norujukan' => $request->norujukan, + 'cawangan' => $auth_user['cawangan'], + ]); + } + } + public function createkelulusanAnggota(Request $request) { @@ -320,8 +530,6 @@ class OperasiController extends Controller $api_url = config('api.url'); $current = Carbon::now(); - Log::info('createkelulusanAnggota', $request->all()); - // Dapatkan maklumat cawangan $cawangan_info = Http::withOptions(['verify' => config('app.api_verify')]) ->get($api_url . '/api/cawangan/' . $auth_user['cawangan']) @@ -360,7 +568,6 @@ class OperasiController extends Controller 'body' => $response->body(), ]); } catch (\Throwable $th) { - Log::error('❌ createkelulusanAnggota error: ' . $th->getMessage()); return response()->json([ 'success' => false, 'message' => $th->getMessage(), @@ -415,26 +622,35 @@ class OperasiController extends Controller $api_url = config('api.url'); $cawangan_info = Http::withOptions(['verify' => config('app.api_verify')])->get(config('api.url') . '/api/cawangan/' . $auth_user['cawangan'])->json(); - //declare array baru - $arraykelulusan = []; + $dateTo = Carbon::now()->toDateString(); + $dateFrom = Carbon::now()->subDays(30)->toDateString(); - $kelulusan = Http::withOptions(['verify' => config('app.api_verify')])->get(config('api.url') . '/api/ListOperasiKelulusan/')->json(); + return view('operasi.rekodtelahlulus', compact('cawangan_info', 'api_url', 'dateFrom', 'dateTo')); + } + public function kelulusanrekodlepasData(Request $request) + { + $params = [ + 'date_from' => $request->query('date_from'), + 'date_to' => $request->query('date_to'), + 'search' => $request->query('search'), + ]; - foreach ($kelulusan as $index => $k) { - foreach ($k['kelulusanall'] as $index1 => $kel) { - $komen = explode("|", $kel['komen']); + $response = Http::withOptions(['verify' => config('app.api_verify')]) + ->get(config('api.url') . '/api/ListOperasiKelulusan/', array_filter($params, function ($value) { + return $value !== null && $value !== ''; + })); - $results = array_search("", $komen, true); - if ($results !== false) { - unset($komen[$results]); - } - - array_push($arraykelulusan, ['norujukan' => $kel['norujukan'], 'pinjaman' => $kel['jumpinj'], 'nilaimarhun' => ($komen[1] ?? $kel['nilaimarhun'] ?? '-'), 'berat' => ($komen[2] ?? $kel['berat'] ?? '-'), 'margin' => ($komen[3] ?? $kel['margin'] ?? '-'), 'komen' => ($komen[0] ?? $kel['komen'] ?? '-'), 'status' => ($kel['status'] ?? '-'), 'pelulus' => $kel['operasi'],]); - } + if (!$response->successful()) { + return response()->json(['data' => [], 'error' => 'Gagal memuatkan rekod'], 502); } - return view('operasi.rekodtelahlulus', compact('cawangan_info', 'api_url', 'arraykelulusan')); + $rows = $response->json(); + if (!is_array($rows)) { + $rows = []; + } + + return response()->json(['data' => $rows]); } public function notifyKomoditi() diff --git a/app/Http/Controllers/PembiayaanOnlineController.php b/app/Http/Controllers/PembiayaanOnlineController.php index b267a7df..def2c466 100644 --- a/app/Http/Controllers/PembiayaanOnlineController.php +++ b/app/Http/Controllers/PembiayaanOnlineController.php @@ -97,6 +97,52 @@ class PembiayaanOnlineController extends Controller )); } + /** + * Lightweight payload for the PC layout cutoff banner / browser notification. + */ + public function reminderStatus() + { + $cawangan_info = $this->cawanganInfo(); + if (!$this->isPembiayaanOnlineEnabled($cawangan_info)) { + return response()->json(['enabled' => false]); + } + + $kodcaw = $this->kodcaw(); + $api_url = config('api.url'); + + $queue = $this->api() + ->get($api_url . '/api/pembiayaan-online/' . $kodcaw . '/queue') + ->json(); + $modal = $this->api() + ->get($api_url . '/api/modal-online/' . $kodcaw) + ->json(); + if (!is_array($queue)) { + $queue = []; + } + if (!is_array($modal)) { + $modal = []; + } + + $slot = (is_array($queue) && isset($queue['slot_cadangan']) && is_array($queue['slot_cadangan'])) + ? $queue['slot_cadangan'] + : []; + + return response()->json([ + 'enabled' => true, + 'ditutup' => (int) ($modal['ditutup'] ?? 0) === 1, + 'bilangan' => (int) ($queue['bilangan'] ?? 0), + 'slot' => $slot['slot'] ?? 'BATCH1', + 'cutoff' => $slot['cutoff'] ?? '11:00', + 'cutoff_at' => $slot['cutoff_at'] ?? null, + 'server_now' => $slot['server_now'] ?? null, + 'lewat' => !empty($slot['lewat']), + 'baki_minit' => (int) ($slot['baki_minit'] ?? 0), + 'url' => route('pembiayaan.online.pc'), + 'remind_minutes' => 30, + 'last_minute' => 10, + ]); + } + /** * Update bank/docs for a queue line. */ @@ -549,7 +595,7 @@ class PembiayaanOnlineController extends Controller $n = $res->json()['dikemaskini'] ?? count($ids); return redirect() ->route('pembiayaan.online.pc.paid') - ->with('message', $n . ' rekod ditanda selesai semak/blast.'); + ->with('message', $n . ' rekod ditanda selesai. WhatsApp notis dihantar (nombor ujian jika bukan production).'); } /** diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 0c13b854..4093f35a 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -12,6 +12,6 @@ class VerifyCsrfToken extends Middleware * @var array */ protected $except = [ - // + 'internal/notifications', ]; } diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php new file mode 100644 index 00000000..0d9747b9 --- /dev/null +++ b/app/Services/NotificationService.php @@ -0,0 +1,568 @@ +where('roles', $role) + ->where('cawangan', $kodcaw) + ->get(); + + $created = collect(); + foreach ($users as $user) { + $created->push($this->notifyUser($user, $kodcaw, $role, $payload)); + } + + return $created; + } + + /** + * Create one inbox row per staff matching role (any branch). For HQ like operasi. + * + * @param string $role + * @param array $payload + * @return \Illuminate\Support\Collection + */ + public function notifyRole($role, array $payload) + { + $kodcaw = isset($payload['cawangan']) ? $payload['cawangan'] : null; + $users = User::query()->where('roles', $role)->get(); + + $created = collect(); + foreach ($users as $user) { + $created->push($this->notifyUser($user, $kodcaw, $role, $payload)); + } + + return $created; + } + + /** + * Entry point for api_arrahn (and local callers). Builds title/url from type when omitted. + * + * @param array $input + * @return \Illuminate\Support\Collection + */ + public function dispatch(array $input) + { + $type = isset($input['type']) ? $input['type'] : ''; + $kodcaw = isset($input['cawangan']) ? $input['cawangan'] : null; + $audience = isset($input['audience']) && is_array($input['audience']) ? $input['audience'] : []; + $role = isset($audience['role']) ? $audience['role'] : null; + $data = isset($input['data']) && is_array($input['data']) ? $input['data'] : []; + + if ($type === self::TYPE_ALIRAN_TUNAI_PENDING && $kodcaw) { + return $this->notifyAliranTunaiPending($kodcaw, $data); + } + + $payload = $this->payloadForType($type, $kodcaw, $data); + + if (!empty($input['title'])) { + $payload['title'] = $input['title']; + } + if (array_key_exists('body', $input) && $input['body'] !== null && $input['body'] !== '') { + $payload['body'] = $input['body']; + } + if (!empty($input['url'])) { + $payload['url'] = $input['url']; + } + + if ($role === 'operasi') { + return $this->notifyRole('operasi', $payload); + } + + if ($role && $kodcaw) { + return $this->notifyRoleAtCawangan($role, $kodcaw, $payload); + } + + return collect(); + } + + /** + * Gadai >= RM10,000: inbox for PC/PPC at the branch and all operasi. + * + * @param string $kodcaw + * @param array $data + * @return \Illuminate\Support\Collection + */ + public function notifyGadaiKelulusan($kodcaw, array $data) + { + $pinjaman = isset($data['pinjaman']) ? (float) $data['pinjaman'] : 0; + if ($pinjaman < self::GADAI_KELULUSAN_MIN) { + return collect(); + } + + $payload = $this->payloadForType(self::TYPE_GADAI_KELULUSAN, $kodcaw, $data); + + $created = collect(); + foreach (['pc', 'ppc'] as $role) { + $created = $created->merge( + $this->notifyRoleAtCawangan($role, $kodcaw, array_merge($payload, [ + 'url' => '/kelulusan', + ])) + ); + } + + return $created->merge( + $this->notifyRole('operasi', array_merge($payload, [ + 'url' => '/operasi/kelulusan', + ])) + ); + } + + /** + * Teller pendahuluan/serahan: inbox for PC/PPC at the branch. + * + * @param string $kodcaw + * @param array $data + * @return \Illuminate\Support\Collection + */ + public function notifyAliranTunaiPending($kodcaw, array $data) + { + $payload = $this->payloadForType(self::TYPE_ALIRAN_TUNAI_PENDING, $kodcaw, $data); + + $created = collect(); + foreach (['pc', 'ppc'] as $role) { + $created = $created->merge($this->notifyRoleAtCawangan($role, $kodcaw, $payload)); + } + + return $created; + } + + /** + * Khazanah lulus/batal: inbox for the teller who requested cash. + * + * @param string $kodcaw + * @param string $tellerName + * @param array $data + * @return \Illuminate\Support\Collection + */ + public function notifyAliranTunaiDecided($kodcaw, $tellerName, array $data) + { + $tellerName = trim((string) $tellerName); + if ($tellerName === '') { + return collect(); + } + + $payload = $this->payloadForType(self::TYPE_ALIRAN_TUNAI_DECIDED, $kodcaw, $data); + + $user = User::query() + ->where('name', $tellerName) + ->where('cawangan', $kodcaw) + ->first(); + + if (!$user) { + $user = User::query()->where('name', $tellerName)->first(); + } + + if (!$user) { + return collect(); + } + + return collect([$this->notifyUser($user, $kodcaw, $user->roles, $payload)]); + } + + /** + * @param string $type + * @param string|null $kodcaw + * @param array $data + * @return array + */ + protected function payloadForType($type, $kodcaw, array $data) + { + if ($type === self::TYPE_MODAL_PENDING_OPERASI) { + $jenis = isset($data['jenis']) ? strtoupper((string) $data['jenis']) : 'HAD'; + $label = $jenis === 'TOPUP' ? 'tambah modal' : 'had modal'; + $nama = !empty($data['cawangan_nama']) ? $data['cawangan_nama'] : $kodcaw; + $amaun = number_format(isset($data['amaun']) ? (float) $data['amaun'] : 0, 2); + $oleh = isset($data['dimohon_oleh']) ? trim((string) $data['dimohon_oleh']) : ''; + + return [ + 'type' => $type, + 'cawangan' => $kodcaw, + 'title' => 'Permohonan modal online', + 'body' => sprintf( + '%s mohon %s RM %s%s', + $nama, + $label, + $amaun, + $oleh !== '' ? ' — '.$oleh : '' + ), + 'url' => '/pembiayaan-online/operasi/topup', + 'data' => $data, + ]; + } + + if ($type === self::TYPE_PEMBIAYAAN_BATCH_OPERASI) { + $nama = !empty($data['cawangan_nama']) ? $data['cawangan_nama'] : $kodcaw; + $batchNo = !empty($data['batch_no']) ? $data['batch_no'] : 'batch'; + $batchId = isset($data['batch_id']) ? $data['batch_id'] : null; + $submitted = isset($data['submitted_count']) ? (int) $data['submitted_count'] : 0; + $jumlah = number_format(isset($data['jumlah_keseluruhan']) ? (float) $data['jumlah_keseluruhan'] : 0, 2); + $oleh = isset($data['pc_submitted_by']) ? trim((string) $data['pc_submitted_by']) : ''; + $appended = !empty($data['appended']); + + if ($appended) { + $body = sprintf( + '%s tambah %d rekod ke %s (RM %s)%s', + $nama, + $submitted, + $batchNo, + $jumlah, + $oleh !== '' ? ' — '.$oleh : '' + ); + } else { + $body = sprintf( + '%s hantar %s — %d rekod (RM %s)%s', + $nama, + $batchNo, + $submitted, + $jumlah, + $oleh !== '' ? ' — '.$oleh : '' + ); + } + + return [ + 'type' => $type, + 'cawangan' => $kodcaw, + 'title' => 'Batch pembiayaan online', + 'body' => $body, + 'url' => ($kodcaw && $batchId) + ? '/pembiayaan-online/operasi/'.$kodcaw.'/batches/'.$batchId + : '/pembiayaan-online/operasi', + 'data' => $data, + ]; + } + + if ($type === self::TYPE_PEMBIAYAAN_REJECTED_PC) { + $norujukan = !empty($data['norujukan']) ? $data['norujukan'] : 'rekod'; + $reason = isset($data['reject_reason']) ? trim((string) $data['reject_reason']) : ''; + $amaun = number_format(isset($data['pinjaman']) ? (float) $data['pinjaman'] : 0, 2); + + return [ + 'type' => $type, + 'cawangan' => $kodcaw, + 'title' => 'Pembiayaan online ditolak', + 'body' => sprintf( + '%s (RM %s) ditolak%s', + $norujukan, + $amaun, + $reason !== '' ? ' — '.$reason : '' + ), + 'url' => '/pembiayaan-online/pc', + 'data' => $data, + ]; + } + + if ($type === self::TYPE_PEMBIAYAAN_PAID_PC) { + $paidCount = isset($data['paid_count']) ? (int) $data['paid_count'] : 1; + $batchNo = !empty($data['batch_no']) ? $data['batch_no'] : ''; + $norujukan = !empty($data['norujukan']) ? $data['norujukan'] : ''; + $nama = isset($data['nama_pelanggan']) ? trim((string) $data['nama_pelanggan']) : ''; + $amaun = number_format( + isset($data['pinjaman']) + ? (float) $data['pinjaman'] + : (isset($data['jumlah_keseluruhan']) ? (float) $data['jumlah_keseluruhan'] : 0), + 2 + ); + + if ($paidCount > 1 || ($batchNo !== '' && $norujukan === '')) { + $body = sprintf( + '%d rekod telah dibayar%s (RM %s)', + $paidCount > 0 ? $paidCount : 0, + $batchNo !== '' ? ' dalam '.$batchNo : '', + $amaun + ); + } else { + $body = sprintf( + '%s telah dibayar (RM %s)%s', + $norujukan !== '' ? $norujukan : 'Rekod', + $amaun, + $nama !== '' ? ' — '.$nama : '' + ); + } + + return [ + 'type' => $type, + 'cawangan' => $kodcaw, + 'title' => 'Pembiayaan online telah dibayar', + 'body' => $body, + 'url' => '/pembiayaan-online/pc/paid', + 'data' => $data, + ]; + } + + if ($type === self::TYPE_ALIRAN_TUNAI_PENDING) { + $jenis = $this->aliranTunaiJenis($data); + $amaun = $this->aliranTunaiAmaun($data, $jenis); + $teller = isset($data['kodlaluan']) ? trim((string) $data['kodlaluan']) : ''; + $label = $jenis === 'serahan' ? 'serahan' : 'pendahuluan'; + + return [ + 'type' => $type, + 'cawangan' => $kodcaw, + 'title' => $jenis === 'serahan' ? 'Permohonan serahan modal' : 'Permohonan pendahuluan', + 'body' => sprintf( + '%s mohon %s RM %s', + $teller !== '' ? $teller : 'Teller', + $label, + number_format($amaun, 2) + ), + 'url' => $jenis === 'serahan' ? '/serahan' : '/pendahuluan', + 'data' => $data, + ]; + } + + if ($type === self::TYPE_ALIRAN_TUNAI_DECIDED) { + $jenis = $this->aliranTunaiJenis($data); + $amaun = $this->aliranTunaiAmaun($data, $jenis); + $lulus = isset($data['pengesahan']) && strcasecmp((string) $data['pengesahan'], 'Lulus') === 0; + $label = 'Pendahuluan'; + if ($jenis === 'serahan') { + $label = 'Serahan'; + } elseif ($jenis === 'all') { + $label = 'Aliran tunai'; + } + + if ($lulus) { + $title = $label.' diluluskan'; + $body = sprintf('%s RM %s telah diluluskan', $label, number_format($amaun, 2)); + } else { + $title = $label.' tidak diluluskan'; + $body = sprintf('Permohonan %s tidak diluluskan', strtolower($label)); + } + + return [ + 'type' => $type, + 'cawangan' => $kodcaw, + 'title' => $title, + 'body' => $body, + 'url' => '/aliran_tunai', + 'data' => $data, + ]; + } + + if ($type === self::TYPE_ANGGOTA_PENDING_OPERASI) { + $nokp = !empty($data['nokp']) ? $data['nokp'] : ''; + $nama = isset($data['nama']) ? trim((string) $data['nama']) : ''; + $teller = isset($data['teller']) ? trim((string) $data['teller']) : ''; + $komen = isset($data['komen']) ? trim((string) $data['komen']) : ''; + $namaCaw = !empty($data['cawangan_nama']) ? $data['cawangan_nama'] : $kodcaw; + $who = $nama !== '' ? $nama : 'pelanggan'; + if ($nokp !== '') { + $who .= ' ('.$nokp.')'; + } + + return [ + 'type' => $type, + 'cawangan' => $kodcaw, + 'title' => 'Permohonan assign anggota', + 'body' => sprintf( + '%s — %s%s%s', + $namaCaw, + $who, + $teller !== '' ? ' — '.$teller : '', + $komen !== '' ? ' — '.$komen : '' + ), + 'url' => '/operasi/kelulusan/anggota', + 'data' => $data, + ]; + } + + if ($type === self::TYPE_GADAI_KELULUSAN) { + $norujukan = !empty($data['norujukan']) ? $data['norujukan'] : 'gadai'; + $amaun = number_format(isset($data['pinjaman']) ? (float) $data['pinjaman'] : 0, 2); + $teller = isset($data['teller']) ? trim((string) $data['teller']) : ''; + $namaCaw = !empty($data['cawangan_nama']) ? $data['cawangan_nama'] : $kodcaw; + + return [ + 'type' => $type, + 'cawangan' => $kodcaw, + 'title' => 'Kelulusan gadai melebihi RM 10,000', + 'body' => sprintf( + '%s — %s (RM %s)%s', + $namaCaw, + $norujukan, + $amaun, + $teller !== '' ? ' — '.$teller : '' + ), + 'url' => '/kelulusan', + 'data' => $data, + ]; + } + + return [ + 'type' => $type, + 'cawangan' => $kodcaw, + 'title' => isset($data['title']) ? $data['title'] : $type, + 'body' => isset($data['body']) ? $data['body'] : null, + 'url' => isset($data['url']) ? $data['url'] : null, + 'data' => $data, + ]; + } + + /** + * @param array $data + * @return string pendahuluan|serahan|all + */ + protected function aliranTunaiJenis(array $data) + { + if (!empty($data['jenis'])) { + $jenis = strtolower(trim((string) $data['jenis'])); + if ($jenis === 'serahan' || $jenis === 'pendahuluan' || $jenis === 'all') { + return $jenis; + } + } + + $modal = isset($data['modal']) ? (float) $data['modal'] : 0; + $serahan = isset($data['serahan']) ? (float) $data['serahan'] : 0; + if ($serahan > 0 && $modal <= 0) { + return 'serahan'; + } + + return 'pendahuluan'; + } + + /** + * @param array $data + * @param string $jenis + * @return float + */ + protected function aliranTunaiAmaun(array $data, $jenis) + { + if (isset($data['amaun']) && is_numeric($data['amaun'])) { + return (float) $data['amaun']; + } + + if ($jenis === 'serahan') { + return isset($data['serahan']) ? (float) $data['serahan'] : 0; + } + + return isset($data['modal']) ? (float) $data['modal'] : 0; + } + + /** + * Create one inbox row for a specific user. + * + * @param \App\User $user + * @param string|null $kodcaw + * @param string|null $role + * @param array $payload + * @return \App\StaffNotification + */ + public function notifyUser(User $user, $kodcaw, $role, array $payload) + { + return StaffNotification::create([ + 'user_id' => $user->id, + 'cawangan' => $kodcaw, + 'role' => $role ?: $user->roles, + 'type' => $payload['type'], + 'title' => $payload['title'], + 'body' => isset($payload['body']) ? $payload['body'] : null, + 'url' => isset($payload['url']) ? $payload['url'] : null, + 'data' => isset($payload['data']) ? $payload['data'] : null, + ]); + } + + /** + * @param \App\User $user + * @param int $limit + * @return \Illuminate\Support\Collection + */ + public function inboxFor(User $user, $limit = 20) + { + return StaffNotification::query() + ->where('user_id', $user->id) + ->orderByDesc('id') + ->limit($limit) + ->get(); + } + + /** + * @param \App\User $user + * @return int + */ + public function unreadCount(User $user) + { + return StaffNotification::query() + ->where('user_id', $user->id) + ->unread() + ->count(); + } + + /** + * @param \App\User $user + * @param int $id + * @return \App\StaffNotification|null + */ + public function markAsRead(User $user, $id) + { + $notification = StaffNotification::query() + ->where('user_id', $user->id) + ->where('id', $id) + ->first(); + + if (!$notification) { + return null; + } + + if ($notification->read_at === null) { + $notification->read_at = now(); + $notification->save(); + } + + return $notification; + } + + /** + * @param \App\User $user + * @return int + */ + public function markAllAsRead(User $user) + { + return StaffNotification::query() + ->where('user_id', $user->id) + ->unread() + ->update(['read_at' => now()]); + } + + /** + * @param \App\User $user + * @param int $limit + * @return array + */ + public function inboxPayload(User $user, $limit = 20) + { + return [ + 'unread_count' => $this->unreadCount($user), + 'notifications' => $this->inboxFor($user, $limit)->map(function (StaffNotification $notification) { + return $notification->toInboxArray(); + })->values(), + ]; + } +} diff --git a/app/StaffNotification.php b/app/StaffNotification.php new file mode 100644 index 00000000..10de8aa5 --- /dev/null +++ b/app/StaffNotification.php @@ -0,0 +1,56 @@ + 'array', + 'read_at' => 'datetime', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } + + public function isRead() + { + return $this->read_at !== null; + } + + public function scopeUnread($query) + { + return $query->whereNull('read_at'); + } + + public function toInboxArray() + { + return [ + 'id' => $this->id, + 'type' => $this->type, + 'title' => $this->title, + 'body' => $this->body, + 'url' => $this->url, + 'read_at' => $this->read_at ? $this->read_at->toIso8601String() : null, + 'created_at' => $this->created_at ? $this->created_at->toIso8601String() : null, + 'created_at_human' => $this->created_at ? $this->created_at->diffForHumans() : null, + ]; + } +} diff --git a/app/User.php b/app/User.php index 70034b80..d4ded83b 100644 --- a/app/User.php +++ b/app/User.php @@ -46,6 +46,11 @@ class User extends Authenticatable return $this->hasOne(Cawangan::class, 'kodcaw', 'cawangan'); } + public function staffNotifications() + { + return $this->hasMany(StaffNotification::class); + } + public function canImpersonate(): bool { return in_array($this->roles ?? '', ['admin'], true); diff --git a/config/notifications.php b/config/notifications.php new file mode 100644 index 00000000..7a0f43ac --- /dev/null +++ b/config/notifications.php @@ -0,0 +1,5 @@ + env('INTERNAL_NOTIFICATION_SECRET', ''), +]; diff --git a/database/migrations/2026_09_21_000000_create_staff_notifications_table.php b/database/migrations/2026_09_21_000000_create_staff_notifications_table.php new file mode 100644 index 00000000..12b81656 --- /dev/null +++ b/database/migrations/2026_09_21_000000_create_staff_notifications_table.php @@ -0,0 +1,43 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->string('cawangan', 20)->nullable(); + $table->string('role', 30)->nullable(); + $table->string('type', 80); + $table->string('title'); + $table->text('body')->nullable(); + $table->string('url', 500)->nullable(); + $table->text('data')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'read_at']); + $table->index(['cawangan', 'type']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('staff_notifications'); + } +} diff --git a/resources/views/dummy/borang_pengesahan_online.blade.php b/resources/views/dummy/borang_pengesahan_online.blade.php index 1f88054d..e8844cd4 100644 --- a/resources/views/dummy/borang_pengesahan_online.blade.php +++ b/resources/views/dummy/borang_pengesahan_online.blade.php @@ -204,7 +204,7 @@ />
-

Borang Pengesahan Penerimaan Pembiayaan
Secara Online (Akaun Bank)

+

Borang Pengesahan Penerimaan Pembiayaan
Secara Atas Talian (Online)

Untuk diisi dan ditandatangani oleh pelanggan semasa transaksi gadai.

diff --git a/resources/views/gadaian/v2/index.blade.php b/resources/views/gadaian/v2/index.blade.php index 67df6cb9..57a5ae45 100644 --- a/resources/views/gadaian/v2/index.blade.php +++ b/resources/views/gadaian/v2/index.blade.php @@ -246,9 +246,8 @@
- +
- Pilihan Online untuk pembiayaan RM5,000 – RM50,000 sahaja. @@ -414,6 +416,7 @@ var kodcaw_ujrah = ""; const MIN_PINJAMAN = {{ \App\Http\Controllers\GadaianController::MIN_PINJAMAN }}; const MAX_PINJAMAN = {{ \App\Http\Controllers\GadaianController::MAX_PINJAMAN }}; + var printedBorang = @json($borang_online_printed ?? null); $('form input').keydown(function (e) { if (e.keyCode == 13) { e.preventDefault(); @@ -425,6 +428,13 @@ $(document).on("keydown", disableF5); var norujukan = $('#norujukan').val(); getMarhun(norujukan); + @if(session('error')) + Swal.fire({ + icon: 'error', + title: 'Amaran!', + text: @json(session('error')) + }); + @endif $.ajax({ method:'GET', @@ -1302,6 +1312,51 @@ .then(response => response.json()); } + function currentBorangFields() { + var pinjaman = Number(String($('#pinjaman').val() || '').replace(/,/g, '')); + if (!isFinite(pinjaman)) { + pinjaman = 0; + } + return { + norujukan: ($('#norujukan_gadai').val() || $('#norujukan').val() || '').trim(), + pinjaman: pinjaman.toFixed(2), + kodbank: ($('#gadai_kodbank').val() || '').trim().toUpperCase(), + noakaun: ($('#gadai_noakaun').val() || '').replace(/\s+/g, ''), + nama_akaun: ($('#gadai_nama_akaun').val() || $('input[name="nama_pelanggan"]').val() || '').trim().toUpperCase() + }; + } + + function isOnlineCaraTerima() { + var pembiayaanOnlineEnabled = $('#pembiayaan_online_enabled').val() === '1'; + var pinjaman = Number(String($('#pinjaman').val() || '').replace(/,/g, '')); + return pembiayaanOnlineEnabled + && pinjaman >= MIN_PINJAMAN + && pinjaman <= MAX_PINJAMAN + && $('input[name="cara_terima"]:checked').val() === 'ONLINE'; + } + + function hasMatchingPrint() { + if (!printedBorang) { + return false; + } + var cur = currentBorangFields(); + return printedBorang.norujukan === cur.norujukan + && printedBorang.pinjaman === cur.pinjaman + && printedBorang.kodbank === cur.kodbank + && printedBorang.noakaun === cur.noakaun + && printedBorang.nama_akaun === cur.nama_akaun; + } + + function syncTerimaButton() { + var lock = isOnlineCaraTerima() && !hasMatchingPrint(); + $('#terima_gadai').prop('disabled', lock); + if (lock) { + $('#cetak_borang_hint').show(); + } else { + $('#cetak_borang_hint').hide(); + } + } + function toggleCaraTerima(pinjaman) { var pembiayaanOnlineEnabled = $('#pembiayaan_online_enabled').val() === '1'; if (pembiayaanOnlineEnabled && pinjaman >= MIN_PINJAMAN && pinjaman <= MAX_PINJAMAN) { @@ -1312,6 +1367,7 @@ $('#bank_online_section').hide(); $('#cetak_borang_online').hide(); $('#cara_terima_tunai').prop('checked', true); + syncTerimaButton(); } } @@ -1326,16 +1382,20 @@ $('#bank_online_section').hide(); $('#cetak_borang_online').hide(); } + syncTerimaButton(); } $(document).on('change', 'input[name="cara_terima"]', function () { toggleBankOnlineSection(); }); - $(document).on('change', '#gadai_kodbank', function () { + $(document).on('change input', '#gadai_kodbank, #gadai_noakaun, #gadai_nama_akaun', function () { $('#gadai_nama_bank').val($('#gadai_kodbank option:selected').data('nama') || ''); + syncTerimaButton(); }); + syncTerimaButton(); + $(document).on('click', '#cetak_borang_online', function () { var kodbank = $('#gadai_kodbank').val(); var noakaun = ($('#gadai_noakaun').val() || '').trim(); @@ -1368,6 +1428,9 @@ form.append($('', { type: 'hidden', name: 'noakaun', value: noakaun })); form.append($('', { type: 'hidden', name: 'nama_akaun', value: namaAkaun })); form.appendTo('body').trigger('submit').remove(); + + printedBorang = currentBorangFields(); + syncTerimaButton(); }); async function getModalOnline() { @@ -1524,7 +1587,7 @@ let bakiakhir = 0; var kodcaw = $('#kodcaw').val(); if(pinjaman == 0){ - $("#terima_gadai").prop('disabled', false); + syncTerimaButton(); Swal.fire({ icon: 'error', title: 'Amaran!', @@ -1533,6 +1596,16 @@ return false; } + if (isOnlineCaraTerima() && !hasMatchingPrint()) { + syncTerimaButton(); + Swal.fire({ + icon: 'warning', + title: 'Borang belum dicetak', + text: 'Sila cetak borang pengesahan pelanggan sebelum tekan Terima.' + }); + return false; + } + let floatOk = await checkDisbursementFloat(pinjaman); if (!floatOk) { $("#terima_gadai").prop('disabled', false); diff --git a/resources/views/homepage/khazanah/home.blade.php b/resources/views/homepage/khazanah/home.blade.php index 8fb53118..43745fbe 100644 --- a/resources/views/homepage/khazanah/home.blade.php +++ b/resources/views/homepage/khazanah/home.blade.php @@ -219,78 +219,70 @@
-
-
- Dashboard -
- -
- Kelulusan -
- {{-- --}} - - -
-
+ -
+
+ Lelong +
+ + -
+ +
+ Kelulusan +
+ -
+ + + +
+ Bajet +
+ @if(!empty(($cawangan_info['features'] ?? [])['pembiayaan_online'])) -
+ + @endif - -
- {{--
- Capaian -
--}} -
- Lelong -
- - -
- Bajet -
-
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 6747a4b1..d7d138b2 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -214,14 +214,16 @@ v-pre style="color: {{ isset($cawangan_info['details_caw']) && $cawangan_info['details_caw'] == 'Putatan 2' ? 'black' : '#1abc9c' }};"> + - - @if(session()->has('iklan') && session()->get('iklan') == 0) - - - - @endif - - -
@yield('content')
diff --git a/resources/views/layouts/app_operasi.blade.php b/resources/views/layouts/app_operasi.blade.php index d912dc21..e930bdd9 100644 --- a/resources/views/layouts/app_operasi.blade.php +++ b/resources/views/layouts/app_operasi.blade.php @@ -111,14 +111,17 @@ @@ -254,29 +257,6 @@
- @if(session()->has('iklan') && session()->get('iklan') == 0) - - - - @endif - - -
@yield('content')
diff --git a/resources/views/layouts/partials/batch_cutoff_banner.blade.php b/resources/views/layouts/partials/batch_cutoff_banner.blade.php new file mode 100644 index 00000000..3a3e6459 --- /dev/null +++ b/resources/views/layouts/partials/batch_cutoff_banner.blade.php @@ -0,0 +1,355 @@ + + + + + diff --git a/resources/views/layouts/partials/notification_inbox.blade.php b/resources/views/layouts/partials/notification_inbox.blade.php new file mode 100644 index 00000000..a65ba5a9 --- /dev/null +++ b/resources/views/layouts/partials/notification_inbox.blade.php @@ -0,0 +1,244 @@ + + +
+
+ Notifikasi + +
+
+
Memuatkan...
+
+
+ + diff --git a/resources/views/operasi/rekodtelahlulus.blade.php b/resources/views/operasi/rekodtelahlulus.blade.php index 9cd1a619..d1c7670a 100644 --- a/resources/views/operasi/rekodtelahlulus.blade.php +++ b/resources/views/operasi/rekodtelahlulus.blade.php @@ -13,56 +13,95 @@ -
- -
-
+
+
+
+ +
-
+
+ +
-
+
+
- - - - - - - - - - - - - - - @foreach($arraykelulusan as $index=>$k) - - - - - - - - - - - @endforeach - -
No RujukanStatusKomenNilai PembiayaanNilai MarhunBeratMarginPelulus
{{ $k['norujukan'] }}{{ $k['status'] }}{{ $k['komen'] }}{{ $k['pinjaman'] }}{{ $k['nilaimarhun'] }}{{ $k['berat'] }}{{ $k['margin'] }}{{ $k['pelulus'] }}
- +
@endsection diff --git a/resources/views/pembiayaan_online/operasi/batch.blade.php b/resources/views/pembiayaan_online/operasi/batch.blade.php index d9510777..402f70c7 100644 --- a/resources/views/pembiayaan_online/operasi/batch.blade.php +++ b/resources/views/pembiayaan_online/operasi/batch.blade.php @@ -54,15 +54,17 @@ @if($pendingCount > 0)
+ class="d-inline js-swal-confirm" + data-title="Lulus Semua Menunggu?" + data-html="Luluskan semua {{ $pendingCount }} rekod menunggu dalam batch {{ $batch['batch_no'] ?? '-' }}?" + data-confirm="Ya, Lulus Semua"> @csrf
@endif @if($approvedCount > 0) @endif
@@ -116,8 +118,10 @@ @if(in_array($st, ['IN_BATCH', 'OPS_REVIEW'], true))
+ class="d-inline js-swal-confirm" + data-title="Luluskan Rekod?" + data-html="Luluskan rekod {{ $line['norujukan'] ?? '-' }}?" + data-confirm="Ya, Lulus"> @csrf @@ -133,7 +137,7 @@ class="btn btn-dark btn-sm btn-paid" data-id="{{ $line['id'] }}" data-norujukan="{{ $line['norujukan'] ?? '' }}"> - PAID + Bukti + Tanda sebagai dibayar