From 7a1c4efe4ab8776e515f1cbef0542932d3f8d2ed Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Sun, 28 Jun 2026 16:12:15 +0700 Subject: [PATCH] feat: enhance controller best practices and introduce new Cutting and Order management features with updated services and validation handling --- BEST_PRACTICE.md | 115 ++++++++++++++++++ app/Enums/OwnerVerificationAction.php | 4 +- app/Enums/PriceType.php | 4 +- app/Enums/ProductStockQuality.php | 4 +- .../Admin/Account/AppearanceController.php | 5 +- .../Admin/Account/ProfileController.php | 14 +-- .../Controllers/Admin/AnalysisController.php | 5 +- .../Admin/Finance/CashController.php | 36 +++--- .../Finance/EmployeeAdvanceController.php | 27 ++-- .../Admin/Finance/PayrollController.php | 17 +-- .../Admin/Hr/AttendanceController.php | 35 +----- .../Admin/Hr/EmployeeController.php | 59 ++++----- .../Admin/Hr/LeaveRequestController.php | 27 ++-- .../{ => Cutting}/CuttingController.php | 23 ++-- .../CuttingDraftItemController.php | 2 +- .../Manage/{ => Order}/OrderController.php | 32 +++-- .../{ => Order}/OrderDraftItemController.php | 2 +- .../{ => Purchase}/PurchaseController.php | 2 +- .../PurchaseDraftItemController.php | 2 +- .../Manage/{ => Stock}/StockController.php | 2 +- .../StockRetailController.php} | 14 +-- .../Admin/Manage/StokOpnameController.php | 42 ++----- .../Admin/Master/CategoryController.php | 2 +- .../Admin/Master/CustomerController.php | 22 ++-- .../Admin/Master/ProductController.php | 33 ++--- .../Admin/Master/RawMaterialController.php | 30 ++--- .../Admin/NotificationController.php | 54 ++------ .../Admin/System/RoleController.php | 31 +---- .../Admin/System/SettingController.php | 16 +-- .../Concerns/ParsesDataTableQuery.php | 7 -- app/Http/Controllers/HomeController.php | 64 +--------- .../Finance/ApproveEmployeeAdvanceRequest.php | 19 +++ .../Finance/DestroyEmployeeAdvanceRequest.php | 19 +++ .../Finance/PayEmployeeAdvanceRequest.php | 21 ++++ .../Admin/Hr/ApproveLeaveRequestRequest.php | 19 +++ ...est.php => StockRetailTransferRequest.php} | 2 +- .../Manage/StokOpnameAutoSaveRequest.php | 32 +++++ .../Requests/Admin/Master/ProductRequest.php | 4 +- app/Models/Order.php | 4 +- app/Models/ProductVariant.php | 2 +- ...EcerHistory.php => StockRetailHistory.php} | 8 +- .../Finance/EmployeeAdvanceService.php | 18 +++ app/Services/Hr/AttendanceService.php | 43 +++++++ app/Services/Hr/EmployeeService.php | 18 +++ app/Services/Hr/LeaveRequestService.php | 30 +++++ app/Services/Manage/CuttingService.php | 20 +++ app/Services/Manage/OrderService.php | 33 +++-- .../Manage/OwnerVerificationService.php | 14 ++- ...EcerService.php => StockRetailService.php} | 32 ++--- app/Services/Master/CustomerService.php | 5 + app/Services/Master/ProductService.php | 30 +++-- app/Services/Master/RawMaterialService.php | 13 ++ app/Services/System/AnalysisService.php | 10 +- app/Services/System/HomepageService.php | 77 ++++++++++++ app/Services/System/NotificationService.php | 69 +++++++++++ app/Services/System/RoleService.php | 26 ++++ .../VerificationChangeFormatter.php | 4 +- ...0_100003_create_product_variants_table.php | 2 +- ...0_create_stock_retail_histories_table.php} | 8 +- resources/js/components/card/ProductCard.vue | 2 +- .../js/components/modal/ProductModal.vue | 4 +- ...Modal.vue => StockRetailTransferModal.vue} | 14 +-- resources/js/constants/stock-quality.ts | 2 +- resources/js/pages/Home.vue | 2 +- .../admin/manage/orders/form/OrderPosForm.vue | 22 ++-- .../js/pages/admin/master/products/Edit.vue | 2 +- .../master/products/form/ProductForm.vue | 18 +-- .../products/table/ProductGroupedTable.vue | 16 +-- resources/js/types/order.ts | 2 +- resources/js/types/product.ts | 8 +- routes/web.php | 20 +-- 71 files changed, 894 insertions(+), 532 deletions(-) rename app/Http/Controllers/Admin/Manage/{ => Cutting}/CuttingController.php (81%) rename app/Http/Controllers/Admin/Manage/{ => Cutting}/CuttingDraftItemController.php (96%) rename app/Http/Controllers/Admin/Manage/{ => Order}/OrderController.php (87%) rename app/Http/Controllers/Admin/Manage/{ => Order}/OrderDraftItemController.php (96%) rename app/Http/Controllers/Admin/Manage/{ => Purchase}/PurchaseController.php (98%) rename app/Http/Controllers/Admin/Manage/{ => Purchase}/PurchaseDraftItemController.php (94%) rename app/Http/Controllers/Admin/Manage/{ => Stock}/StockController.php (96%) rename app/Http/Controllers/Admin/Manage/{StockEcerController.php => Stock/StockRetailController.php} (57%) create mode 100644 app/Http/Requests/Admin/Finance/ApproveEmployeeAdvanceRequest.php create mode 100644 app/Http/Requests/Admin/Finance/DestroyEmployeeAdvanceRequest.php create mode 100644 app/Http/Requests/Admin/Finance/PayEmployeeAdvanceRequest.php create mode 100644 app/Http/Requests/Admin/Hr/ApproveLeaveRequestRequest.php rename app/Http/Requests/Admin/Manage/{StockEcerTransferRequest.php => StockRetailTransferRequest.php} (94%) create mode 100644 app/Http/Requests/Admin/Manage/StokOpnameAutoSaveRequest.php rename app/Models/{StockEcerHistory.php => StockRetailHistory.php} (79%) rename app/Services/Manage/{StockEcerService.php => StockRetailService.php} (81%) create mode 100644 app/Services/System/HomepageService.php create mode 100644 app/Services/System/NotificationService.php rename database/migrations/{2026_06_28_094400_create_stock_ecer_histories_table.php => 2026_06_28_094400_create_stock_retail_histories_table.php} (74%) rename resources/js/components/modal/{StockEcerTransferModal.vue => StockRetailTransferModal.vue} (93%) diff --git a/BEST_PRACTICE.md b/BEST_PRACTICE.md index c8135f1..686865a 100644 --- a/BEST_PRACTICE.md +++ b/BEST_PRACTICE.md @@ -169,6 +169,121 @@ ### Model Eloquent } ``` +### Controller + +- **Tugas Controller:** + - Controller hanya menangani **request dan response**. + - Semua logika bisnis (validasi aturan bisnis, query kompleks, transformasi data, authorization bisnis) **wajib** dipindahkan ke **Service**. + - Controller memanggil service, lalu mengembalikan `Inertia::render()`, `redirect()`, atau `response()->json()`. + +- **Urutan Method:** + 1. `index` + 2. `create` + 3. `store` + 4. `show` (jika ada) + 5. `edit` + 6. `update` + 7. `destroy` + 8. Method tambahan (custom action) ditempatkan **di bawah** `destroy`. + +- **Form Request:** + - **Selalu** gunakan Form Request untuk validasi, sekecil apapun validasinya. + - Authorization di Form Request menggunakan `$this->user()`, bukan helper global. + +- **Authenticated User:** + - **Selalu** ambil user dari `$request->user()` pada method controller. + - **Jangan** gunakan `request()->user()`, `auth()->user()`, atau `Auth::user()`. + +- **Docblock:** + - **Jangan** gunakan PHPDoc/docblock pada controller. + +- **Penamaan:** + - Variable, method, file, dan elemen sistem lainnya **wajib** menggunakan **Bahasa Inggris**. + - Teks yang ditampilkan ke user (flash message, label UI) menggunakan **Bahasa Indonesia**. + - Kecuali nama modul yang memang sudah Bahasa Inggris (misal: `customer`, `cutting`, `order`). + +- **Flash Message:** + - Gunakan helper dari trait `FlashesEntityMessage` untuk pesan standar CRUD: + - `flashCreated($entity)` → "{entity} berhasil ditambahkan." + - `flashUpdated($entity)` → "{entity} berhasil diperbarui." + - `flashDeleted($entity)` → "{entity} berhasil dihapus." + - `flashStatusUpdated($entity)` → "Status {entity} berhasil diperbarui." + - Gunakan `flashSuccess($message)` **hanya** untuk pesan custom (verifikasi owner, approve/reject, transisi status, dll.). + +- **Struktur Folder:** + - Jika satu modul memiliki **lebih dari satu controller**, kelompokkan dalam subfolder modul. + - **Contoh:** + ``` + app/Http/Controllers/Admin/Manage/ + Cutting/ + CuttingController.php + CuttingDraftItemController.php + Order/ + OrderController.php + OrderDraftItemController.php + Purchase/ + PurchaseController.php + PurchaseDraftItemController.php + Stock/ + StockController.php + StockRetailController.php + StokOpnameController.php ← modul dengan 1 controller tetap di level parent + OwnerVerificationController.php + ``` + +- **Contoh:** + ```php + namespace App\Http\Controllers\Admin\Manage\Order; + + use App\Http\Controllers\Concerns\FlashesEntityMessage; + use App\Http\Controllers\Concerns\ParsesDataTableQuery; + use App\Http\Controllers\Controller; + use App\Http\Requests\Admin\Manage\OrderRequest; + use App\Models\Order; + use App\Services\Manage\OrderService; + use Illuminate\Http\RedirectResponse; + use Illuminate\Http\Request; + use Inertia\Inertia; + use Inertia\Response; + + class OrderController extends Controller + { + use FlashesEntityMessage, ParsesDataTableQuery; + + public function __construct( + private readonly OrderService $orderService, + ) {} + + public function index(Request $request): Response + { + $tableQuery = $this->parseDataTableQuery($request); + + return Inertia::render('admin/manage/orders/Index', [ + 'orders' => $this->orderService->paginateForIndex($tableQuery, $request->user()), + 'filters' => $this->dataTableFilters($tableQuery), + ]); + } + + public function store(OrderRequest $request): RedirectResponse + { + $this->orderService->create($request->validated(), $request->user()); + + $this->flashCreated('Pesanan'); + + return redirect()->route('admin.manage.orders.index'); + } + + public function destroy(Order $order): RedirectResponse + { + $this->orderService->delete($order); + + $this->flashDeleted('Pesanan'); + + return redirect()->route('admin.manage.orders.index'); + } + } + ``` + ## Database & Migrasi ### Struktur File Migrasi diff --git a/app/Enums/OwnerVerificationAction.php b/app/Enums/OwnerVerificationAction.php index fea8c8e..c871c11 100644 --- a/app/Enums/OwnerVerificationAction.php +++ b/app/Enums/OwnerVerificationAction.php @@ -13,7 +13,7 @@ enum OwnerVerificationAction: string case DELETE = 'delete'; case TOGGLE_STATUS = 'toggle_status'; case STOCK_VERIFY = 'stock_verify'; - case STOCK_ECER_TRANSFER = 'stock_ecer_transfer'; + case STOCK_RETAIL_TRANSFER = 'stock_retail_transfer'; public function label(): string { @@ -23,7 +23,7 @@ public function label(): string self::DELETE => 'Hapus', self::TOGGLE_STATUS => 'Ubah Status', self::STOCK_VERIFY => 'Verifikasi Stok', - self::STOCK_ECER_TRANSFER => 'Transfer Stok Ecer', + self::STOCK_RETAIL_TRANSFER => 'Transfer Stok Ecer', }; } } diff --git a/app/Enums/PriceType.php b/app/Enums/PriceType.php index bc23ec6..da9d533 100644 --- a/app/Enums/PriceType.php +++ b/app/Enums/PriceType.php @@ -12,7 +12,7 @@ enum PriceType: string case AGENT = 'agent'; case SUB_AGENT = 'sub_agent'; case GROSIR = 'grosir'; - case ECER = 'ecer'; + case RETAIL = 'retail'; case TIKTOK = 'tiktok'; case SHOPEE = 'shopee'; case HARGA_MODAL = 'harga_modal'; @@ -24,7 +24,7 @@ public function label(): string self::AGENT => 'Agen', self::SUB_AGENT => 'Sub Agen', self::GROSIR => 'Grosir', - self::ECER => 'Eceran', + self::RETAIL => 'Eceran', self::TIKTOK => 'TikTok', self::SHOPEE => 'Shopee', self::HARGA_MODAL => 'Harga Modal', diff --git a/app/Enums/ProductStockQuality.php b/app/Enums/ProductStockQuality.php index cfe5282..d29c1ea 100644 --- a/app/Enums/ProductStockQuality.php +++ b/app/Enums/ProductStockQuality.php @@ -10,14 +10,14 @@ enum ProductStockQuality: string case GOOD = 'good'; case REJECT = 'reject'; - case ECER = 'ecer'; + case RETAIL = 'retail'; public function label(): string { return match ($this) { self::GOOD => 'Bagus', self::REJECT => 'Reject', - self::ECER => 'Eceran', + self::RETAIL => 'Eceran', }; } } diff --git a/app/Http/Controllers/Admin/Account/AppearanceController.php b/app/Http/Controllers/Admin/Account/AppearanceController.php index 0be19ea..68e18b6 100644 --- a/app/Http/Controllers/Admin/Account/AppearanceController.php +++ b/app/Http/Controllers/Admin/Account/AppearanceController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Admin\Account\UpdateAppearanceRequest; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; use Symfony\Component\HttpFoundation\Cookie; @@ -14,10 +15,10 @@ class AppearanceController extends Controller { use FlashesEntityMessage; - public function edit(): Response + public function edit(Request $request): Response { return Inertia::render('admin/account/Appearance', [ - 'appearance' => request()->cookie('appearance', 'system'), + 'appearance' => $request->cookie('appearance', 'system'), ]); } diff --git a/app/Http/Controllers/Admin/Account/ProfileController.php b/app/Http/Controllers/Admin/Account/ProfileController.php index 4eebb03..12e7506 100644 --- a/app/Http/Controllers/Admin/Account/ProfileController.php +++ b/app/Http/Controllers/Admin/Account/ProfileController.php @@ -7,8 +7,9 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Admin\Account\UpdateProfileRequest; use App\Services\Account\ProfileService; -use App\Support\Media\MediaPresenter; +use App\Services\Hr\EmployeeService; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -18,18 +19,17 @@ class ProfileController extends Controller public function __construct( private readonly ProfileService $profileService, + private readonly EmployeeService $employeeService, ) {} - public function edit(): Response + public function edit(Request $request): Response { - $user = auth()->user()->load('profile'); + $editData = $this->employeeService->findForEdit($request->user()); return Inertia::render('admin/account/Profile', [ 'genders' => Gender::selectOptions(), - 'user' => $user, - 'profilePhoto' => $user->profile - ? MediaPresenter::first($user->profile, 'profile_photo') - : null, + 'user' => $editData['employee'], + 'profilePhoto' => $editData['profilePhoto'], ]); } diff --git a/app/Http/Controllers/Admin/AnalysisController.php b/app/Http/Controllers/Admin/AnalysisController.php index 4c076a5..790552d 100644 --- a/app/Http/Controllers/Admin/AnalysisController.php +++ b/app/Http/Controllers/Admin/AnalysisController.php @@ -2,7 +2,6 @@ namespace App\Http\Controllers\Admin; -use App\Enums\Role; use App\Http\Controllers\Controller; use App\Services\System\AnalysisService; use Carbon\Carbon; @@ -20,9 +19,7 @@ public function index(Request $request): Response { $startDate = $request->query('start_date') ? Carbon::parse($request->query('start_date'))->startOfDay() : null; $endDate = $request->query('end_date') ? Carbon::parse($request->query('end_date'))->endOfDay() : null; - $user = $request->user(); - $isManager = $user?->hasAnyRole([Role::OWNER->value, Role::DEVELOPER->value, Role::DIREKTUR->value]) ?? false; return Inertia::render('admin/Analysis', [ 'filters' => [ @@ -31,7 +28,7 @@ public function index(Request $request): Response ], 'attendance' => $this->analysisService->getAttendance(), 'myAttendance' => $user ? $this->analysisService->getMyAttendance($user, $startDate, $endDate) : null, - 'isManager' => $isManager, + 'isManager' => $this->analysisService->isManager($user), 'cashOverview' => $this->analysisService->getCashOverview(), 'rawMaterialStock' => $this->analysisService->getRawMaterialStock(), 'productStock' => $this->analysisService->getProductStock(), diff --git a/app/Http/Controllers/Admin/Finance/CashController.php b/app/Http/Controllers/Admin/Finance/CashController.php index a9806da..6fee9d8 100644 --- a/app/Http/Controllers/Admin/Finance/CashController.php +++ b/app/Http/Controllers/Admin/Finance/CashController.php @@ -38,6 +38,24 @@ public function index(Request $request): Response ]); } + public function update(UpdateCashTransactionRequest $request, CashTransaction $cashTransaction): RedirectResponse + { + $this->cashService->updateDeposit($cashTransaction, $request->validated()); + + $this->flashUpdated('Setor kas'); + + return redirect()->route('admin.finance.cash.index'); + } + + public function destroy(CashTransaction $cashTransaction): RedirectResponse + { + $this->cashService->deleteTransaction($cashTransaction); + + $this->flashDeleted('Setor kas'); + + return redirect()->route('admin.finance.cash.index'); + } + public function deposit(DepositRequest $request): RedirectResponse { $cashAccount = $this->cashService->getDefaultAccount(); @@ -59,22 +77,4 @@ public function withdraw(WithdrawRequest $request): RedirectResponse return redirect()->route('admin.finance.cash.index'); } - - public function update(UpdateCashTransactionRequest $request, CashTransaction $cashTransaction): RedirectResponse - { - $this->cashService->updateDeposit($cashTransaction, $request->validated()); - - $this->flashSuccess('Setor kas berhasil diperbarui.'); - - return redirect()->route('admin.finance.cash.index'); - } - - public function destroy(CashTransaction $cashTransaction): RedirectResponse - { - $this->cashService->deleteTransaction($cashTransaction); - - $this->flashSuccess('Setor kas berhasil dihapus.'); - - return redirect()->route('admin.finance.cash.index'); - } } diff --git a/app/Http/Controllers/Admin/Finance/EmployeeAdvanceController.php b/app/Http/Controllers/Admin/Finance/EmployeeAdvanceController.php index 3b5d709..3ecff39 100644 --- a/app/Http/Controllers/Admin/Finance/EmployeeAdvanceController.php +++ b/app/Http/Controllers/Admin/Finance/EmployeeAdvanceController.php @@ -2,11 +2,13 @@ namespace App\Http\Controllers\Admin\Finance; -use App\Enums\Permission; use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\Finance\ApproveEmployeeAdvanceRequest; +use App\Http\Requests\Admin\Finance\DestroyEmployeeAdvanceRequest; use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest; +use App\Http\Requests\Admin\Finance\PayEmployeeAdvanceRequest; use App\Http\Requests\Admin\Finance\RejectEmployeeAdvanceRequest; use App\Models\EmployeeAdvance; use App\Services\Finance\EmployeeAdvanceService; @@ -27,15 +29,10 @@ public function index(Request $request): Response { $tableQuery = $this->parseDataTableQuery($request); $status = $request->string('status')->toString(); - $user = $request->user(); + $pageData = $this->employeeAdvanceService->indexPageData($tableQuery, $request->user(), $status); return Inertia::render('admin/finance/employee-advances/Index', [ - 'employeeAdvances' => $this->employeeAdvanceService->paginateForIndex($tableQuery, $user, $status), - 'summary' => $this->employeeAdvanceService->outstandingSummary($user), - 'authEmployeeId' => $user?->employee?->id, - 'canSubmit' => $user?->can(Permission::EMPLOYEE_ADVANCES_CREATE->value) - && ! $user->can(Permission::EMPLOYEE_ADVANCES_VERIFY->value) - && $user->employee !== null, + ...$pageData, 'filters' => $this->dataTableFilters($tableQuery, [ 'status' => $status, ]), @@ -60,18 +57,18 @@ public function update(EmployeeAdvanceRequest $request, EmployeeAdvance $employe return redirect()->route('admin.finance.employee_advances.index'); } - public function destroy(EmployeeAdvance $employeeAdvance): RedirectResponse + public function destroy(DestroyEmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse { - $this->employeeAdvanceService->delete($employeeAdvance, auth()->user()); + $this->employeeAdvanceService->delete($employeeAdvance, $request->user()); $this->flashDeleted('Kasbon'); return redirect()->route('admin.finance.employee_advances.index'); } - public function approve(EmployeeAdvance $employeeAdvance): RedirectResponse + public function approve(Request $request, EmployeeAdvance $employeeAdvance): RedirectResponse { - $this->employeeAdvanceService->approve($employeeAdvance, auth()->user()); + $this->employeeAdvanceService->approve($employeeAdvance, $request->user()); $this->flashSuccess('Kasbon berhasil disetujui dan dicairkan dari kas.'); @@ -83,7 +80,7 @@ public function reject(RejectEmployeeAdvanceRequest $request, EmployeeAdvance $e $this->employeeAdvanceService->reject( $employeeAdvance, $request->validated('reason'), - auth()->user() + $request->user(), ); $this->flashSuccess('Kasbon berhasil ditolak.'); @@ -91,11 +88,11 @@ public function reject(RejectEmployeeAdvanceRequest $request, EmployeeAdvance $e return redirect()->route('admin.finance.employee_advances.index'); } - public function pay(Request $request, EmployeeAdvance $employeeAdvance): RedirectResponse + public function pay(PayEmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse { $payAmount = $request->input('amount') ? (int) $request->input('amount') : null; - $this->employeeAdvanceService->pay($employeeAdvance, auth()->user(), $payAmount); + $this->employeeAdvanceService->pay($employeeAdvance, $request->user(), $payAmount); $this->flashSuccess('Kasbon berhasil dibayar.'); diff --git a/app/Http/Controllers/Admin/Finance/PayrollController.php b/app/Http/Controllers/Admin/Finance/PayrollController.php index 279bb99..f0b032f 100644 --- a/app/Http/Controllers/Admin/Finance/PayrollController.php +++ b/app/Http/Controllers/Admin/Finance/PayrollController.php @@ -17,7 +17,7 @@ class PayrollController extends Controller { - use FlashesEntityMessage,ParsesDataTableQuery; + use FlashesEntityMessage, ParsesDataTableQuery; public function __construct( private readonly PayrollService $payrollService, @@ -28,15 +28,16 @@ public function index(Request $request): Response $tableQuery = $this->parseDataTableQuery($request); $periodId = $request->integer('period_id') ?: null; $period = $this->payrollService->resolvePeriod($periodId); + $user = $request->user(); return Inertia::render('admin/finance/payroll/Index', [ 'periods' => $this->payrollService->listPeriods(), 'currentPeriod' => $period, 'payrolls' => $period - ? $this->payrollService->paginateForPeriod($period, $tableQuery, auth()->user()) + ? $this->payrollService->paginateForPeriod($period, $tableQuery, $user) : null, 'summary' => $period - ? $this->payrollService->periodSummary($period, auth()->user()) + ? $this->payrollService->periodSummary($period, $user) : null, 'adjustmentTypes' => PayrollAdjustmentType::selectOptions(), 'filters' => $this->dataTableFilters($tableQuery, [ @@ -50,10 +51,10 @@ public function storeAdjustment(PayrollAdjustmentRequest $request, Payroll $payr $this->payrollService->addAdjustment( $payroll, $request->validated(), - auth()->user(), + $request->user(), ); - $this->flashSuccess('Penyesuaian gaji berhasil ditambahkan.'); + $this->flashCreated('Penyesuaian gaji'); return redirect()->route('admin.finance.payroll.index', [ 'period_id' => $payroll->payroll_period_id, @@ -65,10 +66,10 @@ public function updateAdjustment(PayrollAdjustmentRequest $request, PayrollAdjus $this->payrollService->updateAdjustment( $payrollAdjustment, $request->validated(), - auth()->user(), + $request->user(), ); - $this->flashSuccess('Penyesuaian gaji berhasil diperbarui.'); + $this->flashUpdated('Penyesuaian gaji'); return redirect()->route('admin.finance.payroll.index', [ 'period_id' => $payrollAdjustment->payroll->payroll_period_id, @@ -81,7 +82,7 @@ public function destroyAdjustment(PayrollAdjustment $payrollAdjustment): Redirec $this->payrollService->deleteAdjustment($payrollAdjustment); - $this->flashSuccess('Penyesuaian gaji berhasil dihapus.'); + $this->flashDeleted('Penyesuaian gaji'); return redirect()->route('admin.finance.payroll.index', [ 'period_id' => $payroll->payroll_period_id, diff --git a/app/Http/Controllers/Admin/Hr/AttendanceController.php b/app/Http/Controllers/Admin/Hr/AttendanceController.php index 3f31daa..a43d59a 100644 --- a/app/Http/Controllers/Admin/Hr/AttendanceController.php +++ b/app/Http/Controllers/Admin/Hr/AttendanceController.php @@ -2,8 +2,6 @@ namespace App\Http\Controllers\Admin\Hr; -use App\Enums\Permission; -use App\Enums\Role; use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\Hr\Attendance\CheckInRequest; @@ -25,37 +23,14 @@ public function __construct( public function index(Request $request): Response { - $user = $request->user(); - $employee = $user?->employee; - $isManager = $user?->hasAnyRole([Role::OWNER->value, Role::DEVELOPER->value, Role::DIREKTUR->value]) ?? false; - - $scopedEmployeeId = $isManager ? null : $employee?->id; - $hasScopedAccess = $isManager || $employee !== null; - $start = $request->date('start') ?? now()->startOfMonth(); $end = $request->date('end') ?? now()->endOfMonth(); - return Inertia::render('admin/hr/attendances/Index', [ - 'attendances' => $this->attendanceService->listForCalendar( - $start, - $end, - $scopedEmployeeId, - $hasScopedAccess, - ), - 'todayAttendance' => $employee - ? $this->attendanceService->todayAttendanceForEmployee($employee) - : null, - 'isOnLeave' => $employee - ? $this->attendanceService->isOnLeaveToday($employee) - : false, - 'canCheckIn' => ($user?->can(Permission::ATTENDANCES_CREATE->value) ?? false) - && $employee !== null, - 'canManageAll' => $isManager, - 'calendarRange' => [ - 'start' => $start->toDateString(), - 'end' => $end->toDateString(), - ], - ]); + return Inertia::render('admin/hr/attendances/Index', $this->attendanceService->indexPageData( + $request->user(), + $start, + $end, + )); } public function checkIn(CheckInRequest $request): RedirectResponse diff --git a/app/Http/Controllers/Admin/Hr/EmployeeController.php b/app/Http/Controllers/Admin/Hr/EmployeeController.php index 0e3fb4b..1e9fc01 100644 --- a/app/Http/Controllers/Admin/Hr/EmployeeController.php +++ b/app/Http/Controllers/Admin/Hr/EmployeeController.php @@ -4,7 +4,6 @@ use App\Enums\EmploymentStatus; use App\Enums\Gender; -use App\Enums\Role; use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Controller; @@ -12,7 +11,6 @@ use App\Http\Requests\Admin\ToggleStatusRequest; use App\Models\User; use App\Services\Hr\EmployeeService; -use App\Support\Media\MediaPresenter; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -26,11 +24,6 @@ public function __construct( private readonly EmployeeService $employeeService, ) {} - private function assignableRoleOptions(): array - { - return Role::assignableSelectOptions(); - } - public function index(Request $request): Response { $tableQuery = $this->parseDataTableQuery($request); @@ -53,7 +46,7 @@ public function index(Request $request): Response 'employment_status' => $employmentStatus, 'is_active' => $isActive, ]), - 'roles' => $this->assignableRoleOptions(), + 'roles' => $this->employeeService->assignableRoleOptions(), 'genders' => Gender::selectOptions(), 'employmentStatuses' => EmploymentStatus::selectOptions(), ]); @@ -64,7 +57,7 @@ public function create(): Response return Inertia::render('admin/hr/employees/Create', [ 'genders' => Gender::selectOptions(), 'employmentStatuses' => EmploymentStatus::selectOptions(), - 'roles' => $this->assignableRoleOptions(), + 'roles' => $this->employeeService->assignableRoleOptions(), ]); } @@ -79,16 +72,14 @@ public function store(EmployeeRequest $request): RedirectResponse public function edit(User $user): Response { - $user->load(['profile', 'employee', 'roles']); + $editData = $this->employeeService->findForEdit($user); return Inertia::render('admin/hr/employees/Edit', [ 'genders' => Gender::selectOptions(), 'employmentStatuses' => EmploymentStatus::selectOptions(), - 'roles' => $this->assignableRoleOptions(), - 'employee' => $user, - 'profilePhoto' => $user->profile - ? MediaPresenter::first($user->profile, 'profile_photo') - : null, + 'roles' => $this->employeeService->assignableRoleOptions(), + 'employee' => $editData['employee'], + 'profilePhoto' => $editData['profilePhoto'], ]); } @@ -96,25 +87,7 @@ public function update(EmployeeRequest $request, User $user): RedirectResponse { $this->employeeService->update($user, $request->validated()); - $this->flashSuccess('Data pegawai berhasil diperbarui.'); - - return redirect()->route('admin.hr.employees.index'); - } - - public function toggleStatus(ToggleStatusRequest $request, User $user): RedirectResponse - { - $this->employeeService->toggleStatus($user, $request->validated()); - - $this->flashSuccess('Status pegawai berhasil diubah.'); - - return redirect()->route('admin.hr.employees.index'); - } - - public function resetPassword(User $user): RedirectResponse - { - $this->employeeService->resetPassword($user); - - $this->flashSuccess('Kata sandi pegawai berhasil direset.'); + $this->flashUpdated('Pegawai'); return redirect()->route('admin.hr.employees.index'); } @@ -127,4 +100,22 @@ public function destroy(User $user): RedirectResponse return redirect()->route('admin.hr.employees.index'); } + + public function toggleStatus(ToggleStatusRequest $request, User $user): RedirectResponse + { + $this->employeeService->toggleStatus($user, $request->validated()); + + $this->flashStatusUpdated('Pegawai'); + + return redirect()->route('admin.hr.employees.index'); + } + + public function resetPassword(User $user): RedirectResponse + { + $this->employeeService->resetPassword($user); + + $this->flashSuccess('Kata sandi pegawai berhasil direset.'); + + return redirect()->route('admin.hr.employees.index'); + } } diff --git a/app/Http/Controllers/Admin/Hr/LeaveRequestController.php b/app/Http/Controllers/Admin/Hr/LeaveRequestController.php index 8f00c50..71c7abe 100644 --- a/app/Http/Controllers/Admin/Hr/LeaveRequestController.php +++ b/app/Http/Controllers/Admin/Hr/LeaveRequestController.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Admin\Hr; -use App\Enums\Permission; use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\Hr\ApproveLeaveRequestRequest; use App\Http\Requests\Admin\Hr\RejectLeaveRequestRequest; use App\Http\Requests\Admin\Hr\SubmitLeaveRequest; use App\Models\LeaveRequest; @@ -26,23 +26,10 @@ public function __construct( public function index(Request $request): Response { $tableQuery = $this->parseDataTableQuery($request); - $user = $request->user(); - - $hasPending = false; - if ($user?->employee !== null) { - $hasPending = LeaveRequest::query() - ->pending() - ->where('employee_id', $user->employee->id) - ->exists(); - } + $pageData = $this->leaveRequestService->indexPageData($tableQuery, $request->user()); return Inertia::render('admin/hr/leave-requests/Index', [ - 'leaveRequests' => $this->leaveRequestService->paginateForIndex($tableQuery, $user), - 'authEmployeeId' => $user?->employee?->id, - 'canSubmit' => $user?->can(Permission::LEAVE_REQUESTS_CREATE->value) - && ! $user->can(Permission::LEAVE_REQUESTS_VERIFY->value) - && $user->employee !== null, - 'hasPending' => $hasPending, + ...$pageData, 'filters' => $this->dataTableFilters($tableQuery), ]); } @@ -67,16 +54,16 @@ public function update(SubmitLeaveRequest $request, LeaveRequest $leaveRequest): public function destroy(LeaveRequest $leaveRequest): RedirectResponse { - $this->leaveRequestService->delete($leaveRequest, auth()->user()); + $this->leaveRequestService->delete($leaveRequest); $this->flashDeleted('Pengajuan cuti'); return redirect()->route('admin.hr.leave_requests.index'); } - public function approve(LeaveRequest $leaveRequest): RedirectResponse + public function approve(ApproveLeaveRequestRequest $request, LeaveRequest $leaveRequest): RedirectResponse { - $this->leaveRequestService->approve($leaveRequest, auth()->user()); + $this->leaveRequestService->approve($leaveRequest, $request->user()); $this->flashSuccess('Pengajuan cuti berhasil disetujui.'); @@ -88,7 +75,7 @@ public function reject(RejectLeaveRequestRequest $request, LeaveRequest $leaveRe $this->leaveRequestService->reject( $leaveRequest, $request->validated('reason'), - auth()->user(), + $request->user(), ); $this->flashSuccess('Pengajuan cuti berhasil ditolak.'); diff --git a/app/Http/Controllers/Admin/Manage/CuttingController.php b/app/Http/Controllers/Admin/Manage/Cutting/CuttingController.php similarity index 81% rename from app/Http/Controllers/Admin/Manage/CuttingController.php rename to app/Http/Controllers/Admin/Manage/Cutting/CuttingController.php index 43167dd..5cb753a 100644 --- a/app/Http/Controllers/Admin/Manage/CuttingController.php +++ b/app/Http/Controllers/Admin/Manage/Cutting/CuttingController.php @@ -1,6 +1,6 @@ user(); + $user = $request->user(); return Inertia::render('admin/manage/cuttings/Create', [ 'rawMaterialCatalog' => $this->cuttingService->rawMaterialCatalog(user: $user), @@ -57,8 +58,10 @@ public function store(CuttingRequest $request): RedirectResponse public function edit(Cutting $cutting): Response|RedirectResponse { - if (! $this->cuttingService->isEditable($cutting->status)) { - $this->flashError('Proses cutting tidak dapat diubah.'); + try { + $this->cuttingService->ensureEditable($cutting); + } catch (ValidationException $exception) { + $this->flashError($exception->validator->errors()->first('status')); return redirect()->route('admin.manage.cuttings.index'); } @@ -102,15 +105,7 @@ public function transitionStatus(CuttingStatusTransitionRequest $request, Cuttin $request->validated('result_prices'), ); - $message = match ($status) { - CuttingStatus::COMPLETED => 'Proses cutting berhasil diselesaikan. Menunggu verifikasi admin toko.', - CuttingStatus::VERIFIED => 'Proses cutting berhasil diverifikasi. Stok produk telah diperbarui.', - CuttingStatus::REJECTED => 'Proses cutting berhasil ditolak.', - CuttingStatus::IN_PROGRESS => 'Proses cutting dikembalikan ke proses.', - default => 'Status proses cutting berhasil diperbarui.', - }; - - $this->flashSuccess($message); + $this->flashSuccess($this->cuttingService->transitionStatusMessage($status)); return redirect()->route('admin.manage.cuttings.index'); } diff --git a/app/Http/Controllers/Admin/Manage/CuttingDraftItemController.php b/app/Http/Controllers/Admin/Manage/Cutting/CuttingDraftItemController.php similarity index 96% rename from app/Http/Controllers/Admin/Manage/CuttingDraftItemController.php rename to app/Http/Controllers/Admin/Manage/Cutting/CuttingDraftItemController.php index e17dad8..a2a1d65 100644 --- a/app/Http/Controllers/Admin/Manage/CuttingDraftItemController.php +++ b/app/Http/Controllers/Admin/Manage/Cutting/CuttingDraftItemController.php @@ -1,6 +1,6 @@ $this->orderService->findForShow($order), - ]); - } - public function create(Request $request): Response { $user = $request->user(); @@ -73,10 +67,19 @@ public function store(OrderRequest $request): RedirectResponse return redirect()->route('admin.manage.orders.index', $params); } + public function show(Order $order): Response + { + return Inertia::render('admin/manage/orders/Show', [ + 'order' => $this->orderService->findForShow($order), + ]); + } + public function edit(Order $order): Response|RedirectResponse { - if (! $this->orderService->isEditable($order->status)) { - $this->flashError('Pesanan tidak dapat diubah.'); + try { + $this->orderService->ensureEditable($order); + } catch (ValidationException $exception) { + $this->flashError($exception->validator->errors()->first('status')); return redirect()->route('admin.manage.orders.index'); } @@ -116,14 +119,7 @@ public function transitionStatus(OrderStatusTransitionRequest $request, Order $o $this->orderService->transitionStatus($order, $status); - $message = match ($status) { - OrderStatus::PROCESSING => 'Pesanan berhasil dikirim.', - OrderStatus::COMPLETED => 'Pesanan berhasil diselesaikan.', - OrderStatus::CANCELLED => 'Pesanan berhasil dibatalkan.', - default => 'Status pesanan berhasil diperbarui.', - }; - - $this->flashSuccess($message); + $this->flashSuccess($this->orderService->transitionStatusMessage($status)); return redirect()->route('admin.manage.orders.index'); } diff --git a/app/Http/Controllers/Admin/Manage/OrderDraftItemController.php b/app/Http/Controllers/Admin/Manage/Order/OrderDraftItemController.php similarity index 96% rename from app/Http/Controllers/Admin/Manage/OrderDraftItemController.php rename to app/Http/Controllers/Admin/Manage/Order/OrderDraftItemController.php index 74581da..7cfd40b 100644 --- a/app/Http/Controllers/Admin/Manage/OrderDraftItemController.php +++ b/app/Http/Controllers/Admin/Manage/Order/OrderDraftItemController.php @@ -1,6 +1,6 @@ validated(); - $this->stockEcerService->transfer( + $this->stockRetailService->transfer( $validated['product_variant_id'], $validated['quantity'], $request->user(), diff --git a/app/Http/Controllers/Admin/Manage/StokOpnameController.php b/app/Http/Controllers/Admin/Manage/StokOpnameController.php index 0e59953..c2f1b1f 100644 --- a/app/Http/Controllers/Admin/Manage/StokOpnameController.php +++ b/app/Http/Controllers/Admin/Manage/StokOpnameController.php @@ -5,12 +5,14 @@ use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\Manage\StokOpnameAutoSaveRequest; use App\Http\Requests\Admin\Manage\StokOpnameRejectRequest; use App\Http\Requests\Admin\Manage\StokOpnameRequest; use App\Http\Requests\Admin\Manage\StokOpnameSubmitRequest; use App\Http\Requests\Admin\Manage\StokOpnameVerifyRequest; use App\Models\StokOpname; use App\Services\Manage\StokOpnameService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -50,36 +52,6 @@ public function store(StokOpnameRequest $request): RedirectResponse return redirect()->route('admin.manage.stok-opnames.index'); } - /** - * Auto-save draft via AJAX (creates or updates silently). - */ - public function autoSave(Request $request): \Illuminate\Http\JsonResponse - { - $user = $request->user(); - - if (! $user->can(\App\Enums\Permission::STOK_OPNAMES_CREATE->value) && - ! $user->can(\App\Enums\Permission::STOK_OPNAMES_UPDATE->value)) { - return response()->json(['message' => 'Unauthorized'], 403); - } - - $validated = $request->validate([ - 'stok_opname_id' => ['nullable', 'integer', 'exists:stok_opnames,id'], - 'opname_date' => ['required', 'date'], - 'notes' => ['nullable', 'string', 'max:1000'], - 'items' => ['nullable', 'array'], - 'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'], - 'items.*.physical_stock' => ['nullable', 'integer', 'min:0'], - 'items.*.notes' => ['nullable', 'string', 'max:500'], - ]); - - $stokOpname = $this->stokOpnameService->autoSave($validated, $user); - - return response()->json([ - 'stok_opname_id' => $stokOpname->id, - 'message' => 'Draft tersimpan otomatis.', - ]); - } - public function edit(StokOpname $stokOpname): Response { return Inertia::render('admin/manage/stok-opnames/Edit', [ @@ -106,6 +78,16 @@ public function destroy(StokOpname $stokOpname): RedirectResponse return redirect()->route('admin.manage.stok-opnames.index'); } + public function autoSave(StokOpnameAutoSaveRequest $request): JsonResponse + { + $stokOpname = $this->stokOpnameService->autoSave($request->validated(), $request->user()); + + return response()->json([ + 'stok_opname_id' => $stokOpname->id, + 'message' => 'Draft tersimpan otomatis.', + ]); + } + public function submit(StokOpnameSubmitRequest $request, StokOpname $stokOpname): RedirectResponse { $this->stokOpnameService->submit($stokOpname, $request->user()); diff --git a/app/Http/Controllers/Admin/Master/CategoryController.php b/app/Http/Controllers/Admin/Master/CategoryController.php index 9d9996b..d44ea55 100644 --- a/app/Http/Controllers/Admin/Master/CategoryController.php +++ b/app/Http/Controllers/Admin/Master/CategoryController.php @@ -15,7 +15,7 @@ class CategoryController extends Controller { - use FlashesEntityMessage,ParsesDataTableQuery; + use FlashesEntityMessage, ParsesDataTableQuery; public function __construct( private readonly CategoryService $categoryService, diff --git a/app/Http/Controllers/Admin/Master/CustomerController.php b/app/Http/Controllers/Admin/Master/CustomerController.php index 6f61452..14677da 100644 --- a/app/Http/Controllers/Admin/Master/CustomerController.php +++ b/app/Http/Controllers/Admin/Master/CustomerController.php @@ -41,18 +41,6 @@ public function store(CustomerRequest $request): RedirectResponse return redirect()->route('admin.master.customers.index'); } - public function storeApi(CustomerRequest $request): JsonResponse - { - $this->customerService->create($request->validated()); - - $customer = Customer::query()->latest()->first(); - - return response()->json([ - 'id' => $customer->id, - 'name' => $customer->name, - ]); - } - public function update(CustomerRequest $request, Customer $customer): RedirectResponse { $this->customerService->update($customer, $request->validated()); @@ -70,4 +58,14 @@ public function destroy(Customer $customer): RedirectResponse return redirect()->route('admin.master.customers.index'); } + + public function storeApi(CustomerRequest $request): JsonResponse + { + $customer = $this->customerService->createAndReturn($request->validated()); + + return response()->json([ + 'id' => $customer->id, + 'name' => $customer->name, + ]); + } } diff --git a/app/Http/Controllers/Admin/Master/ProductController.php b/app/Http/Controllers/Admin/Master/ProductController.php index 3d5f9dd..f9eeb80 100644 --- a/app/Http/Controllers/Admin/Master/ProductController.php +++ b/app/Http/Controllers/Admin/Master/ProductController.php @@ -8,10 +8,8 @@ use App\Http\Requests\Admin\Master\ProductRequest; use App\Http\Requests\Admin\ToggleStatusRequest; use App\Models\Product; -use App\Models\ProductVariant; use App\Services\Master\CategoryService; use App\Services\Master\ProductService; -use App\Support\Media\MediaPresenter; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -62,20 +60,9 @@ public function store(ProductRequest $request): RedirectResponse public function edit(Product $product): Response { - $product->load([ - 'categories', - 'variants' => fn ($query) => $query - ->with('media') - ->orderBy('created_at'), - ]); - - $product->variants->each(function (ProductVariant $variant): void { - $variant->setAttribute('images', MediaPresenter::collection($variant, 'images')); - }); - return Inertia::render('admin/master/products/Edit', [ 'categories' => $this->categoryService->getSelectOptions(), - 'product' => $product, + 'product' => $this->productService->findForEdit($product), ]); } @@ -88,15 +75,6 @@ public function update(ProductRequest $request, Product $product): RedirectRespo return redirect()->route('admin.master.products.index'); } - public function toggleStatus(ToggleStatusRequest $request, Product $product): RedirectResponse - { - $this->productService->toggleStatus($product, $request->validated(), $request->user()); - - $this->flashSuccess('Perubahan status produk berhasil diajukan dan menunggu verifikasi owner.'); - - return back(); - } - public function destroy(Request $request, Product $product): RedirectResponse { $this->productService->delete($product, $request->user()); @@ -105,4 +83,13 @@ public function destroy(Request $request, Product $product): RedirectResponse return redirect()->route('admin.master.products.index'); } + + public function toggleStatus(ToggleStatusRequest $request, Product $product): RedirectResponse + { + $this->productService->toggleStatus($product, $request->validated(), $request->user()); + + $this->flashSuccess('Perubahan status produk berhasil diajukan dan menunggu verifikasi owner.'); + + return back(); + } } diff --git a/app/Http/Controllers/Admin/Master/RawMaterialController.php b/app/Http/Controllers/Admin/Master/RawMaterialController.php index 4d3c56b..b4121f3 100644 --- a/app/Http/Controllers/Admin/Master/RawMaterialController.php +++ b/app/Http/Controllers/Admin/Master/RawMaterialController.php @@ -9,9 +9,7 @@ use App\Http\Requests\Admin\Master\RawMaterialRequest; use App\Http\Requests\Admin\ToggleStatusRequest; use App\Models\RawMaterial; -use App\Models\RawMaterialPrice; use App\Services\Master\RawMaterialService; -use App\Support\Media\MediaPresenter; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -58,17 +56,9 @@ public function store(RawMaterialRequest $request): RedirectResponse public function edit(RawMaterial $rawMaterial): Response { - $rawMaterial->load([ - 'prices' => fn ($query) => $query->orderBy('created_at')->with('media'), - ]); - - $rawMaterial->prices->each(function (RawMaterialPrice $price): void { - $price->setAttribute('images', MediaPresenter::collection($price, 'images')); - }); - return Inertia::render('admin/master/raw-materials/Edit', [ 'units' => RawMaterialUnit::selectOptions(), - 'rawMaterial' => $rawMaterial, + 'rawMaterial' => $this->rawMaterialService->findForEdit($rawMaterial), ]); } @@ -81,15 +71,6 @@ public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): R return redirect()->route('admin.master.raw_materials.index'); } - public function toggleStatus(ToggleStatusRequest $request, RawMaterial $rawMaterial): RedirectResponse - { - $this->rawMaterialService->toggleStatus($rawMaterial, $request->validated(), $request->user()); - - $this->flashSuccess('Perubahan status bahan baku berhasil diajukan dan menunggu verifikasi owner.'); - - return back(); - } - public function destroy(Request $request, RawMaterial $rawMaterial): RedirectResponse { $this->rawMaterialService->delete($rawMaterial, $request->user()); @@ -98,4 +79,13 @@ public function destroy(Request $request, RawMaterial $rawMaterial): RedirectRes return redirect()->route('admin.master.raw_materials.index'); } + + public function toggleStatus(ToggleStatusRequest $request, RawMaterial $rawMaterial): RedirectResponse + { + $this->rawMaterialService->toggleStatus($rawMaterial, $request->validated(), $request->user()); + + $this->flashSuccess('Perubahan status bahan baku berhasil diajukan dan menunggu verifikasi owner.'); + + return back(); + } } diff --git a/app/Http/Controllers/Admin/NotificationController.php b/app/Http/Controllers/Admin/NotificationController.php index 1c63b9e..b824eca 100644 --- a/app/Http/Controllers/Admin/NotificationController.php +++ b/app/Http/Controllers/Admin/NotificationController.php @@ -4,77 +4,47 @@ use App\Http\Controllers\Controller; use App\Models\Notification; +use App\Services\System\NotificationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class NotificationController extends Controller { + public function __construct( + private readonly NotificationService $notificationService, + ) {} + public function index(Request $request): JsonResponse { - $user = $request->user(); - - $notifications = Notification::query() - ->where('user_id', $user->id) - ->latest() - ->limit(20) - ->get() - ->map(fn (Notification $n) => [ - 'id' => $n->id, - 'title' => $n->title, - 'body' => $n->body, - 'url' => $n->url, - 'is_read' => $n->is_read, - 'created_at' => $n->created_at->toIso8601String(), - ]); - - $unreadCount = Notification::query() - ->where('user_id', $user->id) - ->unread() - ->count(); - - return response()->json([ - 'notifications' => $notifications, - 'unread_count' => $unreadCount, - ]); + return response()->json( + $this->notificationService->listForUser($request->user()), + ); } public function markAsRead(Request $request, Notification $notification): JsonResponse { - if ($notification->user_id !== $request->user()->id) { - abort(403); - } - - $notification->markAsRead(); + $this->notificationService->markAsRead($notification, $request->user()); return response()->json(['success' => true]); } public function markAllAsRead(Request $request): JsonResponse { - Notification::query() - ->where('user_id', $request->user()->id) - ->unread() - ->update(['is_read' => true, 'read_at' => now()]); + $this->notificationService->markAllAsRead($request->user()); return response()->json(['success' => true]); } public function destroy(Request $request, Notification $notification): JsonResponse { - if ($notification->user_id !== $request->user()->id) { - abort(403); - } - - $notification->delete(); + $this->notificationService->delete($notification, $request->user()); return response()->json(['success' => true]); } public function destroyAll(Request $request): JsonResponse { - Notification::query() - ->where('user_id', $request->user()->id) - ->delete(); + $this->notificationService->deleteAll($request->user()); return response()->json(['success' => true]); } diff --git a/app/Http/Controllers/Admin/System/RoleController.php b/app/Http/Controllers/Admin/System/RoleController.php index 699df36..78a0341 100644 --- a/app/Http/Controllers/Admin/System/RoleController.php +++ b/app/Http/Controllers/Admin/System/RoleController.php @@ -2,7 +2,6 @@ namespace App\Http\Controllers\Admin\System; -use App\Enums\Permission as PermissionEnum; use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Controller; @@ -34,17 +33,8 @@ public function index(Request $request): Response public function create(): Response { - $permissions = collect(PermissionEnum::cases()) - ->map(fn (PermissionEnum $permission) => [ - 'value' => $permission->value, - 'label' => $permission->label(), - 'group' => $permission->group(), - ]) - ->values() - ->all(); - return Inertia::render('admin/system/roles/Create', [ - 'permissions' => $permissions, + 'permissions' => $this->roleService->permissionOptions(), ]); } @@ -59,24 +49,9 @@ public function store(RoleRequest $request): RedirectResponse public function edit(Role $role): Response { - $role->load('permissions'); - - $permissions = collect(PermissionEnum::cases()) - ->map(fn (PermissionEnum $permission) => [ - 'value' => $permission->value, - 'label' => $permission->label(), - 'group' => $permission->group(), - ]) - ->values() - ->all(); - return Inertia::render('admin/system/roles/Edit', [ - 'role' => [ - 'id' => $role->id, - 'name' => $role->name, - 'permissions' => $role->permissions->pluck('name'), - ], - 'permissions' => $permissions, + ...$this->roleService->findForEdit($role), + 'permissions' => $this->roleService->permissionOptions(), ]); } diff --git a/app/Http/Controllers/Admin/System/SettingController.php b/app/Http/Controllers/Admin/System/SettingController.php index 0c22b9c..fb86f86 100644 --- a/app/Http/Controllers/Admin/System/SettingController.php +++ b/app/Http/Controllers/Admin/System/SettingController.php @@ -10,13 +10,12 @@ use App\Http\Requests\Admin\System\Setting\MarketplaceRequest; use App\Http\Requests\Admin\System\Setting\SocialMediaRequest; use App\Http\Requests\Admin\System\Setting\SystemRequest; -use App\Models\OwnerVerificationRequest; +use App\Services\Manage\OwnerVerificationService; use App\Services\System\Setting\HomepageSettingService; use App\Services\System\Setting\HrSettingService; use App\Services\System\Setting\MarketplaceService; use App\Services\System\Setting\SocialMediaService; use App\Services\System\Setting\SystemService; -use App\Settings\MarketplaceSettings; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -31,6 +30,7 @@ public function __construct( private readonly MarketplaceService $marketplaceService, private readonly HrSettingService $hrSettingService, private readonly HomepageSettingService $homepageSettingService, + private readonly OwnerVerificationService $ownerVerificationService, ) {} public function index(): Response @@ -41,10 +41,7 @@ public function index(): Response 'marketplace' => $this->marketplaceService->marketplaceData(), 'hr' => $this->hrSettingService->hrData(), 'homepage' => $this->homepageSettingService->homepageData(), - 'hasPendingMarketplaceVerification' => OwnerVerificationRequest::query() - ->where('subject_type', MarketplaceSettings::class) - ->pending() - ->exists(), + 'hasPendingMarketplaceVerification' => $this->ownerVerificationService->hasPendingMarketplaceVerification(), ]); } @@ -78,12 +75,7 @@ public function updateMarketplace(MarketplaceRequest $request): RedirectResponse { $this->marketplaceService->updateMarketplace($request->validated(), $request->user()); - $hasPending = OwnerVerificationRequest::query() - ->where('subject_type', MarketplaceSettings::class) - ->pending() - ->exists(); - - if ($hasPending) { + if ($this->ownerVerificationService->hasPendingMarketplaceVerification()) { $this->flashSuccess('Perubahan pengaturan marketplace berhasil diajukan dan menunggu verifikasi owner.'); } else { $this->flashSuccess('Pengaturan marketplace berhasil disimpan.'); diff --git a/app/Http/Controllers/Concerns/ParsesDataTableQuery.php b/app/Http/Controllers/Concerns/ParsesDataTableQuery.php index 9bb10b3..895024c 100644 --- a/app/Http/Controllers/Concerns/ParsesDataTableQuery.php +++ b/app/Http/Controllers/Concerns/ParsesDataTableQuery.php @@ -6,9 +6,6 @@ trait ParsesDataTableQuery { - /** - * @return array{search: string, sort: string, direction: 'asc'|'desc'} - */ protected function parseDataTableQuery(Request $request): array { return [ @@ -18,10 +15,6 @@ protected function parseDataTableQuery(Request $request): array ]; } - /** - * @param array{search: string, sort: string, direction: 'asc'|'desc'} $query - * @return array{search: string, sort: string, direction: 'asc'|'desc'|null} - */ protected function dataTableFilters(array $query, array $extra = []): array { return array_merge([ diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 7a884dbe..9741891 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -2,76 +2,18 @@ namespace App\Http\Controllers; -use App\Models\Category; -use App\Models\Product; -use App\Models\SystemConfiguration; -use App\Services\Manage\CuttingResultPriceResolver; -use App\Services\System\Setting\HomepageSettingService; -use App\Settings\SocialMediaSettings; -use App\Settings\SystemSettings; -use App\Support\Media\MediaPresenter; +use App\Services\System\HomepageService; use Inertia\Inertia; use Inertia\Response; class HomeController extends Controller { public function __construct( - private readonly CuttingResultPriceResolver $cuttingResultPriceResolver, - private readonly HomepageSettingService $homepageSettingService, + private readonly HomepageService $homepageService, ) {} public function index(): Response { - $categories = Category::getActiveWithProducts(); - - $products = Product::getActiveWithVariantsAndCategories(); - - $allVariantIds = $products - ->flatMap(fn ($product) => $product->variants->pluck('id')) - ->all(); - - $allPricesByVariant = $this->cuttingResultPriceResolver->latestPricesForVariants($allVariantIds); - - $products = $products->map(function ($product) use ($allPricesByVariant) { - $product->variants->each(function ($variant) use ($allPricesByVariant) { - $variant->setAttribute('images', MediaPresenter::collection($variant, 'images')); - $variantPrices = $allPricesByVariant->get($variant->id, collect()); - $variant->setAttribute( - 'prices', - $variantPrices->map(fn ($price) => [ - 'type' => $price->price_type->value, - 'type_label' => $price->price_type->label(), - 'price' => (int) $price->price, - 'price_formatted' => $price->price_formatted, - ])->values()->all(), - ); - }); - - return $product; - }); - - $configuration = SystemConfiguration::instance(); - $logo = MediaPresenter::first($configuration, 'logo'); - $logoUrl = $logo['url'] ?? null; - - $settings = app(SystemSettings::class); - $socialSettings = app(SocialMediaSettings::class); - - $appName = $settings->app_name ?? 'DST Collection'; - - return Inertia::render('Home', [ - 'categories' => $categories, - 'products' => $products, - 'appName' => $appName, - 'aboutApp' => $settings->about_app ?? '', - 'contactEmail' => $settings->email ?? '', - 'contactPhone' => $settings->phone ?? '', - 'contactAddress' => $settings->address ?? '', - 'logoUrl' => $logoUrl, - 'instagramUrl' => $socialSettings->instagram_url ?? null, - 'facebookUrl' => $socialSettings->facebook_url ?? null, - 'tiktokUrl' => $socialSettings->tiktok_url ?? null, - 'homepage' => $this->homepageSettingService->homepageData(), - ]); + return Inertia::render('Home', $this->homepageService->pageData()); } } diff --git a/app/Http/Requests/Admin/Finance/ApproveEmployeeAdvanceRequest.php b/app/Http/Requests/Admin/Finance/ApproveEmployeeAdvanceRequest.php new file mode 100644 index 0000000..edb030e --- /dev/null +++ b/app/Http/Requests/Admin/Finance/ApproveEmployeeAdvanceRequest.php @@ -0,0 +1,19 @@ +user()?->can(Permission::EMPLOYEE_ADVANCES_VERIFY->value) ?? false; + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Admin/Finance/DestroyEmployeeAdvanceRequest.php b/app/Http/Requests/Admin/Finance/DestroyEmployeeAdvanceRequest.php new file mode 100644 index 0000000..f15d270 --- /dev/null +++ b/app/Http/Requests/Admin/Finance/DestroyEmployeeAdvanceRequest.php @@ -0,0 +1,19 @@ +user()?->can(Permission::EMPLOYEE_ADVANCES_DELETE->value) ?? false; + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Admin/Finance/PayEmployeeAdvanceRequest.php b/app/Http/Requests/Admin/Finance/PayEmployeeAdvanceRequest.php new file mode 100644 index 0000000..aa35a23 --- /dev/null +++ b/app/Http/Requests/Admin/Finance/PayEmployeeAdvanceRequest.php @@ -0,0 +1,21 @@ +user()?->can(Permission::EMPLOYEE_ADVANCES_PAY->value) ?? false; + } + + public function rules(): array + { + return [ + 'amount' => ['nullable', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Hr/ApproveLeaveRequestRequest.php b/app/Http/Requests/Admin/Hr/ApproveLeaveRequestRequest.php new file mode 100644 index 0000000..6414630 --- /dev/null +++ b/app/Http/Requests/Admin/Hr/ApproveLeaveRequestRequest.php @@ -0,0 +1,19 @@ +user()?->can(Permission::LEAVE_REQUESTS_VERIFY->value) ?? false; + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Admin/Manage/StockEcerTransferRequest.php b/app/Http/Requests/Admin/Manage/StockRetailTransferRequest.php similarity index 94% rename from app/Http/Requests/Admin/Manage/StockEcerTransferRequest.php rename to app/Http/Requests/Admin/Manage/StockRetailTransferRequest.php index 4ebfae9..9f32ade 100644 --- a/app/Http/Requests/Admin/Manage/StockEcerTransferRequest.php +++ b/app/Http/Requests/Admin/Manage/StockRetailTransferRequest.php @@ -6,7 +6,7 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -class StockEcerTransferRequest extends FormRequest +class StockRetailTransferRequest extends FormRequest { public function authorize(): bool { diff --git a/app/Http/Requests/Admin/Manage/StokOpnameAutoSaveRequest.php b/app/Http/Requests/Admin/Manage/StokOpnameAutoSaveRequest.php new file mode 100644 index 0000000..91927fb --- /dev/null +++ b/app/Http/Requests/Admin/Manage/StokOpnameAutoSaveRequest.php @@ -0,0 +1,32 @@ +user(); + + return $user !== null && ( + $user->can(Permission::STOK_OPNAMES_CREATE->value) + || $user->can(Permission::STOK_OPNAMES_UPDATE->value) + ); + } + + public function rules(): array + { + return [ + 'stok_opname_id' => ['nullable', 'integer', 'exists:stok_opnames,id'], + 'opname_date' => ['required', 'date'], + 'notes' => ['nullable', 'string', 'max:1000'], + 'items' => ['nullable', 'array'], + 'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'], + 'items.*.physical_stock' => ['nullable', 'integer', 'min:0'], + 'items.*.notes' => ['nullable', 'string', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Master/ProductRequest.php b/app/Http/Requests/Admin/Master/ProductRequest.php index c7ae080..8840e49 100644 --- a/app/Http/Requests/Admin/Master/ProductRequest.php +++ b/app/Http/Requests/Admin/Master/ProductRequest.php @@ -42,7 +42,7 @@ public function rules(): array ], 'variants.*.name' => ['required', 'string', 'max:200'], 'variants.*.stock' => ['required', 'integer', 'min:0'], - 'variants.*.stock_ecer' => ['required', 'integer', 'min:0'], + 'variants.*.stock_retail' => ['required', 'integer', 'min:0'], ...$this->variantImageRules(), ]; } @@ -60,7 +60,7 @@ public function attributes(): array 'variants' => 'varian', 'variants.*.name' => 'nama varian', 'variants.*.stock' => 'stok', - 'variants.*.stock_ecer' => 'stok ecer', + 'variants.*.stock_retail' => 'stok ecer', ...$this->variantImageAttributes('variants', 'foto varian'), ]; } diff --git a/app/Models/Order.php b/app/Models/Order.php index 31451c7..ddcd11a 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -127,9 +127,9 @@ protected function distributor(Builder $query): void } #[Scope] - protected function ecer(Builder $query): void + protected function retail(Builder $query): void { - $query->where('price_type', PriceType::ECER); + $query->where('price_type', PriceType::RETAIL); } #[Scope] diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php index 6dcba3a..32b8007 100644 --- a/app/Models/ProductVariant.php +++ b/app/Models/ProductVariant.php @@ -26,7 +26,7 @@ protected function casts(): array return [ 'reject_stock' => 'integer', 'stock' => 'integer', - 'stock_ecer' => 'integer', + 'stock_retail' => 'integer', ]; } diff --git a/app/Models/StockEcerHistory.php b/app/Models/StockRetailHistory.php similarity index 79% rename from app/Models/StockEcerHistory.php rename to app/Models/StockRetailHistory.php index 073cf3c..536b42f 100644 --- a/app/Models/StockEcerHistory.php +++ b/app/Models/StockRetailHistory.php @@ -7,9 +7,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; #[Guarded(['id'])] -class StockEcerHistory extends Model +class StockRetailHistory extends Model { - protected $table = 'stock_ecer_histories'; + protected $table = 'stock_retail_histories'; public $timestamps = false; @@ -21,8 +21,8 @@ protected function casts(): array 'quantity' => 'integer', 'stock_after' => 'integer', 'stock_before' => 'integer', - 'stock_ecer_after' => 'integer', - 'stock_ecer_before' => 'integer', + 'stock_retail_after' => 'integer', + 'stock_retail_before' => 'integer', ]; } diff --git a/app/Services/Finance/EmployeeAdvanceService.php b/app/Services/Finance/EmployeeAdvanceService.php index 6bc764d..e24ee9f 100644 --- a/app/Services/Finance/EmployeeAdvanceService.php +++ b/app/Services/Finance/EmployeeAdvanceService.php @@ -3,6 +3,7 @@ namespace App\Services\Finance; use App\Enums\EmployeeAdvanceStatus; +use App\Enums\Permission; use App\Enums\Role; use App\Models\EmployeeAdvance; use App\Models\EmployeeAdvancePayment; @@ -309,4 +310,21 @@ private function applySorting(Builder $query, string $sort, string $direction): $query->latest(); } + + public function canSubmit(User $user): bool + { + return $user->can(Permission::EMPLOYEE_ADVANCES_CREATE->value) + && ! $user->can(Permission::EMPLOYEE_ADVANCES_VERIFY->value) + && $user->employee !== null; + } + + public function indexPageData(array $tableQuery, User $user, string $status = ''): array + { + return [ + 'employeeAdvances' => $this->paginateForIndex($tableQuery, $user, $status), + 'summary' => $this->outstandingSummary($user), + 'authEmployeeId' => $user->employee?->id, + 'canSubmit' => $this->canSubmit($user), + ]; + } } diff --git a/app/Services/Hr/AttendanceService.php b/app/Services/Hr/AttendanceService.php index afc7c0a..f391a5c 100644 --- a/app/Services/Hr/AttendanceService.php +++ b/app/Services/Hr/AttendanceService.php @@ -2,6 +2,8 @@ namespace App\Services\Hr; +use App\Enums\Permission; +use App\Enums\Role; use App\Models\Attendance; use App\Models\Employee; use App\Models\LeaveRequest; @@ -175,4 +177,45 @@ public function delete(Attendance $attendance): void route('admin.hr.attendances.index'), ); } + + public function resolveAttendanceScope(User $user): array + { + $employee = $user->employee; + $isManager = $user->hasAnyRole([Role::OWNER->value, Role::DEVELOPER->value, Role::DIREKTUR->value]); + + return [ + 'scopedEmployeeId' => $isManager ? null : $employee?->id, + 'hasScopedAccess' => $isManager || $employee !== null, + 'isManager' => $isManager, + 'employee' => $employee, + 'canCheckIn' => $user->can(Permission::ATTENDANCES_CREATE->value) && $employee !== null, + ]; + } + + public function indexPageData(User $user, CarbonInterface $start, CarbonInterface $end): array + { + $scope = $this->resolveAttendanceScope($user); + $employee = $scope['employee']; + + return [ + 'attendances' => $this->listForCalendar( + $start, + $end, + $scope['scopedEmployeeId'], + $scope['hasScopedAccess'], + ), + 'todayAttendance' => $employee + ? $this->todayAttendanceForEmployee($employee) + : null, + 'isOnLeave' => $employee + ? $this->isOnLeaveToday($employee) + : false, + 'canCheckIn' => $scope['canCheckIn'], + 'canManageAll' => $scope['isManager'], + 'calendarRange' => [ + 'start' => $start->toDateString(), + 'end' => $end->toDateString(), + ], + ]; + } } diff --git a/app/Services/Hr/EmployeeService.php b/app/Services/Hr/EmployeeService.php index 2589104..3706d47 100644 --- a/app/Services/Hr/EmployeeService.php +++ b/app/Services/Hr/EmployeeService.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Models\UserProfile; use App\Services\Media\MediaService; +use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; @@ -58,6 +59,23 @@ public function paginateForIndex( ->withQueryString(); } + public function assignableRoleOptions(): array + { + return Role::assignableSelectOptions(); + } + + public function findForEdit(User $user): array + { + $user->load(['profile', 'employee', 'roles']); + + return [ + 'employee' => $user, + 'profilePhoto' => $user->profile + ? MediaPresenter::first($user->profile, 'profile_photo') + : null, + ]; + } + /** * @param array $validated */ diff --git a/app/Services/Hr/LeaveRequestService.php b/app/Services/Hr/LeaveRequestService.php index 64e5594..16f2ded 100644 --- a/app/Services/Hr/LeaveRequestService.php +++ b/app/Services/Hr/LeaveRequestService.php @@ -3,6 +3,7 @@ namespace App\Services\Hr; use App\Enums\LeaveRequestStatus; +use App\Enums\Permission; use App\Models\LeaveRequest; use App\Models\User; use App\Services\Concerns\ResolvesAuthenticatedEmployee; @@ -224,4 +225,33 @@ private function applySorting(Builder $query, string $sort, string $direction): $query->latest(); } + + public function hasPendingForEmployee(User $user): bool + { + if ($user->employee === null) { + return false; + } + + return LeaveRequest::query() + ->pending() + ->where('employee_id', $user->employee->id) + ->exists(); + } + + public function canSubmit(User $user): bool + { + return $user->can(Permission::LEAVE_REQUESTS_CREATE->value) + && ! $user->can(Permission::LEAVE_REQUESTS_VERIFY->value) + && $user->employee !== null; + } + + public function indexPageData(array $tableQuery, User $user): array + { + return [ + 'leaveRequests' => $this->paginateForIndex($tableQuery, $user), + 'authEmployeeId' => $user->employee?->id, + 'canSubmit' => $this->canSubmit($user), + 'hasPending' => $this->hasPendingForEmployee($user), + ]; + } } diff --git a/app/Services/Manage/CuttingService.php b/app/Services/Manage/CuttingService.php index 3952229..cd71e0d 100644 --- a/app/Services/Manage/CuttingService.php +++ b/app/Services/Manage/CuttingService.php @@ -34,6 +34,26 @@ public function isEditable(CuttingStatus $status): bool return $status->isEditable(); } + public function ensureEditable(Cutting $cutting): void + { + if (! $this->isEditable($cutting->status)) { + throw ValidationException::withMessages([ + 'status' => 'Proses cutting tidak dapat diubah.', + ]); + } + } + + public function transitionStatusMessage(CuttingStatus $status): string + { + return match ($status) { + CuttingStatus::COMPLETED => 'Proses cutting berhasil diselesaikan. Menunggu verifikasi admin toko.', + CuttingStatus::VERIFIED => 'Proses cutting berhasil diverifikasi. Stok produk telah diperbarui.', + CuttingStatus::REJECTED => 'Proses cutting berhasil ditolak.', + CuttingStatus::IN_PROGRESS => 'Proses cutting dikembalikan ke proses.', + default => 'Status proses cutting berhasil diperbarui.', + }; + } + public function canTransitionTo(CuttingStatus $from, CuttingStatus $to): bool { return $from->canTransitionTo($to); diff --git a/app/Services/Manage/OrderService.php b/app/Services/Manage/OrderService.php index eec91af..614b0c2 100644 --- a/app/Services/Manage/OrderService.php +++ b/app/Services/Manage/OrderService.php @@ -44,6 +44,25 @@ public function isEditable(OrderStatus $status): bool return $status->isEditable(); } + public function ensureEditable(Order $order): void + { + if (! $this->isEditable($order->status)) { + throw ValidationException::withMessages([ + 'status' => 'Pesanan tidak dapat diubah.', + ]); + } + } + + public function transitionStatusMessage(OrderStatus $status): string + { + return match ($status) { + OrderStatus::PROCESSING => 'Pesanan berhasil dikirim.', + OrderStatus::COMPLETED => 'Pesanan berhasil diselesaikan.', + OrderStatus::CANCELLED => 'Pesanan berhasil dibatalkan.', + default => 'Status pesanan berhasil diperbarui.', + }; + } + public function canTransitionTo(OrderStatus $from, OrderStatus $to): bool { return $from->canTransitionTo($to); @@ -76,7 +95,7 @@ public function stockColumn(ProductStockQuality $quality): string return match ($quality) { ProductStockQuality::GOOD => 'stock', ProductStockQuality::REJECT => 'reject_stock', - ProductStockQuality::ECER => 'stock_ecer', + ProductStockQuality::RETAIL => 'stock_retail', }; } @@ -331,13 +350,13 @@ public function draftItemsForUser(User $user): array */ public function syncDraftItem(array $validated, User $user): array { - // Force ecer price type and stock quality for cashier role + // Force retail price type and stock quality for cashier role $priceType = $user->hasRole('cashier') - ? PriceType::ECER + ? PriceType::RETAIL : PriceType::from($validated['price_type']); $stockQuality = $user->hasRole('cashier') - ? ProductStockQuality::ECER + ? ProductStockQuality::RETAIL : ProductStockQuality::from($validated['stock_quality']); $variant = ProductVariant::query()->findOrFail($validated['product_variant_id']); @@ -391,9 +410,9 @@ public function removeDraftItem(User $user, ProductVariant $productVariant, Prod */ public function resyncDraftPrices(User $user, string $priceTypeValue): array { - // Force ecer price type for cashier role + // Force retail price type for cashier role $priceType = $user->hasRole('cashier') - ? PriceType::ECER + ? PriceType::RETAIL : PriceType::from($priceTypeValue); $items = $this->draftItemsQuery($user) @@ -430,7 +449,7 @@ public function create(array $validated, User $user): Order // Force cashier settings if ($user->hasRole('cashier')) { $validated['channel'] = 'store'; - $validated['price_type'] = 'ecer'; + $validated['price_type'] = 'retail'; $validated['payment_type'] = 'cash'; unset($validated['customer_id']); } diff --git a/app/Services/Manage/OwnerVerificationService.php b/app/Services/Manage/OwnerVerificationService.php index d1766d3..8f99563 100644 --- a/app/Services/Manage/OwnerVerificationService.php +++ b/app/Services/Manage/OwnerVerificationService.php @@ -36,9 +36,17 @@ public function __construct( private readonly PurchaseService $purchaseService, private readonly PushNotificationService $pushNotificationService, private readonly MarketplaceService $marketplaceService, - private readonly StockEcerService $stockEcerService, + private readonly StockRetailService $stockRetailService, ) {} + public function hasPendingMarketplaceVerification(): bool + { + return OwnerVerificationRequest::query() + ->where('subject_type', MarketplaceSettings::class) + ->pending() + ->exists(); + } + /** * @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery */ @@ -284,7 +292,7 @@ private function rejectVerificationRequest(OwnerVerificationRequest $request): v RawMaterial::class => $this->rawMaterialService->rejectVerificationRequest($request), Purchase::class => $this->purchaseService->rejectVerificationRequest($request), MarketplaceSettings::class => null, - ProductVariant::class => $this->stockEcerService->rejectStockEcerTransfer($request), + ProductVariant::class => $this->stockRetailService->rejectStockRetailTransfer($request), default => throw ValidationException::withMessages([ 'subject_type' => 'Tipe data verifikasi tidak didukung.', ]), @@ -298,7 +306,7 @@ private function applyVerificationRequest(OwnerVerificationRequest $request): vo RawMaterial::class => $this->rawMaterialService->applyVerificationRequest($request), Purchase::class => $this->purchaseService->applyVerificationRequest($request), MarketplaceSettings::class => $this->marketplaceService->applyVerificationRequest($request), - ProductVariant::class => $this->stockEcerService->applyStockEcerTransfer($request), + ProductVariant::class => $this->stockRetailService->applyStockRetailTransfer($request), default => throw ValidationException::withMessages([ 'subject_type' => 'Tipe data verifikasi tidak didukung.', ]), diff --git a/app/Services/Manage/StockEcerService.php b/app/Services/Manage/StockRetailService.php similarity index 81% rename from app/Services/Manage/StockEcerService.php rename to app/Services/Manage/StockRetailService.php index 81fd5fb..eb0b16c 100644 --- a/app/Services/Manage/StockEcerService.php +++ b/app/Services/Manage/StockRetailService.php @@ -6,21 +6,21 @@ use App\Enums\OwnerVerificationStatus; use App\Models\OwnerVerificationRequest; use App\Models\ProductVariant; -use App\Models\StockEcerHistory; +use App\Models\StockRetailHistory; use App\Models\User; use App\Services\System\PushNotificationService; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; -class StockEcerService +class StockRetailService { public function __construct( private readonly PushNotificationService $pushNotificationService, ) {} /** - * Submit a stock ecer transfer request for owner verification. + * Submit a stock retail transfer request for owner verification. */ public function transfer(int $variantId, int $quantity, User $user, ?string $notes = null): OwnerVerificationRequest { @@ -41,7 +41,7 @@ public function transfer(int $variantId, int $quantity, User $user, ?string $not $existingPending = OwnerVerificationRequest::query() ->where('subject_type', ProductVariant::class) ->where('subject_id', $variant->id) - ->where('action', OwnerVerificationAction::STOCK_ECER_TRANSFER) + ->where('action', OwnerVerificationAction::STOCK_RETAIL_TRANSFER) ->where('status', OwnerVerificationStatus::PENDING) ->exists(); @@ -53,7 +53,7 @@ public function transfer(int $variantId, int $quantity, User $user, ?string $not try { $request = OwnerVerificationRequest::create([ - 'action' => OwnerVerificationAction::STOCK_ECER_TRANSFER, + 'action' => OwnerVerificationAction::STOCK_RETAIL_TRANSFER, 'status' => OwnerVerificationStatus::PENDING, 'subject_type' => ProductVariant::class, 'subject_id' => $variant->id, @@ -61,12 +61,12 @@ public function transfer(int $variantId, int $quantity, User $user, ?string $not 'payload' => [ 'old' => [ 'stock' => $variant->stock, - 'stock_ecer' => $variant->stock_ecer, + 'stock_retail' => $variant->stock_retail, ], 'new' => [ 'quantity' => $quantity, 'stock' => $variant->stock - $quantity, - 'stock_ecer' => $variant->stock_ecer + $quantity, + 'stock_retail' => $variant->stock_retail + $quantity, 'notes' => $notes, 'variant_name' => $variant->name, 'product_name' => $variant->product?->name ?? '-', @@ -103,9 +103,9 @@ public function transfer(int $variantId, int $quantity, User $user, ?string $not } /** - * Apply the stock ecer transfer when owner approves. + * Apply the stock retail transfer when owner approves. */ - public function applyStockEcerTransfer(OwnerVerificationRequest $request): void + public function applyStockRetailTransfer(OwnerVerificationRequest $request): void { $payload = $request->payload ?? []; $newData = $payload['new'] ?? []; @@ -122,19 +122,19 @@ public function applyStockEcerTransfer(OwnerVerificationRequest $request): void ->firstOrFail(); $stockBefore = $variant->stock; - $stockEcerBefore = $variant->stock_ecer; + $stockRetailBefore = $variant->stock_retail; $variant->decrement('stock', $quantity); - $variant->increment('stock_ecer', $quantity); + $variant->increment('stock_retail', $quantity); - StockEcerHistory::create([ + StockRetailHistory::create([ 'product_variant_id' => $variant->id, 'user_id' => $request->submitted_by_id, 'quantity' => $quantity, 'stock_before' => $stockBefore, - 'stock_ecer_before' => $stockEcerBefore, + 'stock_retail_before' => $stockRetailBefore, 'stock_after' => $stockBefore - $quantity, - 'stock_ecer_after' => $stockEcerBefore + $quantity, + 'stock_retail_after' => $stockRetailBefore + $quantity, 'notes' => $newData['notes'] ?? null, 'created_at' => now(), ]); @@ -142,9 +142,9 @@ public function applyStockEcerTransfer(OwnerVerificationRequest $request): void } /** - * Reject the stock ecer transfer (no-op since nothing changed). + * Reject the stock retail transfer (no-op since nothing changed). */ - public function rejectStockEcerTransfer(OwnerVerificationRequest $request): void + public function rejectStockRetailTransfer(OwnerVerificationRequest $request): void { // No-op: nothing was changed yet, so nothing to rollback. } diff --git a/app/Services/Master/CustomerService.php b/app/Services/Master/CustomerService.php index d4391bf..b869123 100644 --- a/app/Services/Master/CustomerService.php +++ b/app/Services/Master/CustomerService.php @@ -32,6 +32,11 @@ public function create(array $validated): void Customer::create($validated); } + public function createAndReturn(array $validated): Customer + { + return Customer::create($validated); + } + public function update(Customer $customer, array $validated): void { $customer->update($validated); diff --git a/app/Services/Master/ProductService.php b/app/Services/Master/ProductService.php index 1842a01..76bc43f 100644 --- a/app/Services/Master/ProductService.php +++ b/app/Services/Master/ProductService.php @@ -76,12 +76,12 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca $pendingRequest = $product->pendingOwnerVerificationRequest; - // Also check for pending stock ecer transfer on variants + // Also check for pending stock retail transfer on variants if ($pendingRequest === null) { $pendingRequest = OwnerVerificationRequest::query() ->where('subject_type', ProductVariant::class) ->whereIn('subject_id', $product->variants->pluck('id')) - ->where('action', OwnerVerificationAction::STOCK_ECER_TRANSFER) + ->where('action', OwnerVerificationAction::STOCK_RETAIL_TRANSFER) ->pending() ->latest() ->first(); @@ -100,6 +100,22 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca }); } + public function findForEdit(Product $product): Product + { + $product->load([ + 'categories', + 'variants' => fn ($query) => $query + ->with('media') + ->orderBy('created_at'), + ]); + + $product->variants->each(function (ProductVariant $variant): void { + $variant->setAttribute('images', MediaPresenter::collection($variant, 'images')); + }); + + return $product; + } + /** * @param array $validated */ @@ -119,7 +135,7 @@ public function create(array $validated, User $user): void $variant = $product->variants()->create([ 'name' => $variantData['name'], 'stock' => $variantData['stock'], - 'stock_ecer' => $variantData['stock_ecer'], + 'stock_retail' => $variantData['stock_retail'], ]); $this->syncVariantImages($variant, $variantData, $index); @@ -381,7 +397,7 @@ private function applyPayloadToProduct( $variant->update([ 'name' => $variantData['name'], 'stock' => $variantData['stock'], - 'stock_ecer' => $variantData['stock_ecer'], + 'stock_retail' => $variantData['stock_retail'], ]); if ($verificationRequest !== null) { @@ -394,7 +410,7 @@ private function applyPayloadToProduct( $variant = $product->variants()->create([ 'name' => $variantData['name'], 'stock' => $variantData['stock'], - 'stock_ecer' => $variantData['stock_ecer'], + 'stock_retail' => $variantData['stock_retail'], ]); if ($verificationRequest !== null) { @@ -539,7 +555,7 @@ private function snapshotProduct(Product $product): array 'id' => $variant->id, 'name' => $variant->name, 'stock' => $variant->stock, - 'stock_ecer' => $variant->stock_ecer, + 'stock_retail' => $variant->stock_retail, ]) ->all(), ]); @@ -575,7 +591,7 @@ private function buildPayloadFromValidated(array $validated): array 'id' => $variantData['id'] ?? null, 'name' => $variantData['name'], 'stock' => $variantData['stock'], - 'stock_ecer' => $variantData['stock_ecer'], + 'stock_retail' => $variantData['stock_retail'], 'remove_media_ids' => $variantData['remove_media_ids'] ?? [], ]) ->all(), diff --git a/app/Services/Master/RawMaterialService.php b/app/Services/Master/RawMaterialService.php index 571c943..2fd7b37 100644 --- a/app/Services/Master/RawMaterialService.php +++ b/app/Services/Master/RawMaterialService.php @@ -97,6 +97,19 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $st }); } + public function findForEdit(RawMaterial $rawMaterial): RawMaterial + { + $rawMaterial->load([ + 'prices' => fn ($query) => $query->orderBy('created_at')->with('media'), + ]); + + $rawMaterial->prices->each(function (RawMaterialPrice $price): void { + $price->setAttribute('images', MediaPresenter::collection($price, 'images')); + }); + + return $rawMaterial; + } + /** * @param array $validated */ diff --git a/app/Services/System/AnalysisService.php b/app/Services/System/AnalysisService.php index 9a529ed..00bb366 100644 --- a/app/Services/System/AnalysisService.php +++ b/app/Services/System/AnalysisService.php @@ -3,6 +3,7 @@ namespace App\Services\System; use App\Enums\OrderStatus; +use App\Enums\Role; use App\Models\Attendance; use App\Models\CashAccount; use App\Models\CashTransaction; @@ -498,7 +499,7 @@ public function getProductStock(): array ->selectRaw(' SUM(product_variants.stock) as total_stock, SUM(product_variants.reject_stock) as total_reject, - SUM(product_variants.stock_ecer) as total_ecer, + SUM(product_variants.stock_retail) as total_retail, COUNT(product_variants.id) as total_variants, COUNT(DISTINCT products.id) as total_products ') @@ -516,7 +517,7 @@ public function getProductStock(): array return [ 'total_stock' => (int) ($variants->total_stock ?? 0), 'total_reject' => (int) ($variants->total_reject ?? 0), - 'total_ecer' => (int) ($variants->total_ecer ?? 0), + 'total_retail' => (int) ($variants->total_retail ?? 0), 'total_value' => (int) ($totalValue ?? 0), 'total_products' => (int) ($variants->total_products ?? 0), 'total_variants' => (int) ($variants->total_variants ?? 0), @@ -577,4 +578,9 @@ public function getTopProducts(?Carbon $startDate = null, ?Carbon $endDate = nul ]) ->toArray(); } + + public function isManager(?User $user): bool + { + return $user?->hasAnyRole([Role::OWNER->value, Role::DEVELOPER->value, Role::DIREKTUR->value]) ?? false; + } } diff --git a/app/Services/System/HomepageService.php b/app/Services/System/HomepageService.php new file mode 100644 index 0000000..345e55e --- /dev/null +++ b/app/Services/System/HomepageService.php @@ -0,0 +1,77 @@ +getProducts(); + + $configuration = SystemConfiguration::instance(); + $logo = MediaPresenter::first($configuration, 'logo'); + $logoUrl = $logo['url'] ?? null; + + $settings = app(SystemSettings::class); + $socialSettings = app(SocialMediaSettings::class); + + return [ + 'categories' => $categories, + 'products' => $products, + 'appName' => $settings->app_name ?? 'DST Collection', + 'aboutApp' => $settings->about_app ?? '', + 'contactEmail' => $settings->email ?? '', + 'contactPhone' => $settings->phone ?? '', + 'contactAddress' => $settings->address ?? '', + 'logoUrl' => $logoUrl, + 'instagramUrl' => $socialSettings->instagram_url ?? null, + 'facebookUrl' => $socialSettings->facebook_url ?? null, + 'tiktokUrl' => $socialSettings->tiktok_url ?? null, + 'homepage' => $this->homepageSettingService->homepageData(), + ]; + } + + private function getProducts() + { + $products = Product::getActiveWithVariantsAndCategories(); + + $allVariantIds = $products + ->flatMap(fn ($product) => $product->variants->pluck('id')) + ->all(); + + $allPricesByVariant = $this->cuttingResultPriceResolver->latestPricesForVariants($allVariantIds); + + return $products->map(function ($product) use ($allPricesByVariant) { + $product->variants->each(function ($variant) use ($allPricesByVariant) { + $variant->setAttribute('images', MediaPresenter::collection($variant, 'images')); + $variantPrices = $allPricesByVariant->get($variant->id, collect()); + $variant->setAttribute( + 'prices', + $variantPrices->map(fn ($price) => [ + 'type' => $price->price_type->value, + 'type_label' => $price->price_type->label(), + 'price' => (int) $price->price, + 'price_formatted' => $price->price_formatted, + ])->values()->all(), + ); + }); + + return $product; + }); + } +} diff --git a/app/Services/System/NotificationService.php b/app/Services/System/NotificationService.php new file mode 100644 index 0000000..283472b --- /dev/null +++ b/app/Services/System/NotificationService.php @@ -0,0 +1,69 @@ +where('user_id', $user->id) + ->latest() + ->limit(20) + ->get() + ->map(fn (Notification $notification) => [ + 'id' => $notification->id, + 'title' => $notification->title, + 'body' => $notification->body, + 'url' => $notification->url, + 'is_read' => $notification->is_read, + 'created_at' => $notification->created_at->toIso8601String(), + ]); + + $unreadCount = Notification::query() + ->where('user_id', $user->id) + ->unread() + ->count(); + + return [ + 'notifications' => $notifications, + 'unread_count' => $unreadCount, + ]; + } + + public function markAsRead(Notification $notification, User $user): void + { + if ($notification->user_id !== $user->id) { + abort(403); + } + + $notification->markAsRead(); + } + + public function markAllAsRead(User $user): void + { + Notification::query() + ->where('user_id', $user->id) + ->unread() + ->update(['is_read' => true, 'read_at' => now()]); + } + + public function delete(Notification $notification, User $user): void + { + if ($notification->user_id !== $user->id) { + abort(403); + } + + $notification->delete(); + } + + public function deleteAll(User $user): void + { + Notification::query() + ->where('user_id', $user->id) + ->delete(); + } +} diff --git a/app/Services/System/RoleService.php b/app/Services/System/RoleService.php index 9c32b42..c610cb0 100644 --- a/app/Services/System/RoleService.php +++ b/app/Services/System/RoleService.php @@ -3,6 +3,7 @@ namespace App\Services\System; use App\Enums\Role as EnumsRole; +use App\Enums\Permission as PermissionEnum; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; @@ -31,6 +32,31 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator ->withQueryString(); } + public function permissionOptions(): array + { + return collect(PermissionEnum::cases()) + ->map(fn (PermissionEnum $permission) => [ + 'value' => $permission->value, + 'label' => $permission->label(), + 'group' => $permission->group(), + ]) + ->values() + ->all(); + } + + public function findForEdit(Role $role): array + { + $role->load('permissions'); + + return [ + 'role' => [ + 'id' => $role->id, + 'name' => $role->name, + 'permissions' => $role->permissions->pluck('name'), + ], + ]; + } + public function create(array $validated): void { try { diff --git a/app/Support/OwnerVerification/VerificationChangeFormatter.php b/app/Support/OwnerVerification/VerificationChangeFormatter.php index 2cf41fc..9b5aa22 100644 --- a/app/Support/OwnerVerification/VerificationChangeFormatter.php +++ b/app/Support/OwnerVerification/VerificationChangeFormatter.php @@ -77,7 +77,7 @@ private static function label(string $field): string 'prices' => 'Varian Harga', 'quantity' => 'Jumlah Transfer', 'stock' => 'Stok Bagus', - 'stock_ecer' => 'Stok Ecer', + 'stock_retail' => 'Stok Ecer', 'variant_name' => 'Varian', 'product_name' => 'Produk', 'tiktok_shop_platform_commission' => 'TikTok Shop - Komisi Platform', @@ -186,7 +186,7 @@ private static function presentValue(string $field, mixed $value): mixed return implode(', ', $value); } - if (in_array($field, ['quantity', 'stock', 'stock_ecer'], true)) { + if (in_array($field, ['quantity', 'stock', 'stock_retail'], true)) { return (int) $value; } diff --git a/database/migrations/2026_06_10_100003_create_product_variants_table.php b/database/migrations/2026_06_10_100003_create_product_variants_table.php index 0e93467..a4e3a3d 100644 --- a/database/migrations/2026_06_10_100003_create_product_variants_table.php +++ b/database/migrations/2026_06_10_100003_create_product_variants_table.php @@ -16,7 +16,7 @@ public function up(): void $table->string('name', 200); $table->unsignedInteger('stock')->default(0); $table->unsignedInteger('reject_stock')->default(0); - $table->unsignedInteger('stock_ecer')->default(0); + $table->unsignedInteger('stock_retail')->default(0); $table->timestamp('created_at')->useCurrent(); $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); diff --git a/database/migrations/2026_06_28_094400_create_stock_ecer_histories_table.php b/database/migrations/2026_06_28_094400_create_stock_retail_histories_table.php similarity index 74% rename from database/migrations/2026_06_28_094400_create_stock_ecer_histories_table.php rename to database/migrations/2026_06_28_094400_create_stock_retail_histories_table.php index 7c7f859..afb7125 100644 --- a/database/migrations/2026_06_28_094400_create_stock_ecer_histories_table.php +++ b/database/migrations/2026_06_28_094400_create_stock_retail_histories_table.php @@ -8,7 +8,7 @@ { public function up(): void { - Schema::create('stock_ecer_histories', function (Blueprint $table) { + Schema::create('stock_retail_histories', function (Blueprint $table) { $table->id(); $table->foreignId('product_variant_id')->constrained('product_variants')->cascadeOnDelete(); @@ -16,9 +16,9 @@ public function up(): void $table->unsignedInteger('quantity'); $table->unsignedInteger('stock_before'); - $table->unsignedInteger('stock_ecer_before'); + $table->unsignedInteger('stock_retail_before'); $table->unsignedInteger('stock_after'); - $table->unsignedInteger('stock_ecer_after'); + $table->unsignedInteger('stock_retail_after'); $table->string('notes')->nullable(); $table->timestamp('created_at')->useCurrent(); @@ -27,6 +27,6 @@ public function up(): void public function down(): void { - Schema::dropIfExists('stock_ecer_histories'); + Schema::dropIfExists('stock_retail_histories'); } }; diff --git a/resources/js/components/card/ProductCard.vue b/resources/js/components/card/ProductCard.vue index 571b806..dbecf0e 100644 --- a/resources/js/components/card/ProductCard.vue +++ b/resources/js/components/card/ProductCard.vue @@ -52,7 +52,7 @@ const getProductImage = (product: Product, indexOffset = 0) => { const getProductPriceRange = (product: Product) => { const prices = product.variants.flatMap(v => - v.prices.filter(p => p.type === 'ecer').map(p => p.price) + v.prices.filter(p => p.type === 'retail').map(p => p.price) ); if (prices.length === 0) { diff --git a/resources/js/components/modal/ProductModal.vue b/resources/js/components/modal/ProductModal.vue index fbba607..df1b1eb 100644 --- a/resources/js/components/modal/ProductModal.vue +++ b/resources/js/components/modal/ProductModal.vue @@ -14,7 +14,7 @@ const emit = defineEmits<{ }>(); const activeVariant = ref(null); -const activePriceType = ref('ecer'); +const activePriceType = ref('retail'); const activeImageIndex = ref(0); const allImages = computed(() => { @@ -40,7 +40,7 @@ const mainImage = computed(() => { watch(() => props.product, (newProduct) => { if (newProduct) { activeVariant.value = newProduct.variants[0] || null; - activePriceType.value = 'ecer'; + activePriceType.value = 'retail'; activeImageIndex.value = 0; } else { activeVariant.value = null; diff --git a/resources/js/components/modal/StockEcerTransferModal.vue b/resources/js/components/modal/StockRetailTransferModal.vue similarity index 93% rename from resources/js/components/modal/StockEcerTransferModal.vue rename to resources/js/components/modal/StockRetailTransferModal.vue index 937176c..78b2797 100644 --- a/resources/js/components/modal/StockEcerTransferModal.vue +++ b/resources/js/components/modal/StockRetailTransferModal.vue @@ -20,7 +20,7 @@ import { } from '@/components/ui/field'; import { Textarea } from '@/components/ui/textarea'; import { apiFetch } from '@/lib/api'; -import { transfer } from '@/routes/admin/manage/stock-ecer'; +import { transfer } from '@/routes/admin/manage/stock-retail'; const open = defineModel('open', { default: false }); @@ -29,7 +29,7 @@ const props = defineProps<{ variantName: string; productName: string; stockBagus: number; - stockEcer: number; + stockRetail: number; }>(); const emit = defineEmits<{ @@ -116,7 +116,7 @@ function formatNumber(value: number): string {
Stok Ecer - {{ formatNumber(stockEcer) }} + {{ formatNumber(stockRetail) }}
@@ -128,9 +128,9 @@ function formatNumber(value: number): string {
- Jumlah Transfer + Jumlah Transfer - Catatan + Catatan