feat: update ProductRequest to require images only when product is new; enhance removeItem function to allow forced removal; add sync-quantity-input event to PurchasePosCartSummaryItems; implement material search functionality in PurchasePosForm; add disabled prop to RawMaterialInfoSection; integrate variant selection in RawMaterialVariantSection

This commit is contained in:
Yoga Pangestu 2026-07-27 21:29:55 +07:00
parent 562c5721dd
commit 1a2b741209
6 changed files with 206 additions and 23 deletions

View File

@ -37,7 +37,7 @@ public function rules(): array
...$this->productVariantRules(
productId: $this->route('product')?->id,
imagesRequired: $this->isMethod('POST'),
imagesRequired: $this->route('product') === null,
),
];
}

View File

@ -21,8 +21,8 @@ export function useVariantList<T extends VariantItem>(
items.value = [...items.value, createEmpty()];
}
function removeItem(clientId: string) {
if (items.value.length <= 1) {
function removeItem(clientId: string, force = false) {
if (!force && items.value.length <= 1) {
return;
}

View File

@ -24,6 +24,7 @@ const emit = defineEmits<{
remove: [index: number];
'adjust-quantity': [index: number, delta: number];
'sync-quantity': [index: number];
'sync-quantity-input': [index: number];
}>();
</script>
@ -82,6 +83,7 @@ const emit = defineEmits<{
<DecimalInput
v-model="item.quantity"
class="h-8 text-center"
@input="emit('sync-quantity-input', index)"
@change="emit('sync-quantity', index)"
/>
<Button

View File

@ -1,12 +1,13 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Plus, ShoppingCart } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { Check, Plus, Search, ShoppingCart } from '@lucide/vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { apiFetch } from '@/lib/api';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
FieldError,
FieldGroup,
@ -111,13 +112,7 @@ const {
}));
}
return [{
client_id: createClientId(),
variant: '',
price: '',
stock: '0',
media: createMediaUploadState(),
}];
return [];
}
);
@ -226,7 +221,7 @@ async function handleRemovePrice() {
console.error(error);
}
}
removePrice(clientId);
removePrice(clientId, true);
}
priceToDelete.value = null;
@ -253,9 +248,19 @@ function adjustQuantity(index: number, delta: number) {
}
}
function syncCartInput(index: number) {
const cartItem = cart.value[index];
const price = prices.value[index];
if (price && cartItem) {
price.stock = cartItem.quantity;
}
}
function syncCartItemQuantity(index: number) {
const price = prices.value[index];
if (price) {
const cartItem = cart.value[index];
if (price && cartItem) {
price.stock = cartItem.quantity;
debouncedSave(price);
}
}
@ -448,7 +453,133 @@ const catalogMaterialNames = computed(() => {
return [...new Set(props.catalog.map((c) => c.name))];
});
// Search existing raw materials from catalog
const materialSearch = ref('');
const materialSearchResults = ref<Array<{
priceId: number;
rawMaterialId: number;
name: string;
unit: string;
variant: string;
price: number;
priceFormatted: string;
stockFormatted: string;
images: MediaItem[];
}>>([]);
const searchContainerRef = ref<HTMLElement | null>(null);
const isExistingMaterial = ref(false);
const selectedMaterialId = ref<number | null>(null);
const selectedMaterialVariants = computed(() => {
if (!selectedMaterialId.value) return [];
const mat = props.catalog.find((r) => r.id === selectedMaterialId.value);
return mat ? mat.prices.map((p) => ({ value: p.variant, label: p.variant })) : [];
});
watch(() => prices.value.length, (len) => {
if (len === 0) {
isExistingMaterial.value = false;
selectedMaterialId.value = null;
materialSearch.value = '';
materialSearchResults.value = [];
}
});
function onDocumentClick(e: MouseEvent) {
if (searchContainerRef.value && !searchContainerRef.value.contains(e.target as Node)) {
materialSearchResults.value = [];
}
}
onMounted(() => document.addEventListener('click', onDocumentClick));
onUnmounted(() => document.removeEventListener('click', onDocumentClick));
function onMaterialSearch() {
const keyword = materialSearch.value.trim().toLowerCase();
const results: typeof materialSearchResults.value[0][] = [];
if (selectedMaterialId.value !== null) {
const mat = props.catalog.find((r) => r.id === selectedMaterialId.value);
if (mat) {
for (const price of mat.prices) {
if (!keyword || price.variant.toLowerCase().includes(keyword)) {
results.push({
priceId: price.id,
rawMaterialId: mat.id,
name: mat.name,
unit: mat.unit,
variant: price.variant,
price: price.price,
priceFormatted: price.price_formatted,
stockFormatted: price.stock_formatted,
images: price.images ?? [],
});
}
}
}
} else {
if (!keyword) {
materialSearchResults.value = [];
return;
}
for (const rm of props.catalog) {
for (const price of rm.prices) {
if (rm.name.toLowerCase().includes(keyword) || price.variant.toLowerCase().includes(keyword)) {
results.push({
priceId: price.id,
rawMaterialId: rm.id,
name: rm.name,
unit: rm.unit,
variant: price.variant,
price: price.price,
priceFormatted: price.price_formatted,
stockFormatted: price.stock_formatted,
images: price.images ?? [],
});
}
}
}
}
materialSearchResults.value = results.slice(0, 20);
}
function selectExistingMaterial(result: (typeof materialSearchResults.value)[0]) {
if (selectedMaterialId.value !== result.rawMaterialId) {
isExistingMaterial.value = true;
selectedMaterialId.value = result.rawMaterialId;
form.name = result.name;
form.unit = result.unit;
}
prices.value.push({
client_id: createClientId(),
id: result.priceId,
variant: result.variant,
price: String(result.price),
stock: '0',
media: createMediaUploadState(result.images),
});
toast.success(`"${result.name}${result.variant}" ditambahkan ke keranjang.`);
const lastPrice = prices.value[prices.value.length - 1];
if (lastPrice) {
debouncedSave(lastPrice);
}
onMaterialSearch();
}
function isInCart(priceId: number): boolean {
return prices.value.some((p) => p.id === priceId);
}
function onMaterialNameInput() {
isExistingMaterial.value = false;
selectedMaterialId.value = null;
const matched = props.catalog.find(
(c) => c.name.toLowerCase() === form.name.trim().toLowerCase()
);
@ -564,13 +695,7 @@ function populateForm() {
} else {
form.name = '';
form.unit = 'yard';
prices.value = [{
client_id: createClientId(),
variant: '',
price: '',
stock: '0',
media: createMediaUploadState(),
}];
prices.value = [];
}
}
@ -674,7 +799,40 @@ function submit() {
<div class="grid min-w-0 gap-4 xl:grid-cols-[1fr_380px]">
<!-- Left Column: Form Info & Variants (styled exactly like RawMaterialForm.vue) -->
<div class="space-y-6">
<RawMaterialInfoSection :form="form" :units="units" method="post" />
<!-- Search existing raw materials -->
<div ref="searchContainerRef" class="relative">
<div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="materialSearch"
:placeholder="selectedMaterialId ? 'Cari varian dari ' + (props.catalog.find(r => r.id === selectedMaterialId)?.name ?? '') + '...' : 'Cari bahan baku yang sudah ada...'"
class="pl-9" @input="onMaterialSearch" />
</div>
<div v-if="materialSearchResults.length > 0" class="absolute left-0 right-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-md border bg-popover shadow-md">
<div v-for="result in materialSearchResults" :key="result.priceId"
class="flex items-center gap-3 px-3 py-2 text-sm"
:class="isInCart(result.priceId)
? 'cursor-default opacity-60'
: 'cursor-pointer hover:bg-accent'"
@click="!isInCart(result.priceId) && selectExistingMaterial(result)"
>
<div class="min-w-0 flex-1">
<p class="truncate font-medium">{{ selectedMaterialId ? '' : result.name + ' → ' }}{{ result.variant }}</p>
<p class="text-xs text-muted-foreground">
Stok: {{ result.stockFormatted }} | Harga: {{ result.priceFormatted }}
</p>
</div>
<Button v-if="isInCart(result.priceId)" type="button" variant="ghost" size="icon-sm" disabled>
<Check class="size-3.5 text-primary" />
</Button>
<Button v-else type="button" variant="outline" size="icon-sm">
<Plus class="size-3.5" />
</Button>
</div>
</div>
</div>
<RawMaterialInfoSection :form="form" :units="units" method="post" :disabled="isExistingMaterial" />
<RawMaterialSharedPriceSection :form="form" :prices="prices" :use-same-price="useSamePrice"
@toggle-use-same-price="handleToggleUseSamePrice" @set-shared-price="handleSetSharedPrice" />
@ -682,6 +840,7 @@ function submit() {
<RawMaterialVariantSection v-for="(price, index) in prices" :key="price.client_id" :form="form"
:price="price" :index="index" :total-prices="prices.length" :use-same-price="useSamePrice"
:price-errors="(clientId, field) => priceErrors(form, clientId, field)"
:variant-options="selectedMaterialVariants"
@remove="confirmRemovePrice(price.client_id)" @apply-price-to-all="handleApplyPriceToAll(price.client_id)"
@update:variant="handleUpdateVariant(price.client_id, $event)"
@update:stock="handleUpdateStock(price.client_id, $event)"
@ -721,6 +880,7 @@ function submit() {
@remove="removeFromCart"
@adjust-quantity="adjustQuantity"
@sync-quantity="syncCartItemQuantity"
@sync-quantity-input="syncCartInput"
/>
<!-- Totals, Discount, Shipping, Notes, Photo dropzone, Submit -->

View File

@ -25,6 +25,7 @@ defineProps<{
units: EnumOption[];
method: 'post' | 'put';
selectPortalTarget?: HTMLElement;
disabled?: boolean;
}>();
</script>
@ -44,6 +45,7 @@ defineProps<{
type="text"
placeholder="Masukkan nama bahan baku"
:maxlength="FIELD_LIMITS.name"
:disabled="disabled"
/>
<FieldError :errors="formErrors(form, 'name')" />
</Field>

View File

@ -14,6 +14,13 @@ import {
} from '@/components/ui/field';
import FieldDescription from '@/components/ui/field/FieldDescription.vue';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FIELD_LIMITS } from '@/lib/field-limits';
import type { FormWithErrors } from '@/lib/form';
import type { RawMaterialPriceFormItem } from '@/types/raw-material';
@ -25,6 +32,7 @@ defineProps<{
totalPrices: number;
useSamePrice: boolean;
priceErrors: (clientId: string, field: string) => string[];
variantOptions?: { value: string; label: string }[];
}>();
const emit = defineEmits<{
@ -59,7 +67,18 @@ const emit = defineEmits<{
<FieldLabel :for="`variant_${price.client_id}`" required>
Nama Varian
</FieldLabel>
<Input :id="`variant_${price.client_id}`" :model-value="price.variant" type="text"
<Select v-if="variantOptions && variantOptions.length > 0" :model-value="price.variant"
@update:model-value="emit('update:variant', $event as string)">
<SelectTrigger :id="`variant_${price.client_id}`" class="w-full">
<SelectValue placeholder="Pilih varian" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="opt in variantOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
<Input v-else :id="`variant_${price.client_id}`" :model-value="price.variant" type="text"
placeholder="Masukkan nama varian" :maxlength="FIELD_LIMITS.variantName"
@update:model-value="emit('update:variant', String($event))" />
<FieldError :errors="priceErrors(price.client_id, 'variant')" />