Refactor services to improve role checks and streamline data retrieval

- Updated CustomerService to simplify getAll method.
- Refactored ProductService to utilize HasRoleChecks trait and improved role verification logic.
- Enhanced ProductVariantService with new methods for fetching data for restocking and transactions.
- Cleaned up RawMaterialService by removing unused methods and improving data retrieval.
- Adjusted SupplierService to streamline getAll method.
- Refactored RoleService to use Spatie's Role model and improved role filtering logic.
- Updated NotificationService to handle role labels more effectively.
- Improved StockMutationService by removing redundant paginated method.
- Cleaned up various frontend components to directly accept necessary props instead of nested data objects.
- Updated tests to reflect changes in service method names and ensure proper notification handling.
This commit is contained in:
Yoga Pangestu 2026-08-09 11:33:25 +07:00
parent 46af27e537
commit 0023309a8f
59 changed files with 784 additions and 821 deletions

View File

@ -66,10 +66,10 @@ ## Module Overview
| 8 | Transaksi | `admin/manage/transactions` | `Admin/Manage/TransactionController` | `Admin/Manage/TransactionService` |
| 9 | Restock | `admin/manage/restocks` | `Admin/Manage/RestockController` | `Admin/Manage/RestockService` |
| 10 | Stok Opname | `admin/manage/stok-opnames` | (via StockMutationController) | — |
| 11 | Kas Toko | `admin/finance/cash-accounts` | `Admin/Finance/CashAccountController` | `Admin/Finance/CashAccountService` |
| 11 | Kas Toko | `admin/finance/cash-accounts` | `Admin/Finance/CashAccountController` | `Admin/Finance/Cash/CashAccountService`, `Admin/Finance/Cash/CashTransactionService` |
| 12 | Pengeluaran | `admin/finance/expenses` | `Admin/Finance/ExpenseController` | `Admin/Finance/ExpenseService` |
| 13 | Kasbon | `admin/finance/employee-advances` | `Admin/Finance/EmployeeAdvanceController` | `Admin/Finance/EmployeeAdvanceService` |
| 14 | Gaji | `admin/finance/payroll-periods` | `Admin/Finance/PayrollController` | `Admin/Finance/PayrollPeriodService` |
| 14 | Gaji | `admin/finance/payroll-periods` | `Admin/Finance/PayrollController` | `Admin/Finance/Payroll/PayrollPeriodService`, `Admin/Finance/Payroll/PayrollAdjustmentService` |
| 15 | Pegawai | `admin/hr/employees` | `Admin/HR/EmployeeController` | `Admin/HR/EmployeeService` |
| 16 | Presensi | `admin/hr/attendances` | `Admin/HR/AttendanceController` | `Admin/HR/AttendanceService` |
| 17 | Cuti | `admin/hr/leave-requests` | `Admin/HR/LeaveRequestController` | `Admin/HR/LeaveRequestService` |

View File

@ -178,6 +178,9 @@ ### Urutan Isi Model
public function categories(): BelongsToMany { ... }
public function productVariants(): HasMany { ... }
}
// ✅ Trait stacking — gabungkan dengan koma
use HasFactory, SoftDeletes; // 1 baris, pisah koma
```
### Casting — Wajib untuk Semua Tipe
@ -698,7 +701,7 @@ ### Penamaan Method — HARUS KONSISTEN
}
// 2. getAll() — untuk semua data (tanpa paginasi)
public function getAll(array $filters = []): Collection
public function getAll(): Collection
{
return Product::query()
->select(['id', 'name'])
@ -753,6 +756,36 @@ ### Property Declaration
}
```
### Urutan Method — KONSISTEN
```php
// ✅ Urutan method di service WAJIB konsisten:
// 1. CRUD (paginated, getAll, store, update, destroy)
// 2. Custom methods (business logic)
// 3. Private helpers (di paling bawah)
class ProductService
{
// 1. CRUD
public function paginated(...): LengthAwarePaginator { ... }
public function getAll(...): Collection { ... }
public function store(array $data): Product { ... }
public function update(Product $product, array $data): Product { ... }
public function destroy(Product $product): bool { ... }
// 2. Custom methods
public function getNames(): array { ... }
public function getForEdit(Product $product): array { ... }
public function toggleStatus(Product $product): void { ... }
public function approve(Product $product): void { ... }
public function reject(Product $product, string $reason): void { ... }
public function resubmit(Product $product): void { ... }
// 3. Private helpers (paling bawah)
private function canVerify(): bool { ... }
private function assertNotPending(Product $product): void { ... }
}
```
### Service Concerns (Traits)
```php
// ✅ Gunakan traits untuk kode yang berulang di banyak service
@ -800,6 +833,64 @@ ### Service Concerns (Traits)
return $restock;
}
}
// 4. HasRoleChecks — untuk role checking
use App\Concerns\HasRoleChecks;
class ProductService
{
use HasRoleChecks;
public function store(array $data): Product
{
$status = self::hasAnyRole([Role::DEVELOPER, Role::OWNER])
? ProductStatus::ACTIVE
: ProductStatus::PENDING;
// ...
}
}
```
### Trait Stacking — Gunakan Koma
```php
// ✅ Gabungkan multiple traits dengan koma dalam 1 baris
class PayrollPeriodService
{
use HandlesCashTransactions, HasRoleChecks;
}
class AttendanceService
{
use RegistersMedia, HasRoleChecks;
}
// ❌ JANGAN pisah per baris
class PayrollPeriodService
{
use HandlesCashTransactions;
use HasRoleChecks; // JANGAN
}
```
### Role Checking — Gunakan RoleEnum
```php
// ✅ SELALU gunakan Role enum, JANGAN string
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
class ProductService
{
use HasRoleChecks;
public function store(array $data): Product
{
// ✅ BENAR — pakai Role enum
if (self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) { ... }
// ❌ SALAH — pakai string
if (auth()->user()->hasAnyRole(['developer', 'owner'])) { ... }
}
}
```
### Cash Transaction Pattern
@ -820,6 +911,44 @@ ### Stock Adjustment Pattern
$this->adjustStock($model, $field, $quantity, $sign);
```
### NotificationService — Gunakan RoleEnum
```php
// ✅ SELALU gunakan Role enum untuk roles parameter
use App\Enums\Role;
use App\Services\NotificationService;
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Judul Notifikasi',
body: 'Isi notifikasi',
url: route('admin.module.index'),
);
// ❌ JANGAN pakai string
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], // JANGAN
...
);
```
### Media Sync — Gunakan syncPhoto/syncReceipt
```php
// ✅ SELALU gunakan syncPhoto untuk photo_key, syncReceipt untuk receipt_key
// Trait: App\Services\Concerns\RegistersMedia
// Photo — otomatis handle comparison + clear + register
$this->syncPhoto($model, $data, 'photos');
// Receipt — otomatis handle comparison + cache clearing + clear + register
$this->syncReceipt($model, $data['receipt_key'] ?? null, 'receipts', 'cache_prefix', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
// ❌ JANGAN handle manual
if (! empty($data['photo_key'])) {
$model->clearMediaCollection('photos');
$this->registerMedia($model, $data['photo_key'], 'photos'); // JANGAN
}
```
---
@ -1544,6 +1673,8 @@ ### ✅ Do
- Selalu pindahkan logic ke Service (controller = lalu lintas saja)
- Selalu pakai nama method yang konsisten di semua service (`paginated`, `getAll`, `store`, `update`, `destroy`)
- Selalu cari kode berulang → jadikan Trait/Concern
- Selalu pakai `Role` enum untuk role checking & notifikasi (bukan string)
- Selalu pakai `syncPhoto`/`syncReceipt` untuk media handling (bukan manual)
- Selalu pakai `withTrashed()` untuk relasi ke model yang mungkin di-soft-delete
- Selalu pakai return type di SEMUA method (controller & service)
- **Frontend**: Selalu gunakan `formatted_*` accessor dari model, bukan format di TypeScript
@ -1557,6 +1688,8 @@ ### ❌ Don't
- Jangan pakai `$fillable` (pakai `#[Guarded]`)
- Jangan biarkan kolom tanpa cast
- Jangan pakai string biasa untuk data opsi (pakai Enum)
- Jangan pakai string untuk role checking (pakai `Role` enum)
- Jangan handle media sync manual (pakai `syncPhoto`/`syncReceipt`)
- Jangan lupa buat scope untuk data opsi, gunakan `#[Scope]` + return type `void`
- Jangan pakai `public function` untuk scope (pakai `protected function`)
- Jangan lupa tulis relasi 2 ARAH

View File

@ -143,6 +143,7 @@ # Manual create
- [ ] Method wajib: `paginated()`, `getAll()`, `store()`, `update()`, `destroy()`
- [ ] Select kolom yang dibutuhkan + eager load relasi
- [ ] Gunakan traits jika perlu: `HandlesCashTransactions`, `HasStockAdjustment`, `RegistersMedia`
- [ ] Urutan method: CRUD → custom methods → private helpers (di paling bawah)
### 5. Form Request
```bash

View File

@ -0,0 +1,15 @@
<?php
namespace App\Concerns;
use App\Enums\Role;
trait HasRoleChecks
{
public static function hasAnyRole(array $roles): bool
{
return auth()->user()->hasAnyRole(
array_map(fn (Role $role) => $role->value, $roles)
);
}
}

View File

@ -2,11 +2,8 @@
namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum Role: string
{
use ProvidesEnumOptions;
case DEVELOPER = 'developer';
case OWNER = 'owner';
@ -95,7 +92,6 @@ public function permissions(): array
Permission::CUSTOMERS_VIEW,
Permission::PRODUCTS_VIEW,
Permission::STOCKS_VIEW,
Permission::ORDERS_VIEW,
@ -139,8 +135,6 @@ public function permissions(): array
Permission::ANALYSIS_TOP_PRODUCTS,
Permission::ANALYSIS_MARKETING_SALES,
Permission::STOCKS_VIEW,
Permission::STOK_OPNAMES_VIEW,
Permission::ATTENDANCES_VIEW,
@ -167,7 +161,6 @@ public function permissions(): array
Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS,
Permission::STOCKS_VIEW,
Permission::ORDERS_VIEW,
Permission::ORDERS_CREATE,
@ -293,7 +286,6 @@ public function permissions(): array
Permission::RAW_MATERIALS_CREATE,
Permission::RAW_MATERIALS_UPDATE,
Permission::RAW_MATERIALS_TOGGLE_STATUS,
Permission::STOCKS_VIEW,
Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE,
@ -393,7 +385,6 @@ public function permissions(): array
Permission::LEAVE_REQUESTS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::STOCKS_VIEW,
Permission::STOK_OPNAMES_VIEW,
Permission::STOK_OPNAMES_CREATE,

View File

@ -7,7 +7,8 @@
use App\Http\Requests\Admin\Finance\CashTransactionRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\CashTransaction;
use App\Services\Admin\Finance\CashAccountService;
use App\Services\Admin\Finance\Cash\CashAccountService;
use App\Services\Admin\Finance\Cash\CashTransactionService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -15,14 +16,15 @@
class CashAccountController extends Controller
{
public function __construct(
private CashAccountService $service
private CashAccountService $cashAccountService,
private CashTransactionService $cashTransactionService,
) {}
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/finance/cash-account/index', [
'cashAccount' => $this->service->get(),
'transactions' => $this->service->paginatedTransactions(
'cashAccount' => $this->cashAccountService->get(),
'transactions' => $this->cashTransactionService->paginated(
...$request->validatedWithDefaults(),
filters: $request->only(['type']),
),
@ -36,7 +38,7 @@ public function index(PaginatedRequest $request): Response
public function deposit(CashTransactionRequest $request): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->deposit($request->validated()),
fn () => $this->cashTransactionService->deposit($request->validated()),
'Deposit berhasil ditambahkan.',
'admin.finance.cash-accounts.index'
);
@ -45,7 +47,7 @@ public function deposit(CashTransactionRequest $request): RedirectResponse
public function withdrawal(CashTransactionRequest $request): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->withdrawal($request->validated()),
fn () => $this->cashTransactionService->withdrawal($request->validated()),
'Withdrawal berhasil ditambahkan.',
'admin.finance.cash-accounts.index'
);
@ -54,7 +56,7 @@ public function withdrawal(CashTransactionRequest $request): RedirectResponse
public function update(CashTransactionRequest $request, CashTransaction $transaction): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->updateTransaction($transaction, $request->validated()),
fn () => $this->cashTransactionService->update($transaction, $request->validated()),
'Transaksi berhasil diperbarui.',
'admin.finance.cash-accounts.index'
);
@ -63,7 +65,7 @@ public function update(CashTransactionRequest $request, CashTransaction $transac
public function destroy(CashTransaction $transaction): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->deleteTransaction($transaction),
fn () => $this->cashTransactionService->destroy($transaction),
'Transaksi berhasil dihapus.',
'admin.finance.cash-accounts.index'
);

View File

@ -6,7 +6,7 @@
use App\Http\Requests\Admin\Finance\PayrollAdjustmentRequest;
use App\Models\Payroll;
use App\Models\PayrollAdjustment;
use App\Services\Admin\Finance\PayrollAdjustmentService;
use App\Services\Admin\Finance\Payroll\PayrollAdjustmentService;
use Illuminate\Http\RedirectResponse;
class PayrollAdjustmentController extends Controller
@ -18,7 +18,7 @@ public function __construct(
public function store(PayrollAdjustmentRequest $request, Payroll $payroll): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->store($payroll, $request->validated()),
fn () => $this->service->store($payroll, $request->validated()),
'Adjustment gaji berhasil ditambahkan.',
'admin.finance.payroll-periods.show',
parameters: ['payroll_period' => $payroll->payroll_period_id]
@ -28,7 +28,7 @@ public function store(PayrollAdjustmentRequest $request, Payroll $payroll): Redi
public function destroy(PayrollAdjustment $payrollAdjustment): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->destroy($payrollAdjustment),
fn () => $this->service->destroy($payrollAdjustment),
'Adjustment gaji berhasil dihapus.',
'admin.finance.payroll-periods.show',
parameters: ['payroll_period' => $payrollAdjustment->payroll->payroll_period_id]

View File

@ -4,7 +4,7 @@
use App\Http\Controllers\Controller;
use App\Models\Payroll;
use App\Services\Admin\Finance\PayrollPeriodService;
use App\Services\Admin\Finance\Payroll\PayrollPeriodService;
use Illuminate\Http\RedirectResponse;
class PayrollController extends Controller
@ -16,7 +16,7 @@ public function __construct(
public function pay(Payroll $payroll): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->pay($payroll),
fn () => $this->service->pay($payroll),
'Gaji berhasil dibayar.',
'admin.finance.payroll-periods.show',
parameters: ['payroll_period' => $payroll->payroll_period_id]
@ -26,7 +26,7 @@ public function pay(Payroll $payroll): RedirectResponse
public function cancel(Payroll $payroll): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->cancel($payroll),
fn () => $this->service->cancel($payroll),
'Gaji berhasil dibatalkan.',
'admin.finance.payroll-periods.show',
parameters: ['payroll_period' => $payroll->payroll_period_id]

View File

@ -2,16 +2,20 @@
namespace App\Http\Controllers\Admin\Finance\Payroll;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Models\PayrollPeriod;
use App\Services\Admin\Finance\PayrollPeriodService;
use App\Services\Admin\Finance\Payroll\PayrollPeriodService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class PayrollPeriodController extends Controller
{
use HasRoleChecks;
public function __construct(
private PayrollPeriodService $service
) {}
@ -32,15 +36,23 @@ public function current(): RedirectResponse
public function show(PayrollPeriod $payrollPeriod): Response
{
$payrollPeriod->load([
'payrolls' => function ($query) {
$query->with(['employee.user.userProfile', 'payrollAdjustments'])
->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->orderBy('id');
},
]);
return Inertia::render('admin/finance/payroll-period/show', [
'payrollPeriod' => $this->service->getDetail($payrollPeriod),
'payrollPeriod' => $payrollPeriod,
]);
}
public function close(PayrollPeriod $payrollPeriod): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->close($payrollPeriod),
fn () => $this->service->close($payrollPeriod),
'Periode gaji berhasil ditutup.',
'admin.finance.payroll-periods.index'
);
@ -49,7 +61,7 @@ public function close(PayrollPeriod $payrollPeriod): RedirectResponse
public function reopen(PayrollPeriod $payrollPeriod): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->reopen($payrollPeriod),
fn () => $this->service->reopen($payrollPeriod),
'Periode gaji berhasil dibuka kembali.',
'admin.finance.payroll-periods.index'
);

View File

@ -30,7 +30,7 @@ public function index(Request $request): Response
public function store(AttendanceRequest $request): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->checkIn($request->validated()),
fn () => $this->service->checkIn($request->validated()),
'Berhasil check-in.',
'admin.hr.attendances.index'
);
@ -39,7 +39,7 @@ public function store(AttendanceRequest $request): RedirectResponse
public function update(AttendanceRequest $request, Attendance $attendance): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->checkOut($attendance, $request->validated()),
fn () => $this->service->checkOut($attendance, $request->validated()),
'Berhasil check-out.',
'admin.hr.attendances.index'
);

View File

@ -42,7 +42,7 @@ public function create(): Response
public function store(EmployeeRequest $request): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->store($request->validated()),
fn () => $this->service->store($request->validated()),
'Pegawai berhasil ditambahkan.',
'admin.hr.employees.index'
);
@ -62,7 +62,7 @@ public function edit(User $user): Response
public function update(EmployeeRequest $request, User $user): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->update($user, $request->validated()),
fn () => $this->service->update($user, $request->validated()),
'Pegawai berhasil diperbarui.',
'admin.hr.employees.index'
);
@ -71,7 +71,7 @@ public function update(EmployeeRequest $request, User $user): RedirectResponse
public function destroy(User $user): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->destroy($user),
fn () => $this->service->destroy($user),
'Pegawai berhasil dihapus.',
'admin.hr.employees.index'
);
@ -90,7 +90,7 @@ public function toggleActive(User $user): RedirectResponse
public function resetPassword(User $user): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->resetPassword($user),
fn () => $this->service->resetPassword($user),
'Kata sandi pegawai berhasil direset ke kata sandi default.',
'admin.hr.employees.index'
);

View File

@ -7,6 +7,7 @@
use App\Http\Requests\PaginatedRequest;
use App\Models\Cutting;
use App\Services\Admin\Manage\CuttingService;
use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -15,6 +16,7 @@ class CuttingController extends Controller
{
public function __construct(
private CuttingService $service,
private RawMaterialVariantService $rawMaterialVariantService,
) {}
public function index(PaginatedRequest $request): Response
@ -29,14 +31,14 @@ public function index(PaginatedRequest $request): Response
public function create(): Response
{
return Inertia::render('admin/manage/cutting/create', [
'data' => $this->service->getForCreate(),
'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]);
}
public function store(CuttingRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
fn () => $this->service->store($request->validated()),
'Cutting berhasil ditambahkan.',
'admin.manage.cuttings.index',
'admin.manage.cuttings.create'
@ -47,7 +49,7 @@ public function edit(Cutting $cutting): Response
{
return Inertia::render('admin/manage/cutting/edit', [
'cutting' => $this->service->getForEdit($cutting),
'data' => $this->service->getForCreate(),
'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]);
}
@ -65,7 +67,7 @@ public function update(CuttingRequest $request, Cutting $cutting): RedirectRespo
public function destroy(Cutting $cutting): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($cutting),
fn () => $this->service->destroy($cutting),
'Cutting berhasil dihapus.',
'admin.manage.cuttings.index'
);

View File

@ -7,6 +7,7 @@
use App\Http\Requests\PaginatedRequest;
use App\Models\Purchase;
use App\Services\Admin\Manage\PurchaseService;
use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService;
use App\Services\Admin\Master\SupplierService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
@ -17,6 +18,7 @@ class PurchaseController extends Controller
public function __construct(
private readonly PurchaseService $service,
private readonly SupplierService $supplierService,
private readonly RawMaterialVariantService $rawMaterialVariantService,
) {}
public function index(PaginatedRequest $request): Response
@ -34,14 +36,15 @@ public function index(PaginatedRequest $request): Response
public function create(): Response
{
return Inertia::render('admin/manage/purchase/create', [
'data' => $this->service->getForCreate(),
'suppliers' => $this->supplierService->getAll(),
'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]);
}
public function store(PurchaseRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
fn () => $this->service->store($request->validated()),
'Belanja berhasil ditambahkan.',
'admin.manage.purchases.index',
'admin.manage.purchases.create'
@ -52,7 +55,8 @@ public function edit(Purchase $purchase): Response
{
return Inertia::render('admin/manage/purchase/edit', [
'purchase' => $this->service->getForEdit($purchase),
'data' => $this->service->getForCreate(),
'suppliers' => $this->supplierService->getAll(),
'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]);
}
@ -70,7 +74,7 @@ public function update(PurchaseRequest $request, Purchase $purchase): RedirectRe
public function destroy(Purchase $purchase): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($purchase),
fn () => $this->service->destroy($purchase),
'Belanja berhasil dihapus.',
'admin.manage.purchases.index'
);

View File

@ -7,6 +7,7 @@
use App\Http\Requests\PaginatedRequest;
use App\Models\Restock;
use App\Services\Admin\Manage\RestockService;
use App\Services\Admin\Master\Product\ProductVariantService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -15,6 +16,7 @@ class RestockController extends Controller
{
public function __construct(
private RestockService $service,
private ProductVariantService $productVariantService,
) {}
public function index(PaginatedRequest $request): Response
@ -29,14 +31,14 @@ public function index(PaginatedRequest $request): Response
public function create(): Response
{
return Inertia::render('admin/manage/restock/create', [
'data' => $this->service->getForCreate(),
'products' => $this->productVariantService->getForRestock(),
]);
}
public function store(RestockRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
fn () => $this->service->store($request->validated()),
'Restock berhasil ditambahkan.',
'admin.manage.restocks.index',
'admin.manage.restocks.create'
@ -47,7 +49,7 @@ public function edit(Restock $restock): Response
{
return Inertia::render('admin/manage/restock/edit', [
'restock' => $this->service->getForEdit($restock),
'data' => $this->service->getForCreate(),
'products' => $this->productVariantService->getForRestock(),
]);
}
@ -65,7 +67,7 @@ public function update(RestockRequest $request, Restock $restock): RedirectRespo
public function destroy(Restock $restock): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($restock),
fn () => $this->service->destroy($restock),
'Restock berhasil dihapus.',
'admin.manage.restocks.index'
);

View File

@ -2,11 +2,18 @@
namespace App\Http\Controllers\Admin\Manage;
use App\Enums\OrderChannel;
use App\Enums\PaymentType;
use App\Enums\PriceType;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\TransactionRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Customer;
use App\Models\Order;
use App\Models\User;
use App\Services\Admin\Manage\TransactionService;
use App\Services\Admin\Master\CustomerService;
use App\Services\Admin\Master\Product\ProductVariantService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -15,6 +22,8 @@ class TransactionController extends Controller
{
public function __construct(
private TransactionService $service,
private ProductVariantService $productVariantService,
private CustomerService $customerService,
) {}
public function index(PaginatedRequest $request): Response
@ -35,14 +44,19 @@ public function index(PaginatedRequest $request): Response
public function create(): Response
{
return Inertia::render('admin/manage/transaction/create', [
'data' => $this->service->getForCreate(),
'products' => $this->productVariantService->getForTransaction(),
'customers' => $this->customerService->getAll(),
'employees' => $this->getEmployees(),
'channelOptions' => OrderChannel::toSelect(),
'paymentTypeOptions' => PaymentType::toSelect(),
'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values(),
]);
}
public function store(TransactionRequest $request): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->create($request->validated()),
fn () => $this->service->store($request->validated()),
'Transaksi berhasil ditambahkan.',
'admin.manage.transactions.index',
'admin.manage.transactions.create'
@ -53,14 +67,19 @@ public function edit(Order $transaction): Response
{
return Inertia::render('admin/manage/transaction/edit', [
'transaction' => $this->service->getForEdit($transaction),
'data' => $this->service->getForCreate(),
'products' => $this->productVariantService->getForTransaction(),
'customers' => $this->customerService->getAll(),
'employees' => $this->getEmployees(),
'channelOptions' => OrderChannel::toSelect(),
'paymentTypeOptions' => PaymentType::toSelect(),
'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values(),
]);
}
public function update(TransactionRequest $request, Order $transaction): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->update($transaction, $request->validated()),
fn () => $this->service->update($transaction, $request->validated()),
'Transaksi berhasil diperbarui.',
'admin.manage.transactions.index',
'admin.manage.transactions.edit',
@ -71,7 +90,7 @@ public function update(TransactionRequest $request, Order $transaction): Redirec
public function destroy(Order $transaction): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->delete($transaction),
fn () => $this->service->destroy($transaction),
'Transaksi berhasil dihapus.',
'admin.manage.transactions.index'
);
@ -80,9 +99,21 @@ public function destroy(Order $transaction): RedirectResponse
public function updateStatus(Order $transaction): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->updateStatus($transaction, request('status')),
fn () => $this->service->updateStatus($transaction, request('status')),
'Status transaksi berhasil diperbarui.',
'admin.manage.transactions.index'
);
}
private function getEmployees()
{
return User::query()
->select('id')
->active()
->with('userProfile:id,user_id,full_name')
->orderBy('id')
->get()
->filter(fn (User $user) => $user->userProfile?->full_name)
->values();
}
}

View File

@ -42,7 +42,7 @@ public function create(): Response
public function store(ProductRequest $request): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->create($request->validated()),
fn () => $this->service->create($request->validated()),
'Produk berhasil ditambahkan.',
'admin.master.products.index',
'admin.master.products.create'
@ -60,7 +60,7 @@ public function edit(Product $product): Response
public function update(ProductRequest $request, Product $product): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->update($product, $request->validated()),
fn () => $this->service->update($product, $request->validated()),
'Produk berhasil diperbarui.',
'admin.master.products.index',
'admin.master.products.edit',
@ -71,7 +71,7 @@ public function update(ProductRequest $request, Product $product): RedirectRespo
public function destroy(Product $product): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->delete($product),
fn () => $this->service->delete($product),
'Produk berhasil dihapus.',
'admin.master.products.index'
);

View File

@ -36,7 +36,7 @@ public function create(): Response
public function store(RawMaterialRequest $request): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->store($request->validated()),
fn () => $this->service->store($request->validated()),
'Bahan baku berhasil ditambahkan.',
'admin.master.raw-materials.index',
'admin.master.raw-materials.create'
@ -53,7 +53,7 @@ public function edit(RawMaterial $rawMaterial): Response
public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->update($rawMaterial, $request->validated()),
fn () => $this->service->update($rawMaterial, $request->validated()),
'Bahan baku berhasil diperbarui.',
'admin.master.raw-materials.index',
'admin.master.raw-materials.edit',
@ -64,7 +64,7 @@ public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): R
public function destroy(RawMaterial $rawMaterial): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->destroy($rawMaterial),
fn () => $this->service->destroy($rawMaterial),
'Bahan baku berhasil dihapus.',
'admin.master.raw-materials.index'
);

View File

@ -34,7 +34,7 @@ public function create(): Response
public function store(RoleRequest $request): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->store($request->validated()),
fn () => $this->service->store($request->validated()),
'Role berhasil ditambahkan.',
'admin.settings.roles.index'
);
@ -51,7 +51,7 @@ public function edit(Role $role): Response
public function update(RoleRequest $request, Role $role): RedirectResponse
{
return $this->handleAction(
fn() => $this->service->update($role, $request->validated()),
fn () => $this->service->update($role, $request->validated()),
'Role berhasil diperbarui.',
'admin.settings.roles.index'
);

View File

@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Services\AnalysisService;
use Illuminate\Http\Request;
use Inertia\Inertia;
@ -9,6 +11,8 @@
class AnalysisController extends Controller
{
use HasRoleChecks;
public function __construct(
private AnalysisService $service
) {}
@ -16,7 +20,7 @@ public function __construct(
public function index(Request $request): Response
{
$user = $request->user();
$isManager = $user->hasAnyRole(['owner', 'developer', 'admin-toko', 'direktur']);
$isManager = self::hasAnyRole([Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO, Role::DIREKTUR]);
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');

View File

@ -117,7 +117,7 @@ public function __invoke(Request $request): Response
'category' => $category,
],
'seo' => [
'title' => $system->app_name . ' - ' . $homepage->hero_badge,
'title' => $system->app_name.' - '.$homepage->hero_badge,
'description' => $homepage->hero_description,
'image' => url('/assets/logo.png'),
'url' => url('/'),

View File

@ -40,9 +40,9 @@ public function rules(): array
'category_ids' => ['required', 'array', 'min:1'],
'category_ids.*' => [Rule::exists('categories', 'id')],
'use_same_price' => ['nullable', 'boolean'],
'shared_prices' => [Rule::requiredIf(fn() => $useSamePrice), 'nullable', 'array', ...($useSamePrice ? ['size:9'] : [])],
'shared_prices.*.type' => [Rule::requiredIf(fn() => $useSamePrice), 'nullable', Rule::in(PriceType::values())],
'shared_prices.*.price' => [Rule::requiredIf(fn() => $useSamePrice), 'nullable', 'integer', 'min:0'],
'shared_prices' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', 'array', ...($useSamePrice ? ['size:9'] : [])],
'shared_prices.*.type' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', Rule::in(PriceType::values())],
'shared_prices.*.price' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', 'integer', 'min:0'],
'variants' => ['required', 'array', 'min:1'],
'variants.*.id' => ['nullable', 'integer'],
'variants.*.name' => ['required', 'string', 'max:200'],
@ -51,10 +51,10 @@ public function rules(): array
'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
'variants.*.photo_keys' => ['required', 'array', 'min:1', 'max:5'],
'variants.*.photo_keys.*' => ['required', 'string', 'max:500'],
'variants.*.prices' => [Rule::requiredIf(fn() => ! $useSamePrice), 'nullable', 'array', ...(! $useSamePrice ? ['size:9'] : [])],
'variants.*.prices' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', 'array', ...(! $useSamePrice ? ['size:9'] : [])],
'variants.*.prices.*.id' => ['nullable', 'integer'],
'variants.*.prices.*.type' => [Rule::requiredIf(fn() => ! $useSamePrice), 'nullable', Rule::in(PriceType::values())],
'variants.*.prices.*.price' => [Rule::requiredIf(fn() => ! $useSamePrice), 'nullable', 'integer', 'min:0'],
'variants.*.prices.*.type' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', Rule::in(PriceType::values())],
'variants.*.prices.*.price' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', 'integer', 'min:0'],
];
}

View File

@ -2,7 +2,6 @@
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;

View File

@ -46,15 +46,6 @@ public function getHomepageData(): array
];
}
private function getTemporaryUrl(string $key): string
{
if (str_starts_with($key, 'http')) {
return $key;
}
return $this->s3Service->getTemporaryUrl($key, 60);
}
public function getSocialMediaData(): array
{
$settings = app(SocialMediaSettings::class);
@ -167,4 +158,13 @@ public function updateHR(array $data): void
$settings->fill($data);
$settings->save();
}
private function getTemporaryUrl(string $key): string
{
if (str_starts_with($key, 'http')) {
return $key;
}
return $this->s3Service->getTemporaryUrl($key, 60);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Services\Admin\Finance\Cash;
use App\Models\CashAccount;
class CashAccountService
{
public function get(): ?CashAccount
{
return CashAccount::select(['id', 'name', 'balance'])->first();
}
}

View File

@ -1,8 +1,9 @@
<?php
namespace App\Services\Admin\Finance;
namespace App\Services\Admin\Finance\Cash;
use App\Enums\CashTransactionType;
use App\Enums\Role;
use App\Models\CashAccount;
use App\Models\CashTransaction;
use App\Services\Concerns\HandlesCashTransactions;
@ -11,45 +12,22 @@
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CashAccountService
class CashTransactionService
{
use HandlesCashTransactions, RegistersMedia;
public function __construct(
private S3PresignedService $s3Service,
private CashAccountService $cashAccountService
) {}
public function get(): ?CashAccount
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return CashAccount::select(['id', 'name', 'balance'])->first();
}
public function getAllTransactions(array $filters = []): Collection
{
$cashAccount = $this->get();
if (! $cashAccount) {
return collect();
}
return $cashAccount->cashTransactions()
->select(['id', 'created_by_id', 'reference_type', 'reference_id', 'amount', 'balance_after', 'type', 'description', 'created_at'])
->with('createdBy.userProfile', 'media')
->when($filters['type'] ?? null, function ($query, $type) {
$query->where('type', $type);
})
->latest()
->get()
->map(fn (CashTransaction $transaction) => $this->formatTransaction($transaction));
}
public function paginatedTransactions(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
$cashAccount = $this->get();
$cashAccount = $this->cashAccountService->get();
if (! $cashAccount) {
return new PaginationLengthAwarePaginator(collect(), 0, $perPage);
@ -70,29 +48,6 @@ public function paginatedTransactions(int $perPage = 25, string $search = '', st
return $paginator;
}
private function formatTransaction(CashTransaction $transaction): array
{
$media = $transaction->getFirstMedia('receipts');
if (! $media) {
return $transaction->toArray() + [
'receipt_key' => null,
'receipt_url' => null,
];
}
$s3Key = $media->file_name;
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
return $transaction->toArray() + [
'receipt_key' => $s3Key,
'receipt_url' => $this->s3Service->getTemporaryUrl($s3Key),
];
}
public function deposit(array $data): CashTransaction
{
$transaction = DB::transaction(fn () => $this->creditCash(
@ -100,12 +55,12 @@ public function deposit(array $data): CashTransaction
description: $data['description'],
));
if (! empty($data['receipt_key'])) {
$this->registerMedia($transaction, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
if (array_key_exists('receipt_key', $data)) {
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Setoran Kas Toko',
body: 'Setoran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'),
@ -122,12 +77,12 @@ public function withdrawal(array $data): CashTransaction
type: CashTransactionType::WITHDRAWAL,
));
if (! empty($data['receipt_key'])) {
$this->registerMedia($transaction, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
if (array_key_exists('receipt_key', $data)) {
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Penarikan Kas Toko',
body: 'Penarikan sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'),
@ -136,7 +91,7 @@ public function withdrawal(array $data): CashTransaction
return $transaction;
}
public function updateTransaction(CashTransaction $transaction, array $data): CashTransaction
public function update(CashTransaction $transaction, array $data): CashTransaction
{
$transaction = DB::transaction(function () use ($transaction, $data) {
if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) {
@ -168,27 +123,14 @@ public function updateTransaction(CashTransaction $transaction, array $data): Ca
]);
if (array_key_exists('receipt_key', $data)) {
$currentMedia = $transaction->getFirstMedia('receipts');
$currentKey = $currentMedia?->file_name;
if ($currentMedia && ! str_contains($currentKey, '/')) {
$currentKey = $currentMedia->getPath();
}
if ($data['receipt_key'] !== $currentKey) {
$transaction->clearMediaCollection('receipts');
if (! empty($data['receipt_key'])) {
$this->registerMedia($transaction, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
}
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
return $transaction;
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Transaksi Kas Diperbarui',
body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'),
@ -197,7 +139,7 @@ public function updateTransaction(CashTransaction $transaction, array $data): Ca
return $transaction;
}
public function deleteTransaction(CashTransaction $transaction): bool
public function destroy(CashTransaction $transaction): bool
{
return DB::transaction(function () use ($transaction) {
if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) {
@ -221,13 +163,18 @@ public function deleteTransaction(CashTransaction $transaction): bool
$cashAccount->update(['balance' => $newBalance]);
$media = $transaction->getFirstMedia('receipts');
if ($media) {
Cache::forget("cash_transaction_receipt_{$media->id}");
}
$transaction->clearMediaCollection('receipts');
$deleted = $transaction->delete();
if ($deleted) {
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Transaksi Kas Dihapus',
body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'),
@ -237,4 +184,27 @@ public function deleteTransaction(CashTransaction $transaction): bool
return $deleted;
});
}
private function formatTransaction(CashTransaction $transaction): array
{
$media = $transaction->getFirstMedia('receipts');
if (! $media) {
return $transaction->toArray() + [
'receipt_key' => null,
'receipt_url' => null,
];
}
$s3Key = $media->file_name;
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
return $transaction->toArray() + [
'receipt_key' => $s3Key,
'receipt_url' => $this->s3Service->getTemporaryUrl($s3Key),
];
}
}

View File

@ -2,41 +2,28 @@
namespace App\Services\Admin\Finance;
use App\Concerns\HasRoleChecks;
use App\Enums\CashTransactionType;
use App\Enums\EmployeeAdvanceStatus;
use App\Enums\Role;
use App\Models\EmployeeAdvance;
use App\Models\EmployeeAdvancePayment;
use App\Services\Concerns\HandlesCashTransactions;
use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class EmployeeAdvanceService
{
use HandlesCashTransactions;
private function canViewAll(): bool
{
return auth()->user()->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
}
public function getAll(array $filters = []): Collection
{
return EmployeeAdvance::select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
->with(['employee.user.userProfile', 'payments.paidBy.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->latest()
->get();
}
use HandlesCashTransactions, HasRoleChecks;
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return EmployeeAdvance::query()
->select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
->with(['employee.user.userProfile', 'payments.paidBy.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when($filters['status'] ?? null, fn ($q) => $q->where('status', $filters['status']))
->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%"))
->orderBy($sort, $direction)
@ -64,7 +51,7 @@ public function store(array $data): EmployeeAdvance
$employeeAdvance->load('employee.user');
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Kasbon Baru',
body: 'Kasbon sebesar Rp '.number_format($data['amount'], 0, ',', '.')." dari {$employeeAdvance->employee->name} menunggu persetujuan".' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.employee-advances.index'),
@ -181,7 +168,7 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Kasbon Disetujui',
body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah disetujui'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.employee-advances.index'),
@ -234,7 +221,7 @@ public function pay(EmployeeAdvance $employeeAdvance, int $amount): EmployeeAdva
: 'Pembayaran kasbon sebesar Rp '.number_format($amount, 0, ',', '.').' oleh '.auth()->user()->full_name.'. Sisa: Rp '.number_format($employeeAdvance->amount - $employeeAdvance->paid_amount, 0, ',', '.').'.';
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: $employeeAdvance->status === EmployeeAdvanceStatus::PAID ? 'Kasbon Dibayar' : 'Pembayaran Kasbon',
body: $notificationBody,
url: route('admin.finance.employee-advances.index'),

View File

@ -3,6 +3,7 @@
namespace App\Services\Admin\Finance;
use App\Enums\CashTransactionType;
use App\Enums\Role;
use App\Models\CashAccount;
use App\Models\Expense;
use App\Services\Concerns\HandlesCashTransactions;
@ -10,7 +11,6 @@
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@ -23,15 +23,6 @@ public function __construct(
private S3PresignedService $s3Service,
) {}
public function getAll(array $filters = []): Collection
{
return Expense::select(['id', 'created_by_id', 'amount', 'description', 'created_at'])
->with('createdBy.userProfile', 'media')
->latest()
->get()
->map(fn (Expense $expense) => $this->formatExpense($expense));
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
$paginator = Expense::query()
@ -46,35 +37,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator;
}
private function formatExpense(Expense $expense): array
{
$media = $expense->getFirstMedia('receipts');
if (! $media) {
return $expense->toArray() + [
'receipt_key' => null,
'receipt_url' => null,
];
}
$s3Key = $media->file_name;
// Handle old data where file_name is just the filename, not full path
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
$cacheKey = "expense_receipt_{$media->id}";
$receiptUrl = Cache::remember($cacheKey, now()->addMinutes(55), function () use ($s3Key) {
return $this->s3Service->getTemporaryUrl($s3Key);
});
return $expense->toArray() + [
'receipt_key' => $s3Key,
'receipt_url' => $receiptUrl,
];
}
public function store(array $data): Expense
{
$expense = DB::transaction(function () use ($data) {
@ -90,15 +52,15 @@ public function store(array $data): Expense
'description' => $data['description'],
]);
if (! empty($data['receipt_key'])) {
$this->registerMedia($expense, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
if (array_key_exists('receipt_key', $data)) {
$this->syncReceipt($expense, $data['receipt_key'] ?? null, 'receipts', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
return $expense;
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Pengeluaran Baru',
body: 'Pengeluaran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.expenses.index'),
@ -138,33 +100,14 @@ public function update(Expense $expense, array $data): Expense
]);
if (array_key_exists('receipt_key', $data)) {
$currentMedia = $expense->getFirstMedia('receipts');
$currentKey = $currentMedia?->file_name;
// Normalize: if file_name is just a filename (old data), use getPath()
if ($currentMedia && ! str_contains($currentKey, '/')) {
$currentKey = $currentMedia->getPath();
}
if ($data['receipt_key'] !== $currentKey) {
// Invalidate old receipt cache
if ($currentMedia) {
Cache::forget("expense_receipt_{$currentMedia->id}");
}
$expense->clearMediaCollection('receipts');
if (! empty($data['receipt_key'])) {
$this->registerMedia($expense, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
}
$this->syncReceipt($expense, $data['receipt_key'] ?? null, 'receipts', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
return $expense;
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Pengeluaran Diperbarui',
body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.expenses.index'),
@ -182,7 +125,6 @@ public function destroy(Expense $expense): bool
type: CashTransactionType::DEPOSIT,
);
// Invalidate receipt cache
$media = $expense->getFirstMedia('receipts');
if ($media) {
Cache::forget("expense_receipt_{$media->id}");
@ -194,7 +136,7 @@ public function destroy(Expense $expense): bool
if ($deleted) {
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Pengeluaran Dihapus',
body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.expenses.index'),
@ -204,4 +146,32 @@ public function destroy(Expense $expense): bool
return $deleted;
});
}
private function formatExpense(Expense $expense): array
{
$media = $expense->getFirstMedia('receipts');
if (! $media) {
return $expense->toArray() + [
'receipt_key' => null,
'receipt_url' => null,
];
}
$s3Key = $media->file_name;
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
$cacheKey = "expense_receipt_{$media->id}";
$receiptUrl = Cache::remember($cacheKey, now()->addMinutes(55), function () use ($s3Key) {
return $this->s3Service->getTemporaryUrl($s3Key);
});
return $expense->toArray() + [
'receipt_key' => $s3Key,
'receipt_url' => $receiptUrl,
];
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Services\Admin\Finance;
namespace App\Services\Admin\Finance\Payroll;
use App\Enums\PayrollAdjustmentType;
use App\Enums\PayrollStatus;

View File

@ -1,67 +1,29 @@
<?php
namespace App\Services\Admin\Finance;
namespace App\Services\Admin\Finance\Payroll;
use App\Concerns\HasRoleChecks;
use App\Enums\CashTransactionType;
use App\Enums\PayrollPeriodStatus;
use App\Enums\PayrollStatus;
use App\Enums\Role;
use App\Models\Payroll;
use App\Models\PayrollPeriod;
use App\Services\Concerns\HandlesCashTransactions;
use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class PayrollPeriodService
{
use HandlesCashTransactions;
private function canViewAll(): bool
{
return auth()->user()->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
}
public function getCurrentOrCreate(): PayrollPeriod
{
$now = now();
return PayrollPeriod::firstOrCreate(
['year' => $now->year, 'month' => $now->month],
['status' => PayrollPeriodStatus::OPEN]
);
}
public function getAll(array $filters = []): Collection
{
return PayrollPeriod::select(['id', 'year', 'month', 'status', 'closed_at', 'created_at'])
->when(! $this->canViewAll(), function ($query) {
$query->withCount(['payrolls as payrolls_count' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withSum(['payrolls as payrolls_sum_total_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'total_amount')
->withSum(['payrolls as payrolls_sum_bonus_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'bonus_amount')
->withSum(['payrolls as payrolls_sum_deduction_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'deduction_amount')
->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]);
})
->when($this->canViewAll(), function ($query) {
$query->withCount('payrolls')
->withSum('payrolls', 'total_amount')
->withSum('payrolls', 'bonus_amount')
->withSum('payrolls', 'deduction_amount')
->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()])
->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()]);
})
->latest('year')
->latest('month')
->get();
}
use HandlesCashTransactions, HasRoleChecks;
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return PayrollPeriod::query()
->select(['id', 'year', 'month', 'status', 'closed_at', 'created_at'])
->when(! $this->canViewAll(), function ($query) {
->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($query) {
$query->withCount(['payrolls as payrolls_count' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withSum(['payrolls as payrolls_sum_total_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'total_amount')
->withSum(['payrolls as payrolls_sum_bonus_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'bonus_amount')
@ -69,7 +31,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]);
})
->when($this->canViewAll(), function ($query) {
->when(self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($query) {
$query->withCount('payrolls')
->withSum('payrolls', 'total_amount')
->withSum('payrolls', 'bonus_amount')
@ -82,15 +44,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage);
}
public function getDetail(PayrollPeriod $period): PayrollPeriod
public function getCurrentOrCreate(): PayrollPeriod
{
return $period->load([
'payrolls' => function ($query) {
$query->with(['employee.user.userProfile', 'payrollAdjustments'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->orderBy('id');
},
]);
$now = now();
return PayrollPeriod::firstOrCreate(
['year' => $now->year, 'month' => $now->month],
['status' => PayrollPeriodStatus::OPEN]
);
}
public function close(PayrollPeriod $period): PayrollPeriod
@ -171,7 +132,7 @@ public function pay(Payroll $payroll): Payroll
$employeeUser = $payroll->employee->user ?? null;
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
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.'.',
url: route('admin.finance.payroll-periods.index'),
@ -202,7 +163,7 @@ public function cancel(Payroll $payroll): Payroll
$employeeUser = $payroll->employee->user ?? null;
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
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.'.',
url: route('admin.finance.payroll-periods.index'),

View File

@ -2,6 +2,8 @@
namespace App\Services\Admin\HR;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Models\Attendance;
use App\Models\Employee;
use App\Models\LeaveRequest;
@ -17,16 +19,17 @@
class AttendanceService
{
use RegistersMedia;
use HasRoleChecks, RegistersMedia;
public function __construct(
private S3PresignedService $s3Service,
private EmployeeService $employeeService,
) {}
public function getIndexData(int $year, int $month): array
{
$user = auth()->user();
$isAdmin = $user->hasAnyRole(['developer', 'owner']);
$isAdmin = self::hasAnyRole([Role::DEVELOPER, Role::OWNER]);
$hrSettings = app(HRSettings::class);
$employeeId = $isAdmin ? null : $user->employee?->id;
@ -34,7 +37,7 @@ public function getIndexData(int $year, int $month): array
return [
'attendances' => $this->getByMonth($year, $month, $employeeId),
'leaves' => $this->getLeavesByMonth($year, $month, $employeeId),
'employees' => $isAdmin ? $this->getAllEmployees() : [],
'employees' => $isAdmin ? $this->employeeService->getAll() : [],
'todayAttendance' => $isAdmin ? null : $this->getToday(),
'currentYear' => $year,
'currentMonth' => $month,
@ -49,14 +52,6 @@ public function getIndexData(int $year, int $month): array
];
}
public function getAll(): Collection
{
return Attendance::with(['employee.user.userProfile', 'media'])
->latest('attendance_date')
->get()
->map(fn (Attendance $attendance) => $this->formatAttendance($attendance));
}
public function getByMonth(int $year, int $month, ?int $employeeId = null): Collection
{
return Attendance::with(['employee.user.userProfile', 'media'])
@ -82,21 +77,6 @@ public function getToday(): ?array
return $this->getByDate(now()->toDateString());
}
public function isOnLeave(User $user): bool
{
$employee = $user->employee;
if (! $employee) {
return false;
}
return LeaveRequest::approved()
->where('employee_id', $employee->id)
->where('start_date', '<=', now()->toDateString())
->where('end_date', '>=', now()->toDateString())
->exists();
}
public function getLeavesByMonth(int $year, int $month, ?int $employeeId = null): Collection
{
$startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth();
@ -119,17 +99,6 @@ public function getLeavesByMonth(int $year, int $month, ?int $employeeId = null)
]);
}
public function getAllEmployees(): Collection
{
return Employee::with(['user.userProfile', 'user.roles'])
->whereHas('user', fn ($q) => $q->where('is_active', true))
->get()
->map(fn (Employee $employee) => [
'id' => $employee->id,
'name' => $employee->user?->userProfile?->full_name ?? '-',
]);
}
public function getMonthStats(int $year, int $month, ?int $employeeId = null): array
{
$startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth();
@ -153,6 +122,87 @@ public function getMonthStats(int $year, int $month, ?int $employeeId = null): a
return $this->getMonthStatsForAll($year, $month, $startOfMonth, $statEnd, $workingDays);
}
public function isOnLeave(User $user): bool
{
$employee = $user->employee;
if (! $employee) {
return false;
}
return LeaveRequest::approved()
->where('employee_id', $employee->id)
->where('start_date', '<=', now()->toDateString())
->where('end_date', '>=', now()->toDateString())
->exists();
}
public function checkIn(array $data): Attendance
{
$employee = auth()->user()->employee;
if (! $employee) {
throw ValidationException::withMessages([
'employee' => 'Anda tidak terdaftar sebagai karyawan.',
]);
}
$today = now()->toDateString();
$existing = Attendance::where('employee_id', $employee->id)
->where('attendance_date', $today)
->first();
if ($existing) {
throw ValidationException::withMessages([
'attendance' => 'Anda sudah melakukan presensi hari ini.',
]);
}
$attendance = Attendance::create([
'employee_id' => $employee->id,
'attendance_date' => $today,
'check_in_at' => now(),
'check_in_latitude' => $data['latitude'],
'check_in_longitude' => $data['longitude'],
]);
if (! empty($data['photo'])) {
$this->registerMediaFromBase64($attendance, $data['photo'], 'check-in', 'attendances');
}
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR],
title: 'Presensi Masuk',
body: 'Presensi masuk oleh '.auth()->user()->full_name.'.',
url: route('admin.hr.attendances.index'),
);
return $attendance;
}
public function checkOut(Attendance $attendance, array $data): Attendance
{
$attendance->update([
'check_out_at' => now(),
'check_out_latitude' => $data['latitude'],
'check_out_longitude' => $data['longitude'],
]);
if (! empty($data['photo'])) {
$this->registerMediaFromBase64($attendance, $data['photo'], 'check-out', 'attendances');
}
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR],
title: 'Presensi Pulang',
body: 'Presensi pulang oleh '.auth()->user()->full_name.'.',
url: route('admin.hr.attendances.index'),
);
return $attendance;
}
private function getMonthStatsForEmployee(int $year, int $month, Carbon $startOfMonth, Carbon $statEnd, int $workingDays, int $employeeId): array
{
$attendanceQuery = Attendance::whereYear('attendance_date', $year)
@ -240,72 +290,6 @@ private function getMonthStatsForAll(int $year, int $month, Carbon $startOfMonth
];
}
public function checkIn(array $data): Attendance
{
$employee = auth()->user()->employee;
if (! $employee) {
throw ValidationException::withMessages([
'employee' => 'Anda tidak terdaftar sebagai karyawan.',
]);
}
$today = now()->toDateString();
$existing = Attendance::where('employee_id', $employee->id)
->where('attendance_date', $today)
->first();
if ($existing) {
throw ValidationException::withMessages([
'attendance' => 'Anda sudah melakukan presensi hari ini.',
]);
}
$attendance = Attendance::create([
'employee_id' => $employee->id,
'attendance_date' => $today,
'check_in_at' => now(),
'check_in_latitude' => $data['latitude'],
'check_in_longitude' => $data['longitude'],
]);
if (! empty($data['photo'])) {
$this->registerMediaFromBase64($attendance, $data['photo'], 'check-in', 'attendances');
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur'],
title: 'Presensi Masuk',
body: 'Presensi masuk oleh '.auth()->user()->full_name.'.',
url: route('admin.hr.attendances.index'),
);
return $attendance;
}
public function checkOut(Attendance $attendance, array $data): Attendance
{
$attendance->update([
'check_out_at' => now(),
'check_out_latitude' => $data['latitude'],
'check_out_longitude' => $data['longitude'],
]);
if (! empty($data['photo'])) {
$this->registerMediaFromBase64($attendance, $data['photo'], 'check-out', 'attendances');
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur'],
title: 'Presensi Pulang',
body: 'Presensi pulang oleh '.auth()->user()->full_name.'.',
url: route('admin.hr.attendances.index'),
);
return $attendance;
}
private function formatAttendance(Attendance $attendance): array
{
$toArray = $attendance->toArray();

View File

@ -2,46 +2,17 @@
namespace App\Services\Admin\HR;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Models\Employee;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class EmployeeService
{
private const ADMIN_ROLES = ['developer', 'owner', 'direktur', 'admin-toko'];
private const RESTRICTED_ROLES = ['admin-toko', 'direktur'];
public function canViewAll(): bool
{
return auth()->user()->hasAnyRole(self::ADMIN_ROLES);
}
public function shouldHideAdminBahanBaku(): bool
{
return auth()->user()->hasAnyRole(self::RESTRICTED_ROLES);
}
public function getAll(array $filters = []): Collection
{
return User::select(['id', 'email', 'username', 'is_active'])
->where(fn ($q) => $q->whereHas('employee')->orWhereHas('roles', fn ($rq) => $rq->where('name', 'Owner')))
->with([
'userProfile' => fn ($q) => $q->select(['id', 'user_id', 'full_name', 'phone_number', 'gender']),
'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
'roles' => fn ($q) => $q->select(['id', 'name']),
])
->when(! $this->canViewAll(), function ($q) {
$userRoles = auth()->user()->roles->pluck('name');
$q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles));
})
->when($this->shouldHideAdminBahanBaku(), fn ($q) => $q->whereDoesntHave('roles', fn ($rq) => $rq->where('name', 'admin-bahan-baku')))
->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status)))
->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)))
->when($filters['gender'] ?? null, fn ($q, $gender) => $q->whereHas('userProfile', fn ($uq) => $uq->where('gender', $gender)))
->latest()
->get();
}
use HasRoleChecks;
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
@ -53,11 +24,11 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
'roles' => fn ($q) => $q->select(['id', 'name']),
])
->when(! $this->canViewAll(), function ($q) {
->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($q) {
$userRoles = auth()->user()->roles->pluck('name');
$q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles));
})
->when($this->shouldHideAdminBahanBaku(), fn ($q) => $q->whereDoesntHave('roles', fn ($rq) => $rq->where('name', 'admin-bahan-baku')))
->when(self::hasAnyRole([Role::ADMIN_TOKO, Role::DIREKTUR]), fn ($q) => $q->whereDoesntHave('roles', fn ($rq) => $rq->where('name', Role::ADMIN_BAHAN_BAKU->value)))
->when($search, fn ($q) => $q->whereHas('userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%")))
->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status)))
->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)))
@ -66,6 +37,17 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage);
}
public function getAll(): Collection
{
return Employee::with(['user.userProfile', 'user.roles'])
->whereHas('user', fn ($q) => $q->where('is_active', true))
->get()
->map(fn (Employee $employee) => [
'id' => $employee->id,
'name' => $employee->user?->userProfile?->full_name ?? '-',
]);
}
public function store(array $data): User
{
return DB::transaction(function () use ($data) {

View File

@ -2,40 +2,26 @@
namespace App\Services\Admin\HR;
use App\Concerns\HasRoleChecks;
use App\Enums\LeaveRequestStatus;
use App\Enums\Role;
use App\Models\LeaveRequest;
use App\Services\NotificationService;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class LeaveRequestService
{
private function canViewAll(): bool
{
return auth()->user()->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
}
public function getAll(array $filters = []): Collection
{
return LeaveRequest::select(['id', 'employee_id', 'start_date', 'end_date', 'total_days', 'status', 'created_at'])
->with(['employee.user.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when($filters['status'] ?? null, function ($query, $status) {
$query->where('status', $status);
})
->latest()
->get();
}
use HasRoleChecks;
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return LeaveRequest::query()
->select(['id', 'employee_id', 'start_date', 'end_date', 'total_days', 'status', 'created_at'])
->with(['employee.user.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when($search, fn ($q) => $q->whereHas('employee.user.userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%")))
->when($filters['status'] ?? null, function ($query, $status) {
$query->where('status', $status);
@ -71,7 +57,7 @@ public function store(array $data): LeaveRequest
$leaveRequest->load('employee.user');
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Pengajuan Cuti Baru',
body: "Pengajuan cuti {$leaveRequest->total_days} hari oleh ".auth()->user()->full_name.'.',
url: route('admin.hr.leave-requests.index'),
@ -114,7 +100,7 @@ public function approve(LeaveRequest $leaveRequest): LeaveRequest
$employeeUser = $leaveRequest->employee->user ?? null;
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Cuti Disetujui',
body: "Cuti {$leaveRequest->employee->name} telah disetujui oleh ".auth()->user()->full_name.'.',
url: route('admin.hr.leave-requests.index'),
@ -135,7 +121,7 @@ public function reject(LeaveRequest $leaveRequest): LeaveRequest
$employeeUser = $leaveRequest->employee->user ?? null;
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Cuti Ditolak',
body: "Cuti {$leaveRequest->employee->name} telah ditolak oleh ".auth()->user()->full_name.'.',
url: route('admin.hr.leave-requests.index'),

View File

@ -7,7 +7,6 @@
use App\Models\CuttingMaterialCombination;
use App\Models\CuttingResult;
use App\Models\RawMaterialPrice;
use App\Services\Admin\Master\RawMaterial\RawMaterialService;
use App\Services\Concerns\RegistersMedia;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -20,7 +19,6 @@ class CuttingService
public function __construct(
private S3PresignedService $s3Service,
private RawMaterialService $rawMaterialService,
) {}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
@ -62,13 +60,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator;
}
public function getForCreate(): array
{
return [
'rawMaterials' => $this->rawMaterialService->getVariantsForCutting(),
];
}
public function getForEdit(Cutting $cutting): array
{
$cutting->load([
@ -129,7 +120,7 @@ public function getForEdit(Cutting $cutting): array
];
}
public function create(array $data): Cutting
public function store(array $data): Cutting
{
return DB::transaction(function () use ($data) {
foreach ($data['materials'] as $materialData) {
@ -335,7 +326,7 @@ public function update(Cutting $cutting, array $data): Cutting
});
}
public function delete(Cutting $cutting): bool
public function destroy(Cutting $cutting): bool
{
return DB::transaction(function () use ($cutting) {
$cutting->load('cuttingMaterials.rawMaterialPrice');

View File

@ -2,6 +2,7 @@
namespace App\Services\Admin\Manage;
use App\Enums\Role;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\RawMaterial;
@ -154,16 +155,16 @@ public function getForEdit(Purchase $purchase): array
];
}
public function create(array $data): Purchase
public function store(array $data): Purchase
{
if (($data['mode'] ?? 'new') === 'existing') {
return $this->createFromExisting($data);
return $this->storeFromExisting($data);
}
return $this->createNew($data);
return $this->storeNew($data);
}
private function createFromExisting(array $data): Purchase
private function storeFromExisting(array $data): Purchase
{
return DB::transaction(function () use ($data) {
$now = now();
@ -219,7 +220,7 @@ private function createFromExisting(array $data): Purchase
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Bahan Baku'],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Belanja Baru',
body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.purchases.index'),
@ -229,7 +230,7 @@ private function createFromExisting(array $data): Purchase
});
}
private function createNew(array $data): Purchase
private function storeNew(array $data): Purchase
{
return DB::transaction(function () use ($data) {
$rawMaterial = RawMaterial::create([
@ -309,7 +310,7 @@ private function createNew(array $data): Purchase
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Bahan Baku'],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Belanja Baru',
body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.purchases.index'),
@ -460,7 +461,7 @@ public function update(Purchase $purchase, array $data): Purchase
});
}
public function delete(Purchase $purchase): bool
public function destroy(Purchase $purchase): bool
{
return DB::transaction(function () use ($purchase) {
$purchase->load('purchaseItems.rawMaterialPrice');

View File

@ -4,6 +4,7 @@
use App\Enums\PriceType;
use App\Enums\ProductStockQuality;
use App\Enums\Role;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Restock;
@ -30,14 +31,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->with([
'createdBy:id',
'createdBy.userProfile:id,user_id,full_name',
'restockItems' => fn ($q) => $q
'restockItems' => fn($q) => $q
->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal'])
->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'),
'restockItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock',
'restockItems.productVariant.product:id,name',
])
->when($search, function ($q) use ($search) {
$q->whereHas('restockItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
$q->whereHas('restockItems.productVariant.product', fn($sq) => $sq->where('name', 'like', "%{$search}%"))
->orWhere('notes', 'like', "%{$search}%");
})
->orderBy($sort, $direction)
@ -59,65 +60,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator;
}
public function getForCreate(): array
{
return [
'products' => Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
$capitalPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::CAPITAL);
$variant->capital_price = $capitalPrice?->price ?? 0;
$rejectPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::REJECT);
$variant->reject_price = $rejectPrice?->price ?? 0;
});
}),
];
}
public function getForEdit(Restock $restock): array
{
$restock->load([
'restockItems' => fn ($q) => $q
->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'),
'restockItems.productVariant.product',
]);
$media = $restock->getFirstMedia('photos');
return [
'id' => $restock->id,
'stock_type' => $restock->stock_type->value,
'notes' => $restock->notes,
'photo_key' => $media?->file_name,
'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null,
'items' => $restock->restockItems->map(fn (RestockItem $item) => [
'id' => $item->id,
'product_variant_id' => $item->product_variant_id,
'quantity' => $item->quantity,
'unit_price' => $item->unit_price,
])->values(),
];
}
public function create(array $data): Restock
public function store(array $data): Restock
{
return DB::transaction(function () use ($data) {
$now = now();
@ -143,9 +86,9 @@ public function create(array $data): Restock
$this->syncPhoto($restock, $data);
NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
title: 'Restock Baru',
body: 'Restock '.($stockType === ProductStockQuality::GOOD->value ? 'produk' : 'reject').' sebesar Rp '.number_format($subtotal, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
body: 'Restock ' . ($stockType === ProductStockQuality::GOOD->value ? 'produk' : 'reject') . ' sebesar Rp ' . number_format($subtotal, 0, ',', '.') . ' berhasil dicatat oleh ' . auth()->user()->full_name . '.',
url: route('admin.manage.restocks.index'),
);
@ -189,7 +132,7 @@ public function update(Restock $restock, array $data): Restock
});
}
public function delete(Restock $restock): bool
public function destroy(Restock $restock): bool
{
return DB::transaction(function () use ($restock) {
$restock->load('restockItems');
@ -219,7 +162,7 @@ private function buildItemRows(array $items, string $stockType, $now, int &$subt
->get()
->mapWithKeys(function (ProductVariant $variant) use ($priceType) {
$price = $variant->productPrices
->first(fn ($p) => $p->type === $priceType);
->first(fn($p) => $p->type === $priceType);
return [$variant->id => $price?->price ?? 0];
});

View File

@ -7,6 +7,7 @@
use App\Enums\PaymentType;
use App\Enums\PriceType;
use App\Enums\ProductStockQuality;
use App\Enums\Role;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderItem;
@ -143,83 +144,7 @@ public function getFilterOptions(): array
];
}
public function getForCreate(): array
{
return [
'products' => Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
$prices = $variant->productPrices->mapWithKeys(fn($p) => [$p->type->value => $p->price]);
$variant->prices = $prices;
});
}),
'customers' => Customer::query()
->select(['id', 'name'])
->orderBy('name')
->get(),
'employees' => User::query()
->select('id')
->active()
->with('userProfile:id,user_id,full_name')
->orderBy('id')
->get()
->filter(fn(User $user) => $user->userProfile?->full_name)
->values(),
'channelOptions' => OrderChannel::toSelect(),
'paymentTypeOptions' => PaymentType::toSelect(),
'priceTypeOptions' => PriceType::toSelect()->filter(fn($p) => $p['value'] !== PriceType::CAPITAL->value)->values(),
];
}
public function getForEdit(Order $order): array
{
$order->load('orderItems.productVariant.product');
$media = $order->getFirstMedia('photos');
return [
'id' => $order->id,
'order_number' => $order->order_number,
'stock_type' => $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value,
'channel' => $order->channel?->value ?? OrderChannel::STORE->value,
'price_type' => $order->price_type?->value ?? PriceType::RETAIL->value,
'payment_type' => $order->payment_type?->value ?? PaymentType::CASH->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,
'is_affiliate' => $order->is_affiliate,
'tiktok_order_id' => $order->tiktok_order_id,
'shopee_order_id' => $order->shopee_order_id,
'notes' => $order->notes,
'photo_key' => $media?->file_name,
'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: 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,
])->values(),
];
}
public function create(array $data): Order
public function store(array $data): Order
{
return DB::transaction(function () use ($data) {
$now = now();
@ -268,7 +193,7 @@ public function create(array $data): Order
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'],
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 . '.',
url: route('admin.manage.transactions.index'),
@ -339,7 +264,7 @@ public function update(Order $order, array $data): Order
});
}
public function delete(Order $order): bool
public function destroy(Order $order): bool
{
return DB::transaction(function () use ($order) {
$order->load('orderItems');

View File

@ -8,20 +8,20 @@
class CategoryService
{
public function getAll(array $filters = []): Collection
{
return Category::select(['id', 'name'])->latest()->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Category::query()
->select(['id', 'name'])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
->when($search, fn($q) => $q->where('name', 'like', "%{$search}%"))
->orderBy($sort, $direction)
->paginate($perPage);
}
public function getAll(): Collection
{
return Category::select(['id', 'name'])->latest()->get();
}
public function store(array $data): Category
{
return Category::create($data);

View File

@ -8,11 +8,6 @@
class CustomerService
{
public function getAll(array $filters = []): Collection
{
return Customer::select(['id', 'name', 'phone_number', 'address'])->latest()->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Customer::query()
@ -22,6 +17,11 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage);
}
public function getAll(): Collection
{
return Customer::select(['id', 'name'])->orderBy('name')->get();
}
public function store(array $data): Customer
{
return Customer::create($data);

View File

@ -2,75 +2,28 @@
namespace App\Services\Admin\Master\Product;
use App\Concerns\HasRoleChecks;
use App\Enums\ProductStatus;
use App\Enums\Role;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use App\Services\StockMutationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ProductService
{
use HasRoleChecks;
public function __construct(
private ProductVariantService $variantService,
private S3PresignedService $s3Service,
private StockMutationService $stockMutationService,
) {}
private function canVerify(): bool
{
$user = auth()->user();
return $user->hasAnyRole(['developer', 'owner']);
}
private function assertNotPending(Product $product): void
{
if ($product->status === ProductStatus::PENDING && ! $this->canVerify()) {
throw ValidationException::withMessages([
'status' => 'Produk sedang menunggu verifikasi dan tidak dapat diubah.',
]);
}
}
public function getNames(): array
{
return Product::where('status', '!=', 'deleted')
->orderBy('name')
->pluck('name')
->unique()
->values()
->toArray();
}
public function getAll(array $filters = []): Collection
{
$products = Product::select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason'])
->with([
'categories:id,name',
'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
'productVariants.productPrices:id,variant_id,type,price',
'productVariants.media',
])
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
->latest()
->get();
$products->each(function ($product) {
$product->productVariants->each(function ($variant) {
$media = $variant->getMedia('photos');
$variant->photo_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
});
});
return $products;
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
$paginator = Product::query()
@ -105,9 +58,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator;
}
public function create(array $data): Product
public function store(array $data): Product
{
$status = $this->canVerify()
$status = self::hasAnyRole([Role::DEVELOPER, Role::OWNER])
? ($data['status'] ?? ProductStatus::ACTIVE)
: ProductStatus::PENDING;
@ -182,7 +135,7 @@ public function create(array $data): Product
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Baru',
body: "Produk \"{$product->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
@ -236,7 +189,7 @@ public function update(Product $product, array $data): Product
$product = DB::transaction(function () use ($product, $data) {
// Auto-resubmit: non-verifier editing rejected product → status becomes pending
$newStatus = $data['status'] ?? $product->status;
if ($product->status === ProductStatus::REJECTED && ! $this->canVerify()) {
if ($product->status === ProductStatus::REJECTED && ! self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) {
$newStatus = ProductStatus::PENDING;
}
@ -433,7 +386,7 @@ public function update(Product $product, array $data): Product
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Diperbarui',
body: "Produk \"{$product->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
@ -442,7 +395,7 @@ public function update(Product $product, array $data): Product
return $product;
}
public function delete(Product $product): bool
public function destroy(Product $product): bool
{
$this->assertNotPending($product);
@ -459,7 +412,7 @@ public function delete(Product $product): bool
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Dihapus',
body: "Produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
@ -484,7 +437,7 @@ public function approve(Product $product): void
]);
NotificationService::notify(
roles: ['Owner', 'Developer'],
roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Disetujui',
body: "Produk \"{$product->name}\" telah disetujui oleh ".auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
@ -500,7 +453,7 @@ public function reject(Product $product, string $reason = ''): void
]);
NotificationService::notify(
roles: ['Owner', 'Developer'],
roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Ditolak',
body: "Produk \"{$product->name}\" telah ditolak oleh ".auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
@ -516,10 +469,19 @@ public function resubmit(Product $product): void
]);
NotificationService::notify(
roles: ['Owner', 'Developer'],
roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Diajukan Ulang',
body: "Produk \"{$product->name}\" telah diajukan ulang oleh ".auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
);
}
private function assertNotPending(Product $product): void
{
if ($product->status === ProductStatus::PENDING && ! self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) {
throw ValidationException::withMessages([
'status' => 'Produk sedang menunggu verifikasi dan tidak dapat diubah.',
]);
}
}
}

View File

@ -2,7 +2,10 @@
namespace App\Services\Admin\Master\Product;
use App\Concerns\HasRoleChecks;
use App\Enums\PriceType;
use App\Enums\ProductStatus;
use App\Enums\Role;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Services\Concerns\RegistersMedia;
@ -14,27 +17,64 @@
class ProductVariantService
{
use RegistersMedia;
use HasRoleChecks, RegistersMedia;
public function __construct(
private S3PresignedService $s3Service,
private StockMutationService $stockMutationService,
) {}
private function canVerify(): bool
public function getForRestock(): array
{
$user = auth()->user();
return Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
return $user->hasAnyRole(['developer', 'owner']);
$capitalPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::CAPITAL);
$variant->capital_price = $capitalPrice?->price ?? 0;
$rejectPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::REJECT);
$variant->reject_price = $rejectPrice?->price ?? 0;
});
});
}
private function assertNotPending(Product $product): void
public function getForTransaction(): array
{
if ($product->status === ProductStatus::PENDING && ! $this->canVerify()) {
throw ValidationException::withMessages([
'status' => 'Produk sedang menunggu verifikasi dan tidak dapat diubah.',
]);
}
return Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
$prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]);
$variant->prices = $prices;
});
});
}
public function getForEdit(ProductVariant $variant): array
@ -102,7 +142,7 @@ public function update(ProductVariant $variant, array $data): ProductVariant
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Diperbarui',
body: "Varian \"{$variant->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
@ -111,7 +151,7 @@ public function update(ProductVariant $variant, array $data): ProductVariant
return $variant->fresh();
}
public function delete(Product $product, ProductVariant $variant): bool
public function destroy(Product $product, ProductVariant $variant): bool
{
$this->assertNotPending($product);
@ -123,7 +163,7 @@ public function delete(Product $product, ProductVariant $variant): bool
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Dihapus',
body: "Varian \"{$variant->name}\" dari produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
@ -184,7 +224,7 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Transfer Stok',
body: "{$quantity} unit dari varian \"{$variant->name}\" berhasil ditransfer dari stok bagus ke stok ecer".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
@ -192,4 +232,13 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
return $variant->fresh();
}
private function assertNotPending(Product $product): void
{
if ($product->status === ProductStatus::PENDING && ! self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) {
throw ValidationException::withMessages([
'status' => 'Produk sedang menunggu verifikasi dan tidak dapat diubah.',
]);
}
}
}

View File

@ -7,7 +7,6 @@
use App\Services\Concerns\RegistersMedia;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class RawMaterialService
@ -18,27 +17,6 @@ public function __construct(
private S3PresignedService $s3Service,
) {}
public function getAll(array $filters = []): Collection
{
return RawMaterial::select(['id', 'name', 'unit', 'is_active'])
->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
'rawMaterialPrices.media',
])
->when($filters['is_active'] ?? null, fn ($q, $isActive) => $q->where('is_active', $isActive === 'true'))
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
->latest()
->get()
->each(function ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function ($price) {
$media = $price->getMedia('photos');
$price->photo_url = $media->first()
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
: null;
});
});
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
$paginator = RawMaterial::query()
@ -239,26 +217,6 @@ public function toggleStatus(RawMaterial $rawMaterial): void
]);
}
public function getVariantsForCutting(): array
{
return RawMaterial::query()
->select(['id', 'name', 'unit', 'is_active'])
->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
])
->active()
->orderBy('name')
->get()
->each(function (RawMaterial $rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$media = $price->getFirstMedia('photos');
$price->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
});
});
}
private function registerPhoto(RawMaterialPrice $price, string $s3Key): void
{
$this->registerMedia(

View File

@ -2,6 +2,7 @@
namespace App\Services\Admin\Master\RawMaterial;
use App\Enums\Role;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Services\Concerns\RegistersMedia;
@ -17,6 +18,26 @@ public function __construct(
private S3PresignedService $s3Service,
) {}
public function getForCutting(): array
{
return RawMaterial::query()
->select(['id', 'name', 'unit', 'is_active'])
->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
])
->active()
->orderBy('name')
->get()
->each(function (RawMaterial $rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$media = $price->getFirstMedia('photos');
$price->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
});
});
}
public function getForEdit(RawMaterialPrice $variant): array
{
$variant->load('media');
@ -61,7 +82,7 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Diperbarui',
body: "Varian \"{$variant->variant}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'),
@ -70,7 +91,7 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
return $variant->fresh();
}
public function delete(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool
public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool
{
$result = DB::transaction(function () use ($variant) {
$variant->clearMediaCollection('photos');
@ -79,7 +100,7 @@ public function delete(RawMaterial $rawMaterial, RawMaterialPrice $variant): boo
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Dihapus',
body: "Varian \"{$variant->variant}\" dari bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'),

View File

@ -8,11 +8,6 @@
class SupplierService
{
public function getAll(array $filters = []): Collection
{
return Supplier::select(['id', 'name', 'phone_number', 'address'])->latest()->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Supplier::query()
@ -22,6 +17,11 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage);
}
public function getAll(): Collection
{
return Supplier::select(['id', 'name', 'phone_number', 'address'])->latest()->get();
}
public function store(array $data): Supplier
{
return Supplier::create($data);

View File

@ -2,17 +2,21 @@
namespace App\Services\Admin\Settings;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Role as SpatieRole;
class RoleService
{
use HasRoleChecks;
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Role::query()
return SpatieRole::query()
->select(['id', 'name'])
->withCount('permissions')
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
@ -23,7 +27,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
public function store(array $data): Role
{
return DB::transaction(function () use ($data) {
$role = Role::create(['name' => $data['name']]);
$role = SpatieRole::create(['name' => $data['name']]);
$role->syncPermissions($data['permissions']);
return $role;
@ -55,15 +59,15 @@ public function getPermissionsByModule(): array
public function getForEmployee(): Collection
{
$query = Role::where('name', '!=', 'Developer');
$query = SpatieRole::where('name', '!=', Role::DEVELOPER->value);
$user = auth()->user();
if ($user->hasAnyRole(['admin-toko', 'direktur'])) {
$query->where('name', '!=', 'admin-bahan-baku');
if (self::hasAnyRole([Role::ADMIN_TOKO, Role::DIREKTUR])) {
$query->where('name', '!=', Role::ADMIN_BAHAN_BAKU->value);
}
if (! $user->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko'])) {
if (! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO])) {
$userRoles = $user->roles->pluck('name');
$query->whereIn('name', $userRoles);
}

View File

@ -36,7 +36,7 @@ public function getAttendanceStats(?string $startDate, ?string $endDate): array
$current->addDay();
}
$totalEmployees = Employee::whereHas('user', fn($q) => $q->where('is_active', true))->count();
$totalEmployees = Employee::whereHas('user', fn ($q) => $q->where('is_active', true))->count();
$present = Attendance::whereBetween('attendance_date', [$start, $end])->count();
@ -142,12 +142,12 @@ public function getRawMaterialStock(): array
->get();
$totalStock = $prices->sum('stock');
$totalValue = $prices->sum(fn($p) => $p->stock * $p->price);
$totalValue = $prices->sum(fn ($p) => $p->stock * $p->price);
$byUnit = [
'yard' => $prices->filter(fn($p) => $p->rawMaterial?->unit === RawMaterialUnit::YARD)->sum('stock'),
'meter' => $prices->filter(fn($p) => $p->rawMaterial?->unit === RawMaterialUnit::METER)->sum('stock'),
'kilogram' => $prices->filter(fn($p) => $p->rawMaterial?->unit === RawMaterialUnit::KG)->sum('stock'),
'yard' => $prices->filter(fn ($p) => $p->rawMaterial?->unit === RawMaterialUnit::YARD)->sum('stock'),
'meter' => $prices->filter(fn ($p) => $p->rawMaterial?->unit === RawMaterialUnit::METER)->sum('stock'),
'kilogram' => $prices->filter(fn ($p) => $p->rawMaterial?->unit === RawMaterialUnit::KG)->sum('stock'),
];
return [
@ -226,7 +226,7 @@ public function getMonthlyRevenue(?string $startDate, ?string $endDate): array
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->orderBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"))
->get()
->map(fn($item) => $item->only(['month', 'total', 'net', 'net_warehouse', 'net_retail', 'deduction']));
->map(fn ($item) => $item->only(['month', 'total', 'net', 'net_warehouse', 'net_retail', 'deduction']));
return $monthly->values()->toArray();
}
@ -244,7 +244,7 @@ public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate)
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->orderBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"))
->get()
->map(fn($item) => $item->only(['month', 'store', 'shopee', 'tiktok']));
->map(fn ($item) => $item->only(['month', 'store', 'shopee', 'tiktok']));
return $monthly->values()->toArray();
}
@ -259,7 +259,7 @@ public function getRevenueByPaymentType(?string $startDate, ?string $endDate): a
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
->groupBy('payment_type')
->get()
->map(fn($item) => [
->map(fn ($item) => [
'payment_type' => $item->payment_type,
'label' => $item->payment_type->label(),
'total' => (int) $item->total,

View File

@ -3,6 +3,7 @@
namespace App\Services\Concerns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
@ -109,4 +110,35 @@ private function syncPhoto(Model $model, array $data, string $collectionName = '
);
}
}
private function syncReceipt(Model $model, ?string $newKey, string $collectionName = 'receipts', string $cachePrefix = 'receipt', ?int $fileSize = null, ?string $fileMimeType = null): void
{
$currentMedia = $model->getFirstMedia($collectionName);
$currentKey = $currentMedia?->file_name;
if ($currentMedia && ! str_contains($currentKey, '/')) {
$currentKey = $currentMedia->getPath();
}
if ($newKey === $currentKey) {
return;
}
if ($currentMedia) {
Cache::forget("{$cachePrefix}_{$currentMedia->id}");
}
$model->clearMediaCollection($collectionName);
if (! empty($newKey)) {
$this->registerMedia(
model: $model,
s3Key: $newKey,
collectionName: $collectionName,
generatedConversions: ['thumb' => true],
fileSize: $fileSize,
mimeType: $fileMimeType,
);
}
}
}

View File

@ -2,18 +2,24 @@
namespace App\Services;
use App\Enums\Role;
use App\Models\User;
use App\Notifications\WebPushNotification;
use Illuminate\Support\Facades\Log;
class NotificationService
{
/**
* @param array<Role> $roles
*/
public static function notify(array $roles, string $title, string $body, string $url, ?User $additionalUser = null): void
{
$roleLabels = array_map(fn (Role $role) => $role->label(), $roles);
$users = User::query()
->select(['id'])
->where('is_active', true)
->whereHas('roles', fn ($q) => $q->whereIn('name', $roles))
->whereHas('roles', fn ($q) => $q->whereIn('name', $roleLabels))
->get();
if ($additionalUser && ! $users->contains('id', $additionalUser->id)) {

View File

@ -17,6 +17,16 @@ class StockMutationService
'retail_stock' => 'retail',
];
public function paginated(Model $model, int $perPage = 20): LengthAwarePaginator
{
return StockMutation::query()
->where('stockable_type', get_class($model))
->where('stockable_id', $model->id)
->with('user:id,username,email')
->latest()
->paginate($perPage);
}
public function recordInitial(Model $model, array $stockData, string $description = 'Stok awal'): void
{
$userId = auth()->id();
@ -172,14 +182,4 @@ public function recordTransfer(
]);
});
}
public function paginated(Model $model, int $perPage = 20): LengthAwarePaginator
{
return StockMutation::query()
->where('stockable_type', get_class($model))
->where('stockable_id', $model->id)
->with('user:id,username,email')
->latest()
->paginate($perPage);
}
}

View File

@ -1,7 +1,6 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration

View File

@ -41,11 +41,10 @@ type CombinationState = {
};
type Props = {
data: CuttingCreateData;
rawMaterials: CuttingCreateData['rawMaterials'];
};
export default function CuttingCreate({ data }: Props) {
const { rawMaterials } = data;
export default function CuttingCreate({ rawMaterials }: Props) {
const { auth, errors } = usePage().props as { auth: { user?: { id?: number } }; errors: Record<string, string> };
const userId = auth.user?.id;

View File

@ -42,11 +42,10 @@ type CombinationState = {
type Props = {
cutting: CuttingForEdit;
data: CuttingCreateData;
rawMaterials: CuttingCreateData['rawMaterials'];
};
export default function CuttingEdit({ cutting, data }: Props) {
const { rawMaterials } = data;
export default function CuttingEdit({ cutting, rawMaterials }: Props) {
const { errors } = usePage().props as { errors: Record<string, string> };
const [materials, setMaterials] = useState<MaterialState[]>(() => {

View File

@ -72,11 +72,11 @@ type CartLine = {
};
type Props = {
data: PurchaseCreateData;
suppliers: PurchaseCreateData['suppliers'];
rawMaterials: PurchaseCreateData['rawMaterials'];
};
export default function PurchaseCreate({ data }: Props) {
const { suppliers, rawMaterials } = data;
export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
const userId = auth.user?.id;

View File

@ -57,11 +57,15 @@ type CartLine = {
type Props = {
purchase: PurchaseForEdit;
data: PurchaseCreateData;
suppliers: PurchaseCreateData['suppliers'];
rawMaterials: PurchaseCreateData['rawMaterials'];
};
export default function PurchaseEdit({ purchase, data }: Props) {
const { suppliers, rawMaterials } = data;
export default function PurchaseEdit({
purchase,
suppliers,
rawMaterials,
}: Props) {
const [supplierId, setSupplierId] = useState(String(purchase.supplier_id));
const selectedSupplier =

View File

@ -50,11 +50,10 @@ type CartLine = {
};
type Props = {
data: RestockCreateData;
products: RestockCreateData['products'];
};
export default function RestockCreate({ data }: Props) {
const { products } = data;
export default function RestockCreate({ products }: Props) {
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
const userId = auth.user?.id;

View File

@ -41,11 +41,10 @@ type CartLine = {
type Props = {
restock: RestockForEdit;
data: RestockCreateData;
products: RestockCreateData['products'];
};
export default function RestockEdit({ restock, data }: Props) {
const { products } = data;
export default function RestockEdit({ restock, products }: Props) {
const [stockType, setStockType] = useState<'good' | 'reject'>(
restock.stock_type === 'reject' ? 'reject' : 'good',

View File

@ -60,13 +60,24 @@ type CartLine = {
};
type Props = {
data: TransactionCreateData;
products: TransactionCreateData['products'];
customers: TransactionCreateData['customers'];
employees: TransactionCreateData['employees'];
channelOptions: TransactionCreateData['channelOptions'];
paymentTypeOptions: TransactionCreateData['paymentTypeOptions'];
priceTypeOptions: TransactionCreateData['priceTypeOptions'];
};
const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee'];
export default function TransactionCreate({ data }: Props) {
const { products, customers, employees, channelOptions, paymentTypeOptions, priceTypeOptions } = data;
export default function TransactionCreate({
products,
customers,
employees,
channelOptions,
paymentTypeOptions,
priceTypeOptions,
}: Props) {
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
const userId = auth.user?.id;

View File

@ -59,13 +59,25 @@ type CartLine = {
type Props = {
transaction: TransactionForEdit;
data: TransactionCreateData;
products: TransactionCreateData['products'];
customers: TransactionCreateData['customers'];
employees: TransactionCreateData['employees'];
channelOptions: TransactionCreateData['channelOptions'];
paymentTypeOptions: TransactionCreateData['paymentTypeOptions'];
priceTypeOptions: TransactionCreateData['priceTypeOptions'];
};
const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee'];
export default function TransactionEdit({ transaction, data }: Props) {
const { products, customers, employees, channelOptions, paymentTypeOptions, priceTypeOptions } = data;
export default function TransactionEdit({
transaction,
products,
customers,
employees,
channelOptions,
paymentTypeOptions,
priceTypeOptions,
}: Props) {
const [stockType, setStockType] = useState<'good' | 'reject'>(
transaction.stock_type === 'reject' ? 'reject' : 'good',

View File

@ -132,4 +132,4 @@
});
});
require __DIR__ . '/settings.php';
require __DIR__.'/settings.php';

View File

@ -9,10 +9,10 @@
use App\Models\Product;
use App\Models\User;
use App\Notifications\WebPushNotification;
use App\Services\Admin\Finance\CashAccountService;
use App\Services\Admin\Finance\Cash\CashTransactionService;
use App\Services\Admin\Finance\EmployeeAdvanceService;
use App\Services\Admin\Finance\ExpenseService;
use App\Services\Admin\Finance\PayrollPeriodService;
use App\Services\Admin\Finance\Payroll\PayrollPeriodService;
use App\Services\Admin\HR\AttendanceService;
use App\Services\Admin\HR\LeaveRequestService;
use App\Services\Admin\Master\Product\ProductService;
@ -283,7 +283,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
|--------------------------------------------------------------------------
*/
test('CashAccountService deposit sends notification to correct roles', function () {
test('CashTransactionService deposit sends notification to correct roles', function () {
Notification::fake();
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
@ -293,7 +293,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
$this->actingAs($developer);
$service = new CashAccountService;
$service = new CashTransactionService;
$service->deposit([
'amount' => 500000,
'description' => 'Setoran modal',
@ -306,7 +306,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
expect($developer->notifications()->first()->title)->toBe('Setoran Kas Toko');
});
test('CashAccountService withdrawal sends notification to correct roles', function () {
test('CashTransactionService withdrawal sends notification to correct roles', function () {
Notification::fake();
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
@ -315,7 +315,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
$this->actingAs($developer);
$service = new CashAccountService;
$service = new CashTransactionService;
$service->withdrawal([
'amount' => 200000,
'description' => 'Penarikan kas',
@ -327,7 +327,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
expect($developer->notifications()->first()->title)->toBe('Penarikan Kas Toko');
});
test('CashAccountService updateTransaction sends notification to correct roles', function () {
test('CashTransactionService update sends notification to correct roles', function () {
Notification::fake();
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
@ -336,7 +336,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
$this->actingAs($developer);
$service = new CashAccountService;
$service = new CashTransactionService;
$transaction = $service->deposit([
'amount' => 500000,
'description' => 'Initial deposit',
@ -344,7 +344,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
Notification::fake();
$service->updateTransaction($transaction, [
$service->update($transaction, [
'amount' => 600000,
'description' => 'Updated deposit',
]);
@ -355,7 +355,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
expect($developer->notifications()->where('title', 'Transaksi Kas Diperbarui')->count())->toBe(1);
});
test('CashAccountService deleteTransaction sends notification to correct roles', function () {
test('CashTransactionService destroy sends notification to correct roles', function () {
Notification::fake();
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
@ -364,7 +364,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
$this->actingAs($developer);
$service = new CashAccountService;
$service = new CashTransactionService;
$transaction = $service->deposit([
'amount' => 500000,
'description' => 'To be deleted',
@ -372,7 +372,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
Notification::fake();
$service->deleteTransaction($transaction);
$service->destroy($transaction);
Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($owner, WebPushNotification::class);
@ -849,13 +849,13 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
]);
});
test('CashAccountService deposit creates in-app notification record', function () {
test('CashTransactionService deposit creates in-app notification record', function () {
CashAccount::factory()->create(['balance' => 5000000]);
$owner = notifUserWithRole('Owner');
$this->actingAs($owner);
$service = new CashAccountService;
$service = new CashTransactionService;
$service->deposit([
'amount' => 250000,
'description' => 'Test deposit',