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` | | 8 | Transaksi | `admin/manage/transactions` | `Admin/Manage/TransactionController` | `Admin/Manage/TransactionService` |
| 9 | Restock | `admin/manage/restocks` | `Admin/Manage/RestockController` | `Admin/Manage/RestockService` | | 9 | Restock | `admin/manage/restocks` | `Admin/Manage/RestockController` | `Admin/Manage/RestockService` |
| 10 | Stok Opname | `admin/manage/stok-opnames` | (via StockMutationController) | — | | 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` | | 12 | Pengeluaran | `admin/finance/expenses` | `Admin/Finance/ExpenseController` | `Admin/Finance/ExpenseService` |
| 13 | Kasbon | `admin/finance/employee-advances` | `Admin/Finance/EmployeeAdvanceController` | `Admin/Finance/EmployeeAdvanceService` | | 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` | | 15 | Pegawai | `admin/hr/employees` | `Admin/HR/EmployeeController` | `Admin/HR/EmployeeService` |
| 16 | Presensi | `admin/hr/attendances` | `Admin/HR/AttendanceController` | `Admin/HR/AttendanceService` | | 16 | Presensi | `admin/hr/attendances` | `Admin/HR/AttendanceController` | `Admin/HR/AttendanceService` |
| 17 | Cuti | `admin/hr/leave-requests` | `Admin/HR/LeaveRequestController` | `Admin/HR/LeaveRequestService` | | 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 categories(): BelongsToMany { ... }
public function productVariants(): HasMany { ... } public function productVariants(): HasMany { ... }
} }
// ✅ Trait stacking — gabungkan dengan koma
use HasFactory, SoftDeletes; // 1 baris, pisah koma
``` ```
### Casting — Wajib untuk Semua Tipe ### Casting — Wajib untuk Semua Tipe
@ -698,7 +701,7 @@ ### Penamaan Method — HARUS KONSISTEN
} }
// 2. getAll() — untuk semua data (tanpa paginasi) // 2. getAll() — untuk semua data (tanpa paginasi)
public function getAll(array $filters = []): Collection public function getAll(): Collection
{ {
return Product::query() return Product::query()
->select(['id', 'name']) ->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) ### Service Concerns (Traits)
```php ```php
// ✅ Gunakan traits untuk kode yang berulang di banyak service // ✅ Gunakan traits untuk kode yang berulang di banyak service
@ -800,6 +833,64 @@ ### Service Concerns (Traits)
return $restock; 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 ### Cash Transaction Pattern
@ -820,6 +911,44 @@ ### Stock Adjustment Pattern
$this->adjustStock($model, $field, $quantity, $sign); $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 pindahkan logic ke Service (controller = lalu lintas saja)
- Selalu pakai nama method yang konsisten di semua service (`paginated`, `getAll`, `store`, `update`, `destroy`) - Selalu pakai nama method yang konsisten di semua service (`paginated`, `getAll`, `store`, `update`, `destroy`)
- Selalu cari kode berulang → jadikan Trait/Concern - 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 `withTrashed()` untuk relasi ke model yang mungkin di-soft-delete
- Selalu pakai return type di SEMUA method (controller & service) - Selalu pakai return type di SEMUA method (controller & service)
- **Frontend**: Selalu gunakan `formatted_*` accessor dari model, bukan format di TypeScript - **Frontend**: Selalu gunakan `formatted_*` accessor dari model, bukan format di TypeScript
@ -1557,6 +1688,8 @@ ### ❌ Don't
- Jangan pakai `$fillable` (pakai `#[Guarded]`) - Jangan pakai `$fillable` (pakai `#[Guarded]`)
- Jangan biarkan kolom tanpa cast - Jangan biarkan kolom tanpa cast
- Jangan pakai string biasa untuk data opsi (pakai Enum) - 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 lupa buat scope untuk data opsi, gunakan `#[Scope]` + return type `void`
- Jangan pakai `public function` untuk scope (pakai `protected function`) - Jangan pakai `public function` untuk scope (pakai `protected function`)
- Jangan lupa tulis relasi 2 ARAH - Jangan lupa tulis relasi 2 ARAH

View File

@ -143,6 +143,7 @@ # Manual create
- [ ] Method wajib: `paginated()`, `getAll()`, `store()`, `update()`, `destroy()` - [ ] Method wajib: `paginated()`, `getAll()`, `store()`, `update()`, `destroy()`
- [ ] Select kolom yang dibutuhkan + eager load relasi - [ ] Select kolom yang dibutuhkan + eager load relasi
- [ ] Gunakan traits jika perlu: `HandlesCashTransactions`, `HasStockAdjustment`, `RegistersMedia` - [ ] Gunakan traits jika perlu: `HandlesCashTransactions`, `HasStockAdjustment`, `RegistersMedia`
- [ ] Urutan method: CRUD → custom methods → private helpers (di paling bawah)
### 5. Form Request ### 5. Form Request
```bash ```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; namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum Role: string enum Role: string
{ {
use ProvidesEnumOptions;
case DEVELOPER = 'developer'; case DEVELOPER = 'developer';
case OWNER = 'owner'; case OWNER = 'owner';
@ -95,7 +92,6 @@ public function permissions(): array
Permission::CUSTOMERS_VIEW, Permission::CUSTOMERS_VIEW,
Permission::PRODUCTS_VIEW, Permission::PRODUCTS_VIEW,
Permission::STOCKS_VIEW,
Permission::ORDERS_VIEW, Permission::ORDERS_VIEW,
@ -139,8 +135,6 @@ public function permissions(): array
Permission::ANALYSIS_TOP_PRODUCTS, Permission::ANALYSIS_TOP_PRODUCTS,
Permission::ANALYSIS_MARKETING_SALES, Permission::ANALYSIS_MARKETING_SALES,
Permission::STOCKS_VIEW,
Permission::STOK_OPNAMES_VIEW, Permission::STOK_OPNAMES_VIEW,
Permission::ATTENDANCES_VIEW, Permission::ATTENDANCES_VIEW,
@ -167,7 +161,6 @@ public function permissions(): array
Permission::PRODUCTS_UPDATE, Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE, Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS, Permission::PRODUCTS_TOGGLE_STATUS,
Permission::STOCKS_VIEW,
Permission::ORDERS_VIEW, Permission::ORDERS_VIEW,
Permission::ORDERS_CREATE, Permission::ORDERS_CREATE,
@ -293,7 +286,6 @@ public function permissions(): array
Permission::RAW_MATERIALS_CREATE, Permission::RAW_MATERIALS_CREATE,
Permission::RAW_MATERIALS_UPDATE, Permission::RAW_MATERIALS_UPDATE,
Permission::RAW_MATERIALS_TOGGLE_STATUS, Permission::RAW_MATERIALS_TOGGLE_STATUS,
Permission::STOCKS_VIEW,
Permission::PRODUCTS_VIEW, Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE, Permission::PRODUCTS_CREATE,
@ -393,7 +385,6 @@ public function permissions(): array
Permission::LEAVE_REQUESTS_DELETE, Permission::LEAVE_REQUESTS_DELETE,
Permission::PRODUCTS_VIEW, Permission::PRODUCTS_VIEW,
Permission::STOCKS_VIEW,
Permission::STOK_OPNAMES_VIEW, Permission::STOK_OPNAMES_VIEW,
Permission::STOK_OPNAMES_CREATE, Permission::STOK_OPNAMES_CREATE,

View File

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

View File

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

View File

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

View File

@ -2,16 +2,20 @@
namespace App\Http\Controllers\Admin\Finance\Payroll; namespace App\Http\Controllers\Admin\Finance\Payroll;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\PayrollPeriod; use App\Models\PayrollPeriod;
use App\Services\Admin\Finance\PayrollPeriodService; use App\Services\Admin\Finance\Payroll\PayrollPeriodService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
class PayrollPeriodController extends Controller class PayrollPeriodController extends Controller
{ {
use HasRoleChecks;
public function __construct( public function __construct(
private PayrollPeriodService $service private PayrollPeriodService $service
) {} ) {}
@ -32,15 +36,23 @@ public function current(): RedirectResponse
public function show(PayrollPeriod $payrollPeriod): Response 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', [ return Inertia::render('admin/finance/payroll-period/show', [
'payrollPeriod' => $this->service->getDetail($payrollPeriod), 'payrollPeriod' => $payrollPeriod,
]); ]);
} }
public function close(PayrollPeriod $payrollPeriod): RedirectResponse public function close(PayrollPeriod $payrollPeriod): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn() => $this->service->close($payrollPeriod), fn () => $this->service->close($payrollPeriod),
'Periode gaji berhasil ditutup.', 'Periode gaji berhasil ditutup.',
'admin.finance.payroll-periods.index' 'admin.finance.payroll-periods.index'
); );
@ -49,7 +61,7 @@ public function close(PayrollPeriod $payrollPeriod): RedirectResponse
public function reopen(PayrollPeriod $payrollPeriod): RedirectResponse public function reopen(PayrollPeriod $payrollPeriod): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn() => $this->service->reopen($payrollPeriod), fn () => $this->service->reopen($payrollPeriod),
'Periode gaji berhasil dibuka kembali.', 'Periode gaji berhasil dibuka kembali.',
'admin.finance.payroll-periods.index' 'admin.finance.payroll-periods.index'
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,11 +2,18 @@
namespace App\Http\Controllers\Admin\Manage; 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\Controllers\Controller;
use App\Http\Requests\Admin\Manage\TransactionRequest; use App\Http\Requests\Admin\Manage\TransactionRequest;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\Customer;
use App\Models\Order; use App\Models\Order;
use App\Models\User;
use App\Services\Admin\Manage\TransactionService; use App\Services\Admin\Manage\TransactionService;
use App\Services\Admin\Master\CustomerService;
use App\Services\Admin\Master\Product\ProductVariantService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -15,6 +22,8 @@ class TransactionController extends Controller
{ {
public function __construct( public function __construct(
private TransactionService $service, private TransactionService $service,
private ProductVariantService $productVariantService,
private CustomerService $customerService,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
@ -35,14 +44,19 @@ public function index(PaginatedRequest $request): Response
public function create(): Response public function create(): Response
{ {
return Inertia::render('admin/manage/transaction/create', [ 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 public function store(TransactionRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn() => $this->service->create($request->validated()), fn () => $this->service->store($request->validated()),
'Transaksi berhasil ditambahkan.', 'Transaksi berhasil ditambahkan.',
'admin.manage.transactions.index', 'admin.manage.transactions.index',
'admin.manage.transactions.create' 'admin.manage.transactions.create'
@ -53,14 +67,19 @@ public function edit(Order $transaction): Response
{ {
return Inertia::render('admin/manage/transaction/edit', [ return Inertia::render('admin/manage/transaction/edit', [
'transaction' => $this->service->getForEdit($transaction), '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 public function update(TransactionRequest $request, Order $transaction): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn() => $this->service->update($transaction, $request->validated()), fn () => $this->service->update($transaction, $request->validated()),
'Transaksi berhasil diperbarui.', 'Transaksi berhasil diperbarui.',
'admin.manage.transactions.index', 'admin.manage.transactions.index',
'admin.manage.transactions.edit', 'admin.manage.transactions.edit',
@ -71,7 +90,7 @@ public function update(TransactionRequest $request, Order $transaction): Redirec
public function destroy(Order $transaction): RedirectResponse public function destroy(Order $transaction): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn() => $this->service->delete($transaction), fn () => $this->service->destroy($transaction),
'Transaksi berhasil dihapus.', 'Transaksi berhasil dihapus.',
'admin.manage.transactions.index' 'admin.manage.transactions.index'
); );
@ -80,9 +99,21 @@ public function destroy(Order $transaction): RedirectResponse
public function updateStatus(Order $transaction): RedirectResponse public function updateStatus(Order $transaction): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn() => $this->service->updateStatus($transaction, request('status')), fn () => $this->service->updateStatus($transaction, request('status')),
'Status transaksi berhasil diperbarui.', 'Status transaksi berhasil diperbarui.',
'admin.manage.transactions.index' '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 public function store(ProductRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn() => $this->service->create($request->validated()), fn () => $this->service->create($request->validated()),
'Produk berhasil ditambahkan.', 'Produk berhasil ditambahkan.',
'admin.master.products.index', 'admin.master.products.index',
'admin.master.products.create' 'admin.master.products.create'
@ -60,7 +60,7 @@ public function edit(Product $product): Response
public function update(ProductRequest $request, Product $product): RedirectResponse public function update(ProductRequest $request, Product $product): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn() => $this->service->update($product, $request->validated()), fn () => $this->service->update($product, $request->validated()),
'Produk berhasil diperbarui.', 'Produk berhasil diperbarui.',
'admin.master.products.index', 'admin.master.products.index',
'admin.master.products.edit', 'admin.master.products.edit',
@ -71,7 +71,7 @@ public function update(ProductRequest $request, Product $product): RedirectRespo
public function destroy(Product $product): RedirectResponse public function destroy(Product $product): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn() => $this->service->delete($product), fn () => $this->service->delete($product),
'Produk berhasil dihapus.', 'Produk berhasil dihapus.',
'admin.master.products.index' 'admin.master.products.index'
); );

View File

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

View File

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

View File

@ -2,6 +2,8 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Services\AnalysisService; use App\Services\AnalysisService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@ -9,6 +11,8 @@
class AnalysisController extends Controller class AnalysisController extends Controller
{ {
use HasRoleChecks;
public function __construct( public function __construct(
private AnalysisService $service private AnalysisService $service
) {} ) {}
@ -16,7 +20,7 @@ public function __construct(
public function index(Request $request): Response public function index(Request $request): Response
{ {
$user = $request->user(); $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'); $startDate = $request->input('start_date');
$endDate = $request->input('end_date'); $endDate = $request->input('end_date');

View File

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

View File

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

View File

@ -2,7 +2,6 @@
namespace App\Models; namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute; 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 public function getSocialMediaData(): array
{ {
$settings = app(SocialMediaSettings::class); $settings = app(SocialMediaSettings::class);
@ -167,4 +158,13 @@ public function updateHR(array $data): void
$settings->fill($data); $settings->fill($data);
$settings->save(); $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 <?php
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance\Cash;
use App\Enums\CashTransactionType; use App\Enums\CashTransactionType;
use App\Enums\Role;
use App\Models\CashAccount; use App\Models\CashAccount;
use App\Models\CashTransaction; use App\Models\CashTransaction;
use App\Services\Concerns\HandlesCashTransactions; use App\Services\Concerns\HandlesCashTransactions;
@ -11,45 +12,22 @@
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class CashAccountService class CashTransactionService
{ {
use HandlesCashTransactions, RegistersMedia; use HandlesCashTransactions, RegistersMedia;
public function __construct( public function __construct(
private S3PresignedService $s3Service, 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(); $cashAccount = $this->cashAccountService->get();
}
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();
if (! $cashAccount) { if (! $cashAccount) {
return new PaginationLengthAwarePaginator(collect(), 0, $perPage); return new PaginationLengthAwarePaginator(collect(), 0, $perPage);
@ -70,29 +48,6 @@ public function paginatedTransactions(int $perPage = 25, string $search = '', st
return $paginator; 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 public function deposit(array $data): CashTransaction
{ {
$transaction = DB::transaction(fn () => $this->creditCash( $transaction = DB::transaction(fn () => $this->creditCash(
@ -100,12 +55,12 @@ public function deposit(array $data): CashTransaction
description: $data['description'], description: $data['description'],
)); ));
if (! empty($data['receipt_key'])) { if (array_key_exists('receipt_key', $data)) {
$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);
} }
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Setoran Kas Toko', title: 'Setoran Kas Toko',
body: 'Setoran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.', body: 'Setoran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'), url: route('admin.finance.cash-accounts.index'),
@ -122,12 +77,12 @@ public function withdrawal(array $data): CashTransaction
type: CashTransactionType::WITHDRAWAL, type: CashTransactionType::WITHDRAWAL,
)); ));
if (! empty($data['receipt_key'])) { if (array_key_exists('receipt_key', $data)) {
$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);
} }
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Penarikan Kas Toko', title: 'Penarikan Kas Toko',
body: 'Penarikan sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.', body: 'Penarikan sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'), url: route('admin.finance.cash-accounts.index'),
@ -136,7 +91,7 @@ public function withdrawal(array $data): CashTransaction
return $transaction; 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) { $transaction = DB::transaction(function () use ($transaction, $data) {
if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) { 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)) { if (array_key_exists('receipt_key', $data)) {
$currentMedia = $transaction->getFirstMedia('receipts'); $this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
$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);
}
}
} }
return $transaction; return $transaction;
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Transaksi Kas Diperbarui', title: 'Transaksi Kas Diperbarui',
body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.', body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'), url: route('admin.finance.cash-accounts.index'),
@ -197,7 +139,7 @@ public function updateTransaction(CashTransaction $transaction, array $data): Ca
return $transaction; return $transaction;
} }
public function deleteTransaction(CashTransaction $transaction): bool public function destroy(CashTransaction $transaction): bool
{ {
return DB::transaction(function () use ($transaction) { return DB::transaction(function () use ($transaction) {
if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) { if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) {
@ -221,13 +163,18 @@ public function deleteTransaction(CashTransaction $transaction): bool
$cashAccount->update(['balance' => $newBalance]); $cashAccount->update(['balance' => $newBalance]);
$media = $transaction->getFirstMedia('receipts');
if ($media) {
Cache::forget("cash_transaction_receipt_{$media->id}");
}
$transaction->clearMediaCollection('receipts'); $transaction->clearMediaCollection('receipts');
$deleted = $transaction->delete(); $deleted = $transaction->delete();
if ($deleted) { if ($deleted) {
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Transaksi Kas Dihapus', title: 'Transaksi Kas Dihapus',
body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.', body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'), url: route('admin.finance.cash-accounts.index'),
@ -237,4 +184,27 @@ public function deleteTransaction(CashTransaction $transaction): bool
return $deleted; 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; namespace App\Services\Admin\Finance;
use App\Concerns\HasRoleChecks;
use App\Enums\CashTransactionType; use App\Enums\CashTransactionType;
use App\Enums\EmployeeAdvanceStatus; use App\Enums\EmployeeAdvanceStatus;
use App\Enums\Role;
use App\Models\EmployeeAdvance; use App\Models\EmployeeAdvance;
use App\Models\EmployeeAdvancePayment; use App\Models\EmployeeAdvancePayment;
use App\Services\Concerns\HandlesCashTransactions; use App\Services\Concerns\HandlesCashTransactions;
use App\Services\NotificationService; use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class EmployeeAdvanceService class EmployeeAdvanceService
{ {
use HandlesCashTransactions; use HandlesCashTransactions, HasRoleChecks;
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();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
return EmployeeAdvance::query() return EmployeeAdvance::query()
->select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at']) ->select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
->with(['employee.user.userProfile', 'payments.paidBy.userProfile']) ->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($filters['status'] ?? null, fn ($q) => $q->where('status', $filters['status']))
->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%"))
->orderBy($sort, $direction) ->orderBy($sort, $direction)
@ -64,7 +51,7 @@ public function store(array $data): EmployeeAdvance
$employeeAdvance->load('employee.user'); $employeeAdvance->load('employee.user');
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Kasbon Baru', title: 'Kasbon Baru',
body: 'Kasbon sebesar Rp '.number_format($data['amount'], 0, ',', '.')." dari {$employeeAdvance->employee->name} menunggu persetujuan".' oleh '.auth()->user()->full_name.'.', 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'), url: route('admin.finance.employee-advances.index'),
@ -181,7 +168,7 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Kasbon Disetujui', title: 'Kasbon Disetujui',
body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah disetujui'.' oleh '.auth()->user()->full_name.'.', body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah disetujui'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.employee-advances.index'), 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, ',', '.').'.'; : '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( 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', title: $employeeAdvance->status === EmployeeAdvanceStatus::PAID ? 'Kasbon Dibayar' : 'Pembayaran Kasbon',
body: $notificationBody, body: $notificationBody,
url: route('admin.finance.employee-advances.index'), url: route('admin.finance.employee-advances.index'),

View File

@ -3,6 +3,7 @@
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance;
use App\Enums\CashTransactionType; use App\Enums\CashTransactionType;
use App\Enums\Role;
use App\Models\CashAccount; use App\Models\CashAccount;
use App\Models\Expense; use App\Models\Expense;
use App\Services\Concerns\HandlesCashTransactions; use App\Services\Concerns\HandlesCashTransactions;
@ -10,7 +11,6 @@
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -23,15 +23,6 @@ public function __construct(
private S3PresignedService $s3Service, 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 public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
$paginator = Expense::query() $paginator = Expense::query()
@ -46,35 +37,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator; 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 public function store(array $data): Expense
{ {
$expense = DB::transaction(function () use ($data) { $expense = DB::transaction(function () use ($data) {
@ -90,15 +52,15 @@ public function store(array $data): Expense
'description' => $data['description'], 'description' => $data['description'],
]); ]);
if (! empty($data['receipt_key'])) { if (array_key_exists('receipt_key', $data)) {
$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; return $expense;
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Pengeluaran Baru', title: 'Pengeluaran Baru',
body: 'Pengeluaran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.', body: 'Pengeluaran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.expenses.index'), 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)) { if (array_key_exists('receipt_key', $data)) {
$currentMedia = $expense->getFirstMedia('receipts'); $this->syncReceipt($expense, $data['receipt_key'] ?? null, 'receipts', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
$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);
}
}
} }
return $expense; return $expense;
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Pengeluaran Diperbarui', title: 'Pengeluaran Diperbarui',
body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.', body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.expenses.index'), url: route('admin.finance.expenses.index'),
@ -182,7 +125,6 @@ public function destroy(Expense $expense): bool
type: CashTransactionType::DEPOSIT, type: CashTransactionType::DEPOSIT,
); );
// Invalidate receipt cache
$media = $expense->getFirstMedia('receipts'); $media = $expense->getFirstMedia('receipts');
if ($media) { if ($media) {
Cache::forget("expense_receipt_{$media->id}"); Cache::forget("expense_receipt_{$media->id}");
@ -194,7 +136,7 @@ public function destroy(Expense $expense): bool
if ($deleted) { if ($deleted) {
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Pengeluaran Dihapus', title: 'Pengeluaran Dihapus',
body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.', body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.expenses.index'), url: route('admin.finance.expenses.index'),
@ -204,4 +146,32 @@ public function destroy(Expense $expense): bool
return $deleted; 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 <?php
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance\Payroll;
use App\Enums\PayrollAdjustmentType; use App\Enums\PayrollAdjustmentType;
use App\Enums\PayrollStatus; use App\Enums\PayrollStatus;

View File

@ -1,67 +1,29 @@
<?php <?php
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance\Payroll;
use App\Concerns\HasRoleChecks;
use App\Enums\CashTransactionType; use App\Enums\CashTransactionType;
use App\Enums\PayrollPeriodStatus; use App\Enums\PayrollPeriodStatus;
use App\Enums\PayrollStatus; use App\Enums\PayrollStatus;
use App\Enums\Role;
use App\Models\Payroll; use App\Models\Payroll;
use App\Models\PayrollPeriod; use App\Models\PayrollPeriod;
use App\Services\Concerns\HandlesCashTransactions; use App\Services\Concerns\HandlesCashTransactions;
use App\Services\NotificationService; use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class PayrollPeriodService class PayrollPeriodService
{ {
use HandlesCashTransactions; use HandlesCashTransactions, HasRoleChecks;
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();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
return PayrollPeriod::query() return PayrollPeriod::query()
->select(['id', 'year', 'month', 'status', 'closed_at', 'created_at']) ->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()))]) $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_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_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 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()))]); ->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') $query->withCount('payrolls')
->withSum('payrolls', 'total_amount') ->withSum('payrolls', 'total_amount')
->withSum('payrolls', 'bonus_amount') ->withSum('payrolls', 'bonus_amount')
@ -82,15 +44,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage); ->paginate($perPage);
} }
public function getDetail(PayrollPeriod $period): PayrollPeriod public function getCurrentOrCreate(): PayrollPeriod
{ {
return $period->load([ $now = now();
'payrolls' => function ($query) {
$query->with(['employee.user.userProfile', 'payrollAdjustments']) return PayrollPeriod::firstOrCreate(
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))) ['year' => $now->year, 'month' => $now->month],
->orderBy('id'); ['status' => PayrollPeriodStatus::OPEN]
}, );
]);
} }
public function close(PayrollPeriod $period): PayrollPeriod public function close(PayrollPeriod $period): PayrollPeriod
@ -171,7 +132,7 @@ public function pay(Payroll $payroll): Payroll
$employeeUser = $payroll->employee->user ?? null; $employeeUser = $payroll->employee->user ?? null;
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Gaji Dibayar', title: 'Gaji Dibayar',
body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibayar".' oleh '.auth()->user()->full_name.'.', body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibayar".' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.payroll-periods.index'), url: route('admin.finance.payroll-periods.index'),
@ -202,7 +163,7 @@ public function cancel(Payroll $payroll): Payroll
$employeeUser = $payroll->employee->user ?? null; $employeeUser = $payroll->employee->user ?? null;
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Gaji Dibatalkan', title: 'Gaji Dibatalkan',
body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibatalkan".' oleh '.auth()->user()->full_name.'.', body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibatalkan".' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.payroll-periods.index'), url: route('admin.finance.payroll-periods.index'),

View File

@ -2,6 +2,8 @@
namespace App\Services\Admin\HR; namespace App\Services\Admin\HR;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Models\Attendance; use App\Models\Attendance;
use App\Models\Employee; use App\Models\Employee;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
@ -17,16 +19,17 @@
class AttendanceService class AttendanceService
{ {
use RegistersMedia; use HasRoleChecks, RegistersMedia;
public function __construct( public function __construct(
private S3PresignedService $s3Service, private S3PresignedService $s3Service,
private EmployeeService $employeeService,
) {} ) {}
public function getIndexData(int $year, int $month): array public function getIndexData(int $year, int $month): array
{ {
$user = auth()->user(); $user = auth()->user();
$isAdmin = $user->hasAnyRole(['developer', 'owner']); $isAdmin = self::hasAnyRole([Role::DEVELOPER, Role::OWNER]);
$hrSettings = app(HRSettings::class); $hrSettings = app(HRSettings::class);
$employeeId = $isAdmin ? null : $user->employee?->id; $employeeId = $isAdmin ? null : $user->employee?->id;
@ -34,7 +37,7 @@ public function getIndexData(int $year, int $month): array
return [ return [
'attendances' => $this->getByMonth($year, $month, $employeeId), 'attendances' => $this->getByMonth($year, $month, $employeeId),
'leaves' => $this->getLeavesByMonth($year, $month, $employeeId), 'leaves' => $this->getLeavesByMonth($year, $month, $employeeId),
'employees' => $isAdmin ? $this->getAllEmployees() : [], 'employees' => $isAdmin ? $this->employeeService->getAll() : [],
'todayAttendance' => $isAdmin ? null : $this->getToday(), 'todayAttendance' => $isAdmin ? null : $this->getToday(),
'currentYear' => $year, 'currentYear' => $year,
'currentMonth' => $month, '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 public function getByMonth(int $year, int $month, ?int $employeeId = null): Collection
{ {
return Attendance::with(['employee.user.userProfile', 'media']) return Attendance::with(['employee.user.userProfile', 'media'])
@ -82,21 +77,6 @@ public function getToday(): ?array
return $this->getByDate(now()->toDateString()); 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 public function getLeavesByMonth(int $year, int $month, ?int $employeeId = null): Collection
{ {
$startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth(); $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 public function getMonthStats(int $year, int $month, ?int $employeeId = null): array
{ {
$startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth(); $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); 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 private function getMonthStatsForEmployee(int $year, int $month, Carbon $startOfMonth, Carbon $statEnd, int $workingDays, int $employeeId): array
{ {
$attendanceQuery = Attendance::whereYear('attendance_date', $year) $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 private function formatAttendance(Attendance $attendance): array
{ {
$toArray = $attendance->toArray(); $toArray = $attendance->toArray();

View File

@ -2,46 +2,17 @@
namespace App\Services\Admin\HR; namespace App\Services\Admin\HR;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Models\Employee;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class EmployeeService class EmployeeService
{ {
private const ADMIN_ROLES = ['developer', 'owner', 'direktur', 'admin-toko']; use HasRoleChecks;
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();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator 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']), 'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
'roles' => fn ($q) => $q->select(['id', 'name']), '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'); $userRoles = auth()->user()->roles->pluck('name');
$q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles)); $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($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($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(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); ->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 public function store(array $data): User
{ {
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {

View File

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

View File

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

View File

@ -2,6 +2,7 @@
namespace App\Services\Admin\Manage; namespace App\Services\Admin\Manage;
use App\Enums\Role;
use App\Models\Purchase; use App\Models\Purchase;
use App\Models\PurchaseItem; use App\Models\PurchaseItem;
use App\Models\RawMaterial; 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') { 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) { return DB::transaction(function () use ($data) {
$now = now(); $now = now();
@ -219,7 +220,7 @@ private function createFromExisting(array $data): Purchase
} }
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Bahan Baku'], roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Belanja Baru', title: 'Belanja Baru',
body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.', body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.purchases.index'), 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) { return DB::transaction(function () use ($data) {
$rawMaterial = RawMaterial::create([ $rawMaterial = RawMaterial::create([
@ -309,7 +310,7 @@ private function createNew(array $data): Purchase
} }
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Bahan Baku'], roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Belanja Baru', title: 'Belanja Baru',
body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.', body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.purchases.index'), 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) { return DB::transaction(function () use ($purchase) {
$purchase->load('purchaseItems.rawMaterialPrice'); $purchase->load('purchaseItems.rawMaterialPrice');

View File

@ -4,6 +4,7 @@
use App\Enums\PriceType; use App\Enums\PriceType;
use App\Enums\ProductStockQuality; use App\Enums\ProductStockQuality;
use App\Enums\Role;
use App\Models\Product; use App\Models\Product;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Models\Restock; use App\Models\Restock;
@ -30,14 +31,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->with([ ->with([
'createdBy:id', 'createdBy:id',
'createdBy.userProfile:id,user_id,full_name', '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']) ->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)'), ->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:id,product_id,name,stock,reject_stock,retail_stock',
'restockItems.productVariant.product:id,name', 'restockItems.productVariant.product:id,name',
]) ])
->when($search, function ($q) use ($search) { ->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}%"); ->orWhere('notes', 'like', "%{$search}%");
}) })
->orderBy($sort, $direction) ->orderBy($sort, $direction)
@ -59,65 +60,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator; return $paginator;
} }
public function getForCreate(): array public function store(array $data): Restock
{
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
{ {
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
$now = now(); $now = now();
@ -143,9 +86,9 @@ public function create(array $data): Restock
$this->syncPhoto($restock, $data); $this->syncPhoto($restock, $data);
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
title: 'Restock Baru', 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'), 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) { return DB::transaction(function () use ($restock) {
$restock->load('restockItems'); $restock->load('restockItems');
@ -219,7 +162,7 @@ private function buildItemRows(array $items, string $stockType, $now, int &$subt
->get() ->get()
->mapWithKeys(function (ProductVariant $variant) use ($priceType) { ->mapWithKeys(function (ProductVariant $variant) use ($priceType) {
$price = $variant->productPrices $price = $variant->productPrices
->first(fn ($p) => $p->type === $priceType); ->first(fn($p) => $p->type === $priceType);
return [$variant->id => $price?->price ?? 0]; return [$variant->id => $price?->price ?? 0];
}); });

View File

@ -7,6 +7,7 @@
use App\Enums\PaymentType; use App\Enums\PaymentType;
use App\Enums\PriceType; use App\Enums\PriceType;
use App\Enums\ProductStockQuality; use App\Enums\ProductStockQuality;
use App\Enums\Role;
use App\Models\Customer; use App\Models\Customer;
use App\Models\Order; use App\Models\Order;
use App\Models\OrderItem; use App\Models\OrderItem;
@ -143,83 +144,7 @@ public function getFilterOptions(): array
]; ];
} }
public function getForCreate(): array public function store(array $data): Order
{
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
{ {
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
$now = now(); $now = now();
@ -268,7 +193,7 @@ public function create(array $data): Order
} }
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
title: 'Transaksi Baru', title: 'Transaksi Baru',
body: 'Transaksi ' . $order->order_number . ' sebesar Rp ' . number_format($totalAmount, 0, ',', '.') . ' berhasil dicatat oleh ' . auth()->user()->full_name . '.', body: 'Transaksi ' . $order->order_number . ' sebesar Rp ' . number_format($totalAmount, 0, ',', '.') . ' berhasil dicatat oleh ' . auth()->user()->full_name . '.',
url: route('admin.manage.transactions.index'), 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) { return DB::transaction(function () use ($order) {
$order->load('orderItems'); $order->load('orderItems');

View File

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

View File

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

View File

@ -2,75 +2,28 @@
namespace App\Services\Admin\Master\Product; namespace App\Services\Admin\Master\Product;
use App\Concerns\HasRoleChecks;
use App\Enums\ProductStatus; use App\Enums\ProductStatus;
use App\Enums\Role;
use App\Models\Product; use App\Models\Product;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use App\Services\StockMutationService; use App\Services\StockMutationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class ProductService class ProductService
{ {
use HasRoleChecks;
public function __construct( public function __construct(
private ProductVariantService $variantService, private ProductVariantService $variantService,
private S3PresignedService $s3Service, private S3PresignedService $s3Service,
private StockMutationService $stockMutationService, 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 public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
$paginator = Product::query() $paginator = Product::query()
@ -105,9 +58,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator; 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) ? ($data['status'] ?? ProductStatus::ACTIVE)
: ProductStatus::PENDING; : ProductStatus::PENDING;
@ -182,7 +135,7 @@ public function create(array $data): Product
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Baru', title: 'Produk Baru',
body: "Produk \"{$product->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.', body: "Produk \"{$product->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'), 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) { $product = DB::transaction(function () use ($product, $data) {
// Auto-resubmit: non-verifier editing rejected product → status becomes pending // Auto-resubmit: non-verifier editing rejected product → status becomes pending
$newStatus = $data['status'] ?? $product->status; $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; $newStatus = ProductStatus::PENDING;
} }
@ -433,7 +386,7 @@ public function update(Product $product, array $data): Product
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Diperbarui', title: 'Produk Diperbarui',
body: "Produk \"{$product->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', body: "Produk \"{$product->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
@ -442,7 +395,7 @@ public function update(Product $product, array $data): Product
return $product; return $product;
} }
public function delete(Product $product): bool public function destroy(Product $product): bool
{ {
$this->assertNotPending($product); $this->assertNotPending($product);
@ -459,7 +412,7 @@ public function delete(Product $product): bool
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Dihapus', title: 'Produk Dihapus',
body: "Produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', body: "Produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
@ -484,7 +437,7 @@ public function approve(Product $product): void
]); ]);
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer'], roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Disetujui', title: 'Produk Disetujui',
body: "Produk \"{$product->name}\" telah disetujui oleh ".auth()->user()->full_name.'.', body: "Produk \"{$product->name}\" telah disetujui oleh ".auth()->user()->full_name.'.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
@ -500,7 +453,7 @@ public function reject(Product $product, string $reason = ''): void
]); ]);
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer'], roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Ditolak', title: 'Produk Ditolak',
body: "Produk \"{$product->name}\" telah ditolak oleh ".auth()->user()->full_name.'.', body: "Produk \"{$product->name}\" telah ditolak oleh ".auth()->user()->full_name.'.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
@ -516,10 +469,19 @@ public function resubmit(Product $product): void
]); ]);
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer'], roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Diajukan Ulang', title: 'Produk Diajukan Ulang',
body: "Produk \"{$product->name}\" telah diajukan ulang oleh ".auth()->user()->full_name.'.', body: "Produk \"{$product->name}\" telah diajukan ulang oleh ".auth()->user()->full_name.'.',
url: route('admin.master.products.index'), 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; namespace App\Services\Admin\Master\Product;
use App\Concerns\HasRoleChecks;
use App\Enums\PriceType;
use App\Enums\ProductStatus; use App\Enums\ProductStatus;
use App\Enums\Role;
use App\Models\Product; use App\Models\Product;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
@ -14,27 +17,64 @@
class ProductVariantService class ProductVariantService
{ {
use RegistersMedia; use HasRoleChecks, RegistersMedia;
public function __construct( public function __construct(
private S3PresignedService $s3Service, private S3PresignedService $s3Service,
private StockMutationService $stockMutationService, 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()) { return Product::query()
throw ValidationException::withMessages([ ->select(['id', 'name', 'status'])
'status' => 'Produk sedang menunggu verifikasi dan tidak dapat diubah.', ->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 public function getForEdit(ProductVariant $variant): array
@ -102,7 +142,7 @@ public function update(ProductVariant $variant, array $data): ProductVariant
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Diperbarui', title: 'Varian Diperbarui',
body: "Varian \"{$variant->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', body: "Varian \"{$variant->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
@ -111,7 +151,7 @@ public function update(ProductVariant $variant, array $data): ProductVariant
return $variant->fresh(); return $variant->fresh();
} }
public function delete(Product $product, ProductVariant $variant): bool public function destroy(Product $product, ProductVariant $variant): bool
{ {
$this->assertNotPending($product); $this->assertNotPending($product);
@ -123,7 +163,7 @@ public function delete(Product $product, ProductVariant $variant): bool
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Dihapus', title: 'Varian Dihapus',
body: "Varian \"{$variant->name}\" dari produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', body: "Varian \"{$variant->name}\" dari produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
@ -184,7 +224,7 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Transfer Stok', title: 'Transfer Stok',
body: "{$quantity} unit dari varian \"{$variant->name}\" berhasil ditransfer dari stok bagus ke stok ecer".' oleh '.auth()->user()->full_name.'.', 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'), url: route('admin.master.products.index'),
@ -192,4 +232,13 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
return $variant->fresh(); 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\Concerns\RegistersMedia;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class RawMaterialService class RawMaterialService
@ -18,27 +17,6 @@ public function __construct(
private S3PresignedService $s3Service, 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 public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
$paginator = RawMaterial::query() $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 private function registerPhoto(RawMaterialPrice $price, string $s3Key): void
{ {
$this->registerMedia( $this->registerMedia(

View File

@ -2,6 +2,7 @@
namespace App\Services\Admin\Master\RawMaterial; namespace App\Services\Admin\Master\RawMaterial;
use App\Enums\Role;
use App\Models\RawMaterial; use App\Models\RawMaterial;
use App\Models\RawMaterialPrice; use App\Models\RawMaterialPrice;
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
@ -17,6 +18,26 @@ public function __construct(
private S3PresignedService $s3Service, 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 public function getForEdit(RawMaterialPrice $variant): array
{ {
$variant->load('media'); $variant->load('media');
@ -61,7 +82,7 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Diperbarui', title: 'Varian Diperbarui',
body: "Varian \"{$variant->variant}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', body: "Varian \"{$variant->variant}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'), url: route('admin.master.raw-materials.index'),
@ -70,7 +91,7 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
return $variant->fresh(); 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) { $result = DB::transaction(function () use ($variant) {
$variant->clearMediaCollection('photos'); $variant->clearMediaCollection('photos');
@ -79,7 +100,7 @@ public function delete(RawMaterial $rawMaterial, RawMaterialPrice $variant): boo
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Dihapus', title: 'Varian Dihapus',
body: "Varian \"{$variant->variant}\" dari bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', body: "Varian \"{$variant->variant}\" dari bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'), url: route('admin.master.raw-materials.index'),

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@
namespace App\Services\Concerns; namespace App\Services\Concerns;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Spatie\MediaLibrary\MediaCollections\Models\Media; 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; namespace App\Services;
use App\Enums\Role;
use App\Models\User; use App\Models\User;
use App\Notifications\WebPushNotification; use App\Notifications\WebPushNotification;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class NotificationService class NotificationService
{ {
/**
* @param array<Role> $roles
*/
public static function notify(array $roles, string $title, string $body, string $url, ?User $additionalUser = null): void public static function notify(array $roles, string $title, string $body, string $url, ?User $additionalUser = null): void
{ {
$roleLabels = array_map(fn (Role $role) => $role->label(), $roles);
$users = User::query() $users = User::query()
->select(['id']) ->select(['id'])
->where('is_active', true) ->where('is_active', true)
->whereHas('roles', fn ($q) => $q->whereIn('name', $roles)) ->whereHas('roles', fn ($q) => $q->whereIn('name', $roleLabels))
->get(); ->get();
if ($additionalUser && ! $users->contains('id', $additionalUser->id)) { if ($additionalUser && ! $users->contains('id', $additionalUser->id)) {

View File

@ -17,6 +17,16 @@ class StockMutationService
'retail_stock' => 'retail', '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 public function recordInitial(Model $model, array $stockData, string $description = 'Stok awal'): void
{ {
$userId = auth()->id(); $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 <?php
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
return new class extends Migration return new class extends Migration

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -59,13 +59,25 @@ type CartLine = {
type Props = { type Props = {
transaction: TransactionForEdit; 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']; const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee'];
export default function TransactionEdit({ transaction, data }: Props) { export default function TransactionEdit({
const { products, customers, employees, channelOptions, paymentTypeOptions, priceTypeOptions } = data; transaction,
products,
customers,
employees,
channelOptions,
paymentTypeOptions,
priceTypeOptions,
}: Props) {
const [stockType, setStockType] = useState<'good' | 'reject'>( const [stockType, setStockType] = useState<'good' | 'reject'>(
transaction.stock_type === 'reject' ? 'reject' : 'good', 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\Product;
use App\Models\User; use App\Models\User;
use App\Notifications\WebPushNotification; 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\EmployeeAdvanceService;
use App\Services\Admin\Finance\ExpenseService; 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\AttendanceService;
use App\Services\Admin\HR\LeaveRequestService; use App\Services\Admin\HR\LeaveRequestService;
use App\Services\Admin\Master\Product\ProductService; 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(); Notification::fake();
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]); $cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
@ -293,7 +293,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
$this->actingAs($developer); $this->actingAs($developer);
$service = new CashAccountService; $service = new CashTransactionService;
$service->deposit([ $service->deposit([
'amount' => 500000, 'amount' => 500000,
'description' => 'Setoran modal', 'description' => 'Setoran modal',
@ -306,7 +306,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
expect($developer->notifications()->first()->title)->toBe('Setoran Kas Toko'); 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(); Notification::fake();
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]); $cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
@ -315,7 +315,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
$this->actingAs($developer); $this->actingAs($developer);
$service = new CashAccountService; $service = new CashTransactionService;
$service->withdrawal([ $service->withdrawal([
'amount' => 200000, 'amount' => 200000,
'description' => 'Penarikan kas', 'description' => 'Penarikan kas',
@ -327,7 +327,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
expect($developer->notifications()->first()->title)->toBe('Penarikan Kas Toko'); 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(); Notification::fake();
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]); $cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
@ -336,7 +336,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
$this->actingAs($developer); $this->actingAs($developer);
$service = new CashAccountService; $service = new CashTransactionService;
$transaction = $service->deposit([ $transaction = $service->deposit([
'amount' => 500000, 'amount' => 500000,
'description' => 'Initial deposit', 'description' => 'Initial deposit',
@ -344,7 +344,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
Notification::fake(); Notification::fake();
$service->updateTransaction($transaction, [ $service->update($transaction, [
'amount' => 600000, 'amount' => 600000,
'description' => 'Updated deposit', 'description' => 'Updated deposit',
]); ]);
@ -355,7 +355,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
expect($developer->notifications()->where('title', 'Transaksi Kas Diperbarui')->count())->toBe(1); 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(); Notification::fake();
$cashAccount = CashAccount::factory()->create(['balance' => 1000000]); $cashAccount = CashAccount::factory()->create(['balance' => 1000000]);
@ -364,7 +364,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
$this->actingAs($developer); $this->actingAs($developer);
$service = new CashAccountService; $service = new CashTransactionService;
$transaction = $service->deposit([ $transaction = $service->deposit([
'amount' => 500000, 'amount' => 500000,
'description' => 'To be deleted', 'description' => 'To be deleted',
@ -372,7 +372,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array
Notification::fake(); Notification::fake();
$service->deleteTransaction($transaction); $service->destroy($transaction);
Notification::assertSentTo($developer, WebPushNotification::class); Notification::assertSentTo($developer, WebPushNotification::class);
Notification::assertSentTo($owner, 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]); CashAccount::factory()->create(['balance' => 5000000]);
$owner = notifUserWithRole('Owner'); $owner = notifUserWithRole('Owner');
$this->actingAs($owner); $this->actingAs($owner);
$service = new CashAccountService; $service = new CashTransactionService;
$service->deposit([ $service->deposit([
'amount' => 250000, 'amount' => 250000,
'description' => 'Test deposit', 'description' => 'Test deposit',