feat: implement push notification system with web-push integration and subscription management

This commit is contained in:
Yoga Pangestu 2026-06-14 00:08:06 +07:00
parent cd84a16549
commit c22252f44c
30 changed files with 1365 additions and 67 deletions

View File

@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use App\Models\PushSubscription;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class PushSubscriptionController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'endpoint' => ['required', 'string', 'url'],
'publicKey' => ['required', 'string'],
'authToken' => ['required', 'string'],
]);
PushSubscription::updateOrCreate(
[
'user_id' => Auth::id(),
'endpoint' => $validated['endpoint'],
],
[
'public_key' => $validated['publicKey'],
'auth_token' => $validated['authToken'],
]
);
return response()->json(['message' => 'Subscription berhasil disimpan.']);
}
public function destroy(Request $request): JsonResponse
{
$validated = $request->validate([
'endpoint' => ['required', 'string'],
]);
PushSubscription::where('user_id', Auth::id())
->where('endpoint', $validated['endpoint'])
->delete();
return response()->json(['message' => 'Subscription berhasil dihapus.']);
}
}

View File

@ -58,6 +58,7 @@ public function share(Request $request): array
'sidebarOpen' => ! $request->hasCookie('sidebar_state')
|| $request->cookie('sidebar_state') === 'true',
'appearance' => $request->cookie('appearance', 'system'),
'vapidPublicKey' => config('webpush.vapid.public_key'),
];
}
}

View File

@ -77,7 +77,8 @@ public function withValidator(Validator $validator): void
$originalResult = $cuttingResults->get($variantId);
if ($originalResult === null) {
$validator->errors()->add("results.{$index}.product_variant_id", "Varian produk tidak ditemukan pada cutting ini.");
$validator->errors()->add("results.{$index}.product_variant_id", 'Varian produk tidak ditemukan pada cutting ini.');
continue;
}

View File

@ -0,0 +1,101 @@
<?php
namespace App\Jobs;
use App\Models\PushSubscription;
use App\Models\SystemConfiguration;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Minishlink\WebPush\Subscription;
use Minishlink\WebPush\WebPush;
class SendPushNotificationJob implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly string $title,
private readonly string $body,
private readonly string $url = '/admin/dashboard',
private readonly array $roles = [],
private readonly ?int $userId = null,
) {}
public function handle(): void
{
$subscriptions = $this->resolveSubscriptions();
if ($subscriptions->isEmpty()) {
return;
}
$icon = $this->resolveIcon();
$webPush = new WebPush([
'VAPID' => [
'subject' => config('app.url'),
'publicKey' => config('webpush.vapid.public_key'),
'privateKey' => config('webpush.vapid.private_key'),
],
]);
$payload = json_encode([
'title' => $this->title,
'body' => $this->body,
'icon' => $icon,
'url' => $this->url,
]);
foreach ($subscriptions as $subscription) {
$sub = Subscription::create([
'endpoint' => $subscription->endpoint,
'publicKey' => $subscription->public_key,
'authToken' => $subscription->auth_token,
'contentEncoding' => 'aes128gcm',
]);
$webPush->queueNotification($sub, $payload);
}
$expiredEndpoints = [];
foreach ($webPush->flush() as $report) {
if (! $report->isSuccess()) {
$expiredEndpoints[] = (string) $report->getRequest()->getUri();
}
}
if (! empty($expiredEndpoints)) {
PushSubscription::whereIn('endpoint', $expiredEndpoints)->delete();
}
}
private function resolveIcon(): string
{
try {
$configuration = SystemConfiguration::instance();
$configuration->load('media');
$logo = MediaPresenter::first($configuration, 'logo');
return $logo['url'] ?? config('app.url').'/assets/logo.png';
} catch (\Throwable) {
return config('app.url').'/assets/logo.png';
}
}
private function resolveSubscriptions()
{
$query = PushSubscription::query();
if ($this->userId !== null) {
$query->where('user_id', $this->userId);
} elseif (! empty($this->roles)) {
$query->whereHas('user', function ($q): void {
$q->whereHas('roles', fn ($r) => $r->whereIn('name', $this->roles));
});
}
return $query->get();
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Guarded(['id'])]
class PushSubscription extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -77,6 +77,11 @@ public function profile(): HasOne
return $this->hasOne(UserProfile::class);
}
public function pushSubscriptions(): HasMany
{
return $this->hasMany(PushSubscription::class);
}
public function purchases(): HasMany
{
return $this->hasMany(Purchase::class, 'created_by_id');

View File

@ -6,6 +6,7 @@
use App\Models\CashTransaction;
use App\Models\User;
use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
@ -19,6 +20,7 @@ class CashService
public function __construct(
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
) {}
public function getDefaultAccount(): CashAccount
@ -61,7 +63,7 @@ public function paginateForIndex(CashAccount $cashAccount, array $tableQuery): L
*/
public function deposit(CashAccount $cashAccount, array $validated, User $user): CashTransaction
{
return DB::transaction(function () use ($cashAccount, $validated, $user): CashTransaction {
$transaction = DB::transaction(function () use ($cashAccount, $validated, $user): CashTransaction {
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);
$amount = (int) $validated['amount'];
$newBalance = $account->balance + $amount;
@ -81,6 +83,15 @@ public function deposit(CashAccount $cashAccount, array $validated, User $user):
return $transaction;
});
$this->pushNotificationService->sendToRoles(
'💰 Setoran Kas',
"Setoran kas baru {$transaction->amount_formatted} dengan keterangan: {$transaction->description} oleh {$user->profile?->full_name}.",
['owner', 'developer', 'admin-toko'],
'/admin/finance/cash',
);
return $transaction;
}
public function recordOutgoing(
@ -171,6 +182,13 @@ public function updateDeposit(CashTransaction $transaction, array $validated): v
$this->recalculateBalances($transaction->cashAccount);
});
$this->pushNotificationService->sendToRoles(
'✏️ Transaksi Kas Diperbarui',
"Transaksi kas dengan keterangan {$transaction->description} diperbarui menjadi senilai {$transaction->amount_formatted}.",
['owner', 'developer', 'admin-toko'],
'/admin/finance/cash',
);
}
public function deleteTransaction(CashTransaction $transaction): void
@ -185,6 +203,13 @@ public function deleteTransaction(CashTransaction $transaction): void
$this->recalculateBalances($transaction->cashAccount);
});
$this->pushNotificationService->sendToRoles(
'🗑️ Transaksi Kas Dihapus',
"Transaksi kas senilai {$transaction->amount_formatted} dengan keterangan {$transaction->description} telah dihapus.",
['owner', 'developer', 'admin-toko'],
'/admin/finance/cash',
);
}
public function updateReferencedTransaction(

View File

@ -7,6 +7,7 @@
use App\Models\User;
use App\Services\Concerns\ResolvesAuthEmployee;
use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
@ -22,6 +23,7 @@ class EmployeeAdvanceService
public function __construct(
private readonly CashService $cashService,
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
) {}
/**
@ -83,7 +85,7 @@ public function create(array $validated, User $user): void
{
$employee = $this->resolveAuthEmployee($user);
DB::transaction(function () use ($validated, $employee): void {
$employeeAdvance = DB::transaction(function () use ($validated, $employee) {
$employeeAdvance = EmployeeAdvance::create([
'employee_id' => $employee->id,
'amount' => (int) $validated['amount'],
@ -93,7 +95,16 @@ public function create(array $validated, User $user): void
]);
$this->syncPhotos($employeeAdvance, $validated);
return $employeeAdvance;
});
$this->pushNotificationService->sendToRoles(
'💰 Pengajuan Kasbon Baru',
"Karyawan {$user->profil?->full_name} mengajukan kasbon sebesar {$employeeAdvance->amount_formatted} dengan keterangan: {$employeeAdvance->description}.",
['owner', 'developer'],
'/admin/finance/employee-advances',
);
}
/**
@ -148,6 +159,16 @@ public function approve(EmployeeAdvance $employeeAdvance, User $user): void
$employeeAdvance->verified_by_id = $user->id;
$employeeAdvance->save();
});
$employeeAdvance->loadMissing('employee.user');
if ($employeeAdvance->employee?->user_id) {
$this->pushNotificationService->sendToUser(
'💰 Kasbon Disetujui',
"Pengajuan kasbon Anda sebesar {$employeeAdvance->amount_formatted} telah disetujui.",
$employeeAdvance->employee->user_id,
'/admin/finance/employee-advances',
);
}
}
public function reject(EmployeeAdvance $employeeAdvance, string $reason, User $user): void
@ -165,6 +186,16 @@ public function reject(EmployeeAdvance $employeeAdvance, string $reason, User $u
'rejected_by_id' => $user->id,
]);
});
$employeeAdvance->loadMissing('employee.user');
if ($employeeAdvance->employee?->user_id) {
$this->pushNotificationService->sendToUser(
'💰 Kasbon Ditolak',
"Pengajuan kasbon Anda sebesar {$employeeAdvance->amount_formatted} ditolak dengan alasan: {$reason}.",
$employeeAdvance->employee->user_id,
'/admin/finance/employee-advances',
);
}
}
public function pay(EmployeeAdvance $employeeAdvance, User $user): void

View File

@ -5,6 +5,7 @@
use App\Models\Expense;
use App\Models\User;
use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
@ -17,6 +18,7 @@ class ExpenseService
public function __construct(
private readonly CashService $cashService,
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
) {}
/**
@ -53,7 +55,7 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator
*/
public function create(array $validated, User $user): void
{
DB::transaction(function () use ($validated, $user): void {
$expense = DB::transaction(function () use ($validated, $user): Expense {
$amount = (int) $validated['amount'];
$description = $validated['description'];
@ -74,7 +76,16 @@ public function create(array $validated, User $user): void
$expense->save();
$this->syncPhotos($expense, $validated);
return $expense;
});
$this->pushNotificationService->sendToRoles(
'💸 Pengeluaran Baru',
"Pengeluaran baru sebesar {$expense->amount_formatted} dengan keterangan {$expense->description} telah dicatat oleh {$user->profile?->full_name}.",
['owner', 'developer'],
'/admin/finance/expenses',
);
}
/**
@ -100,10 +111,20 @@ public function update(Expense $expense, array $validated): void
$this->syncPhotos($expense, $validated);
});
$this->pushNotificationService->sendToRoles(
'✏️ Pengeluaran Diperbarui',
"Pengeluaran dengan keterangan {$expense->description} diperbarui menjadi sebesar {$expense->amount_formatted}.",
['owner', 'developer'],
'/admin/finance/expenses',
);
}
public function delete(Expense $expense): void
{
$amount = $expense->amount;
$description = $expense->description;
DB::transaction(function () use ($expense): void {
if ($expense->cashTransaction) {
$this->cashService->deleteReferencedTransaction($expense->cashTransaction);
@ -112,6 +133,13 @@ public function delete(Expense $expense): void
$expense->clearMediaCollection('photos');
$expense->delete();
});
$this->pushNotificationService->sendToRoles(
'🗑️ Pengeluaran Dihapus',
"Pengeluaran sebesar {$expense->amount_formatted} dengan keterangan {$expense->description} telah dihapus.",
['owner', 'developer'],
'/admin/finance/expenses',
);
}
/**

View File

@ -12,6 +12,7 @@
use App\Models\Payroll;
use App\Models\PayrollPeriod;
use App\Models\User;
use App\Services\System\PushNotificationService;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
@ -23,6 +24,7 @@ class PayrollService
{
public function __construct(
private readonly CashService $cashService,
private readonly PushNotificationService $pushNotificationService,
) {}
/**
@ -106,7 +108,7 @@ public function paginateForPeriod(PayrollPeriod $period, array $tableQuery): Len
public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod
{
return DB::transaction(function () use ($closedBy): PayrollPeriod {
$period = DB::transaction(function () use ($closedBy): PayrollPeriod {
$now = now();
$year = $now->year;
$month = $now->month;
@ -141,6 +143,14 @@ public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod
return $period->fresh();
});
$this->pushNotificationService->sendToAll(
'📊 Periode Gaji Baru Dibuka',
"Periode gaji untuk {$period->period_label} telah dibuka.",
'/admin/finance/payrolls',
);
return $period;
}
public function closePeriod(PayrollPeriod $period, User $user): void
@ -167,6 +177,12 @@ public function closePeriod(PayrollPeriod $period, User $user): void
$period->closed_at = now();
$period->closed_by_id = $user->id;
$period->save();
$this->pushNotificationService->sendToAll(
'📊 Periode Gaji Ditutup',
"Periode gaji untuk {$period->period_label} telah ditutup.",
'/admin/finance/payrolls',
);
}
public function generatePayrollsForPeriod(PayrollPeriod $period): void
@ -277,6 +293,15 @@ public function pay(Payroll $payroll, User $user): void
$this->settleKasbonFromPayroll($payroll, $user);
});
if ($payroll->employee?->user_id) {
$this->pushNotificationService->sendToUser(
'💸 Gaji Dibayarkan',
"Gaji Anda untuk periode {$payroll->payrollPeriod->period_label} senilai {$payroll->total_amount_formatted} telah dibayarkan.",
$payroll->employee->user_id,
'/admin/finance/payrolls',
);
}
}
/**

View File

@ -7,6 +7,7 @@
use App\Models\User;
use App\Services\Concerns\ResolvesAuthEmployee;
use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
@ -19,6 +20,7 @@ class AttendanceService
public function __construct(
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
) {}
public function listForCalendar(
@ -95,6 +97,13 @@ public function checkIn(array $validated, User $user): void
'checkin',
'checkin',
);
$this->pushNotificationService->sendToRoles(
'⏰ Presensi Masuk',
"Karyawan {$user->profile?->full_name} melakukan presensi masuk di {$locationTag}.",
['owner', 'developer'],
'/admin/hr/attendances',
);
}
/**
@ -143,6 +152,13 @@ public function checkOut(array $validated, User $user): void
'checkout',
'checkout',
);
$this->pushNotificationService->sendToRoles(
'⏰ Presensi Pulang',
"Karyawan {$user->profile?->full_name} melakukan presensi pulang di {$locationTag} (Durasi kerja: ".round($workDurationMinutes / 60, 1).' jam).',
['owner', 'developer'],
'/admin/hr/attendances',
);
}
public function delete(Attendance $attendance): void

View File

@ -6,6 +6,7 @@
use App\Models\LeaveRequest;
use App\Models\User;
use App\Services\Concerns\ResolvesAuthEmployee;
use App\Services\System\PushNotificationService;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
@ -16,6 +17,10 @@ class LeaveRequestService
{
use ResolvesAuthEmployee;
public function __construct(
private readonly PushNotificationService $pushNotificationService,
) {}
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
@ -52,13 +57,20 @@ public function create(array $validated, User $user): void
$this->ensureMinimumLeadTime($startDate);
$this->ensureValidDateRange($startDate, $endDate);
LeaveRequest::create([
$leaveRequest = LeaveRequest::create([
'employee_id' => $employee->id,
'start_date' => $startDate,
'end_date' => $endDate,
'total_days' => $this->calculateTotalDays($startDate, $endDate),
'status' => LeaveRequestStatus::PENDING,
]);
$this->pushNotificationService->sendToRoles(
'🏖️ Pengajuan Cuti Baru',
"Karyawan {$user->profile?->full_name} mengajukan cuti selama {$leaveRequest->total_days} hari mulai dari tanggal {$leaveRequest->start_date_formatted}.",
['owner', 'developer'],
'/admin/hr/leave-requests',
);
}
/**
@ -99,6 +111,16 @@ public function approve(LeaveRequest $leaveRequest, User $user): void
$leaveRequest->verified_by_id = $user->id;
$leaveRequest->save();
});
$leaveRequest->loadMissing('employee.user');
if ($leaveRequest->employee?->user_id) {
$this->pushNotificationService->sendToUser(
'🏖️ Pengajuan Cuti Disetujui',
"Pengajuan cuti Anda selama {$leaveRequest->total_days} hari ({$leaveRequest->start_date_formatted} - {$leaveRequest->end_date_formatted}) telah disetujui.",
$leaveRequest->employee->user_id,
'/admin/hr/leave-requests',
);
}
}
public function reject(LeaveRequest $leaveRequest, string $reason, User $user): void
@ -116,6 +138,16 @@ public function reject(LeaveRequest $leaveRequest, string $reason, User $user):
'rejected_by_id' => $user->id,
]);
});
$leaveRequest->loadMissing('employee.user');
if ($leaveRequest->employee?->user_id) {
$this->pushNotificationService->sendToUser(
'🏖️ Pengajuan Cuti Ditolak',
"Pengajuan cuti Anda selama {$leaveRequest->total_days} hari ({$leaveRequest->start_date_formatted} - {$leaveRequest->end_date_formatted}) ditolak dengan alasan: '{$reason}'.",
$leaveRequest->employee->user_id,
'/admin/hr/leave-requests',
);
}
}
private function calculateTotalDays(Carbon $startDate, Carbon $endDate): int

View File

@ -11,6 +11,7 @@
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Models\User;
use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
@ -20,6 +21,10 @@
class CuttingService
{
public function __construct(
private readonly PushNotificationService $pushNotificationService,
) {}
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
@ -207,7 +212,7 @@ public function findForEdit(Cutting $cutting): Cutting
*/
public function create(array $validated, User $user): Cutting
{
return DB::transaction(function () use ($validated, $user): Cutting {
$cutting = DB::transaction(function () use ($validated, $user): Cutting {
$materials = $this->buildMaterials($validated['materials']);
$results = $this->buildResults($validated['results']);
@ -230,6 +235,15 @@ public function create(array $validated, User $user): Cutting
return $cutting;
});
$this->pushNotificationService->sendToRoles(
'✂️ Proses Potong Baru',
"Proses potong dengan deskripsi ({$cutting->description}) telah dimulai oleh {$user->profile?->full_name}.",
['owner', 'developer'],
'/admin/manage/cuttings',
);
return $cutting;
}
/**
@ -286,6 +300,8 @@ public function delete(Cutting $cutting): void
]);
}
$description = $cutting->description ?? '-';
DB::transaction(function () use ($cutting): void {
$cutting->load(['materials.rawMaterialPrice.rawMaterial']);
$this->reverseTotalMaterialStock($cutting);
@ -294,6 +310,13 @@ public function delete(Cutting $cutting): void
$cutting->results()->delete();
$cutting->delete();
});
$this->pushNotificationService->sendToRoles(
'🗑️ Proses Potong Dihapus',
"Proses potong dengan deskripsi {$description} telah dihapus.",
['owner', 'developer'],
'/admin/manage/cuttings',
);
}
public function transitionStatus(
@ -310,7 +333,7 @@ public function transitionStatus(
]);
}
DB::transaction(function () use ($cutting, $status, $reason, $verificationNote, $results, $user): void {
DB::transaction(function () use ($cutting, $status, $verificationNote, $results, $user): void {
$cutting->load(['materials', 'results']);
if ($status === CuttingStatus::COMPLETED) {
@ -342,6 +365,30 @@ public function transitionStatus(
$cutting->status = $status;
$cutting->save();
});
$description = $cutting->description ?? '-';
$message = match ($status) {
CuttingStatus::COMPLETED => "Proses potong dengan deskripsi '{$description}' telah selesai dan menunggu verifikasi.",
CuttingStatus::VERIFIED => "Proses potong dengan deskripsi '{$description}' telah diverifikasi.",
CuttingStatus::REJECTED => "Proses potong dengan deskripsi '{$description}' ditolak".($reason ? " dengan alasan: '{$reason}'" : '').'.',
CuttingStatus::IN_PROGRESS => "Proses potong dengan deskripsi '{$description}' dikembalikan ke proses.",
default => "Status proses potong dengan deskripsi '{$description}' telah diperbarui ke: {$status->label()}.",
};
$title = match ($status) {
CuttingStatus::COMPLETED => '✂️ Proses Potong Selesai',
CuttingStatus::VERIFIED => '✂️ Proses Potong Terverifikasi',
CuttingStatus::REJECTED => '✂️ Proses Potong Ditolak',
CuttingStatus::IN_PROGRESS => '✂️ Proses Potong Dikembalikan',
default => '✂️ Proses Potong Diperbarui',
};
$this->pushNotificationService->sendToRoles(
$title,
$message,
['owner', 'developer'],
'/admin/manage/cuttings',
);
}
/**

View File

@ -12,6 +12,7 @@
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Models\User;
use App\Services\System\PushNotificationService;
use App\Services\System\Setting\MarketplaceService;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -25,6 +26,7 @@ class OrderService
{
public function __construct(
private readonly MarketplaceService $marketplaceService,
private readonly PushNotificationService $pushNotificationService,
) {}
/**
@ -270,7 +272,7 @@ public function resyncDraftPrices(User $user, string $priceTypeValue): array
*/
public function create(array $validated, User $user): Order
{
return DB::transaction(function () use ($validated, $user): Order {
$order = DB::transaction(function () use ($validated, $user): Order {
$priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']);
/** @var EloquentCollection<int, OrderItem> $draftItems */
@ -316,6 +318,15 @@ public function create(array $validated, User $user): Order
return $order;
});
$this->pushNotificationService->sendToRoles(
'📦 Pesanan Baru',
"Pesanan baru {$order->order_number} senilai {$order->total_amount_formatted} telah dibuat oleh {$user->profile?->full_name}.",
['owner', 'developer'],
'/admin/manage/orders',
);
return $order;
}
/**
@ -364,10 +375,20 @@ public function update(Order $order, array $validated): void
$this->decrementStock($orderItem);
}
});
$this->pushNotificationService->sendToRoles(
'✏️ Pesanan Diperbarui',
"Pesanan {$order->order_number} senilai {$order->total_amount_formatted} telah diperbarui.",
['owner', 'developer'],
'/admin/manage/orders',
);
}
public function delete(Order $order): void
{
$orderNumber = $order->order_number;
$totalAmount = $order->total_amount;
DB::transaction(function () use ($order): void {
$order->load('items');
@ -380,6 +401,13 @@ public function delete(Order $order): void
$order->items()->delete();
$order->delete();
});
$this->pushNotificationService->sendToRoles(
'🗑️ Pesanan Dihapus',
"Pesanan {$orderNumber} senilai {$order->total_amount_formatted} telah dihapus.",
['owner', 'developer'],
'/admin/manage/orders',
);
}
public function transitionStatus(Order $order, OrderStatus $status): void
@ -402,6 +430,13 @@ public function transitionStatus(Order $order, OrderStatus $status): void
$order->status = $status;
$order->save();
});
$this->pushNotificationService->sendToRoles(
'📦 Status Pesanan Diubah',
"Pesanan {$order->order_number} diubah statusnya menjadi {$status->label()}.",
['owner', 'developer'],
'/admin/manage/orders',
);
}
/**

View File

@ -9,6 +9,7 @@
use App\Models\Supplier;
use App\Models\User;
use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
@ -23,6 +24,7 @@ class PurchaseService
public function __construct(
private readonly MediaService $mediaService,
private readonly PushNotificationService $pushNotificationService,
) {}
/**
@ -207,7 +209,7 @@ public function removeDraftItem(User $user, RawMaterialPrice $rawMaterialPrice):
*/
public function create(array $validated, User $user): Purchase
{
return DB::transaction(function () use ($validated, $user): Purchase {
$purchase = DB::transaction(function () use ($validated, $user): Purchase {
/** @var EloquentCollection<int, PurchaseItem> $draftItems */
$draftItems = $this->draftItemsQuery($user)
->lockForUpdate()
@ -242,6 +244,16 @@ public function create(array $validated, User $user): Purchase
return $purchase;
});
$purchase->load('supplier');
$this->pushNotificationService->sendToRoles(
'🛒 Belanja Baru',
"Pembelian dari supplier {$purchase->supplier->name} senilai {$purchase->total_formatted} telah ditambahkan oleh {$user->profile?->full_name}.",
['owner', 'developer'],
'/admin/manage/purchases',
);
return $purchase;
}
/**
@ -277,10 +289,21 @@ public function update(Purchase $purchase, array $validated): void
$this->syncPhotos($purchase, $validated);
});
$purchase->load('supplier');
$this->pushNotificationService->sendToRoles(
'✏️ Belanja Diperbarui',
"Pembelian dari supplier {$purchase->supplier->name} senilai {$purchase->total_formatted} telah diperbarui.",
['owner', 'developer'],
'/admin/manage/purchases',
);
}
public function delete(Purchase $purchase): void
{
$supplierName = $purchase->supplier->name;
$total = $purchase->total;
DB::transaction(function () use ($purchase): void {
$purchase->load('items');
@ -292,6 +315,13 @@ public function delete(Purchase $purchase): void
$purchase->items()->delete();
$purchase->delete();
});
$this->pushNotificationService->sendToRoles(
'🗑️ Belanja Dihapus',
"Pembelian dari supplier {$supplierName} senilai {$total_formatted} telah dihapus.",
['owner', 'developer'],
'/admin/manage/purchases',
);
}
/**

View File

@ -2,6 +2,7 @@
namespace App\Services\Master;
use App\Jobs\SendPushNotificationJob;
use App\Models\Category;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
@ -33,7 +34,12 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator
*/
public function create(array $validated): void
{
Category::create($validated);
$category = Category::create($validated);
SendPushNotificationJob::dispatch(
'📦 Kategori Baru',
"Kategori '{$category->name}' telah ditambahkan.",
);
}
/**
@ -42,11 +48,22 @@ public function create(array $validated): void
public function update(Category $category, array $validated): void
{
$category->fill($validated)->save();
SendPushNotificationJob::dispatch(
'✏️ Kategori Diperbarui',
"Kategori '{$category->name}' telah diperbarui.",
);
}
public function delete(Category $category): void
{
$name = $category->name;
$category->delete();
SendPushNotificationJob::dispatch(
'🗑️ Kategori Dihapus',
"Kategori '{$name}' telah dihapus.",
);
}
private function applySorting(Builder $query, string $sort, string $direction): void

View File

@ -0,0 +1,34 @@
<?php
namespace App\Services\System;
use App\Jobs\SendPushNotificationJob;
class PushNotificationService
{
/**
* Send notification to users with specific roles.
*
* @param list<string> $roles
*/
public function sendToRoles(string $title, string $body, array $roles, string $url = '/admin/dashboard'): void
{
SendPushNotificationJob::dispatch($title, $body, $url, $roles);
}
/**
* Send notification to a specific user.
*/
public function sendToUser(string $title, string $body, int $userId, string $url = '/admin/dashboard'): void
{
SendPushNotificationJob::dispatch($title, $body, $url, [], $userId);
}
/**
* Send notification to all users.
*/
public function sendToAll(string $title, string $body, string $url = '/admin/dashboard'): void
{
SendPushNotificationJob::dispatch($title, $body, $url, []);
}
}

View File

@ -14,6 +14,7 @@
"laravel/framework": "^13.7",
"laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.14",
"minishlink/web-push": "^9.0",
"spatie/laravel-activitylog": "^5.0",
"spatie/laravel-medialibrary": "^11.23",
"spatie/laravel-permission": "^8.0",

413
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "75257a69f859a79b7713f638be63b5ed",
"content-hash": "396b0f4aff13715436b182ab532948db",
"packages": [
{
"name": "brick/math",
@ -2369,6 +2369,73 @@
],
"time": "2026-04-11T18:38:28+00:00"
},
{
"name": "minishlink/web-push",
"version": "v9.0.4",
"source": {
"type": "git",
"url": "https://github.com/web-push-libs/web-push-php.git",
"reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/f979f40b0017d2f86d82b9f21edbc515d031cc23",
"reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-openssl": "*",
"guzzlehttp/guzzle": "^7.9.2",
"php": ">=8.1",
"spomky-labs/base64url": "^2.0.4",
"symfony/polyfill-php82": "^v1.31.0",
"web-token/jwt-library": "^3.3.0|^4.0.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.91.3",
"phpstan/phpstan": "^2.1.2",
"phpunit/phpunit": "^10.5.44|^11.5.6",
"symfony/polyfill-iconv": "^1.33"
},
"suggest": {
"ext-bcmath": "Optional for performance.",
"ext-gmp": "Optional for performance."
},
"type": "library",
"autoload": {
"psr-4": {
"Minishlink\\WebPush\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Louis Lagrange",
"email": "lagrange.louis@gmail.com",
"homepage": "https://github.com/Minishlink"
}
],
"description": "Web Push library for PHP",
"homepage": "https://github.com/web-push-libs/web-push-php",
"keywords": [
"Push API",
"WebPush",
"notifications",
"push",
"web"
],
"support": {
"issues": "https://github.com/web-push-libs/web-push-php/issues",
"source": "https://github.com/web-push-libs/web-push-php/tree/v9.0.4"
},
"time": "2025-12-10T14:00:12+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@ -4505,6 +4572,181 @@
],
"time": "2026-01-12T07:42:22+00:00"
},
{
"name": "spomky-labs/base64url",
"version": "v2.0.4",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/base64url.git",
"reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d",
"reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"require-dev": {
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^0.11|^0.12",
"phpstan/phpstan-beberlei-assert": "^0.11|^0.12",
"phpstan/phpstan-deprecation-rules": "^0.11|^0.12",
"phpstan/phpstan-phpunit": "^0.11|^0.12",
"phpstan/phpstan-strict-rules": "^0.11|^0.12"
},
"type": "library",
"autoload": {
"psr-4": {
"Base64Url\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky-Labs/base64url/contributors"
}
],
"description": "Base 64 URL Safe Encoding/Decoding PHP Library",
"homepage": "https://github.com/Spomky-Labs/base64url",
"keywords": [
"base64",
"rfc4648",
"safe",
"url"
],
"support": {
"issues": "https://github.com/Spomky-Labs/base64url/issues",
"source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2020-11-03T09:10:25+00:00"
},
{
"name": "spomky-labs/pki-framework",
"version": "1.4.2",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/pki-framework.git",
"reference": "aa576cbd07128075bef97ac2f8af9854e67513d8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8",
"reference": "aa576cbd07128075bef97ac2f8af9854e67513d8",
"shasum": ""
},
"require": {
"brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
"ext-mbstring": "*",
"php": ">=8.1",
"psr/clock": "^1.0"
},
"require-dev": {
"ekino/phpstan-banned-code": "^1.0|^2.0|^3.0",
"ext-gmp": "*",
"ext-openssl": "*",
"infection/infection": "^0.28|^0.29|^0.31|^0.32",
"php-parallel-lint/php-parallel-lint": "^1.3",
"phpstan/extension-installer": "^1.3|^2.0",
"phpstan/phpstan": "^1.8|^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0|^2.0",
"phpstan/phpstan-phpunit": "^1.1|^2.0",
"phpstan/phpstan-strict-rules": "^1.3|^2.0",
"phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0",
"rector/rector": "^1.0|^2.0",
"roave/security-advisories": "dev-latest",
"symfony/string": "^6.4|^7.0|^8.0",
"symfony/var-dumper": "^6.4|^7.0|^8.0",
"symplify/easy-coding-standard": "^12.0|^13.0"
},
"suggest": {
"ext-bcmath": "For better performance (or GMP)",
"ext-gmp": "For better performance (or BCMath)",
"ext-openssl": "For OpenSSL based cyphering"
},
"type": "library",
"autoload": {
"psr-4": {
"SpomkyLabs\\Pki\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Joni Eskelinen",
"email": "jonieske@gmail.com",
"role": "Original developer"
},
{
"name": "Florent Morselli",
"email": "florent.morselli@spomky-labs.com",
"role": "Spomky-Labs PKI Framework developer"
}
],
"description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.",
"homepage": "https://github.com/spomky-labs/pki-framework",
"keywords": [
"DER",
"Private Key",
"ac",
"algorithm identifier",
"asn.1",
"asn1",
"attribute certificate",
"certificate",
"certification request",
"cryptography",
"csr",
"decrypt",
"ec",
"encrypt",
"pem",
"pkcs",
"public key",
"rsa",
"sign",
"signature",
"verify",
"x.509",
"x.690",
"x509",
"x690"
],
"support": {
"issues": "https://github.com/Spomky-Labs/pki-framework/issues",
"source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2026-03-23T22:56:56+00:00"
},
{
"name": "symfony/clock",
"version": "v8.1.0",
@ -5999,6 +6241,86 @@
],
"time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-php82",
"version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php82.git",
"reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b",
"reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php82\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-26T12:45:58+00:00"
},
{
"name": "symfony/polyfill-php84",
"version": "v1.38.1",
@ -7196,6 +7518,95 @@
}
],
"time": "2026-04-26T05:33:54+00:00"
},
{
"name": "web-token/jwt-library",
"version": "4.1.7",
"source": {
"type": "git",
"url": "https://github.com/web-token/jwt-library.git",
"reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-token/jwt-library/zipball/fbcbf2c276d04d8b056f5c2957815abd5dfb704d",
"reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d",
"shasum": ""
},
"require": {
"brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
"php": ">=8.2",
"psr/clock": "^1.0",
"spomky-labs/pki-framework": "^1.2.1"
},
"conflict": {
"spomky-labs/jose": "*"
},
"suggest": {
"ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance",
"ext-gmp": "GMP or BCMath is highly recommended to improve the library performance",
"ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)",
"ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
"paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
"spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)",
"symfony/console": "Needed to use console commands",
"symfony/http-client": "To enable JKU/X5U support."
},
"type": "library",
"autoload": {
"psr-4": {
"Jose\\Component\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/web-token/jwt-framework/contributors"
}
],
"description": "JWT library",
"homepage": "https://github.com/web-token",
"keywords": [
"JOSE",
"JWE",
"JWK",
"JWKSet",
"JWS",
"Jot",
"RFC7515",
"RFC7516",
"RFC7517",
"RFC7518",
"RFC7519",
"RFC7520",
"bundle",
"jwa",
"jwt",
"symfony"
],
"support": {
"issues": "https://github.com/web-token/jwt-library/issues",
"source": "https://github.com/web-token/jwt-library/tree/4.1.7"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2026-06-06T18:12:39+00:00"
}
],
"packages-dev": [

9
config/webpush.php Normal file
View File

@ -0,0 +1,9 @@
<?php
return [
'vapid' => [
'public_key' => env('VAPID_PUBLIC_KEY', ''),
'private_key' => env('VAPID_PRIVATE_KEY', ''),
'subject' => env('VAPID_SUBJECT', env('APP_URL', 'http://localhost')),
],
];

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('push_subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('endpoint', 500);
$table->string('public_key', 255)->nullable();
$table->string('auth_token', 255)->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->unique('endpoint');
});
}
public function down(): void
{
Schema::dropIfExists('push_subscriptions');
}
};

View File

@ -0,0 +1,36 @@
{
"name": "DST Collection",
"short_name": "DST Collection",
"description": "DST Collection - Progressive Web App",
"theme_color": "#171717",
"background_color": "#ffffff",
"display": "standalone",
"orientation": "portrait",
"scope": "/",
"start_url": "/",
"id": "/",
"icons": [
{
"src": "/assets/pwa-64x64.png",
"sizes": "64x64",
"type": "image/png"
},
{
"src": "/assets/pwa-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/assets/pwa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/maskable-icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

70
public/sw.js Normal file
View File

@ -0,0 +1,70 @@
// DST Collection — Service Worker
// Push Notification Handler
const CACHE_NAME = 'dst-v1';
// ── Install ──────────────────────────────────────────────────────────────────
self.addEventListener('install', () => {
self.skipWaiting();
});
// ── Activate ─────────────────────────────────────────────────────────────────
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)),
),
)
.then(() => self.clients.claim()),
);
});
// ── Push Notification ─────────────────────────────────────────────────────────
self.addEventListener('push', (event) => {
if (!event.data) return;
let data = {};
try {
data = event.data.json();
} catch (e) {
data = { title: 'DST Collection', body: event.data.text() };
}
const title = data.title || 'DST Collection';
const options = {
body: data.body || '',
icon: data.icon || '/assets/pwa-192x192.png',
badge: '/assets/pwa-64x64.png',
data: { url: data.url || '/admin/master/categories' },
tag: 'dst-notification',
renotify: true,
requireInteraction: false,
};
event.waitUntil(self.registration.showNotification(title, options));
});
// ── Notification Click ────────────────────────────────────────────────────────
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetUrl = (event.notification.data && event.notification.data.url)
? event.notification.data.url
: '/admin/dashboard';
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
for (const client of clientList) {
if ('navigate' in client) {
return client.navigate(targetUrl).then((c) => c && c.focus());
}
}
return self.clients.openWindow(targetUrl);
}),
);
});

View File

@ -1,14 +1,21 @@
/// <reference types="vite-plugin-pwa/client" />
import { createInertiaApp } from '@inertiajs/vue3';
import { registerSW } from 'virtual:pwa-register';
import { createApp, h } from 'vue';
import 'vue-sonner/style.css';
import { Toaster } from '@/components/ui/sonner';
import { useFlashToast } from '@/composables/useFlashToast';
import { restoreConnection } from '@/lib/thermal-printer/stable-transport';
registerSW({ immediate: true });
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js', { scope: '/' })
.then((reg) => {
console.log('[SW] Registered:', reg.scope);
})
.catch((err) => {
console.error('[SW] Registration failed:', err);
});
}
void restoreConnection();
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { router, usePage } from '@inertiajs/vue3';
import { LogOut, User } from '@lucide/vue';
import { Bell, BellOff, LogOut, User } from '@lucide/vue';
import { computed } from 'vue';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@ -11,6 +11,7 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { usePushNotification } from '@/composables/usePushNotification';
import Separator from './ui/separator/Separator.vue';
const page = usePage();
@ -23,41 +24,61 @@ const initials = computed(() => {
return username.slice(0, 2).toUpperCase();
});
const { isSupported, isSubscribed, isLoading, toggle } = usePushNotification();
function logout() {
router.post('/auth/logout');
}
</script>
<template>
<DropdownMenu v-if="user">
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="relative size-8 rounded-full">
<Avatar class="size-8">
<AvatarFallback>{{ initials }}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-56" align="end">
<DropdownMenuLabel class="font-normal">
<div class="flex flex-col space-y-1">
<p class="text-sm leading-none font-medium">
{{ user.username }}
</p>
<p class="text-muted-foreground text-xs leading-none">
{{ user.email }}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem @click="router.visit('/admin/account/profile')">
<User />
Profil
</DropdownMenuItem>
<Separator />
<DropdownMenuItem variant="destructive" @click="logout">
<LogOut />
Keluar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div class="flex items-center gap-2">
<Button
v-if="isSupported"
:disabled="isLoading"
:title="isSubscribed ? 'Matikan notifikasi' : 'Aktifkan notifikasi'"
class="relative size-8 transition-all"
variant="ghost"
@click="toggle"
>
<BellOff v-if="isSubscribed" class="size-4 text-muted-foreground" />
<Bell v-else class="size-4" />
<span
v-if="isSubscribed"
class="absolute top-1 right-1 size-2 rounded-full bg-emerald-500 ring-1 ring-background"
/>
</Button>
<DropdownMenu v-if="user">
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="relative size-8 rounded-full">
<Avatar class="size-8">
<AvatarFallback>{{ initials }}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-56" align="end">
<DropdownMenuLabel class="font-normal">
<div class="flex flex-col space-y-1">
<p class="text-sm leading-none font-medium">
{{ user.username }}
</p>
<p class="text-muted-foreground text-xs leading-none">
{{ user.email }}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem @click="router.visit('/admin/account/profile')">
<User />
Profil
</DropdownMenuItem>
<Separator />
<DropdownMenuItem variant="destructive" @click="logout">
<LogOut />
Keluar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>

View File

@ -0,0 +1,145 @@
import { usePage } from '@inertiajs/vue3';
import { computed, onMounted, ref } from 'vue';
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
export function usePushNotification() {
const isSupported = computed(
() =>
'Notification' in window &&
'serviceWorker' in navigator &&
'PushManager' in window,
);
const isSubscribed = ref(false);
const isLoading = ref(false);
const page = usePage();
const vapidPublicKey = (page.props as Record<string, unknown>)
.vapidPublicKey as string | undefined;
async function checkSubscriptionStatus(): Promise<void> {
if (!isSupported.value) return;
try {
const registration = await navigator.serviceWorker.ready;
const subscription =
await registration.pushManager.getSubscription();
isSubscribed.value = !!subscription;
} catch {
isSubscribed.value = false;
}
}
async function subscribe(): Promise<void> {
if (!isSupported.value || isLoading.value) return;
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
isLoading.value = true;
try {
const registration = await navigator.serviceWorker.ready;
const publicKey =
vapidPublicKey ?? import.meta.env.VITE_VAPID_PUBLIC_KEY ?? '';
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
const json = subscription.toJSON();
await fetch('/push-subscriptions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN':
(
document.querySelector(
'meta[name="csrf-token"]',
) as HTMLMetaElement
)?.content ?? '',
},
body: JSON.stringify({
endpoint: subscription.endpoint,
publicKey: json.keys?.p256dh ?? '',
authToken: json.keys?.auth ?? '',
}),
});
isSubscribed.value = true;
} catch (error) {
console.error('[PushNotification] Subscribe failed:', error);
} finally {
isLoading.value = false;
}
}
async function unsubscribe(): Promise<void> {
if (!isSupported.value || isLoading.value) return;
isLoading.value = true;
try {
const registration = await navigator.serviceWorker.ready;
const subscription =
await registration.pushManager.getSubscription();
if (!subscription) {
isSubscribed.value = false;
return;
}
await fetch('/push-subscriptions', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN':
(
document.querySelector(
'meta[name="csrf-token"]',
) as HTMLMetaElement
)?.content ?? '',
},
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
await subscription.unsubscribe();
isSubscribed.value = false;
} catch (error) {
console.error('[PushNotification] Unsubscribe failed:', error);
} finally {
isLoading.value = false;
}
}
async function toggle(): Promise<void> {
if (isSubscribed.value) {
await unsubscribe();
} else {
await subscribe();
}
}
onMounted(() => {
void checkSubscriptionStatus();
});
return {
isSupported,
isSubscribed,
isLoading,
subscribe,
unsubscribe,
toggle,
};
}

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

@ -0,0 +1,87 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
const sw = self as unknown as ServiceWorkerGlobalScope;
interface ExtendableEvent extends Event {
waitUntil(fn: Promise<unknown>): void;
}
interface PushEvent extends ExtendableEvent {
data: PushMessageData | null;
}
interface PushMessageData {
json(): unknown;
text(): string;
}
interface NotificationEvent extends ExtendableEvent {
notification: Notification;
}
// ──────────────────────────────────────────────────────────────────────────────
// Service Worker: Install & Activate
// ──────────────────────────────────────────────────────────────────────────────
sw.addEventListener('install', () => {
sw.skipWaiting();
});
sw.addEventListener('activate', (event: Event) => {
(event as ExtendableEvent).waitUntil(sw.clients.claim());
});
// ──────────────────────────────────────────────────────────────────────────────
// Push Notification Handler
// ──────────────────────────────────────────────────────────────────────────────
sw.addEventListener('push', (event: Event) => {
const pushEvent = event as PushEvent;
if (!pushEvent.data) return;
let data: { title?: string; body?: string; icon?: string; url?: string } =
{};
try {
data = pushEvent.data.json() as typeof data;
} catch {
data = { title: 'DST Collection', body: pushEvent.data.text() };
}
const title = data.title ?? 'DST Collection';
const options: NotificationOptions = {
body: data.body ?? '',
icon: data.icon ?? '/assets/pwa-192x192.png',
badge: '/assets/pwa-64x64.png',
data: { url: data.url ?? '/admin/master/categories' },
tag: 'dst-notification',
renotify: true,
};
pushEvent.waitUntil(sw.registration.showNotification(title, options));
});
// ──────────────────────────────────────────────────────────────────────────────
// Notification Click Handler
// ──────────────────────────────────────────────────────────────────────────────
sw.addEventListener('notificationclick', (event: Event) => {
const notifEvent = event as NotificationEvent;
notifEvent.notification.close();
const targetUrl: string =
(notifEvent.notification.data?.url as string) ?? '/admin/dashboard';
notifEvent.waitUntil(
sw.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
for (const client of clientList) {
if ('focus' in client) {
return (client as WindowClient)
.navigate(targetUrl)
.then((c) => c?.focus());
}
}
return sw.clients.openWindow(targetUrl);
}),
);
});

View File

@ -16,6 +16,7 @@
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="description" content="{{ config('app.name', 'Laravel') }} - Progressive Web App">
<meta name="theme-color" content="#171717">

View File

@ -27,6 +27,7 @@
use App\Http\Controllers\Admin\System\SettingController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\LogoutController;
use App\Http\Controllers\PushSubscriptionController;
use Illuminate\Support\Facades\Route;
Route::inertia('/', 'Welcome')->name('home');
@ -39,6 +40,10 @@
Route::middleware(['auth', 'permission:'.Permission::DASHBOARD_VIEW->value])->group(function () {
Route::post('/auth/logout', [LogoutController::class, 'store'])->name('logout');
// Push Notifications
Route::post('/push-subscriptions', [PushSubscriptionController::class, 'store'])->name('push-subscriptions.store');
Route::delete('/push-subscriptions', [PushSubscriptionController::class, 'destroy'])->name('push-subscriptions.destroy');
Route::prefix('admin')->name('admin.')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])
->name('dashboard');

View File

@ -32,13 +32,6 @@ const manifestIcons = [
},
] as const;
const publicIcons = [
{ src: '/favicon.ico' },
{ src: '/favicon.svg' },
{ src: '/assets/apple-touch-icon-180x180.png' },
{ src: '/assets/logo.png' },
];
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const appName = env.VITE_APP_NAME || 'DST Collection';
@ -72,27 +65,18 @@ export default defineConfig(({ mode }) => {
scope: '/',
base: '/',
registerType: 'autoUpdate',
// Kita daftarkan SW manual di app.ts (public/sw.js yang support push)
// VitePWA hanya generate manifest di sini
injectRegister: null,
devOptions: {
enabled: false,
},
includeAssets: [],
workbox: {
globPatterns: [
'**/*.{js,css,html,ico,jpg,png,svg,woff,woff2,ttf,eot}',
],
navigateFallback: '/',
navigateFallbackDenylist: [/^\/telescope/],
additionalManifestEntries: [
{ url: '/', revision: `${Date.now()}` },
...manifestIcons.map((icon) => ({
url: icon.src,
revision: `${Date.now()}`,
})),
...publicIcons.map((icon) => ({
url: icon.src,
revision: `${Date.now()}`,
})),
],
maximumFileSizeToCacheInBytes: 3_000_000,
},
manifest: {