DONE: add notification table on kaunter, notifies pc and operasi for pembiayaan online; WIP: notification for gadaian above 10000

This commit is contained in:
ISMAIL MASSERAN
2026-09-21 09:24:31 +08:00
parent 370b320d14
commit d0f4e04f96
20 changed files with 870 additions and 156 deletions
+3 -1
View File
@@ -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
MYKAD_READER_ENABLED_CAWANGAN='' # IF NO CAWANGAN STATED, ALL CAWANGAN IS ENABLED
INTERNAL_NOTIFICATION_SECRET=erahn-local-notification-secret
+3
View File
@@ -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=
@@ -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']);
}
-23
View File
@@ -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();
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers;
use App\Services\NotificationService;
use Illuminate\Http\Request;
class InternalNotificationController extends Controller
{
public function store(Request $request, NotificationService $notifications)
{
$expected = (string) config('notifications.internal_secret');
$provided = (string) $request->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);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
use App\Services\NotificationService;
use Illuminate\Support\Facades\Auth;
class NotificationController extends Controller
{
public function __construct()
{
$this->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,
]);
}
}
+1 -1
View File
@@ -12,6 +12,6 @@ class VerifyCsrfToken extends Middleware
* @var array
*/
protected $except = [
//
'internal/notifications',
];
}
+335
View File
@@ -0,0 +1,335 @@
<?php
namespace App\Services;
use App\StaffNotification;
use App\User;
class NotificationService
{
const TYPE_PEMBIAYAAN_PENDING_PC = 'pembiayaan_online.pending_pc';
const TYPE_MODAL_PENDING_OPERASI = 'modal_online.pending_operasi';
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';
/**
* Create one inbox row per staff matching role + branch.
*
* @param string $role
* @param string $kodcaw
* @param array $payload
* @return \Illuminate\Support\Collection
*/
public function notifyRoleAtCawangan($role, $kodcaw, array $payload)
{
$users = User::query()
->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(),
];
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class StaffNotification extends Model
{
protected $table = 'staff_notifications';
protected $fillable = [
'user_id',
'cawangan',
'role',
'type',
'title',
'body',
'url',
'data',
'read_at',
];
protected $casts = [
'data' => '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,
];
}
}
+5
View File
@@ -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);
+5
View File
@@ -0,0 +1,5 @@
<?php
return [
'internal_secret' => env('INTERNAL_NOTIFICATION_SECRET', ''),
];
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateStaffNotificationsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('staff_notifications', function (Blueprint $table) {
$table->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');
}
}
@@ -219,78 +219,70 @@
<div class="card-body text-center">
<div class="row form-group justify-content-center">
<div class="col-3 mx-1">
<div class="row">
<a href="{{ url('dashboard') }}" disabled
class="btn btn-info btn-lg btn-block mb-3">Dashboard</a>
</div>
<div class="row">
<a href="{{ url('kakitangan') }}"
class="btn btn-info btn-lg btn-block mb-3">Kakitangan</a>
</div>
<div class="row">
<a href="{{ url('kelulusan') }}" id="btnkelulusan"
class="btn btn-info btn-lg btn-block mb-3">Kelulusan</a>
</div>
{{-- <div class="row">
<a href="{{ url('permohonan') }}" class="btn btn-danger btn-lg btn-block mb-3">Permohonan</a>
</div> --}}
<div class="row">
<a href="#" data-target="#denominasiShowModal" data-backdrop="static" data-keyboard="false"
data-toggle="modal" class="btn btn-info btn-lg btn-block mb-3">Denominasi</a>
<div class="col-4 px-2">
<a href="{{ url('dashboard') }}" disabled
class="btn btn-info btn-lg btn-block mb-3">Dashboard</a>
</div>
<div class="row">
<a href="{{ route('khazanah.harga_emas') }}"
class="btn btn-info btn-lg btn-block mb-3">Harga Emas</a>
</div>
</div>
<div class="col-3 mx-1">
<div class="row">
<div class="col-4 px-2">
<a href="{{ route('khazanah.pelanggan') }}"
class="btn btn-info btn-lg btn-block mb-3">Pelanggan</a>
</div>
<div class="row">
<div class="col-4 px-2">
<a href="{{ route('lelong') }}" class="btn btn-info btn-lg btn-block mb-3">Lelong</a>
</div>
<div class="col-4 px-2">
<a href="{{ url('kakitangan') }}"
class="btn btn-info btn-lg btn-block mb-3">Kakitangan</a>
</div>
<div class="col-4 px-2">
<a href="{{ route('laporan') }}" class="btn btn-info btn-lg btn-block mb-3">Laporan</a>
</div>
<div class="row">
<div class="col-4 px-2">
<a href="{{ url('panjar') }}" class="btn btn-info btn-lg btn-block mb-3">Perbelanjaan</a>
</div>
<div class="col-4 px-2">
<a href="{{ url('kelulusan') }}" id="btnkelulusan"
class="btn btn-info btn-lg btn-block mb-3">Kelulusan</a>
</div>
<div class="col-4 px-2">
<a href="{{ url('menu') }}"
id="btn-khazanah"
class="btn {{ $countkhazanah > 0 ? 'btn-danger' : 'btn-info' }} btn-lg btn-block mb-3">Khazanah</a>
</div>
<div class="row">
<div class="col-4 px-2">
<a href="{{ url('alirantunai') }}" class="btn btn-info btn-lg btn-block mb-3">Aliran
Tunai</a>
</div>
<div class="col-4 px-2">
<a href="#" data-target="#denominasiShowModal" data-backdrop="static" data-keyboard="false"
data-toggle="modal" class="btn btn-info btn-lg btn-block mb-3">Denominasi</a>
</div>
<div class="col-4 px-2">
<a href="{{ url('pendahuluanserahan') }}"
class="btn btn-info btn-lg btn-block mb-3">Pendahuluan - Serahan</a>
</div>
<div class="col-4 px-2">
<a href="{{ route('khazanah.bajet') }}" class="btn btn-info btn-lg btn-block mb-3">Bajet</a>
</div>
<div class="col-4 px-2">
<a href="{{ route('khazanah.harga_emas') }}"
class="btn btn-info btn-lg btn-block mb-3">Harga Emas</a>
</div>
@if(!empty(($cawangan_info['features'] ?? [])['pembiayaan_online']))
<div class="row">
<div class="col-4 px-2">
<a href="{{ route('pembiayaan.online.pc') }}"
class="btn btn-info btn-lg btn-block mb-3">Pembiayaan Online</a>
</div>
<div class="col-4 px-2">
<a href="{{ route('pembiayaan.online.pc.topup') }}"
class="btn btn-info btn-lg btn-block mb-3">Had / Modal Online</a>
</div>
@endif
<div class="row">
<div class="col-4 px-2">
<a href="{{ route('khazanah.indexwakil') }}"
class="btn btn-info btn-lg btn-block mb-3">Surat Wakil</a>
</div>
</div>
<div class="col-3 mx-1">
{{-- <div class="row">
<a href="#" class="btn btn-danger btn-lg btn-block mb-3">Capaian</a>
</div> --}}
<div class="row">
<a href="{{ route('lelong') }}" class="btn btn-info btn-lg btn-block mb-3">Lelong</a>
</div>
<div class="row">
<a href="{{ url('panjar') }}" class="btn btn-info btn-lg btn-block mb-3">Perbelanjaan</a>
</div>
<div class="row">
<a href="{{ url('alirantunai') }}" class="btn btn-info btn-lg btn-block mb-3">Aliran
Tunai</a>
</div>
<div class="row">
<a href="{{ route('khazanah.bajet') }}" class="btn btn-info btn-lg btn-block mb-3">Bajet</a>
</div>
</div>
</div>
</div>
+4 -24
View File
@@ -214,14 +214,16 @@
v-pre
style="color: {{ isset($cawangan_info['details_caw']) && $cawangan_info['details_caw'] == 'Putatan 2' ? 'black' : '#1abc9c' }};">
<i id="bell" class="fa fa-bell"></i>
<span id="staff-notification-badge" class="staff-notification-badge"></span>
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">
<div id="divbiasa">
<div class="dropdown-menu dropdown-menu-right staff-notification-menu" aria-labelledby="navbarDropdown">
<div id="divbiasa" class="d-none">
<a class="dropdown-item" id="linkz" href="#">
Tiada Data
</a>
</div>
@include('layouts.partials.notification_inbox')
</div>
</li>
@@ -448,28 +450,6 @@
@endif
@endif
@if(session()->has('iklan') && session()->get('iklan') == 0)
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="position: absolute; top: 1rem; right: 1rem; width:100%;z-index:1;" data-autohide="false">
<div class="toast-header">
<i class="text-danger fa-2x fa fa-exclamation-triangle mr-2"></i>
<strong class="mr-auto text-danger fa-2x">Amaran</strong>
{{-- <small>11 mins ago</small> --}}
<button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="toast-body">
<b style="font-size:18px;">sila tukar kata laluan asal dengan segera!</b>
</div>
</div>
<script>
$('.toast').toast('show');
</script>
@endif
<main class="py-4">
@yield('content')
</main>
@@ -164,29 +164,6 @@
</div>
</nav>
@if(session()->has('iklan') && session()->get('iklan') == 0)
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="position: absolute; top: 1rem; right: 1rem; width:100%;z-index:1;" data-autohide="false">
<div class="toast-header">
<i class="text-danger fa-2x fa fa-exclamation-triangle mr-2"></i>
<strong class="mr-auto text-danger fa-2x">Amaran</strong>
{{-- <small>11 mins ago</small> --}}
<button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="toast-body">
<b style="font-size:18px;">sila tukar kata laluan asal dengan segera!</b>
</div>
</div>
<script>
$('.toast').toast('show');
</script>
@endif
<main class="py-4">
@yield('content')
</main>
+5 -25
View File
@@ -111,14 +111,17 @@
<li class="nav-item dropdown" style="margin-right:10px;">
<a id="navbarDropdown" class="nav-link " href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" v-pre>
<i id="bell" class="fa fa-bell"></i>
<span id="staff-notification-badge" class="staff-notification-badge"></span>
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">
<div id="divbiasa">
<div class="dropdown-menu dropdown-menu-right staff-notification-menu" aria-labelledby="navbarDropdown">
<div id="divbiasa" class="d-none">
<a class="dropdown-item" id="gadailinkz" href="#">
Tiada Notifikasi
</a>
<a class="dropdown-item" id="linkz" href="#"></a>
</div>
@include('layouts.partials.notification_inbox')
</div>
</li>
@@ -254,29 +257,6 @@
</div>
</nav>
@if(session()->has('iklan') && session()->get('iklan') == 0)
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="position: absolute; top: 1rem; right: 1rem; width:100%;z-index:1;" data-autohide="false">
<div class="toast-header">
<i class="text-danger fa-2x fa fa-exclamation-triangle mr-2"></i>
<strong class="mr-auto text-danger fa-2x">Amaran</strong>
{{-- <small>11 mins ago</small> --}}
<button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="toast-body">
<b style="font-size:18px;">sila tukar kata laluan asal dengan segera!</b>
</div>
</div>
<script>
$('.toast').toast('show');
</script>
@endif
<main class="py-4">
@yield('content')
</main>
@@ -0,0 +1,244 @@
<style>
.staff-notification-menu {
min-width: 340px;
max-width: 380px;
padding: 0;
}
.staff-notification-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid #eee;
font-weight: 600;
font-size: 13px;
}
.staff-notification-header button {
border: 0;
background: none;
color: #1abc9c;
font-size: 12px;
padding: 0;
cursor: pointer;
}
.staff-notification-header button:disabled {
color: #bbb;
cursor: default;
}
.staff-notification-list {
max-height: 360px;
overflow-y: auto;
}
.staff-notification-item {
display: block;
padding: 10px 14px;
border-bottom: 1px solid #f3f3f3;
white-space: normal;
color: #333;
text-decoration: none;
}
.staff-notification-item:hover {
background: #f8f9fa;
text-decoration: none;
color: #333;
}
.staff-notification-item.is-unread {
background: #eafaf6;
}
.staff-notification-item .sn-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 2px;
}
.staff-notification-item.is-unread .sn-title {
font-weight: 700;
}
.staff-notification-item .sn-body,
.staff-notification-item .sn-time {
font-size: 12px;
color: #666;
margin: 0;
}
.staff-notification-empty {
padding: 18px 14px;
text-align: center;
color: #888;
font-size: 13px;
}
.staff-notification-badge {
position: absolute;
top: 2px;
right: 0;
font-size: 10px;
min-width: 16px;
padding: 2px 5px;
border-radius: 10px;
background: #e74c3c;
color: #fff;
line-height: 1.2;
display: none;
}
.staff-notification-toggle {
position: relative;
display: inline-block;
}
a.nav-link #staff-notification-badge {
position: absolute;
top: 2px;
right: -4px;
}
li.nav-item.dropdown > a.nav-link {
position: relative;
}
</style>
<div id="staff-notification-inbox">
<div class="staff-notification-header">
<span>Notifikasi</span>
<button type="button" id="staff-notification-mark-all" disabled>Tanda semua dibaca</button>
</div>
<div id="staff-notification-list" class="staff-notification-list">
<div class="staff-notification-empty">Memuatkan...</div>
</div>
</div>
<script>
(function () {
var listUrl = @json(route('notifications.index'));
var markAllUrl = @json(route('notifications.read.all'));
var markOneUrlTemplate = @json(url('/notifications/__ID__/read'));
var csrf = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrf ? csrf.getAttribute('content') : '';
function markOneUrl(id) {
return markOneUrlTemplate.replace('__ID__', id);
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function setBadge(count) {
var badge = document.getElementById('staff-notification-badge');
var bell = document.getElementById('bell');
if (!badge) {
return;
}
if (count > 0) {
badge.style.display = 'inline-block';
badge.textContent = count > 99 ? '99+' : String(count);
if (bell) {
bell.style.color = '#e74c3c';
}
} else {
badge.style.display = 'none';
badge.textContent = '';
if (bell) {
bell.style.color = '';
}
}
var markAll = document.getElementById('staff-notification-mark-all');
if (markAll) {
markAll.disabled = count < 1;
}
}
function render(payload) {
var wrap = document.getElementById('staff-notification-list');
if (!wrap) {
return;
}
var items = payload.notifications || [];
setBadge(payload.unread_count || 0);
if (!items.length) {
wrap.innerHTML = '<div class="staff-notification-empty">Tiada notifikasi</div>';
return;
}
wrap.innerHTML = items.map(function (item) {
var unread = !item.read_at;
return (
'<a class="dropdown-item staff-notification-item' + (unread ? ' is-unread' : '') + '" href="' + escapeHtml(item.url || '#') + '" data-id="' + item.id + '">' +
'<div class="sn-title">' + escapeHtml(item.title) + '</div>' +
(item.body ? '<p class="sn-body">' + escapeHtml(item.body) + '</p>' : '') +
'<p class="sn-time">' + escapeHtml(item.created_at_human || '') + '</p>' +
'</a>'
);
}).join('');
}
function loadInbox() {
return fetch(listUrl, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin'
}).then(function (res) {
if (!res.ok) {
throw new Error('Gagal muat notifikasi');
}
return res.json();
}).then(render).catch(function () {
var wrap = document.getElementById('staff-notification-list');
if (wrap) {
wrap.innerHTML = '<div class="staff-notification-empty">Tidak dapat memuatkan notifikasi</div>';
}
});
}
function postJson(url) {
return fetch(url, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrfToken
},
credentials: 'same-origin',
body: '{}'
}).then(function (res) {
if (!res.ok) {
throw new Error('Gagal kemaskini notifikasi');
}
return res.json();
});
}
document.addEventListener('click', function (event) {
var target = event.target;
if (target && target.nodeType !== 1) {
target = target.parentElement;
}
if (!target || !target.closest) {
return;
}
var item = target.closest('.staff-notification-item');
if (!item) {
return;
}
var id = item.getAttribute('data-id');
if (!id) {
return;
}
postJson(markOneUrl(id)).catch(function () {});
});
var markAll = document.getElementById('staff-notification-mark-all');
if (markAll) {
markAll.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
postJson(markAllUrl).then(function () {
loadInbox();
}).catch(function () {});
});
}
loadInbox();
setInterval(loadInbox, 30000);
})();
</script>
@@ -62,7 +62,7 @@
@endif
@if($approvedCount > 0)
<button type="button" class="btn btn-dark btn-sm" data-toggle="modal" data-target="#paidBatchModal">
Tanda PAID Batch ({{ $approvedCount }})
Tanda semua sebagai dibayar ({{ $approvedCount }})
</button>
@endif
</div>
@@ -133,7 +133,7 @@
class="btn btn-dark btn-sm btn-paid"
data-id="{{ $line['id'] }}"
data-norujukan="{{ $line['norujukan'] ?? '' }}">
PAID + Bukti
Dibayar + Bukti
</button>
<button type="button"
class="btn btn-outline-danger btn-sm btn-reject"
@@ -211,7 +211,7 @@
<input type="hidden" name="batch_id" value="{{ $batch['id'] }}">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Tanda PAID — <span id="paidNorujukan"></span></h5>
<h5 class="modal-title">Tanda sebagai dibayar — <span id="paidNorujukan"></span></h5>
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
</div>
<div class="modal-body">
@@ -231,7 +231,7 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Batal</button>
<button type="submit" class="btn btn-dark">Simpan PAID</button>
<button type="submit" class="btn btn-dark">Simpan sebagai dibayar</button>
</div>
</div>
</form>
@@ -247,7 +247,7 @@
@csrf
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Tanda PAID Semua APPROVED ({{ $approvedCount }})</h5>
<h5 class="modal-title">Tanda semua sebagai dibayar ({{ $approvedCount }})</h5>
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
</div>
<div class="modal-body">
@@ -267,7 +267,7 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Batal</button>
<button type="submit" class="btn btn-dark">Simpan PAID Batch</button>
<button type="submit" class="btn btn-dark">Simpan semua sebagai dibayar</button>
</div>
</div>
</form>
@@ -69,7 +69,7 @@
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
<h5 class="mb-0">Senarai Pembiayaan Online — PAID</h5>
<button type="button" class="btn btn-success btn-sm mt-2 mt-md-0" id="btnMarkBlast" disabled>
Sahkan Tally / Selesai
Sahkan semua sebagai dibayar
</button>
</div>
<div class="card-body">
@@ -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();
});
</script>
+6
View File
@@ -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');