refactor: streamline product management by removing unused components, enhancing form structure, and integrating category service for improved data handling
This commit is contained in:
parent
8ea6f3cca1
commit
ab03d5f0b9
@ -2,16 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Admin\Master;
|
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\FlashesEntityMessage;
|
||||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Master\ProductRequest;
|
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\Product;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Services\Manage\CuttingService;
|
use App\Services\Master\CategoryService;
|
||||||
use App\Services\Master\ProductService;
|
use App\Services\Master\ProductService;
|
||||||
use App\Support\Media\MediaPresenter;
|
use App\Support\Media\MediaPresenter;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@ -24,8 +22,8 @@ class ProductController extends Controller
|
|||||||
use FlashesEntityMessage, ParsesDataTableQuery;
|
use FlashesEntityMessage, ParsesDataTableQuery;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CuttingService $cuttingService,
|
|
||||||
private readonly ProductService $productService,
|
private readonly ProductService $productService,
|
||||||
|
private readonly CategoryService $categoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
@ -33,18 +31,15 @@ public function index(Request $request): Response
|
|||||||
$tableQuery = $this->parseDataTableQuery($request);
|
$tableQuery = $this->parseDataTableQuery($request);
|
||||||
$isActive = $request->string('is_active')->toString();
|
$isActive = $request->string('is_active')->toString();
|
||||||
$categoryId = $request->string('category_id')->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', [
|
return Inertia::render('admin/master/products/Index', [
|
||||||
'products' => $this->productService->paginateForIndex($tableQuery, $isActive, $categoryId, $status),
|
'products' => $this->productService->paginateForIndex($tableQuery, $isActive, $categoryId, $stockStatus),
|
||||||
'completedCuttings' => $this->cuttingService->getCompletedCuttings($request->user()),
|
'categories' => $this->categoryService->getSelectOptions(),
|
||||||
'outOfStockGroups' => $this->productService->outOfStockGroups(),
|
|
||||||
'stockWarningGroups' => $this->productService->stockWarningGroups(),
|
|
||||||
'categories' => $this->productService->categoryOptions(),
|
|
||||||
'filters' => $this->dataTableFilters($tableQuery, [
|
'filters' => $this->dataTableFilters($tableQuery, [
|
||||||
'is_active' => $isActive,
|
'is_active' => $isActive,
|
||||||
'category_id' => $categoryId,
|
'category_id' => $categoryId,
|
||||||
'status' => $status,
|
'stock_status' => $stockStatus,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -52,23 +47,15 @@ public function index(Request $request): Response
|
|||||||
public function create(): Response
|
public function create(): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/master/products/Create', [
|
return Inertia::render('admin/master/products/Create', [
|
||||||
'categories' => $this->productService->categoryOptions(),
|
'categories' => $this->categoryService->getSelectOptions(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(ProductRequest $request): RedirectResponse
|
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');
|
return redirect()->route('admin.master.products.index');
|
||||||
}
|
}
|
||||||
@ -87,35 +74,23 @@ public function edit(Product $product): Response
|
|||||||
});
|
});
|
||||||
|
|
||||||
return Inertia::render('admin/master/products/Edit', [
|
return Inertia::render('admin/master/products/Edit', [
|
||||||
'categories' => $this->productService->categoryOptions(),
|
'categories' => $this->categoryService->getSelectOptions(),
|
||||||
'product' => $product,
|
'product' => $product,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(ProductRequest $request, Product $product): RedirectResponse
|
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');
|
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([
|
$this->productService->toggleStatus($product, $request->validated());
|
||||||
'is_active' => ['required', 'boolean'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->productService->toggleStatus($product, $validated['is_active'], $request->user());
|
|
||||||
|
|
||||||
$this->flashStatusUpdated('produk');
|
$this->flashStatusUpdated('produk');
|
||||||
|
|
||||||
@ -124,57 +99,10 @@ public function toggleStatus(Request $request, Product $product): RedirectRespon
|
|||||||
|
|
||||||
public function destroy(Product $product): RedirectResponse
|
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');
|
$this->flashDeleted('Produk');
|
||||||
|
|
||||||
return redirect()->route('admin.master.products.index');
|
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.');
|
|
||||||
|
|
||||||
return redirect()->route('admin.master.products.index');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,4 +59,19 @@ private function applySorting(Builder $query, string $sort, string $direction):
|
|||||||
|
|
||||||
$query->latest();
|
$query->latest();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{value: int, label: string}>
|
||||||
|
*/
|
||||||
|
public function getSelectOptions(): array
|
||||||
|
{
|
||||||
|
return Category::query()
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name'])
|
||||||
|
->map(fn (Category $category) => [
|
||||||
|
'value' => $category->id,
|
||||||
|
'label' => $category->name,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,22 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Services\Master;
|
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\Product;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Manage\CuttingResultPriceResolver;
|
|
||||||
use App\Services\Media\MediaService;
|
use App\Services\Media\MediaService;
|
||||||
use App\Services\System\PushNotificationService;
|
|
||||||
use App\Support\Media\MediaPresenter;
|
use App\Support\Media\MediaPresenter;
|
||||||
use Carbon\Carbon;
|
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class ProductService
|
class ProductService
|
||||||
{
|
{
|
||||||
@ -25,138 +16,40 @@ class ProductService
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MediaService $mediaService,
|
private readonly MediaService $mediaService,
|
||||||
private readonly CuttingResultPriceResolver $cuttingResultPriceResolver,
|
|
||||||
private readonly PushNotificationService $pushNotificationService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{
|
|
||||||
* id: int,
|
|
||||||
* name: string,
|
|
||||||
* edit_url: string,
|
|
||||||
* variants: list<array{
|
|
||||||
* id: int,
|
|
||||||
* name: string,
|
|
||||||
* stock_formatted: string,
|
|
||||||
* images: list<array<string, mixed>>,
|
|
||||||
* }>,
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
public function outOfStockGroups(): array
|
|
||||||
{
|
|
||||||
return $this->inventoryAlertGroups(
|
|
||||||
fn (ProductVariant $variant): bool => $variant->stock <= 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{
|
|
||||||
* id: int,
|
|
||||||
* name: string,
|
|
||||||
* edit_url: string,
|
|
||||||
* variants: list<array{
|
|
||||||
* id: int,
|
|
||||||
* name: string,
|
|
||||||
* stock_formatted: string,
|
|
||||||
* images: list<array<string, mixed>>,
|
|
||||||
* }>,
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
public function stockWarningGroups(): array
|
|
||||||
{
|
|
||||||
return $this->inventoryAlertGroups(function (ProductVariant $variant): bool {
|
|
||||||
if ($variant->stock <= 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $variant->stock < ProductVariant::minStock();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{
|
|
||||||
* id: int,
|
|
||||||
* name: string,
|
|
||||||
* edit_url: string,
|
|
||||||
* variants: list<array{
|
|
||||||
* id: int,
|
|
||||||
* name: string,
|
|
||||||
* stock_formatted: string,
|
|
||||||
* images: list<array<string, mixed>>,
|
|
||||||
* }>,
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
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
|
* @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()
|
$query = Product::query()
|
||||||
->with([
|
->with([
|
||||||
'categories',
|
'categories',
|
||||||
'rejection',
|
|
||||||
'variants' => fn ($query) => $query
|
'variants' => fn ($query) => $query
|
||||||
->with('media')
|
->with('media')
|
||||||
->orderBy('created_at'),
|
->orderBy('created_at'),
|
||||||
])
|
])
|
||||||
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
|
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
|
||||||
$search = $tableQuery['search'];
|
$search = $tableQuery['search'];
|
||||||
$query->where(function (Builder $query) use ($search) {
|
$query->where(function (Builder $query) use ($search): void {
|
||||||
$query->where('name', 'like', "%{$search}%")
|
$query->where('name', 'like', "%{$search}%")
|
||||||
->orWhere('slug', 'like', "%{$search}%")
|
->orWhere('slug', 'like', "%{$search}%")
|
||||||
->orWhere('description', 'like', "%{$search}%")
|
->orWhere('description', 'like', "%{$search}%")
|
||||||
->orWhereHas('categories', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"))
|
->orWhereHas('categories', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"))
|
||||||
->orWhereHas('variants', 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}%"));
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
->when(
|
->when($isActive !== '', fn (Builder $query) => $query->where('is_active', $isActive === '1'))
|
||||||
$isActive !== '',
|
->when($categoryId !== '', function (Builder $query) use ($categoryId): void {
|
||||||
fn (Builder $query) => $query->where('is_active', $isActive === '1')
|
$query->whereHas('categories', fn (Builder $query) => $query->where('categories.id', $categoryId));
|
||||||
)
|
})
|
||||||
->when(
|
->when($stockStatus === 'out_of_stock', function (Builder $query): void {
|
||||||
$status !== '',
|
$query->whereHas('variants', fn (Builder $q) => $q->where('stock', '<=', 0));
|
||||||
fn (Builder $query) => $query->where('status', $status)
|
})
|
||||||
)
|
->when($stockStatus === 'low_stock', function (Builder $query): void {
|
||||||
->when(
|
$query->whereHas('variants', fn (Builder $q) => $q->where('stock', '>', 0)->where('stock', '<', ProductVariant::minStock()));
|
||||||
$categoryId !== '',
|
});
|
||||||
fn (Builder $query) => $query->whereHas('categories', fn (Builder $query) => $query->where('categories.id', $categoryId))
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
|
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
|
||||||
|
|
||||||
@ -169,48 +62,22 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
|
|||||||
'images',
|
'images',
|
||||||
MediaPresenter::collection($variant, 'images'),
|
MediaPresenter::collection($variant, 'images'),
|
||||||
);
|
);
|
||||||
$variant->setAttribute(
|
|
||||||
'prices',
|
|
||||||
$this->presentVariantPrices($variant->id),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return $product;
|
return $product;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{value: int, label: string}>
|
|
||||||
*/
|
|
||||||
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<string, mixed> $validated
|
* @param array<string, mixed> $validated
|
||||||
*/
|
*/
|
||||||
public function create(array $validated, User $user): void
|
public function create(array $validated): void
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($validated, $user): void {
|
DB::transaction(function () use ($validated): void {
|
||||||
$autoApprove = $this->userCanAutoApprove($user);
|
|
||||||
|
|
||||||
$product = Product::create([
|
$product = Product::create([
|
||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
'description' => $validated['description'] ?? null,
|
'description' => $validated['description'] ?? null,
|
||||||
'is_active' => $autoApprove,
|
'is_active' => true,
|
||||||
'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,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$product->categories()->sync($validated['category_ids']);
|
$product->categories()->sync($validated['category_ids']);
|
||||||
@ -218,43 +85,17 @@ public function create(array $validated, User $user): void
|
|||||||
foreach ($validated['variants'] as $index => $variantData) {
|
foreach ($validated['variants'] as $index => $variantData) {
|
||||||
$this->createVariant($product, $variantData, $index);
|
$this->createVariant($product, $variantData, $index);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $autoApprove) {
|
|
||||||
$this->notifyOwnersOfPendingProduct($product, 'baru');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $validated
|
* @param array<string, mixed> $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): void {
|
||||||
|
|
||||||
DB::transaction(function () use ($validated, $product, $user): void {
|
|
||||||
$autoApprove = $this->userCanAutoApprove($user);
|
|
||||||
|
|
||||||
$product->name = $validated['name'];
|
$product->name = $validated['name'];
|
||||||
$product->description = $validated['description'] ?? null;
|
$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->save();
|
||||||
|
|
||||||
$product->categories()->sync($validated['category_ids']);
|
$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);
|
$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)) {
|
$product->is_active = $validated['is_active'];
|
||||||
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->save();
|
$product->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(Product $product, User $user): void
|
public function delete(Product $product): 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
|
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($product): void {
|
DB::transaction(function () use ($product): void {
|
||||||
$product->variants()->each(function (ProductVariant $variant): void {
|
$product->variants()->each(function (ProductVariant $variant): void {
|
||||||
@ -472,56 +144,13 @@ private function performDelete(Product $product): void
|
|||||||
});
|
});
|
||||||
$product->variants()->delete();
|
$product->variants()->delete();
|
||||||
$product->categories()->detach();
|
$product->categories()->detach();
|
||||||
$product->rejection()?->delete();
|
|
||||||
$product->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
|
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);
|
$query->orderBy($sort, $direction);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -560,31 +189,4 @@ private function syncVariantImages(ProductVariant $variant, array $variantData,
|
|||||||
errorKey: "variants.{$index}.images",
|
errorKey: "variants.{$index}.images",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{
|
|
||||||
* type: string,
|
|
||||||
* type_label: string,
|
|
||||||
* price: int,
|
|
||||||
* price_formatted: string,
|
|
||||||
* price_input: string,
|
|
||||||
* cost_per_unit: int,
|
|
||||||
* cost_per_unit_formatted: string,
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,678 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { router, useForm } from '@inertiajs/vue3';
|
|
||||||
import { Check, ChevronDown, Copy, RotateCcw, Scissors, X } from '@lucide/vue';
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { toast } from 'vue-sonner';
|
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
|
||||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
Empty,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyTitle,
|
|
||||||
} from '@/components/ui/empty';
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
FieldSet,
|
|
||||||
} from '@/components/ui/field';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import { useCan } from '@/composables/useCan';
|
|
||||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
|
||||||
import { parseRupiah } from '@/lib/rupiah';
|
|
||||||
import type { CuttingListItem } from '@/types/cutting';
|
|
||||||
import {
|
|
||||||
PRICE_TYPES,
|
|
||||||
PRICE_TYPE_LABELS,
|
|
||||||
} from '@/types/product';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
cuttings: CuttingListItem[];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { can } = useCan();
|
|
||||||
const open = ref(false);
|
|
||||||
|
|
||||||
const totalCompleted = computed(() => props.cuttings.length);
|
|
||||||
|
|
||||||
// Status transition
|
|
||||||
const statusConfirmOpen = ref(false);
|
|
||||||
const statusProcessing = ref(false);
|
|
||||||
const pendingAction = ref<CuttingStatusAction | null>(null);
|
|
||||||
const activeCutting = ref<CuttingListItem | null>(null);
|
|
||||||
|
|
||||||
// Reject
|
|
||||||
const rejectDialogOpen = ref(false);
|
|
||||||
const rejectForm = useForm({
|
|
||||||
status: 'rejected',
|
|
||||||
reason: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
const verifyDialogOpen = ref(false);
|
|
||||||
const allMatches = ref(true);
|
|
||||||
const useSamePrice = ref(true);
|
|
||||||
const sharedPrices = ref<Record<string, string>>(buildEmptyPrices());
|
|
||||||
|
|
||||||
function buildEmptyPrices(): Record<string, string> {
|
|
||||||
return Object.fromEntries(PRICE_TYPES.map((type) => [type, '']));
|
|
||||||
}
|
|
||||||
|
|
||||||
const verifyForm = useForm({
|
|
||||||
status: 'verified',
|
|
||||||
verification_note: '',
|
|
||||||
results: [] as Array<{
|
|
||||||
product_variant_id: number;
|
|
||||||
name: string;
|
|
||||||
cutting_result: number;
|
|
||||||
warehouse_stock: number;
|
|
||||||
cutting_reject: number;
|
|
||||||
original_warehouse_stock: number;
|
|
||||||
original_cutting_reject: number;
|
|
||||||
prices: Record<string, string>;
|
|
||||||
}>,
|
|
||||||
result_prices: [] as Array<{
|
|
||||||
product_variant_id: number;
|
|
||||||
prices: Array<{ type: string; price: number }>;
|
|
||||||
}>,
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(verifyDialogOpen, (isOpen) => {
|
|
||||||
if (isOpen && activeCutting.value) {
|
|
||||||
allMatches.value = true;
|
|
||||||
useSamePrice.value = true;
|
|
||||||
sharedPrices.value = buildEmptyPrices();
|
|
||||||
verifyForm.verification_note = '';
|
|
||||||
verifyForm.results = activeCutting.value.results.map(res => ({
|
|
||||||
product_variant_id: res.product_variant?.id || 0,
|
|
||||||
name: `${res.product_variant?.product?.name || ''} (${res.product_variant?.name || ''})`,
|
|
||||||
cutting_result: res.cutting_result,
|
|
||||||
warehouse_stock: res.warehouse_stock,
|
|
||||||
cutting_reject: res.cutting_reject,
|
|
||||||
original_warehouse_stock: res.warehouse_stock,
|
|
||||||
original_cutting_reject: res.cutting_reject,
|
|
||||||
prices: buildEmptyPrices(),
|
|
||||||
}));
|
|
||||||
verifyForm.result_prices = [];
|
|
||||||
verifyForm.clearErrors();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(allMatches, (matches) => {
|
|
||||||
if (!matches) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyForm.results = verifyForm.results.map((result) => ({
|
|
||||||
...result,
|
|
||||||
warehouse_stock: result.original_warehouse_stock,
|
|
||||||
cutting_reject: result.original_cutting_reject,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
function setAllMatches(value: boolean) {
|
|
||||||
allMatches.value = value;
|
|
||||||
|
|
||||||
if (value) {
|
|
||||||
verifyForm.results = verifyForm.results.map((result) => ({
|
|
||||||
...result,
|
|
||||||
warehouse_stock: result.original_warehouse_stock,
|
|
||||||
cutting_reject: result.original_cutting_reject,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSharedPrice(type: string, value: string) {
|
|
||||||
sharedPrices.value = { ...sharedPrices.value, [type]: value };
|
|
||||||
|
|
||||||
if (useSamePrice.value) {
|
|
||||||
verifyForm.results = verifyForm.results.map((result) => ({
|
|
||||||
...result,
|
|
||||||
prices: { ...result.prices, [type]: value },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleUseSamePrice(checked: boolean) {
|
|
||||||
useSamePrice.value = checked;
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
// Apply shared prices to all results
|
|
||||||
verifyForm.results = verifyForm.results.map((result) => ({
|
|
||||||
...result,
|
|
||||||
prices: { ...sharedPrices.value },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setResultPrice(resultIndex: number, type: string, value: string) {
|
|
||||||
const result = verifyForm.results[resultIndex];
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.prices = {
|
|
||||||
...result.prices,
|
|
||||||
[type]: value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPriceToAllVariants(sourceIndex: number) {
|
|
||||||
const source = verifyForm.results[sourceIndex];
|
|
||||||
|
|
||||||
if (!source) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyForm.results = verifyForm.results.map((result) => ({
|
|
||||||
...result,
|
|
||||||
prices: { ...source.prices },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildResultPricesPayload() {
|
|
||||||
return verifyForm.results.map((result) => ({
|
|
||||||
product_variant_id: result.product_variant_id,
|
|
||||||
prices: PRICE_TYPES.map((type) => ({
|
|
||||||
type,
|
|
||||||
price: Number.parseInt(parseRupiah(result.prices[type] ?? ''), 10) || 0,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAction(cutting: CuttingListItem, action: CuttingStatusAction) {
|
|
||||||
activeCutting.value = cutting;
|
|
||||||
|
|
||||||
if (action.status === 'verified') {
|
|
||||||
verifyDialogOpen.value = true;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.status === 'rejected') {
|
|
||||||
rejectForm.reset();
|
|
||||||
rejectForm.clearErrors();
|
|
||||||
rejectDialogOpen.value = true;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingAction.value = action;
|
|
||||||
statusConfirmOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusConfirmDescription(action: CuttingStatusAction): string {
|
|
||||||
if (action.status === 'verified') {
|
|
||||||
return 'Hasil cutting akan diverifikasi. Stok bagus dan reject akan ditambahkan ke produk.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.status === 'in_progress') {
|
|
||||||
return 'Cutting dikembalikan ke proses untuk diperbaiki.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function transitionStatus() {
|
|
||||||
if (!pendingAction.value || !activeCutting.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
statusProcessing.value = true;
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
`/admin/manage/cuttings/${activeCutting.value.id}/status`,
|
|
||||||
{
|
|
||||||
status: pendingAction.value.status,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
preserveScroll: true,
|
|
||||||
onSuccess: () => {
|
|
||||||
statusConfirmOpen.value = false;
|
|
||||||
pendingAction.value = null;
|
|
||||||
activeCutting.value = null;
|
|
||||||
},
|
|
||||||
onError: (errors) => {
|
|
||||||
const message = Object.values(errors)[0];
|
|
||||||
|
|
||||||
toast.error(
|
|
||||||
typeof message === 'string'
|
|
||||||
? message
|
|
||||||
: 'Gagal memperbarui status cutting.',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onFinish: () => {
|
|
||||||
statusProcessing.value = false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitVerify() {
|
|
||||||
if (!activeCutting.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyForm
|
|
||||||
.transform((data) => ({
|
|
||||||
status: data.status,
|
|
||||||
verification_note: data.verification_note,
|
|
||||||
results: data.results.map(({ product_variant_id, warehouse_stock, cutting_reject }) => ({
|
|
||||||
product_variant_id,
|
|
||||||
warehouse_stock,
|
|
||||||
cutting_reject,
|
|
||||||
})),
|
|
||||||
result_prices: buildResultPricesPayload(),
|
|
||||||
}))
|
|
||||||
.post(`/admin/manage/cuttings/${activeCutting.value.id}/status`, {
|
|
||||||
preserveScroll: true,
|
|
||||||
onSuccess: () => {
|
|
||||||
verifyDialogOpen.value = false;
|
|
||||||
activeCutting.value = null;
|
|
||||||
},
|
|
||||||
onError: (errors) => {
|
|
||||||
const message = Object.values(errors)[0];
|
|
||||||
|
|
||||||
toast.error(
|
|
||||||
typeof message === 'string'
|
|
||||||
? message
|
|
||||||
: 'Gagal memverifikasi cutting.',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitReject() {
|
|
||||||
if (!activeCutting.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
rejectForm.post(`/admin/manage/cuttings/${activeCutting.value.id}/status`, {
|
|
||||||
preserveScroll: true,
|
|
||||||
onSuccess: () => {
|
|
||||||
rejectDialogOpen.value = false;
|
|
||||||
activeCutting.value = null;
|
|
||||||
},
|
|
||||||
onError: (errors) => {
|
|
||||||
const message = Object.values(errors)[0];
|
|
||||||
|
|
||||||
toast.error(
|
|
||||||
typeof message === 'string'
|
|
||||||
? message
|
|
||||||
: 'Gagal menolak cutting.',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(rejectDialogOpen, (isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
rejectForm.reset();
|
|
||||||
rejectForm.clearErrors();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Collapsible v-model:open="open">
|
|
||||||
<Card class="!py-0">
|
|
||||||
<CollapsibleTrigger
|
|
||||||
class="flex w-full items-center justify-between gap-3 px-6 py-4 text-left transition-colors hover:bg-muted/30">
|
|
||||||
<div class="min-w-0 space-y-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Scissors class="size-4 shrink-0 text-green-600 dark:text-green-400" />
|
|
||||||
<h3 class="font-semibold leading-tight">
|
|
||||||
Verifikasi Cutting
|
|
||||||
</h3>
|
|
||||||
<Badge v-if="totalCompleted > 0" variant="outline"
|
|
||||||
class="border-green-600/30 text-green-600 dark:text-green-400">
|
|
||||||
{{ totalCompleted }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChevronDown class="size-4 shrink-0 text-muted-foreground transition-transform duration-200"
|
|
||||||
:class="open ? 'rotate-180' : ''" />
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
<CollapsibleContent>
|
|
||||||
<CardContent class="pt-0 pb-6">
|
|
||||||
<div v-if="cuttings.length" class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<Card v-for="cutting in cuttings" :key="cutting.id"
|
|
||||||
class="flex flex-col justify-between overflow-hidden border bg-card/50 py-0 gap-0">
|
|
||||||
<div class="border-b bg-muted/20 px-4 py-3 flex items-center justify-between gap-2">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h4 class="font-medium text-sm truncate">
|
|
||||||
Cutting #{{ cutting.id }}
|
|
||||||
</h4>
|
|
||||||
<span class="text-[10px] text-muted-foreground block truncate">
|
|
||||||
{{ cutting.created_at_formatted }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-1">
|
|
||||||
<Tooltip v-if="can('cuttings.verify')">
|
|
||||||
<TooltipTrigger as-child>
|
|
||||||
<Button size="sm" variant="ghost"
|
|
||||||
@click="openAction(cutting, { status: 'verified', label: 'Verifikasi', destructive: false, permission: 'cuttings.verify', icon_only: false })">
|
|
||||||
<Check class="size-3.5" />
|
|
||||||
<span class="sr-only">Verifikasi</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Verifikasi</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip v-if="can('cuttings.reject')">
|
|
||||||
<TooltipTrigger as-child>
|
|
||||||
<Button size="sm" variant="ghost"
|
|
||||||
class="text-destructive hover:text-destructive"
|
|
||||||
@click="openAction(cutting, { status: 'rejected', label: 'Tolak', destructive: true, permission: 'cuttings.reject', icon_only: false })">
|
|
||||||
<X class="size-3.5" />
|
|
||||||
<span class="sr-only">Tolak</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Tolak</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip v-if="can('cuttings.reject')">
|
|
||||||
<TooltipTrigger as-child>
|
|
||||||
<Button size="sm" variant="ghost"
|
|
||||||
class="text-destructive hover:text-destructive"
|
|
||||||
@click="openAction(cutting, { status: 'in_progress', label: 'Kembalikan ke Proses', destructive: true, permission: 'cuttings.reject', icon_only: false })">
|
|
||||||
<RotateCcw class="size-3.5" />
|
|
||||||
<span class="sr-only">Kembalikan ke Proses</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Kembalikan ke Proses</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 space-y-3 flex-1 text-xs">
|
|
||||||
<div v-if="cutting.description" class="text-muted-foreground pb-2 border-b">
|
|
||||||
<span class="font-medium text-foreground">Catatan:</span> {{ cutting.description }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
<span class="font-medium text-foreground">Bahan Baku:</span>
|
|
||||||
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
|
|
||||||
<li v-for="mat in cutting.materials" :key="mat.id">
|
|
||||||
{{ mat.raw_material_price?.raw_material?.name }} ({{
|
|
||||||
mat.raw_material_price?.variant }}) - {{ mat.material_usage_formatted }}
|
|
||||||
</li>
|
|
||||||
<li v-if="!cutting.materials.length">Belum ada bahan baku</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
<span class="font-medium text-foreground">Hasil Produk:</span>
|
|
||||||
<ul class="list-disc pl-4 space-y-0.5 text-muted-foreground">
|
|
||||||
<li v-for="res in cutting.results" :key="res.id">
|
|
||||||
{{ res.product_variant?.product?.name }} ({{ res.product_variant?.name }}) -
|
|
||||||
{{ res.cutting_result }} pcs
|
|
||||||
</li>
|
|
||||||
<li v-if="!cutting.results.length">Belum ada hasil produk</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="cutting.estimated_cost_per_unit_formatted" class="text-[11px] pt-2 border-t">
|
|
||||||
<span class="text-muted-foreground">Harga modal: </span>
|
|
||||||
<span class="font-semibold tabular-nums">{{
|
|
||||||
cutting.estimated_cost_per_unit_formatted }} / pcs</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="text-[11px] text-muted-foreground pt-2 border-t flex items-center justify-between">
|
|
||||||
<span>Pembuat:</span>
|
|
||||||
<span class="font-medium text-foreground">
|
|
||||||
{{ cutting.created_by?.profile?.full_name ?? cutting.created_by?.username }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Empty v-else class="py-8">
|
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyTitle>Tidak ada cutting menunggu verifikasi</EmptyTitle>
|
|
||||||
<EmptyDescription>
|
|
||||||
Semua cutting selesai telah diverifikasi atau belum selesai diproses.
|
|
||||||
</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
</CardContent>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Card>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
<!-- Status Confirm Dialog (Return to Process) -->
|
|
||||||
<ConfirmDialog v-model:open="statusConfirmOpen" :title="pendingAction
|
|
||||||
? `${pendingAction.label} cutting?`
|
|
||||||
: 'Ubah status cutting?'
|
|
||||||
" :description="pendingAction ? statusConfirmDescription(pendingAction) : ''
|
|
||||||
" :confirm-label="pendingAction?.label ?? 'Konfirmasi'" cancel-label="Batal"
|
|
||||||
:destructive="pendingAction?.destructive ?? false" :loading="statusProcessing" @confirm="transitionStatus" />
|
|
||||||
|
|
||||||
<!-- Reject Dialog -->
|
|
||||||
<Dialog v-model:open="rejectDialogOpen">
|
|
||||||
<DialogContent class="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Tolak Cutting</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form @submit.prevent="submitReject">
|
|
||||||
<FieldGroup>
|
|
||||||
<FieldSet class="grid gap-4">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel for="cutting-reject-reason" required>Alasan Penolakan</FieldLabel>
|
|
||||||
<Textarea id="cutting-reject-reason" v-model="rejectForm.reason"
|
|
||||||
placeholder="Contoh: Jumlah barang yang diterima tidak sesuai" rows="3" autofocus
|
|
||||||
:maxlength="FIELD_LIMITS.reason" />
|
|
||||||
<FieldError :errors="rejectForm.errors.reason
|
|
||||||
? [rejectForm.errors.reason]
|
|
||||||
: []
|
|
||||||
" />
|
|
||||||
</Field>
|
|
||||||
</FieldSet>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
<DialogFooter class="mt-6">
|
|
||||||
<Button type="button" variant="outline" :disabled="rejectForm.processing"
|
|
||||||
@click="rejectDialogOpen = false">
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" variant="destructive" :disabled="rejectForm.processing">
|
|
||||||
{{ rejectForm.processing ? 'Menyimpan...' : 'Tolak' }}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<!-- Verify Dialog -->
|
|
||||||
<Dialog v-model:open="verifyDialogOpen">
|
|
||||||
<DialogContent class="sm:max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Verifikasi Hasil Cutting</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form @submit.prevent="submitVerify">
|
|
||||||
<div class="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin px-1 py-1">
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
Verifikasi jumlah produk yang diterima di toko dan tentukan harga jual per varian.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between rounded-lg border p-3">
|
|
||||||
<Label for="all-matches-verif" class="text-sm font-medium">
|
|
||||||
Semua sesuai dengan data cutting
|
|
||||||
</Label>
|
|
||||||
<Switch id="all-matches-verif" :model-value="allMatches" @update:model-value="setAllMatches" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="activeCutting?.estimated_cost_per_unit_formatted"
|
|
||||||
class="rounded-lg border bg-muted/40 p-3 text-sm space-y-1">
|
|
||||||
<p class="font-medium">Harga Modal Batch</p>
|
|
||||||
<p class="flex justify-between gap-2 text-muted-foreground">
|
|
||||||
<span>Bahan baku</span>
|
|
||||||
<span>{{ activeCutting.total_material_cost_formatted ?? '-' }}</span>
|
|
||||||
</p>
|
|
||||||
<p class="flex justify-between gap-2 text-muted-foreground">
|
|
||||||
<span>Jasa jahit</span>
|
|
||||||
<span>{{ activeCutting.sewing_cost_formatted ?? '-' }}</span>
|
|
||||||
</p>
|
|
||||||
<p class="flex justify-between gap-2 text-muted-foreground">
|
|
||||||
<span>Biaya lainnya</span>
|
|
||||||
<span>{{ activeCutting.other_cost_formatted ?? '-' }}</span>
|
|
||||||
</p>
|
|
||||||
<p class="flex justify-between gap-2 font-medium">
|
|
||||||
<span>Total</span>
|
|
||||||
<span>{{ activeCutting.total_production_cost_formatted ?? '-' }}</span>
|
|
||||||
</p>
|
|
||||||
<p class="flex justify-between gap-2 text-muted-foreground">
|
|
||||||
<span>Total hasil</span>
|
|
||||||
<span>{{ activeCutting.total_result_pieces ?? 0 }} pcs</span>
|
|
||||||
</p>
|
|
||||||
<p class="font-semibold tabular-nums pt-1 border-t">
|
|
||||||
{{ activeCutting.estimated_cost_per_unit_formatted }} / pcs
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<!-- Shared Price Toggle -->
|
|
||||||
<div v-if="verifyForm.results.length > 1"
|
|
||||||
class="flex items-center justify-between rounded-lg border p-3">
|
|
||||||
<Label for="use-same-price" class="text-sm font-medium">
|
|
||||||
Gunakan harga yang sama untuk semua varian
|
|
||||||
</Label>
|
|
||||||
<Switch id="use-same-price" :model-value="useSamePrice"
|
|
||||||
@update:model-value="toggleUseSamePrice" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Shared Price Inputs -->
|
|
||||||
<div v-if="useSamePrice && verifyForm.results.length > 1"
|
|
||||||
class="rounded-lg border p-3 space-y-3">
|
|
||||||
<p class="text-sm font-medium">Harga Jual</p>
|
|
||||||
<div class="grid gap-2 sm:grid-cols-2">
|
|
||||||
<Field v-for="type in PRICE_TYPES" :key="`shared-${type}`">
|
|
||||||
<FieldLabel class="text-xs" :for="`shared-price-${type}`">
|
|
||||||
{{ PRICE_TYPE_LABELS[type] }}
|
|
||||||
</FieldLabel>
|
|
||||||
<RupiahInput :id="`shared-price-${type}`" :model-value="sharedPrices[type]"
|
|
||||||
@update:model-value="setSharedPrice(type, $event)" />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Per Variant Section -->
|
|
||||||
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id"
|
|
||||||
class="p-3 border rounded-lg space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="font-medium text-sm">
|
|
||||||
{{ result.name }}
|
|
||||||
</div>
|
|
||||||
<Button v-if="!useSamePrice && verifyForm.results.length > 1 && index > 0" type="button"
|
|
||||||
variant="outline" size="sm" @click="applyPriceToAllVariants(index)">
|
|
||||||
<Copy class="size-3.5" />
|
|
||||||
Terapkan ke Semua
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-3 gap-2 items-center text-xs">
|
|
||||||
<div>
|
|
||||||
<span class="text-muted-foreground block">Hasil Potong:</span>
|
|
||||||
<span class="font-semibold">{{ result.cutting_result }} pcs</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-muted-foreground block mb-0.5">Data cutting:</span>
|
|
||||||
<span class="font-medium tabular-nums">
|
|
||||||
{{ result.original_warehouse_stock }} bagus ·
|
|
||||||
{{ result.original_cutting_reject }} reject
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-muted-foreground block">Stok Reject:</span>
|
|
||||||
<Badge variant="secondary" class="font-semibold">
|
|
||||||
{{ result.cutting_reject }} pcs
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-2 items-end text-xs">
|
|
||||||
<div>
|
|
||||||
<label :for="`verif-good-${index}`" class="text-muted-foreground block mb-0.5">Stok
|
|
||||||
Bagus (diterima):</label>
|
|
||||||
<Input :id="`verif-good-${index}`" type="number"
|
|
||||||
v-model.number="result.warehouse_stock" min="0" :max="result.cutting_result"
|
|
||||||
class="h-8 w-full px-2 text-xs" :disabled="allMatches"
|
|
||||||
@input="result.cutting_reject = result.cutting_result - result.warehouse_stock" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label :for="`verif-reject-${index}`"
|
|
||||||
class="text-muted-foreground block mb-0.5">Stok Reject (diterima):</label>
|
|
||||||
<Input :id="`verif-reject-${index}`" type="number"
|
|
||||||
v-model.number="result.cutting_reject" min="0" :max="result.cutting_result"
|
|
||||||
class="h-8 w-full px-2 text-xs" :disabled="allMatches"
|
|
||||||
@input="result.warehouse_stock = result.cutting_result - result.cutting_reject" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!useSamePrice || verifyForm.results.length === 1"
|
|
||||||
class="grid gap-2 sm:grid-cols-2">
|
|
||||||
<Field v-for="type in PRICE_TYPES" :key="`${result.product_variant_id}-${type}`">
|
|
||||||
<FieldLabel class="text-xs" :for="`verif-price-${index}-${type}`">
|
|
||||||
{{ PRICE_TYPE_LABELS[type] }}
|
|
||||||
</FieldLabel>
|
|
||||||
<RupiahInput :id="`verif-price-${index}-${type}`" :model-value="result.prices[type]"
|
|
||||||
@update:model-value="setResultPrice(index, type, $event)" />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FieldError :errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []" />
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel for="verification-note">Catatan Verifikasi</FieldLabel>
|
|
||||||
<Textarea id="verification-note" v-model="verifyForm.verification_note"
|
|
||||||
placeholder="Contoh: Terdapat 1 barang cacat jahitan saat dihitung di toko" rows="3" />
|
|
||||||
<FieldError
|
|
||||||
:errors="verifyForm.errors.verification_note ? [verifyForm.errors.verification_note] : []" />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter class="mt-6">
|
|
||||||
<Button type="button" variant="outline" :disabled="verifyForm.processing"
|
|
||||||
@click="verifyDialogOpen = false">
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" :disabled="verifyForm.processing">
|
|
||||||
{{ verifyForm.processing ? 'Menyimpan...' : 'Verifikasi' }}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useForm } from '@inertiajs/vue3';
|
|
||||||
import { Save } from '@lucide/vue';
|
|
||||||
import { watch } from 'vue';
|
|
||||||
import { toast } from 'vue-sonner';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
FieldSet,
|
|
||||||
} from '@/components/ui/field';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
|
||||||
import { formErrors } from '@/lib/form';
|
|
||||||
import type { ProductListItem } from '@/types/product';
|
|
||||||
|
|
||||||
const open = defineModel<boolean>('open', { default: false });
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
product?: ProductListItem | null;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
reason: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
form.reset();
|
|
||||||
form.clearErrors();
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(open, (isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
resetForm();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
if (!props.product) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.post(`/admin/master/products/${props.product.id}/reject`, {
|
|
||||||
preserveScroll: true,
|
|
||||||
onSuccess: () => {
|
|
||||||
open.value = false;
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error('Gagal menolak produk. Periksa kembali formulir.');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Dialog v-model:open="open">
|
|
||||||
<DialogContent class="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Tolak Pengajuan Produk</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form @submit.prevent="submit">
|
|
||||||
<FieldGroup>
|
|
||||||
<FieldSet class="grid gap-4">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel for="reject-product-reason" required>Alasan Penolakan</FieldLabel>
|
|
||||||
<Textarea
|
|
||||||
id="reject-product-reason"
|
|
||||||
v-model="form.reason"
|
|
||||||
placeholder="Contoh: Data varian belum lengkap"
|
|
||||||
rows="3"
|
|
||||||
autofocus
|
|
||||||
:maxlength="FIELD_LIMITS.reason"
|
|
||||||
/>
|
|
||||||
<FieldError :errors="formErrors(form, 'reason')" />
|
|
||||||
</Field>
|
|
||||||
</FieldSet>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
<DialogFooter class="mt-6">
|
|
||||||
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" variant="destructive" :disabled="form.processing">
|
|
||||||
<Save class="size-4" />
|
|
||||||
{{ form.processing ? 'Menyimpan...' : 'Tolak' }}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Link, router } from '@inertiajs/vue3';
|
|
||||||
import { Check, Pencil, Trash2, X } from '@lucide/vue';
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { toast } from 'vue-sonner';
|
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import { useCan } from '@/composables/useCan';
|
|
||||||
import type { ProductListItem } from '@/types/product';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
product: ProductListItem;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
reject: [product: ProductListItem];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { can } = useCan();
|
|
||||||
|
|
||||||
const deleteConfirmOpen = ref(false);
|
|
||||||
const approveConfirmOpen = ref(false);
|
|
||||||
const deleteProcessing = ref(false);
|
|
||||||
const approveProcessing = ref(false);
|
|
||||||
|
|
||||||
const canEdit = computed(() => (
|
|
||||||
props.product.is_editable
|
|
||||||
&& !can('products.verify')
|
|
||||||
&& can('products.update')
|
|
||||||
));
|
|
||||||
|
|
||||||
const canDelete = computed(() => {
|
|
||||||
if (!can('products.delete')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (can('products.verify')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return props.product.is_editable || props.product.status === 'approved';
|
|
||||||
});
|
|
||||||
|
|
||||||
const isDeleteRequest = computed(() => (
|
|
||||||
props.product.status === 'pending'
|
|
||||||
&& props.product.pending_action === 'delete'
|
|
||||||
));
|
|
||||||
|
|
||||||
const deleteDescription = computed(() => {
|
|
||||||
if (can('products.verify')) {
|
|
||||||
return `Produk ${props.product.name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDeleteRequest.value) {
|
|
||||||
return `Pengajuan penghapusan produk ${props.product.name} sedang menunggu persetujuan owner.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `Pengajuan penghapusan produk ${props.product.name} akan dikirim ke owner untuk disetujui.`;
|
|
||||||
});
|
|
||||||
|
|
||||||
function destroyProduct() {
|
|
||||||
if (isDeleteRequest.value && !can('products.verify')) {
|
|
||||||
deleteConfirmOpen.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteProcessing.value = true;
|
|
||||||
|
|
||||||
router.delete(`/admin/master/products/${props.product.id}`, {
|
|
||||||
preserveScroll: true,
|
|
||||||
onSuccess: () => {
|
|
||||||
deleteConfirmOpen.value = false;
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error('Gagal mengajukan penghapusan produk.');
|
|
||||||
},
|
|
||||||
onFinish: () => {
|
|
||||||
deleteProcessing.value = false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function approveProduct() {
|
|
||||||
approveProcessing.value = true;
|
|
||||||
|
|
||||||
router.post(`/admin/master/products/${props.product.id}/approve`, {}, {
|
|
||||||
preserveScroll: true,
|
|
||||||
onSuccess: () => {
|
|
||||||
approveConfirmOpen.value = false;
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error('Gagal menyetujui produk.');
|
|
||||||
},
|
|
||||||
onFinish: () => {
|
|
||||||
approveProcessing.value = false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
<Tooltip v-if="canEdit">
|
|
||||||
<TooltipTrigger as-child>
|
|
||||||
<Button variant="ghost" size="icon" class="size-8" as-child>
|
|
||||||
<Link :href="`/admin/master/products/${product.id}/edit`">
|
|
||||||
<Pencil class="size-4" />
|
|
||||||
<span class="sr-only">Ubah</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Ubah</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip v-if="product.can_verify && can('products.verify')">
|
|
||||||
<TooltipTrigger as-child>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="size-8 text-green-600 hover:text-green-600"
|
|
||||||
@click="approveConfirmOpen = true"
|
|
||||||
>
|
|
||||||
<Check class="size-4" />
|
|
||||||
<span class="sr-only">Setujui</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Setujui</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip v-if="product.can_verify && can('products.verify')">
|
|
||||||
<TooltipTrigger as-child>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="text-destructive hover:text-destructive size-8"
|
|
||||||
@click="emit('reject', product)"
|
|
||||||
>
|
|
||||||
<X class="size-4" />
|
|
||||||
<span class="sr-only">Tolak</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Tolak</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip v-if="canDelete && !isDeleteRequest">
|
|
||||||
<TooltipTrigger as-child>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="text-destructive hover:text-destructive size-8"
|
|
||||||
@click="deleteConfirmOpen = true"
|
|
||||||
>
|
|
||||||
<Trash2 class="size-4" />
|
|
||||||
<span class="sr-only">Hapus</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{{ can('products.verify') ? 'Hapus' : 'Ajukan Penghapusan' }}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
v-if="can('products.verify')"
|
|
||||||
v-model:open="approveConfirmOpen"
|
|
||||||
title="Setujui produk?"
|
|
||||||
:description="`Produk ${product.name}${product.pending_action === 'delete' ? ' akan dihapus' : ' akan disetujui dan diaktifkan'}.`"
|
|
||||||
confirm-label="Setujui"
|
|
||||||
cancel-label="Batal"
|
|
||||||
:loading="approveProcessing"
|
|
||||||
@confirm="approveProduct"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
v-if="canDelete && !isDeleteRequest"
|
|
||||||
v-model:open="deleteConfirmOpen"
|
|
||||||
:title="can('products.verify') ? 'Hapus produk?' : 'Ajukan penghapusan produk?'"
|
|
||||||
:description="deleteDescription"
|
|
||||||
:confirm-label="can('products.verify') ? 'Hapus' : 'Ajukan'"
|
|
||||||
cancel-label="Batal"
|
|
||||||
destructive
|
|
||||||
:loading="deleteProcessing"
|
|
||||||
@confirm="destroyProduct"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
91
resources/js/composables/useVariantList.ts
Normal file
91
resources/js/composables/useVariantList.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { formErrors } from '@/lib/form';
|
||||||
|
import type { FormWithErrors } from '@/lib/form';
|
||||||
|
|
||||||
|
export interface VariantItem {
|
||||||
|
client_id: string;
|
||||||
|
id?: number;
|
||||||
|
media: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVariantList<T extends VariantItem>(
|
||||||
|
prefix: string,
|
||||||
|
createEmpty: () => T,
|
||||||
|
buildInitial: () => T[],
|
||||||
|
) {
|
||||||
|
const items: Ref<T[]> = ref(buildInitial()) as Ref<T[]>;
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
items.value = [...items.value, createEmpty()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(clientId: string) {
|
||||||
|
if (items.value.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.value = items.value.filter((item) => item.client_id !== clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setField(clientId: string, key: string, value: unknown) {
|
||||||
|
items.value = items.value.map((item) =>
|
||||||
|
item.client_id === clientId ? { ...item, [key]: value } : item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexOf(clientId: string): number {
|
||||||
|
return items.value.findIndex((item) => item.client_id === clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendToFormData(
|
||||||
|
formData: FormData,
|
||||||
|
appendItem: (formData: FormData, index: number, item: T) => void,
|
||||||
|
method?: 'post' | 'put',
|
||||||
|
) {
|
||||||
|
if (method === 'put') {
|
||||||
|
formData.append('_method', 'PUT');
|
||||||
|
}
|
||||||
|
|
||||||
|
items.value.forEach((item, index) => {
|
||||||
|
if (item.id) {
|
||||||
|
formData.append(`${prefix}[${index}][id]`, String(item.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
appendItem(formData, index, item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemErrors(
|
||||||
|
form: FormWithErrors,
|
||||||
|
clientId: string,
|
||||||
|
field: string,
|
||||||
|
): string[] {
|
||||||
|
const index = indexOf(clientId);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return formErrors(form, `${prefix}.${index}.${field}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function allItemErrors(
|
||||||
|
form: FormWithErrors,
|
||||||
|
field: string,
|
||||||
|
): string[] {
|
||||||
|
return items.value.flatMap((item) => itemErrors(form, item.client_id, field));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
addItem,
|
||||||
|
removeItem,
|
||||||
|
setField,
|
||||||
|
indexOf,
|
||||||
|
appendToFormData,
|
||||||
|
itemErrors,
|
||||||
|
allItemErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import type { InertiaForm } from '@inertiajs/vue3';
|
export interface FormWithErrors {
|
||||||
|
errors: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
export function formErrors<T extends Record<string, unknown>>(
|
export function formErrors(
|
||||||
form: InertiaForm<T>,
|
form: FormWithErrors,
|
||||||
key: keyof T | string,
|
key: string,
|
||||||
): string[] {
|
): string[] {
|
||||||
const error = form.errors[key as keyof typeof form.errors];
|
const error = form.errors[key];
|
||||||
|
|
||||||
return error ? [error] : [];
|
return error ? [error] : [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import { ArrowLeft } from '@lucide/vue';
|
import { ArrowLeft } from '@lucide/vue';
|
||||||
import ProductForm from '@/components/admin/master/products/ProductForm.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
import type { CategoryOption } from '@/types/product';
|
import type { CategoryOption } from '@/types/product';
|
||||||
|
import ProductForm from './form/ProductForm.vue';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
categories: CategoryOption[];
|
categories: CategoryOption[];
|
||||||
|
|||||||
@ -2,10 +2,10 @@
|
|||||||
import { Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import { ArrowLeft } from '@lucide/vue';
|
import { ArrowLeft } from '@lucide/vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import ProductForm from '@/components/admin/master/products/ProductForm.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
import type { CategoryOption, ProductListItem } from '@/types/product';
|
import type { CategoryOption, ProductListItem } from '@/types/product';
|
||||||
|
import ProductForm from './form/ProductForm.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
product: ProductListItem & { description?: string | null };
|
product: ProductListItem & { description?: string | null };
|
||||||
@ -26,6 +26,7 @@ const initialData = computed(() => ({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<Head title="Ubah Produk" />
|
<Head title="Ubah Produk" />
|
||||||
|
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
|
|||||||
@ -2,10 +2,6 @@
|
|||||||
import { Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import { Plus } from '@lucide/vue';
|
import { Plus } from '@lucide/vue';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import MasterOutOfStockCatalogSection from '@/components/admin/master/MasterOutOfStockCatalogSection.vue';
|
|
||||||
import CuttingVerificationSection from '@/components/admin/master/products/CuttingVerificationSection.vue';
|
|
||||||
import ProductGroupedTable from '@/components/admin/master/products/ProductGroupedTable.vue';
|
|
||||||
import RejectProductModal from '@/components/admin/master/products/RejectProductModal.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { useCan } from '@/composables/useCan';
|
import { useCan } from '@/composables/useCan';
|
||||||
@ -14,21 +10,15 @@ import {
|
|||||||
useDataTableQuerySync,
|
useDataTableQuerySync,
|
||||||
} from '@/composables/useDataTableQuery';
|
} from '@/composables/useDataTableQuery';
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
import type { CuttingListItem } from '@/types/cutting';
|
|
||||||
import type { DataTableFilterDef } from '@/types/data-table';
|
import type { DataTableFilterDef } from '@/types/data-table';
|
||||||
import type {
|
import type {
|
||||||
CategoryOption,
|
CategoryOption,
|
||||||
PaginatedProducts,
|
PaginatedProducts,
|
||||||
ProductListItem,
|
|
||||||
ProductOutOfStockGroup,
|
|
||||||
ProductStockWarningGroup,
|
|
||||||
} from '@/types/product';
|
} from '@/types/product';
|
||||||
|
import ProductGroupedTable from './table/ProductGroupedTable.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
products: PaginatedProducts;
|
products: PaginatedProducts;
|
||||||
completedCuttings: CuttingListItem[];
|
|
||||||
outOfStockGroups: ProductOutOfStockGroup[];
|
|
||||||
stockWarningGroups: ProductStockWarningGroup[];
|
|
||||||
categories: CategoryOption[];
|
categories: CategoryOption[];
|
||||||
filters: {
|
filters: {
|
||||||
search: string;
|
search: string;
|
||||||
@ -36,13 +26,10 @@ const props = defineProps<{
|
|||||||
direction?: 'asc' | 'desc';
|
direction?: 'asc' | 'desc';
|
||||||
is_active?: string;
|
is_active?: string;
|
||||||
category_id?: string;
|
category_id?: string;
|
||||||
status?: string;
|
stock_status?: string;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const rejectModalOpen = ref(false);
|
|
||||||
const rejectingProduct = ref<ProductListItem | null>(null);
|
|
||||||
|
|
||||||
const { can } = useCan();
|
const { can } = useCan();
|
||||||
const search = ref(props.filters.search ?? '');
|
const search = ref(props.filters.search ?? '');
|
||||||
|
|
||||||
@ -50,7 +37,7 @@ const { query, setSearch, setFilter, resetFilters, syncFromServer } =
|
|||||||
useDataTableQuery({
|
useDataTableQuery({
|
||||||
url: '/admin/master/products',
|
url: '/admin/master/products',
|
||||||
initial: { ...props.filters },
|
initial: { ...props.filters },
|
||||||
filterKeys: ['is_active', 'category_id', 'status'],
|
filterKeys: ['is_active', 'category_id', 'stock_status'],
|
||||||
});
|
});
|
||||||
|
|
||||||
useDataTableQuerySync(() => props.filters, syncFromServer);
|
useDataTableQuerySync(() => props.filters, syncFromServer);
|
||||||
@ -62,16 +49,6 @@ const filterDefs = computed<DataTableFilterDef[]>(() => [
|
|||||||
type: 'select',
|
type: 'select',
|
||||||
options: props.categories.map((c) => ({ value: String(c.value), label: c.label })),
|
options: props.categories.map((c) => ({ value: String(c.value), label: c.label })),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
label: 'Persetujuan',
|
|
||||||
type: 'select',
|
|
||||||
options: [
|
|
||||||
{ value: 'pending', label: 'Menunggu' },
|
|
||||||
{ value: 'approved', label: 'Disetujui' },
|
|
||||||
{ value: 'rejected', label: 'Ditolak' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'is_active',
|
key: 'is_active',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
@ -81,20 +58,24 @@ const filterDefs = computed<DataTableFilterDef[]>(() => [
|
|||||||
{ value: '0', label: 'Nonaktif' },
|
{ value: '0', label: 'Nonaktif' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'stock_status',
|
||||||
|
label: 'Stok',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ value: 'out_of_stock', label: 'Stok Habis' },
|
||||||
|
{ value: 'low_stock', label: 'Stok Menipis' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const filterValues = computed(() => ({
|
const filterValues = computed(() => ({
|
||||||
category_id: query.value.category_id ?? '',
|
category_id: query.value.category_id ?? '',
|
||||||
status: query.value.status ?? '',
|
|
||||||
is_active: query.value.is_active ?? '',
|
is_active: query.value.is_active ?? '',
|
||||||
|
stock_status: query.value.stock_status ?? '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function openRejectModal(product: ProductListItem) {
|
const pagination = computed(() => ({
|
||||||
rejectingProduct.value = product;
|
|
||||||
rejectModalOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tablePagination = computed(() => ({
|
|
||||||
currentPage: props.products.current_page,
|
currentPage: props.products.current_page,
|
||||||
perPage: props.products.per_page,
|
perPage: props.products.per_page,
|
||||||
lastPage: props.products.last_page,
|
lastPage: props.products.last_page,
|
||||||
@ -137,22 +118,12 @@ watch(
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CuttingVerificationSection v-if="can('cuttings.verify')" :cuttings="completedCuttings" />
|
|
||||||
|
|
||||||
<MasterOutOfStockCatalogSection severity="warning" title="Stok Menipis" empty-title="Tidak ada peringatan stok"
|
|
||||||
empty-description="Semua varian masih berada di atas batas stok minimum." :groups="stockWarningGroups" />
|
|
||||||
|
|
||||||
<MasterOutOfStockCatalogSection title="Stok Habis" :groups="outOfStockGroups" />
|
|
||||||
|
|
||||||
<Card class="min-w-0">
|
<Card class="min-w-0">
|
||||||
<CardContent class="min-w-0">
|
<CardContent class="min-w-0">
|
||||||
<ProductGroupedTable v-model:search="search" :products="products.data" :first-item="firstItem"
|
<ProductGroupedTable v-model:search="search" :products="products.data" :first-item="firstItem"
|
||||||
:pagination="tablePagination" :pagination-links="products.links" :filter-defs="filterDefs"
|
:pagination="pagination" :pagination-links="products.links" :filter-defs="filterDefs"
|
||||||
:filter-values="filterValues" @filter-change="setFilter" @filters-reset="resetFilters"
|
:filter-values="filterValues" @filter-change="setFilter" @filters-reset="resetFilters" />
|
||||||
@reject="openRejectModal" />
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<RejectProductModal v-model:open="rejectModalOpen" :product="rejectingProduct" />
|
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { Plus, Save, Trash2 } from '@lucide/vue';
|
import { Plus, Save, Trash2 } from '@lucide/vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
|
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
|
||||||
import { NumberInput } from '@/components/form/number-input';
|
import { NumberInput } from '@/components/form/number-input';
|
||||||
@ -16,6 +16,7 @@ import {
|
|||||||
} from '@/components/ui/field';
|
} from '@/components/ui/field';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useVariantList } from '@/composables/useVariantList';
|
||||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||||
import { formErrors } from '@/lib/form';
|
import { formErrors } from '@/lib/form';
|
||||||
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
||||||
@ -49,18 +50,29 @@ function createClientId(): string {
|
|||||||
return `variant-${crypto.randomUUID()}`;
|
return `variant-${crypto.randomUUID()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEmptyVariant(): ProductVariantFormItem {
|
const {
|
||||||
return {
|
items: variants,
|
||||||
|
addItem: addVariant,
|
||||||
|
removeItem: removeVariant,
|
||||||
|
setField: setVariantField,
|
||||||
|
appendToFormData,
|
||||||
|
itemErrors: variantErrors,
|
||||||
|
} = useVariantList<ProductVariantFormItem>(
|
||||||
|
'variants',
|
||||||
|
() => ({
|
||||||
client_id: createClientId(),
|
client_id: createClientId(),
|
||||||
name: '',
|
name: '',
|
||||||
stock: '0',
|
stock: '0',
|
||||||
media: createMediaUploadState(),
|
media: createMediaUploadState(),
|
||||||
};
|
}),
|
||||||
}
|
() => {
|
||||||
|
|
||||||
function buildInitialVariants(): ProductVariantFormItem[] {
|
|
||||||
if (!props.initialData?.variants?.length) {
|
if (!props.initialData?.variants?.length) {
|
||||||
return [createEmptyVariant()];
|
return [{
|
||||||
|
client_id: createClientId(),
|
||||||
|
name: '',
|
||||||
|
stock: '0',
|
||||||
|
media: createMediaUploadState(),
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
return props.initialData.variants.map((variant) => ({
|
return props.initialData.variants.map((variant) => ({
|
||||||
@ -70,9 +82,8 @@ function buildInitialVariants(): ProductVariantFormItem[] {
|
|||||||
stock: variant.stock != null ? String(variant.stock) : '0',
|
stock: variant.stock != null ? String(variant.stock) : '0',
|
||||||
media: createMediaUploadState(variant.images ?? []),
|
media: createMediaUploadState(variant.images ?? []),
|
||||||
}));
|
}));
|
||||||
}
|
},
|
||||||
|
);
|
||||||
const variants = ref<ProductVariantFormItem[]>(buildInitialVariants());
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
name: props.initialData?.name ?? '',
|
name: props.initialData?.name ?? '',
|
||||||
@ -96,31 +107,11 @@ function isCategoryChecked(categoryId: number): boolean {
|
|||||||
return form.category_ids.includes(categoryId);
|
return form.category_ids.includes(categoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addVariant() {
|
const categoryError = computed(() => form.errors.category_ids);
|
||||||
variants.value = [...variants.value, createEmptyVariant()];
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeVariant(clientId: string) {
|
|
||||||
if (variants.value.length <= 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
variants.value = variants.value.filter((variant) => variant.client_id !== clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setVariantField(clientId: string, key: 'name' | 'stock', value: string) {
|
|
||||||
variants.value = variants.value.map((variant) =>
|
|
||||||
variant.client_id === clientId ? { ...variant, [key]: value } : variant,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFormData(): FormData {
|
function buildFormData(): FormData {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
if (props.method === 'put') {
|
|
||||||
formData.append('_method', 'PUT');
|
|
||||||
}
|
|
||||||
|
|
||||||
formData.append('name', form.name.trim());
|
formData.append('name', form.name.trim());
|
||||||
formData.append('description', form.description.trim());
|
formData.append('description', form.description.trim());
|
||||||
|
|
||||||
@ -128,36 +119,15 @@ function buildFormData(): FormData {
|
|||||||
formData.append('category_ids[]', String(categoryId));
|
formData.append('category_ids[]', String(categoryId));
|
||||||
});
|
});
|
||||||
|
|
||||||
variants.value.forEach((variant, index) => {
|
appendToFormData(formData, (formData, index, variant) => {
|
||||||
if (variant.id) {
|
|
||||||
formData.append(`variants[${index}][id]`, String(variant.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
formData.append(`variants[${index}][name]`, variant.name.trim());
|
formData.append(`variants[${index}][name]`, variant.name.trim());
|
||||||
formData.append(`variants[${index}][stock]`, String(Number.parseInt(variant.stock, 10) || 0));
|
formData.append(`variants[${index}][stock]`, String(Number.parseInt(variant.stock, 10) || 0));
|
||||||
|
|
||||||
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
|
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
|
||||||
});
|
}, props.method);
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formError(key: string): string | undefined {
|
|
||||||
return (form.errors as Record<string, string>)[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
function variantError(clientId: string, field: string): string | undefined {
|
|
||||||
const index = variants.value.findIndex((variant) => variant.client_id === clientId);
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return formError(`variants.${index}.${field}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryError = computed(() => form.errors.category_ids);
|
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
const options = {
|
const options = {
|
||||||
forceFormData: true,
|
forceFormData: true,
|
||||||
@ -240,8 +210,7 @@ function submit() {
|
|||||||
<Input :id="`variant_name_${variant.client_id}`" :model-value="variant.name" type="text"
|
<Input :id="`variant_name_${variant.client_id}`" :model-value="variant.name" type="text"
|
||||||
placeholder="Contoh: Polkadot" :maxlength="FIELD_LIMITS.variantName"
|
placeholder="Contoh: Polkadot" :maxlength="FIELD_LIMITS.variantName"
|
||||||
@update:model-value="setVariantField(variant.client_id, 'name', String($event))" />
|
@update:model-value="setVariantField(variant.client_id, 'name', String($event))" />
|
||||||
<FieldError
|
<FieldError :errors="variantErrors(form, variant.client_id, 'name')" />
|
||||||
:errors="variantError(variant.client_id, 'name') ? [variantError(variant.client_id, 'name')!] : []" />
|
|
||||||
</Field>
|
</Field>
|
||||||
<Field v-if="method !== 'put'">
|
<Field v-if="method !== 'put'">
|
||||||
<FieldLabel :for="`variant_stock_${variant.client_id}`" required>
|
<FieldLabel :for="`variant_stock_${variant.client_id}`" required>
|
||||||
@ -249,15 +218,14 @@ function submit() {
|
|||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<NumberInput :id="`variant_stock_${variant.client_id}`" :model-value="variant.stock"
|
<NumberInput :id="`variant_stock_${variant.client_id}`" :model-value="variant.stock"
|
||||||
@update:model-value="setVariantField(variant.client_id, 'stock', String($event))" />
|
@update:model-value="setVariantField(variant.client_id, 'stock', String($event))" />
|
||||||
<FieldError
|
<FieldError :errors="variantErrors(form, variant.client_id, 'stock')" />
|
||||||
:errors="variantError(variant.client_id, 'stock') ? [variantError(variant.client_id, 'stock')!] : []" />
|
|
||||||
</Field>
|
</Field>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MultipleImageUploadField :id="`variant_images_${variant.client_id}`"
|
<MultipleImageUploadField :id="`variant_images_${variant.client_id}`"
|
||||||
v-model="variant.media" label="Foto Varian" :max-files="5" required
|
v-model="variant.media" label="Foto Varian" :max-files="5" required
|
||||||
:errors="variantError(variant.client_id, 'images') ? [variantError(variant.client_id, 'images')!] : []" />
|
:errors="variantErrors(form, variant.client_id, 'images')" />
|
||||||
</div>
|
</div>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -1,8 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import DataTableActions from '@/components/admin/master/products/data-table-actions.vue';
|
|
||||||
import ProductStatusToggle from '@/components/admin/master/products/product-status-toggle.vue';
|
|
||||||
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
|
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
|
||||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -31,6 +29,8 @@ import {
|
|||||||
PRICE_TYPE_LABELS,
|
PRICE_TYPE_LABELS,
|
||||||
} from '@/types/product';
|
} from '@/types/product';
|
||||||
import type { ProductListItem } from '@/types/product';
|
import type { ProductListItem } from '@/types/product';
|
||||||
|
import DataTableActions from './data-table-actions.vue';
|
||||||
|
import ProductStatusToggle from './product-status-toggle.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
products: ProductListItem[];
|
products: ProductListItem[];
|
||||||
@ -46,7 +46,6 @@ const search = defineModel<string>('search', { default: '' });
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'filter-change': [key: string, value: string];
|
'filter-change': [key: string, value: string];
|
||||||
'filters-reset': [];
|
'filters-reset': [];
|
||||||
reject: [product: ProductListItem];
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showingCount = computed(() => props.products.length);
|
const showingCount = computed(() => props.products.length);
|
||||||
@ -72,38 +71,17 @@ function formatStock(value: number): string {
|
|||||||
function rowNumber(index: number): number {
|
function rowNumber(index: number): number {
|
||||||
return (props.firstItem ?? 1) + index;
|
return (props.firstItem ?? 1) + index;
|
||||||
}
|
}
|
||||||
|
|
||||||
function approvalStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
|
||||||
switch (status) {
|
|
||||||
case 'approved':
|
|
||||||
return 'default';
|
|
||||||
case 'rejected':
|
|
||||||
return 'destructive';
|
|
||||||
default:
|
|
||||||
return 'outline';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<DataTableToolbar
|
<DataTableToolbar v-model:search="search" :filter-defs="filterDefs" :filter-values="filterValues"
|
||||||
v-model:search="search"
|
@filter-change="(key, value) => emit('filter-change', key, value)" @filters-reset="emit('filters-reset')" />
|
||||||
:filter-defs="filterDefs"
|
|
||||||
:filter-values="filterValues"
|
|
||||||
@filter-change="(key, value) => emit('filter-change', key, value)"
|
|
||||||
@filters-reset="emit('filters-reset')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="products.length" class="space-y-4">
|
<div v-if="products.length" class="space-y-4">
|
||||||
|
<div v-for="(product, index) in products" :key="product.id" class="overflow-hidden rounded-md border">
|
||||||
<div
|
<div
|
||||||
v-for="(product, index) in products"
|
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
:key="product.id"
|
|
||||||
class="overflow-hidden rounded-md border"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
|
|
||||||
>
|
|
||||||
<div class="flex min-w-0 items-start gap-3">
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
<span class="text-muted-foreground w-8 shrink-0 pt-0.5 text-center text-sm tabular-nums">
|
<span class="text-muted-foreground w-8 shrink-0 pt-0.5 text-center text-sm tabular-nums">
|
||||||
{{ rowNumber(index) }}
|
{{ rowNumber(index) }}
|
||||||
@ -113,40 +91,25 @@ function approvalStatusVariant(status: string): 'default' | 'secondary' | 'destr
|
|||||||
<h3 class="font-medium leading-tight">
|
<h3 class="font-medium leading-tight">
|
||||||
{{ product.name }}
|
{{ product.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge :variant="approvalStatusVariant(product.status)">
|
|
||||||
{{ product.status_label }}
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
v-if="product.pending_action === 'delete'"
|
|
||||||
variant="destructive"
|
|
||||||
>
|
|
||||||
{{ product.pending_action_label }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="product.categories.length" class="flex flex-wrap gap-1">
|
<div v-if="product.categories.length" class="flex flex-wrap gap-1">
|
||||||
<Badge
|
<Badge v-for="category in product.categories" :key="category.id" variant="outline">
|
||||||
v-for="category in product.categories"
|
|
||||||
:key="category.id"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{{ category.name }}
|
{{ category.name }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||||
v-if="product.rejection_reason"
|
<span>
|
||||||
class="text-destructive text-sm"
|
Total stok <strong class="text-primary">
|
||||||
>
|
{{ product.variants.reduce((acc, v) => acc + v.stock, 0) }}
|
||||||
Alasan penolakan: {{ product.rejection_reason }}
|
</strong>
|
||||||
</p>
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
|
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
|
||||||
<ProductStatusToggle :product="product" />
|
<ProductStatusToggle :product="product" />
|
||||||
<DataTableActions
|
<DataTableActions :product="product" />
|
||||||
:product="product"
|
|
||||||
@reject="emit('reject', $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -155,14 +118,13 @@ function approvalStatusVariant(status: string): 'default' | 'secondary' | 'destr
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Varian</TableHead>
|
<TableHead>Varian</TableHead>
|
||||||
<TableHead>Foto</TableHead>
|
<TableHead>Foto</TableHead>
|
||||||
<TableHead>Stok Bagus</TableHead>
|
<TableHead>Stok</TableHead>
|
||||||
<TableHead>Stok Reject</TableHead>
|
|
||||||
<TableHead>Harga</TableHead>
|
<TableHead>Harga</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-if="!product.variants.length" :key="`${product.id}-empty`">
|
<TableRow v-if="!product.variants.length" :key="`${product.id}-empty`">
|
||||||
<TableCell colspan="5" class="text-muted-foreground">
|
<TableCell colspan="4" class="text-muted-foreground">
|
||||||
Belum ada varian
|
Belum ada varian
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -176,22 +138,17 @@ function approvalStatusVariant(status: string): 'default' | 'secondary' | 'destr
|
|||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ formatStock(variant.stock) }}
|
{{ formatStock(variant.stock) }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
|
||||||
{{ formatStock(variant.reject_stock ?? 0) }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div v-if="variant.prices.length" class="space-y-0.5 text-xs">
|
<div v-if="variant.prices?.length" class="space-y-0.5 text-xs">
|
||||||
<div v-for="type in PRICE_TYPES" :key="type">
|
<div v-for="type in PRICE_TYPES" :key="type">
|
||||||
<div
|
<div v-if="variant.prices?.find((item) => item.type === type)"
|
||||||
v-if="variant.prices.find((item) => item.type === type)"
|
class="flex items-center justify-between gap-3">
|
||||||
class="flex items-center justify-between gap-3"
|
|
||||||
>
|
|
||||||
<span class="text-muted-foreground">
|
<span class="text-muted-foreground">
|
||||||
{{ PRICE_TYPE_LABELS[type] }}
|
{{ PRICE_TYPE_LABELS[type] }}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-medium tabular-nums">
|
<span class="font-medium tabular-nums">
|
||||||
{{
|
{{
|
||||||
variant.prices.find((item) => item.type === type)?.price_formatted
|
variant.prices?.find((item) => item.type === type)?.price_formatted
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -221,18 +178,10 @@ function approvalStatusVariant(status: string): 'default' | 'secondary' | 'destr
|
|||||||
{{ paginationSummary }}
|
{{ paginationSummary }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div v-if="paginationLinks?.length && pagination.lastPage > 1"
|
||||||
v-if="paginationLinks?.length && pagination.lastPage > 1"
|
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end">
|
||||||
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end"
|
<Button v-for="link in paginationLinks" :key="`${link.label}-${link.url}`" variant="outline" size="sm"
|
||||||
>
|
:disabled="!link.url || link.active" as-child>
|
||||||
<Button
|
|
||||||
v-for="link in paginationLinks"
|
|
||||||
:key="`${link.label}-${link.url}`"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
:disabled="!link.url || link.active"
|
|
||||||
as-child
|
|
||||||
>
|
|
||||||
<Link v-if="link.url" :href="link.url" preserve-scroll>
|
<Link v-if="link.url" :href="link.url" preserve-scroll>
|
||||||
<span v-html="link.label" />
|
<span v-html="link.label" />
|
||||||
</Link>
|
</Link>
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link, router } from '@inertiajs/vue3';
|
||||||
|
import { Pencil, Trash2 } from '@lucide/vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { useCan } from '@/composables/useCan';
|
||||||
|
import type { ProductListItem } from '@/types/product';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
product: ProductListItem;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { can } = useCan();
|
||||||
|
|
||||||
|
const deleteConfirmOpen = ref(false);
|
||||||
|
const deleteProcessing = ref(false);
|
||||||
|
|
||||||
|
function destroyProduct() {
|
||||||
|
deleteProcessing.value = true;
|
||||||
|
|
||||||
|
router.delete(`/admin/master/products/${props.product.id}`, {
|
||||||
|
onSuccess: () => {
|
||||||
|
deleteConfirmOpen.value = false;
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Gagal menghapus produk.');
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
deleteProcessing.value = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<Tooltip v-if="can('products.update')">
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Button variant="ghost" size="icon" class="size-8" as-child>
|
||||||
|
<Link :href="`/admin/master/products/${product.id}/edit`">
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
<span class="sr-only">Ubah</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Ubah</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip v-if="can('products.delete')">
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
|
||||||
|
@click="deleteConfirmOpen = true">
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
<span class="sr-only">Hapus</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Hapus</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog v-if="can('products.delete')" v-model:open="deleteConfirmOpen" title="Hapus produk?"
|
||||||
|
:description="`Produk ${product.name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`"
|
||||||
|
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing" @confirm="destroyProduct" />
|
||||||
|
</template>
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { router } from '@inertiajs/vue3';
|
import { router } from '@inertiajs/vue3';
|
||||||
import { ref, watch, computed } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
@ -16,13 +16,6 @@ const { can } = useCan();
|
|||||||
const isActive = ref(props.product.is_active);
|
const isActive = ref(props.product.is_active);
|
||||||
const processing = ref(false);
|
const processing = ref(false);
|
||||||
|
|
||||||
const canToggle = computed(() => (
|
|
||||||
can('products.toggle-status')
|
|
||||||
&& can('products.verify')
|
|
||||||
&& props.product.status === 'approved'
|
|
||||||
&& props.product.pending_action !== 'delete'
|
|
||||||
));
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.product.is_active,
|
() => props.product.is_active,
|
||||||
(value) => {
|
(value) => {
|
||||||
@ -31,7 +24,7 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
function toggleStatus(checked: boolean) {
|
function toggleStatus(checked: boolean) {
|
||||||
if (!canToggle.value) {
|
if (!can('products.update')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,11 +49,8 @@ function toggleStatus(checked: boolean) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Switch
|
<Switch :model-value="isActive" :disabled="processing || !can('products.update')"
|
||||||
:model-value="isActive"
|
@update:model-value="toggleStatus" />
|
||||||
:disabled="processing || !canToggle"
|
|
||||||
@update:model-value="toggleStatus"
|
|
||||||
/>
|
|
||||||
<Badge :variant="isActive ? 'default' : 'secondary'">
|
<Badge :variant="isActive ? 'default' : 'secondary'">
|
||||||
{{ isActive ? 'Aktif' : 'Nonaktif' }}
|
{{ isActive ? 'Aktif' : 'Nonaktif' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -23,6 +23,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useVariantList } from '@/composables/useVariantList';
|
||||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||||
import { formErrors } from '@/lib/form';
|
import { formErrors } from '@/lib/form';
|
||||||
import { parseRupiah } from '@/lib/rupiah';
|
import { parseRupiah } from '@/lib/rupiah';
|
||||||
@ -61,19 +62,31 @@ function createClientId(): string {
|
|||||||
return `price-${crypto.randomUUID()}`;
|
return `price-${crypto.randomUUID()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEmptyPrice(): RawMaterialPriceFormItem {
|
const {
|
||||||
return {
|
items: prices,
|
||||||
|
addItem: addPriceRaw,
|
||||||
|
removeItem: removePrice,
|
||||||
|
setField: setPriceField,
|
||||||
|
appendToFormData,
|
||||||
|
itemErrors: priceErrors,
|
||||||
|
} = useVariantList<RawMaterialPriceFormItem>(
|
||||||
|
'prices',
|
||||||
|
() => ({
|
||||||
client_id: createClientId(),
|
client_id: createClientId(),
|
||||||
variant: '',
|
variant: '',
|
||||||
price: '',
|
price: '',
|
||||||
stock: '0',
|
stock: '0',
|
||||||
media: createMediaUploadState(),
|
media: createMediaUploadState(),
|
||||||
};
|
}),
|
||||||
}
|
() => {
|
||||||
|
|
||||||
function buildInitialPrices(): RawMaterialPriceFormItem[] {
|
|
||||||
if (!props.initialData?.prices?.length) {
|
if (!props.initialData?.prices?.length) {
|
||||||
return [createEmptyPrice()];
|
return [{
|
||||||
|
client_id: createClientId(),
|
||||||
|
variant: '',
|
||||||
|
price: '',
|
||||||
|
stock: '0',
|
||||||
|
media: createMediaUploadState(),
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
return props.initialData.prices.map((price) => ({
|
return props.initialData.prices.map((price) => ({
|
||||||
@ -84,28 +97,34 @@ function buildInitialPrices(): RawMaterialPriceFormItem[] {
|
|||||||
stock: price.stock ?? '0',
|
stock: price.stock ?? '0',
|
||||||
media: createMediaUploadState(price.images ?? []),
|
media: createMediaUploadState(price.images ?? []),
|
||||||
}));
|
}));
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const prices = ref<RawMaterialPriceFormItem[]>(buildInitialPrices());
|
const useSamePrice = ref(prices.value.length <= 1 || allPricesHaveSameValue());
|
||||||
const useSamePrice = ref(prices.value.length <= 1 || allPricesHaveSameValue(prices.value));
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
name: props.initialData?.name ?? '',
|
name: props.initialData?.name ?? '',
|
||||||
unit: props.initialData?.unit ?? '',
|
unit: props.initialData?.unit ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
function allPricesHaveSameValue(items: RawMaterialPriceFormItem[]): boolean {
|
function allPricesHaveSameValue(): boolean {
|
||||||
if (items.length <= 1) {
|
if (prices.value.length <= 1) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const first = items[0].price.trim();
|
const first = prices.value[0].price.trim();
|
||||||
|
|
||||||
return items.every((item) => item.price.trim() === first);
|
return prices.value.every((item) => item.price.trim() === first);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addPrice() {
|
function addPrice() {
|
||||||
const newPrice = createEmptyPrice();
|
const newPrice: RawMaterialPriceFormItem = {
|
||||||
|
client_id: createClientId(),
|
||||||
|
variant: '',
|
||||||
|
price: '',
|
||||||
|
stock: '0',
|
||||||
|
media: createMediaUploadState(),
|
||||||
|
};
|
||||||
|
|
||||||
if (useSamePrice.value && prices.value[0]) {
|
if (useSamePrice.value && prices.value[0]) {
|
||||||
newPrice.price = prices.value[0].price;
|
newPrice.price = prices.value[0].price;
|
||||||
@ -114,24 +133,8 @@ function addPrice() {
|
|||||||
prices.value = [...prices.value, newPrice];
|
prices.value = [...prices.value, newPrice];
|
||||||
}
|
}
|
||||||
|
|
||||||
function removePrice(clientId: string) {
|
|
||||||
if (prices.value.length <= 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
prices.value = prices.value.filter((price) => price.client_id !== clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setPriceField(clientId: string, key: 'variant' | 'stock', value: string) {
|
|
||||||
prices.value = prices.value.map((price) =>
|
|
||||||
price.client_id === clientId ? { ...price, [key]: value } : price,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setPriceValue(clientId: string, value: string) {
|
function setPriceValue(clientId: string, value: string) {
|
||||||
prices.value = prices.value.map((price) =>
|
setPriceField(clientId, 'price', value);
|
||||||
price.client_id === clientId ? { ...price, price: value } : price,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSharedPrice(value: string) {
|
function setSharedPrice(value: string) {
|
||||||
@ -168,24 +171,15 @@ function parseStockValue(value: string): number {
|
|||||||
function buildFormData(): FormData {
|
function buildFormData(): FormData {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
if (props.method === 'put') {
|
|
||||||
formData.append('_method', 'PUT');
|
|
||||||
}
|
|
||||||
|
|
||||||
formData.append('name', form.name.trim());
|
formData.append('name', form.name.trim());
|
||||||
formData.append('unit', form.unit);
|
formData.append('unit', form.unit);
|
||||||
|
|
||||||
prices.value.forEach((price, index) => {
|
appendToFormData(formData, (formData, index, price) => {
|
||||||
if (price.id) {
|
|
||||||
formData.append(`prices[${index}][id]`, String(price.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
formData.append(`prices[${index}][variant]`, price.variant.trim());
|
formData.append(`prices[${index}][variant]`, price.variant.trim());
|
||||||
formData.append(`prices[${index}][price]`, String(Number.parseInt(parseRupiah(price.price), 10) || 0));
|
formData.append(`prices[${index}][price]`, String(Number.parseInt(parseRupiah(price.price), 10) || 0));
|
||||||
formData.append(`prices[${index}][stock]`, String(parseStockValue(price.stock)));
|
formData.append(`prices[${index}][stock]`, String(parseStockValue(price.stock)));
|
||||||
|
|
||||||
appendMediaToFormData(formData, `prices[${index}]`, price.media);
|
appendMediaToFormData(formData, `prices[${index}]`, price.media);
|
||||||
});
|
}, props.method);
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
@ -292,7 +286,7 @@ function submit() {
|
|||||||
placeholder="Contoh: Premium / 40s" :maxlength="FIELD_LIMITS.variantName"
|
placeholder="Contoh: Premium / 40s" :maxlength="FIELD_LIMITS.variantName"
|
||||||
@update:model-value="setPriceField(price.client_id, 'variant', String($event))" />
|
@update:model-value="setPriceField(price.client_id, 'variant', String($event))" />
|
||||||
<FieldError
|
<FieldError
|
||||||
:errors="formErrors(form, `prices.${index}.variant`)" />
|
:errors="priceErrors(form, price.client_id, 'variant')" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field v-if="method !== 'put'">
|
<Field v-if="method !== 'put'">
|
||||||
<FieldLabel :for="`stock_${price.client_id}`" required>
|
<FieldLabel :for="`stock_${price.client_id}`" required>
|
||||||
@ -301,7 +295,7 @@ function submit() {
|
|||||||
<DecimalInput :id="`stock_${price.client_id}`" :model-value="price.stock"
|
<DecimalInput :id="`stock_${price.client_id}`" :model-value="price.stock"
|
||||||
@update:model-value="setPriceField(price.client_id, 'stock', String($event))" />
|
@update:model-value="setPriceField(price.client_id, 'stock', String($event))" />
|
||||||
<FieldError
|
<FieldError
|
||||||
:errors="formErrors(form, `prices.${index}.stock`)" />
|
:errors="priceErrors(form, price.client_id, 'stock')" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field v-if="!useSamePrice || prices.length === 1">
|
<Field v-if="!useSamePrice || prices.length === 1">
|
||||||
<FieldLabel :for="`price_${price.client_id}`" required>
|
<FieldLabel :for="`price_${price.client_id}`" required>
|
||||||
@ -310,14 +304,14 @@ function submit() {
|
|||||||
<RupiahInput :id="`price_${price.client_id}`" :model-value="price.price"
|
<RupiahInput :id="`price_${price.client_id}`" :model-value="price.price"
|
||||||
@update:model-value="setPriceValue(price.client_id, $event)" />
|
@update:model-value="setPriceValue(price.client_id, $event)" />
|
||||||
<FieldError
|
<FieldError
|
||||||
:errors="formErrors(form, `prices.${index}.price`)" />
|
:errors="priceErrors(form, price.client_id, 'price')" />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<MultipleImageUploadField :id="`price_images_${price.client_id}`" v-model="price.media"
|
<MultipleImageUploadField :id="`price_images_${price.client_id}`" v-model="price.media"
|
||||||
label="Foto Varian" :max-files="5" required
|
label="Foto Varian" :max-files="5" required
|
||||||
:errors="formErrors(form, `prices.${index}.images`)" />
|
:errors="priceErrors(form, price.client_id, 'images')" />
|
||||||
</div>
|
</div>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
import type { MasterOutOfStockGroup } from '@/types/master-inventory';
|
|
||||||
import type { MediaItem, MediaUploadState } from '@/types/media';
|
import type { MediaItem, MediaUploadState } from '@/types/media';
|
||||||
|
|
||||||
export type { MasterOutOfStockGroup as ProductOutOfStockGroup };
|
|
||||||
export type { MasterOutOfStockGroup as ProductStockWarningGroup };
|
|
||||||
|
|
||||||
export type EnumOption = {
|
export type EnumOption = {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
@ -33,7 +29,6 @@ export type ProductVariantItem = {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
stock: number;
|
stock: number;
|
||||||
reject_stock: number;
|
|
||||||
prices: ProductPriceItem[];
|
prices: ProductPriceItem[];
|
||||||
images?: MediaItem[];
|
images?: MediaItem[];
|
||||||
};
|
};
|
||||||
@ -42,13 +37,6 @@ export type ProductListItem = {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
status: 'pending' | 'approved' | 'rejected';
|
|
||||||
status_label: string;
|
|
||||||
pending_action?: 'delete' | null;
|
|
||||||
pending_action_label?: string | null;
|
|
||||||
rejection_reason?: string | null;
|
|
||||||
is_editable: boolean;
|
|
||||||
can_verify: boolean;
|
|
||||||
categories: ProductCategoryItem[];
|
categories: ProductCategoryItem[];
|
||||||
variants: ProductVariantItem[];
|
variants: ProductVariantItem[];
|
||||||
};
|
};
|
||||||
@ -77,7 +65,8 @@ export type ProductFilters = {
|
|||||||
sort?: string;
|
sort?: string;
|
||||||
direction?: 'asc' | 'desc' | null;
|
direction?: 'asc' | 'desc' | null;
|
||||||
is_active?: string;
|
is_active?: string;
|
||||||
status?: string;
|
category_id?: string;
|
||||||
|
stock_status?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaginatedProducts = {
|
export type PaginatedProducts = {
|
||||||
|
|||||||
@ -92,14 +92,6 @@
|
|||||||
->middleware('permission:'.Permission::PRODUCTS_TOGGLE_STATUS->value)
|
->middleware('permission:'.Permission::PRODUCTS_TOGGLE_STATUS->value)
|
||||||
->name('toggle-status');
|
->name('toggle-status');
|
||||||
|
|
||||||
Route::post('{product}/approve', [ProductController::class, 'approve'])
|
|
||||||
->middleware('permission:'.Permission::PRODUCTS_VERIFY->value)
|
|
||||||
->name('approve');
|
|
||||||
|
|
||||||
Route::post('{product}/reject', [ProductController::class, 'reject'])
|
|
||||||
->middleware('permission:'.Permission::PRODUCTS_VERIFY->value)
|
|
||||||
->name('reject');
|
|
||||||
|
|
||||||
Route::delete('{product}', [ProductController::class, 'destroy'])
|
Route::delete('{product}', [ProductController::class, 'destroy'])
|
||||||
->middleware('permission:'.Permission::PRODUCTS_DELETE->value)
|
->middleware('permission:'.Permission::PRODUCTS_DELETE->value)
|
||||||
->name('destroy');
|
->name('destroy');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user