diff --git a/app/Http/Controllers/PushSubscriptionController.php b/app/Http/Controllers/PushSubscriptionController.php new file mode 100644 index 0000000..c6f88d4 --- /dev/null +++ b/app/Http/Controllers/PushSubscriptionController.php @@ -0,0 +1,46 @@ +validate([ + 'endpoint' => ['required', 'string', 'url'], + 'publicKey' => ['required', 'string'], + 'authToken' => ['required', 'string'], + ]); + + PushSubscription::updateOrCreate( + [ + 'user_id' => Auth::id(), + 'endpoint' => $validated['endpoint'], + ], + [ + 'public_key' => $validated['publicKey'], + 'auth_token' => $validated['authToken'], + ] + ); + + return response()->json(['message' => 'Subscription berhasil disimpan.']); + } + + public function destroy(Request $request): JsonResponse + { + $validated = $request->validate([ + 'endpoint' => ['required', 'string'], + ]); + + PushSubscription::where('user_id', Auth::id()) + ->where('endpoint', $validated['endpoint']) + ->delete(); + + return response()->json(['message' => 'Subscription berhasil dihapus.']); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index c17e7f6..2837b5c 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -58,6 +58,7 @@ public function share(Request $request): array 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'appearance' => $request->cookie('appearance', 'system'), + 'vapidPublicKey' => config('webpush.vapid.public_key'), ]; } } diff --git a/app/Http/Requests/Admin/Manage/CuttingStatusTransitionRequest.php b/app/Http/Requests/Admin/Manage/CuttingStatusTransitionRequest.php index c0c23db..60eb066 100644 --- a/app/Http/Requests/Admin/Manage/CuttingStatusTransitionRequest.php +++ b/app/Http/Requests/Admin/Manage/CuttingStatusTransitionRequest.php @@ -77,7 +77,8 @@ public function withValidator(Validator $validator): void $originalResult = $cuttingResults->get($variantId); if ($originalResult === null) { - $validator->errors()->add("results.{$index}.product_variant_id", "Varian produk tidak ditemukan pada cutting ini."); + $validator->errors()->add("results.{$index}.product_variant_id", 'Varian produk tidak ditemukan pada cutting ini.'); + continue; } diff --git a/app/Jobs/SendPushNotificationJob.php b/app/Jobs/SendPushNotificationJob.php new file mode 100644 index 0000000..3b35b5c --- /dev/null +++ b/app/Jobs/SendPushNotificationJob.php @@ -0,0 +1,101 @@ +resolveSubscriptions(); + + if ($subscriptions->isEmpty()) { + return; + } + + $icon = $this->resolveIcon(); + + $webPush = new WebPush([ + 'VAPID' => [ + 'subject' => config('app.url'), + 'publicKey' => config('webpush.vapid.public_key'), + 'privateKey' => config('webpush.vapid.private_key'), + ], + ]); + + $payload = json_encode([ + 'title' => $this->title, + 'body' => $this->body, + 'icon' => $icon, + 'url' => $this->url, + ]); + + foreach ($subscriptions as $subscription) { + $sub = Subscription::create([ + 'endpoint' => $subscription->endpoint, + 'publicKey' => $subscription->public_key, + 'authToken' => $subscription->auth_token, + 'contentEncoding' => 'aes128gcm', + ]); + + $webPush->queueNotification($sub, $payload); + } + + $expiredEndpoints = []; + + foreach ($webPush->flush() as $report) { + if (! $report->isSuccess()) { + $expiredEndpoints[] = (string) $report->getRequest()->getUri(); + } + } + + if (! empty($expiredEndpoints)) { + PushSubscription::whereIn('endpoint', $expiredEndpoints)->delete(); + } + } + + private function resolveIcon(): string + { + try { + $configuration = SystemConfiguration::instance(); + $configuration->load('media'); + $logo = MediaPresenter::first($configuration, 'logo'); + + return $logo['url'] ?? config('app.url').'/assets/logo.png'; + } catch (\Throwable) { + return config('app.url').'/assets/logo.png'; + } + } + + private function resolveSubscriptions() + { + $query = PushSubscription::query(); + + if ($this->userId !== null) { + $query->where('user_id', $this->userId); + } elseif (! empty($this->roles)) { + $query->whereHas('user', function ($q): void { + $q->whereHas('roles', fn ($r) => $r->whereIn('name', $this->roles)); + }); + } + + return $query->get(); + } +} diff --git a/app/Models/PushSubscription.php b/app/Models/PushSubscription.php new file mode 100644 index 0000000..0f33a33 --- /dev/null +++ b/app/Models/PushSubscription.php @@ -0,0 +1,16 @@ +belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index c85c0a6..3bf6b3a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -77,6 +77,11 @@ public function profile(): HasOne return $this->hasOne(UserProfile::class); } + public function pushSubscriptions(): HasMany + { + return $this->hasMany(PushSubscription::class); + } + public function purchases(): HasMany { return $this->hasMany(Purchase::class, 'created_by_id'); diff --git a/app/Services/Finance/CashService.php b/app/Services/Finance/CashService.php index 6e432d9..5c89815 100644 --- a/app/Services/Finance/CashService.php +++ b/app/Services/Finance/CashService.php @@ -6,6 +6,7 @@ use App\Models\CashTransaction; use App\Models\User; use App\Services\Media\MediaService; +use App\Services\System\PushNotificationService; use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; @@ -19,6 +20,7 @@ class CashService public function __construct( private readonly MediaService $mediaService, + private readonly PushNotificationService $pushNotificationService, ) {} public function getDefaultAccount(): CashAccount @@ -61,7 +63,7 @@ public function paginateForIndex(CashAccount $cashAccount, array $tableQuery): L */ public function deposit(CashAccount $cashAccount, array $validated, User $user): CashTransaction { - return DB::transaction(function () use ($cashAccount, $validated, $user): CashTransaction { + $transaction = DB::transaction(function () use ($cashAccount, $validated, $user): CashTransaction { $account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id); $amount = (int) $validated['amount']; $newBalance = $account->balance + $amount; @@ -81,6 +83,15 @@ public function deposit(CashAccount $cashAccount, array $validated, User $user): return $transaction; }); + + $this->pushNotificationService->sendToRoles( + '💰 Setoran Kas', + "Setoran kas baru {$transaction->amount_formatted} dengan keterangan: {$transaction->description} oleh {$user->profile?->full_name}.", + ['owner', 'developer', 'admin-toko'], + '/admin/finance/cash', + ); + + return $transaction; } public function recordOutgoing( @@ -171,6 +182,13 @@ public function updateDeposit(CashTransaction $transaction, array $validated): v $this->recalculateBalances($transaction->cashAccount); }); + + $this->pushNotificationService->sendToRoles( + '✏️ Transaksi Kas Diperbarui', + "Transaksi kas dengan keterangan {$transaction->description} diperbarui menjadi senilai {$transaction->amount_formatted}.", + ['owner', 'developer', 'admin-toko'], + '/admin/finance/cash', + ); } public function deleteTransaction(CashTransaction $transaction): void @@ -185,6 +203,13 @@ public function deleteTransaction(CashTransaction $transaction): void $this->recalculateBalances($transaction->cashAccount); }); + + $this->pushNotificationService->sendToRoles( + '🗑️ Transaksi Kas Dihapus', + "Transaksi kas senilai {$transaction->amount_formatted} dengan keterangan {$transaction->description} telah dihapus.", + ['owner', 'developer', 'admin-toko'], + '/admin/finance/cash', + ); } public function updateReferencedTransaction( diff --git a/app/Services/Finance/EmployeeAdvanceService.php b/app/Services/Finance/EmployeeAdvanceService.php index fe75d2c..ab2bfdd 100644 --- a/app/Services/Finance/EmployeeAdvanceService.php +++ b/app/Services/Finance/EmployeeAdvanceService.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Services\Concerns\ResolvesAuthEmployee; use App\Services\Media\MediaService; +use App\Services\System\PushNotificationService; use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; @@ -22,6 +23,7 @@ class EmployeeAdvanceService public function __construct( private readonly CashService $cashService, private readonly MediaService $mediaService, + private readonly PushNotificationService $pushNotificationService, ) {} /** @@ -83,7 +85,7 @@ public function create(array $validated, User $user): void { $employee = $this->resolveAuthEmployee($user); - DB::transaction(function () use ($validated, $employee): void { + $employeeAdvance = DB::transaction(function () use ($validated, $employee) { $employeeAdvance = EmployeeAdvance::create([ 'employee_id' => $employee->id, 'amount' => (int) $validated['amount'], @@ -93,7 +95,16 @@ public function create(array $validated, User $user): void ]); $this->syncPhotos($employeeAdvance, $validated); + + return $employeeAdvance; }); + + $this->pushNotificationService->sendToRoles( + '💰 Pengajuan Kasbon Baru', + "Karyawan {$user->profil?->full_name} mengajukan kasbon sebesar {$employeeAdvance->amount_formatted} dengan keterangan: {$employeeAdvance->description}.", + ['owner', 'developer'], + '/admin/finance/employee-advances', + ); } /** @@ -148,6 +159,16 @@ public function approve(EmployeeAdvance $employeeAdvance, User $user): void $employeeAdvance->verified_by_id = $user->id; $employeeAdvance->save(); }); + + $employeeAdvance->loadMissing('employee.user'); + if ($employeeAdvance->employee?->user_id) { + $this->pushNotificationService->sendToUser( + '💰 Kasbon Disetujui', + "Pengajuan kasbon Anda sebesar {$employeeAdvance->amount_formatted} telah disetujui.", + $employeeAdvance->employee->user_id, + '/admin/finance/employee-advances', + ); + } } public function reject(EmployeeAdvance $employeeAdvance, string $reason, User $user): void @@ -165,6 +186,16 @@ public function reject(EmployeeAdvance $employeeAdvance, string $reason, User $u 'rejected_by_id' => $user->id, ]); }); + + $employeeAdvance->loadMissing('employee.user'); + if ($employeeAdvance->employee?->user_id) { + $this->pushNotificationService->sendToUser( + '💰 Kasbon Ditolak', + "Pengajuan kasbon Anda sebesar {$employeeAdvance->amount_formatted} ditolak dengan alasan: {$reason}.", + $employeeAdvance->employee->user_id, + '/admin/finance/employee-advances', + ); + } } public function pay(EmployeeAdvance $employeeAdvance, User $user): void diff --git a/app/Services/Finance/ExpenseService.php b/app/Services/Finance/ExpenseService.php index 3632a51..0e95c80 100644 --- a/app/Services/Finance/ExpenseService.php +++ b/app/Services/Finance/ExpenseService.php @@ -5,6 +5,7 @@ use App\Models\Expense; use App\Models\User; use App\Services\Media\MediaService; +use App\Services\System\PushNotificationService; use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; @@ -17,6 +18,7 @@ class ExpenseService public function __construct( private readonly CashService $cashService, private readonly MediaService $mediaService, + private readonly PushNotificationService $pushNotificationService, ) {} /** @@ -53,7 +55,7 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator */ public function create(array $validated, User $user): void { - DB::transaction(function () use ($validated, $user): void { + $expense = DB::transaction(function () use ($validated, $user): Expense { $amount = (int) $validated['amount']; $description = $validated['description']; @@ -74,7 +76,16 @@ public function create(array $validated, User $user): void $expense->save(); $this->syncPhotos($expense, $validated); + + return $expense; }); + + $this->pushNotificationService->sendToRoles( + '💸 Pengeluaran Baru', + "Pengeluaran baru sebesar {$expense->amount_formatted} dengan keterangan {$expense->description} telah dicatat oleh {$user->profile?->full_name}.", + ['owner', 'developer'], + '/admin/finance/expenses', + ); } /** @@ -100,10 +111,20 @@ public function update(Expense $expense, array $validated): void $this->syncPhotos($expense, $validated); }); + + $this->pushNotificationService->sendToRoles( + '✏️ Pengeluaran Diperbarui', + "Pengeluaran dengan keterangan {$expense->description} diperbarui menjadi sebesar {$expense->amount_formatted}.", + ['owner', 'developer'], + '/admin/finance/expenses', + ); } public function delete(Expense $expense): void { + $amount = $expense->amount; + $description = $expense->description; + DB::transaction(function () use ($expense): void { if ($expense->cashTransaction) { $this->cashService->deleteReferencedTransaction($expense->cashTransaction); @@ -112,6 +133,13 @@ public function delete(Expense $expense): void $expense->clearMediaCollection('photos'); $expense->delete(); }); + + $this->pushNotificationService->sendToRoles( + '🗑️ Pengeluaran Dihapus', + "Pengeluaran sebesar {$expense->amount_formatted} dengan keterangan {$expense->description} telah dihapus.", + ['owner', 'developer'], + '/admin/finance/expenses', + ); } /** diff --git a/app/Services/Finance/PayrollService.php b/app/Services/Finance/PayrollService.php index 9dc2f1e..9949f22 100644 --- a/app/Services/Finance/PayrollService.php +++ b/app/Services/Finance/PayrollService.php @@ -12,6 +12,7 @@ use App\Models\Payroll; use App\Models\PayrollPeriod; use App\Models\User; +use App\Services\System\PushNotificationService; use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; @@ -23,6 +24,7 @@ class PayrollService { public function __construct( private readonly CashService $cashService, + private readonly PushNotificationService $pushNotificationService, ) {} /** @@ -106,7 +108,7 @@ public function paginateForPeriod(PayrollPeriod $period, array $tableQuery): Len public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod { - return DB::transaction(function () use ($closedBy): PayrollPeriod { + $period = DB::transaction(function () use ($closedBy): PayrollPeriod { $now = now(); $year = $now->year; $month = $now->month; @@ -141,6 +143,14 @@ public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod return $period->fresh(); }); + + $this->pushNotificationService->sendToAll( + '📊 Periode Gaji Baru Dibuka', + "Periode gaji untuk {$period->period_label} telah dibuka.", + '/admin/finance/payrolls', + ); + + return $period; } public function closePeriod(PayrollPeriod $period, User $user): void @@ -167,6 +177,12 @@ public function closePeriod(PayrollPeriod $period, User $user): void $period->closed_at = now(); $period->closed_by_id = $user->id; $period->save(); + + $this->pushNotificationService->sendToAll( + '📊 Periode Gaji Ditutup', + "Periode gaji untuk {$period->period_label} telah ditutup.", + '/admin/finance/payrolls', + ); } public function generatePayrollsForPeriod(PayrollPeriod $period): void @@ -277,6 +293,15 @@ public function pay(Payroll $payroll, User $user): void $this->settleKasbonFromPayroll($payroll, $user); }); + + if ($payroll->employee?->user_id) { + $this->pushNotificationService->sendToUser( + '💸 Gaji Dibayarkan', + "Gaji Anda untuk periode {$payroll->payrollPeriod->period_label} senilai {$payroll->total_amount_formatted} telah dibayarkan.", + $payroll->employee->user_id, + '/admin/finance/payrolls', + ); + } } /** diff --git a/app/Services/Hr/AttendanceService.php b/app/Services/Hr/AttendanceService.php index 2a06f85..1b4c179 100644 --- a/app/Services/Hr/AttendanceService.php +++ b/app/Services/Hr/AttendanceService.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Services\Concerns\ResolvesAuthEmployee; use App\Services\Media\MediaService; +use App\Services\System\PushNotificationService; use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; @@ -19,6 +20,7 @@ class AttendanceService public function __construct( private readonly MediaService $mediaService, + private readonly PushNotificationService $pushNotificationService, ) {} public function listForCalendar( @@ -95,6 +97,13 @@ public function checkIn(array $validated, User $user): void 'checkin', 'checkin', ); + + $this->pushNotificationService->sendToRoles( + '⏰ Presensi Masuk', + "Karyawan {$user->profile?->full_name} melakukan presensi masuk di {$locationTag}.", + ['owner', 'developer'], + '/admin/hr/attendances', + ); } /** @@ -143,6 +152,13 @@ public function checkOut(array $validated, User $user): void 'checkout', 'checkout', ); + + $this->pushNotificationService->sendToRoles( + '⏰ Presensi Pulang', + "Karyawan {$user->profile?->full_name} melakukan presensi pulang di {$locationTag} (Durasi kerja: ".round($workDurationMinutes / 60, 1).' jam).', + ['owner', 'developer'], + '/admin/hr/attendances', + ); } public function delete(Attendance $attendance): void diff --git a/app/Services/Hr/LeaveRequestService.php b/app/Services/Hr/LeaveRequestService.php index 3aecccc..9cf21ed 100644 --- a/app/Services/Hr/LeaveRequestService.php +++ b/app/Services/Hr/LeaveRequestService.php @@ -6,6 +6,7 @@ use App\Models\LeaveRequest; use App\Models\User; use App\Services\Concerns\ResolvesAuthEmployee; +use App\Services\System\PushNotificationService; use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; @@ -16,6 +17,10 @@ class LeaveRequestService { use ResolvesAuthEmployee; + public function __construct( + private readonly PushNotificationService $pushNotificationService, + ) {} + /** * @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery */ @@ -52,13 +57,20 @@ public function create(array $validated, User $user): void $this->ensureMinimumLeadTime($startDate); $this->ensureValidDateRange($startDate, $endDate); - LeaveRequest::create([ + $leaveRequest = LeaveRequest::create([ 'employee_id' => $employee->id, 'start_date' => $startDate, 'end_date' => $endDate, 'total_days' => $this->calculateTotalDays($startDate, $endDate), 'status' => LeaveRequestStatus::PENDING, ]); + + $this->pushNotificationService->sendToRoles( + '🏖️ Pengajuan Cuti Baru', + "Karyawan {$user->profile?->full_name} mengajukan cuti selama {$leaveRequest->total_days} hari mulai dari tanggal {$leaveRequest->start_date_formatted}.", + ['owner', 'developer'], + '/admin/hr/leave-requests', + ); } /** @@ -99,6 +111,16 @@ public function approve(LeaveRequest $leaveRequest, User $user): void $leaveRequest->verified_by_id = $user->id; $leaveRequest->save(); }); + + $leaveRequest->loadMissing('employee.user'); + if ($leaveRequest->employee?->user_id) { + $this->pushNotificationService->sendToUser( + '🏖️ Pengajuan Cuti Disetujui', + "Pengajuan cuti Anda selama {$leaveRequest->total_days} hari ({$leaveRequest->start_date_formatted} - {$leaveRequest->end_date_formatted}) telah disetujui.", + $leaveRequest->employee->user_id, + '/admin/hr/leave-requests', + ); + } } public function reject(LeaveRequest $leaveRequest, string $reason, User $user): void @@ -116,6 +138,16 @@ public function reject(LeaveRequest $leaveRequest, string $reason, User $user): 'rejected_by_id' => $user->id, ]); }); + + $leaveRequest->loadMissing('employee.user'); + if ($leaveRequest->employee?->user_id) { + $this->pushNotificationService->sendToUser( + '🏖️ Pengajuan Cuti Ditolak', + "Pengajuan cuti Anda selama {$leaveRequest->total_days} hari ({$leaveRequest->start_date_formatted} - {$leaveRequest->end_date_formatted}) ditolak dengan alasan: '{$reason}'.", + $leaveRequest->employee->user_id, + '/admin/hr/leave-requests', + ); + } } private function calculateTotalDays(Carbon $startDate, Carbon $endDate): int diff --git a/app/Services/Manage/CuttingService.php b/app/Services/Manage/CuttingService.php index d84ce55..0f8d524 100644 --- a/app/Services/Manage/CuttingService.php +++ b/app/Services/Manage/CuttingService.php @@ -11,6 +11,7 @@ use App\Models\RawMaterial; use App\Models\RawMaterialPrice; use App\Models\User; +use App\Services\System\PushNotificationService; use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; @@ -20,6 +21,10 @@ class CuttingService { + public function __construct( + private readonly PushNotificationService $pushNotificationService, + ) {} + /** * @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery */ @@ -207,7 +212,7 @@ public function findForEdit(Cutting $cutting): Cutting */ public function create(array $validated, User $user): Cutting { - return DB::transaction(function () use ($validated, $user): Cutting { + $cutting = DB::transaction(function () use ($validated, $user): Cutting { $materials = $this->buildMaterials($validated['materials']); $results = $this->buildResults($validated['results']); @@ -230,6 +235,15 @@ public function create(array $validated, User $user): Cutting return $cutting; }); + + $this->pushNotificationService->sendToRoles( + '✂️ Proses Potong Baru', + "Proses potong dengan deskripsi ({$cutting->description}) telah dimulai oleh {$user->profile?->full_name}.", + ['owner', 'developer'], + '/admin/manage/cuttings', + ); + + return $cutting; } /** @@ -286,6 +300,8 @@ public function delete(Cutting $cutting): void ]); } + $description = $cutting->description ?? '-'; + DB::transaction(function () use ($cutting): void { $cutting->load(['materials.rawMaterialPrice.rawMaterial']); $this->reverseTotalMaterialStock($cutting); @@ -294,6 +310,13 @@ public function delete(Cutting $cutting): void $cutting->results()->delete(); $cutting->delete(); }); + + $this->pushNotificationService->sendToRoles( + '🗑️ Proses Potong Dihapus', + "Proses potong dengan deskripsi {$description} telah dihapus.", + ['owner', 'developer'], + '/admin/manage/cuttings', + ); } public function transitionStatus( @@ -310,7 +333,7 @@ public function transitionStatus( ]); } - DB::transaction(function () use ($cutting, $status, $reason, $verificationNote, $results, $user): void { + DB::transaction(function () use ($cutting, $status, $verificationNote, $results, $user): void { $cutting->load(['materials', 'results']); if ($status === CuttingStatus::COMPLETED) { @@ -342,6 +365,30 @@ public function transitionStatus( $cutting->status = $status; $cutting->save(); }); + + $description = $cutting->description ?? '-'; + $message = match ($status) { + CuttingStatus::COMPLETED => "Proses potong dengan deskripsi '{$description}' telah selesai dan menunggu verifikasi.", + CuttingStatus::VERIFIED => "Proses potong dengan deskripsi '{$description}' telah diverifikasi.", + CuttingStatus::REJECTED => "Proses potong dengan deskripsi '{$description}' ditolak".($reason ? " dengan alasan: '{$reason}'" : '').'.', + CuttingStatus::IN_PROGRESS => "Proses potong dengan deskripsi '{$description}' dikembalikan ke proses.", + default => "Status proses potong dengan deskripsi '{$description}' telah diperbarui ke: {$status->label()}.", + }; + + $title = match ($status) { + CuttingStatus::COMPLETED => '✂️ Proses Potong Selesai', + CuttingStatus::VERIFIED => '✂️ Proses Potong Terverifikasi', + CuttingStatus::REJECTED => '✂️ Proses Potong Ditolak', + CuttingStatus::IN_PROGRESS => '✂️ Proses Potong Dikembalikan', + default => '✂️ Proses Potong Diperbarui', + }; + + $this->pushNotificationService->sendToRoles( + $title, + $message, + ['owner', 'developer'], + '/admin/manage/cuttings', + ); } /** diff --git a/app/Services/Manage/OrderService.php b/app/Services/Manage/OrderService.php index d9681fc..59b8a0c 100644 --- a/app/Services/Manage/OrderService.php +++ b/app/Services/Manage/OrderService.php @@ -12,6 +12,7 @@ use App\Models\ProductPrice; use App\Models\ProductVariant; use App\Models\User; +use App\Services\System\PushNotificationService; use App\Services\System\Setting\MarketplaceService; use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -25,6 +26,7 @@ class OrderService { public function __construct( private readonly MarketplaceService $marketplaceService, + private readonly PushNotificationService $pushNotificationService, ) {} /** @@ -270,7 +272,7 @@ public function resyncDraftPrices(User $user, string $priceTypeValue): array */ public function create(array $validated, User $user): Order { - return DB::transaction(function () use ($validated, $user): Order { + $order = DB::transaction(function () use ($validated, $user): Order { $priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']); /** @var EloquentCollection $draftItems */ @@ -316,6 +318,15 @@ public function create(array $validated, User $user): Order return $order; }); + + $this->pushNotificationService->sendToRoles( + '📦 Pesanan Baru', + "Pesanan baru {$order->order_number} senilai {$order->total_amount_formatted} telah dibuat oleh {$user->profile?->full_name}.", + ['owner', 'developer'], + '/admin/manage/orders', + ); + + return $order; } /** @@ -364,10 +375,20 @@ public function update(Order $order, array $validated): void $this->decrementStock($orderItem); } }); + + $this->pushNotificationService->sendToRoles( + '✏️ Pesanan Diperbarui', + "Pesanan {$order->order_number} senilai {$order->total_amount_formatted} telah diperbarui.", + ['owner', 'developer'], + '/admin/manage/orders', + ); } public function delete(Order $order): void { + $orderNumber = $order->order_number; + $totalAmount = $order->total_amount; + DB::transaction(function () use ($order): void { $order->load('items'); @@ -380,6 +401,13 @@ public function delete(Order $order): void $order->items()->delete(); $order->delete(); }); + + $this->pushNotificationService->sendToRoles( + '🗑️ Pesanan Dihapus', + "Pesanan {$orderNumber} senilai {$order->total_amount_formatted} telah dihapus.", + ['owner', 'developer'], + '/admin/manage/orders', + ); } public function transitionStatus(Order $order, OrderStatus $status): void @@ -402,6 +430,13 @@ public function transitionStatus(Order $order, OrderStatus $status): void $order->status = $status; $order->save(); }); + + $this->pushNotificationService->sendToRoles( + '📦 Status Pesanan Diubah', + "Pesanan {$order->order_number} diubah statusnya menjadi {$status->label()}.", + ['owner', 'developer'], + '/admin/manage/orders', + ); } /** diff --git a/app/Services/Manage/PurchaseService.php b/app/Services/Manage/PurchaseService.php index 38cf835..a0172a3 100644 --- a/app/Services/Manage/PurchaseService.php +++ b/app/Services/Manage/PurchaseService.php @@ -9,6 +9,7 @@ use App\Models\Supplier; use App\Models\User; use App\Services\Media\MediaService; +use App\Services\System\PushNotificationService; use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; @@ -23,6 +24,7 @@ class PurchaseService public function __construct( private readonly MediaService $mediaService, + private readonly PushNotificationService $pushNotificationService, ) {} /** @@ -207,7 +209,7 @@ public function removeDraftItem(User $user, RawMaterialPrice $rawMaterialPrice): */ public function create(array $validated, User $user): Purchase { - return DB::transaction(function () use ($validated, $user): Purchase { + $purchase = DB::transaction(function () use ($validated, $user): Purchase { /** @var EloquentCollection $draftItems */ $draftItems = $this->draftItemsQuery($user) ->lockForUpdate() @@ -242,6 +244,16 @@ public function create(array $validated, User $user): Purchase return $purchase; }); + + $purchase->load('supplier'); + $this->pushNotificationService->sendToRoles( + '🛒 Belanja Baru', + "Pembelian dari supplier {$purchase->supplier->name} senilai {$purchase->total_formatted} telah ditambahkan oleh {$user->profile?->full_name}.", + ['owner', 'developer'], + '/admin/manage/purchases', + ); + + return $purchase; } /** @@ -277,10 +289,21 @@ public function update(Purchase $purchase, array $validated): void $this->syncPhotos($purchase, $validated); }); + + $purchase->load('supplier'); + $this->pushNotificationService->sendToRoles( + '✏️ Belanja Diperbarui', + "Pembelian dari supplier {$purchase->supplier->name} senilai {$purchase->total_formatted} telah diperbarui.", + ['owner', 'developer'], + '/admin/manage/purchases', + ); } public function delete(Purchase $purchase): void { + $supplierName = $purchase->supplier->name; + $total = $purchase->total; + DB::transaction(function () use ($purchase): void { $purchase->load('items'); @@ -292,6 +315,13 @@ public function delete(Purchase $purchase): void $purchase->items()->delete(); $purchase->delete(); }); + + $this->pushNotificationService->sendToRoles( + '🗑️ Belanja Dihapus', + "Pembelian dari supplier {$supplierName} senilai {$total_formatted} telah dihapus.", + ['owner', 'developer'], + '/admin/manage/purchases', + ); } /** diff --git a/app/Services/Master/CategoryService.php b/app/Services/Master/CategoryService.php index fddd639..4067ad2 100644 --- a/app/Services/Master/CategoryService.php +++ b/app/Services/Master/CategoryService.php @@ -2,6 +2,7 @@ namespace App\Services\Master; +use App\Jobs\SendPushNotificationJob; use App\Models\Category; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; @@ -33,7 +34,12 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator */ public function create(array $validated): void { - Category::create($validated); + $category = Category::create($validated); + + SendPushNotificationJob::dispatch( + '📦 Kategori Baru', + "Kategori '{$category->name}' telah ditambahkan.", + ); } /** @@ -42,11 +48,22 @@ public function create(array $validated): void public function update(Category $category, array $validated): void { $category->fill($validated)->save(); + + SendPushNotificationJob::dispatch( + '✏️ Kategori Diperbarui', + "Kategori '{$category->name}' telah diperbarui.", + ); } public function delete(Category $category): void { + $name = $category->name; $category->delete(); + + SendPushNotificationJob::dispatch( + '🗑️ Kategori Dihapus', + "Kategori '{$name}' telah dihapus.", + ); } private function applySorting(Builder $query, string $sort, string $direction): void diff --git a/app/Services/System/PushNotificationService.php b/app/Services/System/PushNotificationService.php new file mode 100644 index 0000000..4188228 --- /dev/null +++ b/app/Services/System/PushNotificationService.php @@ -0,0 +1,34 @@ + $roles + */ + public function sendToRoles(string $title, string $body, array $roles, string $url = '/admin/dashboard'): void + { + SendPushNotificationJob::dispatch($title, $body, $url, $roles); + } + + /** + * Send notification to a specific user. + */ + public function sendToUser(string $title, string $body, int $userId, string $url = '/admin/dashboard'): void + { + SendPushNotificationJob::dispatch($title, $body, $url, [], $userId); + } + + /** + * Send notification to all users. + */ + public function sendToAll(string $title, string $body, string $url = '/admin/dashboard'): void + { + SendPushNotificationJob::dispatch($title, $body, $url, []); + } +} diff --git a/composer.json b/composer.json index b780c75..328adaf 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "laravel/framework": "^13.7", "laravel/tinker": "^3.0", "laravel/wayfinder": "^0.1.14", + "minishlink/web-push": "^9.0", "spatie/laravel-activitylog": "^5.0", "spatie/laravel-medialibrary": "^11.23", "spatie/laravel-permission": "^8.0", diff --git a/composer.lock b/composer.lock index cd0bfde..609671f 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": "75257a69f859a79b7713f638be63b5ed", + "content-hash": "396b0f4aff13715436b182ab532948db", "packages": [ { "name": "brick/math", @@ -2369,6 +2369,73 @@ ], "time": "2026-04-11T18:38:28+00:00" }, + { + "name": "minishlink/web-push", + "version": "v9.0.4", + "source": { + "type": "git", + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/f979f40b0017d2f86d82b9f21edbc515d031cc23", + "reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^7.9.2", + "php": ">=8.1", + "spomky-labs/base64url": "^2.0.4", + "symfony/polyfill-php82": "^v1.31.0", + "web-token/jwt-library": "^3.3.0|^4.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.91.3", + "phpstan/phpstan": "^2.1.2", + "phpunit/phpunit": "^10.5.44|^11.5.6", + "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/v9.0.4" + }, + "time": "2025-12-10T14:00:12+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -4505,6 +4572,181 @@ ], "time": "2026-01-12T07:42:22+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/pki-framework", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-mbstring": "*", + "php": ">=8.1", + "psr/clock": "^1.0" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31|^0.32", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0|^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-03-23T22:56:56+00:00" + }, { "name": "symfony/clock", "version": "v8.1.0", @@ -5999,6 +6241,86 @@ ], "time": "2026-04-10T16:19:22+00:00" }, + { + "name": "symfony/polyfill-php82", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "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\\Php82\\": "" + }, + "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.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1" + }, + "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-05-26T12:45:58+00:00" + }, { "name": "symfony/polyfill-php84", "version": "v1.38.1", @@ -7196,6 +7518,95 @@ } ], "time": "2026-04-26T05:33:54+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" } ], "packages-dev": [ diff --git a/config/webpush.php b/config/webpush.php new file mode 100644 index 0000000..85947e1 --- /dev/null +++ b/config/webpush.php @@ -0,0 +1,9 @@ + [ + 'public_key' => env('VAPID_PUBLIC_KEY', ''), + 'private_key' => env('VAPID_PRIVATE_KEY', ''), + 'subject' => env('VAPID_SUBJECT', env('APP_URL', 'http://localhost')), + ], +]; 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 new file mode 100644 index 0000000..b6474f9 --- /dev/null +++ b/database/migrations/2026_06_13_163706_create_push_subscriptions_table.php @@ -0,0 +1,31 @@ +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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('push_subscriptions'); + } +}; diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..e29eabb --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,36 @@ +{ + "name": "DST Collection", + "short_name": "DST Collection", + "description": "DST Collection - Progressive Web App", + "theme_color": "#171717", + "background_color": "#ffffff", + "display": "standalone", + "orientation": "portrait", + "scope": "/", + "start_url": "/", + "id": "/", + "icons": [ + { + "src": "/assets/pwa-64x64.png", + "sizes": "64x64", + "type": "image/png" + }, + { + "src": "/assets/pwa-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/assets/pwa-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/maskable-icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..74f61cd --- /dev/null +++ b/public/sw.js @@ -0,0 +1,70 @@ +// DST Collection — Service Worker +// Push Notification Handler + +const CACHE_NAME = 'dst-v1'; + +// ── Install ────────────────────────────────────────────────────────────────── +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +// ── Activate ───────────────────────────────────────────────────────────────── +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)), + ), + ) + .then(() => self.clients.claim()), + ); +}); + +// ── Push Notification ───────────────────────────────────────────────────────── +self.addEventListener('push', (event) => { + if (!event.data) return; + + let data = {}; + try { + data = event.data.json(); + } catch (e) { + data = { title: 'DST Collection', body: event.data.text() }; + } + + const title = data.title || 'DST Collection'; + const options = { + body: data.body || '', + icon: data.icon || '/assets/pwa-192x192.png', + badge: '/assets/pwa-64x64.png', + data: { url: data.url || '/admin/master/categories' }, + tag: 'dst-notification', + renotify: true, + requireInteraction: false, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +// ── Notification Click ──────────────────────────────────────────────────────── +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + const targetUrl = (event.notification.data && event.notification.data.url) + ? event.notification.data.url + : '/admin/dashboard'; + + event.waitUntil( + self.clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + for (const client of clientList) { + if ('navigate' in client) { + return client.navigate(targetUrl).then((c) => c && c.focus()); + } + } + return self.clients.openWindow(targetUrl); + }), + ); +}); diff --git a/resources/js/app.ts b/resources/js/app.ts index 47abc97..ac7e8be 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -1,14 +1,21 @@ -/// - import { createInertiaApp } from '@inertiajs/vue3'; -import { registerSW } from 'virtual:pwa-register'; import { createApp, h } from 'vue'; import 'vue-sonner/style.css'; import { Toaster } from '@/components/ui/sonner'; import { useFlashToast } from '@/composables/useFlashToast'; import { restoreConnection } from '@/lib/thermal-printer/stable-transport'; -registerSW({ immediate: true }); +if ('serviceWorker' in navigator) { + navigator.serviceWorker + .register('/sw.js', { scope: '/' }) + .then((reg) => { + console.log('[SW] Registered:', reg.scope); + }) + .catch((err) => { + console.error('[SW] Registration failed:', err); + }); +} + void restoreConnection(); const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; diff --git a/resources/js/components/UserNav.vue b/resources/js/components/UserNav.vue index a255ea4..9f78b73 100644 --- a/resources/js/components/UserNav.vue +++ b/resources/js/components/UserNav.vue @@ -1,6 +1,6 @@ diff --git a/resources/js/composables/usePushNotification.ts b/resources/js/composables/usePushNotification.ts new file mode 100644 index 0000000..9c018e0 --- /dev/null +++ b/resources/js/composables/usePushNotification.ts @@ -0,0 +1,145 @@ +import { usePage } from '@inertiajs/vue3'; +import { computed, onMounted, ref } from 'vue'; + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + const rawData = atob(base64); + return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); +} + +export function usePushNotification() { + const isSupported = computed( + () => + 'Notification' in window && + 'serviceWorker' in navigator && + 'PushManager' in window, + ); + + const isSubscribed = ref(false); + const isLoading = ref(false); + + const page = usePage(); + const vapidPublicKey = (page.props as Record) + .vapidPublicKey as string | undefined; + + async function checkSubscriptionStatus(): Promise { + if (!isSupported.value) return; + + try { + const registration = await navigator.serviceWorker.ready; + const subscription = + await registration.pushManager.getSubscription(); + isSubscribed.value = !!subscription; + } catch { + isSubscribed.value = false; + } + } + + async function subscribe(): Promise { + if (!isSupported.value || isLoading.value) return; + + const permission = await Notification.requestPermission(); + if (permission !== 'granted') return; + + isLoading.value = true; + + try { + const registration = await navigator.serviceWorker.ready; + const publicKey = + vapidPublicKey ?? import.meta.env.VITE_VAPID_PUBLIC_KEY ?? ''; + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + + const json = subscription.toJSON(); + + await fetch('/push-subscriptions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': + ( + document.querySelector( + 'meta[name="csrf-token"]', + ) as HTMLMetaElement + )?.content ?? '', + }, + body: JSON.stringify({ + endpoint: subscription.endpoint, + publicKey: json.keys?.p256dh ?? '', + authToken: json.keys?.auth ?? '', + }), + }); + + isSubscribed.value = true; + } catch (error) { + console.error('[PushNotification] Subscribe failed:', error); + } finally { + isLoading.value = false; + } + } + + async function unsubscribe(): Promise { + if (!isSupported.value || isLoading.value) return; + + isLoading.value = true; + + try { + const registration = await navigator.serviceWorker.ready; + const subscription = + await registration.pushManager.getSubscription(); + + if (!subscription) { + isSubscribed.value = false; + return; + } + + await fetch('/push-subscriptions', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': + ( + document.querySelector( + 'meta[name="csrf-token"]', + ) as HTMLMetaElement + )?.content ?? '', + }, + body: JSON.stringify({ endpoint: subscription.endpoint }), + }); + + await subscription.unsubscribe(); + isSubscribed.value = false; + } catch (error) { + console.error('[PushNotification] Unsubscribe failed:', error); + } finally { + isLoading.value = false; + } + } + + async function toggle(): Promise { + if (isSubscribed.value) { + await unsubscribe(); + } else { + await subscribe(); + } + } + + onMounted(() => { + void checkSubscriptionStatus(); + }); + + return { + isSupported, + isSubscribed, + isLoading, + subscribe, + unsubscribe, + toggle, + }; +} diff --git a/resources/js/sw.ts b/resources/js/sw.ts new file mode 100644 index 0000000..49e4d61 --- /dev/null +++ b/resources/js/sw.ts @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const sw = self as unknown as ServiceWorkerGlobalScope; + +interface ExtendableEvent extends Event { + waitUntil(fn: Promise): void; +} +interface PushEvent extends ExtendableEvent { + data: PushMessageData | null; +} +interface PushMessageData { + json(): unknown; + text(): string; +} +interface NotificationEvent extends ExtendableEvent { + notification: Notification; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Service Worker: Install & Activate +// ────────────────────────────────────────────────────────────────────────────── + +sw.addEventListener('install', () => { + sw.skipWaiting(); +}); + +sw.addEventListener('activate', (event: Event) => { + (event as ExtendableEvent).waitUntil(sw.clients.claim()); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Push Notification Handler +// ────────────────────────────────────────────────────────────────────────────── + +sw.addEventListener('push', (event: Event) => { + const pushEvent = event as PushEvent; + + if (!pushEvent.data) return; + + let data: { title?: string; body?: string; icon?: string; url?: string } = + {}; + + try { + data = pushEvent.data.json() as typeof data; + } catch { + data = { title: 'DST Collection', body: pushEvent.data.text() }; + } + + const title = data.title ?? 'DST Collection'; + const options: NotificationOptions = { + body: data.body ?? '', + icon: data.icon ?? '/assets/pwa-192x192.png', + badge: '/assets/pwa-64x64.png', + data: { url: data.url ?? '/admin/master/categories' }, + tag: 'dst-notification', + renotify: true, + }; + + pushEvent.waitUntil(sw.registration.showNotification(title, options)); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Notification Click Handler +// ────────────────────────────────────────────────────────────────────────────── + +sw.addEventListener('notificationclick', (event: Event) => { + const notifEvent = event as NotificationEvent; + notifEvent.notification.close(); + + const targetUrl: string = + (notifEvent.notification.data?.url as string) ?? '/admin/dashboard'; + + notifEvent.waitUntil( + sw.clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + for (const client of clientList) { + if ('focus' in client) { + return (client as WindowClient) + .navigate(targetUrl) + .then((c) => c?.focus()); + } + } + return sw.clients.openWindow(targetUrl); + }), + ); +}); diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 1ce3d45..8aa0ff9 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -16,6 +16,7 @@ + diff --git a/routes/web.php b/routes/web.php index 9c3d1a7..c7029ed 100644 --- a/routes/web.php +++ b/routes/web.php @@ -27,6 +27,7 @@ use App\Http\Controllers\Admin\System\SettingController; use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\Auth\LogoutController; +use App\Http\Controllers\PushSubscriptionController; use Illuminate\Support\Facades\Route; Route::inertia('/', 'Welcome')->name('home'); @@ -39,6 +40,10 @@ Route::middleware(['auth', 'permission:'.Permission::DASHBOARD_VIEW->value])->group(function () { Route::post('/auth/logout', [LogoutController::class, 'store'])->name('logout'); + // Push Notifications + Route::post('/push-subscriptions', [PushSubscriptionController::class, 'store'])->name('push-subscriptions.store'); + Route::delete('/push-subscriptions', [PushSubscriptionController::class, 'destroy'])->name('push-subscriptions.destroy'); + Route::prefix('admin')->name('admin.')->group(function () { Route::get('/dashboard', [DashboardController::class, 'index']) ->name('dashboard'); diff --git a/vite.config.ts b/vite.config.ts index 4fcada6..3b01ecf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,13 +32,6 @@ const manifestIcons = [ }, ] as const; -const publicIcons = [ - { src: '/favicon.ico' }, - { src: '/favicon.svg' }, - { src: '/assets/apple-touch-icon-180x180.png' }, - { src: '/assets/logo.png' }, -]; - export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); const appName = env.VITE_APP_NAME || 'DST Collection'; @@ -72,27 +65,18 @@ export default defineConfig(({ mode }) => { scope: '/', base: '/', registerType: 'autoUpdate', + // Kita daftarkan SW manual di app.ts (public/sw.js yang support push) + // VitePWA hanya generate manifest di sini + injectRegister: null, devOptions: { enabled: false, }, - includeAssets: [], workbox: { globPatterns: [ '**/*.{js,css,html,ico,jpg,png,svg,woff,woff2,ttf,eot}', ], navigateFallback: '/', navigateFallbackDenylist: [/^\/telescope/], - additionalManifestEntries: [ - { url: '/', revision: `${Date.now()}` }, - ...manifestIcons.map((icon) => ({ - url: icon.src, - revision: `${Date.now()}`, - })), - ...publicIcons.map((icon) => ({ - url: icon.src, - revision: `${Date.now()}`, - })), - ], maximumFileSizeToCacheInBytes: 3_000_000, }, manifest: {