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
This commit is contained in:
parent
5a3316c890
commit
9ba87b9381
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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[];
|
||||
}>();
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user