feat: enhance notification system with new NotificationService methods and integrate notifications for various actions in services

This commit is contained in:
Yoga Pangestu 2026-08-14 21:37:03 +07:00
parent f6503ace2b
commit 9ab2b0505c
10 changed files with 272 additions and 55 deletions

View File

@ -10,6 +10,7 @@
use App\Models\PayrollAdjustment;
use App\Models\PayrollPeriod;
use App\Models\User;
use App\Services\NotificationService;
use App\Settings\HRSettings;
use Carbon\CarbonInterface;
use Illuminate\Bus\Queueable;
@ -131,11 +132,21 @@ private function handleLate(
'attendance_id' => $attendance->id,
'type' => PayrollAdjustmentType::DEDUCTION,
'amount' => $hrSettings->late_penalty_amount,
'description' => 'Denda keterlambatan '.$date->format('d/m/Y').' - '.$lateMinutes.' menit',
'description' => 'Denda keterlambatan '.$date->translatedFormat('l, d F Y').' - '.$lateMinutes.' menit',
]);
$this->recalculatePayroll($payroll);
});
$employeeUser = $payroll->employee->user ?? null;
if ($employeeUser) {
NotificationService::notifyUsers(
[$employeeUser],
'Denda Keterlambatan',
'Anda terkena denda keterlambatan sebesar Rp '.number_format($hrSettings->late_penalty_amount, 0, ',', '.')." pada {$date->translatedFormat('l, d F Y')} ({$lateMinutes} menit).",
route('admin.finance.payroll-periods.index'),
);
}
}
private function handleAbsent(
@ -149,7 +160,7 @@ private function handleAbsent(
return;
}
$description = 'Denda ketidakhadiran '.$date->format('d/m/Y');
$description = 'Denda ketidakhadiran '.$date->translatedFormat('l, d F Y');
$existing = PayrollAdjustment::where('payroll_id', $payroll->id)
->where('type', PayrollAdjustmentType::DEDUCTION)
@ -172,6 +183,16 @@ private function handleAbsent(
$this->recalculatePayroll($payroll);
});
$employeeUser = $employee->user ?? null;
if ($employeeUser) {
NotificationService::notifyUsers(
[$employeeUser],
'Denda Ketidakhadiran',
'Anda terkena denda ketidakhadiran sebesar Rp '.number_format($hrSettings->absent_penalty_amount, 0, ',', '.')." pada {$date->translatedFormat('l, d F Y')}.",
route('admin.finance.payroll-periods.index'),
);
}
}
private function recalculatePayroll(Payroll $payroll): void

View File

@ -6,13 +6,12 @@
use App\Models\Attendance;
use App\Models\LeaveRequest;
use App\Models\User;
use App\Notifications\WebPushNotification;
use App\Services\NotificationService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SendAttendanceReminderJob implements ShouldQueue
{
@ -25,7 +24,7 @@ public function handle(): void
$today = now()->toDateString();
$users = User::query()
->select(['id', 'name'])
->select(['id'])
->active()
->whereHas('roles', function ($q) {
$q->whereHas('permissions', fn($pq) => $pq->where('name', Permission::ATTENDANCES_CREATE->value));
@ -33,11 +32,11 @@ public function handle(): void
->whereHas('employee')
->get();
foreach ($users as $user) {
$eligibleUsers = $users->filter(function (User $user) use ($today) {
$employee = $user->employee;
if (! $employee) {
continue;
return false;
}
$hasAttendedToday = Attendance::where('employee_id', $employee->id)
@ -45,7 +44,7 @@ public function handle(): void
->exists();
if ($hasAttendedToday) {
continue;
return false;
}
$isOnLeave = LeaveRequest::approved()
@ -54,19 +53,18 @@ public function handle(): void
->where('end_date', '>=', $today)
->exists();
if ($isOnLeave) {
continue;
}
return ! $isOnLeave;
});
try {
$user->notify(new WebPushNotification(
title: 'Reminder Presensi',
body: 'Selamat pagi! Jangan lupa untuk melakukan presensi masuk hari ini.',
url: route('admin.hr.attendances.index'),
));
} catch (\Exception $e) {
Log::error("Gagal mengirim reminder presensi ke user {$user->id}: {$e->getMessage()}");
}
if ($eligibleUsers->isEmpty()) {
return;
}
NotificationService::notifyUsers(
$eligibleUsers,
'Reminder Presensi',
'Selamat pagi! Jangan lupa untuk melakukan presensi masuk hari ini.',
route('admin.hr.attendances.index'),
);
}
}

View File

@ -147,7 +147,7 @@ public function pay(Payroll $payroll): Payroll
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Gaji Dibayar',
body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibayar".' oleh '.auth()->user()->full_name.'.',
body: "Gaji {$employeeUser->full_name} sebesar {$payroll->formatted_amount} telah dibayar oleh " . auth()->user()->full_name . '.',
url: route('admin.finance.payroll-periods.index'),
additionalUser: $employeeUser,
);
@ -178,7 +178,7 @@ public function cancel(Payroll $payroll): Payroll
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Gaji Dibatalkan',
body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibatalkan".' oleh '.auth()->user()->full_name.'.',
body: "Gaji {$employeeUser->full_name} sebesar {$payroll->formatted_amount} telah dibatalkan".' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.payroll-periods.index'),
additionalUser: $employeeUser,
);

View File

@ -2,12 +2,14 @@
namespace App\Services\Admin\Manage;
use App\Enums\Role;
use App\Models\Cutting;
use App\Models\CuttingMaterial;
use App\Models\CuttingMaterialCombination;
use App\Models\CuttingResult;
use App\Models\RawMaterialPrice;
use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
@ -61,7 +63,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator;
}
public function getMaterials(Cutting $cutting): \Illuminate\Support\Collection
public function getMaterials(Cutting $cutting): Collection
{
return $cutting->cuttingMaterials()
->select(['id', 'cutting_id', 'raw_material_price_id', 'material_usage', 'material_result', 'combination_id'])
@ -85,7 +87,7 @@ public function getMaterials(Cutting $cutting): \Illuminate\Support\Collection
});
}
public function getCombinations(Cutting $cutting): \Illuminate\Support\Collection
public function getCombinations(Cutting $cutting): Collection
{
return $cutting->cuttingMaterialCombinations()
->select(['id', 'cutting_id', 'material_result'])
@ -248,7 +250,7 @@ public function getForEdit(Cutting $cutting): array
public function store(array $data): Cutting
{
return DB::transaction(function () use ($data) {
$cutting = DB::transaction(function () use ($data) {
foreach ($data['materials'] as $materialData) {
$usage = (int) ($materialData['material_usage'] ?? 0);
if ($usage <= 0) {
@ -346,11 +348,20 @@ public function store(array $data): Cutting
return $cutting;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Cutting Baru',
body: 'Cutting berhasil ditambahkan oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.cuttings.index'),
);
return $cutting;
}
public function update(Cutting $cutting, array $data): Cutting
{
return DB::transaction(function () use ($cutting, $data) {
$cutting = DB::transaction(function () use ($cutting, $data) {
$cutting->load(['cuttingMaterials.rawMaterialPrice']);
foreach ($cutting->cuttingMaterials as $oldMaterial) {
@ -450,11 +461,20 @@ public function update(Cutting $cutting, array $data): Cutting
return $cutting;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Cutting Diperbarui',
body: 'Cutting berhasil diperbarui oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.cuttings.index'),
);
return $cutting;
}
public function destroy(Cutting $cutting): bool
{
return DB::transaction(function () use ($cutting) {
$result = DB::transaction(function () use ($cutting) {
$cutting->load('cuttingMaterials.rawMaterialPrice');
foreach ($cutting->cuttingMaterials as $material) {
@ -470,5 +490,14 @@ public function destroy(Cutting $cutting): bool
return true;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Cutting Dihapus',
body: 'Cutting berhasil dihapus oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.cuttings.index'),
);
return $result;
}
}

View File

@ -340,7 +340,7 @@ private function storeNew(array $data): Purchase
public function update(Purchase $purchase, array $data): Purchase
{
return DB::transaction(function () use ($purchase, $data) {
$purchase = DB::transaction(function () use ($purchase, $data) {
$purchase->load('purchaseItems.rawMaterialPrice.rawMaterial');
$oldItems = $purchase->purchaseItems;
@ -478,11 +478,20 @@ public function update(Purchase $purchase, array $data): Purchase
return $purchase;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Belanja Diperbarui',
body: 'Belanja bahan baku sebesar '.$purchase->formatted_total.' berhasil diperbarui oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.purchases.index'),
);
return $purchase;
}
public function destroy(Purchase $purchase): bool
{
return DB::transaction(function () use ($purchase) {
$result = DB::transaction(function () use ($purchase) {
$purchase->load('purchaseItems.rawMaterialPrice');
// Remove the stock the purchase added, keep the variants.
@ -497,5 +506,14 @@ public function destroy(Purchase $purchase): bool
return true;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Belanja Dihapus',
body: 'Belanja bahan baku berhasil dihapus oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.purchases.index'),
);
return $result;
}
}

View File

@ -107,7 +107,7 @@ public function store(array $data): Restock
public function update(Restock $restock, array $data): Restock
{
return DB::transaction(function () use ($restock, $data) {
$restock = DB::transaction(function () use ($restock, $data) {
$restock->load('restockItems');
$restock->restockItems->each(function (RestockItem $item) use ($restock) {
@ -138,11 +138,20 @@ public function update(Restock $restock, array $data): Restock
return $restock;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
title: 'Restock Diperbarui',
body: 'Restock berhasil diperbarui oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.restocks.index'),
);
return $restock;
}
public function destroy(Restock $restock): bool
{
return DB::transaction(function () use ($restock) {
$result = DB::transaction(function () use ($restock) {
$restock->load('restockItems');
$restock->restockItems->each(function (RestockItem $item) use ($restock) {
@ -154,6 +163,15 @@ public function destroy(Restock $restock): bool
return true;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
title: 'Restock Dihapus',
body: 'Restock berhasil dihapus oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.restocks.index'),
);
return $result;
}
private function buildItemRows(array $items, string $stockType, $now, int &$total): array

View File

@ -94,6 +94,44 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator;
}
public function getForEdit(Order $order): array
{
$order->load([
'orderItems:id,order_id,product_variant_id,stock_quality,quantity,unit_price',
]);
$stockType = $order->orderItems->first()?->stock_quality?->value ?? 'good';
$orderMedia = $order->getFirstMedia('photos');
return [
'id' => $order->id,
'order_number' => $order->order_number,
'stock_type' => $stockType,
'channel' => $order->channel->value,
'price_type' => $order->price_type->value,
'payment_type' => $order->payment_type->value,
'customer_id' => $order->customer_id,
'marketing_id' => $order->marketing_id,
'discount' => $order->discount,
'nego_price' => $order->nego_price,
'is_completed' => $order->status === OrderStatus::COMPLETED,
'tiktok_order_id' => $order->tiktok_order_id,
'shopee_order_id' => $order->shopee_order_id,
'notes' => $order->notes,
'photo_key' => $orderMedia?->getCustomProperty('s3_key') ?? $orderMedia?->file_name,
'photo_url' => $orderMedia
? $this->s3Service->getTemporaryUrl($orderMedia->getPath())
: null,
'items' => $order->orderItems->map(fn (OrderItem $item) => [
'id' => $item->id,
'product_variant_id' => $item->product_variant_id,
'quantity' => $item->quantity,
'unit_price' => $item->unit_price,
]),
];
}
public function getItems(Order $order): \Illuminate\Support\Collection
{
return $order->orderItems()
@ -224,7 +262,7 @@ public function store(array $data): Order
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
title: 'Transaksi Baru',
body: 'Transaksi '.$order->order_number.' sebesar Rp '.number_format($totalAmount, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
body: 'Transaksi '.$order->order_number.' sebesar Rp '.$order->formatted_total_amount.' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.transactions.index'),
);
@ -234,7 +272,7 @@ public function store(array $data): Order
public function update(Order $order, array $data): Order
{
return DB::transaction(function () use ($order, $data) {
$order = DB::transaction(function () use ($order, $data) {
$order->load('orderItems');
$oldStockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value;
@ -296,11 +334,20 @@ public function update(Order $order, array $data): Order
return $order;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
title: 'Transaksi Diperbarui',
body: 'Transaksi '.$order->order_number.' sebesar '.$order->formatted_total_amount.' berhasil diperbarui oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.transactions.index'),
);
return $order;
}
public function destroy(Order $order): bool
{
return DB::transaction(function () use ($order) {
$result = DB::transaction(function () use ($order) {
$order->load('orderItems');
$stockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value;
@ -314,6 +361,15 @@ public function destroy(Order $order): bool
return true;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
title: 'Transaksi Dihapus',
body: 'Transaksi '.$order->order_number.' berhasil dihapus oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.transactions.index'),
);
return $result;
}
public function updateStatus(Order $order, string $status): Order

View File

@ -439,6 +439,15 @@ public function toggleStatus(Product $product): void
$product->update([
'status' => $product->status->value === 'active' ? 'inactive' : 'active',
]);
$status = $product->status->value === 'active' ? 'diaktifkan' : 'dinonaktifkan';
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Status Produk Diubah',
body: "Produk \"{$product->name}\" berhasil {$status}".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
);
}
public function toggleFeatured(Product $product): void
@ -448,6 +457,15 @@ public function toggleFeatured(Product $product): void
$product->update([
'is_featured' => ! $product->is_featured,
]);
$status = $product->is_featured ? 'ditandai sebagai unggulan' : 'dihapus dari unggulan';
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Status Unggulan Produk Diubah',
body: "Produk \"{$product->name}\" berhasil {$status}".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
);
}
public function approve(Product $product): void
@ -457,7 +475,7 @@ public function approve(Product $product): void
]);
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
title: 'Produk Disetujui',
body: "Produk \"{$product->name}\" telah disetujui oleh ".auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
@ -473,7 +491,7 @@ public function reject(Product $product, string $reason = ''): void
]);
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
title: 'Produk Ditolak',
body: "Produk \"{$product->name}\" telah ditolak oleh ".auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
@ -489,7 +507,7 @@ public function resubmit(Product $product): void
]);
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
title: 'Produk Diajukan Ulang',
body: "Produk \"{$product->name}\" telah diajukan ulang oleh ".auth()->user()->full_name.'.',
url: route('admin.master.products.index'),

View File

@ -2,9 +2,11 @@
namespace App\Services\Admin\Master\RawMaterial;
use App\Enums\Role;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
@ -71,7 +73,7 @@ public function getVariants(RawMaterial $rawMaterial): Collection
public function store(array $data): RawMaterial
{
return DB::transaction(function () use ($data) {
$rawMaterial = DB::transaction(function () use ($data) {
$rawMaterial = RawMaterial::create([
'name' => $data['name'],
'unit' => $data['unit'],
@ -103,6 +105,15 @@ public function store(array $data): RawMaterial
return $rawMaterial;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Bahan Baku Baru',
body: "Bahan baku \"{$rawMaterial->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'),
);
return $rawMaterial;
}
public function getForEdit(RawMaterial $rawMaterial): array
@ -137,7 +148,7 @@ public function getForEdit(RawMaterial $rawMaterial): array
public function update(RawMaterial $rawMaterial, array $data): RawMaterial
{
return DB::transaction(function () use ($rawMaterial, $data) {
$rawMaterial = DB::transaction(function () use ($rawMaterial, $data) {
$rawMaterial->update([
'name' => $data['name'],
'unit' => $data['unit'],
@ -214,17 +225,35 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
return $rawMaterial;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Bahan Baku Diperbarui',
body: "Bahan baku \"{$rawMaterial->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'),
);
return $rawMaterial;
}
public function destroy(RawMaterial $rawMaterial): bool
{
return DB::transaction(function () use ($rawMaterial) {
$result = DB::transaction(function () use ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$price->delete();
});
return $rawMaterial->delete();
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Bahan Baku Dihapus',
body: "Bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'),
);
return $result;
}
public function toggleStatus(RawMaterial $rawMaterial): void
@ -232,5 +261,14 @@ public function toggleStatus(RawMaterial $rawMaterial): void
$rawMaterial->update([
'is_active' => ! $rawMaterial->is_active,
]);
$status = $rawMaterial->is_active ? 'diaktifkan' : 'dinonaktifkan';
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Status Bahan Baku Diubah',
body: "Bahan baku \"{$rawMaterial->name}\" berhasil {$status}".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'),
);
}
}

View File

@ -5,6 +5,7 @@
use App\Enums\Role;
use App\Models\User;
use App\Notifications\WebPushNotification;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class NotificationService
@ -12,32 +13,52 @@ class NotificationService
/**
* @param array<Role> $roles
*/
public static function notify(array $roles, string $title, string $body, string $url, ?User $additionalUser = null): void
public static function notify(array $roles, string $title, string $body, string $url, ?User $additionalUser = null, ?User $except = null): void
{
$roleLabels = array_map(fn (Role $role) => $role->label(), $roles);
$except ??= auth()->user();
$roleValues = array_map(fn (Role $role) => $role->value, $roles);
$users = User::query()
->select(['id'])
->where('is_active', true)
->whereHas('roles', fn ($q) => $q->whereIn('name', $roleLabels))
->where('id', '!=', $except?->id)
->whereHas('roles', fn ($q) => $q->whereIn('name', $roleValues))
->get();
if ($additionalUser && ! $users->contains('id', $additionalUser->id)) {
if ($additionalUser && $additionalUser->id !== $except?->id && ! $users->contains('id', $additionalUser->id)) {
$users->push($additionalUser);
}
$users->each(function (User $user) use ($title, $body, $url) {
try {
$user->notifications()->create([
'title' => $title,
'body' => $body,
'url' => $url,
]);
$user->notify(new WebPushNotification($title, $body));
} catch (\Exception $e) {
Log::error("Gagal mengirim notifikasi ke user {$user->id}: {$e->getMessage()}");
}
self::sendNotification($user, $title, $body, $url);
});
}
/**
* @param Collection<int, User>|array<int, User> $users
*/
public static function notifyUsers(Collection|array $users, string $title, string $body, string $url): void
{
$users = $users instanceof Collection ? $users : collect($users);
$users->each(function (User $user) use ($title, $body, $url) {
self::sendNotification($user, $title, $body, $url);
});
}
private static function sendNotification(User $user, string $title, string $body, string $url): void
{
try {
$user->notifications()->create([
'title' => $title,
'body' => $body,
'url' => $url,
]);
$user->notify(new WebPushNotification($title, $body));
} catch (\Exception $e) {
Log::error("Gagal mengirim notifikasi ke user {$user->id}: {$e->getMessage()}");
}
}
}