feat: implement push notification system with web-push integration and subscription management
This commit is contained in:
parent
cd84a16549
commit
c22252f44c
46
app/Http/Controllers/PushSubscriptionController.php
Normal file
46
app/Http/Controllers/PushSubscriptionController.php
Normal 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.']);
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
101
app/Jobs/SendPushNotificationJob.php
Normal file
101
app/Jobs/SendPushNotificationJob.php
Normal 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();
|
||||
}
|
||||
}
|
||||
16
app/Models/PushSubscription.php
Normal file
16
app/Models/PushSubscription.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
34
app/Services/System/PushNotificationService.php
Normal file
34
app/Services/System/PushNotificationService.php
Normal 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, []);
|
||||
}
|
||||
}
|
||||
@ -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
413
composer.lock
generated
@ -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
9
config/webpush.php
Normal 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')),
|
||||
],
|
||||
];
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
36
public/manifest.webmanifest
Normal file
36
public/manifest.webmanifest
Normal 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
70
public/sw.js
Normal 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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
|
||||
145
resources/js/composables/usePushNotification.ts
Normal file
145
resources/js/composables/usePushNotification.ts
Normal 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
87
resources/js/sw.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
@ -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">
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user