feat: implement quick-create functionality for raw materials and products in Cutting module; enhance CuttingController and related components to support new features and improve user experience
This commit is contained in:
parent
47289d158e
commit
5a96b48725
@ -3,11 +3,13 @@
|
||||
namespace App\Http\Controllers\Admin\Manage\Cutting;
|
||||
|
||||
use App\Enums\CuttingStatus;
|
||||
use App\Enums\RawMaterialUnit;
|
||||
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\CuttingRequest;
|
||||
use App\Http\Requests\Admin\Manage\CuttingStatusTransitionRequest;
|
||||
use App\Models\Category;
|
||||
use App\Models\Cutting;
|
||||
use App\Services\Manage\CuttingService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@ -44,6 +46,8 @@ public function create(Request $request): Response
|
||||
'productCatalog' => $this->cuttingService->productCatalog(user: $user),
|
||||
'draftMaterials' => $this->cuttingService->draftMaterialsForUser($user),
|
||||
'draftResults' => $this->cuttingService->draftResultsForUser($user),
|
||||
'categories' => Category::query()->orderBy('name')->get(['id', 'name'])->map(fn ($c) => ['value' => $c->id, 'label' => $c->name])->all(),
|
||||
'units' => collect(RawMaterialUnit::cases())->map(fn ($u) => ['value' => $u->value, 'label' => $u->label()])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -70,6 +74,8 @@ public function edit(Cutting $cutting): Response|RedirectResponse
|
||||
'cutting' => $this->cuttingService->findForEdit($cutting),
|
||||
'rawMaterialCatalog' => $this->cuttingService->rawMaterialCatalog($cutting),
|
||||
'productCatalog' => $this->cuttingService->productCatalog($cutting),
|
||||
'categories' => Category::query()->orderBy('name')->get(['id', 'name'])->map(fn ($c) => ['value' => $c->id, 'label' => $c->name])->all(),
|
||||
'units' => collect(RawMaterialUnit::cases())->map(fn ($u) => ['value' => $u->value, 'label' => $u->label()])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\CuttingDraftMaterialRequest;
|
||||
use App\Http\Requests\Admin\Manage\CuttingDraftResultRequest;
|
||||
use App\Http\Requests\Admin\Manage\CuttingQuickCreateProductRequest;
|
||||
use App\Http\Requests\Admin\Manage\CuttingQuickCreateRawMaterialRequest;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Services\Manage\CuttingService;
|
||||
@ -44,4 +46,18 @@ public function destroyResult(Request $request, ProductVariant $productVariant):
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function quickCreateRawMaterial(CuttingQuickCreateRawMaterialRequest $request): JsonResponse
|
||||
{
|
||||
$rawMaterial = $this->cuttingService->quickCreateRawMaterial($request->validated());
|
||||
|
||||
return response()->json(['raw_material' => $rawMaterial]);
|
||||
}
|
||||
|
||||
public function quickCreateProduct(CuttingQuickCreateProductRequest $request): JsonResponse
|
||||
{
|
||||
$product = $this->cuttingService->quickCreateProduct($request->validated());
|
||||
|
||||
return response()->json(['product' => $product]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Requests\Concerns\HasProductVariantRules;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CuttingQuickCreateProductRequest extends FormRequest
|
||||
{
|
||||
use HasProductVariantRules;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can(Permission::CUTTINGS_CREATE->value) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'description' => ['nullable', 'string'],
|
||||
|
||||
'category_ids' => ['nullable', 'array'],
|
||||
'category_ids.*' => ['integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
|
||||
|
||||
...$this->productVariantRules(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Nama Produk',
|
||||
'description' => 'Deskripsi',
|
||||
'category_ids' => 'Kategori',
|
||||
'category_ids.*' => 'Kategori',
|
||||
...$this->productVariantAttributes(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\RawMaterialUnit;
|
||||
use App\Http\Requests\Concerns\HasRawMaterialPriceRules;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CuttingQuickCreateRawMaterialRequest extends FormRequest
|
||||
{
|
||||
use HasRawMaterialPriceRules;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can(Permission::CUTTINGS_CREATE->value) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'unit' => ['required', Rule::enum(RawMaterialUnit::class)],
|
||||
|
||||
...$this->rawMaterialPriceRules(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Nama Bahan Baku',
|
||||
'unit' => 'Satuan',
|
||||
...$this->rawMaterialPriceAttributes(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -3,14 +3,13 @@
|
||||
namespace App\Http\Requests\Admin\Master;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\Role;
|
||||
use App\Http\Requests\Concerns\ValidatesMediaUploads;
|
||||
use App\Http\Requests\Concerns\HasProductVariantRules;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProductRequest extends FormRequest
|
||||
{
|
||||
use ValidatesMediaUploads;
|
||||
use HasProductVariantRules;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
@ -26,42 +25,15 @@ public function authorize(): bool
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$isAdminBahanBaku = $this->user()?->hasRole(Role::ADMIN_BAHAN_BAKU->value) ?? false;
|
||||
|
||||
$rules = [
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'description' => ['nullable', 'string'],
|
||||
|
||||
'category_ids' => ['required', 'array', 'min:1'],
|
||||
'category_ids.*' => ['integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
|
||||
|
||||
'variants' => ['required', 'array', 'min:1'],
|
||||
'variants.*.id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('product_variants', 'id')
|
||||
->where('product_id', $this->route('product')?->id)
|
||||
->whereNull('deleted_at'),
|
||||
],
|
||||
'variants.*.name' => ['required', 'string', 'max:200'],
|
||||
'variants.*.stock' => ['required', 'integer', 'min:0'],
|
||||
'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
|
||||
...$this->variantImageRules(),
|
||||
...$this->productVariantRules(productId: $this->route('product')?->id),
|
||||
];
|
||||
|
||||
if (! $isAdminBahanBaku) {
|
||||
$rules['variants.*.prices'] = ['required', 'array'];
|
||||
$rules['variants.*.prices.distributor'] = ['required', 'integer', 'min:0'];
|
||||
$rules['variants.*.prices.agent'] = ['required', 'integer', 'min:0'];
|
||||
$rules['variants.*.prices.sub_agent'] = ['required', 'integer', 'min:0'];
|
||||
$rules['variants.*.prices.grosir'] = ['required', 'integer', 'min:0'];
|
||||
$rules['variants.*.prices.retail'] = ['required', 'integer', 'min:0'];
|
||||
$rules['variants.*.prices.tiktok'] = ['required', 'integer', 'min:0'];
|
||||
$rules['variants.*.prices.shopee'] = ['required', 'integer', 'min:0'];
|
||||
$rules['variants.*.prices.harga_modal'] = ['required', 'integer', 'min:0'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -74,20 +46,7 @@ public function attributes(): array
|
||||
'description' => 'Deskripsi',
|
||||
'category_ids' => 'Kategori',
|
||||
'category_ids.*' => 'Kategori',
|
||||
'variants' => 'Varian',
|
||||
'variants.*.name' => 'Nama Varian',
|
||||
'variants.*.stock' => 'Stok',
|
||||
'variants.*.retail_stock' => 'Stok Ecer',
|
||||
'variants.*.prices' => 'Harga',
|
||||
'variants.*.prices.distributor' => 'Distributor',
|
||||
'variants.*.prices.agent' => 'Agen',
|
||||
'variants.*.prices.sub_agent' => 'Sub Agen',
|
||||
'variants.*.prices.grosir' => 'Grosir',
|
||||
'variants.*.prices.retail' => 'Eceran',
|
||||
'variants.*.prices.tiktok' => 'TikTok',
|
||||
'variants.*.prices.shopee' => 'Shopee',
|
||||
'variants.*.prices.harga_modal' => 'Harga Modal',
|
||||
...$this->variantImageAttributes('variants', 'Foto Varian'),
|
||||
...$this->productVariantAttributes(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,13 +4,13 @@
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\RawMaterialUnit;
|
||||
use App\Http\Requests\Concerns\ValidatesMediaUploads;
|
||||
use App\Http\Requests\Concerns\HasRawMaterialPriceRules;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class RawMaterialRequest extends FormRequest
|
||||
{
|
||||
use ValidatesMediaUploads;
|
||||
use HasRawMaterialPriceRules;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
@ -30,18 +30,7 @@ public function rules(): array
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'unit' => ['required', Rule::enum(RawMaterialUnit::class)],
|
||||
|
||||
'prices' => ['required', 'array', 'min:1'],
|
||||
'prices.*.id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('raw_material_prices', 'id')
|
||||
->where('raw_material_id', $this->route('rawMaterial')?->id)
|
||||
->whereNull('deleted_at'),
|
||||
],
|
||||
'prices.*.variant' => ['required', 'string', 'max:200'],
|
||||
'prices.*.price' => ['required', 'integer', 'gt:0'],
|
||||
'prices.*.stock' => ['required', 'numeric', 'decimal:0,4', 'min:0'],
|
||||
...$this->variantImageRules('prices'),
|
||||
...$this->rawMaterialPriceRules(rawMaterialId: $this->route('rawMaterial')?->id),
|
||||
];
|
||||
}
|
||||
|
||||
@ -53,11 +42,7 @@ public function attributes(): array
|
||||
return [
|
||||
'name' => 'Nama Bahan Baku',
|
||||
'unit' => 'Satuan',
|
||||
'prices' => 'Varian',
|
||||
'prices.*.variant' => 'Nama Varian',
|
||||
'prices.*.price' => 'Harga',
|
||||
'prices.*.stock' => 'Stok',
|
||||
...$this->variantImageAttributes('prices', 'Foto Varian'),
|
||||
...$this->rawMaterialPriceAttributes(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
78
app/Http/Requests/Concerns/HasProductVariantRules.php
Normal file
78
app/Http/Requests/Concerns/HasProductVariantRules.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Concerns;
|
||||
|
||||
use App\Enums\Role;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* @method FormRequest user()
|
||||
*/
|
||||
trait HasProductVariantRules
|
||||
{
|
||||
use ValidatesMediaUploads;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function productVariantRules(string $variantsKey = 'variants', ?int $productId = null): array
|
||||
{
|
||||
$isAdminBahanBaku = $this->user()?->hasRole(Role::ADMIN_BAHAN_BAKU->value) ?? false;
|
||||
|
||||
$rules = [
|
||||
"{$variantsKey}" => ['required', 'array', 'min:1'],
|
||||
"{$variantsKey}.*.name" => ['required', 'string', 'max:200'],
|
||||
"{$variantsKey}.*.stock" => ['required', 'integer', 'min:0'],
|
||||
"{$variantsKey}.*.retail_stock" => ['required', 'integer', 'min:0'],
|
||||
...$this->variantImageRules($variantsKey),
|
||||
];
|
||||
|
||||
if ($productId) {
|
||||
$rules["{$variantsKey}.*.id"] = [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('product_variants', 'id')
|
||||
->where('product_id', $productId)
|
||||
->whereNull('deleted_at'),
|
||||
];
|
||||
}
|
||||
|
||||
if (! $isAdminBahanBaku) {
|
||||
$rules["{$variantsKey}.*.prices"] = ['required', 'array'];
|
||||
$rules["{$variantsKey}.*.prices.distributor"] = ['required', 'integer', 'min:0'];
|
||||
$rules["{$variantsKey}.*.prices.agent"] = ['required', 'integer', 'min:0'];
|
||||
$rules["{$variantsKey}.*.prices.sub_agent"] = ['required', 'integer', 'min:0'];
|
||||
$rules["{$variantsKey}.*.prices.grosir"] = ['required', 'integer', 'min:0'];
|
||||
$rules["{$variantsKey}.*.prices.retail"] = ['required', 'integer', 'min:0'];
|
||||
$rules["{$variantsKey}.*.prices.tiktok"] = ['required', 'integer', 'min:0'];
|
||||
$rules["{$variantsKey}.*.prices.shopee"] = ['required', 'integer', 'min:0'];
|
||||
$rules["{$variantsKey}.*.prices.harga_modal"] = ['required', 'integer', 'min:0'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function productVariantAttributes(string $variantsKey = 'variants'): array
|
||||
{
|
||||
return [
|
||||
$variantsKey => 'Varian',
|
||||
"{$variantsKey}.*.name" => 'Nama Varian',
|
||||
"{$variantsKey}.*.stock" => 'Stok',
|
||||
"{$variantsKey}.*.retail_stock" => 'Stok Ecer',
|
||||
"{$variantsKey}.*.prices" => 'Harga',
|
||||
"{$variantsKey}.*.prices.distributor" => 'Distributor',
|
||||
"{$variantsKey}.*.prices.agent" => 'Agen',
|
||||
"{$variantsKey}.*.prices.sub_agent" => 'Sub Agen',
|
||||
"{$variantsKey}.*.prices.grosir" => 'Grosir',
|
||||
"{$variantsKey}.*.prices.retail" => 'Eceran',
|
||||
"{$variantsKey}.*.prices.tiktok" => 'TikTok',
|
||||
"{$variantsKey}.*.prices.shopee" => 'Shopee',
|
||||
"{$variantsKey}.*.prices.harga_modal" => 'Harga Modal',
|
||||
...$this->variantImageAttributes($variantsKey, 'Foto Varian'),
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Http/Requests/Concerns/HasRawMaterialPriceRules.php
Normal file
51
app/Http/Requests/Concerns/HasRawMaterialPriceRules.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Concerns;
|
||||
|
||||
use App\Enums\RawMaterialUnit;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
trait HasRawMaterialPriceRules
|
||||
{
|
||||
use ValidatesMediaUploads;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function rawMaterialPriceRules(string $pricesKey = 'prices', ?int $rawMaterialId = null): array
|
||||
{
|
||||
$rules = [
|
||||
"{$pricesKey}" => ['required', 'array', 'min:1'],
|
||||
"{$pricesKey}.*.variant" => ['required', 'string', 'max:200'],
|
||||
"{$pricesKey}.*.price" => ['required', 'integer', 'gt:0'],
|
||||
"{$pricesKey}.*.stock" => ['required', 'numeric', 'decimal:0,4', 'min:0'],
|
||||
...$this->variantImageRules($pricesKey),
|
||||
];
|
||||
|
||||
if ($rawMaterialId) {
|
||||
$rules["{$pricesKey}.*.id"] = [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('raw_material_prices', 'id')
|
||||
->where('raw_material_id', $rawMaterialId)
|
||||
->whereNull('deleted_at'),
|
||||
];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function rawMaterialPriceAttributes(string $pricesKey = 'prices'): array
|
||||
{
|
||||
return [
|
||||
$pricesKey => 'Varian',
|
||||
"{$pricesKey}.*.variant" => 'Nama Varian',
|
||||
"{$pricesKey}.*.price" => 'Harga',
|
||||
"{$pricesKey}.*.stock" => 'Stok',
|
||||
...$this->variantImageAttributes($pricesKey, 'Foto Varian'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Services\Manage;
|
||||
|
||||
use App\Enums\CuttingStatus;
|
||||
use App\Enums\RawMaterialUnit;
|
||||
use App\Enums\Role;
|
||||
use App\Models\Cutting;
|
||||
use App\Models\CuttingMaterial;
|
||||
@ -13,6 +14,7 @@
|
||||
use App\Models\RawMaterial;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\User;
|
||||
use App\Services\Media\MediaService;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use App\Support\Media\MediaPresenter;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@ -26,6 +28,7 @@ class CuttingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PushNotificationService $pushNotificationService,
|
||||
private readonly MediaService $mediaService,
|
||||
) {}
|
||||
|
||||
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
|
||||
@ -1034,4 +1037,129 @@ private function storeResultPrices(Cutting $cutting, array $resultPrices): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick-create a raw material with variants, bypassing owner verification.
|
||||
*/
|
||||
public function quickCreateRawMaterial(array $validated): array
|
||||
{
|
||||
$rawMaterial = RawMaterial::create([
|
||||
'name' => $validated['name'],
|
||||
'unit' => $validated['unit'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$createdPrices = [];
|
||||
$maxVariantImages = 5;
|
||||
|
||||
foreach ($validated['prices'] as $index => $priceData) {
|
||||
$price = $rawMaterial->prices()->create([
|
||||
'variant' => $priceData['variant'],
|
||||
'price' => $priceData['price'],
|
||||
'stock' => $priceData['stock'],
|
||||
]);
|
||||
|
||||
$this->mediaService->syncCollection(
|
||||
$price,
|
||||
'images',
|
||||
$priceData['images'] ?? null,
|
||||
null,
|
||||
$maxVariantImages,
|
||||
required: true,
|
||||
errorKey: "prices.{$index}.images",
|
||||
);
|
||||
|
||||
$price->refresh();
|
||||
|
||||
$createdPrices[] = [
|
||||
'id' => $price->id,
|
||||
'variant' => $price->variant,
|
||||
'price' => $price->price,
|
||||
'price_formatted' => 'Rp '.number_format($price->price, 0, ',', '.'),
|
||||
'stock' => $price->stock,
|
||||
'stock_formatted' => $this->formatStockForUnit((float) $price->stock, $rawMaterial->unit),
|
||||
'stock_input' => $this->formatQuantityInput((float) $price->stock),
|
||||
'images' => MediaPresenter::collection($price, 'images'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $rawMaterial->id,
|
||||
'name' => $rawMaterial->name,
|
||||
'unit' => $rawMaterial->unit->value,
|
||||
'unit_label' => $rawMaterial->unit->label(),
|
||||
'unit_abbreviation' => $rawMaterial->unit->abbreviation(),
|
||||
'is_active' => true,
|
||||
'prices' => $createdPrices,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick-create a product with variants and prices, bypassing owner verification.
|
||||
*/
|
||||
public function quickCreateProduct(array $validated): array
|
||||
{
|
||||
$product = Product::create([
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
if (!empty($validated['category_ids'])) {
|
||||
$product->categories()->sync($validated['category_ids']);
|
||||
}
|
||||
|
||||
$createdVariants = [];
|
||||
$maxVariantImages = 5;
|
||||
|
||||
foreach ($validated['variants'] as $index => $variantData) {
|
||||
$variant = $product->variants()->create([
|
||||
'name' => $variantData['name'],
|
||||
'stock' => $variantData['stock'] ?? 0,
|
||||
'retail_stock' => $variantData['retail_stock'] ?? 0,
|
||||
]);
|
||||
|
||||
if (!empty($variantData['prices'])) {
|
||||
foreach ($variantData['prices'] as $type => $priceValue) {
|
||||
$variant->prices()->create([
|
||||
'type' => $type,
|
||||
'price' => (int) $priceValue,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->mediaService->syncCollection(
|
||||
$variant,
|
||||
'images',
|
||||
$variantData['images'] ?? null,
|
||||
null,
|
||||
$maxVariantImages,
|
||||
required: true,
|
||||
errorKey: "variants.{$index}.images",
|
||||
);
|
||||
|
||||
$variant->refresh();
|
||||
|
||||
$createdVariants[] = [
|
||||
'id' => $variant->id,
|
||||
'name' => $variant->name,
|
||||
'stock' => $variant->stock,
|
||||
'images' => MediaPresenter::collection($variant, 'images'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'is_active' => true,
|
||||
'variants' => $createdVariants,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatStockForUnit(float $stock, RawMaterialUnit $unit): string
|
||||
{
|
||||
$formatted = rtrim(rtrim(number_format($stock, 2, ',', '.'), '0'), ',');
|
||||
|
||||
return "{$formatted} {$unit->abbreviation()}";
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,15 +7,26 @@ function getXsrfToken(): string {
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const isFormData = options.body instanceof FormData;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'X-XSRF-TOKEN': getXsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...(!isFormData && { 'Content-Type': 'application/json' }),
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
// Remove any null/undefined headers
|
||||
Object.keys(headers).forEach((key) => {
|
||||
if (headers[key] == null) {
|
||||
delete headers[key];
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': getXsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...options.headers,
|
||||
},
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
|
||||
@ -7,6 +7,8 @@ import type {
|
||||
CuttingRawMaterialCatalogItem,
|
||||
CuttingResultCartItem,
|
||||
} from '@/types/cutting';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import type { EnumOption } from '@/types/raw-material';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import CuttingPosForm from './form/CuttingPosForm.vue';
|
||||
import { index, store } from '@/routes/admin/manage/cuttings';
|
||||
@ -16,6 +18,8 @@ defineProps<{
|
||||
productCatalog: CuttingProductCatalogItem[];
|
||||
draftMaterials: CuttingMaterialCartItem[];
|
||||
draftResults: CuttingResultCartItem[];
|
||||
categories: CategoryOption[];
|
||||
units: EnumOption[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@ -40,6 +44,8 @@ defineProps<{
|
||||
:product-catalog="productCatalog"
|
||||
:draft-materials="draftMaterials"
|
||||
:draft-results="draftResults"
|
||||
:categories="categories"
|
||||
:units="units"
|
||||
:submit-url="store.url()"
|
||||
method="post"
|
||||
submit-label="Simpan"
|
||||
|
||||
@ -6,6 +6,8 @@ import type {
|
||||
CuttingProductCatalogItem,
|
||||
CuttingRawMaterialCatalogItem,
|
||||
} from '@/types/cutting';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import type { EnumOption } from '@/types/raw-material';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import CuttingPosForm from './form/CuttingPosForm.vue';
|
||||
@ -15,6 +17,8 @@ const props = defineProps<{
|
||||
cutting: CuttingEditItem;
|
||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||
productCatalog: CuttingProductCatalogItem[];
|
||||
categories: CategoryOption[];
|
||||
units: EnumOption[];
|
||||
}>();
|
||||
|
||||
const initialData = computed(() => ({
|
||||
@ -45,7 +49,6 @@ const initialData = computed(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head title="Ubah Cutting" />
|
||||
|
||||
<AdminLayout>
|
||||
@ -58,6 +61,7 @@ const initialData = computed(() => ({
|
||||
</div>
|
||||
|
||||
<CuttingPosForm :raw-material-catalog="rawMaterialCatalog" :product-catalog="productCatalog"
|
||||
:categories="categories" :units="units"
|
||||
:initial-data="initialData" :submit-url="update.url(props.cutting.id)" method="put"
|
||||
submit-label="Perbarui" />
|
||||
</AdminLayout>
|
||||
|
||||
@ -9,6 +9,8 @@ import type {
|
||||
CuttingRawMaterialCatalogItem,
|
||||
CuttingResultCartItem,
|
||||
} from '@/types/cutting';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import type { EnumOption } from '@/types/raw-material';
|
||||
import CuttingPosCartDetailDialog from './CuttingPosCartDetailDialog.vue';
|
||||
import CuttingPosMaterialCatalogPanel from './CuttingPosMaterialCatalogPanel.vue';
|
||||
import CuttingPosResultCatalogPanel from './CuttingPosResultCatalogPanel.vue';
|
||||
@ -18,6 +20,8 @@ import { useCuttingPosCart } from './useCuttingPosCart';
|
||||
const props = defineProps<{
|
||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||
productCatalog: CuttingProductCatalogItem[];
|
||||
categories: CategoryOption[];
|
||||
units: EnumOption[];
|
||||
initialData?: {
|
||||
description: string;
|
||||
sewing_cost?: string;
|
||||
@ -35,6 +39,13 @@ const props = defineProps<{
|
||||
const isCreateMode = computed(() => props.method === 'post');
|
||||
const cartDetailOpen = ref(false);
|
||||
|
||||
// Make catalogs mutable so we can add new items from quick-create dialogs
|
||||
const rawMaterialCatalogState = ref<CuttingRawMaterialCatalogItem[]>([...props.rawMaterialCatalog]);
|
||||
const productCatalogState = ref<CuttingProductCatalogItem[]>([...props.productCatalog]);
|
||||
|
||||
watch(() => props.rawMaterialCatalog, (val) => { rawMaterialCatalogState.value = [...val]; });
|
||||
watch(() => props.productCatalog, (val) => { productCatalogState.value = [...val]; });
|
||||
|
||||
const form = useForm({
|
||||
description: '',
|
||||
sewing_cost: '0',
|
||||
@ -65,8 +76,8 @@ const {
|
||||
syncMaterialField,
|
||||
syncResultField,
|
||||
} = useCuttingPosCart({
|
||||
rawMaterialCatalog: () => props.rawMaterialCatalog,
|
||||
productCatalog: () => props.productCatalog,
|
||||
rawMaterialCatalog: rawMaterialCatalogState,
|
||||
productCatalog: productCatalogState,
|
||||
isCreateMode: () => isCreateMode.value,
|
||||
});
|
||||
|
||||
@ -142,6 +153,30 @@ function submit() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onRawMaterialCreated(rawMaterial: CuttingRawMaterialCatalogItem) {
|
||||
// Add to catalog
|
||||
rawMaterialCatalogState.value.push(rawMaterial);
|
||||
|
||||
// Auto-add the first price variant to cart
|
||||
const firstPrice = rawMaterial.prices[0];
|
||||
|
||||
if (firstPrice) {
|
||||
addMaterial(rawMaterial, firstPrice);
|
||||
}
|
||||
}
|
||||
|
||||
function onProductCreated(product: CuttingProductCatalogItem) {
|
||||
// Add to catalog
|
||||
productCatalogState.value.push(product);
|
||||
|
||||
// Auto-add the first variant to cart
|
||||
const firstVariant = product.variants[0];
|
||||
|
||||
if (firstVariant) {
|
||||
addResult(product, firstVariant);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -149,12 +184,16 @@ function submit() {
|
||||
<div class="space-y-4">
|
||||
<CuttingPosMaterialCatalogPanel v-model:material-search="materialSearch"
|
||||
:filtered-raw-materials="filteredRawMaterials" :get-material-cart-item="getMaterialCartItem"
|
||||
@add-material="addMaterial" @decrease-material-qty="decreaseMaterialQty" />
|
||||
:units="units"
|
||||
@add-material="addMaterial" @decrease-material-qty="decreaseMaterialQty"
|
||||
@raw-material-created="onRawMaterialCreated" />
|
||||
|
||||
<CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts"
|
||||
:get-result-cart-item="getResultCartItem" @add-result="addResult"
|
||||
:is-create-mode="isCreateMode"
|
||||
@decrease-result-qty="decreaseResultQty" />
|
||||
:categories="categories"
|
||||
@decrease-result-qty="decreaseResultQty"
|
||||
@product-created="onProductCreated" />
|
||||
</div>
|
||||
|
||||
<CuttingPosSummaryPanel :form="form" :material-cart="materialCart" :result-cart="resultCart"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Minus, Plus, Search } from '@lucide/vue';
|
||||
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
||||
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
||||
@ -14,11 +15,14 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
||||
import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting';
|
||||
import type { EnumOption } from '@/types/raw-material';
|
||||
import type { CuttingCatalogPrice } from './useCuttingPosCart';
|
||||
import QuickCreateRawMaterialModal from './QuickCreateRawMaterialModal.vue';
|
||||
|
||||
defineProps<{
|
||||
filteredRawMaterials: CuttingRawMaterialCatalogItem[];
|
||||
getMaterialCartItem: (priceId: number) => CuttingMaterialCartItem | undefined;
|
||||
units: EnumOption[];
|
||||
}>();
|
||||
|
||||
const materialSearch = defineModel<string>('materialSearch', { required: true });
|
||||
@ -26,13 +30,20 @@ const materialSearch = defineModel<string>('materialSearch', { required: true })
|
||||
const emit = defineEmits<{
|
||||
'add-material': [rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice];
|
||||
'decrease-material-qty': [priceId: number];
|
||||
'raw-material-created': [rawMaterial: CuttingRawMaterialCatalogItem];
|
||||
}>();
|
||||
|
||||
const quickCreateOpen = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="min-w-0">
|
||||
<CardHeader class="pb-3">
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-3 space-y-0">
|
||||
<CardTitle class="text-base">Pilih Bahan Baku</CardTitle>
|
||||
<Button type="button" variant="outline" size="sm" @click="quickCreateOpen = true">
|
||||
<Plus class="size-3.5 mr-1" />
|
||||
Baru
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
@ -98,7 +109,7 @@ const emit = defineEmits<{
|
||||
>
|
||||
<Minus class="size-3.5" />
|
||||
</Button>
|
||||
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
|
||||
<span class="min-w-5 text-center text-xs font-semibold tabular-nums">
|
||||
{{ getMaterialCartItem(price.id)!.material_usage }}
|
||||
</span>
|
||||
<Button
|
||||
@ -126,4 +137,10 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<QuickCreateRawMaterialModal
|
||||
v-model:open="quickCreateOpen"
|
||||
:units="units"
|
||||
@created="emit('raw-material-created', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Check, Minus, Plus, Search } from '@lucide/vue';
|
||||
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
||||
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
||||
@ -13,12 +14,15 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
||||
import type { CuttingProductCatalogItem, CuttingResultCartItem } from '@/types/cutting';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import type { CuttingCatalogVariant } from './useCuttingPosCart';
|
||||
import QuickCreateProductModal from './QuickCreateProductModal.vue';
|
||||
|
||||
defineProps<{
|
||||
filteredProducts: CuttingProductCatalogItem[];
|
||||
getResultCartItem: (variantId: number) => CuttingResultCartItem | undefined;
|
||||
isCreateMode: boolean;
|
||||
categories: CategoryOption[];
|
||||
}>();
|
||||
|
||||
const productSearch = defineModel<string>('productSearch', { required: true });
|
||||
@ -26,13 +30,20 @@ const productSearch = defineModel<string>('productSearch', { required: true });
|
||||
const emit = defineEmits<{
|
||||
'add-result': [product: CuttingProductCatalogItem, variant: CuttingCatalogVariant];
|
||||
'decrease-result-qty': [variantId: number];
|
||||
'product-created': [product: CuttingProductCatalogItem];
|
||||
}>();
|
||||
|
||||
const quickCreateOpen = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="min-w-0">
|
||||
<CardHeader class="pb-3">
|
||||
<CardHeader class="flex flex-row items-center justify-between pb-3 space-y-0">
|
||||
<CardTitle class="text-base">Pilih Produk Hasil</CardTitle>
|
||||
<Button type="button" variant="outline" size="sm" @click="quickCreateOpen = true">
|
||||
<Plus class="size-3.5 mr-1" />
|
||||
Baru
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-4">
|
||||
@ -107,7 +118,7 @@ const emit = defineEmits<{
|
||||
>
|
||||
<Minus class="size-3.5" />
|
||||
</Button>
|
||||
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
|
||||
<span class="min-w-5 text-center text-xs font-semibold tabular-nums">
|
||||
{{ getResultCartItem(variant.id)!.cutting_result }}
|
||||
</span>
|
||||
<Button
|
||||
@ -136,4 +147,10 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<QuickCreateProductModal
|
||||
v-model:open="quickCreateOpen"
|
||||
:categories="categories"
|
||||
@created="emit('product-created', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
import { Plus, Save } from '@lucide/vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import { useVariantList } from '@/composables/useVariantList';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import ProductInfoSection from '@/pages/admin/master/products/form/ProductInfoSection.vue';
|
||||
import ProductVariantSection from '@/pages/admin/master/products/form/ProductVariantSection.vue';
|
||||
import type { CuttingProductCatalogItem } from '@/types/cutting';
|
||||
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
||||
import type { CategoryOption, ProductVariantFormItem } from '@/types/product';
|
||||
|
||||
const { hasRole } = useCan();
|
||||
const showPrices = !hasRole('admin-bahan-baku');
|
||||
|
||||
const props = defineProps<{
|
||||
categories: CategoryOption[];
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
'created': [product: CuttingProductCatalogItem];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
function createClientId(): string {
|
||||
return `variant-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
category_ids: [] as number[],
|
||||
errors: {} as Record<string, string>,
|
||||
});
|
||||
|
||||
const {
|
||||
items: variants,
|
||||
addItem: addVariant,
|
||||
removeItem: removeVariant,
|
||||
setField: setVariantField,
|
||||
appendToFormData,
|
||||
itemErrors: variantErrors,
|
||||
} = useVariantList<ProductVariantFormItem>(
|
||||
'variants',
|
||||
() => ({
|
||||
client_id: createClientId(),
|
||||
name: '',
|
||||
stock: '0',
|
||||
retail_stock: '0',
|
||||
prices: {
|
||||
distributor: '0',
|
||||
agent: '0',
|
||||
sub_agent: '0',
|
||||
grosir: '0',
|
||||
retail: '0',
|
||||
tiktok: '0',
|
||||
shopee: '0',
|
||||
harga_modal: '0',
|
||||
},
|
||||
media: createMediaUploadState(),
|
||||
}),
|
||||
() => [{
|
||||
client_id: createClientId(),
|
||||
name: '',
|
||||
stock: '0',
|
||||
retail_stock: '0',
|
||||
prices: {
|
||||
distributor: '0',
|
||||
agent: '0',
|
||||
sub_agent: '0',
|
||||
grosir: '0',
|
||||
retail: '0',
|
||||
tiktok: '0',
|
||||
shopee: '0',
|
||||
harga_modal: '0',
|
||||
},
|
||||
media: createMediaUploadState(),
|
||||
}],
|
||||
);
|
||||
|
||||
const copiedPrices = ref<Record<string, string> | null>(null);
|
||||
|
||||
function copyPrices(variantPrices: Record<string, string>) {
|
||||
copiedPrices.value = { ...variantPrices };
|
||||
}
|
||||
|
||||
function pastePrices(clientId: string) {
|
||||
if (!copiedPrices.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVariantField(clientId, 'prices', { ...copiedPrices.value });
|
||||
}
|
||||
|
||||
function applyToAllPrices(variantPrices: Record<string, string>) {
|
||||
variants.value.forEach((v) => {
|
||||
setVariantField(v.client_id, 'prices', { ...variantPrices });
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCategory(categoryId: number, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!form.value.category_ids.includes(categoryId)) {
|
||||
form.value.category_ids = [...form.value.category_ids, categoryId];
|
||||
}
|
||||
} else {
|
||||
form.value.category_ids = form.value.category_ids.filter((id) => id !== categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
const categoryError = computed(() => form.value.errors.category_ids ?? '');
|
||||
|
||||
function resetForm() {
|
||||
form.value = { name: '', description: '', category_ids: [], errors: {} };
|
||||
variants.value = [{
|
||||
client_id: createClientId(),
|
||||
name: '',
|
||||
stock: '0',
|
||||
retail_stock: '0',
|
||||
prices: {
|
||||
distributor: '0',
|
||||
agent: '0',
|
||||
sub_agent: '0',
|
||||
grosir: '0',
|
||||
retail: '0',
|
||||
tiktok: '0',
|
||||
shopee: '0',
|
||||
harga_modal: '0',
|
||||
},
|
||||
media: createMediaUploadState(),
|
||||
}];
|
||||
}
|
||||
|
||||
function buildFormData(): FormData {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('name', form.value.name.trim());
|
||||
formData.append('description', form.value.description.trim());
|
||||
|
||||
form.value.category_ids.forEach((categoryId) => {
|
||||
formData.append('category_ids[]', String(categoryId));
|
||||
});
|
||||
|
||||
appendToFormData(formData, (formData, index, variant) => {
|
||||
formData.append(`variants[${index}][name]`, variant.name.trim());
|
||||
formData.append(`variants[${index}][stock]`, String(Number.parseInt(String(variant.stock), 10) || 0));
|
||||
formData.append(`variants[${index}][retail_stock]`, String(Number.parseInt(String(variant.retail_stock), 10) || 0));
|
||||
|
||||
if (variant.prices && showPrices) {
|
||||
Object.entries(variant.prices as Record<string, string>).forEach(([type, value]) => {
|
||||
formData.append(`variants[${index}][prices][${type}]`, String(Number.parseInt(value, 10) || 0));
|
||||
});
|
||||
}
|
||||
|
||||
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
|
||||
}, 'post');
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
loading.value = true;
|
||||
form.value.errors = {};
|
||||
|
||||
try {
|
||||
const payload = buildFormData();
|
||||
const { product } = await apiFetch<{ product: CuttingProductCatalogItem }>(
|
||||
'/admin/manage/cuttings/quick-create-product',
|
||||
{
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
|
||||
toast.success(`Produk "${product.name}" berhasil ditambahkan.`);
|
||||
emit('created', product);
|
||||
open.value = false;
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="flex max-h-[90vh] flex-col sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Produk Baru</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto pr-1">
|
||||
<form id="quick-create-product-form" @submit.prevent="submit" class="space-y-6">
|
||||
<ProductInfoSection :form="form" :categories="categories" :category-error="categoryError"
|
||||
@update:name="form.name = $event" @update:description="form.description = $event"
|
||||
@toggle-category="toggleCategory" />
|
||||
|
||||
<ProductVariantSection v-for="(variant, index) in variants" :key="variant.client_id" :form="form"
|
||||
:variant="variant" :index="index" :can-remove="variants.length > 1"
|
||||
:has-copied-prices="!!copiedPrices"
|
||||
:variant-errors="(clientId, field) => variantErrors(form, clientId, field)"
|
||||
@remove="removeVariant(variant.client_id)"
|
||||
@update:name="setVariantField(variant.client_id, 'name', $event)"
|
||||
@update:stock="setVariantField(variant.client_id, 'stock', $event)"
|
||||
@update:retail-stock="setVariantField(variant.client_id, 'retail_stock', $event)"
|
||||
@update:prices="setVariantField(variant.client_id, 'prices', $event)"
|
||||
@update:media="setVariantField(variant.client_id, 'media', $event)"
|
||||
@copy-prices="copyPrices(variant.prices as Record<string, string>)"
|
||||
@paste-prices="pastePrices(variant.client_id)"
|
||||
@apply-to-all-prices="applyToAllPrices(variant.prices as Record<string, string>)" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 border-t pt-4">
|
||||
<Button type="button" variant="outline" @click="addVariant">
|
||||
<Plus class="size-4" />
|
||||
Tambah Varian
|
||||
</Button>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button type="button" variant="outline" :disabled="loading" @click="open = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" form="quick-create-product-form" :disabled="loading">
|
||||
<Save class="size-4" />
|
||||
{{ loading ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import { Plus, Save } from '@lucide/vue';
|
||||
import { ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useVariantList } from '@/composables/useVariantList';
|
||||
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import type { EnumOption, RawMaterialPriceFormItem } from '@/types/raw-material';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { FieldError } from '@/components/ui/field';
|
||||
import RawMaterialInfoSection from '@/pages/admin/master/raw-materials/form/RawMaterialInfoSection.vue';
|
||||
import RawMaterialSharedPriceSection from '@/pages/admin/master/raw-materials/form/RawMaterialSharedPriceSection.vue';
|
||||
import RawMaterialVariantSection from '@/pages/admin/master/raw-materials/form/RawMaterialVariantSection.vue';
|
||||
import type { CuttingRawMaterialCatalogItem } from '@/types/cutting';
|
||||
|
||||
const props = defineProps<{
|
||||
units: EnumOption[];
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
'created': [rawMaterial: CuttingRawMaterialCatalogItem];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const selectPortalTarget = ref<HTMLElement>();
|
||||
|
||||
function createClientId(): string {
|
||||
return `price-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
unit: '',
|
||||
errors: {} as Record<string, string>,
|
||||
});
|
||||
|
||||
const {
|
||||
items: prices,
|
||||
removeItem: removePrice,
|
||||
setField: setPriceField,
|
||||
appendToFormData,
|
||||
itemErrors: priceErrors,
|
||||
} = useVariantList<RawMaterialPriceFormItem>(
|
||||
'prices',
|
||||
() => ({
|
||||
client_id: createClientId(),
|
||||
variant: '',
|
||||
price: '',
|
||||
stock: '0',
|
||||
media: createMediaUploadState(),
|
||||
}),
|
||||
() => [{
|
||||
client_id: createClientId(),
|
||||
variant: '',
|
||||
price: '',
|
||||
stock: '0',
|
||||
media: createMediaUploadState(),
|
||||
}],
|
||||
);
|
||||
|
||||
const useSamePrice = ref(true);
|
||||
|
||||
function addPrice() {
|
||||
const newPrice: RawMaterialPriceFormItem = {
|
||||
client_id: createClientId(),
|
||||
variant: '',
|
||||
price: '',
|
||||
stock: '0',
|
||||
media: createMediaUploadState(),
|
||||
};
|
||||
|
||||
if (useSamePrice.value && prices.value[0]) {
|
||||
newPrice.price = prices.value[0].price;
|
||||
}
|
||||
|
||||
prices.value = [...prices.value, newPrice];
|
||||
}
|
||||
|
||||
function setPriceValue(clientId: string, value: string) {
|
||||
setPriceField(clientId, 'price', value);
|
||||
}
|
||||
|
||||
function setSharedPrice(value: string) {
|
||||
prices.value = prices.value.map((price) => ({ ...price, price: value }));
|
||||
}
|
||||
|
||||
function toggleUseSamePrice(checked: boolean) {
|
||||
useSamePrice.value = checked;
|
||||
|
||||
if (!checked || !prices.value[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourcePrice = prices.value[0].price;
|
||||
prices.value = prices.value.map((price) => ({ ...price, price: sourcePrice }));
|
||||
}
|
||||
|
||||
function applyPriceToAllVariants(sourceClientId: string) {
|
||||
const source = prices.value.find((price) => price.client_id === sourceClientId);
|
||||
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
|
||||
prices.value = prices.value.map((price) => ({ ...price, price: source.price }));
|
||||
}
|
||||
|
||||
function parseStockValue(value: string): number {
|
||||
const parsed = Number.parseFloat(value.replace(',', '.'));
|
||||
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = { name: '', unit: '', errors: {} };
|
||||
prices.value = [{
|
||||
client_id: createClientId(),
|
||||
variant: '',
|
||||
price: '',
|
||||
stock: '0',
|
||||
media: createMediaUploadState(),
|
||||
}];
|
||||
useSamePrice.value = true;
|
||||
}
|
||||
|
||||
function buildFormData(): FormData {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('name', form.value.name.trim());
|
||||
formData.append('unit', form.value.unit);
|
||||
|
||||
appendToFormData(formData, (formData, index, price) => {
|
||||
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}][stock]`, String(parseStockValue(price.stock)));
|
||||
appendMediaToFormData(formData, `prices[${index}]`, price.media);
|
||||
}, 'post');
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
loading.value = true;
|
||||
form.value.errors = {};
|
||||
|
||||
try {
|
||||
const payload = buildFormData();
|
||||
const { raw_material } = await apiFetch<{ raw_material: CuttingRawMaterialCatalogItem }>(
|
||||
'/admin/manage/cuttings/quick-create-raw-material',
|
||||
{
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
|
||||
toast.success(`Bahan baku "${raw_material.name}" berhasil ditambahkan.`);
|
||||
emit('created', raw_material);
|
||||
open.value = false;
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="flex max-h-[90vh] flex-col sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Bahan Baku Baru</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto pr-1">
|
||||
<form id="quick-create-raw-material-form" @submit.prevent="submit" class="space-y-6">
|
||||
<RawMaterialInfoSection :form="form" :units="units" method="post" :select-portal-target="selectPortalTarget" />
|
||||
|
||||
<RawMaterialSharedPriceSection
|
||||
:form="form"
|
||||
:prices="prices"
|
||||
:use-same-price="useSamePrice"
|
||||
@toggle-use-same-price="toggleUseSamePrice"
|
||||
@set-shared-price="setSharedPrice"
|
||||
/>
|
||||
|
||||
<RawMaterialVariantSection
|
||||
v-for="(price, index) in prices"
|
||||
:key="price.client_id"
|
||||
:form="form"
|
||||
:price="price"
|
||||
:index="index"
|
||||
:total-prices="prices.length"
|
||||
:use-same-price="useSamePrice"
|
||||
:price-errors="(clientId, field) => priceErrors(form, clientId, field)"
|
||||
@remove="removePrice(price.client_id)"
|
||||
@apply-price-to-all="applyPriceToAllVariants(price.client_id)"
|
||||
@update:variant="setPriceField(price.client_id, 'variant', $event)"
|
||||
@update:stock="setPriceField(price.client_id, 'stock', $event)"
|
||||
@update:price="setPriceValue(price.client_id, $event)"
|
||||
/>
|
||||
|
||||
<FieldError :errors="formErrors(form, 'prices')" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 border-t pt-4">
|
||||
<Button type="button" variant="outline" @click="addPrice">
|
||||
<Plus class="size-4" />
|
||||
Tambah Varian
|
||||
</Button>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button type="button" variant="outline" :disabled="loading" @click="open = false">
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" form="quick-create-raw-material-form" :disabled="loading">
|
||||
<Save class="size-4" />
|
||||
{{ loading ? 'Menyimpan...' : 'Simpan' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="selectPortalTarget" class="contents"></div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@ -23,6 +23,7 @@ defineProps<{
|
||||
form: FormWithErrors & { name: string; unit: string };
|
||||
units: EnumOption[];
|
||||
method: 'post' | 'put';
|
||||
selectPortalTarget?: HTMLElement;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@ -52,7 +53,7 @@ defineProps<{
|
||||
<SelectTrigger id="unit" class="w-full">
|
||||
<SelectValue placeholder="Pilih satuan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent :to="selectPortalTarget">
|
||||
<SelectItem v-for="option in units" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
|
||||
@ -330,6 +330,14 @@
|
||||
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
|
||||
->name('draft_results.destroy');
|
||||
|
||||
Route::post('quick-create-raw-material', [CuttingDraftItemController::class, 'quickCreateRawMaterial'])
|
||||
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
|
||||
->name('quick_create_raw_material');
|
||||
|
||||
Route::post('quick-create-product', [CuttingDraftItemController::class, 'quickCreateProduct'])
|
||||
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
|
||||
->name('quick_create_product');
|
||||
|
||||
Route::post('/', [CuttingController::class, 'store'])
|
||||
->middleware('permission:'.Permission::CUTTINGS_VIEW->value.'|'.Permission::CUTTINGS_CREATE->value)
|
||||
->name('store');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user