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:
parent
562c5721dd
commit
1a2b741209
@ -37,7 +37,7 @@ public function rules(): array
|
||||
|
||||
...$this->productVariantRules(
|
||||
productId: $this->route('product')?->id,
|
||||
imagesRequired: $this->isMethod('POST'),
|
||||
imagesRequired: $this->route('product') === null,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 -->
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')" />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user