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;
|
namespace App\Http\Requests\Admin\Manage;
|
||||||
|
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Http\Requests\Concerns\HasProductVariantRules;
|
use App\Http\Requests\Concerns\ValidatesMediaUploads;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class CuttingQuickCreateProductRequest extends FormRequest
|
class CuttingQuickCreateProductRequest extends FormRequest
|
||||||
{
|
{
|
||||||
use HasProductVariantRules;
|
use ValidatesMediaUploads;
|
||||||
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
@ -28,7 +28,11 @@ public function rules(): array
|
|||||||
'category_ids' => ['nullable', 'array'],
|
'category_ids' => ['nullable', 'array'],
|
||||||
'category_ids.*' => ['integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
|
'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',
|
'description' => 'Deskripsi',
|
||||||
'category_ids' => 'Kategori',
|
'category_ids' => 'Kategori',
|
||||||
'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"
|
<CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts"
|
||||||
:get-result-cart-item="getResultCartItem" @add-result="addResult" :is-create-mode="isCreateMode"
|
: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>
|
</div>
|
||||||
|
|
||||||
<CuttingPosSummaryPanel v-model:image-state="imageState" :form="form" :material-cart="materialCart"
|
<CuttingPosSummaryPanel v-model:image-state="imageState" :form="form" :material-cart="materialCart"
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
} from '@/components/ui/empty';
|
} from '@/components/ui/empty';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
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 type { CategoryOption } from '@/types/product';
|
||||||
import QuickCreateProductModal from './QuickCreateProductModal.vue';
|
import QuickCreateProductModal from './QuickCreateProductModal.vue';
|
||||||
import type { CuttingCatalogVariant } from './useCuttingPosCart';
|
import type { CuttingCatalogVariant } from './useCuttingPosCart';
|
||||||
@ -23,6 +23,7 @@ defineProps<{
|
|||||||
getResultCartItem: (variantId: number) => CuttingResultCartItem | undefined;
|
getResultCartItem: (variantId: number) => CuttingResultCartItem | undefined;
|
||||||
isCreateMode: boolean;
|
isCreateMode: boolean;
|
||||||
categories: CategoryOption[];
|
categories: CategoryOption[];
|
||||||
|
materialCart: CuttingMaterialCartItem[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const productSearch = defineModel<string>('productSearch', { required: true });
|
const productSearch = defineModel<string>('productSearch', { required: true });
|
||||||
@ -151,6 +152,7 @@ const quickCreateOpen = ref(false);
|
|||||||
<QuickCreateProductModal
|
<QuickCreateProductModal
|
||||||
v-model:open="quickCreateOpen"
|
v-model:open="quickCreateOpen"
|
||||||
:categories="categories"
|
:categories="categories"
|
||||||
|
:selected-materials="materialCart"
|
||||||
@created="emit('product-created', $event)"
|
@created="emit('product-created', $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</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">
|
<script setup lang="ts">
|
||||||
import { Plus, Save } from '@lucide/vue';
|
import { Layers, Plus, Save } from '@lucide/vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -9,22 +9,62 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { useCan } from '@/composables/useCan';
|
|
||||||
import { useVariantList } from '@/composables/useVariantList';
|
import { useVariantList } from '@/composables/useVariantList';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import ProductInfoSection from '@/pages/admin/master/products/form/ProductInfoSection.vue';
|
import ProductInfoSection from '@/pages/admin/master/products/form/ProductInfoSection.vue';
|
||||||
import ProductVariantSection from '@/pages/admin/master/products/form/ProductVariantSection.vue';
|
import CuttingProductVariantSection from './CuttingProductVariantSection.vue';
|
||||||
import type { CuttingProductCatalogItem } from '@/types/cutting';
|
import type { CuttingMaterialCartItem, CuttingProductCatalogItem } from '@/types/cutting';
|
||||||
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
||||||
import type { CategoryOption, ProductVariantFormItem } from '@/types/product';
|
import type { CategoryOption, ProductVariantFormItem } from '@/types/product';
|
||||||
|
|
||||||
const { hasRole } = useCan();
|
|
||||||
const showPrices = !hasRole('admin-bahan-baku');
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
categories: CategoryOption[];
|
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 open = defineModel<boolean>('open', { required: true });
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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) {
|
function toggleCategory(categoryId: number, checked: boolean) {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (!form.value.category_ids.includes(categoryId)) {
|
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}][stock]`, String(Number.parseInt(String(variant.stock), 10) || 0));
|
||||||
formData.append(`variants[${index}][retail_stock]`, String(Number.parseInt(String(variant.retail_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);
|
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
|
||||||
}, 'post');
|
}, 'post');
|
||||||
|
|
||||||
@ -208,25 +222,50 @@ async function submit() {
|
|||||||
<DialogTitle>Tambah Produk Baru</DialogTitle>
|
<DialogTitle>Tambah Produk Baru</DialogTitle>
|
||||||
</DialogHeader>
|
</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">
|
<div class="scrollbar-thin flex-1 overflow-y-auto pr-1">
|
||||||
<form id="quick-create-product-form" @submit.prevent="submit" class="space-y-6">
|
<form id="quick-create-product-form" @submit.prevent="submit" class="space-y-6">
|
||||||
<ProductInfoSection :form="form" :categories="categories" :category-error="categoryError"
|
<ProductInfoSection :form="form" :categories="categories" :category-error="categoryError"
|
||||||
@update:name="form.name = $event" @update:description="form.description = $event"
|
@update:name="form.name = $event" @update:description="form.description = $event"
|
||||||
@toggle-category="toggleCategory" />
|
@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"
|
:variant="variant" :index="index" :can-remove="variants.length > 1"
|
||||||
:has-copied-prices="!!copiedPrices"
|
|
||||||
:variant-errors="(clientId, field) => variantErrors(form, clientId, field)"
|
:variant-errors="(clientId, field) => variantErrors(form, clientId, field)"
|
||||||
@remove="removeVariant(variant.client_id)"
|
@remove="removeVariant(variant.client_id)"
|
||||||
@update:name="setVariantField(variant.client_id, 'name', $event)"
|
@update:name="setVariantField(variant.client_id, 'name', $event)"
|
||||||
@update:stock="setVariantField(variant.client_id, 'stock', $event)"
|
@update:stock="setVariantField(variant.client_id, 'stock', $event)"
|
||||||
@update:retail-stock="setVariantField(variant.client_id, 'retail_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)" />
|
||||||
@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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Trash2, Copy, Clipboard, Check } from '@lucide/vue';
|
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 { NumberInput } from '@/components/form/number-input';
|
||||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
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';
|
import type { ProductVariantFormItem } from '@/types/product';
|
||||||
|
|
||||||
const { hasRole } = useCan();
|
const { hasRole } = useCan();
|
||||||
const showPrices = !hasRole('admin-bahan-baku');
|
const showPrices = computed(() => props.showPrices ?? !hasRole('admin-bahan-baku'));
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
form: {
|
form: {
|
||||||
@ -31,6 +31,7 @@ const props = defineProps<{
|
|||||||
index: number;
|
index: number;
|
||||||
canRemove: boolean;
|
canRemove: boolean;
|
||||||
hasCopiedPrices: boolean;
|
hasCopiedPrices: boolean;
|
||||||
|
showPrices?: boolean;
|
||||||
variantErrors: (clientId: string, field: string) => string[];
|
variantErrors: (clientId: string, field: string) => string[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user