feat: implement product variant management in CuttingQuickCreateProductRequest and related components; enhance validation rules, add new CuttingProductVariantSection, and improve material handling in QuickCreateProductModal for better user experience
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run

This commit is contained in:
Yoga Pangestu 2026-07-05 15:26:33 +07:00
parent 5a3316c890
commit 9ba87b9381
6 changed files with 184 additions and 48 deletions

View File

@ -3,13 +3,13 @@
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use App\Http\Requests\Concerns\HasProductVariantRules;
use App\Http\Requests\Concerns\ValidatesMediaUploads;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CuttingQuickCreateProductRequest extends FormRequest
{
use HasProductVariantRules;
use ValidatesMediaUploads;
public function authorize(): bool
{
@ -28,7 +28,11 @@ public function rules(): array
'category_ids' => ['nullable', 'array'],
'category_ids.*' => ['integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
...$this->productVariantRules(),
'variants' => ['required', 'array', 'min:1'],
'variants.*.name' => ['required', 'string', 'max:200'],
'variants.*.stock' => ['required', 'integer', 'min:0'],
'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
...$this->variantImageRules('variants'),
];
}
@ -42,7 +46,11 @@ public function attributes(): array
'description' => 'Deskripsi',
'category_ids' => 'Kategori',
'category_ids.*' => 'Kategori',
...$this->productVariantAttributes(),
'variants' => 'Varian',
'variants.*.name' => 'Nama Varian',
'variants.*.stock' => 'Stok',
'variants.*.retail_stock' => 'Stok Ecer',
...$this->variantImageAttributes('variants', 'Foto Varian'),
];
}
}

View File

@ -283,7 +283,7 @@ function onProductCreated(product: CuttingProductCatalogItem) {
<CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts"
:get-result-cart-item="getResultCartItem" @add-result="addResult" :is-create-mode="isCreateMode"
:categories="categories" @decrease-result-qty="decreaseResultQty" @product-created="onProductCreated" />
:categories="categories" :material-cart="materialCart" @decrease-result-qty="decreaseResultQty" @product-created="onProductCreated" />
</div>
<CuttingPosSummaryPanel v-model:image-state="imageState" :form="form" :material-cart="materialCart"

View File

@ -13,7 +13,7 @@ import {
} from '@/components/ui/empty';
import { Input } from '@/components/ui/input';
import { getFirstCoverImage } from '@/lib/catalog-cover';
import type { CuttingProductCatalogItem, CuttingResultCartItem } from '@/types/cutting';
import type { CuttingProductCatalogItem, CuttingResultCartItem, CuttingMaterialCartItem } from '@/types/cutting';
import type { CategoryOption } from '@/types/product';
import QuickCreateProductModal from './QuickCreateProductModal.vue';
import type { CuttingCatalogVariant } from './useCuttingPosCart';
@ -23,6 +23,7 @@ defineProps<{
getResultCartItem: (variantId: number) => CuttingResultCartItem | undefined;
isCreateMode: boolean;
categories: CategoryOption[];
materialCart: CuttingMaterialCartItem[];
}>();
const productSearch = defineModel<string>('productSearch', { required: true });
@ -151,6 +152,7 @@ const quickCreateOpen = ref(false);
<QuickCreateProductModal
v-model:open="quickCreateOpen"
:categories="categories"
:selected-materials="materialCart"
@created="emit('product-created', $event)"
/>
</template>

View File

@ -0,0 +1,86 @@
<script setup lang="ts">
import { Trash2 } from '@lucide/vue';
import { NumberInput } from '@/components/form/number-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { FIELD_LIMITS } from '@/lib/field-limits';
import type { MediaUploadState } from '@/types/media';
import type { ProductVariantFormItem } from '@/types/product';
const props = defineProps<{
form: {
errors: Record<string, string>;
};
variant: ProductVariantFormItem;
index: number;
canRemove: boolean;
variantErrors: (clientId: string, field: string) => string[];
}>();
const emit = defineEmits<{
remove: [];
'update:name': [value: string];
'update:stock': [value: string];
'update:retail-stock': [value: string];
'update:media': [value: MediaUploadState];
}>();
</script>
<template>
<Card>
<CardHeader class="flex flex-row items-start justify-between gap-4">
<CardTitle>Varian {{ index + 1 }}</CardTitle>
<Button v-if="canRemove" type="button" variant="outline" size="icon"
class="text-destructive hover:text-destructive size-8" @click="emit('remove')">
<Trash2 class="size-4" />
</Button>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldSet class="grid gap-4 md:grid-cols-3">
<Field>
<FieldLabel :for="`variant_name_${variant.client_id}`" required>
Nama Varian
</FieldLabel>
<Input :id="`variant_name_${variant.client_id}`" :model-value="variant.name" type="text"
placeholder="Masukkan nama varian" :maxlength="FIELD_LIMITS.variantName"
@update:model-value="emit('update:name', String($event))" />
<FieldError :errors="variantErrors(variant.client_id, 'name')" />
</Field>
<Field>
<FieldLabel :for="`variant_stock_${variant.client_id}`" required>
Stok
</FieldLabel>
<NumberInput :id="`variant_stock_${variant.client_id}`" :model-value="variant.stock"
@update:model-value="emit('update:stock', String($event))" />
<FieldError :errors="variantErrors(variant.client_id, 'stock')" />
</Field>
<Field>
<FieldLabel :for="`variant_retail_stock_${variant.client_id}`" required>
Stok Ecer
</FieldLabel>
<NumberInput :id="`variant_retail_stock_${variant.client_id}`"
:model-value="variant.retail_stock"
@update:model-value="emit('update:retail-stock', String($event))" />
<FieldError :errors="variantErrors(variant.client_id, 'retail_stock')" />
</Field>
</FieldSet>
<div>
<MediaDropzone :id="`variant_images_${variant.client_id}`" :model-value="variant.media"
label="Foto Varian" :max-files="5" required :errors="variantErrors(variant.client_id, 'images')"
@update:model-value="emit('update:media', $event)" />
</div>
</FieldGroup>
</CardContent>
</Card>
</template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { Plus, Save } from '@lucide/vue';
import { Layers, Plus, Save } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
@ -9,22 +9,62 @@ import {
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 CuttingProductVariantSection from './CuttingProductVariantSection.vue';
import type { CuttingMaterialCartItem, 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[];
selectedMaterials?: CuttingMaterialCartItem[];
}>();
const materialGroups = computed(() => {
if (!props.selectedMaterials) {
return [];
}
const combinationMap = new Map<number, CuttingMaterialCartItem[]>();
const singleItems: CuttingMaterialCartItem[] = [];
props.selectedMaterials.forEach((item) => {
if (item.combination_id) {
if (!combinationMap.has(item.combination_id)) {
combinationMap.set(item.combination_id, []);
}
combinationMap.get(item.combination_id)!.push(item);
} else {
singleItems.push(item);
}
});
const groups: Array<{
type: 'combination' | 'single';
combinationId?: number;
items: CuttingMaterialCartItem[];
}> = [];
for (const [combinationId, items] of combinationMap) {
groups.push({
type: 'combination',
combinationId,
items,
});
}
if (singleItems.length > 0) {
groups.push({
type: 'single',
items: singleItems,
});
}
return groups;
});
const open = defineModel<boolean>('open', { required: true });
const emit = defineEmits<{
@ -93,26 +133,6 @@ const {
}],
);
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)) {
@ -161,12 +181,6 @@ function buildFormData(): FormData {
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');
@ -208,25 +222,50 @@ async function submit() {
<DialogTitle>Tambah Produk Baru</DialogTitle>
</DialogHeader>
<!-- Selected Raw Materials -->
<div v-if="selectedMaterials && selectedMaterials.length > 0" class="space-y-2">
<p class="text-sm font-medium">Bahan Baku Terpilih</p>
<div class="scrollbar-thin max-h-32 space-y-1 overflow-y-auto overscroll-y-contain">
<template v-for="group in materialGroups" :key="group.combinationId ?? 'single'">
<div v-if="group.type === 'combination'"
class="rounded-md border border-dashed border-primary/30 px-3 py-2">
<div class="flex items-center gap-2">
<Layers class="size-3.5 text-primary" />
<span class="text-xs font-medium text-primary">Kombinasi ({{ group.items.length }} bahan)</span>
</div>
<div class="mt-1 space-y-0.5 pl-5">
<p v-for="mat in group.items" :key="mat.raw_material_price_id"
class="truncate text-xs text-muted-foreground">
{{ mat.raw_material_name }} - {{ mat.variant }}
</p>
</div>
</div>
<template v-else>
<p v-for="mat in group.items" :key="mat.raw_material_price_id"
class="truncate rounded-md border px-3 py-1.5 text-xs">
{{ mat.raw_material_name }} - {{ mat.variant }}
</p>
</template>
</template>
</div>
</div>
<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"
<CuttingProductVariantSection 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>)" />
@update:media="setVariantField(variant.client_id, 'media', $event)" />
</form>
</div>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Trash2, Copy, Clipboard, Check } from '@lucide/vue';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { NumberInput } from '@/components/form/number-input';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
@ -21,7 +21,7 @@ import { PRICE_TYPES, PRICE_TYPE_LABELS } from '@/types/product';
import type { ProductVariantFormItem } from '@/types/product';
const { hasRole } = useCan();
const showPrices = !hasRole('admin-bahan-baku');
const showPrices = computed(() => props.showPrices ?? !hasRole('admin-bahan-baku'));
const props = defineProps<{
form: {
@ -31,6 +31,7 @@ const props = defineProps<{
index: number;
canRemove: boolean;
hasCopiedPrices: boolean;
showPrices?: boolean;
variantErrors: (clientId: string, field: string) => string[];
}>();