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({
+ Menampilkan kategori dari notifikasi. + +
+ )}