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')
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state')
|
||||||
|| $request->cookie('sidebar_state') === 'true',
|
|| $request->cookie('sidebar_state') === 'true',
|
||||||
'appearance' => $request->cookie('appearance', 'system'),
|
'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);
|
$originalResult = $cuttingResults->get($variantId);
|
||||||
if ($originalResult === null) {
|
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;
|
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);
|
return $this->hasOne(UserProfile::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function pushSubscriptions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PushSubscription::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function purchases(): HasMany
|
public function purchases(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Purchase::class, 'created_by_id');
|
return $this->hasMany(Purchase::class, 'created_by_id');
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Models\CashTransaction;
|
use App\Models\CashTransaction;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Media\MediaService;
|
use App\Services\Media\MediaService;
|
||||||
|
use App\Services\System\PushNotificationService;
|
||||||
use App\Support\Media\MediaPresenter;
|
use App\Support\Media\MediaPresenter;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -19,6 +20,7 @@ class CashService
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MediaService $mediaService,
|
private readonly MediaService $mediaService,
|
||||||
|
private readonly PushNotificationService $pushNotificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getDefaultAccount(): CashAccount
|
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
|
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);
|
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);
|
||||||
$amount = (int) $validated['amount'];
|
$amount = (int) $validated['amount'];
|
||||||
$newBalance = $account->balance + $amount;
|
$newBalance = $account->balance + $amount;
|
||||||
@ -81,6 +83,15 @@ public function deposit(CashAccount $cashAccount, array $validated, User $user):
|
|||||||
|
|
||||||
return $transaction;
|
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(
|
public function recordOutgoing(
|
||||||
@ -171,6 +182,13 @@ public function updateDeposit(CashTransaction $transaction, array $validated): v
|
|||||||
|
|
||||||
$this->recalculateBalances($transaction->cashAccount);
|
$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
|
public function deleteTransaction(CashTransaction $transaction): void
|
||||||
@ -185,6 +203,13 @@ public function deleteTransaction(CashTransaction $transaction): void
|
|||||||
|
|
||||||
$this->recalculateBalances($transaction->cashAccount);
|
$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(
|
public function updateReferencedTransaction(
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Concerns\ResolvesAuthEmployee;
|
use App\Services\Concerns\ResolvesAuthEmployee;
|
||||||
use App\Services\Media\MediaService;
|
use App\Services\Media\MediaService;
|
||||||
|
use App\Services\System\PushNotificationService;
|
||||||
use App\Support\Media\MediaPresenter;
|
use App\Support\Media\MediaPresenter;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -22,6 +23,7 @@ class EmployeeAdvanceService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CashService $cashService,
|
private readonly CashService $cashService,
|
||||||
private readonly MediaService $mediaService,
|
private readonly MediaService $mediaService,
|
||||||
|
private readonly PushNotificationService $pushNotificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -83,7 +85,7 @@ public function create(array $validated, User $user): void
|
|||||||
{
|
{
|
||||||
$employee = $this->resolveAuthEmployee($user);
|
$employee = $this->resolveAuthEmployee($user);
|
||||||
|
|
||||||
DB::transaction(function () use ($validated, $employee): void {
|
$employeeAdvance = DB::transaction(function () use ($validated, $employee) {
|
||||||
$employeeAdvance = EmployeeAdvance::create([
|
$employeeAdvance = EmployeeAdvance::create([
|
||||||
'employee_id' => $employee->id,
|
'employee_id' => $employee->id,
|
||||||
'amount' => (int) $validated['amount'],
|
'amount' => (int) $validated['amount'],
|
||||||
@ -93,7 +95,16 @@ public function create(array $validated, User $user): void
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->syncPhotos($employeeAdvance, $validated);
|
$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->verified_by_id = $user->id;
|
||||||
$employeeAdvance->save();
|
$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
|
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,
|
'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
|
public function pay(EmployeeAdvance $employeeAdvance, User $user): void
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Models\Expense;
|
use App\Models\Expense;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Media\MediaService;
|
use App\Services\Media\MediaService;
|
||||||
|
use App\Services\System\PushNotificationService;
|
||||||
use App\Support\Media\MediaPresenter;
|
use App\Support\Media\MediaPresenter;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -17,6 +18,7 @@ class ExpenseService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CashService $cashService,
|
private readonly CashService $cashService,
|
||||||
private readonly MediaService $mediaService,
|
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
|
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'];
|
$amount = (int) $validated['amount'];
|
||||||
$description = $validated['description'];
|
$description = $validated['description'];
|
||||||
|
|
||||||
@ -74,7 +76,16 @@ public function create(array $validated, User $user): void
|
|||||||
$expense->save();
|
$expense->save();
|
||||||
|
|
||||||
$this->syncPhotos($expense, $validated);
|
$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->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
|
public function delete(Expense $expense): void
|
||||||
{
|
{
|
||||||
|
$amount = $expense->amount;
|
||||||
|
$description = $expense->description;
|
||||||
|
|
||||||
DB::transaction(function () use ($expense): void {
|
DB::transaction(function () use ($expense): void {
|
||||||
if ($expense->cashTransaction) {
|
if ($expense->cashTransaction) {
|
||||||
$this->cashService->deleteReferencedTransaction($expense->cashTransaction);
|
$this->cashService->deleteReferencedTransaction($expense->cashTransaction);
|
||||||
@ -112,6 +133,13 @@ public function delete(Expense $expense): void
|
|||||||
$expense->clearMediaCollection('photos');
|
$expense->clearMediaCollection('photos');
|
||||||
$expense->delete();
|
$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\Payroll;
|
||||||
use App\Models\PayrollPeriod;
|
use App\Models\PayrollPeriod;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\System\PushNotificationService;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -23,6 +24,7 @@ class PayrollService
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CashService $cashService,
|
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
|
public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($closedBy): PayrollPeriod {
|
$period = DB::transaction(function () use ($closedBy): PayrollPeriod {
|
||||||
$now = now();
|
$now = now();
|
||||||
$year = $now->year;
|
$year = $now->year;
|
||||||
$month = $now->month;
|
$month = $now->month;
|
||||||
@ -141,6 +143,14 @@ public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod
|
|||||||
|
|
||||||
return $period->fresh();
|
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
|
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_at = now();
|
||||||
$period->closed_by_id = $user->id;
|
$period->closed_by_id = $user->id;
|
||||||
$period->save();
|
$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
|
public function generatePayrollsForPeriod(PayrollPeriod $period): void
|
||||||
@ -277,6 +293,15 @@ public function pay(Payroll $payroll, User $user): void
|
|||||||
|
|
||||||
$this->settleKasbonFromPayroll($payroll, $user);
|
$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\Models\User;
|
||||||
use App\Services\Concerns\ResolvesAuthEmployee;
|
use App\Services\Concerns\ResolvesAuthEmployee;
|
||||||
use App\Services\Media\MediaService;
|
use App\Services\Media\MediaService;
|
||||||
|
use App\Services\System\PushNotificationService;
|
||||||
use Carbon\CarbonInterface;
|
use Carbon\CarbonInterface;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@ -19,6 +20,7 @@ class AttendanceService
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MediaService $mediaService,
|
private readonly MediaService $mediaService,
|
||||||
|
private readonly PushNotificationService $pushNotificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function listForCalendar(
|
public function listForCalendar(
|
||||||
@ -95,6 +97,13 @@ public function checkIn(array $validated, User $user): void
|
|||||||
'checkin',
|
'checkin',
|
||||||
'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',
|
||||||
'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
|
public function delete(Attendance $attendance): void
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Models\LeaveRequest;
|
use App\Models\LeaveRequest;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Concerns\ResolvesAuthEmployee;
|
use App\Services\Concerns\ResolvesAuthEmployee;
|
||||||
|
use App\Services\System\PushNotificationService;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -16,6 +17,10 @@ class LeaveRequestService
|
|||||||
{
|
{
|
||||||
use ResolvesAuthEmployee;
|
use ResolvesAuthEmployee;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly PushNotificationService $pushNotificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
|
* @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->ensureMinimumLeadTime($startDate);
|
||||||
$this->ensureValidDateRange($startDate, $endDate);
|
$this->ensureValidDateRange($startDate, $endDate);
|
||||||
|
|
||||||
LeaveRequest::create([
|
$leaveRequest = LeaveRequest::create([
|
||||||
'employee_id' => $employee->id,
|
'employee_id' => $employee->id,
|
||||||
'start_date' => $startDate,
|
'start_date' => $startDate,
|
||||||
'end_date' => $endDate,
|
'end_date' => $endDate,
|
||||||
'total_days' => $this->calculateTotalDays($startDate, $endDate),
|
'total_days' => $this->calculateTotalDays($startDate, $endDate),
|
||||||
'status' => LeaveRequestStatus::PENDING,
|
'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->verified_by_id = $user->id;
|
||||||
$leaveRequest->save();
|
$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
|
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,
|
'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
|
private function calculateTotalDays(Carbon $startDate, Carbon $endDate): int
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Models\RawMaterial;
|
use App\Models\RawMaterial;
|
||||||
use App\Models\RawMaterialPrice;
|
use App\Models\RawMaterialPrice;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\System\PushNotificationService;
|
||||||
use App\Support\Media\MediaPresenter;
|
use App\Support\Media\MediaPresenter;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -20,6 +21,10 @@
|
|||||||
|
|
||||||
class CuttingService
|
class CuttingService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PushNotificationService $pushNotificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
|
* @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
|
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']);
|
$materials = $this->buildMaterials($validated['materials']);
|
||||||
$results = $this->buildResults($validated['results']);
|
$results = $this->buildResults($validated['results']);
|
||||||
|
|
||||||
@ -230,6 +235,15 @@ public function create(array $validated, User $user): Cutting
|
|||||||
|
|
||||||
return $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 {
|
DB::transaction(function () use ($cutting): void {
|
||||||
$cutting->load(['materials.rawMaterialPrice.rawMaterial']);
|
$cutting->load(['materials.rawMaterialPrice.rawMaterial']);
|
||||||
$this->reverseTotalMaterialStock($cutting);
|
$this->reverseTotalMaterialStock($cutting);
|
||||||
@ -294,6 +310,13 @@ public function delete(Cutting $cutting): void
|
|||||||
$cutting->results()->delete();
|
$cutting->results()->delete();
|
||||||
$cutting->delete();
|
$cutting->delete();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->pushNotificationService->sendToRoles(
|
||||||
|
'🗑️ Proses Potong Dihapus',
|
||||||
|
"Proses potong dengan deskripsi {$description} telah dihapus.",
|
||||||
|
['owner', 'developer'],
|
||||||
|
'/admin/manage/cuttings',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function transitionStatus(
|
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']);
|
$cutting->load(['materials', 'results']);
|
||||||
|
|
||||||
if ($status === CuttingStatus::COMPLETED) {
|
if ($status === CuttingStatus::COMPLETED) {
|
||||||
@ -342,6 +365,30 @@ public function transitionStatus(
|
|||||||
$cutting->status = $status;
|
$cutting->status = $status;
|
||||||
$cutting->save();
|
$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\ProductPrice;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\System\PushNotificationService;
|
||||||
use App\Services\System\Setting\MarketplaceService;
|
use App\Services\System\Setting\MarketplaceService;
|
||||||
use App\Support\Media\MediaPresenter;
|
use App\Support\Media\MediaPresenter;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
@ -25,6 +26,7 @@ class OrderService
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MarketplaceService $marketplaceService,
|
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
|
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']);
|
$priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']);
|
||||||
|
|
||||||
/** @var EloquentCollection<int, OrderItem> $draftItems */
|
/** @var EloquentCollection<int, OrderItem> $draftItems */
|
||||||
@ -316,6 +318,15 @@ public function create(array $validated, User $user): Order
|
|||||||
|
|
||||||
return $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->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
|
public function delete(Order $order): void
|
||||||
{
|
{
|
||||||
|
$orderNumber = $order->order_number;
|
||||||
|
$totalAmount = $order->total_amount;
|
||||||
|
|
||||||
DB::transaction(function () use ($order): void {
|
DB::transaction(function () use ($order): void {
|
||||||
$order->load('items');
|
$order->load('items');
|
||||||
|
|
||||||
@ -380,6 +401,13 @@ public function delete(Order $order): void
|
|||||||
$order->items()->delete();
|
$order->items()->delete();
|
||||||
$order->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
|
public function transitionStatus(Order $order, OrderStatus $status): void
|
||||||
@ -402,6 +430,13 @@ public function transitionStatus(Order $order, OrderStatus $status): void
|
|||||||
$order->status = $status;
|
$order->status = $status;
|
||||||
$order->save();
|
$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\Supplier;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Media\MediaService;
|
use App\Services\Media\MediaService;
|
||||||
|
use App\Services\System\PushNotificationService;
|
||||||
use App\Support\Media\MediaPresenter;
|
use App\Support\Media\MediaPresenter;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -23,6 +24,7 @@ class PurchaseService
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MediaService $mediaService,
|
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
|
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 */
|
/** @var EloquentCollection<int, PurchaseItem> $draftItems */
|
||||||
$draftItems = $this->draftItemsQuery($user)
|
$draftItems = $this->draftItemsQuery($user)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@ -242,6 +244,16 @@ public function create(array $validated, User $user): Purchase
|
|||||||
|
|
||||||
return $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);
|
$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
|
public function delete(Purchase $purchase): void
|
||||||
{
|
{
|
||||||
|
$supplierName = $purchase->supplier->name;
|
||||||
|
$total = $purchase->total;
|
||||||
|
|
||||||
DB::transaction(function () use ($purchase): void {
|
DB::transaction(function () use ($purchase): void {
|
||||||
$purchase->load('items');
|
$purchase->load('items');
|
||||||
|
|
||||||
@ -292,6 +315,13 @@ public function delete(Purchase $purchase): void
|
|||||||
$purchase->items()->delete();
|
$purchase->items()->delete();
|
||||||
$purchase->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;
|
namespace App\Services\Master;
|
||||||
|
|
||||||
|
use App\Jobs\SendPushNotificationJob;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -33,7 +34,12 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator
|
|||||||
*/
|
*/
|
||||||
public function create(array $validated): void
|
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
|
public function update(Category $category, array $validated): void
|
||||||
{
|
{
|
||||||
$category->fill($validated)->save();
|
$category->fill($validated)->save();
|
||||||
|
|
||||||
|
SendPushNotificationJob::dispatch(
|
||||||
|
'✏️ Kategori Diperbarui',
|
||||||
|
"Kategori '{$category->name}' telah diperbarui.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(Category $category): void
|
public function delete(Category $category): void
|
||||||
{
|
{
|
||||||
|
$name = $category->name;
|
||||||
$category->delete();
|
$category->delete();
|
||||||
|
|
||||||
|
SendPushNotificationJob::dispatch(
|
||||||
|
'🗑️ Kategori Dihapus',
|
||||||
|
"Kategori '{$name}' telah dihapus.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applySorting(Builder $query, string $sort, string $direction): void
|
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/framework": "^13.7",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"laravel/wayfinder": "^0.1.14",
|
"laravel/wayfinder": "^0.1.14",
|
||||||
|
"minishlink/web-push": "^9.0",
|
||||||
"spatie/laravel-activitylog": "^5.0",
|
"spatie/laravel-activitylog": "^5.0",
|
||||||
"spatie/laravel-medialibrary": "^11.23",
|
"spatie/laravel-medialibrary": "^11.23",
|
||||||
"spatie/laravel-permission": "^8.0",
|
"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",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "75257a69f859a79b7713f638be63b5ed",
|
"content-hash": "396b0f4aff13715436b182ab532948db",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@ -2369,6 +2369,73 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-04-11T18:38:28+00:00"
|
"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",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
@ -4505,6 +4572,181 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-12T07:42:22+00:00"
|
"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",
|
"name": "symfony/clock",
|
||||||
"version": "v8.1.0",
|
"version": "v8.1.0",
|
||||||
@ -5999,6 +6241,86 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-04-10T16:19:22+00:00"
|
"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",
|
"name": "symfony/polyfill-php84",
|
||||||
"version": "v1.38.1",
|
"version": "v1.38.1",
|
||||||
@ -7196,6 +7518,95 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-04-26T05:33:54+00:00"
|
"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": [
|
"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 { createInertiaApp } from '@inertiajs/vue3';
|
||||||
import { registerSW } from 'virtual:pwa-register';
|
|
||||||
import { createApp, h } from 'vue';
|
import { createApp, h } from 'vue';
|
||||||
import 'vue-sonner/style.css';
|
import 'vue-sonner/style.css';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { useFlashToast } from '@/composables/useFlashToast';
|
import { useFlashToast } from '@/composables/useFlashToast';
|
||||||
import { restoreConnection } from '@/lib/thermal-printer/stable-transport';
|
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();
|
void restoreConnection();
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { router, usePage } from '@inertiajs/vue3';
|
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 { computed } from 'vue';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator, DropdownMenuTrigger
|
DropdownMenuSeparator, DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { usePushNotification } from '@/composables/usePushNotification';
|
||||||
import Separator from './ui/separator/Separator.vue';
|
import Separator from './ui/separator/Separator.vue';
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
@ -23,41 +24,61 @@ const initials = computed(() => {
|
|||||||
return username.slice(0, 2).toUpperCase();
|
return username.slice(0, 2).toUpperCase();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { isSupported, isSubscribed, isLoading, toggle } = usePushNotification();
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
router.post('/auth/logout');
|
router.post('/auth/logout');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenu v-if="user">
|
<div class="flex items-center gap-2">
|
||||||
<DropdownMenuTrigger as-child>
|
<Button
|
||||||
<Button variant="ghost" class="relative size-8 rounded-full">
|
v-if="isSupported"
|
||||||
<Avatar class="size-8">
|
:disabled="isLoading"
|
||||||
<AvatarFallback>{{ initials }}</AvatarFallback>
|
:title="isSubscribed ? 'Matikan notifikasi' : 'Aktifkan notifikasi'"
|
||||||
</Avatar>
|
class="relative size-8 transition-all"
|
||||||
</Button>
|
variant="ghost"
|
||||||
</DropdownMenuTrigger>
|
@click="toggle"
|
||||||
<DropdownMenuContent class="w-56" align="end">
|
>
|
||||||
<DropdownMenuLabel class="font-normal">
|
<BellOff v-if="isSubscribed" class="size-4 text-muted-foreground" />
|
||||||
<div class="flex flex-col space-y-1">
|
<Bell v-else class="size-4" />
|
||||||
<p class="text-sm leading-none font-medium">
|
<span
|
||||||
{{ user.username }}
|
v-if="isSubscribed"
|
||||||
</p>
|
class="absolute top-1 right-1 size-2 rounded-full bg-emerald-500 ring-1 ring-background"
|
||||||
<p class="text-muted-foreground text-xs leading-none">
|
/>
|
||||||
{{ user.email }}
|
</Button>
|
||||||
</p>
|
|
||||||
</div>
|
<DropdownMenu v-if="user">
|
||||||
</DropdownMenuLabel>
|
<DropdownMenuTrigger as-child>
|
||||||
<DropdownMenuSeparator />
|
<Button variant="ghost" class="relative size-8 rounded-full">
|
||||||
<DropdownMenuItem @click="router.visit('/admin/account/profile')">
|
<Avatar class="size-8">
|
||||||
<User />
|
<AvatarFallback>{{ initials }}</AvatarFallback>
|
||||||
Profil
|
</Avatar>
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
<Separator />
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem variant="destructive" @click="logout">
|
<DropdownMenuContent class="w-56" align="end">
|
||||||
<LogOut />
|
<DropdownMenuLabel class="font-normal">
|
||||||
Keluar
|
<div class="flex flex-col space-y-1">
|
||||||
</DropdownMenuItem>
|
<p class="text-sm leading-none font-medium">
|
||||||
</DropdownMenuContent>
|
{{ user.username }}
|
||||||
</DropdownMenu>
|
</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>
|
</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>
|
</script>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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="description" content="{{ config('app.name', 'Laravel') }} - Progressive Web App">
|
||||||
<meta name="theme-color" content="#171717">
|
<meta name="theme-color" content="#171717">
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
use App\Http\Controllers\Admin\System\SettingController;
|
use App\Http\Controllers\Admin\System\SettingController;
|
||||||
use App\Http\Controllers\Auth\LoginController;
|
use App\Http\Controllers\Auth\LoginController;
|
||||||
use App\Http\Controllers\Auth\LogoutController;
|
use App\Http\Controllers\Auth\LogoutController;
|
||||||
|
use App\Http\Controllers\PushSubscriptionController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::inertia('/', 'Welcome')->name('home');
|
Route::inertia('/', 'Welcome')->name('home');
|
||||||
@ -39,6 +40,10 @@
|
|||||||
Route::middleware(['auth', 'permission:'.Permission::DASHBOARD_VIEW->value])->group(function () {
|
Route::middleware(['auth', 'permission:'.Permission::DASHBOARD_VIEW->value])->group(function () {
|
||||||
Route::post('/auth/logout', [LogoutController::class, 'store'])->name('logout');
|
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::prefix('admin')->name('admin.')->group(function () {
|
||||||
Route::get('/dashboard', [DashboardController::class, 'index'])
|
Route::get('/dashboard', [DashboardController::class, 'index'])
|
||||||
->name('dashboard');
|
->name('dashboard');
|
||||||
|
|||||||
@ -32,13 +32,6 @@ const manifestIcons = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] 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 }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, process.cwd(), '');
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
const appName = env.VITE_APP_NAME || 'DST Collection';
|
const appName = env.VITE_APP_NAME || 'DST Collection';
|
||||||
@ -72,27 +65,18 @@ export default defineConfig(({ mode }) => {
|
|||||||
scope: '/',
|
scope: '/',
|
||||||
base: '/',
|
base: '/',
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
|
// Kita daftarkan SW manual di app.ts (public/sw.js yang support push)
|
||||||
|
// VitePWA hanya generate manifest di sini
|
||||||
|
injectRegister: null,
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
includeAssets: [],
|
|
||||||
workbox: {
|
workbox: {
|
||||||
globPatterns: [
|
globPatterns: [
|
||||||
'**/*.{js,css,html,ico,jpg,png,svg,woff,woff2,ttf,eot}',
|
'**/*.{js,css,html,ico,jpg,png,svg,woff,woff2,ttf,eot}',
|
||||||
],
|
],
|
||||||
navigateFallback: '/',
|
navigateFallback: '/',
|
||||||
navigateFallbackDenylist: [/^\/telescope/],
|
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,
|
maximumFileSizeToCacheInBytes: 3_000_000,
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user