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; namespace App\Http\Requests\Admin\Master;
use App\Enums\Permission; use App\Enums\Permission;
use App\Enums\Role;
use App\Http\Requests\Concerns\ValidatesMediaUploads; use App\Http\Requests\Concerns\ValidatesMediaUploads;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -25,7 +26,9 @@ public function authorize(): bool
*/ */
public function rules(): array public function rules(): array
{ {
return [ $isAdminBahanBaku = $this->user()?->hasRole(Role::ADMIN_BAHAN_BAKU->value) ?? false;
$rules = [
'name' => ['required', 'string', 'max:200'], 'name' => ['required', 'string', 'max:200'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
@ -43,17 +46,22 @@ public function rules(): array
'variants.*.name' => ['required', 'string', 'max:200'], 'variants.*.name' => ['required', 'string', 'max:200'],
'variants.*.stock' => ['required', 'integer', 'min:0'], 'variants.*.stock' => ['required', 'integer', 'min:0'],
'variants.*.retail_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(), ...$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); $this->syncVariantImages($variant, $variantData, $index);
foreach ($variantData['prices'] as $type => $priceValue) { if (! empty($variantData['prices'])) {
$variant->prices()->create([ foreach ($variantData['prices'] as $type => $priceValue) {
'type' => $type, $variant->prices()->create([
'price' => $priceValue, 'type' => $type,
]); 'price' => $priceValue,
]);
}
} }
} }

View File

@ -5,12 +5,16 @@ 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';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useCan } from '@/composables/useCan';
import { useVariantList } from '@/composables/useVariantList'; import { useVariantList } from '@/composables/useVariantList';
import { appendMediaToFormData, createMediaUploadState } from '@/types/media'; import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
import type { CategoryOption, ProductFormInitialData, ProductVariantFormItem } from '@/types/product'; import type { CategoryOption, ProductFormInitialData, ProductVariantFormItem } from '@/types/product';
import ProductInfoSection from './ProductInfoSection.vue'; import ProductInfoSection from './ProductInfoSection.vue';
import ProductVariantSection from './ProductVariantSection.vue'; import ProductVariantSection from './ProductVariantSection.vue';
const { hasRole } = useCan();
const showPrices = !hasRole('admin-bahan-baku');
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
categories: CategoryOption[]; categories: CategoryOption[];
@ -156,7 +160,7 @@ function buildFormData(): FormData {
formData.append(`variants[${index}][name]`, variant.name.trim()); 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}][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) { if (variant.prices && showPrices) {
Object.entries(variant.prices as Record<string, string>).forEach(([type, value]) => { Object.entries(variant.prices as Record<string, string>).forEach(([type, value]) => {
formData.append(`variants[${index}][prices][${type}]`, String(Number.parseInt(value, 10) || 0)); formData.append(`variants[${index}][prices][${type}]`, String(Number.parseInt(value, 10) || 0));
}); });

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import { Trash2, Copy, Clipboard, Check } from '@lucide/vue'; import { Trash2, Copy, Clipboard, Check } from '@lucide/vue';
import { 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';
@ -14,10 +14,14 @@ import {
FieldSet, FieldSet,
} from '@/components/ui/field'; } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useCan } from '@/composables/useCan';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';
import type { MediaUploadState } from '@/types/media';
import { PRICE_TYPES, PRICE_TYPE_LABELS } from '@/types/product'; import { PRICE_TYPES, PRICE_TYPE_LABELS } from '@/types/product';
import type { ProductVariantFormItem } 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<{ const props = defineProps<{
form: { form: {
@ -83,14 +87,8 @@ function updatePrice(type: string, value: string) {
<Card> <Card>
<CardHeader class="flex flex-row items-start justify-between gap-4"> <CardHeader class="flex flex-row items-start justify-between gap-4">
<CardTitle>Varian {{ index + 1 }}</CardTitle> <CardTitle>Varian {{ index + 1 }}</CardTitle>
<Button <Button v-if="canRemove" type="button" variant="outline" size="icon"
v-if="canRemove" class="text-destructive hover:text-destructive size-8" @click="emit('remove')">
type="button"
variant="outline"
size="icon"
class="text-destructive hover:text-destructive size-8"
@click="emit('remove')"
>
<Trash2 class="size-4" /> <Trash2 class="size-4" />
</Button> </Button>
</CardHeader> </CardHeader>
@ -101,75 +99,53 @@ function updatePrice(type: string, value: string) {
<FieldLabel :for="`variant_name_${variant.client_id}`" required> <FieldLabel :for="`variant_name_${variant.client_id}`" required>
Nama Varian Nama Varian
</FieldLabel> </FieldLabel>
<Input <Input :id="`variant_name_${variant.client_id}`" :model-value="variant.name" type="text"
:id="`variant_name_${variant.client_id}`" placeholder="Masukkan nama varian" :maxlength="FIELD_LIMITS.variantName"
:model-value="variant.name" @update:model-value="emit('update:name', String($event))" />
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')" /> <FieldError :errors="variantErrors(variant.client_id, 'name')" />
</Field> </Field>
<Field> <Field>
<FieldLabel :for="`variant_stock_${variant.client_id}`" required> <FieldLabel :for="`variant_stock_${variant.client_id}`" required>
Stok Stok
</FieldLabel> </FieldLabel>
<NumberInput <NumberInput :id="`variant_stock_${variant.client_id}`" :model-value="variant.stock"
:id="`variant_stock_${variant.client_id}`" @update:model-value="emit('update:stock', String($event))" />
:model-value="variant.stock"
@update:model-value="emit('update:stock', String($event))"
/>
<FieldError :errors="variantErrors(variant.client_id, 'stock')" /> <FieldError :errors="variantErrors(variant.client_id, 'stock')" />
</Field> </Field>
<Field> <Field>
<FieldLabel :for="`variant_retail_stock_${variant.client_id}`" required> <FieldLabel :for="`variant_retail_stock_${variant.client_id}`" required>
Stok Ecer Stok Ecer
</FieldLabel> </FieldLabel>
<NumberInput <NumberInput :id="`variant_retail_stock_${variant.client_id}`"
:id="`variant_retail_stock_${variant.client_id}`"
:model-value="variant.retail_stock" :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')" /> <FieldError :errors="variantErrors(variant.client_id, 'retail_stock')" />
</Field> </Field>
</FieldSet> </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"> <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> <h4 class="text-sm font-semibold">Harga Varian</h4>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<Button <Button type="button" variant="outline" size="sm"
type="button" class="h-8 px-2.5 text-xs flex items-center gap-1.5" @click="handleCopy">
variant="outline" <Check v-if="isCopied"
size="sm" class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
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" /> <Copy v-else class="size-3.5" />
{{ isCopied ? 'Berhasil' : 'Salin Harga' }} {{ isCopied ? 'Berhasil' : 'Salin Harga' }}
</Button> </Button>
<Button <Button type="button" variant="outline" size="sm"
type="button" class="h-8 px-2.5 text-xs flex items-center gap-1.5" :disabled="!hasCopiedPrices"
variant="outline" @click="handlePaste">
size="sm" <Check v-if="isPasted"
class="h-8 px-2.5 text-xs flex items-center gap-1.5" class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
: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" /> <Clipboard v-else class="size-3.5" />
{{ isPasted ? 'Berhasil' : 'Tempel Harga' }} {{ isPasted ? 'Berhasil' : 'Tempel Harga' }}
</Button> </Button>
<Button <Button type="button" variant="outline" size="sm"
type="button" class="h-8 px-2.5 text-xs flex items-center gap-1.5" @click="handleApply">
variant="outline" <Check v-if="isApplied"
size="sm" class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
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" /> <Check v-else class="size-3.5" />
{{ isApplied ? 'Berhasil' : 'Terapkan ke Semua' }} {{ isApplied ? 'Berhasil' : 'Terapkan ke Semua' }}
</Button> </Button>
@ -180,26 +156,18 @@ function updatePrice(type: string, value: string) {
<FieldLabel class="text-xs" :for="`variant_price_${variant.client_id}_${type}`" required> <FieldLabel class="text-xs" :for="`variant_price_${variant.client_id}_${type}`" required>
{{ PRICE_TYPE_LABELS[type] }} {{ PRICE_TYPE_LABELS[type] }}
</FieldLabel> </FieldLabel>
<RupiahInput <RupiahInput :id="`variant_price_${variant.client_id}_${type}`"
:id="`variant_price_${variant.client_id}_${type}`"
:model-value="(variant.prices as Record<string, string>)?.[type] ?? '0'" :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}`)" /> <FieldError :errors="variantErrors(variant.client_id, `prices.${type}`)" />
</Field> </Field>
</div> </div>
</div> </div>
<div> <div>
<MediaDropzone <MediaDropzone :id="`variant_images_${variant.client_id}`" :model-value="variant.media"
:id="`variant_images_${variant.client_id}`" label="Foto Varian" :max-files="5" required :errors="variantErrors(variant.client_id, 'images')"
:model-value="variant.media" @update:model-value="emit('update:media', $event)" />
label="Foto Varian"
:max-files="5"
required
:errors="variantErrors(variant.client_id, 'images')"
@update:model-value="emit('update:media', $event)"
/>
</div> </div>
</FieldGroup> </FieldGroup>
</CardContent> </CardContent>