diff --git a/app/Http/Controllers/Admin/Master/ProductController.php b/app/Http/Controllers/Admin/Master/ProductController.php index 10b366a..71e58f4 100644 --- a/app/Http/Controllers/Admin/Master/ProductController.php +++ b/app/Http/Controllers/Admin/Master/ProductController.php @@ -2,16 +2,14 @@ namespace App\Http\Controllers\Admin\Master; -use App\Enums\Permission; -use App\Enums\ProductStatus; use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\Master\ProductRequest; -use App\Http\Requests\Admin\Master\RejectProductRequest; +use App\Http\Requests\Admin\ToggleStatusRequest; use App\Models\Product; use App\Models\ProductVariant; -use App\Services\Manage\CuttingService; +use App\Services\Master\CategoryService; use App\Services\Master\ProductService; use App\Support\Media\MediaPresenter; use Illuminate\Http\RedirectResponse; @@ -24,8 +22,8 @@ class ProductController extends Controller use FlashesEntityMessage, ParsesDataTableQuery; public function __construct( - private readonly CuttingService $cuttingService, private readonly ProductService $productService, + private readonly CategoryService $categoryService, ) {} public function index(Request $request): Response @@ -33,18 +31,15 @@ public function index(Request $request): Response $tableQuery = $this->parseDataTableQuery($request); $isActive = $request->string('is_active')->toString(); $categoryId = $request->string('category_id')->toString(); - $status = $request->string('status')->toString(); + $stockStatus = $request->string('stock_status')->toString(); return Inertia::render('admin/master/products/Index', [ - 'products' => $this->productService->paginateForIndex($tableQuery, $isActive, $categoryId, $status), - 'completedCuttings' => $this->cuttingService->getCompletedCuttings($request->user()), - 'outOfStockGroups' => $this->productService->outOfStockGroups(), - 'stockWarningGroups' => $this->productService->stockWarningGroups(), - 'categories' => $this->productService->categoryOptions(), + 'products' => $this->productService->paginateForIndex($tableQuery, $isActive, $categoryId, $stockStatus), + 'categories' => $this->categoryService->getSelectOptions(), 'filters' => $this->dataTableFilters($tableQuery, [ 'is_active' => $isActive, 'category_id' => $categoryId, - 'status' => $status, + 'stock_status' => $stockStatus, ]), ]); } @@ -52,23 +47,15 @@ public function index(Request $request): Response public function create(): Response { return Inertia::render('admin/master/products/Create', [ - 'categories' => $this->productService->categoryOptions(), + 'categories' => $this->categoryService->getSelectOptions(), ]); } public function store(ProductRequest $request): RedirectResponse { - $this->productService->create($request->validated(), $request->user()); + $this->productService->create($request->validated()); - $message = $request->user()?->can(Permission::PRODUCTS_VERIFY->value) - ? null - : 'Pengajuan produk berhasil dikirim dan menunggu persetujuan owner.'; - - if ($message) { - $this->flashSuccess($message); - } else { - $this->flashCreated('Produk'); - } + $this->flashCreated('Produk'); return redirect()->route('admin.master.products.index'); } @@ -87,35 +74,23 @@ public function edit(Product $product): Response }); return Inertia::render('admin/master/products/Edit', [ - 'categories' => $this->productService->categoryOptions(), + 'categories' => $this->categoryService->getSelectOptions(), 'product' => $product, ]); } public function update(ProductRequest $request, Product $product): RedirectResponse { - $this->productService->update($product, $request->validated(), $request->user()); + $this->productService->update($product, $request->validated()); - $message = $request->user()?->can(Permission::PRODUCTS_VERIFY->value) - ? null - : 'Pengajuan perubahan produk berhasil dikirim dan menunggu persetujuan owner.'; - - if ($message) { - $this->flashSuccess($message); - } else { - $this->flashUpdated('Produk'); - } + $this->flashUpdated('Produk'); return redirect()->route('admin.master.products.index'); } - public function toggleStatus(Request $request, Product $product): RedirectResponse + public function toggleStatus(ToggleStatusRequest $request, Product $product): RedirectResponse { - $validated = $request->validate([ - 'is_active' => ['required', 'boolean'], - ]); - - $this->productService->toggleStatus($product, $validated['is_active'], $request->user()); + $this->productService->toggleStatus($product, $request->validated()); $this->flashStatusUpdated('produk'); @@ -124,56 +99,9 @@ public function toggleStatus(Request $request, Product $product): RedirectRespon public function destroy(Product $product): RedirectResponse { - $user = auth()->user(); + $this->productService->delete($product); - if ($user?->can(Permission::PRODUCTS_VERIFY->value)) { - $this->productService->delete($product, $user); - $this->flashDeleted('Produk'); - - return redirect()->route('admin.master.products.index'); - } - - $wasDeleteRequest = $product->status === ProductStatus::APPROVED; - - $this->productService->delete($product, $user); - - if ($wasDeleteRequest) { - $this->flashSuccess('Pengajuan penghapusan produk berhasil dikirim dan menunggu persetujuan owner.'); - } else { - $this->flashDeleted('Produk'); - } - - return redirect()->route('admin.master.products.index'); - } - - public function approve(Product $product): RedirectResponse - { - $user = auth()->user(); - if ($user && ! $user->hasAnyRole(['owner', 'developer'])) { - abort(403); - } - - $this->productService->approve($product, $user); - - $this->flashSuccess('Produk berhasil disetujui.'); - - return redirect()->route('admin.master.products.index'); - } - - public function reject(RejectProductRequest $request, Product $product): RedirectResponse - { - $user = auth()->user(); - if ($user && ! $user->hasAnyRole(['owner', 'developer'])) { - abort(403); - } - - $this->productService->reject( - $product, - $request->validated('reason'), - $user, - ); - - $this->flashSuccess('Produk berhasil ditolak.'); + $this->flashDeleted('Produk'); return redirect()->route('admin.master.products.index'); } diff --git a/app/Services/Master/CategoryService.php b/app/Services/Master/CategoryService.php index fddd639..dd5b909 100644 --- a/app/Services/Master/CategoryService.php +++ b/app/Services/Master/CategoryService.php @@ -59,4 +59,19 @@ private function applySorting(Builder $query, string $sort, string $direction): $query->latest(); } + + /** + * @return list + */ + public function getSelectOptions(): array + { + return Category::query() + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn (Category $category) => [ + 'value' => $category->id, + 'label' => $category->name, + ]) + ->all(); + } } diff --git a/app/Services/Master/ProductService.php b/app/Services/Master/ProductService.php index 3379c01..7047cc3 100644 --- a/app/Services/Master/ProductService.php +++ b/app/Services/Master/ProductService.php @@ -2,22 +2,13 @@ namespace App\Services\Master; -use App\Enums\Permission; -use App\Enums\ProductPendingAction; -use App\Enums\ProductStatus; -use App\Models\Category; use App\Models\Product; use App\Models\ProductVariant; -use App\Models\User; -use App\Services\Manage\CuttingResultPriceResolver; use App\Services\Media\MediaService; -use App\Services\System\PushNotificationService; use App\Support\Media\MediaPresenter; -use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; -use Illuminate\Validation\ValidationException; class ProductService { @@ -25,138 +16,40 @@ class ProductService public function __construct( private readonly MediaService $mediaService, - private readonly CuttingResultPriceResolver $cuttingResultPriceResolver, - private readonly PushNotificationService $pushNotificationService, ) {} - /** - * @return list>, - * }>, - * }> - */ - public function outOfStockGroups(): array - { - return $this->inventoryAlertGroups( - fn (ProductVariant $variant): bool => $variant->stock <= 0, - ); - } - - /** - * @return list>, - * }>, - * }> - */ - public function stockWarningGroups(): array - { - return $this->inventoryAlertGroups(function (ProductVariant $variant): bool { - if ($variant->stock <= 0) { - return false; - } - - return $variant->stock < ProductVariant::minStock(); - }); - } - - /** - * @return list>, - * }>, - * }> - */ - private function inventoryAlertGroups(callable $filter): array - { - return ProductVariant::query() - ->with(['product', 'media']) - ->whereHas('product', fn (Builder $query) => $query - ->where('is_active', true) - ->where('status', ProductStatus::APPROVED)) - ->get() - ->filter($filter) - ->sortBy([ - fn (ProductVariant $variant) => $variant->product->name, - fn (ProductVariant $variant) => $variant->name, - ]) - ->groupBy('product_id') - ->map(function ($variants, $productId) { - $product = $variants->first()->product; - - return [ - 'id' => (int) $productId, - 'name' => $product->name, - 'edit_url' => route('admin.master.products.edit', $productId), - 'variants' => $variants->map(function (ProductVariant $variant): array { - return [ - 'id' => $variant->id, - 'name' => $variant->name, - 'stock_formatted' => number_format($variant->stock, 0, ',', '.').' pcs', - 'images' => MediaPresenter::collection($variant, 'images'), - ]; - })->values()->all(), - ]; - }) - ->values() - ->all(); - } - /** * @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery */ - public function paginateForIndex(array $tableQuery, string $isActive, string $categoryId = '', string $status = ''): LengthAwarePaginator + public function paginateForIndex(array $tableQuery, string $isActive, string $categoryId = '', string $stockStatus = ''): LengthAwarePaginator { $query = Product::query() ->with([ 'categories', - 'rejection', 'variants' => fn ($query) => $query ->with('media') ->orderBy('created_at'), ]) ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { $search = $tableQuery['search']; - $query->where(function (Builder $query) use ($search) { + $query->where(function (Builder $query) use ($search): void { $query->where('name', 'like', "%{$search}%") ->orWhere('slug', 'like', "%{$search}%") ->orWhere('description', 'like', "%{$search}%") ->orWhereHas('categories', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")) - ->orWhereHas('variants', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")) - ->orWhereHas('rejection', fn (Builder $query) => $query->where('reason', 'like', "%{$search}%")); + ->orWhereHas('variants', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")); }); }) - ->when( - $isActive !== '', - fn (Builder $query) => $query->where('is_active', $isActive === '1') - ) - ->when( - $status !== '', - fn (Builder $query) => $query->where('status', $status) - ) - ->when( - $categoryId !== '', - fn (Builder $query) => $query->whereHas('categories', fn (Builder $query) => $query->where('categories.id', $categoryId)) - ); + ->when($isActive !== '', fn (Builder $query) => $query->where('is_active', $isActive === '1')) + ->when($categoryId !== '', function (Builder $query) use ($categoryId): void { + $query->whereHas('categories', fn (Builder $query) => $query->where('categories.id', $categoryId)); + }) + ->when($stockStatus === 'out_of_stock', function (Builder $query): void { + $query->whereHas('variants', fn (Builder $q) => $q->where('stock', '<=', 0)); + }) + ->when($stockStatus === 'low_stock', function (Builder $query): void { + $query->whereHas('variants', fn (Builder $q) => $q->where('stock', '>', 0)->where('stock', '<', ProductVariant::minStock())); + }); $this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']); @@ -169,48 +62,22 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca 'images', MediaPresenter::collection($variant, 'images'), ); - $variant->setAttribute( - 'prices', - $this->presentVariantPrices($variant->id), - ); }); return $product; }); } - /** - * @return list - */ - public function categoryOptions(): array - { - return Category::query() - ->orderBy('name') - ->get(['id', 'name']) - ->map(fn (Category $category) => [ - 'value' => $category->id, - 'label' => $category->name, - ]) - ->all(); - } - /** * @param array $validated */ - public function create(array $validated, User $user): void + public function create(array $validated): void { - DB::transaction(function () use ($validated, $user): void { - $autoApprove = $this->userCanAutoApprove($user); - + DB::transaction(function () use ($validated): void { $product = Product::create([ 'name' => $validated['name'], 'description' => $validated['description'] ?? null, - 'is_active' => $autoApprove, - 'status' => $autoApprove ? ProductStatus::APPROVED : ProductStatus::PENDING, - 'was_ever_approved' => $autoApprove, - 'submitted_by_id' => $autoApprove ? null : $user->id, - 'verified_at' => $autoApprove ? Carbon::now() : null, - 'verified_by_id' => $autoApprove ? $user->id : null, + 'is_active' => true, ]); $product->categories()->sync($validated['category_ids']); @@ -218,43 +85,17 @@ public function create(array $validated, User $user): void foreach ($validated['variants'] as $index => $variantData) { $this->createVariant($product, $variantData, $index); } - - if (! $autoApprove) { - $this->notifyOwnersOfPendingProduct($product, 'baru'); - } }); } /** * @param array $validated */ - public function update(Product $product, array $validated, User $user): void + public function update(Product $product, array $validated): void { - $this->ensureEditable($product, 'Produk hanya dapat diubah saat status menunggu atau ditolak.'); - - DB::transaction(function () use ($validated, $product, $user): void { - $autoApprove = $this->userCanAutoApprove($user); - + DB::transaction(function () use ($validated, $product): void { $product->name = $validated['name']; $product->description = $validated['description'] ?? null; - - if ($autoApprove) { - $product->status = ProductStatus::APPROVED; - $product->pending_action = null; - $product->was_ever_approved = true; - $product->verified_at = Carbon::now(); - $product->verified_by_id = $user->id; - $product->submitted_by_id = null; - } else { - $product->status = ProductStatus::PENDING; - $product->pending_action = null; - $product->verified_at = null; - $product->verified_by_id = null; - $product->submitted_by_id = $user->id; - $product->is_active = false; - $product->rejection()?->delete(); - } - $product->save(); $product->categories()->sync($validated['category_ids']); @@ -286,185 +127,16 @@ public function update(Product $product, array $validated, User $user): void $this->createVariant($product, $variantData, $index); } - - if (! $autoApprove) { - $this->notifyOwnersOfPendingProduct($product, 'diperbarui'); - } }); } - public function toggleStatus(Product $product, bool $isActive, User $user): void + public function toggleStatus(Product $product, array $validated): void { - if (! $this->userCanAutoApprove($user)) { - throw ValidationException::withMessages([ - 'is_active' => 'Perubahan status produk memerlukan persetujuan owner.', - ]); - } - - $this->ensureApproved($product, 'Status produk hanya dapat diubah setelah disetujui.'); - - $product->is_active = $isActive; + $product->is_active = $validated['is_active']; $product->save(); } - public function delete(Product $product, User $user): void - { - if ($this->userCanAutoApprove($user)) { - $this->performDelete($product); - - return; - } - - if ($product->status === ProductStatus::PENDING && $product->pending_action === null) { - $this->performDelete($product); - - return; - } - - if ($product->status === ProductStatus::REJECTED && $product->pending_action === null) { - $this->performDelete($product); - - return; - } - - $this->ensureApproved($product, 'Pengajuan penghapusan hanya dapat dilakukan untuk produk yang sudah disetujui.'); - - DB::transaction(function () use ($product, $user): void { - $product->status = ProductStatus::PENDING; - $product->pending_action = ProductPendingAction::DELETE; - $product->is_active = false; - $product->submitted_by_id = $user->id; - $product->verified_at = null; - $product->verified_by_id = null; - $product->rejection()?->delete(); - $product->save(); - - $this->notifyOwnersOfPendingProduct($product, 'dihapus'); - }); - } - - public function approve(Product $product, User $user): void - { - $this->ensurePending($product, 'Produk ini sudah diverifikasi.'); - - if ($product->pending_action === ProductPendingAction::DELETE) { - $submittedById = $product->submitted_by_id; - $productName = $product->name; - - DB::transaction(function () use ($product): void { - $this->performDelete($product); - }); - - if ($submittedById) { - $this->pushNotificationService->sendToUser( - '✅ Penghapusan Produk Disetujui', - "Pengajuan penghapusan produk {$productName} telah disetujui.", - $submittedById, - '/admin/master/products', - ); - } - - return; - } - - DB::transaction(function () use ($product, $user): void { - $product->status = ProductStatus::APPROVED; - $product->pending_action = null; - $product->is_active = true; - $product->was_ever_approved = true; - $product->verified_at = Carbon::now(); - $product->verified_by_id = $user->id; - $product->save(); - - $product->rejection()?->delete(); - }); - - $product->loadMissing('submittedBy'); - if ($product->submitted_by_id) { - $this->pushNotificationService->sendToUser( - '✅ Produk Disetujui', - "Produk {$product->name} telah disetujui dan aktif.", - $product->submitted_by_id, - '/admin/master/products', - ); - } - } - - public function reject(Product $product, string $reason, User $user): void - { - $this->ensurePending($product, 'Produk ini sudah diverifikasi.'); - - if ($product->pending_action === ProductPendingAction::DELETE) { - DB::transaction(function () use ($product, $user, $reason): void { - $product->status = ProductStatus::APPROVED; - $product->pending_action = null; - $product->is_active = true; - $product->verified_at = Carbon::now(); - $product->verified_by_id = $user->id; - $product->save(); - - $product->rejection()->create([ - 'reason' => $reason, - 'rejected_by_id' => $user->id, - ]); - }); - - $product->loadMissing('submittedBy'); - if ($product->submitted_by_id) { - $this->pushNotificationService->sendToUser( - '❌ Penghapusan Produk Ditolak', - "Pengajuan penghapusan produk {$product->name} ditolak dengan alasan: '{$reason}'.", - $product->submitted_by_id, - '/admin/master/products', - ); - } - - return; - } - - DB::transaction(function () use ($product, $user, $reason): void { - $submittedById = $product->submitted_by_id; - $productName = $product->name; - - if (! $product->was_ever_approved) { - $this->performDelete($product); - - if ($submittedById) { - $this->pushNotificationService->sendToUser( - '❌ Produk Ditolak', - "Pengajuan produk {$productName} ditolak dengan alasan: '{$reason}'.", - $submittedById, - '/admin/master/products', - ); - } - - return; - } - - $product->status = ProductStatus::REJECTED; - $product->pending_action = null; - $product->is_active = false; - $product->verified_at = Carbon::now(); - $product->verified_by_id = $user->id; - $product->save(); - - $product->rejection()->create([ - 'reason' => $reason, - 'rejected_by_id' => $user->id, - ]); - - if ($submittedById) { - $this->pushNotificationService->sendToUser( - '❌ Produk Ditolak', - "Pengajuan produk {$productName} ditolak dengan alasan: '{$reason}'.", - $submittedById, - '/admin/master/products', - ); - } - }); - } - - private function performDelete(Product $product): void + public function delete(Product $product): void { DB::transaction(function () use ($product): void { $product->variants()->each(function (ProductVariant $variant): void { @@ -472,56 +144,13 @@ private function performDelete(Product $product): void }); $product->variants()->delete(); $product->categories()->detach(); - $product->rejection()?->delete(); $product->delete(); }); } - private function notifyOwnersOfPendingProduct(Product $product, string $actionLabel): void - { - $this->pushNotificationService->sendToRoles( - '📦 Pengajuan Produk Baru', - "Produk {$product->name} {$actionLabel} dan menunggu persetujuan owner.", - ['owner', 'developer'], - '/admin/master/products', - ); - } - - private function userCanAutoApprove(User $user): bool - { - return $user->can(Permission::PRODUCTS_VERIFY->value); - } - - private function ensurePending(Product $product, string $message): void - { - if ($product->status !== ProductStatus::PENDING) { - throw ValidationException::withMessages([ - 'product' => $message, - ]); - } - } - - private function ensureApproved(Product $product, string $message): void - { - if ($product->status !== ProductStatus::APPROVED) { - throw ValidationException::withMessages([ - 'product' => $message, - ]); - } - } - - private function ensureEditable(Product $product, string $message): void - { - if (! in_array($product->status, [ProductStatus::PENDING, ProductStatus::REJECTED], true)) { - throw ValidationException::withMessages([ - 'product' => $message, - ]); - } - } - private function applySorting(Builder $query, string $sort, string $direction): void { - if (in_array($sort, ['name', 'slug', 'is_active', 'status'], true)) { + if (in_array($sort, ['name', 'slug', 'is_active'], true)) { $query->orderBy($sort, $direction); return; @@ -560,31 +189,4 @@ private function syncVariantImages(ProductVariant $variant, array $variantData, errorKey: "variants.{$index}.images", ); } - - /** - * @return list - */ - private function presentVariantPrices(int $variantId): array - { - return collect($this->cuttingResultPriceResolver->latestPricesForVariant($variantId)) - ->map(fn ($price) => [ - 'type' => $price->price_type->value, - 'type_label' => $price->price_type->label(), - 'price' => (int) $price->price, - 'price_formatted' => $price->price_formatted, - 'price_input' => (string) $price->price, - 'cost_per_unit' => (int) $price->cost_per_unit, - 'cost_per_unit_formatted' => $price->cost_per_unit_formatted, - ]) - ->values() - ->all(); - } } diff --git a/resources/js/components/admin/master/products/CuttingVerificationSection.vue b/resources/js/components/admin/master/products/CuttingVerificationSection.vue deleted file mode 100644 index 9b30fe1..0000000 --- a/resources/js/components/admin/master/products/CuttingVerificationSection.vue +++ /dev/null @@ -1,678 +0,0 @@ - - -