353 lines
13 KiB
Vue
353 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { useForm } from '@inertiajs/vue3';
|
|
import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
|
|
import { ref } from 'vue';
|
|
import { toast } from 'vue-sonner';
|
|
import MediaImageUpload from '@/components/media/MediaImageUpload.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 { RupiahInput } from '@/components/ui/rupiah-input';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { parseRupiah } from '@/lib/rupiah';
|
|
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
|
import type {
|
|
EnumOption,
|
|
RawMaterialPriceFormItem,
|
|
} from '@/types/raw-material';
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
units: EnumOption[];
|
|
initialData?: {
|
|
name?: string;
|
|
unit?: string;
|
|
prices?: Array<{
|
|
id?: number;
|
|
variant?: string;
|
|
price?: string;
|
|
stock?: string;
|
|
images?: Array<{ id: number; url: string; thumb_url: string }>;
|
|
}>;
|
|
};
|
|
submitUrl: string;
|
|
method?: 'post' | 'put';
|
|
submitLabel?: string;
|
|
}>(),
|
|
{
|
|
method: 'post',
|
|
submitLabel: 'Simpan',
|
|
},
|
|
);
|
|
|
|
function createClientId(): string {
|
|
return `price-${crypto.randomUUID()}`;
|
|
}
|
|
|
|
function createEmptyPrice(): RawMaterialPriceFormItem {
|
|
return {
|
|
client_id: createClientId(),
|
|
variant: '',
|
|
price: '',
|
|
stock: '0',
|
|
media: createMediaUploadState(),
|
|
};
|
|
}
|
|
|
|
function buildInitialPrices(): RawMaterialPriceFormItem[] {
|
|
if (!props.initialData?.prices?.length) {
|
|
return [createEmptyPrice()];
|
|
}
|
|
|
|
return props.initialData.prices.map((price) => ({
|
|
client_id: createClientId(),
|
|
id: price.id,
|
|
variant: price.variant ?? '',
|
|
price: price.price ?? '',
|
|
stock: price.stock ?? '0',
|
|
media: createMediaUploadState(price.images ?? []),
|
|
}));
|
|
}
|
|
|
|
const prices = ref<RawMaterialPriceFormItem[]>(buildInitialPrices());
|
|
const useSamePrice = ref(prices.value.length <= 1 || allPricesHaveSameValue(prices.value));
|
|
|
|
const form = useForm({
|
|
name: props.initialData?.name ?? '',
|
|
unit: props.initialData?.unit ?? '',
|
|
});
|
|
|
|
function allPricesHaveSameValue(items: RawMaterialPriceFormItem[]): boolean {
|
|
if (items.length <= 1) {
|
|
return true;
|
|
}
|
|
|
|
const first = items[0].price.trim();
|
|
|
|
return items.every((item) => item.price.trim() === first);
|
|
}
|
|
|
|
function addPrice() {
|
|
const newPrice = createEmptyPrice();
|
|
|
|
if (useSamePrice.value && prices.value[0]) {
|
|
newPrice.price = prices.value[0].price;
|
|
}
|
|
|
|
prices.value = [...prices.value, newPrice];
|
|
}
|
|
|
|
function removePrice(clientId: string) {
|
|
if (prices.value.length <= 1) {
|
|
return;
|
|
}
|
|
|
|
prices.value = prices.value.filter((price) => price.client_id !== clientId);
|
|
}
|
|
|
|
function setPriceField(clientId: string, key: 'variant' | 'stock', value: string) {
|
|
prices.value = prices.value.map((price) =>
|
|
price.client_id === clientId ? { ...price, [key]: value } : price,
|
|
);
|
|
}
|
|
|
|
function setPriceValue(clientId: string, value: string) {
|
|
prices.value = prices.value.map((price) =>
|
|
price.client_id === clientId ? { ...price, price: value } : price,
|
|
);
|
|
}
|
|
|
|
function setSharedPrice(value: string) {
|
|
prices.value = prices.value.map((price) => ({ ...price, price: value }));
|
|
}
|
|
|
|
function toggleUseSamePrice(checked: boolean) {
|
|
useSamePrice.value = checked;
|
|
|
|
if (!checked || !prices.value[0]) {
|
|
return;
|
|
}
|
|
|
|
const sourcePrice = prices.value[0].price;
|
|
prices.value = prices.value.map((price) => ({ ...price, price: sourcePrice }));
|
|
}
|
|
|
|
function applyPriceToAllVariants(sourceClientId: string) {
|
|
const source = prices.value.find((price) => price.client_id === sourceClientId);
|
|
|
|
if (!source) {
|
|
return;
|
|
}
|
|
|
|
prices.value = prices.value.map((price) => ({ ...price, price: source.price }));
|
|
}
|
|
|
|
function parseStockValue(value: string): number {
|
|
const parsed = Number.parseFloat(value.replace(',', '.'));
|
|
|
|
return Number.isNaN(parsed) ? 0 : parsed;
|
|
}
|
|
|
|
function buildFormData(): FormData {
|
|
const formData = new FormData();
|
|
|
|
if (props.method === 'put') {
|
|
formData.append('_method', 'PUT');
|
|
}
|
|
|
|
formData.append('name', form.name.trim());
|
|
formData.append('unit', form.unit);
|
|
|
|
prices.value.forEach((price, index) => {
|
|
if (price.id) {
|
|
formData.append(`prices[${index}][id]`, String(price.id));
|
|
}
|
|
|
|
formData.append(`prices[${index}][variant]`, price.variant.trim());
|
|
formData.append(`prices[${index}][price]`, String(Number.parseInt(parseRupiah(price.price), 10) || 0));
|
|
formData.append(`prices[${index}][stock]`, String(parseStockValue(price.stock)));
|
|
|
|
appendMediaToFormData(formData, `prices[${index}]`, price.media);
|
|
});
|
|
|
|
return formData;
|
|
}
|
|
|
|
function formError(key: string): string | undefined {
|
|
return (form.errors as Record<string, string>)[key];
|
|
}
|
|
|
|
function priceError(clientId: string, field: string): string | undefined {
|
|
const index = prices.value.findIndex((price) => price.client_id === clientId);
|
|
|
|
if (index === -1) {
|
|
return undefined;
|
|
}
|
|
|
|
return formError(`prices.${index}.${field}`);
|
|
}
|
|
|
|
function submit() {
|
|
const options = {
|
|
forceFormData: true,
|
|
onError: () => {
|
|
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
|
},
|
|
};
|
|
|
|
const payload = buildFormData();
|
|
|
|
form.transform(() => payload).post(props.submitUrl, options);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<form @submit.prevent="submit">
|
|
<div class="grid gap-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Informasi Bahan Baku</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FieldGroup>
|
|
<FieldSet class="grid gap-4 md:grid-cols-2">
|
|
<Field>
|
|
<FieldLabel for="name" required>Nama Bahan Baku</FieldLabel>
|
|
<Input id="name" v-model="form.name" type="text"
|
|
placeholder="Masukkan nama bahan baku" />
|
|
<FieldError :errors="form.errors.name ? [form.errors.name] : []" />
|
|
</Field>
|
|
|
|
<Field>
|
|
<FieldLabel for="unit" required>Satuan</FieldLabel>
|
|
<Select v-model="form.unit">
|
|
<SelectTrigger id="unit" class="w-full">
|
|
<SelectValue placeholder="Pilih satuan" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="option in units" :key="option.value" :value="option.value">
|
|
{{ option.label }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FieldError :errors="form.errors.unit ? [form.errors.unit] : []" />
|
|
</Field>
|
|
</FieldSet>
|
|
</FieldGroup>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card v-if="prices.length > 1">
|
|
<CardHeader>
|
|
<CardTitle>Harga Bersama</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FieldGroup>
|
|
<label class="mb-4 flex cursor-pointer items-center gap-2">
|
|
<input type="checkbox" class="size-4 rounded border-input" :checked="useSamePrice"
|
|
@change="toggleUseSamePrice(($event.target as HTMLInputElement).checked)">
|
|
<span class="text-sm">Gunakan harga yang sama untuk semua varian</span>
|
|
</label>
|
|
|
|
<Field v-if="useSamePrice && prices[0]">
|
|
<FieldLabel :for="`shared_price_${prices[0].client_id}`" required>
|
|
Harga
|
|
</FieldLabel>
|
|
<RupiahInput :id="`shared_price_${prices[0].client_id}`" :model-value="prices[0].price"
|
|
@update:model-value="setSharedPrice" />
|
|
<FieldError
|
|
:errors="priceError(prices[0].client_id, 'price') ? [priceError(prices[0].client_id, 'price')!] : []" />
|
|
</Field>
|
|
</FieldGroup>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card v-for="(price, index) in prices" :key="price.client_id">
|
|
<CardHeader class="flex flex-row items-start justify-between gap-4">
|
|
<CardTitle>Varian {{ index + 1 }}</CardTitle>
|
|
<div class="flex items-center gap-2">
|
|
<Button v-if="prices.length > 1 && !useSamePrice" type="button" variant="outline" size="sm"
|
|
@click="applyPriceToAllVariants(price.client_id)">
|
|
<Copy class="size-4" />
|
|
Terapkan Harga ke Semua
|
|
</Button>
|
|
<Button v-if="prices.length > 1" type="button" variant="outline" size="icon"
|
|
class="text-destructive hover:text-destructive size-8"
|
|
@click="removePrice(price.client_id)">
|
|
<Trash2 class="size-4" />
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FieldGroup>
|
|
<FieldSet class="grid gap-4 md:grid-cols-3">
|
|
<Field>
|
|
<FieldLabel :for="`variant_${price.client_id}`" required>
|
|
Nama Varian
|
|
</FieldLabel>
|
|
<Input :id="`variant_${price.client_id}`" :model-value="price.variant" type="text"
|
|
placeholder="Contoh: Premium / 40s"
|
|
@update:model-value="setPriceField(price.client_id, 'variant', String($event))" />
|
|
<FieldError
|
|
:errors="priceError(price.client_id, 'variant') ? [priceError(price.client_id, 'variant')!] : []" />
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel :for="`stock_${price.client_id}`" required>
|
|
Stok
|
|
</FieldLabel>
|
|
<Input :id="`stock_${price.client_id}`" :model-value="price.stock" type="number" min="0"
|
|
step="0.0001"
|
|
@update:model-value="setPriceField(price.client_id, 'stock', String($event))" />
|
|
<FieldError
|
|
:errors="priceError(price.client_id, 'stock') ? [priceError(price.client_id, 'stock')!] : []" />
|
|
</Field>
|
|
<Field v-if="!useSamePrice || prices.length === 1">
|
|
<FieldLabel :for="`price_${price.client_id}`" required>
|
|
Harga
|
|
</FieldLabel>
|
|
<RupiahInput :id="`price_${price.client_id}`" :model-value="price.price"
|
|
@update:model-value="setPriceValue(price.client_id, $event)" />
|
|
<FieldError
|
|
:errors="priceError(price.client_id, 'price') ? [priceError(price.client_id, 'price')!] : []" />
|
|
</Field>
|
|
</FieldSet>
|
|
|
|
<div class="mt-4">
|
|
<MediaImageUpload :id="`price_images_${price.client_id}`" v-model="price.media"
|
|
label="Foto Varian" :max-files="5" required
|
|
:errors="priceError(price.client_id, 'images') ? [priceError(price.client_id, 'images')!] : []" />
|
|
</div>
|
|
</FieldGroup>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<FieldError :errors="formError('prices') ? [formError('prices')!] : []" />
|
|
|
|
<div class="flex items-center justify-between gap-2">
|
|
<Button type="button" variant="outline" @click="addPrice">
|
|
<Plus class="size-4" />
|
|
Tambah Varian
|
|
</Button>
|
|
|
|
<Button type="submit" :disabled="form.processing">
|
|
<Save class="size-4" />
|
|
{{ form.processing ? 'Menyimpan...' : submitLabel }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</template>
|