From 370b320d14a5f0da845f3fd59181e65732bad8e9 Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Sun, 20 Sep 2026 16:20:45 +0800 Subject: [PATCH 1/6] WIP: whatsapp api wire using hello world template --- app/Http/Controllers/PembiayaanOnlineController.php | 2 +- resources/views/pembiayaan_online/pc/paid.blade.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/PembiayaanOnlineController.php b/app/Http/Controllers/PembiayaanOnlineController.php index b267a7df..43fbf37a 100644 --- a/app/Http/Controllers/PembiayaanOnlineController.php +++ b/app/Http/Controllers/PembiayaanOnlineController.php @@ -549,7 +549,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/resources/views/pembiayaan_online/pc/paid.blade.php b/resources/views/pembiayaan_online/pc/paid.blade.php index 138ba469..3a90133e 100644 --- a/resources/views/pembiayaan_online/pc/paid.blade.php +++ b/resources/views/pembiayaan_online/pc/paid.blade.php @@ -75,7 +75,7 @@

Semak jumlah & bukti bayaran sepadan dengan pelanggan. Setelah semak, tanda selesai. - (WhatsApp/SMS blast boleh disambung kemudian — butang ini menanda rekod sebagai selesai semak.) + WhatsApp notis (template Cloud API) akan dihantar selepas tally. Di persekitaran ujian ia pergi ke nombor admin, bukan pelanggan.

@csrf @@ -180,7 +180,7 @@ $('#btnMarkBlast').on('click', function () { if ($('.row-check:checked').length === 0) return; - if (!confirm('Sahkan tally untuk rekod dipilih? Rekod akan ditanda selesai semak/blast.')) return; + if (!confirm('Sahkan tally untuk rekod dipilih? Rekod akan ditanda selesai dan WhatsApp notis akan dihantar.')) return; $('#formMarkBlast').submit(); }); -- 2.52.0 From d0f4e04f9655d7533d22f10bbfad1a28f19fe437 Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Mon, 21 Sep 2026 09:24:31 +0800 Subject: [PATCH 2/6] DONE: add notification table on kaunter, notifies pc and operasi for pembiayaan online; WIP: notification for gadaian above 10000 --- .env.docker | 4 +- .env.example | 3 + app/Http/Controllers/GadaianController.php | 34 ++ app/Http/Controllers/HomeController.php | 23 -- .../InternalNotificationController.php | 32 ++ .../Controllers/NotificationController.php | 43 +++ app/Http/Middleware/VerifyCsrfToken.php | 2 +- app/Services/NotificationService.php | 335 ++++++++++++++++++ app/StaffNotification.php | 56 +++ app/User.php | 5 + config/notifications.php | 5 + ...00000_create_staff_notifications_table.php | 43 +++ .../views/homepage/khazanah/home.blade.php | 92 +++-- resources/views/layouts/app.blade.php | 28 +- .../views/layouts/app_callcenter.blade.php | 23 -- resources/views/layouts/app_operasi.blade.php | 30 +- .../partials/notification_inbox.blade.php | 244 +++++++++++++ .../pembiayaan_online/operasi/batch.blade.php | 12 +- .../views/pembiayaan_online/pc/paid.blade.php | 6 +- routes/web.php | 6 + 20 files changed, 870 insertions(+), 156 deletions(-) create mode 100644 app/Http/Controllers/InternalNotificationController.php create mode 100644 app/Http/Controllers/NotificationController.php create mode 100644 app/Services/NotificationService.php create mode 100644 app/StaffNotification.php create mode 100644 config/notifications.php create mode 100644 database/migrations/2026_09_21_000000_create_staff_notifications_table.php create mode 100644 resources/views/layouts/partials/notification_inbox.blade.php 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..7248b2d3 100644 --- a/.env.example +++ b/.env.example @@ -48,3 +48,6 @@ MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" MYKAD_READER_PORT=17321 MYKAD_READER_ENABLED_CAWANGAN='' # IF NO CAWANGAN STATED, ALL CAWANGAN IS ENABLED + +# Shared with api_arrahn so the API can write staff inbox rows +INTERNAL_NOTIFICATION_SECRET= diff --git a/app/Http/Controllers/GadaianController.php b/app/Http/Controllers/GadaianController.php index b2074c28..49f10f80 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 === '') { @@ -278,6 +310,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']); } 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/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/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..1ca32a52 --- /dev/null +++ b/app/Services/NotificationService.php @@ -0,0 +1,335 @@ +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'] : []; + + $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(); + } + + /** + * @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, + ]; + } + + 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, + ]; + } + + /** + * 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/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..39d8b1f8 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/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/pembiayaan_online/operasi/batch.blade.php b/resources/views/pembiayaan_online/operasi/batch.blade.php index d9510777..32decfc6 100644 --- a/resources/views/pembiayaan_online/operasi/batch.blade.php +++ b/resources/views/pembiayaan_online/operasi/batch.blade.php @@ -62,7 +62,7 @@ @endif @if($approvedCount > 0) @endif
@@ -133,7 +133,7 @@ class="btn btn-dark btn-sm btn-paid" data-id="{{ $line['id'] }}" data-norujukan="{{ $line['norujukan'] ?? '' }}"> - PAID + Bukti + Dibayar + Bukti
@@ -247,7 +247,7 @@ @csrf diff --git a/resources/views/pembiayaan_online/pc/paid.blade.php b/resources/views/pembiayaan_online/pc/paid.blade.php index 3a90133e..8194ce70 100644 --- a/resources/views/pembiayaan_online/pc/paid.blade.php +++ b/resources/views/pembiayaan_online/pc/paid.blade.php @@ -69,7 +69,7 @@
Senarai Pembiayaan Online — PAID
@@ -168,7 +168,7 @@ function syncBlastButton() { var n = $('.row-check:checked').length; $('#btnMarkBlast').prop('disabled', n === 0); - $('#btnMarkBlast').text(n ? ('Sahkan Tally / Selesai (' + n + ')') : 'Sahkan Tally / Selesai'); + $('#btnMarkBlast').text(n ? ('Sahkan semua sebagai dibayar (' + n + ')') : 'Sahkan semua sebagai dibayar'); } $('#checkAll').on('change', function () { @@ -180,7 +180,7 @@ $('#btnMarkBlast').on('click', function () { if ($('.row-check:checked').length === 0) return; - if (!confirm('Sahkan tally untuk rekod dipilih? Rekod akan ditanda selesai dan WhatsApp notis akan dihantar.')) return; + if (!confirm('Sahkan semua sebagai dibayar untuk rekod dipilih? Rekod akan ditanda sebagai dibayar dan WhatsApp notis akan dihantar.')) return; $('#formMarkBlast').submit(); }); diff --git a/routes/web.php b/routes/web.php index 2176d6f5..25116d6d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -15,6 +15,8 @@ use Illuminate\Support\Facades\Storage; | */ +Route::post('/internal/notifications', ['as' => 'internal.notifications.store', 'uses' => 'InternalNotificationController@store']); + Route::get('/health', function () { return response()->json(['status' => 'ok']); }); @@ -190,6 +192,10 @@ Route::get('/penebusankhazanah/{norujukan}',['as'=>'penebusan.khazanah','uses' = Route::get('/lelongankhazanah/{norujukan}',['as'=>'lelongan.khazanah','uses' => 'KhazanahController@lelongan'])->middleware('auth','khazanah'); Route::get('/permohonan',['as'=>'permohonan.index','uses' => 'KhazanahController@PaparanPermohonan'])->middleware('auth','khazanah'); +Route::get('/notifications',['as'=>'notifications.index','uses'=>'NotificationController@index'])->middleware('auth'); +Route::post('/notifications/read-all',['as'=>'notifications.read.all','uses'=>'NotificationController@markAllAsRead'])->middleware('auth'); +Route::post('/notifications/{id}/read',['as'=>'notifications.read','uses'=>'NotificationController@markAsRead'])->middleware('auth'); + // Pembiayaan Online — PC Route::get('/pembiayaan-online/pc',['as'=>'pembiayaan.online.pc','uses'=>'PembiayaanOnlineController@index'])->middleware('auth','khazanah'); Route::put('/pembiayaan-online/pc/lines/{id}',['as'=>'pembiayaan.online.pc.update','uses'=>'PembiayaanOnlineController@updateLine'])->middleware('auth','khazanah'); -- 2.52.0 From 0e8f340b2973e8389c35fe6cb0a744ccad2cc4fa Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Mon, 21 Sep 2026 14:30:30 +0800 Subject: [PATCH 3/6] DONE: add notification for gadaian and pendahuluan --- app/Http/Controllers/KhazanahController.php | 36 +++ app/Http/Controllers/OperasiController.php | 43 +++- app/Services/NotificationService.php | 205 ++++++++++++++++++ .../dummy/borang_pengesahan_online.blade.php | 2 +- resources/views/gadaian/v2/index.blade.php | 3 +- resources/views/layouts/app.blade.php | 55 +---- .../pembiayaan_online/operasi/batch.blade.php | 40 +++- .../pembiayaan_online/pc/senarai.blade.php | 164 +++++++++++++- .../pembiayaan_online/pc/topup.blade.php | 4 +- .../print/borang_pengesahan_online.blade.php | 2 +- 10 files changed, 474 insertions(+), 80 deletions(-) diff --git a/app/Http/Controllers/KhazanahController.php b/app/Http/Controllers/KhazanahController.php index 6332ed02..dd6c3a19 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 @@ -1084,6 +1085,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 +1111,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 +2851,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/OperasiController.php b/app/Http/Controllers/OperasiController.php index 7b5f6491..c394cf34 100644 --- a/app/Http/Controllers/OperasiController.php +++ b/app/Http/Controllers/OperasiController.php @@ -9,6 +9,7 @@ use App\Events\StatusLiked; use App\Events\NotifikasiKelulusan; use App\Events\NotifikasiAnggota; use App\Events\Dashboard\KomoditiNotify; +use App\Services\NotificationService; use Illuminate\Support\Facades\Auth; use Illuminate\Http\Request; @@ -309,9 +310,48 @@ 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); } + /** + * 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 +360,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 +398,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(), diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index 1ca32a52..bb2953f6 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\StaffNotification; use App\User; +use Illuminate\Support\Facades\Log; class NotificationService { @@ -12,6 +13,10 @@ class NotificationService const TYPE_PEMBIAYAAN_BATCH_OPERASI = 'pembiayaan_online.batch_operasi'; const TYPE_PEMBIAYAAN_REJECTED_PC = 'pembiayaan_online.rejected_pc'; const TYPE_PEMBIAYAAN_PAID_PC = 'pembiayaan_online.paid_pc'; + const TYPE_GADAI_KELULUSAN = 'gadai.kelulusan'; + const TYPE_ALIRAN_TUNAI_PENDING = 'aliran_tunai.pending_khazanah'; + const TYPE_ALIRAN_TUNAI_DECIDED = 'aliran_tunai.decided_teller'; + const GADAI_KELULUSAN_MIN = 10000; /** * Create one inbox row per staff matching role + branch. @@ -70,6 +75,10 @@ class NotificationService $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'])) { @@ -93,6 +102,90 @@ class NotificationService 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 @@ -223,6 +316,78 @@ class NotificationService ]; } + 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_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, @@ -233,6 +398,46 @@ class NotificationService ]; } + /** + * @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. * 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..3ba68a2c 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.
@@ -267,7 +271,7 @@
@@ -316,5 +320,25 @@ $('#paidNorujukan').text($(this).data('norujukan')); $('#paidModal').modal('show'); }); + + $(document).on('submit', '.js-swal-confirm', function (e) { + e.preventDefault(); + var form = this; + Swal.fire({ + title: $(form).attr('data-title') || 'Pengesahan', + html: $(form).attr('data-html') || 'Teruskan tindakan ini?', + icon: 'question', + showCancelButton: true, + confirmButtonColor: '#3085d6', + cancelButtonColor: '#d33', + confirmButtonText: $(form).attr('data-confirm') || 'Ya', + cancelButtonText: 'Batal', + allowOutsideClick: false + }).then(function (result) { + if (result.isConfirmed) { + form.submit(); + } + }); + }); @endsection diff --git a/resources/views/pembiayaan_online/pc/senarai.blade.php b/resources/views/pembiayaan_online/pc/senarai.blade.php index 7c38edf7..a5b141bf 100644 --- a/resources/views/pembiayaan_online/pc/senarai.blade.php +++ b/resources/views/pembiayaan_online/pc/senarai.blade.php @@ -36,6 +36,17 @@ $lineRows = collect($lines); $verifiedCount = $lineRows->where('status', 'VERIFIED_PC')->count(); $unverifiedCount = $lineRows->count() - $verifiedCount; + $slotArr = is_array($slot) ? $slot : []; + $slotName = $slotArr['slot'] ?? 'BATCH1'; + $slotCutoff = $slotArr['cutoff'] ?? '11:00'; + $slotProses = $slotArr['proses'] ?? '12:30'; + $slotLewat = !empty($slotArr['lewat']); + $slotJadual = $slotArr['jadual'] ?? [ + ['slot' => 'BATCH1', 'label' => 'Batch 1', 'mula' => '00:00', 'cutoff' => '11:00', 'proses' => '12:30'], + ['slot' => 'BATCH2', 'label' => 'Batch 2', 'mula' => '11:00', 'cutoff' => '15:00', 'proses' => '16:30'], + ]; + $slotBakiMinit = (int) ($slotArr['baki_minit'] ?? 0); + $slotHampirTamat = !$slotLewat && $slotBakiMinit > 0 && $slotBakiMinit <= 30; @endphp @if($isClosed) @@ -59,6 +70,66 @@ @endif +
+
+
+ Jadual Slot Batch +
+ PC / PPC sila semak dan hantar senarai sebelum tamat slot. + Rekod yang dihantar selepas cutoff mungkin diproses pada slot atau hari berikutnya. +
+
+
+ @if($slotLewat) + Slot semasa: {{ $slotName }} (selepas cutoff {{ $slotCutoff }}) + @else + Slot semasa: {{ $slotName }} + · Hantar sebelum {{ $slotCutoff }} +
+ @if($slotBakiMinit > 0) + Baki masa: {{ intdiv($slotBakiMinit, 60) }}j {{ $slotBakiMinit % 60 }}m + @endif +
+ @endif +
+
+
+ + + + + + + + + + @foreach($slotJadual as $row) + @php + $rowSlot = $row['slot'] ?? ''; + $isCurrent = $rowSlot === $slotName && !$slotLewat; + $isDone = $slotLewat + || ($slotName === 'BATCH2' && $rowSlot === 'BATCH1'); + @endphp + + + + + + @endforeach + +
SlotHantar senarai sebelumProses Operasi
+ {{ $row['label'] ?? $rowSlot }} + @if($isCurrent) + Semasa + @elseif($isDone) + Tamat + @endif + {{ $row['cutoff'] ?? '-' }}~ {{ $row['proses'] ?? '-' }}
+
+
+
@@ -85,7 +156,7 @@
-
Queue PC
+
Senarai Pelanggan menunggu semakan

{{ count($lines) }} rekod

{{ $verifiedCount }} sudah sah @@ -96,11 +167,19 @@
-
+
-
Slot Cadangan
-

{{ $slot['slot'] ?? '-' }}

- {{ $slot['nota'] ?? '' }} +
Slot Semasa
+

{{ $slotName }}

+ + @if($slotLewat) + Cutoff {{ $slotCutoff }} sudah tamat
+ Masih boleh hantar; proses mungkin esok + @else + Hantar sebelum {{ $slotCutoff }} + · Proses ~{{ $slotProses }} + @endif +
@@ -115,10 +194,15 @@

Semak setiap rekod satu-satu (maklumat pelanggan, bank, amaun). Hanya rekod berstatus SAH boleh dipilih dan dihantar ke Operasi. + @if($slotLewat) + Cutoff {{ $slotName }} ({{ $slotCutoff }}) sudah tamat — hantar sekarang jika perlu; Operasi mungkin proses esok. + @else + Sila hantar senarai {{ $slotName }} sebelum {{ $slotCutoff }}. + @endif

@csrf - +
@@ -440,17 +524,46 @@ }); $('#btnSubmitBatch').on('click', function () { - if ($('.row-check:not(:disabled):checked').length === 0) return; - if (!confirm('Hantar rekod yang sudah disahkan ke Operasi?')) return; - $('#formSubmitBatch').submit(); + var n = $('.row-check:not(:disabled):checked').length; + if (n === 0) return; + Swal.fire({ + title: 'Hantar Batch ke Operasi?', + html: n + ' rekod yang sudah SAH akan dihantar sebagai {{ $slotName }}.' + + '
Sila hantar sebelum cutoff {{ $slotCutoff }}.', + icon: 'question', + showCancelButton: true, + confirmButtonColor: '#3085d6', + cancelButtonColor: '#d33', + confirmButtonText: 'Ya, Hantar', + cancelButtonText: 'Batal', + allowOutsideClick: false + }).then(function (result) { + if (result.isConfirmed) { + $('#formSubmitBatch').submit(); + } + }); }); $('.btn-unverify').on('click', function () { var id = $(this).data('id'); var sag = $(this).data('norujukan') || ''; - if (!confirm('Batal semakan ' + sag + '? Rekod ini tidak boleh dihantar sehingga disemak semula.')) return; - $('#formUnverify').attr('action', "{{ url('/pembiayaan-online/pc/lines') }}/" + id + "/unverify"); - $('#formUnverify').submit(); + Swal.fire({ + title: 'Batal Semakan?', + html: 'Batal semakan ' + (sag || 'rekod ini') + '?' + + '
Rekod ini tidak boleh dihantar sehingga disemak semula.', + icon: 'warning', + showCancelButton: true, + confirmButtonColor: '#3085d6', + cancelButtonColor: '#d33', + confirmButtonText: 'Ya, Batal', + cancelButtonText: 'Tutup', + allowOutsideClick: false + }).then(function (result) { + if (result.isConfirmed) { + $('#formUnverify').attr('action', "{{ url('/pembiayaan-online/pc/lines') }}/" + id + "/unverify"); + $('#formUnverify').submit(); + } + }); }); $('.btn-verify').on('click', function () { @@ -513,5 +626,32 @@ $('#editKodbank').on('change', function () { $('#editNamabank').val($('#editKodbank option:selected').data('nama') || ''); }); + + function pad2(n) { + return n < 10 ? '0' + n : String(n); + } + + function tickSlotCountdown() { + var el = document.getElementById('slotCountdown'); + if (!el) return; + if (el.getAttribute('data-lewat') === '1') return; + var cutoff = el.getAttribute('data-cutoff') || ''; + var parts = cutoff.split(':'); + if (parts.length < 2) return; + var now = new Date(); + var end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), parseInt(parts[0], 10), parseInt(parts[1], 10), 0); + var diff = end - now; + if (diff <= 0) { + el.textContent = 'Cutoff telah tamat — sila muat semula halaman'; + return; + } + var h = Math.floor(diff / 3600000); + var m = Math.floor((diff % 3600000) / 60000); + var s = Math.floor((diff % 60000) / 1000); + el.textContent = 'Baki masa: ' + h + 'j ' + pad2(m) + 'm ' + pad2(s) + 's'; + } + + tickSlotCountdown(); + setInterval(tickSlotCountdown, 1000); @endsection diff --git a/resources/views/pembiayaan_online/pc/topup.blade.php b/resources/views/pembiayaan_online/pc/topup.blade.php index 18a5fec9..df12dda8 100644 --- a/resources/views/pembiayaan_online/pc/topup.blade.php +++ b/resources/views/pembiayaan_online/pc/topup.blade.php @@ -131,7 +131,7 @@ -
+ {{--
Tutup Modal Online (EOD)
@@ -153,7 +153,7 @@ @endif
-
+ --}}
diff --git a/resources/views/print/borang_pengesahan_online.blade.php b/resources/views/print/borang_pengesahan_online.blade.php index affd244f..d4d19d76 100644 --- a/resources/views/print/borang_pengesahan_online.blade.php +++ b/resources/views/print/borang_pengesahan_online.blade.php @@ -195,7 +195,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.

-- 2.52.0 From f8e097523a703b7a61163c2bdb20e19dd62b4593 Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Tue, 22 Sep 2026 08:05:57 +0800 Subject: [PATCH 4/6] DONE: add 30 minutes banner popup when pc not submitted the customer list, disable terima button when the confirmation form is not yet printed on teller; WIP: auto approve from operasi for gadai between 10k - 45k, wire up with whatsapp template message --- app/Http/Controllers/GadaianController.php | 102 ++++- .../PembiayaanOnlineController.php | 46 +++ resources/views/gadaian/v2/index.blade.php | 78 +++- resources/views/layouts/app.blade.php | 1 + .../partials/batch_cutoff_banner.blade.php | 355 ++++++++++++++++++ .../pembiayaan_online/operasi/batch.blade.php | 4 +- .../pembiayaan_online/pc/batch.blade.php | 4 +- .../pembiayaan_online/pc/senarai.blade.php | 10 +- routes/web.php | 1 + 9 files changed, 577 insertions(+), 24 deletions(-) create mode 100644 resources/views/layouts/partials/batch_cutoff_banner.blade.php diff --git a/app/Http/Controllers/GadaianController.php b/app/Http/Controllers/GadaianController.php index 49f10f80..95423895 100644 --- a/app/Http/Controllers/GadaianController.php +++ b/app/Http/Controllers/GadaianController.php @@ -93,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) { @@ -120,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'], @@ -144,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, @@ -156,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) @@ -223,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 @@ -274,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() @@ -320,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.'); @@ -371,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); } @@ -501,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/PembiayaanOnlineController.php b/app/Http/Controllers/PembiayaanOnlineController.php index 43fbf37a..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. */ diff --git a/resources/views/gadaian/v2/index.blade.php b/resources/views/gadaian/v2/index.blade.php index 3ba68a2c..57a5ae45 100644 --- a/resources/views/gadaian/v2/index.blade.php +++ b/resources/views/gadaian/v2/index.blade.php @@ -305,6 +305,9 @@ Batal +
@@ -413,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(); @@ -424,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', @@ -1301,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) { @@ -1311,6 +1367,7 @@ $('#bank_online_section').hide(); $('#cetak_borang_online').hide(); $('#cara_terima_tunai').prop('checked', true); + syncTerimaButton(); } } @@ -1325,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(); @@ -1367,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() { @@ -1523,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!', @@ -1532,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/layouts/app.blade.php b/resources/views/layouts/app.blade.php index c532c7a6..d7d138b2 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -401,6 +401,7 @@ @else @endif + @include('layouts.partials.batch_cutoff_banner') @endif
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/pembiayaan_online/operasi/batch.blade.php b/resources/views/pembiayaan_online/operasi/batch.blade.php index 5778f8d1..402f70c7 100644 --- a/resources/views/pembiayaan_online/operasi/batch.blade.php +++ b/resources/views/pembiayaan_online/operasi/batch.blade.php @@ -165,11 +165,11 @@
- - - - - - - - - - - - - - @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/routes/web.php b/routes/web.php index 14dcc76a..fe0ddba8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -176,6 +176,7 @@ Route::get('/khazanahtebus',['as'=>'khazanah.tebus','uses'=>'KhazanahController@ Route::get('/kelulusan','KhazanahController@kelulusan')->middleware('auth','khazanah'); Route::get('/kelulusanrekodlepas',['as'=>'khazanah.rekodlepas','uses'=>'KhazanahController@kelulusanrekodlepas'])->middleware('auth','khazanah'); Route::get('/kelulusanrekodlepasoperasi',['as'=>'operasi.rekodlepas','uses'=>'OperasiController@kelulusanrekodlepas'])->middleware('auth','operasi'); +Route::get('/kelulusanrekodlepasoperasi/data',['as'=>'operasi.rekodlepas.data','uses'=>'OperasiController@kelulusanrekodlepasData'])->middleware('auth','operasi'); Route::get('/pendahuluan',['as'=>'khazanah.pendahulu','uses'=>'KhazanahController@pendahuluan'])->middleware('auth','khazanah'); Route::get('/serahan',['as'=>'khazanah.serahan','uses'=>'KhazanahController@serahan'])->middleware('auth','khazanah'); Route::get('/laporanscanbarcode',['as'=>'laporanscanbarcode','uses'=>'KhazanahController@LaporanSemakBarcode'])->middleware('auth','khazanah'); -- 2.52.0