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(
|
...$this->productVariantRules(
|
||||||
productId: $this->route('product')?->id,
|
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()];
|
items.value = [...items.value, createEmpty()];
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(clientId: string) {
|
function removeItem(clientId: string, force = false) {
|
||||||
if (items.value.length <= 1) {
|
if (!force && items.value.length <= 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,7 @@ const emit = defineEmits<{
|
|||||||
remove: [index: number];
|
remove: [index: number];
|
||||||
'adjust-quantity': [index: number, delta: number];
|
'adjust-quantity': [index: number, delta: number];
|
||||||
'sync-quantity': [index: number];
|
'sync-quantity': [index: number];
|
||||||
|
'sync-quantity-input': [index: number];
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -82,6 +83,7 @@ const emit = defineEmits<{
|
|||||||
<DecimalInput
|
<DecimalInput
|
||||||
v-model="item.quantity"
|
v-model="item.quantity"
|
||||||
class="h-8 text-center"
|
class="h-8 text-center"
|
||||||
|
@input="emit('sync-quantity-input', index)"
|
||||||
@change="emit('sync-quantity', index)"
|
@change="emit('sync-quantity', index)"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { Plus, ShoppingCart } from '@lucide/vue';
|
import { Check, Plus, Search, ShoppingCart } from '@lucide/vue';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||||
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 { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldGroup,
|
FieldGroup,
|
||||||
@ -111,13 +112,7 @@ const {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [{
|
return [];
|
||||||
client_id: createClientId(),
|
|
||||||
variant: '',
|
|
||||||
price: '',
|
|
||||||
stock: '0',
|
|
||||||
media: createMediaUploadState(),
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -226,7 +221,7 @@ async function handleRemovePrice() {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
removePrice(clientId);
|
removePrice(clientId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
priceToDelete.value = null;
|
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) {
|
function syncCartItemQuantity(index: number) {
|
||||||
const price = prices.value[index];
|
const price = prices.value[index];
|
||||||
if (price) {
|
const cartItem = cart.value[index];
|
||||||
|
if (price && cartItem) {
|
||||||
|
price.stock = cartItem.quantity;
|
||||||
debouncedSave(price);
|
debouncedSave(price);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -448,7 +453,133 @@ const catalogMaterialNames = computed(() => {
|
|||||||
return [...new Set(props.catalog.map((c) => c.name))];
|
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() {
|
function onMaterialNameInput() {
|
||||||
|
isExistingMaterial.value = false;
|
||||||
|
selectedMaterialId.value = null;
|
||||||
const matched = props.catalog.find(
|
const matched = props.catalog.find(
|
||||||
(c) => c.name.toLowerCase() === form.name.trim().toLowerCase()
|
(c) => c.name.toLowerCase() === form.name.trim().toLowerCase()
|
||||||
);
|
);
|
||||||
@ -564,13 +695,7 @@ function populateForm() {
|
|||||||
} else {
|
} else {
|
||||||
form.name = '';
|
form.name = '';
|
||||||
form.unit = 'yard';
|
form.unit = 'yard';
|
||||||
prices.value = [{
|
prices.value = [];
|
||||||
client_id: createClientId(),
|
|
||||||
variant: '',
|
|
||||||
price: '',
|
|
||||||
stock: '0',
|
|
||||||
media: createMediaUploadState(),
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -674,7 +799,40 @@ function submit() {
|
|||||||
<div class="grid min-w-0 gap-4 xl:grid-cols-[1fr_380px]">
|
<div class="grid min-w-0 gap-4 xl:grid-cols-[1fr_380px]">
|
||||||
<!-- Left Column: Form Info & Variants (styled exactly like RawMaterialForm.vue) -->
|
<!-- Left Column: Form Info & Variants (styled exactly like RawMaterialForm.vue) -->
|
||||||
<div class="space-y-6">
|
<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"
|
<RawMaterialSharedPriceSection :form="form" :prices="prices" :use-same-price="useSamePrice"
|
||||||
@toggle-use-same-price="handleToggleUseSamePrice" @set-shared-price="handleSetSharedPrice" />
|
@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"
|
<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="price" :index="index" :total-prices="prices.length" :use-same-price="useSamePrice"
|
||||||
:price-errors="(clientId, field) => priceErrors(form, clientId, field)"
|
:price-errors="(clientId, field) => priceErrors(form, clientId, field)"
|
||||||
|
:variant-options="selectedMaterialVariants"
|
||||||
@remove="confirmRemovePrice(price.client_id)" @apply-price-to-all="handleApplyPriceToAll(price.client_id)"
|
@remove="confirmRemovePrice(price.client_id)" @apply-price-to-all="handleApplyPriceToAll(price.client_id)"
|
||||||
@update:variant="handleUpdateVariant(price.client_id, $event)"
|
@update:variant="handleUpdateVariant(price.client_id, $event)"
|
||||||
@update:stock="handleUpdateStock(price.client_id, $event)"
|
@update:stock="handleUpdateStock(price.client_id, $event)"
|
||||||
@ -721,6 +880,7 @@ function submit() {
|
|||||||
@remove="removeFromCart"
|
@remove="removeFromCart"
|
||||||
@adjust-quantity="adjustQuantity"
|
@adjust-quantity="adjustQuantity"
|
||||||
@sync-quantity="syncCartItemQuantity"
|
@sync-quantity="syncCartItemQuantity"
|
||||||
|
@sync-quantity-input="syncCartInput"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Totals, Discount, Shipping, Notes, Photo dropzone, Submit -->
|
<!-- Totals, Discount, Shipping, Notes, Photo dropzone, Submit -->
|
||||||
|
|||||||
@ -25,6 +25,7 @@ defineProps<{
|
|||||||
units: EnumOption[];
|
units: EnumOption[];
|
||||||
method: 'post' | 'put';
|
method: 'post' | 'put';
|
||||||
selectPortalTarget?: HTMLElement;
|
selectPortalTarget?: HTMLElement;
|
||||||
|
disabled?: boolean;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -44,6 +45,7 @@ defineProps<{
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Masukkan nama bahan baku"
|
placeholder="Masukkan nama bahan baku"
|
||||||
:maxlength="FIELD_LIMITS.name"
|
:maxlength="FIELD_LIMITS.name"
|
||||||
|
:disabled="disabled"
|
||||||
/>
|
/>
|
||||||
<FieldError :errors="formErrors(form, 'name')" />
|
<FieldError :errors="formErrors(form, 'name')" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@ -14,6 +14,13 @@ import {
|
|||||||
} from '@/components/ui/field';
|
} from '@/components/ui/field';
|
||||||
import FieldDescription from '@/components/ui/field/FieldDescription.vue';
|
import FieldDescription from '@/components/ui/field/FieldDescription.vue';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||||
import type { FormWithErrors } from '@/lib/form';
|
import type { FormWithErrors } from '@/lib/form';
|
||||||
import type { RawMaterialPriceFormItem } from '@/types/raw-material';
|
import type { RawMaterialPriceFormItem } from '@/types/raw-material';
|
||||||
@ -25,6 +32,7 @@ defineProps<{
|
|||||||
totalPrices: number;
|
totalPrices: number;
|
||||||
useSamePrice: boolean;
|
useSamePrice: boolean;
|
||||||
priceErrors: (clientId: string, field: string) => string[];
|
priceErrors: (clientId: string, field: string) => string[];
|
||||||
|
variantOptions?: { value: string; label: string }[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -59,7 +67,18 @@ const emit = defineEmits<{
|
|||||||
<FieldLabel :for="`variant_${price.client_id}`" required>
|
<FieldLabel :for="`variant_${price.client_id}`" required>
|
||||||
Nama Varian
|
Nama Varian
|
||||||
</FieldLabel>
|
</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"
|
placeholder="Masukkan nama varian" :maxlength="FIELD_LIMITS.variantName"
|
||||||
@update:model-value="emit('update:variant', String($event))" />
|
@update:model-value="emit('update:variant', String($event))" />
|
||||||
<FieldError :errors="priceErrors(price.client_id, 'variant')" />
|
<FieldError :errors="priceErrors(price.client_id, 'variant')" />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user