feat: update ProductRequest and related components to conditionally handle price fields based on user roles; enhance form behavior for better variant price management

This commit is contained in:
Yoga Pangestu 2026-07-02 10:44:47 +07:00
parent 78e30c2aba
commit 47289d158e
4 changed files with 64 additions and 82 deletions

View File

@ -3,6 +3,7 @@
namespace App\Http\Requests\Admin\Master;
use App\Enums\Permission;
use App\Enums\Role;
use App\Http\Requests\Concerns\ValidatesMediaUploads;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -25,7 +26,9 @@ public function authorize(): bool
*/
public function rules(): array
{
return [
$isAdminBahanBaku = $this->user()?->hasRole(Role::ADMIN_BAHAN_BAKU->value) ?? false;
$rules = [
'name' => ['required', 'string', 'max:200'],
'description' => ['nullable', 'string'],
@ -43,17 +46,22 @@ public function rules(): array
'variants.*.name' => ['required', 'string', 'max:200'],
'variants.*.stock' => ['required', 'integer', 'min:0'],
'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
'variants.*.prices' => ['required', 'array'],
'variants.*.prices.distributor' => ['required', 'integer', 'min:0'],
'variants.*.prices.agent' => ['required', 'integer', 'min:0'],
'variants.*.prices.sub_agent' => ['required', 'integer', 'min:0'],
'variants.*.prices.grosir' => ['required', 'integer', 'min:0'],
'variants.*.prices.retail' => ['required', 'integer', 'min:0'],
'variants.*.prices.tiktok' => ['required', 'integer', 'min:0'],
'variants.*.prices.shopee' => ['required', 'integer', 'min:0'],
'variants.*.prices.harga_modal' => ['required', 'integer', 'min:0'],
...$this->variantImageRules(),
];
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;
}
/**

View File

@ -138,11 +138,13 @@ public function create(array $validated, User $user): void
$this->syncVariantImages($variant, $variantData, $index);
foreach ($variantData['prices'] as $type => $priceValue) {
$variant->prices()->create([
'type' => $type,
'price' => $priceValue,
]);
if (! empty($variantData['prices'])) {
foreach ($variantData['prices'] as $type => $priceValue) {
$variant->prices()->create([
'type' => $type,
'price' => $priceValue,
]);
}
}
}

View File

@ -5,12 +5,16 @@ import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useCan } from '@/composables/useCan';
import { useVariantList } from '@/composables/useVariantList';
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
import type { CategoryOption, ProductFormInitialData, ProductVariantFormItem } from '@/types/product';
import ProductInfoSection from './ProductInfoSection.vue';
import ProductVariantSection from './ProductVariantSection.vue';
const { hasRole } = useCan();
const showPrices = !hasRole('admin-bahan-baku');
const props = withDefaults(
defineProps<{
categories: CategoryOption[];
@ -156,7 +160,7 @@ function buildFormData(): FormData {
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) {
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));
});

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Trash2, Copy, Clipboard, Check } from '@lucide/vue';
import { ref } from 'vue';
import { NumberInput } from '@/components/form/number-input';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
@ -14,10 +14,14 @@ import {
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { useCan } from '@/composables/useCan';
import { FIELD_LIMITS } from '@/lib/field-limits';
import type { MediaUploadState } from '@/types/media';
import { PRICE_TYPES, PRICE_TYPE_LABELS } from '@/types/product';
import type { ProductVariantFormItem } from '@/types/product';
import type { MediaUploadState } from '@/types/media';
const { hasRole } = useCan();
const showPrices = !hasRole('admin-bahan-baku');
const props = defineProps<{
form: {
@ -83,14 +87,8 @@ function updatePrice(type: string, value: string) {
<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')"
>
<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>
@ -101,75 +99,53 @@ function updatePrice(type: string, value: string) {
<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))"
/>
<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))"
/>
<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}`"
<NumberInput :id="`variant_retail_stock_${variant.client_id}`"
:model-value="variant.retail_stock"
@update:model-value="emit('update:retail-stock', String($event))"
/>
@update:model-value="emit('update:retail-stock', String($event))" />
<FieldError :errors="variantErrors(variant.client_id, 'retail_stock')" />
</Field>
</FieldSet>
<div class="border-t pt-4 mt-2">
<div v-if="showPrices" class="border-t pt-4 mt-2">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-3">
<h4 class="text-sm font-semibold">Harga Varian</h4>
<div class="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
class="h-8 px-2.5 text-xs flex items-center gap-1.5"
@click="handleCopy"
>
<Check v-if="isCopied" class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
<Button type="button" variant="outline" size="sm"
class="h-8 px-2.5 text-xs flex items-center gap-1.5" @click="handleCopy">
<Check v-if="isCopied"
class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
<Copy v-else class="size-3.5" />
{{ isCopied ? 'Berhasil' : 'Salin Harga' }}
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-8 px-2.5 text-xs flex items-center gap-1.5"
:disabled="!hasCopiedPrices"
@click="handlePaste"
>
<Check v-if="isPasted" class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
<Button type="button" variant="outline" size="sm"
class="h-8 px-2.5 text-xs flex items-center gap-1.5" :disabled="!hasCopiedPrices"
@click="handlePaste">
<Check v-if="isPasted"
class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
<Clipboard v-else class="size-3.5" />
{{ isPasted ? 'Berhasil' : 'Tempel Harga' }}
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-8 px-2.5 text-xs flex items-center gap-1.5"
@click="handleApply"
>
<Check v-if="isApplied" class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
<Button type="button" variant="outline" size="sm"
class="h-8 px-2.5 text-xs flex items-center gap-1.5" @click="handleApply">
<Check v-if="isApplied"
class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
<Check v-else class="size-3.5" />
{{ isApplied ? 'Berhasil' : 'Terapkan ke Semua' }}
</Button>
@ -180,26 +156,18 @@ function updatePrice(type: string, value: string) {
<FieldLabel class="text-xs" :for="`variant_price_${variant.client_id}_${type}`" required>
{{ PRICE_TYPE_LABELS[type] }}
</FieldLabel>
<RupiahInput
:id="`variant_price_${variant.client_id}_${type}`"
<RupiahInput :id="`variant_price_${variant.client_id}_${type}`"
:model-value="(variant.prices as Record<string, string>)?.[type] ?? '0'"
@update:model-value="updatePrice(type, $event)"
/>
@update:model-value="updatePrice(type, $event)" />
<FieldError :errors="variantErrors(variant.client_id, `prices.${type}`)" />
</Field>
</div>
</div>
<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)"
/>
<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>