- Desglosa detalles de paquetes en la generación de tickets. - Mejora modales de edición y eliminación.
268 lines
10 KiB
Vue
268 lines
10 KiB
Vue
<script setup>
|
|
import { ref, computed, watch } from 'vue';
|
|
import { useForm } from '@Services/Api';
|
|
import { formatCurrency } from '@/utils/formatters';
|
|
import { apiTo } from './Module.js';
|
|
|
|
import Modal from '@Holos/Modal.vue';
|
|
import FormInput from '@Holos/Form/Input.vue';
|
|
import FormError from '@Holos/Form/Elements/Error.vue';
|
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
|
import ProductSelector from './ProductSelector.vue';
|
|
|
|
/** Eventos */
|
|
const emit = defineEmits(['close', 'created']);
|
|
|
|
/** Propiedades */
|
|
const props = defineProps({
|
|
show: Boolean
|
|
});
|
|
|
|
/** Formulario */
|
|
const form = useForm({
|
|
name: '',
|
|
sku: '',
|
|
barcode: '',
|
|
items: [],
|
|
retail_price: '',
|
|
tax: ''
|
|
});
|
|
|
|
const selectedProducts = ref([]);
|
|
|
|
/** Computed */
|
|
const totalCost = computed(() => {
|
|
return selectedProducts.value.reduce((sum, item) => {
|
|
return sum + ((item.product.price?.cost || 0) * item.quantity);
|
|
}, 0);
|
|
});
|
|
|
|
const suggestedPrice = computed(() => {
|
|
return selectedProducts.value.reduce((sum, item) => {
|
|
return sum + ((item.product.price?.retail_price || 0) * item.quantity);
|
|
}, 0);
|
|
});
|
|
|
|
/** Métodos */
|
|
const calculateTax = () => {
|
|
if (form.retail_price && !form.tax) {
|
|
form.tax = (parseFloat(form.retail_price) * 0.16).toFixed(2);
|
|
}
|
|
};
|
|
|
|
const handleProductSelect = (product) => {
|
|
if (selectedProducts.value.find(item => item.product.id === product.id)) {
|
|
Notify.warning('Este producto ya está agregado');
|
|
return;
|
|
}
|
|
selectedProducts.value.push({ product, quantity: 1 });
|
|
};
|
|
|
|
const removeProduct = (index) => {
|
|
selectedProducts.value.splice(index, 1);
|
|
};
|
|
|
|
const updateQuantity = (index, quantity) => {
|
|
if (quantity >= 1) {
|
|
selectedProducts.value[index].quantity = parseInt(quantity);
|
|
}
|
|
};
|
|
|
|
const useSuggestedPrice = () => {
|
|
form.retail_price = suggestedPrice.value.toFixed(2);
|
|
calculateTax();
|
|
};
|
|
|
|
const createBundle = () => {
|
|
if (selectedProducts.value.length < 2) {
|
|
Notify.error('Debes agregar al menos 2 productos al paquete');
|
|
return;
|
|
}
|
|
|
|
form.items = selectedProducts.value.map(item => ({
|
|
inventory_id: item.product.id,
|
|
quantity: item.quantity
|
|
}));
|
|
|
|
form.post(apiTo('store'), {
|
|
onSuccess: () => {
|
|
Notify.success('Paquete creado exitosamente');
|
|
emit('created');
|
|
closeModal();
|
|
},
|
|
onError: () => {
|
|
Notify.error('Error al crear el paquete');
|
|
}
|
|
});
|
|
};
|
|
|
|
const closeModal = () => {
|
|
form.reset();
|
|
selectedProducts.value = [];
|
|
emit('close');
|
|
};
|
|
|
|
/** Observadores */
|
|
watch(() => props.show, (val) => {
|
|
if (val) {
|
|
form.reset();
|
|
selectedProducts.value = [];
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Modal :show="show" max-width="2xl" @close="closeModal">
|
|
<div class="p-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
|
Crear Paquete
|
|
</h3>
|
|
<button @click="closeModal" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Formulario -->
|
|
<form @submit.prevent="createBundle" class="space-y-4">
|
|
<div class="grid grid-cols-2 gap-4">
|
|
|
|
<!-- Nombre -->
|
|
<div class="col-span-2">
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
NOMBRE
|
|
</label>
|
|
<FormInput
|
|
v-model="form.name"
|
|
type="text"
|
|
placeholder="Ej: Kit Gamer Pro"
|
|
required
|
|
/>
|
|
<FormError :message="form.errors?.name" />
|
|
</div>
|
|
|
|
<!-- SKU -->
|
|
<div>
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
SKU
|
|
</label>
|
|
<FormInput
|
|
v-model="form.sku"
|
|
type="text"
|
|
placeholder="KIT-001"
|
|
required
|
|
/>
|
|
<FormError :message="form.errors?.sku" />
|
|
</div>
|
|
|
|
<!-- Código de barras -->
|
|
<div>
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
CÓDIGO DE BARRAS
|
|
</label>
|
|
<FormInput
|
|
v-model="form.barcode"
|
|
type="text"
|
|
placeholder="Opcional"
|
|
/>
|
|
<FormError :message="form.errors?.barcode" />
|
|
</div>
|
|
|
|
<!-- Componentes -->
|
|
<div class="col-span-2">
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
COMPONENTES <span class="text-gray-400 normal-case font-normal">(mínimo 2)</span>
|
|
</label>
|
|
<ProductSelector
|
|
:exclude-ids="selectedProducts.map(item => item.product.id)"
|
|
@select="handleProductSelect"
|
|
/>
|
|
<div v-if="selectedProducts.length > 0" class="mt-2 space-y-2">
|
|
<div
|
|
v-for="(item, index) in selectedProducts"
|
|
:key="item.product.id"
|
|
class="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
|
|
>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{{ item.product.name }}</p>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">SKU: {{ item.product.sku }}</p>
|
|
</div>
|
|
<div class="flex items-center gap-1 shrink-0">
|
|
<span class="text-xs text-gray-500">Cant:</span>
|
|
<input
|
|
:value="item.quantity"
|
|
@input="updateQuantity(index, $event.target.value)"
|
|
type="number"
|
|
min="1"
|
|
class="w-16 px-2 py-1 text-center text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-1 focus:ring-indigo-500 dark:bg-gray-800 dark:text-gray-100"
|
|
/>
|
|
</div>
|
|
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300 min-w-20 text-right shrink-0">
|
|
{{ formatCurrency(item.product.price?.retail_price) }}
|
|
</span>
|
|
<button type="button" @click="removeProduct(index)" class="text-red-500 hover:text-red-700 shrink-0">
|
|
<GoogleIcon name="close" class="text-lg" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<FormError :message="form.errors?.items" />
|
|
</div>
|
|
|
|
<!-- Precio de Venta -->
|
|
<div>
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
PRECIO VENTA
|
|
</label>
|
|
<FormInput
|
|
v-model.number="form.retail_price"
|
|
@blur="calculateTax"
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
placeholder="0.00"
|
|
/>
|
|
<FormError :message="form.errors?.retail_price" />
|
|
</div>
|
|
|
|
<!-- Impuesto -->
|
|
<div>
|
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
IMPUESTO (%)
|
|
</label>
|
|
<FormInput
|
|
v-model.number="form.tax"
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
placeholder="16.00"
|
|
/>
|
|
<FormError :message="form.errors?.tax" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Botones -->
|
|
<div class="flex items-center justify-end gap-3 mt-6">
|
|
<button
|
|
type="button"
|
|
@click="closeModal"
|
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
:disabled="form.processing"
|
|
class="px-4 py-2 text-sm font-semibold text-white bg-gray-900 rounded-lg hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<span v-if="form.processing">Guardando...</span>
|
|
<span v-else>Guardar</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</Modal>
|
|
</template>
|