From 95be00c9d1d2db95244690449c8e2fbe43e6141d Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sat, 1 Aug 2026 11:36:20 +0700 Subject: [PATCH] feat: add push notification functionality and service worker - Implemented push notifications with a service worker (sw.ts) to handle caching and notifications. - Added API routes for push subscription and notification management in routes/api.php. - Created NotificationTest to validate notification service functionality and ensure correct notifications are sent based on user roles and actions. - Updated Permissions component to manage notification permissions and subscriptions. - Enhanced CategoryIndex component to highlight categories based on notifications. - Excluded service worker from TypeScript compilation in tsconfig.json. - Updated Vite configuration to include service worker in the build process. --- .../Api/NotificationController.php | 70 ++ .../Api/PushSubscriptionController.php | 35 + .../Settings/PermissionController.php | 4 +- .../Api/DestroyPushSubscriptionRequest.php | 20 + .../Api/StorePushSubscriptionRequest.php | 23 + app/Http/Requests/PaginatedRequest.php | 9 +- app/Models/Payroll.php | 8 + app/Models/User.php | 10 +- app/Notifications/WebPushNotification.php | 33 + .../Admin/Finance/CashAccountService.php | 50 +- .../Admin/Finance/EmployeeAdvanceService.php | 35 +- app/Services/Admin/Finance/ExpenseService.php | 36 +- .../Admin/Finance/PayrollPeriodService.php | 25 +- app/Services/Admin/HR/AttendanceService.php | 15 + app/Services/Admin/HR/LeaveRequestService.php | 35 +- app/Services/Admin/Master/ProductService.php | 23 +- app/Services/NotificationService.php | 31 + app/Services/PushSubscriptionService.php | 33 + composer.json | 1 + composer.lock | 495 ++++++++-- config/webpush.php | 46 + ...163706_create_push_subscriptions_table.php | 37 +- .../js/components/app-sidebar-header.tsx | 4 +- resources/js/components/notification-bell.tsx | 266 ++++++ resources/js/hooks/use-push-notification.ts | 149 +++ .../js/pages/admin/master/category/index.tsx | 23 +- resources/js/pages/settings/permissions.tsx | 203 ++-- resources/js/sw.ts | 106 +++ routes/api.php | 30 + tests/Feature/Admin/NotificationTest.php | 888 ++++++++++++++++++ tsconfig.json | 3 + vite.config.ts | 38 +- 32 files changed, 2574 insertions(+), 210 deletions(-) create mode 100644 app/Http/Controllers/Api/NotificationController.php create mode 100644 app/Http/Controllers/Api/PushSubscriptionController.php create mode 100644 app/Http/Requests/Api/DestroyPushSubscriptionRequest.php create mode 100644 app/Http/Requests/Api/StorePushSubscriptionRequest.php create mode 100644 app/Notifications/WebPushNotification.php create mode 100644 app/Services/NotificationService.php create mode 100644 app/Services/PushSubscriptionService.php create mode 100644 config/webpush.php create mode 100644 resources/js/components/notification-bell.tsx create mode 100644 resources/js/hooks/use-push-notification.ts create mode 100644 resources/js/sw.ts create mode 100644 tests/Feature/Admin/NotificationTest.php diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php new file mode 100644 index 0000000..b27afe7 --- /dev/null +++ b/app/Http/Controllers/Api/NotificationController.php @@ -0,0 +1,70 @@ +user() + ->notifications() + ->orderBy('created_at', 'desc') + ->limit(20) + ->get(); + + return response()->json($notifications); + } + + public function unreadCount(Request $request): JsonResponse + { + $count = $request->user() + ->notifications() + ->where('is_read', false) + ->count(); + + return response()->json(['count' => $count]); + } + + public function markAsRead(Request $request, AppNotification $notification): JsonResponse + { + if ($notification->user_id !== $request->user()->id) { + return response()->json(['message' => 'Unauthorized'], 403); + } + + $notification->update([ + 'is_read' => true, + 'read_at' => now(), + ]); + + return response()->json(['message' => 'Notification marked as read.']); + } + + public function destroy(Request $request, AppNotification $notification): JsonResponse + { + if ($notification->user_id !== $request->user()->id) { + return response()->json(['message' => 'Unauthorized'], 403); + } + + $notification->delete(); + + return response()->json(['message' => 'Notification deleted.']); + } + + public function markAllAsRead(Request $request): JsonResponse + { + $request->user() + ->notifications() + ->where('is_read', false) + ->update([ + 'is_read' => true, + 'read_at' => now(), + ]); + + return response()->json(['message' => 'All notifications marked as read.']); + } +} diff --git a/app/Http/Controllers/Api/PushSubscriptionController.php b/app/Http/Controllers/Api/PushSubscriptionController.php new file mode 100644 index 0000000..5e426f4 --- /dev/null +++ b/app/Http/Controllers/Api/PushSubscriptionController.php @@ -0,0 +1,35 @@ +service->store( + $request->user(), + $request->endpoint, + $request->public_key, + $request->auth_token, + $request->content_encoding, + ); + } + + public function destroy(DestroyPushSubscriptionRequest $request): JsonResponse + { + return $this->service->destroy( + $request->user(), + $request->endpoint, + ); + } +} diff --git a/app/Http/Controllers/Settings/PermissionController.php b/app/Http/Controllers/Settings/PermissionController.php index 4ad3cfa..db202a5 100644 --- a/app/Http/Controllers/Settings/PermissionController.php +++ b/app/Http/Controllers/Settings/PermissionController.php @@ -10,6 +10,8 @@ class PermissionController extends Controller { public function edit(): Response { - return Inertia::render('settings/permissions'); + return Inertia::render('settings/permissions', [ + 'vapidPublicKey' => config('webpush.vapid.public_key'), + ]); } } diff --git a/app/Http/Requests/Api/DestroyPushSubscriptionRequest.php b/app/Http/Requests/Api/DestroyPushSubscriptionRequest.php new file mode 100644 index 0000000..0b38a75 --- /dev/null +++ b/app/Http/Requests/Api/DestroyPushSubscriptionRequest.php @@ -0,0 +1,20 @@ + ['required', 'url', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/Api/StorePushSubscriptionRequest.php b/app/Http/Requests/Api/StorePushSubscriptionRequest.php new file mode 100644 index 0000000..eadd793 --- /dev/null +++ b/app/Http/Requests/Api/StorePushSubscriptionRequest.php @@ -0,0 +1,23 @@ + ['required', 'url', 'max:500'], + 'public_key' => ['nullable', 'string', 'max:255'], + 'auth_token' => ['nullable', 'string', 'max:255'], + 'content_encoding' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/PaginatedRequest.php b/app/Http/Requests/PaginatedRequest.php index 931d442..73fb021 100644 --- a/app/Http/Requests/PaginatedRequest.php +++ b/app/Http/Requests/PaginatedRequest.php @@ -14,10 +14,11 @@ public function authorize(): bool public function rules(): array { return [ - 'per_page' => 'nullable|integer|in:25,50,100,999999', - 'search' => 'nullable|string|max:255', - 'sort' => 'nullable|string', - 'direction' => 'nullable|string|in:asc,desc', + 'per_page' => ['nullable', 'integer', 'in:25,50,100,999999'], + 'search' => ['nullable', 'string', 'max:255'], + 'sort' => ['nullable', 'string'], + 'direction' => ['nullable', 'string', 'in:asc,desc'], + 'highlight' => ['nullable', 'integer'], ]; } diff --git a/app/Models/Payroll.php b/app/Models/Payroll.php index 0010da9..dad12ca 100644 --- a/app/Models/Payroll.php +++ b/app/Models/Payroll.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -29,6 +30,13 @@ protected function casts(): array ]; } + protected function formattedAmount(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->total_amount, 0, ',', '.'), + ); + } + #[Scope] protected function cancelled(Builder $query): void { diff --git a/app/Models/User.php b/app/Models/User.php index 13b46e9..06d436d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -14,13 +14,14 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; +use NotificationChannels\WebPush\HasPushSubscriptions; use Spatie\Permission\Traits\HasRoles; #[Guarded(['id'])] #[Appends(['full_name'])] class User extends Authenticatable { - use HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable; + use HasFactory, HasPushSubscriptions, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable; protected function casts(): array { @@ -49,7 +50,7 @@ protected function name(): Attribute protected function fullName(): Attribute { return Attribute::make( - get: fn () => $this->userProfile?->full_name ?? '', + get: fn () => $this->userProfile?->full_name ?: $this->username, ); } @@ -158,11 +159,6 @@ public function purchaseItems(): HasMany return $this->hasMany(PurchaseItem::class); } - public function pushSubscriptions(): HasMany - { - return $this->hasMany(PushSubscription::class); - } - public function rejections(): HasMany { return $this->hasMany(Rejection::class, 'rejected_by_id'); diff --git a/app/Notifications/WebPushNotification.php b/app/Notifications/WebPushNotification.php new file mode 100644 index 0000000..098fe63 --- /dev/null +++ b/app/Notifications/WebPushNotification.php @@ -0,0 +1,33 @@ +title($this->title) + ->icon($this->icon) + ->body($this->body); + } +} diff --git a/app/Services/Admin/Finance/CashAccountService.php b/app/Services/Admin/Finance/CashAccountService.php index 2e64a6d..dc6a512 100644 --- a/app/Services/Admin/Finance/CashAccountService.php +++ b/app/Services/Admin/Finance/CashAccountService.php @@ -5,8 +5,10 @@ use App\Enums\CashTransactionType; use App\Models\CashAccount; use App\Models\CashTransaction; +use App\Services\NotificationService; use App\Services\S3PresignedService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -48,7 +50,7 @@ public function paginatedTransactions(int $perPage = 15, string $search = '', st $cashAccount = $this->get(); if (! $cashAccount) { - return new \Illuminate\Pagination\LengthAwarePaginator(collect(), 0, $perPage); + return new PaginationLengthAwarePaginator(collect(), 0, $perPage); } $paginator = $cashAccount->cashTransactions() @@ -91,7 +93,7 @@ private function formatTransaction(CashTransaction $transaction): array public function deposit(array $data): CashTransaction { - return DB::transaction(function () use ($data) { + $transaction = DB::transaction(function () use ($data) { $cashAccount = CashAccount::firstOrFail(); $newBalance = $cashAccount->balance + $data['amount']; @@ -112,11 +114,20 @@ public function deposit(array $data): CashTransaction return $transaction; }); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Setoran Kas Toko', + body: 'Setoran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.', + url: route('admin.finance.cash-accounts.index'), + ); + + return $transaction; } public function withdrawal(array $data): CashTransaction { - return DB::transaction(function () use ($data) { + $transaction = DB::transaction(function () use ($data) { $cashAccount = CashAccount::firstOrFail(); if ($cashAccount->balance < $data['amount']) { @@ -143,11 +154,20 @@ public function withdrawal(array $data): CashTransaction return $transaction; }); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Penarikan Kas Toko', + body: 'Penarikan sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.', + url: route('admin.finance.cash-accounts.index'), + ); + + return $transaction; } public function updateTransaction(CashTransaction $transaction, array $data): CashTransaction { - return DB::transaction(function () use ($transaction, $data) { + $transaction = DB::transaction(function () use ($transaction, $data) { if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) { throw ValidationException::withMessages([ 'amount' => 'Transaksi ini tidak dapat diedit.', @@ -195,6 +215,15 @@ public function updateTransaction(CashTransaction $transaction, array $data): Ca return $transaction; }); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Transaksi Kas Diperbarui', + body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.', + url: route('admin.finance.cash-accounts.index'), + ); + + return $transaction; } public function deleteTransaction(CashTransaction $transaction): bool @@ -223,7 +252,18 @@ public function deleteTransaction(CashTransaction $transaction): bool $transaction->clearMediaCollection('receipts'); - return $transaction->delete(); + $deleted = $transaction->delete(); + + if ($deleted) { + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Transaksi Kas Dihapus', + body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.', + url: route('admin.finance.cash-accounts.index'), + ); + } + + return $deleted; }); } diff --git a/app/Services/Admin/Finance/EmployeeAdvanceService.php b/app/Services/Admin/Finance/EmployeeAdvanceService.php index 9c786c0..abb3f8a 100644 --- a/app/Services/Admin/Finance/EmployeeAdvanceService.php +++ b/app/Services/Admin/Finance/EmployeeAdvanceService.php @@ -7,6 +7,7 @@ use App\Models\CashAccount; use App\Models\CashTransaction; use App\Models\EmployeeAdvance; +use App\Services\NotificationService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -34,7 +35,7 @@ public function paginated(int $perPage = 15, string $search = '', string $sort = public function create(array $data): EmployeeAdvance { - return DB::transaction(function () use ($data) { + $employeeAdvance = DB::transaction(function () use ($data) { $cashAccount = CashAccount::firstOrFail(); $employee = auth()->user()->employee; @@ -69,6 +70,18 @@ public function create(array $data): EmployeeAdvance 'status' => EmployeeAdvanceStatus::PENDING, ]); }); + + $employeeAdvance->load('employee.user'); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Kasbon Baru', + body: 'Kasbon sebesar Rp '.number_format($data['amount'], 0, ',', '.')." dari {$employeeAdvance->employee->name} menunggu persetujuan".' oleh '.auth()->user()->full_name.'.', + url: route('admin.finance.employee-advances.index'), + additionalUser: $employeeAdvance->employee->user ?? null, + ); + + return $employeeAdvance; } public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeAdvance @@ -128,12 +141,20 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance 'verified_at' => now(), ]); + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Kasbon Disetujui', + body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah disetujui'.' oleh '.auth()->user()->full_name.'.', + url: route('admin.finance.employee-advances.index'), + additionalUser: $employeeAdvance->employee->user ?? null, + ); + return $employeeAdvance; } public function pay(EmployeeAdvance $employeeAdvance): EmployeeAdvance { - return DB::transaction(function () use ($employeeAdvance) { + $employeeAdvance = DB::transaction(function () use ($employeeAdvance) { $cashAccount = CashAccount::firstOrFail(); $newBalance = $cashAccount->balance + $employeeAdvance->amount; @@ -158,5 +179,15 @@ public function pay(EmployeeAdvance $employeeAdvance): EmployeeAdvance return $employeeAdvance; }); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Kasbon Dibayar', + body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah dibayar lunas'.' oleh '.auth()->user()->full_name.'.', + url: route('admin.finance.employee-advances.index'), + additionalUser: $employeeAdvance->employee->user ?? null, + ); + + return $employeeAdvance; } } diff --git a/app/Services/Admin/Finance/ExpenseService.php b/app/Services/Admin/Finance/ExpenseService.php index 0d2004b..0e2162f 100644 --- a/app/Services/Admin/Finance/ExpenseService.php +++ b/app/Services/Admin/Finance/ExpenseService.php @@ -6,6 +6,7 @@ use App\Models\CashAccount; use App\Models\CashTransaction; use App\Models\Expense; +use App\Services\NotificationService; use App\Services\S3PresignedService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -75,7 +76,7 @@ private function formatExpense(Expense $expense): array public function create(array $data): Expense { - return DB::transaction(function () use ($data) { + $expense = DB::transaction(function () use ($data) { $cashAccount = CashAccount::firstOrFail(); if ($cashAccount->balance < $data['amount']) { @@ -109,11 +110,20 @@ public function create(array $data): Expense return $expense; }); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Pengeluaran Baru', + body: 'Pengeluaran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.', + url: route('admin.finance.expenses.index'), + ); + + return $expense; } public function update(Expense $expense, array $data): Expense { - return DB::transaction(function () use ($expense, $data) { + $expense = DB::transaction(function () use ($expense, $data) { $cashAccount = CashAccount::firstOrFail(); $cashTransaction = $expense->cashTransaction; @@ -166,6 +176,15 @@ public function update(Expense $expense, array $data): Expense return $expense; }); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Pengeluaran Diperbarui', + body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.', + url: route('admin.finance.expenses.index'), + ); + + return $expense; } public function delete(Expense $expense): bool @@ -184,7 +203,18 @@ public function delete(Expense $expense): bool $expense->clearMediaCollection('receipts'); - return $expense->delete(); + $deleted = $expense->delete(); + + if ($deleted) { + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Pengeluaran Dihapus', + body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.', + url: route('admin.finance.expenses.index'), + ); + } + + return $deleted; }); } diff --git a/app/Services/Admin/Finance/PayrollPeriodService.php b/app/Services/Admin/Finance/PayrollPeriodService.php index 2507ca6..ee15eff 100644 --- a/app/Services/Admin/Finance/PayrollPeriodService.php +++ b/app/Services/Admin/Finance/PayrollPeriodService.php @@ -9,6 +9,7 @@ use App\Models\CashTransaction; use App\Models\Payroll; use App\Models\PayrollPeriod; +use App\Services\NotificationService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -121,7 +122,7 @@ public function pay(Payroll $payroll): Payroll ]); } - return DB::transaction(function () use ($payroll) { + $payroll = DB::transaction(function () use ($payroll) { $cashAccount = CashAccount::firstOrFail(); $newBalance = $cashAccount->balance + $payroll->total_amount; @@ -145,6 +146,18 @@ public function pay(Payroll $payroll): Payroll return $payroll; }); + + $employeeUser = $payroll->employee->user ?? null; + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Gaji Dibayar', + body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibayar".' oleh '.auth()->user()->full_name.'.', + url: route('admin.finance.payroll-periods.index'), + additionalUser: $employeeUser, + ); + + return $payroll; } public function cancel(Payroll $payroll): Payroll @@ -165,6 +178,16 @@ public function cancel(Payroll $payroll): Payroll 'status' => PayrollStatus::CANCELLED, ]); + $employeeUser = $payroll->employee->user ?? null; + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Gaji Dibatalkan', + body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibatalkan".' oleh '.auth()->user()->full_name.'.', + url: route('admin.finance.payroll-periods.index'), + additionalUser: $employeeUser, + ); + return $payroll; } } diff --git a/app/Services/Admin/HR/AttendanceService.php b/app/Services/Admin/HR/AttendanceService.php index e8671bb..64d5323 100644 --- a/app/Services/Admin/HR/AttendanceService.php +++ b/app/Services/Admin/HR/AttendanceService.php @@ -4,6 +4,7 @@ use App\Models\Attendance; use App\Models\LeaveRequest; +use App\Services\NotificationService; use App\Services\S3PresignedService; use Carbon\Carbon; use Illuminate\Support\Collection; @@ -117,6 +118,13 @@ public function checkIn(array $data): Attendance $this->registerMedia($attendance, $data['photo'], 'check-in'); } + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur'], + title: 'Presensi Masuk', + body: 'Presensi masuk oleh '.auth()->user()->full_name.'.', + url: route('admin.hr.attendances.index'), + ); + return $attendance; } @@ -132,6 +140,13 @@ public function checkOut(Attendance $attendance, array $data): Attendance $this->registerMedia($attendance, $data['photo'], 'check-out'); } + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur'], + title: 'Presensi Pulang', + body: 'Presensi pulang oleh '.auth()->user()->full_name.'.', + url: route('admin.hr.attendances.index'), + ); + return $attendance; } diff --git a/app/Services/Admin/HR/LeaveRequestService.php b/app/Services/Admin/HR/LeaveRequestService.php index 6f8bb27..73c457f 100644 --- a/app/Services/Admin/HR/LeaveRequestService.php +++ b/app/Services/Admin/HR/LeaveRequestService.php @@ -4,6 +4,7 @@ use App\Enums\LeaveRequestStatus; use App\Models\LeaveRequest; +use App\Services\NotificationService; use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -37,7 +38,7 @@ public function paginated(int $perPage = 15, string $search = '', string $sort = public function create(array $data): LeaveRequest { - return DB::transaction(function () use ($data) { + $leaveRequest = DB::transaction(function () use ($data) { $employee = auth()->user()->employee; if (! $employee) { @@ -56,6 +57,18 @@ public function create(array $data): LeaveRequest 'status' => LeaveRequestStatus::PENDING, ]); }); + + $leaveRequest->load('employee.user'); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Pengajuan Cuti Baru', + body: "Pengajuan cuti {$leaveRequest->total_days} hari oleh ".auth()->user()->full_name.'.', + url: route('admin.hr.leave-requests.index'), + additionalUser: $leaveRequest->employee->user ?? null, + ); + + return $leaveRequest; } public function update(LeaveRequest $leaveRequest, array $data): LeaveRequest @@ -88,6 +101,16 @@ public function approve(LeaveRequest $leaveRequest): LeaveRequest 'verified_at' => now(), ]); + $employeeUser = $leaveRequest->employee->user ?? null; + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Cuti Disetujui', + body: "Cuti {$leaveRequest->employee->name} telah disetujui oleh ".auth()->user()->full_name.'.', + url: route('admin.hr.leave-requests.index'), + additionalUser: $employeeUser, + ); + return $leaveRequest; } @@ -99,6 +122,16 @@ public function reject(LeaveRequest $leaveRequest): LeaveRequest 'verified_at' => now(), ]); + $employeeUser = $leaveRequest->employee->user ?? null; + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Cuti Ditolak', + body: "Cuti {$leaveRequest->employee->name} telah ditolak oleh ".auth()->user()->full_name.'.', + url: route('admin.hr.leave-requests.index'), + additionalUser: $employeeUser, + ); + return $leaveRequest; } } diff --git a/app/Services/Admin/Master/ProductService.php b/app/Services/Admin/Master/ProductService.php index ff70f8e..d7735f3 100644 --- a/app/Services/Admin/Master/ProductService.php +++ b/app/Services/Admin/Master/ProductService.php @@ -5,6 +5,7 @@ use App\Models\Product; use App\Models\ProductPrice; use App\Models\ProductVariant; +use App\Services\NotificationService; use App\Services\S3PresignedService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; @@ -72,7 +73,7 @@ public function paginated(int $perPage = 15, string $search = '', string $sort = public function create(array $data): Product { - return DB::transaction(function () use ($data) { + $product = DB::transaction(function () use ($data) { $product = Product::create([ 'name' => $data['name'], 'description' => $data['description'] ?? null, @@ -110,6 +111,15 @@ public function create(array $data): Product return $product; }); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Produk Baru', + body: "Produk \"{$product->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.', + url: route('admin.master.products.index'), + ); + + return $product; } public function getForEdit(Product $product): array @@ -150,7 +160,7 @@ public function getForEdit(Product $product): array public function update(Product $product, array $data): Product { - return DB::transaction(function () use ($product, $data) { + $product = DB::transaction(function () use ($product, $data) { $product->update([ 'name' => $data['name'], 'description' => $data['description'] ?? null, @@ -210,6 +220,15 @@ public function update(Product $product, array $data): Product return $product; }); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Produk Diperbarui', + body: "Produk \"{$product->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', + url: route('admin.master.products.index'), + ); + + return $product; } public function delete(Product $product): bool diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php new file mode 100644 index 0000000..929c892 --- /dev/null +++ b/app/Services/NotificationService.php @@ -0,0 +1,31 @@ +where('is_active', true) + ->whereHas('roles', fn ($q) => $q->whereIn('name', $roles)) + ->get(); + + if ($additionalUser && ! $users->contains('id', $additionalUser->id)) { + $users->push($additionalUser); + } + + $users->each(function (User $user) use ($title, $body, $url) { + $user->notifications()->create([ + 'title' => $title, + 'body' => $body, + 'url' => $url, + ]); + + $user->notify(new WebPushNotification($title, $body)); + }); + } +} diff --git a/app/Services/PushSubscriptionService.php b/app/Services/PushSubscriptionService.php new file mode 100644 index 0000000..4dab973 --- /dev/null +++ b/app/Services/PushSubscriptionService.php @@ -0,0 +1,33 @@ +updatePushSubscription( + $endpoint, + $publicKey, + $authToken, + $contentEncoding, + ); + + return response()->json(['message' => 'Subscription saved.']); + } + + public function destroy(User $user, string $endpoint): JsonResponse + { + $user->deletePushSubscription($endpoint); + + return response()->json(['message' => 'Subscription removed.']); + } +} diff --git a/composer.json b/composer.json index 5ad144c..c66645e 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.3", "inertiajs/inertia-laravel": "^3.0", + "laravel-notification-channels/webpush": "^11.0", "laravel/chisel": "^0.1.0", "laravel/fortify": "^1.37.2", "laravel/framework": "^13.17", diff --git a/composer.lock b/composer.lock index b23bc25..bd0d3aa 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5891ce301b1670b38277f3a3c2ac2426", + "content-hash": "b8312d5622d94884c0607ebccdfd2ed8", "packages": [ { "name": "aws/aws-crt-php", @@ -214,16 +214,16 @@ }, { "name": "brick/math", - "version": "0.18.0", + "version": "0.17.2", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" + "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "url": "https://api.github.com/repos/brick/math/zipball/8189e751995f9e15729c1aa2f89fa8f166ffe818", + "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818", "shasum": "" }, "require": { @@ -261,7 +261,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.18.0" + "source": "https://github.com/brick/math/tree/0.17.2" }, "funding": [ { @@ -269,7 +269,7 @@ "type": "github" } ], - "time": "2026-06-14T18:21:03+00:00" + "time": "2026-05-25T20:34:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -1510,6 +1510,72 @@ }, "time": "2026-07-02T12:45:54+00:00" }, + { + "name": "laravel-notification-channels/webpush", + "version": "11.0.0", + "source": { + "type": "git", + "url": "https://github.com/laravel-notification-channels/webpush.git", + "reference": "85b577e64459a9df06a24062e2b300abbaa99fa9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel-notification-channels/webpush/zipball/85b577e64459a9df06a24062e2b300abbaa99fa9", + "reference": "85b577e64459a9df06a24062e2b300abbaa99fa9", + "shasum": "" + }, + "require": { + "illuminate/notifications": "^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", + "minishlink/web-push": "^10.0.1", + "php": "^8.2" + }, + "require-dev": { + "larastan/larastan": "^3.1", + "laravel/pint": "^1.25", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^9.2|^10.0|^11.0", + "phpunit/phpunit": "^11.5.3|^12.5.12|^13.1.11", + "rector/rector": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NotificationChannels\\WebPush\\WebPushServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "NotificationChannels\\WebPush\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cretu Eusebiu", + "email": "me@cretueusebiu.com", + "homepage": "http://cretueusebiu.com", + "role": "Developer" + }, + { + "name": "Joost de Bruijn", + "email": "joost@aqualabs.nl", + "role": "Maintainer" + } + ], + "description": "Web Push Notifications driver for Laravel.", + "homepage": "https://github.com/laravel-notification-channels/webpush", + "support": { + "issues": "https://github.com/laravel-notification-channels/webpush/issues", + "source": "https://github.com/laravel-notification-channels/webpush/tree/11.0.0" + }, + "time": "2026-05-24T13:22:27+00:00" + }, { "name": "laravel/chisel", "version": "v0.1.1", @@ -2865,6 +2931,77 @@ ], "time": "2026-04-11T18:38:28+00:00" }, + { + "name": "minishlink/web-push", + "version": "v10.1.0", + "source": { + "type": "git", + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "c922021b4ed1a61e6604d8dc33a2e0378b4382e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/c922021b4ed1a61e6604d8dc33a2e0378b4382e3", + "reference": "c922021b4ed1a61e6604d8dc33a2e0378b4382e3", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^7.9.2", + "php": ">=8.2", + "psr/log": "^2.0|^3.0", + "spomky-labs/base64url": "^2.0.4", + "symfony/polyfill-php83": "^1.33", + "web-token/jwt-library": "^3.4.9|^4.0.6" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.92.2", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.46|^12.5.2", + "symfony/polyfill-iconv": "^1.33" + }, + "suggest": { + "ext-bcmath": "Optional for performance.", + "ext-gmp": "Optional for performance." + }, + "type": "library", + "autoload": { + "psr-4": { + "Minishlink\\WebPush\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Louis Lagrange", + "email": "lagrange.louis@gmail.com", + "homepage": "https://github.com/Minishlink" + } + ], + "description": "Web Push library for PHP", + "homepage": "https://github.com/web-push-libs/web-push-php", + "keywords": [ + "Push API", + "WebPush", + "notifications", + "push", + "web" + ], + "support": { + "issues": "https://github.com/web-push-libs/web-push-php/issues", + "source": "https://github.com/web-push-libs/web-push-php/tree/v10.1.0" + }, + "time": "2026-05-28T09:37:37+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -5325,6 +5462,71 @@ ], "time": "2026-06-22T07:55:44+00:00" }, + { + "name": "spomky-labs/base64url", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/base64url.git", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.11|^0.12", + "phpstan/phpstan-beberlei-assert": "^0.11|^0.12", + "phpstan/phpstan-deprecation-rules": "^0.11|^0.12", + "phpstan/phpstan-phpunit": "^0.11|^0.12", + "phpstan/phpstan-strict-rules": "^0.11|^0.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Base64Url\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky-Labs/base64url/contributors" + } + ], + "description": "Base 64 URL Safe Encoding/Decoding PHP Library", + "homepage": "https://github.com/Spomky-Labs/base64url", + "keywords": [ + "base64", + "rfc4648", + "safe", + "url" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/base64url/issues", + "source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2020-11-03T09:10:25+00:00" + }, { "name": "spomky-labs/cbor-php", "version": "3.3.0", @@ -5584,16 +5786,16 @@ }, { "name": "symfony/console", - "version": "v8.1.1", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d" + "reference": "535e18a1b8925f6c01a55b171d157ab66c2ace15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", - "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", + "url": "https://api.github.com/repos/symfony/console/zipball/535e18a1b8925f6c01a55b171d157ab66c2ace15", + "reference": "535e18a1b8925f6c01a55b171d157ab66c2ace15", "shasum": "" }, "require": { @@ -5660,7 +5862,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.1" + "source": "https://github.com/symfony/console/tree/v8.1.2" }, "funding": [ { @@ -5680,7 +5882,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T12:55:20+00:00" + "time": "2026-07-27T13:58:19+00:00" }, { "name": "symfony/css-selector", @@ -5824,16 +6026,16 @@ }, { "name": "symfony/error-handler", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5" + "reference": "dc98404be5e8c949815e23fee1928f5de4f3f5d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/d8aeb1abd3fef84795567850d3a567bdb5945ee5", - "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/dc98404be5e8c949815e23fee1928f5de4f3f5d3", + "reference": "dc98404be5e8c949815e23fee1928f5de4f3f5d3", "shasum": "" }, "require": { @@ -5881,7 +6083,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v8.1.0" + "source": "https://github.com/symfony/error-handler/tree/v8.1.2" }, "funding": [ { @@ -5901,20 +6103,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-22T15:42:13+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.1.1", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" + "reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", - "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/c14c05a9e6da7f5e375e6efc28952c7e7dbddffb", + "reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb", "shasum": "" }, "require": { @@ -5967,7 +6169,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.2" }, "funding": [ { @@ -5987,7 +6189,7 @@ "type": "tidelift" } ], - "time": "2026-06-09T12:28:30+00:00" + "time": "2026-07-22T15:42:13+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -6210,16 +6412,16 @@ }, { "name": "symfony/http-foundation", - "version": "v8.1.1", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "6a168c8fcee806b57ac020244da14293d1f9a883" + "reference": "9943adbf5a64e2951a8d9eb0485310d55624f0e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6a168c8fcee806b57ac020244da14293d1f9a883", - "reference": "6a168c8fcee806b57ac020244da14293d1f9a883", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9943adbf5a64e2951a8d9eb0485310d55624f0e8", + "reference": "9943adbf5a64e2951a8d9eb0485310d55624f0e8", "shasum": "" }, "require": { @@ -6267,7 +6469,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.1" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.2" }, "funding": [ { @@ -6287,7 +6489,7 @@ "type": "tidelift" } ], - "time": "2026-06-12T08:43:41+00:00" + "time": "2026-07-29T07:22:54+00:00" }, { "name": "symfony/http-kernel", @@ -6400,16 +6602,16 @@ }, { "name": "symfony/mailer", - "version": "v8.1.1", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "4fa583a7377f28d54e4de442fba76375b2e20a12" + "reference": "221c7f326ace1ac2baee8331d829d5b7f04f4d53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/4fa583a7377f28d54e4de442fba76375b2e20a12", - "reference": "4fa583a7377f28d54e4de442fba76375b2e20a12", + "url": "https://api.github.com/repos/symfony/mailer/zipball/221c7f326ace1ac2baee8331d829d5b7f04f4d53", + "reference": "221c7f326ace1ac2baee8331d829d5b7f04f4d53", "shasum": "" }, "require": { @@ -6456,7 +6658,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v8.1.1" + "source": "https://github.com/symfony/mailer/tree/v8.1.2" }, "funding": [ { @@ -6476,20 +6678,20 @@ "type": "tidelift" } ], - "time": "2026-06-16T12:55:20+00:00" + "time": "2026-07-28T07:35:25+00:00" }, { "name": "symfony/mime", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664" + "reference": "75f4779d4ec2e13f24a3a7e5d0347c340c7ca627" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b164ae7e3f7915aacfe9ee155f2f358502440664", - "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664", + "url": "https://api.github.com/repos/symfony/mime/zipball/75f4779d4ec2e13f24a3a7e5d0347c340c7ca627", + "reference": "75f4779d4ec2e13f24a3a7e5d0347c340c7ca627", "shasum": "" }, "require": { @@ -6542,7 +6744,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.1.0" + "source": "https://github.com/symfony/mime/tree/v8.1.2" }, "funding": [ { @@ -6562,7 +6764,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-29T08:00:47+00:00" }, { "name": "symfony/polyfill-ctype", @@ -7070,6 +7272,86 @@ ], "time": "2026-04-10T16:19:22+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, { "name": "symfony/polyfill-php84", "version": "v1.38.1", @@ -7627,16 +7909,16 @@ }, { "name": "symfony/routing", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3" + "reference": "1058d4e13bb81dd9a6f7565686df7e13b880cdbd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", - "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", + "url": "https://api.github.com/repos/symfony/routing/zipball/1058d4e13bb81dd9a6f7565686df7e13b880cdbd", + "reference": "1058d4e13bb81dd9a6f7565686df7e13b880cdbd", "shasum": "" }, "require": { @@ -7683,7 +7965,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.1.0" + "source": "https://github.com/symfony/routing/tree/v8.1.2" }, "funding": [ { @@ -7703,7 +7985,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-22T15:42:13+00:00" }, { "name": "symfony/serializer", @@ -7893,16 +8175,16 @@ }, { "name": "symfony/string", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", "shasum": "" }, "require": { @@ -7959,7 +8241,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.0" + "source": "https://github.com/symfony/string/tree/v8.1.2" }, "funding": [ { @@ -7979,7 +8261,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-28T07:35:25+00:00" }, { "name": "symfony/translation", @@ -8318,16 +8600,16 @@ }, { "name": "symfony/var-dumper", - "version": "v8.1.1", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "40096a2515a979f3125c5c928603995b8664c62a" + "reference": "865103cf742a039f34645b971fc3ace308d6c167" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/40096a2515a979f3125c5c928603995b8664c62a", - "reference": "40096a2515a979f3125c5c928603995b8664c62a", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/865103cf742a039f34645b971fc3ace308d6c167", + "reference": "865103cf742a039f34645b971fc3ace308d6c167", "shasum": "" }, "require": { @@ -8343,7 +8625,7 @@ "symfony/http-kernel": "^7.4|^8.0", "symfony/process": "^7.4|^8.0", "symfony/uid": "^7.4|^8.0", - "twig/twig": "^3.12" + "twig/twig": "^3.12|^4.0" }, "bin": [ "Resources/bin/var-dump-server" @@ -8381,7 +8663,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.1.1" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.2" }, "funding": [ { @@ -8401,7 +8683,7 @@ "type": "tidelift" } ], - "time": "2026-06-09T10:54:51+00:00" + "time": "2026-07-22T15:42:13+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -8773,6 +9055,95 @@ ], "time": "2026-05-31T15:00:08+00:00" }, + { + "name": "web-token/jwt-library", + "version": "4.1.7", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/fbcbf2c276d04d8b056f5c2957815abd5dfb704d", + "reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d", + "shasum": "" + }, + "require": { + "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "php": ">=8.2", + "psr/clock": "^1.0", + "spomky-labs/pki-framework": "^1.2.1" + }, + "conflict": { + "spomky-labs/jose": "*" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance", + "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", + "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)", + "symfony/console": "Needed to use console commands", + "symfony/http-client": "To enable JKU/X5U support." + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "JWT library", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "issues": "https://github.com/web-token/jwt-library/issues", + "source": "https://github.com/web-token/jwt-library/tree/4.1.7" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-06-06T18:12:39+00:00" + }, { "name": "webmozart/assert", "version": "2.4.1", diff --git a/config/webpush.php b/config/webpush.php new file mode 100644 index 0000000..92ae4b1 --- /dev/null +++ b/config/webpush.php @@ -0,0 +1,46 @@ + [ + 'subject' => env('VAPID_SUBJECT'), + 'public_key' => env('VAPID_PUBLIC_KEY'), + 'private_key' => env('VAPID_PRIVATE_KEY'), + 'pem_file' => env('VAPID_PEM_FILE'), + ], + + /** + * This is model that will be used to for push subscriptions. + */ + 'model' => PushSubscription::class, + + /** + * This is the name of the table that will be created by the migration and + * used by the PushSubscription model shipped with this package. + */ + 'table_name' => env('WEBPUSH_DB_TABLE', 'push_subscriptions'), + + /** + * This is the database connection that will be used by the migration and + * the PushSubscription model shipped with this package. + */ + 'database_connection' => env('WEBPUSH_DB_CONNECTION', env('DB_CONNECTION', 'mysql')), + + /** + * The Guzzle client options used by Minishlink\WebPush. + */ + 'client_options' => [], + + /** + * The automatic padding in bytes used by Minishlink\WebPush. + * Set to false to support Firefox Android with v1 endpoint. + */ + 'automatic_padding' => env('WEBPUSH_AUTOMATIC_PADDING', true), + +]; diff --git a/database/migrations/2026_06_13_163706_create_push_subscriptions_table.php b/database/migrations/2026_06_13_163706_create_push_subscriptions_table.php index b6474f9..e5b0cbb 100644 --- a/database/migrations/2026_06_13_163706_create_push_subscriptions_table.php +++ b/database/migrations/2026_06_13_163706_create_push_subscriptions_table.php @@ -6,26 +6,31 @@ return new class extends Migration { - public function up(): void + /** + * Run the migrations. + * + * @return void + */ + public function up() { - Schema::create('push_subscriptions', function (Blueprint $table) { - $table->id(); - - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - - $table->string('endpoint', 500); - $table->string('public_key', 255)->nullable(); - $table->string('auth_token', 255)->nullable(); - - $table->timestamp('created_at')->useCurrent(); - $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); - - $table->unique('endpoint'); + Schema::connection(config('webpush.database_connection'))->create(config('webpush.table_name'), function (Blueprint $table) { + $table->bigIncrements('id'); + $table->morphs('subscribable', 'push_subscriptions_subscribable_morph_idx'); + $table->string('endpoint', 500)->unique(); + $table->string('public_key')->nullable(); + $table->string('auth_token')->nullable(); + $table->string('content_encoding')->nullable(); + $table->timestamps(); }); } - public function down(): void + /** + * Reverse the migrations. + * + * @return void + */ + public function down() { - Schema::dropIfExists('push_subscriptions'); + Schema::connection(config('webpush.database_connection'))->dropIfExists(config('webpush.table_name')); } }; diff --git a/resources/js/components/app-sidebar-header.tsx b/resources/js/components/app-sidebar-header.tsx index e78ce1f..5a4c9e7 100644 --- a/resources/js/components/app-sidebar-header.tsx +++ b/resources/js/components/app-sidebar-header.tsx @@ -1,5 +1,6 @@ import { Breadcrumbs } from '@/components/breadcrumbs'; import { NavUser } from '@/components/nav-user'; +import { NotificationBell } from '@/components/notification-bell'; import { SidebarTrigger } from '@/components/ui/sidebar'; import type { BreadcrumbItem as BreadcrumbItemType } from '@/types'; @@ -14,7 +15,8 @@ export function AppSidebarHeader({ -
+
+
diff --git a/resources/js/components/notification-bell.tsx b/resources/js/components/notification-bell.tsx new file mode 100644 index 0000000..421e4e8 --- /dev/null +++ b/resources/js/components/notification-bell.tsx @@ -0,0 +1,266 @@ +import { Bell, Check, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +interface Notification { + id: number; + title: string; + body: string | null; + url: string | null; + is_read: boolean; + created_at: string; +} + +export function NotificationBell() { + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [isOpen, setIsOpen] = useState(false); + const intervalRef = useRef | null>(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + }; + }, []); + + const fetchUnreadCount = useCallback(async () => { + try { + const response = await fetch('/api/notifications/unread-count', { + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + if (response.ok && mountedRef.current) { + const data = await response.json(); + setUnreadCount(data.count); + } + } catch { + // Silently fail + } + }, []); + + const fetchNotifications = useCallback(async () => { + try { + const response = await fetch('/api/notifications', { + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + if (response.ok && mountedRef.current) { + const data = await response.json(); + setNotifications(data); + } + } catch { + // Silently fail + } + }, []); + + const markAsRead = useCallback(async (id: number) => { + try { + await fetch(`/api/notifications/${id}/read`, { + method: 'PATCH', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)), + ); + setUnreadCount((prev) => Math.max(0, prev - 1)); + } catch { + // Silently fail + } + }, []); + + const deleteNotification = useCallback(async (id: number) => { + try { + await fetch(`/api/notifications/${id}`, { + method: 'DELETE', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + setNotifications((prev) => prev.filter((n) => n.id !== id)); + setUnreadCount((prev) => Math.max(0, prev - 1)); + } catch { + // Silently fail + } + }, []); + + const markAllAsRead = useCallback(async () => { + try { + await fetch('/api/notifications/read-all', { + method: 'PATCH', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + setNotifications((prev) => + prev.map((n) => ({ ...n, is_read: true })), + ); + setUnreadCount(0); + } catch { + // Silently fail + } + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch is safe + void fetchUnreadCount(); + intervalRef.current = setInterval(() => { + void fetchUnreadCount(); + }, 30000); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [fetchUnreadCount]); + + useEffect(() => { + if (isOpen) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- fetch on dropdown open is safe + void fetchNotifications(); + } + }, [isOpen, fetchNotifications]); + + return ( + + + + + +
+ Notifikasi + {unreadCount > 0 && ( + + )} +
+ {notifications.length === 0 ? ( +
+ Tidak ada notifikasi +
+ ) : ( + notifications.map((notification) => ( + + )) + )} +
+
+ ); +} diff --git a/resources/js/hooks/use-push-notification.ts b/resources/js/hooks/use-push-notification.ts new file mode 100644 index 0000000..320e9b4 --- /dev/null +++ b/resources/js/hooks/use-push-notification.ts @@ -0,0 +1,149 @@ +import { useCallback, useState } from 'react'; + +type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown'; + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; i++) { + outputArray[i] = rawData.charCodeAt(i); + } + + return outputArray; +} + +function getInitialPermission(): PermissionStatus { + if (!('Notification' in window)) { + return 'denied'; + } + + return Notification.permission as PermissionStatus; +} + +function getIsSupported(): boolean { + return ( + 'Notification' in window && + 'serviceWorker' in navigator && + 'PushManager' in window + ); +} + +export function usePushNotification() { + const [permission, setPermission] = + useState(getInitialPermission); + const [isSupported] = useState(getIsSupported); + + const requestPermission = + useCallback(async (): Promise => { + if (!('Notification' in window)) { + return 'denied'; + } + + const result = await Notification.requestPermission(); + setPermission(result as PermissionStatus); + + return result as PermissionStatus; + }, []); + + const subscribe = useCallback( + async (vapidPublicKey: string): Promise => { + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array( + vapidPublicKey, + ) as BufferSource, + }); + + const { endpoint } = subscription; + const key = subscription.getKey('p256dh'); + const auth = subscription.getKey('auth'); + + const response = await fetch('/api/push/subscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-XSRF-TOKEN': decodeURIComponent( + document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? + '', + ), + }, + body: JSON.stringify({ + endpoint, + public_key: key + ? btoa(String.fromCharCode(...new Uint8Array(key))) + : null, + auth_token: auth + ? btoa(String.fromCharCode(...new Uint8Array(auth))) + : null, + content_encoding: 'aes128gcm', + }), + }); + + return response.ok; + } catch (error) { + console.error( + 'Failed to subscribe to push notifications:', + error, + ); + + return false; + } + }, + [], + ); + + const unsubscribe = useCallback(async (): Promise => { + try { + const registration = await navigator.serviceWorker.ready; + const subscription = + await registration.pushManager.getSubscription(); + + if (!subscription) { + return true; + } + + const response = await fetch('/api/push/unsubscribe', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-XSRF-TOKEN': decodeURIComponent( + document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '', + ), + }, + body: JSON.stringify({ + endpoint: subscription.endpoint, + }), + }); + + if (response.ok) { + await subscription.unsubscribe(); + } + + return response.ok; + } catch (error) { + console.error( + 'Failed to unsubscribe from push notifications:', + error, + ); + + return false; + } + }, []); + + return { + permission, + isSupported, + requestPermission, + subscribe, + unsubscribe, + }; +} diff --git a/resources/js/pages/admin/master/category/index.tsx b/resources/js/pages/admin/master/category/index.tsx index ced2424..60e533f 100644 --- a/resources/js/pages/admin/master/category/index.tsx +++ b/resources/js/pages/admin/master/category/index.tsx @@ -32,9 +32,10 @@ type Props = { per_page: number; total: number; }; + highlight?: number; }; -export default function CategoryIndex({ categories }: Props) { +export default function CategoryIndex({ categories, highlight }: Props) { const [createOpen, setCreateOpen] = useState(false); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); @@ -149,6 +150,26 @@ export default function CategoryIndex({ categories }: Props) {

Kategori

+ {highlight && ( +

+ Menampilkan kategori dari notifikasi. + +

+ )}