feat: add push notification functionality and service worker

- Implemented push notifications with a service worker (sw.ts) to handle caching and notifications.
- Added API routes for push subscription and notification management in routes/api.php.
- Created NotificationTest to validate notification service functionality and ensure correct notifications are sent based on user roles and actions.
- Updated Permissions component to manage notification permissions and subscriptions.
- Enhanced CategoryIndex component to highlight categories based on notifications.
- Excluded service worker from TypeScript compilation in tsconfig.json.
- Updated Vite configuration to include service worker in the build process.
This commit is contained in:
Yoga Pangestu 2026-08-01 11:36:20 +07:00
parent 23cc327190
commit 95be00c9d1
32 changed files with 2574 additions and 210 deletions

View File

@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AppNotification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function index(Request $request): JsonResponse
{
$notifications = $request->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.']);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\DestroyPushSubscriptionRequest;
use App\Http\Requests\Api\StorePushSubscriptionRequest;
use App\Services\PushSubscriptionService;
use Illuminate\Http\JsonResponse;
class PushSubscriptionController extends Controller
{
public function __construct(
private readonly PushSubscriptionService $service,
) {}
public function store(StorePushSubscriptionRequest $request): JsonResponse
{
return $this->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,
);
}
}

View File

@ -10,6 +10,8 @@ class PermissionController extends Controller
{ {
public function edit(): Response public function edit(): Response
{ {
return Inertia::render('settings/permissions'); return Inertia::render('settings/permissions', [
'vapidPublicKey' => config('webpush.vapid.public_key'),
]);
} }
} }

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class DestroyPushSubscriptionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'endpoint' => ['required', 'url', 'max:500'],
];
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class StorePushSubscriptionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'endpoint' => ['required', 'url', 'max:500'],
'public_key' => ['nullable', 'string', 'max:255'],
'auth_token' => ['nullable', 'string', 'max:255'],
'content_encoding' => ['nullable', 'string', 'max:255'],
];
}
}

View File

@ -14,10 +14,11 @@ public function authorize(): bool
public function rules(): array public function rules(): array
{ {
return [ return [
'per_page' => 'nullable|integer|in:25,50,100,999999', 'per_page' => ['nullable', 'integer', 'in:25,50,100,999999'],
'search' => 'nullable|string|max:255', 'search' => ['nullable', 'string', 'max:255'],
'sort' => 'nullable|string', 'sort' => ['nullable', 'string'],
'direction' => 'nullable|string|in:asc,desc', 'direction' => ['nullable', 'string', 'in:asc,desc'],
'highlight' => ['nullable', 'integer'],
]; ];
} }

View File

@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; 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] #[Scope]
protected function cancelled(Builder $query): void protected function cancelled(Builder $query): void
{ {

View File

@ -14,13 +14,14 @@
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use NotificationChannels\WebPush\HasPushSubscriptions;
use Spatie\Permission\Traits\HasRoles; use Spatie\Permission\Traits\HasRoles;
#[Guarded(['id'])] #[Guarded(['id'])]
#[Appends(['full_name'])] #[Appends(['full_name'])]
class User extends Authenticatable class User extends Authenticatable
{ {
use HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable; use HasFactory, HasPushSubscriptions, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
protected function casts(): array protected function casts(): array
{ {
@ -49,7 +50,7 @@ protected function name(): Attribute
protected function fullName(): Attribute protected function fullName(): Attribute
{ {
return Attribute::make( 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); return $this->hasMany(PurchaseItem::class);
} }
public function pushSubscriptions(): HasMany
{
return $this->hasMany(PushSubscription::class);
}
public function rejections(): HasMany public function rejections(): HasMany
{ {
return $this->hasMany(Rejection::class, 'rejected_by_id'); return $this->hasMany(Rejection::class, 'rejected_by_id');

View File

@ -0,0 +1,33 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use NotificationChannels\WebPush\WebPushChannel;
use NotificationChannels\WebPush\WebPushMessage;
class WebPushNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public string $title,
public string $body,
public string $icon = '/icon-192x192.png',
) {}
public function via(object $notifiable): array
{
return [WebPushChannel::class];
}
public function toWebPush(object $notifiable, mixed $notification): WebPushMessage
{
return (new WebPushMessage)
->title($this->title)
->icon($this->icon)
->body($this->body);
}
}

View File

@ -5,8 +5,10 @@
use App\Enums\CashTransactionType; use App\Enums\CashTransactionType;
use App\Models\CashAccount; use App\Models\CashAccount;
use App\Models\CashTransaction; use App\Models\CashTransaction;
use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -48,7 +50,7 @@ public function paginatedTransactions(int $perPage = 15, string $search = '', st
$cashAccount = $this->get(); $cashAccount = $this->get();
if (! $cashAccount) { if (! $cashAccount) {
return new \Illuminate\Pagination\LengthAwarePaginator(collect(), 0, $perPage); return new PaginationLengthAwarePaginator(collect(), 0, $perPage);
} }
$paginator = $cashAccount->cashTransactions() $paginator = $cashAccount->cashTransactions()
@ -91,7 +93,7 @@ private function formatTransaction(CashTransaction $transaction): array
public function deposit(array $data): CashTransaction public function deposit(array $data): CashTransaction
{ {
return DB::transaction(function () use ($data) { $transaction = DB::transaction(function () use ($data) {
$cashAccount = CashAccount::firstOrFail(); $cashAccount = CashAccount::firstOrFail();
$newBalance = $cashAccount->balance + $data['amount']; $newBalance = $cashAccount->balance + $data['amount'];
@ -112,11 +114,20 @@ public function deposit(array $data): CashTransaction
return $transaction; 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 public function withdrawal(array $data): CashTransaction
{ {
return DB::transaction(function () use ($data) { $transaction = DB::transaction(function () use ($data) {
$cashAccount = CashAccount::firstOrFail(); $cashAccount = CashAccount::firstOrFail();
if ($cashAccount->balance < $data['amount']) { if ($cashAccount->balance < $data['amount']) {
@ -143,11 +154,20 @@ public function withdrawal(array $data): CashTransaction
return $transaction; 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 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])) { if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'amount' => 'Transaksi ini tidak dapat diedit.', 'amount' => 'Transaksi ini tidak dapat diedit.',
@ -195,6 +215,15 @@ public function updateTransaction(CashTransaction $transaction, array $data): Ca
return $transaction; 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 public function deleteTransaction(CashTransaction $transaction): bool
@ -223,7 +252,18 @@ public function deleteTransaction(CashTransaction $transaction): bool
$transaction->clearMediaCollection('receipts'); $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;
}); });
} }

View File

@ -7,6 +7,7 @@
use App\Models\CashAccount; use App\Models\CashAccount;
use App\Models\CashTransaction; use App\Models\CashTransaction;
use App\Models\EmployeeAdvance; use App\Models\EmployeeAdvance;
use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; 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 public function create(array $data): EmployeeAdvance
{ {
return DB::transaction(function () use ($data) { $employeeAdvance = DB::transaction(function () use ($data) {
$cashAccount = CashAccount::firstOrFail(); $cashAccount = CashAccount::firstOrFail();
$employee = auth()->user()->employee; $employee = auth()->user()->employee;
@ -69,6 +70,18 @@ public function create(array $data): EmployeeAdvance
'status' => EmployeeAdvanceStatus::PENDING, '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 public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeAdvance
@ -128,12 +141,20 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
'verified_at' => now(), '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; return $employeeAdvance;
} }
public function pay(EmployeeAdvance $employeeAdvance): EmployeeAdvance public function pay(EmployeeAdvance $employeeAdvance): EmployeeAdvance
{ {
return DB::transaction(function () use ($employeeAdvance) { $employeeAdvance = DB::transaction(function () use ($employeeAdvance) {
$cashAccount = CashAccount::firstOrFail(); $cashAccount = CashAccount::firstOrFail();
$newBalance = $cashAccount->balance + $employeeAdvance->amount; $newBalance = $cashAccount->balance + $employeeAdvance->amount;
@ -158,5 +179,15 @@ public function pay(EmployeeAdvance $employeeAdvance): EmployeeAdvance
return $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;
} }
} }

View File

@ -6,6 +6,7 @@
use App\Models\CashAccount; use App\Models\CashAccount;
use App\Models\CashTransaction; use App\Models\CashTransaction;
use App\Models\Expense; use App\Models\Expense;
use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -75,7 +76,7 @@ private function formatExpense(Expense $expense): array
public function create(array $data): Expense public function create(array $data): Expense
{ {
return DB::transaction(function () use ($data) { $expense = DB::transaction(function () use ($data) {
$cashAccount = CashAccount::firstOrFail(); $cashAccount = CashAccount::firstOrFail();
if ($cashAccount->balance < $data['amount']) { if ($cashAccount->balance < $data['amount']) {
@ -109,11 +110,20 @@ public function create(array $data): Expense
return $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 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(); $cashAccount = CashAccount::firstOrFail();
$cashTransaction = $expense->cashTransaction; $cashTransaction = $expense->cashTransaction;
@ -166,6 +176,15 @@ public function update(Expense $expense, array $data): Expense
return $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 public function delete(Expense $expense): bool
@ -184,7 +203,18 @@ public function delete(Expense $expense): bool
$expense->clearMediaCollection('receipts'); $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;
}); });
} }

View File

@ -9,6 +9,7 @@
use App\Models\CashTransaction; use App\Models\CashTransaction;
use App\Models\Payroll; use App\Models\Payroll;
use App\Models\PayrollPeriod; use App\Models\PayrollPeriod;
use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; 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(); $cashAccount = CashAccount::firstOrFail();
$newBalance = $cashAccount->balance + $payroll->total_amount; $newBalance = $cashAccount->balance + $payroll->total_amount;
@ -145,6 +146,18 @@ public function pay(Payroll $payroll): Payroll
return $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 public function cancel(Payroll $payroll): Payroll
@ -165,6 +178,16 @@ public function cancel(Payroll $payroll): Payroll
'status' => PayrollStatus::CANCELLED, '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; return $payroll;
} }
} }

View File

@ -4,6 +4,7 @@
use App\Models\Attendance; use App\Models\Attendance;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -117,6 +118,13 @@ public function checkIn(array $data): Attendance
$this->registerMedia($attendance, $data['photo'], 'check-in'); $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; return $attendance;
} }
@ -132,6 +140,13 @@ public function checkOut(Attendance $attendance, array $data): Attendance
$this->registerMedia($attendance, $data['photo'], 'check-out'); $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; return $attendance;
} }

View File

@ -4,6 +4,7 @@
use App\Enums\LeaveRequestStatus; use App\Enums\LeaveRequestStatus;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Services\NotificationService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -37,7 +38,7 @@ public function paginated(int $perPage = 15, string $search = '', string $sort =
public function create(array $data): LeaveRequest public function create(array $data): LeaveRequest
{ {
return DB::transaction(function () use ($data) { $leaveRequest = DB::transaction(function () use ($data) {
$employee = auth()->user()->employee; $employee = auth()->user()->employee;
if (! $employee) { if (! $employee) {
@ -56,6 +57,18 @@ public function create(array $data): LeaveRequest
'status' => LeaveRequestStatus::PENDING, '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 public function update(LeaveRequest $leaveRequest, array $data): LeaveRequest
@ -88,6 +101,16 @@ public function approve(LeaveRequest $leaveRequest): LeaveRequest
'verified_at' => now(), '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; return $leaveRequest;
} }
@ -99,6 +122,16 @@ public function reject(LeaveRequest $leaveRequest): LeaveRequest
'verified_at' => now(), '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; return $leaveRequest;
} }
} }

View File

@ -5,6 +5,7 @@
use App\Models\Product; use App\Models\Product;
use App\Models\ProductPrice; use App\Models\ProductPrice;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection; 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 public function create(array $data): Product
{ {
return DB::transaction(function () use ($data) { $product = DB::transaction(function () use ($data) {
$product = Product::create([ $product = Product::create([
'name' => $data['name'], 'name' => $data['name'],
'description' => $data['description'] ?? null, 'description' => $data['description'] ?? null,
@ -110,6 +111,15 @@ public function create(array $data): Product
return $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 public function getForEdit(Product $product): array
@ -150,7 +160,7 @@ public function getForEdit(Product $product): array
public function update(Product $product, array $data): Product public function update(Product $product, array $data): Product
{ {
return DB::transaction(function () use ($product, $data) { $product = DB::transaction(function () use ($product, $data) {
$product->update([ $product->update([
'name' => $data['name'], 'name' => $data['name'],
'description' => $data['description'] ?? null, 'description' => $data['description'] ?? null,
@ -210,6 +220,15 @@ public function update(Product $product, array $data): Product
return $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 public function delete(Product $product): bool

View File

@ -0,0 +1,31 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Notifications\WebPushNotification;
class NotificationService
{
public static function notify(array $roles, string $title, string $body, string $url, ?User $additionalUser = null): void
{
$users = User::query()
->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));
});
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Http\JsonResponse;
class PushSubscriptionService
{
public function store(
User $user,
string $endpoint,
?string $publicKey,
?string $authToken,
?string $contentEncoding,
): JsonResponse {
$user->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.']);
}
}

View File

@ -11,6 +11,7 @@
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"inertiajs/inertia-laravel": "^3.0", "inertiajs/inertia-laravel": "^3.0",
"laravel-notification-channels/webpush": "^11.0",
"laravel/chisel": "^0.1.0", "laravel/chisel": "^0.1.0",
"laravel/fortify": "^1.37.2", "laravel/fortify": "^1.37.2",
"laravel/framework": "^13.17", "laravel/framework": "^13.17",

495
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "5891ce301b1670b38277f3a3c2ac2426", "content-hash": "b8312d5622d94884c0607ebccdfd2ed8",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@ -214,16 +214,16 @@
}, },
{ {
"name": "brick/math", "name": "brick/math",
"version": "0.18.0", "version": "0.17.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/brick/math.git", "url": "https://github.com/brick/math.git",
"reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", "url": "https://api.github.com/repos/brick/math/zipball/8189e751995f9e15729c1aa2f89fa8f166ffe818",
"reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -261,7 +261,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/brick/math/issues", "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": [ "funding": [
{ {
@ -269,7 +269,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-06-14T18:21:03+00:00" "time": "2026-05-25T20:34:43+00:00"
}, },
{ {
"name": "carbonphp/carbon-doctrine-types", "name": "carbonphp/carbon-doctrine-types",
@ -1510,6 +1510,72 @@
}, },
"time": "2026-07-02T12:45:54+00:00" "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", "name": "laravel/chisel",
"version": "v0.1.1", "version": "v0.1.1",
@ -2865,6 +2931,77 @@
], ],
"time": "2026-04-11T18:38:28+00:00" "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", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@ -5325,6 +5462,71 @@
], ],
"time": "2026-06-22T07:55:44+00:00" "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", "name": "spomky-labs/cbor-php",
"version": "3.3.0", "version": "3.3.0",
@ -5584,16 +5786,16 @@
}, },
{ {
"name": "symfony/console", "name": "symfony/console",
"version": "v8.1.1", "version": "v8.1.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/console.git", "url": "https://github.com/symfony/console.git",
"reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d" "reference": "535e18a1b8925f6c01a55b171d157ab66c2ace15"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", "url": "https://api.github.com/repos/symfony/console/zipball/535e18a1b8925f6c01a55b171d157ab66c2ace15",
"reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", "reference": "535e18a1b8925f6c01a55b171d157ab66c2ace15",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -5660,7 +5862,7 @@
"terminal" "terminal"
], ],
"support": { "support": {
"source": "https://github.com/symfony/console/tree/v8.1.1" "source": "https://github.com/symfony/console/tree/v8.1.2"
}, },
"funding": [ "funding": [
{ {
@ -5680,7 +5882,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-16T12:55:20+00:00" "time": "2026-07-27T13:58:19+00:00"
}, },
{ {
"name": "symfony/css-selector", "name": "symfony/css-selector",
@ -5824,16 +6026,16 @@
}, },
{ {
"name": "symfony/error-handler", "name": "symfony/error-handler",
"version": "v8.1.0", "version": "v8.1.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/error-handler.git", "url": "https://github.com/symfony/error-handler.git",
"reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5" "reference": "dc98404be5e8c949815e23fee1928f5de4f3f5d3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/d8aeb1abd3fef84795567850d3a567bdb5945ee5", "url": "https://api.github.com/repos/symfony/error-handler/zipball/dc98404be5e8c949815e23fee1928f5de4f3f5d3",
"reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5", "reference": "dc98404be5e8c949815e23fee1928f5de4f3f5d3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -5881,7 +6083,7 @@
"description": "Provides tools to manage errors and ease debugging PHP code", "description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/error-handler/tree/v8.1.0" "source": "https://github.com/symfony/error-handler/tree/v8.1.2"
}, },
"funding": [ "funding": [
{ {
@ -5901,20 +6103,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-29T05:06:50+00:00" "time": "2026-07-22T15:42:13+00:00"
}, },
{ {
"name": "symfony/event-dispatcher", "name": "symfony/event-dispatcher",
"version": "v8.1.1", "version": "v8.1.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/event-dispatcher.git", "url": "https://github.com/symfony/event-dispatcher.git",
"reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" "reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/c14c05a9e6da7f5e375e6efc28952c7e7dbddffb",
"reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", "reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb",
"shasum": "" "shasum": ""
}, },
"require": { "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", "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.2"
}, },
"funding": [ "funding": [
{ {
@ -5987,7 +6189,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-09T12:28:30+00:00" "time": "2026-07-22T15:42:13+00:00"
}, },
{ {
"name": "symfony/event-dispatcher-contracts", "name": "symfony/event-dispatcher-contracts",
@ -6210,16 +6412,16 @@
}, },
{ {
"name": "symfony/http-foundation", "name": "symfony/http-foundation",
"version": "v8.1.1", "version": "v8.1.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/http-foundation.git", "url": "https://github.com/symfony/http-foundation.git",
"reference": "6a168c8fcee806b57ac020244da14293d1f9a883" "reference": "9943adbf5a64e2951a8d9eb0485310d55624f0e8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/6a168c8fcee806b57ac020244da14293d1f9a883", "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9943adbf5a64e2951a8d9eb0485310d55624f0e8",
"reference": "6a168c8fcee806b57ac020244da14293d1f9a883", "reference": "9943adbf5a64e2951a8d9eb0485310d55624f0e8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -6267,7 +6469,7 @@
"description": "Defines an object-oriented layer for the HTTP specification", "description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/http-foundation/tree/v8.1.1" "source": "https://github.com/symfony/http-foundation/tree/v8.1.2"
}, },
"funding": [ "funding": [
{ {
@ -6287,7 +6489,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-12T08:43:41+00:00" "time": "2026-07-29T07:22:54+00:00"
}, },
{ {
"name": "symfony/http-kernel", "name": "symfony/http-kernel",
@ -6400,16 +6602,16 @@
}, },
{ {
"name": "symfony/mailer", "name": "symfony/mailer",
"version": "v8.1.1", "version": "v8.1.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/mailer.git", "url": "https://github.com/symfony/mailer.git",
"reference": "4fa583a7377f28d54e4de442fba76375b2e20a12" "reference": "221c7f326ace1ac2baee8331d829d5b7f04f4d53"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/mailer/zipball/4fa583a7377f28d54e4de442fba76375b2e20a12", "url": "https://api.github.com/repos/symfony/mailer/zipball/221c7f326ace1ac2baee8331d829d5b7f04f4d53",
"reference": "4fa583a7377f28d54e4de442fba76375b2e20a12", "reference": "221c7f326ace1ac2baee8331d829d5b7f04f4d53",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -6456,7 +6658,7 @@
"description": "Helps sending emails", "description": "Helps sending emails",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/mailer/tree/v8.1.1" "source": "https://github.com/symfony/mailer/tree/v8.1.2"
}, },
"funding": [ "funding": [
{ {
@ -6476,20 +6678,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-16T12:55:20+00:00" "time": "2026-07-28T07:35:25+00:00"
}, },
{ {
"name": "symfony/mime", "name": "symfony/mime",
"version": "v8.1.0", "version": "v8.1.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/mime.git", "url": "https://github.com/symfony/mime.git",
"reference": "b164ae7e3f7915aacfe9ee155f2f358502440664" "reference": "75f4779d4ec2e13f24a3a7e5d0347c340c7ca627"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/b164ae7e3f7915aacfe9ee155f2f358502440664", "url": "https://api.github.com/repos/symfony/mime/zipball/75f4779d4ec2e13f24a3a7e5d0347c340c7ca627",
"reference": "b164ae7e3f7915aacfe9ee155f2f358502440664", "reference": "75f4779d4ec2e13f24a3a7e5d0347c340c7ca627",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -6542,7 +6744,7 @@
"mime-type" "mime-type"
], ],
"support": { "support": {
"source": "https://github.com/symfony/mime/tree/v8.1.0" "source": "https://github.com/symfony/mime/tree/v8.1.2"
}, },
"funding": [ "funding": [
{ {
@ -6562,7 +6764,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-29T05:06:50+00:00" "time": "2026-07-29T08:00:47+00:00"
}, },
{ {
"name": "symfony/polyfill-ctype", "name": "symfony/polyfill-ctype",
@ -7070,6 +7272,86 @@
], ],
"time": "2026-04-10T16:19:22+00:00" "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", "name": "symfony/polyfill-php84",
"version": "v1.38.1", "version": "v1.38.1",
@ -7627,16 +7909,16 @@
}, },
{ {
"name": "symfony/routing", "name": "symfony/routing",
"version": "v8.1.0", "version": "v8.1.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/routing.git", "url": "https://github.com/symfony/routing.git",
"reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3" "reference": "1058d4e13bb81dd9a6f7565686df7e13b880cdbd"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/routing/zipball/fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", "url": "https://api.github.com/repos/symfony/routing/zipball/1058d4e13bb81dd9a6f7565686df7e13b880cdbd",
"reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", "reference": "1058d4e13bb81dd9a6f7565686df7e13b880cdbd",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -7683,7 +7965,7 @@
"url" "url"
], ],
"support": { "support": {
"source": "https://github.com/symfony/routing/tree/v8.1.0" "source": "https://github.com/symfony/routing/tree/v8.1.2"
}, },
"funding": [ "funding": [
{ {
@ -7703,7 +7985,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-29T05:06:50+00:00" "time": "2026-07-22T15:42:13+00:00"
}, },
{ {
"name": "symfony/serializer", "name": "symfony/serializer",
@ -7893,16 +8175,16 @@
}, },
{ {
"name": "symfony/string", "name": "symfony/string",
"version": "v8.1.0", "version": "v8.1.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/string.git", "url": "https://github.com/symfony/string.git",
"reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc",
"reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -7959,7 +8241,7 @@
"utf8" "utf8"
], ],
"support": { "support": {
"source": "https://github.com/symfony/string/tree/v8.1.0" "source": "https://github.com/symfony/string/tree/v8.1.2"
}, },
"funding": [ "funding": [
{ {
@ -7979,7 +8261,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-29T05:06:50+00:00" "time": "2026-07-28T07:35:25+00:00"
}, },
{ {
"name": "symfony/translation", "name": "symfony/translation",
@ -8318,16 +8600,16 @@
}, },
{ {
"name": "symfony/var-dumper", "name": "symfony/var-dumper",
"version": "v8.1.1", "version": "v8.1.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/var-dumper.git", "url": "https://github.com/symfony/var-dumper.git",
"reference": "40096a2515a979f3125c5c928603995b8664c62a" "reference": "865103cf742a039f34645b971fc3ace308d6c167"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/40096a2515a979f3125c5c928603995b8664c62a", "url": "https://api.github.com/repos/symfony/var-dumper/zipball/865103cf742a039f34645b971fc3ace308d6c167",
"reference": "40096a2515a979f3125c5c928603995b8664c62a", "reference": "865103cf742a039f34645b971fc3ace308d6c167",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -8343,7 +8625,7 @@
"symfony/http-kernel": "^7.4|^8.0", "symfony/http-kernel": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0", "symfony/process": "^7.4|^8.0",
"symfony/uid": "^7.4|^8.0", "symfony/uid": "^7.4|^8.0",
"twig/twig": "^3.12" "twig/twig": "^3.12|^4.0"
}, },
"bin": [ "bin": [
"Resources/bin/var-dump-server" "Resources/bin/var-dump-server"
@ -8381,7 +8663,7 @@
"dump" "dump"
], ],
"support": { "support": {
"source": "https://github.com/symfony/var-dumper/tree/v8.1.1" "source": "https://github.com/symfony/var-dumper/tree/v8.1.2"
}, },
"funding": [ "funding": [
{ {
@ -8401,7 +8683,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-09T10:54:51+00:00" "time": "2026-07-22T15:42:13+00:00"
}, },
{ {
"name": "tijsverkoyen/css-to-inline-styles", "name": "tijsverkoyen/css-to-inline-styles",
@ -8773,6 +9055,95 @@
], ],
"time": "2026-05-31T15:00:08+00:00" "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", "name": "webmozart/assert",
"version": "2.4.1", "version": "2.4.1",

46
config/webpush.php Normal file
View File

@ -0,0 +1,46 @@
<?php
use NotificationChannels\WebPush\PushSubscription;
return [
/**
* These are the keys for authentication (VAPID).
* These keys must be safely stored and should not change.
*/
'vapid' => [
'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),
];

View File

@ -6,26 +6,31 @@
return new class extends Migration return new class extends Migration
{ {
public function up(): void /**
* Run the migrations.
*
* @return void
*/
public function up()
{ {
Schema::create('push_subscriptions', function (Blueprint $table) { Schema::connection(config('webpush.database_connection'))->create(config('webpush.table_name'), function (Blueprint $table) {
$table->id(); $table->bigIncrements('id');
$table->morphs('subscribable', 'push_subscriptions_subscribable_morph_idx');
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->string('endpoint', 500)->unique();
$table->string('public_key')->nullable();
$table->string('endpoint', 500); $table->string('auth_token')->nullable();
$table->string('public_key', 255)->nullable(); $table->string('content_encoding')->nullable();
$table->string('auth_token', 255)->nullable(); $table->timestamps();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->unique('endpoint');
}); });
} }
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'));
} }
}; };

View File

@ -1,5 +1,6 @@
import { Breadcrumbs } from '@/components/breadcrumbs'; import { Breadcrumbs } from '@/components/breadcrumbs';
import { NavUser } from '@/components/nav-user'; import { NavUser } from '@/components/nav-user';
import { NotificationBell } from '@/components/notification-bell';
import { SidebarTrigger } from '@/components/ui/sidebar'; import { SidebarTrigger } from '@/components/ui/sidebar';
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types'; import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
@ -14,7 +15,8 @@ export function AppSidebarHeader({
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Breadcrumbs breadcrumbs={breadcrumbs} /> <Breadcrumbs breadcrumbs={breadcrumbs} />
</div> </div>
<div className="ml-auto"> <div className="ml-auto flex items-center gap-2">
<NotificationBell />
<NavUser /> <NavUser />
</div> </div>
</header> </header>

View File

@ -0,0 +1,266 @@
import { Bell, Check, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface Notification {
id: number;
title: string;
body: string | null;
url: string | null;
is_read: boolean;
created_at: string;
}
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const fetchUnreadCount = useCallback(async () => {
try {
const response = await fetch('/api/notifications/unread-count', {
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
if (response.ok && mountedRef.current) {
const data = await response.json();
setUnreadCount(data.count);
}
} catch {
// Silently fail
}
}, []);
const fetchNotifications = useCallback(async () => {
try {
const response = await fetch('/api/notifications', {
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
if (response.ok && mountedRef.current) {
const data = await response.json();
setNotifications(data);
}
} catch {
// Silently fail
}
}, []);
const markAsRead = useCallback(async (id: number) => {
try {
await fetch(`/api/notifications/${id}/read`, {
method: 'PATCH',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
);
setUnreadCount((prev) => Math.max(0, prev - 1));
} catch {
// Silently fail
}
}, []);
const deleteNotification = useCallback(async (id: number) => {
try {
await fetch(`/api/notifications/${id}`, {
method: 'DELETE',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
setNotifications((prev) => prev.filter((n) => n.id !== id));
setUnreadCount((prev) => Math.max(0, prev - 1));
} catch {
// Silently fail
}
}, []);
const markAllAsRead = useCallback(async () => {
try {
await fetch('/api/notifications/read-all', {
method: 'PATCH',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
setNotifications((prev) =>
prev.map((n) => ({ ...n, is_read: true })),
);
setUnreadCount(0);
} catch {
// Silently fail
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch is safe
void fetchUnreadCount();
intervalRef.current = setInterval(() => {
void fetchUnreadCount();
}, 30000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [fetchUnreadCount]);
useEffect(() => {
if (isOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch on dropdown open is safe
void fetchNotifications();
}
}, [isOpen, fetchNotifications]);
return (
<DropdownMenu onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative h-9 w-9"
>
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-xs"
>
{unreadCount > 99 ? '99+' : unreadCount}
</Badge>
)}
<span className="sr-only">Notifikasi</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-sm font-semibold">Notifikasi</span>
{unreadCount > 0 && (
<button
onClick={() => {
void markAllAsRead();
}}
className="text-xs text-muted-foreground hover:text-foreground"
>
Tandai semua dibaca
</button>
)}
</div>
{notifications.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
Tidak ada notifikasi
</div>
) : (
notifications.map((notification) => (
<div key={notification.id}>
<div
className={`flex items-start gap-2 px-4 py-3 ${
!notification.is_read
? 'border-l-2 border-l-primary bg-primary/5'
: 'opacity-60'
}`}
>
<a
href={notification.url || '#'}
className="min-w-0 flex-1 cursor-pointer"
onClick={(e) => {
e.preventDefault();
if (notification.url) {
window.location.href =
notification.url;
}
}}
>
<span
className={`text-sm ${
!notification.is_read
? 'font-semibold'
: 'font-medium'
}`}
>
{notification.title}
</span>
{notification.body && (
<span className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground">
{notification.body}
</span>
)}
<span className="mt-0.5 block text-xs text-muted-foreground">
{new Date(
notification.created_at,
).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</a>
<div className="flex shrink-0 flex-col gap-1 pt-0.5">
{!notification.is_read && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="Tandai sudah dibaca"
onClick={() => {
void markAsRead(
notification.id,
);
}}
>
<Check className="h-3 w-3" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
title="Hapus"
onClick={() => {
void deleteNotification(
notification.id,
);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
<DropdownMenuSeparator />
</div>
))
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,149 @@
import { useCallback, useState } from 'react';
type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown';
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i++) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function getInitialPermission(): PermissionStatus {
if (!('Notification' in window)) {
return 'denied';
}
return Notification.permission as PermissionStatus;
}
function getIsSupported(): boolean {
return (
'Notification' in window &&
'serviceWorker' in navigator &&
'PushManager' in window
);
}
export function usePushNotification() {
const [permission, setPermission] =
useState<PermissionStatus>(getInitialPermission);
const [isSupported] = useState<boolean>(getIsSupported);
const requestPermission =
useCallback(async (): Promise<PermissionStatus> => {
if (!('Notification' in window)) {
return 'denied';
}
const result = await Notification.requestPermission();
setPermission(result as PermissionStatus);
return result as PermissionStatus;
}, []);
const subscribe = useCallback(
async (vapidPublicKey: string): Promise<boolean> => {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
vapidPublicKey,
) as BufferSource,
});
const { endpoint } = subscription;
const key = subscription.getKey('p256dh');
const auth = subscription.getKey('auth');
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ??
'',
),
},
body: JSON.stringify({
endpoint,
public_key: key
? btoa(String.fromCharCode(...new Uint8Array(key)))
: null,
auth_token: auth
? btoa(String.fromCharCode(...new Uint8Array(auth)))
: null,
content_encoding: 'aes128gcm',
}),
});
return response.ok;
} catch (error) {
console.error(
'Failed to subscribe to push notifications:',
error,
);
return false;
}
},
[],
);
const unsubscribe = useCallback(async (): Promise<boolean> => {
try {
const registration = await navigator.serviceWorker.ready;
const subscription =
await registration.pushManager.getSubscription();
if (!subscription) {
return true;
}
const response = await fetch('/api/push/unsubscribe', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '',
),
},
body: JSON.stringify({
endpoint: subscription.endpoint,
}),
});
if (response.ok) {
await subscription.unsubscribe();
}
return response.ok;
} catch (error) {
console.error(
'Failed to unsubscribe from push notifications:',
error,
);
return false;
}
}, []);
return {
permission,
isSupported,
requestPermission,
subscribe,
unsubscribe,
};
}

View File

@ -32,9 +32,10 @@ type Props = {
per_page: number; per_page: number;
total: number; total: number;
}; };
highlight?: number;
}; };
export default function CategoryIndex({ categories }: Props) { export default function CategoryIndex({ categories, highlight }: Props) {
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Category | null>(null); const [editing, setEditing] = useState<Category | null>(null);
const [deleting, setDeleting] = useState<Category | null>(null); const [deleting, setDeleting] = useState<Category | null>(null);
@ -149,6 +150,26 @@ export default function CategoryIndex({ categories }: Props) {
<h2 className="text-2xl font-semibold tracking-tight"> <h2 className="text-2xl font-semibold tracking-tight">
Kategori Kategori
</h2> </h2>
{highlight && (
<p className="mt-1 text-sm text-muted-foreground">
Menampilkan kategori dari notifikasi.
<button
onClick={() => {
router.get(
categoryIndex.url(),
{},
{
replace: true,
preserveState: true,
},
);
}}
className="ml-1 text-primary underline underline-offset-4 hover:text-primary/80"
>
Tampilkan semua
</button>
</p>
)}
</div> </div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<Button asChild> <Button asChild>

View File

@ -1,9 +1,10 @@
import { Head, usePage } from '@inertiajs/react';
import { Bell, Camera, MapPin } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { usePushNotification } from '@/hooks/use-push-notification';
import { edit } from '@/routes/permissions'; import { edit } from '@/routes/permissions';
import { Head } from '@inertiajs/react';
import { Bell, Camera, MapPin } from 'lucide-react';
import { useEffect, useState } from 'react';
type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown'; type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown';
@ -16,8 +17,15 @@ const isPWA = () => {
const getPlatform = (): 'android' | 'ios' | 'desktop' => { const getPlatform = (): 'android' | 'ios' | 'desktop' => {
const ua = navigator.userAgent.toLowerCase(); const ua = navigator.userAgent.toLowerCase();
if (/android/.test(ua)) return 'android';
if (/iphone|ipad|ipod/.test(ua)) return 'ios'; if (/android/.test(ua)) {
return 'android';
}
if (/iphone|ipad|ipod/.test(ua)) {
return 'ios';
}
return 'desktop'; return 'desktop';
}; };
@ -38,83 +46,140 @@ const getDeniedMessage = (appName: string): string => {
} }
}; };
function getInitialNotificationPermission(): PermissionStatus {
if (!('Notification' in window)) {
return 'denied';
}
return Notification.permission as PermissionStatus;
}
function getInitialCameraPermission(): PermissionStatus {
if (!navigator.mediaDevices) {
return 'denied';
}
return 'unknown';
}
function getInitialLocationPermission(): PermissionStatus {
if (!('geolocation' in navigator)) {
return 'denied';
}
return 'unknown';
}
export default function Permissions() { export default function Permissions() {
const [notifications, setNotifications] = const { vapidPublicKey } = usePage().props as { vapidPublicKey?: string };
useState<PermissionStatus>('unknown'); const [notifications, setNotifications] = useState<PermissionStatus>(
const [camera, setCamera] = useState<PermissionStatus>('unknown'); getInitialNotificationPermission,
const [location, setLocation] = useState<PermissionStatus>('unknown'); );
const [camera, setCamera] = useState<PermissionStatus>(
getInitialCameraPermission,
);
const [location, setLocation] = useState<PermissionStatus>(
getInitialLocationPermission,
);
const [isSubscribing, setIsSubscribing] = useState(false);
const { isSupported, requestPermission, subscribe, unsubscribe } =
usePushNotification();
const appName = 'DST Collection'; const appName = 'DST Collection';
useEffect(() => { const checkCameraPermission = useCallback(async () => {
if ('Notification' in window) { if (!navigator.mediaDevices) {
if (Notification.permission === 'granted')
setNotifications('granted');
else if (Notification.permission === 'denied')
setNotifications('denied');
else setNotifications('prompt');
}
if (navigator.mediaDevices) {
navigator.mediaDevices
.getUserMedia({ video: true })
.then((stream) => {
stream.getTracks().forEach((track) => track.stop());
setCamera('granted');
})
.catch(() => {
if ('permissions' in navigator) {
navigator.permissions
.query({ name: 'camera' as PermissionName })
.then((result) => {
setCamera(result.state as PermissionStatus);
})
.catch(() => setCamera('denied'));
} else {
setCamera('denied');
}
});
} else {
setCamera('denied'); setCamera('denied');
return;
} }
if ('geolocation' in navigator) { try {
navigator.geolocation.getCurrentPosition( const stream = await navigator.mediaDevices.getUserMedia({
() => setLocation('granted'), video: true,
() => { });
if ('permissions' in navigator) { stream.getTracks().forEach((track) => track.stop());
navigator.permissions setCamera('granted');
.query({ name: 'geolocation' }) } catch {
.then((result) => { if ('permissions' in navigator) {
setLocation(result.state as PermissionStatus); try {
}) const result = await navigator.permissions.query({
.catch(() => setLocation('denied')); name: 'camera' as PermissionName,
} else { });
setLocation('denied'); setCamera(result.state as PermissionStatus);
} } catch {
}, setCamera('denied');
); }
} else { } else {
setLocation('denied'); setCamera('denied');
}
} }
}, []); }, []);
const checkLocationPermission = useCallback(async () => {
if (!('geolocation' in navigator)) {
setLocation('denied');
return;
}
navigator.geolocation.getCurrentPosition(
() => setLocation('granted'),
async () => {
if ('permissions' in navigator) {
try {
const result = await navigator.permissions.query({
name: 'geolocation',
});
setLocation(result.state as PermissionStatus);
} catch {
setLocation('denied');
}
} else {
setLocation('denied');
}
},
);
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- permission check on mount is safe
checkCameraPermission();
// eslint-disable-next-line react-hooks/set-state-in-effect -- permission check on mount is safe
checkLocationPermission();
}, [checkCameraPermission, checkLocationPermission]);
const handleNotifications = async (checked: boolean) => { const handleNotifications = async (checked: boolean) => {
if (isSubscribing) {
return;
}
if (checked) { if (checked) {
if (!('Notification' in window)) return; if (!isSupported || !('Notification' in window)) {
if (Notification.permission === 'denied') {
setNotifications('denied');
return; return;
} }
const result = await Notification.requestPermission();
setNotifications( if (Notification.permission === 'denied') {
result === 'granted' setNotifications('denied');
? 'granted'
: result === 'denied' return;
? 'denied' }
: 'prompt',
); setIsSubscribing(true);
try {
const result = await requestPermission();
setNotifications(result);
if (result === 'granted' && vapidPublicKey) {
await subscribe(vapidPublicKey);
}
} finally {
setIsSubscribing(false);
}
} else { } else {
await unsubscribe();
setNotifications('prompt'); setNotifications('prompt');
} }
}; };
@ -179,7 +244,9 @@ export default function Permissions() {
<Switch <Switch
checked={notifications === 'granted'} checked={notifications === 'granted'}
onCheckedChange={handleNotifications} onCheckedChange={handleNotifications}
disabled={isDenied(notifications)} disabled={
isDenied(notifications) || isSubscribing
}
/> />
</div> </div>
</div> </div>

106
resources/js/sw.ts Normal file
View File

@ -0,0 +1,106 @@
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst } from 'workbox-strategies';
declare const self: ServiceWorkerGlobalScope;
precacheAndRoute(self.__WB_MANIFEST);
self.skipWaiting();
clientsClaim();
registerRoute(
({ url }) => url.origin === 'https://fonts.bunny.net',
new CacheFirst({
cacheName: 'bunny-fonts-cache',
plugins: [
new ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365,
}),
],
}),
);
registerRoute(
({ url }) => url.origin === 'https://fonts.googleapis.com',
new CacheFirst({
cacheName: 'google-fonts-cache',
plugins: [
new ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365,
}),
],
}),
);
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24,
}),
],
}),
);
self.addEventListener('push', (event: PushEvent) => {
if (!event.data) {
return;
}
const data = event.data.json();
const options: NotificationOptions = {
body: data.body || '',
icon: data.icon || '/icon-192x192.png',
badge: '/icon-192x192.png',
data: data.data || {},
actions: data.actions || [],
};
event.waitUntil(
self.registration.showNotification(
data.title || 'DST Collection',
options,
),
);
});
self.addEventListener('notificationclick', (event: NotificationEvent) => {
event.notification.close();
const action = event.action;
const data = event.notification.data;
let url = '/';
if (action === 'view_category' && data.category_id) {
url = `/admin/master/categories`;
} else if (data.url) {
url = data.url;
}
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clients) => {
for (const client of clients) {
if (client.url.includes(url) && 'focus' in client) {
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(url);
}
}),
);
});

View File

@ -1,6 +1,8 @@
<?php <?php
use App\Http\Controllers\Api\NotificationController;
use App\Http\Controllers\Api\PresignedUrlController; use App\Http\Controllers\Api\PresignedUrlController;
use App\Http\Controllers\Api\PushSubscriptionController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::post('/presigned-url', [PresignedUrlController::class, 'store']) Route::post('/presigned-url', [PresignedUrlController::class, 'store'])
@ -11,3 +13,31 @@
->middleware(['web', 'auth', 'verified']) ->middleware(['web', 'auth', 'verified'])
->name('presigned-url.show') ->name('presigned-url.show')
->where('key', '.*'); ->where('key', '.*');
Route::post('/push/subscribe', [PushSubscriptionController::class, 'store'])
->middleware(['web', 'auth', 'verified'])
->name('push.subscribe');
Route::delete('/push/unsubscribe', [PushSubscriptionController::class, 'destroy'])
->middleware(['web', 'auth', 'verified'])
->name('push.unsubscribe');
Route::get('/notifications', [NotificationController::class, 'index'])
->middleware(['web', 'auth', 'verified'])
->name('notifications.index');
Route::get('/notifications/unread-count', [NotificationController::class, 'unreadCount'])
->middleware(['web', 'auth', 'verified'])
->name('notifications.unread-count');
Route::patch('/notifications/{notification}/read', [NotificationController::class, 'markAsRead'])
->middleware(['web', 'auth', 'verified'])
->name('notifications.mark-as-read');
Route::delete('/notifications/{notification}', [NotificationController::class, 'destroy'])
->middleware(['web', 'auth', 'verified'])
->name('notifications.destroy');
Route::patch('/notifications/read-all', [NotificationController::class, 'markAllAsRead'])
->middleware(['web', 'auth', 'verified'])
->name('notifications.mark-all-as-read');

View File

@ -0,0 +1,888 @@
<?php
use App\Models\Attendance;
use App\Models\CashAccount;
use App\Models\Employee;
use App\Models\Expense;
use App\Models\Payroll;
use App\Models\PayrollPeriod;
use App\Models\Product;
use App\Models\User;
use App\Notifications\WebPushNotification;
use App\Services\Admin\Finance\CashAccountService;
use App\Services\Admin\Finance\EmployeeAdvanceService;
use App\Services\Admin\Finance\ExpenseService;
use App\Services\Admin\Finance\PayrollPeriodService;
use App\Services\Admin\HR\AttendanceService;
use App\Services\Admin\HR\LeaveRequestService;
use App\Services\Admin\Master\ProductService;
use App\Services\NotificationService;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Spatie\Permission\PermissionRegistrar;
uses(RefreshDatabase::class);
beforeEach(function () {
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$this->seed(RolePermissionSeeder::class);
});
/*
|--------------------------------------------------------------------------
| HELPERS
|--------------------------------------------------------------------------
*/
function notifUserWithRole(string $role, bool $active = true): User
{
$user = User::factory()->create(['is_active' => $active]);
$user->assignRole($role);
return $user;
}
function notifEmployeeWithRole(string $role = 'Kasir'): array
{
$user = notifUserWithRole($role);
$employee = Employee::factory()->create(['user_id' => $user->id]);
return ['user' => $user, 'employee' => $employee];
}
/*
|--------------------------------------------------------------------------
| NOTIFICATION SERVICE UNIT TESTS
|--------------------------------------------------------------------------
*/
test('NotificationService creates in-app notification for users with matching role', function () {
$developer = notifUserWithRole('Developer');
$owner = notifUserWithRole('Owner');
notifUserWithRole('Kasir'); // should NOT receive
NotificationService::notify(
roles: ['Owner', 'Developer'],
title: 'Test Title',
body: 'Test Body.',
url: '/test-url'
);
expect($developer->notifications()->count())->toBe(1);
expect($owner->notifications()->count())->toBe(1);
$notification = $developer->notifications()->first();
expect($notification->title)->toBe('Test Title')
->and($notification->body)->toBe('Test Body.')
->and($notification->url)->toBe('/test-url')
->and($notification->is_read)->toBeFalse();
});
test('NotificationService does not notify inactive users', function () {
notifUserWithRole('Developer', active: false);
$active = notifUserWithRole('Developer');
NotificationService::notify(
roles: ['Developer'],
title: 'Test',
body: 'Body.',
url: '/test'
);
expect($active->notifications()->count())->toBe(1);
expect(User::where('is_active', false)->first()->notifications()->count())->toBe(0);
});
test('NotificationService dispatches WebPushNotification to each user', function () {
Notification::fake();
$developer = notifUserWithRole('Developer');
$owner = notifUserWithRole('Owner');
NotificationService::notify(
roles: ['Developer', 'Owner'],
title: 'Push Title',
body: 'Push Body.',
url: '/push'
);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($owner, WebPushNotification::class);
});
test('NotificationService includes additionalUser when not already in role-based list', function () {
$kasir = notifUserWithRole('Kasir');
$developer = notifUserWithRole('Developer');
NotificationService::notify(
roles: ['Developer'],
title: 'Title',
body: 'Body.',
url: '/url',
additionalUser: $kasir
);
expect($kasir->notifications()->count())->toBe(1);
expect($developer->notifications()->count())->toBe(1);
});
test('NotificationService does not duplicate additionalUser already in role list', function () {
$developer = notifUserWithRole('Developer');
NotificationService::notify(
roles: ['Developer'],
title: 'Title',
body: 'Body.',
url: '/url',
additionalUser: $developer
);
expect($developer->notifications()->count())->toBe(1);
});
test('NotificationService handles null additionalUser', function () {
$developer = notifUserWithRole('Developer');
NotificationService::notify(
roles: ['Developer'],
title: 'Title',
body: 'Body.',
url: '/url',
additionalUser: null
);
expect($developer->notifications()->count())->toBe(1);
});
test('NotificationService notifies multiple users across multiple roles', function () {
$developer = notifUserWithRole('Developer');
$owner = notifUserWithRole('Owner');
$direktur = notifUserWithRole('Direktur');
$adminToko = notifUserWithRole('Admin Toko');
notifUserWithRole('Kasir'); // should NOT receive
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Multi Role',
body: 'Body.',
url: '/multi'
);
expect($developer->notifications()->count())->toBe(1);
expect($owner->notifications()->count())->toBe(1);
expect($direktur->notifications()->count())->toBe(1);
expect($adminToko->notifications()->count())->toBe(1);
});
/*
|--------------------------------------------------------------------------
| PRODUCT NOTIFICATIONS
|--------------------------------------------------------------------------
*/
test('ProductService create sends notification to correct roles', function () {
Notification::fake();
$developer = notifUserWithRole('Developer');
$owner = notifUserWithRole('Owner');
$kasir = notifUserWithRole('Kasir');
$this->actingAs($developer);
$service = new ProductService;
$product = $service->create([
'name' => 'Test Product',
'description' => 'A test product',
'status' => 'active',
'category_ids' => [],
'use_same_price' => false,
'variants' => [
[
'name' => 'Default',
'stock' => 10,
'reject_stock' => 0,
'retail_stock' => 5,
'prices' => [
['type' => 'retail', 'price' => 50000],
],
],
],
]);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($owner, WebPushNotification::class);
Notification::assertNothingSentTo($kasir);
expect($developer->notifications()->count())->toBe(1);
expect($developer->notifications()->first()->title)->toBe('Produk Baru');
});
test('ProductService update sends notification to correct roles', function () {
Notification::fake();
$developer = notifUserWithRole('Developer');
$owner = notifUserWithRole('Owner');
$adminToko = notifUserWithRole('Admin Toko');
$this->actingAs($developer);
$service = new ProductService;
$product = $service->create([
'name' => 'Original Product',
'description' => 'Desc',
'status' => 'active',
'category_ids' => [],
'use_same_price' => false,
'variants' => [
[
'name' => 'Var 1',
'stock' => 10,
'reject_stock' => 0,
'retail_stock' => 5,
'prices' => [
['type' => 'retail', 'price' => 50000],
],
],
],
]);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($owner, WebPushNotification::class);
Notification::fake();
$product = $service->update($product, [
'name' => 'Updated Product',
'description' => 'Desc updated',
'status' => 'active',
'category_ids' => [],
'use_same_price' => false,
'variants' => [
[
'id' => $product->productVariants->first()->id,
'name' => 'Var 1',
'stock' => 20,
'reject_stock' => 0,
'retail_stock' => 10,
'prices' => [
['type' => 'retail', 'price' => 60000],
],
],
],
]);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($owner, WebPushNotification::class);
Notification::assertSentTo($adminToko, WebPushNotification::class);
});
/*
|--------------------------------------------------------------------------
| CASH ACCOUNT NOTIFICATIONS
|--------------------------------------------------------------------------
*/
test('CashAccountService deposit sends notification to correct roles', function () {
Notification::fake();
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
$developer = notifUserWithRole('Developer');
$owner = notifUserWithRole('Owner');
$kasir = notifUserWithRole('Kasir');
$this->actingAs($developer);
$service = new CashAccountService;
$service->deposit([
'amount' => 500000,
'description' => 'Setoran modal',
]);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($owner, WebPushNotification::class);
Notification::assertNothingSentTo($kasir);
expect($developer->notifications()->first()->title)->toBe('Setoran Kas Toko');
});
test('CashAccountService withdrawal sends notification to correct roles', function () {
Notification::fake();
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
$developer = notifUserWithRole('Developer');
$direktur = notifUserWithRole('Direktur');
$this->actingAs($developer);
$service = new CashAccountService;
$service->withdrawal([
'amount' => 200000,
'description' => 'Penarikan kas',
]);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($direktur, WebPushNotification::class);
expect($developer->notifications()->first()->title)->toBe('Penarikan Kas Toko');
});
test('CashAccountService updateTransaction sends notification to correct roles', function () {
Notification::fake();
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
$developer = notifUserWithRole('Developer');
$owner = notifUserWithRole('Owner');
$this->actingAs($developer);
$service = new CashAccountService;
$transaction = $service->deposit([
'amount' => 500000,
'description' => 'Initial deposit',
]);
Notification::fake();
$service->updateTransaction($transaction, [
'amount' => 600000,
'description' => 'Updated deposit',
]);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($owner, WebPushNotification::class);
expect($developer->notifications()->where('title', 'Transaksi Kas Diperbarui')->count())->toBe(1);
});
test('CashAccountService deleteTransaction sends notification to correct roles', function () {
Notification::fake();
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
$developer = notifUserWithRole('Developer');
$owner = notifUserWithRole('Owner');
$this->actingAs($developer);
$service = new CashAccountService;
$transaction = $service->deposit([
'amount' => 500000,
'description' => 'To be deleted',
]);
Notification::fake();
$service->deleteTransaction($transaction);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($owner, WebPushNotification::class);
expect($developer->notifications()->where('title', 'Transaksi Kas Dihapus')->count())->toBe(1);
});
/*
|--------------------------------------------------------------------------
| EXPENSE NOTIFICATIONS
|--------------------------------------------------------------------------
*/
test('ExpenseService create sends notification to correct roles', function () {
Notification::fake();
CashAccount::factory()->create(['balance' => 5000000]);
$developer = notifUserWithRole('Developer');
$owner = notifUserWithRole('Owner');
$kasir = notifUserWithRole('Kasir');
$this->actingAs($developer);
$service = new ExpenseService;
$expense = $service->create([
'amount' => 100000,
'description' => 'Office supplies',
]);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($owner, WebPushNotification::class);
Notification::assertNothingSentTo($kasir);
expect($developer->notifications()->first()->title)->toBe('Pengeluaran Baru');
});
test('ExpenseService update sends notification to correct roles', function () {
Notification::fake();
CashAccount::factory()->create(['balance' => 5000000]);
$developer = notifUserWithRole('Developer');
$adminToko = notifUserWithRole('Admin Toko');
$this->actingAs($developer);
$service = new ExpenseService;
$expense = $service->create([
'amount' => 100000,
'description' => 'Original',
]);
Notification::fake();
$service->update($expense, [
'amount' => 150000,
'description' => 'Updated expense',
]);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($adminToko, WebPushNotification::class);
expect($developer->notifications()->where('title', 'Pengeluaran Diperbarui')->count())->toBe(1);
});
test('ExpenseService delete sends notification to correct roles', function () {
Notification::fake();
CashAccount::factory()->create(['balance' => 5000000]);
$developer = notifUserWithRole('Developer');
$direktur = notifUserWithRole('Direktur');
$this->actingAs($developer);
$service = new ExpenseService;
$expense = $service->create([
'amount' => 100000,
'description' => 'To delete',
]);
Notification::fake();
$service->delete($expense);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($direktur, WebPushNotification::class);
expect($developer->notifications()->where('title', 'Pengeluaran Dihapus')->count())->toBe(1);
});
/*
|--------------------------------------------------------------------------
| EMPLOYEE ADVANCE (KASBON) NOTIFICATIONS
|--------------------------------------------------------------------------
*/
test('EmployeeAdvanceService create sends notification including the requesting employee', function () {
Notification::fake();
CashAccount::factory()->create(['balance' => 5000000]);
$developer = notifUserWithRole('Developer');
$owner = notifUserWithRole('Owner');
$kasbonData = notifEmployeeWithRole('Kasir');
$employee = $kasbonData['employee'];
$kasbonUser = $kasbonData['user'];
$this->actingAs($kasbonUser);
$service = new EmployeeAdvanceService;
$advance = $service->create([
'amount' => 500000,
'description' => 'Emergency need',
'due_date' => now()->addDays(30)->toDateString(),
]);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($owner, WebPushNotification::class);
Notification::assertSentTo($kasbonUser, WebPushNotification::class);
expect($kasbonUser->notifications()->first()->title)->toBe('Kasbon Baru');
});
test('EmployeeAdvanceService approve sends notification including the employee', function () {
Notification::fake();
CashAccount::factory()->create(['balance' => 5000000]);
$developer = notifUserWithRole('Developer');
$kasbonData = notifEmployeeWithRole('Kasir');
$employee = $kasbonData['employee'];
$kasbonUser = $kasbonData['user'];
$this->actingAs($kasbonUser);
$service = new EmployeeAdvanceService;
$advance = $service->create([
'amount' => 300000,
'description' => 'Advance',
'due_date' => now()->addDays(14)->toDateString(),
]);
Notification::fake();
$admin = notifUserWithRole('Admin Toko');
$this->actingAs($admin);
$service->approve($advance);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($kasbonUser, WebPushNotification::class);
expect($kasbonUser->notifications()->where('title', 'Kasbon Disetujui')->count())->toBe(1);
});
test('EmployeeAdvanceService pay sends notification including the employee', function () {
Notification::fake();
CashAccount::factory()->create(['balance' => 5000000]);
$developer = notifUserWithRole('Developer');
$kasbonData = notifEmployeeWithRole('Kasir');
$employee = $kasbonData['employee'];
$kasbonUser = $kasbonData['user'];
$this->actingAs($kasbonUser);
$service = new EmployeeAdvanceService;
$advance = $service->create([
'amount' => 200000,
'description' => 'To pay',
'due_date' => now()->addDays(7)->toDateString(),
]);
$admin = notifUserWithRole('Admin Toko');
$this->actingAs($admin);
$service->approve($advance);
Notification::fake();
$service->pay($advance);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($kasbonUser, WebPushNotification::class);
expect($kasbonUser->notifications()->where('title', 'Kasbon Dibayar')->count())->toBe(1);
});
/*
|--------------------------------------------------------------------------
| PAYROLL NOTIFICATIONS
|--------------------------------------------------------------------------
*/
test('PayrollPeriodService pay sends notification including the employee', function () {
Notification::fake();
CashAccount::factory()->create(['balance' => 50000000]);
$developer = notifUserWithRole('Developer');
$owner = notifUserWithRole('Owner');
$employeeData = notifEmployeeWithRole('Kasir');
$employee = $employeeData['employee'];
$employeeUser = $employeeData['user'];
$period = PayrollPeriod::factory()->create(['year' => 2026, 'month' => 7, 'status' => 'open']);
$payroll = Payroll::factory()->create([
'payroll_period_id' => $period->id,
'employee_id' => $employee->id,
'base_salary' => 3000000,
'bonus_amount' => 0,
'deduction_amount' => 0,
'total_amount' => 3000000,
'status' => 'unpaid',
]);
$this->actingAs($developer);
$service = new PayrollPeriodService;
$service->pay($payroll);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($owner, WebPushNotification::class);
Notification::assertSentTo($employeeUser, WebPushNotification::class);
expect($employeeUser->notifications()->where('title', 'Gaji Dibayar')->count())->toBe(1);
});
test('PayrollPeriodService cancel sends notification including the employee', function () {
Notification::fake();
$developer = notifUserWithRole('Developer');
$direktur = notifUserWithRole('Direktur');
$employeeData = notifEmployeeWithRole('Kasir');
$employee = $employeeData['employee'];
$employeeUser = $employeeData['user'];
$period = PayrollPeriod::factory()->create(['year' => 2026, 'month' => 8, 'status' => 'open']);
$payroll = Payroll::factory()->create([
'payroll_period_id' => $period->id,
'employee_id' => $employee->id,
'base_salary' => 3000000,
'bonus_amount' => 0,
'deduction_amount' => 0,
'total_amount' => 3000000,
'status' => 'unpaid',
]);
$this->actingAs($developer);
$service = new PayrollPeriodService;
$service->cancel($payroll);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($direktur, WebPushNotification::class);
Notification::assertSentTo($employeeUser, WebPushNotification::class);
expect($employeeUser->notifications()->where('title', 'Gaji Dibatalkan')->count())->toBe(1);
});
/*
|--------------------------------------------------------------------------
| LEAVE REQUEST NOTIFICATIONS
|--------------------------------------------------------------------------
*/
test('LeaveRequestService create sends notification including the requesting employee', function () {
Notification::fake();
$developer = notifUserWithRole('Developer');
$owner = notifUserWithRole('Owner');
$leaveData = notifEmployeeWithRole('Kasir');
$employee = $leaveData['employee'];
$leaveUser = $leaveData['user'];
$this->actingAs($leaveUser);
$service = new LeaveRequestService;
$leaveRequest = $service->create([
'start_date' => now()->addDays(5)->toDateString(),
'end_date' => now()->addDays(7)->toDateString(),
]);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($owner, WebPushNotification::class);
Notification::assertSentTo($leaveUser, WebPushNotification::class);
expect($leaveUser->notifications()->first()->title)->toBe('Pengajuan Cuti Baru');
});
test('LeaveRequestService approve sends notification including the employee', function () {
Notification::fake();
$developer = notifUserWithRole('Developer');
$leaveData = notifEmployeeWithRole('Kasir');
$employee = $leaveData['employee'];
$leaveUser = $leaveData['user'];
$this->actingAs($leaveUser);
$service = new LeaveRequestService;
$leaveRequest = $service->create([
'start_date' => now()->addDays(10)->toDateString(),
'end_date' => now()->addDays(12)->toDateString(),
]);
Notification::fake();
$admin = notifUserWithRole('Admin Toko');
$this->actingAs($admin);
$service->approve($leaveRequest);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($leaveUser, WebPushNotification::class);
expect($leaveUser->notifications()->where('title', 'Cuti Disetujui')->count())->toBe(1);
});
test('LeaveRequestService reject sends notification including the employee', function () {
Notification::fake();
$developer = notifUserWithRole('Developer');
$leaveData = notifEmployeeWithRole('Kasir');
$employee = $leaveData['employee'];
$leaveUser = $leaveData['user'];
$this->actingAs($leaveUser);
$service = new LeaveRequestService;
$leaveRequest = $service->create([
'start_date' => now()->addDays(15)->toDateString(),
'end_date' => now()->addDays(17)->toDateString(),
]);
Notification::fake();
$admin = notifUserWithRole('Admin Toko');
$this->actingAs($admin);
$service->reject($leaveRequest);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($leaveUser, WebPushNotification::class);
expect($leaveUser->notifications()->where('title', 'Cuti Ditolak')->count())->toBe(1);
});
/*
|--------------------------------------------------------------------------
| ATTENDANCE NOTIFICATIONS
|--------------------------------------------------------------------------
*/
test('AttendanceService checkIn sends notification to Developer, Owner, Direktur', function () {
Notification::fake();
$developer = notifUserWithRole('Developer');
$owner = notifUserWithRole('Owner');
$direktur = notifUserWithRole('Direktur');
$adminToko = notifUserWithRole('Admin Toko');
$attData = notifEmployeeWithRole('Kasir');
$employee = $attData['employee'];
$attendant = $attData['user'];
$this->actingAs($attendant);
$service = new AttendanceService;
$attendance = $service->checkIn([
'latitude' => -6.200000,
'longitude' => 106.816666,
]);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($owner, WebPushNotification::class);
Notification::assertSentTo($direktur, WebPushNotification::class);
Notification::assertNothingSentTo($adminToko);
expect($developer->notifications()->first()->title)->toBe('Presensi Masuk');
});
test('AttendanceService checkOut sends notification to Developer, Owner, Direktur', function () {
Notification::fake();
$developer = notifUserWithRole('Developer');
$owner = notifUserWithRole('Owner');
$direktur = notifUserWithRole('Direktur');
$adminToko = notifUserWithRole('Admin Toko');
$attData = notifEmployeeWithRole('Kasir');
$employee = $attData['employee'];
$attendant = $attData['user'];
$this->actingAs($attendant);
$service = new AttendanceService;
$attendance = $service->checkIn([
'latitude' => -6.200000,
'longitude' => 106.816666,
]);
Notification::fake();
$attendance = $service->checkOut($attendance, [
'latitude' => -6.200100,
'longitude' => 106.816700,
]);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($owner, WebPushNotification::class);
Notification::assertSentTo($direktur, WebPushNotification::class);
Notification::assertNothingSentTo($adminToko);
expect($developer->notifications()->where('title', 'Presensi Pulang')->count())->toBe(1);
});
/*
|--------------------------------------------------------------------------
| IN-APP NOTIFICATION RECORDS
|--------------------------------------------------------------------------
*/
test('services create in-app notification records in database', function () {
$developer = notifUserWithRole('Developer');
$owner = notifUserWithRole('Owner');
NotificationService::notify(
roles: ['Developer', 'Owner'],
title: 'DB Test',
body: 'Body here.',
url: '/db-test'
);
$this->assertDatabaseHas('notifications', [
'user_id' => $developer->id,
'title' => 'DB Test',
'body' => 'Body here.',
'url' => '/db-test',
'is_read' => false,
]);
$this->assertDatabaseHas('notifications', [
'user_id' => $owner->id,
'title' => 'DB Test',
]);
});
test('ProductService create creates in-app notification record', function () {
CashAccount::factory()->create(['balance' => 1000000]);
$developer = notifUserWithRole('Developer');
$this->actingAs($developer);
$service = new ProductService;
$product = $service->create([
'name' => 'DB Notification Product',
'description' => 'Test',
'status' => 'active',
'category_ids' => [],
'use_same_price' => false,
'variants' => [
[
'name' => 'Var',
'stock' => 10,
'reject_stock' => 0,
'retail_stock' => 5,
'prices' => [
['type' => 'retail', 'price' => 50000],
],
],
],
]);
$this->assertDatabaseHas('notifications', [
'user_id' => $developer->id,
'title' => 'Produk Baru',
]);
});
test('CashAccountService deposit creates in-app notification record', function () {
CashAccount::factory()->create(['balance' => 5000000]);
$owner = notifUserWithRole('Owner');
$this->actingAs($owner);
$service = new CashAccountService;
$service->deposit([
'amount' => 250000,
'description' => 'Test deposit',
]);
$this->assertDatabaseHas('notifications', [
'user_id' => $owner->id,
'title' => 'Setoran Kas Toko',
]);
});
test('AttendanceService checkIn creates in-app notification record', function () {
$developer = notifUserWithRole('Developer');
$attData = notifEmployeeWithRole('Kasir');
$employee = $attData['employee'];
$attendant = $attData['user'];
$this->actingAs($attendant);
$service = new AttendanceService;
$service->checkIn([
'latitude' => -6.2,
'longitude' => 106.8,
]);
$this->assertDatabaseHas('notifications', [
'user_id' => $developer->id,
'title' => 'Presensi Masuk',
]);
});

View File

@ -117,5 +117,8 @@
"resources/js/**/*.ts", "resources/js/**/*.ts",
"resources/js/**/*.d.ts", "resources/js/**/*.d.ts",
"resources/js/**/*.tsx" "resources/js/**/*.tsx"
],
"exclude": [
"resources/js/sw.ts"
] ]
} }

View File

@ -32,44 +32,10 @@ export default defineConfig({
registerType: 'autoUpdate', registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'favicon.svg', 'apple-touch-icon.png', 'assets/logo.png'], includeAssets: ['favicon.ico', 'favicon.svg', 'apple-touch-icon.png', 'assets/logo.png'],
manifest: false, manifest: false,
srcDir: 'resources/js',
filename: 'sw.ts',
workbox: { workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'], globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.bunny\.net\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'bunny-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365,
},
},
},
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365,
},
},
},
{
urlPattern: /\/api\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24,
},
},
},
],
}, },
devOptions: { devOptions: {
enabled: false, enabled: false,