store/resources/js/components/admin/master/raw-materials/RawMaterialForm.vue

355 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 { 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 { MultipleImageUploadField } from '@/components/ui/image-upload-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 { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
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"
:maxlength="FIELD_LIMITS.name" />
<FieldError :errors="formErrors(form, '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="formErrors(form, '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" :maxlength="FIELD_LIMITS.variantName"
@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">
<MultipleImageUploadField :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="formErrors(form, '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>