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:
Yoga Pangestu 2026-07-02 12:04:30 +07:00
parent 47289d158e
commit 5a96b48725
19 changed files with 986 additions and 82 deletions

View File

@ -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(),
]);
}

View File

@ -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]);
}
}

View File

@ -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(),
];
}
}

View File

@ -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(),
];
}
}

View File

@ -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(),
];
}
}

View File

@ -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(),
];
}
}

View 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'),
];
}
}

View 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'),
];
}
}

View File

@ -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()}";
}
}

View File

@ -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',
});

View File

@ -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"

View File

@ -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>

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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');