feat: gestión multiproducto en salidas y traspasos
- Habilita selección múltiple con cantidades en ExitModal y TransferModal. - Implementa lógica para agregar/quitar productos y calcular totales. - Agrega validación de selección mínima antes de enviar el formulario.
This commit is contained in:
parent
2c7d2f2001
commit
04e84f6241
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { useApi, apiURL } from '@Services/Api';
|
import { useApi, apiURL } from '@Services/Api';
|
||||||
import { formatDate } from '@/utils/formatters';
|
import { formatDate } from '@/utils/formatters';
|
||||||
|
|
||||||
@ -10,7 +10,8 @@ import Loader from '@Shared/Loader.vue';
|
|||||||
/** Propiedades */
|
/** Propiedades */
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: Boolean,
|
show: Boolean,
|
||||||
movementId: Number
|
movementId: Number,
|
||||||
|
movementData: Object
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Eventos */
|
/** Eventos */
|
||||||
@ -22,10 +23,30 @@ const loading = ref(false);
|
|||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
|
/** Computed */
|
||||||
|
const isMultiProduct = computed(() => {
|
||||||
|
return movement.value?.products && movement.value.products.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalQuantity = computed(() => {
|
||||||
|
if (!isMultiProduct.value) return movement.value?.quantity || 0;
|
||||||
|
return movement.value.products.reduce((sum, p) => sum + Number(p.quantity), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCost = computed(() => {
|
||||||
|
if (!isMultiProduct.value) return 0;
|
||||||
|
return movement.value.products.reduce((sum, p) => sum + (Number(p.quantity) * Number(p.unit_cost || 0)), 0);
|
||||||
|
});
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
const fetchDetail = () => {
|
const fetchDetail = () => {
|
||||||
if (!props.movementId) return;
|
if (!props.movementId) return;
|
||||||
|
|
||||||
|
if(props.movementData && props.movementData.products && props.movementData.products.length > 0) {
|
||||||
|
movement.value = props.movementData;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
movement.value = null;
|
movement.value = null;
|
||||||
|
|
||||||
@ -66,7 +87,7 @@ watch(() => props.show, (isShown) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal :show="show" max-width="lg" @close="handleClose">
|
<Modal :show="show" max-width="2xl" @close="handleClose">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
@ -96,13 +117,74 @@ watch(() => props.show, (isShown) => {
|
|||||||
<GoogleIcon :name="getTypeBadge(movement.movement_type).icon" class="text-lg" />
|
<GoogleIcon :name="getTypeBadge(movement.movement_type).icon" class="text-lg" />
|
||||||
{{ getTypeBadge(movement.movement_type).label }}
|
{{ getTypeBadge(movement.movement_type).label }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
</div>
|
||||||
#{{ movement.id }}
|
|
||||||
|
<!-- Productos (múltiples) -->
|
||||||
|
<div v-if="isMultiProduct" class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-xl p-5 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<GoogleIcon name="inventory_2" class="text-lg text-gray-600 dark:text-gray-400" />
|
||||||
|
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 uppercase">Productos</h4>
|
||||||
|
<span class="ml-auto text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||||
|
{{ movement.products.length }} producto(s)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Producto -->
|
<!-- Tabla de productos -->
|
||||||
<div class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-xl p-5 border border-gray-200 dark:border-gray-700">
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(product, index) in movement.products"
|
||||||
|
:key="index"
|
||||||
|
class="p-3 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-12 gap-3 items-center text-sm">
|
||||||
|
<div class="col-span-12 sm:col-span-5">
|
||||||
|
<p class="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ product.inventory?.name || 'N/A' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||||
|
{{ product.inventory?.sku || 'N/A' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-4 sm:col-span-2 text-center">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 text-xs">Cantidad:</span>
|
||||||
|
<p class="font-bold text-gray-900 dark:text-gray-100">{{ product.quantity }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-4 sm:col-span-2 text-center">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 text-xs">Costo unit.:</span>
|
||||||
|
<p class="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
${{ Number(product.unit_cost || 0).toFixed(2) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-4 sm:col-span-3 text-right">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 text-xs">Subtotal:</span>
|
||||||
|
<p class="font-bold text-indigo-900 dark:text-indigo-100">
|
||||||
|
${{ (product.quantity * Number(product.unit_cost || 0)).toFixed(2) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total -->
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-300 dark:border-gray-600">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
Cantidad total: <span class="text-gray-900 dark:text-gray-100">{{ totalQuantity }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Costo total:</span>
|
||||||
|
<p class="text-xl font-bold text-indigo-900 dark:text-indigo-100">
|
||||||
|
${{ totalCost.toFixed(2) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Producto (individual) -->
|
||||||
|
<div v-else class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-xl p-5 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<GoogleIcon name="inventory_2" class="text-lg text-gray-600 dark:text-gray-400" />
|
<GoogleIcon name="inventory_2" class="text-lg text-gray-600 dark:text-gray-400" />
|
||||||
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 uppercase">Producto</h4>
|
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 uppercase">Producto</h4>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
|
import { formatCurrency } from '@/utils/formatters';
|
||||||
import { useForm, useApi, apiURL } from '@Services/Api';
|
import { useForm, useApi, apiURL } from '@Services/Api';
|
||||||
|
|
||||||
import Modal from '@Holos/Modal.vue';
|
import Modal from '@Holos/Modal.vue';
|
||||||
@ -19,39 +20,170 @@ const props = defineProps({
|
|||||||
const products = ref([]);
|
const products = ref([]);
|
||||||
const warehouses = ref([]);
|
const warehouses = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const selectedProducts = ref([]);
|
||||||
|
|
||||||
|
// Estado para búsqueda de productos
|
||||||
|
let debounceTimer = null;
|
||||||
|
const productSearch = ref('');
|
||||||
|
const searchingProduct = ref(false);
|
||||||
|
const productNotFound = ref(false);
|
||||||
|
const productSuggestions = ref([]);
|
||||||
|
const showProductSuggestions = ref(false);
|
||||||
|
const currentSearchIndex = ref(null); // Índice del producto que se está buscando
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
/** Formulario */
|
/** Formulario */
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
inventory_id: '',
|
|
||||||
warehouse_id: '',
|
warehouse_id: '',
|
||||||
quantity: '',
|
|
||||||
invoice_reference: '',
|
invoice_reference: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
products: []
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Computed */
|
||||||
|
const totalCost = computed(() => {
|
||||||
|
return selectedProducts.value.reduce((sum, item) => {
|
||||||
|
return sum + (item.quantity * item.unit_cost);
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalQuantity = computed(() => {
|
||||||
|
return selectedProducts.value.reduce((sum, item) => sum + Number(item.quantity), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
const loadData = () => {
|
const loadData = () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
api.get(apiURL('inventario'), {
|
Promise.all([
|
||||||
onSuccess: (data) => {
|
|
||||||
products.value = data.products?.data || data.products || [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
api.get(apiURL('almacenes'), {
|
api.get(apiURL('almacenes'), {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
warehouses.value = data.warehouses?.data || data.data || [];
|
warehouses.value = data.warehouses?.data || data.data || [];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
api.get(apiURL('inventario'), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
products.value = data.products?.data || data.data || [];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]).finally(() => {
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addProduct = () => {
|
||||||
|
selectedProducts.value.push({
|
||||||
|
inventory_id: '',
|
||||||
|
product_name: '',
|
||||||
|
product_sku: '',
|
||||||
|
quantity: 1,
|
||||||
|
unit_cost: 0
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeProduct = (index) => {
|
||||||
|
selectedProducts.value.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProductName = (productId) => {
|
||||||
|
const product = products.value.find(p => p.id == productId);
|
||||||
|
return product ? `${product.name} (${product.sku})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Métodos de búsqueda de productos */
|
||||||
|
const onProductInput = (index) => {
|
||||||
|
currentSearchIndex.value = index;
|
||||||
|
productNotFound.value = false;
|
||||||
|
|
||||||
|
const searchValue = productSearch.value?.trim();
|
||||||
|
if (!searchValue || searchValue.length < 2) {
|
||||||
|
productSuggestions.value = [];
|
||||||
|
showProductSuggestions.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
showProductSuggestions.value = true;
|
||||||
|
searchProduct();
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchProduct = () => {
|
||||||
|
const searchValue = productSearch.value?.trim();
|
||||||
|
if (!searchValue) {
|
||||||
|
productSuggestions.value = [];
|
||||||
|
showProductSuggestions.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchingProduct.value = true;
|
||||||
|
productNotFound.value = false;
|
||||||
|
|
||||||
|
api.get(apiURL(`inventario?q=${encodeURIComponent(searchValue)}`), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const foundProducts = data.products?.data || data.data || data.products || [];
|
||||||
|
if (foundProducts.length > 0) {
|
||||||
|
productSuggestions.value = foundProducts;
|
||||||
|
showProductSuggestions.value = true;
|
||||||
|
} else {
|
||||||
|
productSuggestions.value = [];
|
||||||
|
showProductSuggestions.value = false;
|
||||||
|
productNotFound.value = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFail: (data) => {
|
||||||
|
productSuggestions.value = [];
|
||||||
|
showProductSuggestions.value = false;
|
||||||
|
productNotFound.value = true;
|
||||||
|
window.Notify.error(data.message || 'Error al buscar producto');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
productSuggestions.value = [];
|
||||||
|
showProductSuggestions.value = false;
|
||||||
|
productNotFound.value = true;
|
||||||
},
|
},
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
loading.value = false;
|
searchingProduct.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectProduct = (product) => {
|
||||||
|
if (currentSearchIndex.value !== null) {
|
||||||
|
selectedProducts.value[currentSearchIndex.value].inventory_id = product.id;
|
||||||
|
selectedProducts.value[currentSearchIndex.value].product_name = product.name;
|
||||||
|
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
|
||||||
|
}
|
||||||
|
|
||||||
|
productSearch.value = '';
|
||||||
|
productSuggestions.value = [];
|
||||||
|
showProductSuggestions.value = false;
|
||||||
|
productNotFound.value = false;
|
||||||
|
currentSearchIndex.value = null;
|
||||||
|
|
||||||
|
window.Notify.success(`Producto ${product.name} agregado`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearProductSearch = (index) => {
|
||||||
|
if (currentSearchIndex.value === index) {
|
||||||
|
productSearch.value = '';
|
||||||
|
productSuggestions.value = [];
|
||||||
|
showProductSuggestions.value = false;
|
||||||
|
productNotFound.value = false;
|
||||||
|
currentSearchIndex.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createEntry = () => {
|
const createEntry = () => {
|
||||||
|
// Preparar datos del formulario
|
||||||
|
form.products = selectedProducts.value.map(item => ({
|
||||||
|
inventory_id: item.inventory_id,
|
||||||
|
quantity: Number(item.quantity),
|
||||||
|
unit_cost: Number(item.unit_cost)
|
||||||
|
}));
|
||||||
|
|
||||||
form.post(apiURL('movimientos/entrada'), {
|
form.post(apiURL('movimientos/entrada'), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
window.Notify.success('Entrada registrada correctamente');
|
window.Notify.success('Entrada registrada correctamente');
|
||||||
@ -69,6 +201,12 @@ const createEntry = () => {
|
|||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
selectedProducts.value = [];
|
||||||
|
productSearch.value = '';
|
||||||
|
productSuggestions.value = [];
|
||||||
|
showProductSuggestions.value = false;
|
||||||
|
productNotFound.value = false;
|
||||||
|
currentSearchIndex.value = null;
|
||||||
emit('close');
|
emit('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -76,6 +214,17 @@ const closeModal = () => {
|
|||||||
watch(() => props.show, (isShown) => {
|
watch(() => props.show, (isShown) => {
|
||||||
if (isShown) {
|
if (isShown) {
|
||||||
loadData();
|
loadData();
|
||||||
|
if (selectedProducts.value.length === 0) {
|
||||||
|
addProduct();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limpiar productos seleccionados cuando se cambia el almacén
|
||||||
|
watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
||||||
|
if (newWarehouseId !== oldWarehouseId && oldWarehouseId && selectedProducts.value.length > 0) {
|
||||||
|
selectedProducts.value = [];
|
||||||
|
addProduct();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@ -105,24 +254,7 @@ watch(() => props.show, (isShown) => {
|
|||||||
|
|
||||||
<!-- Formulario -->
|
<!-- Formulario -->
|
||||||
<form @submit.prevent="createEntry" class="space-y-4">
|
<form @submit.prevent="createEntry" class="space-y-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="space-y-4">
|
||||||
<!-- Producto -->
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
||||||
PRODUCTO
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
v-model="form.inventory_id"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
>
|
|
||||||
<option value="">Seleccionar producto...</option>
|
|
||||||
<option v-for="product in products" :key="product.id" :value="product.id">
|
|
||||||
{{ product.name }} ({{ product.sku }})
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<FormError :message="form.errors?.inventory_id" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Almacén destino -->
|
<!-- Almacén destino -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
@ -140,35 +272,195 @@ watch(() => props.show, (isShown) => {
|
|||||||
<FormError :message="form.errors?.warehouse_id" />
|
<FormError :message="form.errors?.warehouse_id" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cantidad -->
|
<!-- Lista de productos -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<div class="flex items-center justify-between mb-2">
|
||||||
CANTIDAD
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase">
|
||||||
|
PRODUCTOS
|
||||||
</label>
|
</label>
|
||||||
<FormInput
|
<button
|
||||||
v-model="form.quantity"
|
type="button"
|
||||||
|
@click="addProduct"
|
||||||
|
class="flex items-center gap-1 px-2 py-1 text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="add" class="text-sm" />
|
||||||
|
Agregar producto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in selectedProducts"
|
||||||
|
:key="index"
|
||||||
|
class="p-3 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-12 gap-3 items-start">
|
||||||
|
<!-- Producto -->
|
||||||
|
<div class="col-span-12 sm:col-span-5">
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Producto
|
||||||
|
</label>
|
||||||
|
<!-- Si ya se seleccionó un producto -->
|
||||||
|
<div v-if="item.inventory_id && item.product_name" class="flex items-center gap-2 px-2 py-1.5 bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded text-sm">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{{ item.product_name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ item.product_sku }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="item.inventory_id = ''; item.product_name = ''; item.product_sku = ''"
|
||||||
|
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="close" class="text-sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Input de búsqueda -->
|
||||||
|
<div v-else class="relative">
|
||||||
|
<input
|
||||||
|
v-model="productSearch"
|
||||||
|
@input="onProductInput(index)"
|
||||||
|
@focus="() => { currentSearchIndex = index; productSuggestions.length > 0 && (showProductSuggestions = true); }"
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por código de barras o nombre..."
|
||||||
|
class="w-full px-2 py-1.5 pr-8 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
:class="{
|
||||||
|
'border-red-500 focus:ring-red-500 focus:border-red-500': productNotFound && currentSearchIndex === index
|
||||||
|
}"
|
||||||
|
:disabled="searchingProduct"
|
||||||
|
/>
|
||||||
|
<div v-if="searchingProduct && currentSearchIndex === index" class="absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
|
<GoogleIcon name="hourglass_empty" class="text-sm text-gray-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="productSearch && currentSearchIndex === index" class="absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
|
<GoogleIcon name="search" class="text-sm text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<!-- Dropdown de sugerencias -->
|
||||||
|
<div
|
||||||
|
v-if="showProductSuggestions && productSuggestions.length > 0 && currentSearchIndex === index"
|
||||||
|
class="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="product in productSuggestions"
|
||||||
|
:key="product.id"
|
||||||
|
type="button"
|
||||||
|
@click="selectProduct(product)"
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-2 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{{ product.name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
SKU: {{ product.sku }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Error de producto no encontrado -->
|
||||||
|
<div v-if="productNotFound && currentSearchIndex === index" class="absolute z-50 w-full mt-1">
|
||||||
|
<div class="flex items-start gap-1 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-2">
|
||||||
|
<GoogleIcon name="error" class="text-red-600 dark:text-red-400 text-sm shrink-0" />
|
||||||
|
<p class="text-xs text-red-800 dark:text-red-300">
|
||||||
|
Producto no encontrado
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cantidad -->
|
||||||
|
<div class="col-span-5 sm:col-span-3">
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Cantidad
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="item.quantity"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
|
step="1"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
|
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
<FormError :message="form.errors?.quantity" />
|
</div>
|
||||||
|
|
||||||
|
<!-- Costo unitario -->
|
||||||
|
<div class="col-span-5 sm:col-span-3">
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Costo unit.
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="item.unit_cost"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botón eliminar -->
|
||||||
|
<div class="col-span-2 sm:col-span-1">
|
||||||
|
<label class="block text-xs font-medium text-transparent mb-1">.</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="removeProduct(index)"
|
||||||
|
:disabled="selectedProducts.length === 1"
|
||||||
|
class="w-full px-2 py-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:title="selectedProducts.length === 1 ? 'Debe haber al menos un producto' : 'Eliminar producto'"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="delete" class="text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subtotal del producto -->
|
||||||
|
<div v-if="item.quantity && item.unit_cost" class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Subtotal:
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatCurrency(item.quantity * item.unit_cost) }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resumen total -->
|
||||||
|
<div v-if="selectedProducts.length > 0" class="mt-3 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Total de productos: {{ selectedProducts.length }}
|
||||||
|
</span>
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Cantidad total: {{ totalQuantity }}
|
||||||
|
</span>
|
||||||
|
<span class="font-bold text-indigo-900 dark:text-indigo-100">
|
||||||
|
Costo total: {{ formatCurrency(totalCost) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormError :message="form.errors?.products" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Referencia de factura -->
|
<!-- Referencia de factura -->
|
||||||
<div class="col-span-2">
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
REFERENCIA DE FACTURA <span class="text-gray-400 text-xs normal-case">(opcional)</span>
|
REFERENCIA DE FACTURA
|
||||||
</label>
|
</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
v-model="form.invoice_reference"
|
v-model="form.invoice_reference"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Ej: FAC-2026-001"
|
placeholder="Ej: FAC-2026-001"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<FormError :message="form.errors?.invoice_reference" />
|
<FormError :message="form.errors?.invoice_reference" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notas -->
|
<!-- Notas -->
|
||||||
<div class="col-span-2">
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
NOTAS <span class="text-gray-400 text-xs normal-case">(opcional)</span>
|
NOTAS <span class="text-gray-400 text-xs normal-case">(opcional)</span>
|
||||||
</label>
|
</label>
|
||||||
@ -187,13 +479,13 @@ watch(() => props.show, (isShown) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="closeModal"
|
@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"
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="form.processing"
|
:disabled="form.processing || selectedProducts.length === 0"
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-green-600 rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-green-600 rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<GoogleIcon name="add_circle" class="text-lg" />
|
<GoogleIcon name="add_circle" class="text-lg" />
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { useForm, useApi, apiURL } from '@Services/Api';
|
import { useForm, useApi, apiURL } from '@Services/Api';
|
||||||
|
|
||||||
import Modal from '@Holos/Modal.vue';
|
import Modal from '@Holos/Modal.vue';
|
||||||
@ -19,27 +19,27 @@ const props = defineProps({
|
|||||||
const products = ref([]);
|
const products = ref([]);
|
||||||
const warehouses = ref([]);
|
const warehouses = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const selectedProducts = ref([]);
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
/** Formulario */
|
/** Formulario */
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
inventory_id: '',
|
|
||||||
warehouse_id: '',
|
warehouse_id: '',
|
||||||
quantity: '',
|
reference: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
products: []
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Computed */
|
||||||
|
const totalQuantity = computed(() => {
|
||||||
|
return selectedProducts.value.reduce((sum, item) => sum + Number(item.quantity), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
const loadData = () => {
|
const loadData = () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
api.get(apiURL('inventario'), {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
products.value = data.products?.data || data.products || [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
api.get(apiURL('almacenes'), {
|
api.get(apiURL('almacenes'), {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
warehouses.value = data.warehouses?.data || data.data || [];
|
warehouses.value = data.warehouses?.data || data.data || [];
|
||||||
@ -50,7 +50,42 @@ const loadData = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadProducts = (warehouseId) => {
|
||||||
|
if (!warehouseId) {
|
||||||
|
products.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
api.get(apiURL(`inventario/almacen/${warehouseId}`), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
products.value = data.products || [];
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addProduct = () => {
|
||||||
|
selectedProducts.value.push({
|
||||||
|
inventory_id: '',
|
||||||
|
quantity: 1
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeProduct = (index) => {
|
||||||
|
selectedProducts.value.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
const createExit = () => {
|
const createExit = () => {
|
||||||
|
// Preparar datos del formulario
|
||||||
|
form.products = selectedProducts.value.map(item => ({
|
||||||
|
inventory_id: item.inventory_id,
|
||||||
|
quantity: Number(item.quantity)
|
||||||
|
}));
|
||||||
|
|
||||||
form.post(apiURL('movimientos/salida'), {
|
form.post(apiURL('movimientos/salida'), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
window.Notify.success('Salida registrada correctamente');
|
window.Notify.success('Salida registrada correctamente');
|
||||||
@ -68,6 +103,7 @@ const createExit = () => {
|
|||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
selectedProducts.value = [];
|
||||||
emit('close');
|
emit('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -75,6 +111,22 @@ const closeModal = () => {
|
|||||||
watch(() => props.show, (isShown) => {
|
watch(() => props.show, (isShown) => {
|
||||||
if (isShown) {
|
if (isShown) {
|
||||||
loadData();
|
loadData();
|
||||||
|
if (selectedProducts.value.length === 0) {
|
||||||
|
addProduct();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cargar productos cuando se selecciona un almacén de origen
|
||||||
|
watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
||||||
|
if (newWarehouseId !== oldWarehouseId) {
|
||||||
|
loadProducts(newWarehouseId);
|
||||||
|
|
||||||
|
// Limpiar productos seleccionados si había alguno y se cambió el almacén
|
||||||
|
if (oldWarehouseId && selectedProducts.value.length > 0) {
|
||||||
|
selectedProducts.value = [];
|
||||||
|
addProduct();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@ -104,24 +156,7 @@ watch(() => props.show, (isShown) => {
|
|||||||
|
|
||||||
<!-- Formulario -->
|
<!-- Formulario -->
|
||||||
<form @submit.prevent="createExit" class="space-y-4">
|
<form @submit.prevent="createExit" class="space-y-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="space-y-4">
|
||||||
<!-- Producto -->
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
||||||
PRODUCTO
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
v-model="form.inventory_id"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
>
|
|
||||||
<option value="">Seleccionar producto...</option>
|
|
||||||
<option v-for="product in products" :key="product.id" :value="product.id">
|
|
||||||
{{ product.name }} ({{ product.sku }})
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<FormError :message="form.errors?.inventory_id" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Almacén origen -->
|
<!-- Almacén origen -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
@ -139,22 +174,107 @@ watch(() => props.show, (isShown) => {
|
|||||||
<FormError :message="form.errors?.warehouse_id" />
|
<FormError :message="form.errors?.warehouse_id" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cantidad -->
|
<!-- Lista de productos -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<div class="flex items-center justify-between mb-2">
|
||||||
CANTIDAD
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase">
|
||||||
|
PRODUCTOS
|
||||||
</label>
|
</label>
|
||||||
<FormInput
|
<button
|
||||||
v-model="form.quantity"
|
type="button"
|
||||||
|
@click="addProduct"
|
||||||
|
class="flex items-center gap-1 px-2 py-1 text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="add" class="text-sm" />
|
||||||
|
Agregar producto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in selectedProducts"
|
||||||
|
:key="index"
|
||||||
|
class="p-3 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-12 gap-3 items-start">
|
||||||
|
<!-- Producto -->
|
||||||
|
<div class="col-span-12 sm:col-span-9">
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Producto
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="item.inventory_id"
|
||||||
|
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar...</option>
|
||||||
|
<option v-for="product in products" :key="product.id" :value="product.id">
|
||||||
|
{{ product.name }} ({{ product.sku }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cantidad -->
|
||||||
|
<div class="col-span-10 sm:col-span-2">
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Cantidad
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="item.quantity"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
|
step="1"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
|
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
<FormError :message="form.errors?.quantity" />
|
</div>
|
||||||
|
|
||||||
|
<!-- Botón eliminar -->
|
||||||
|
<div class="col-span-2 sm:col-span-1">
|
||||||
|
<label class="block text-xs font-medium text-transparent mb-1">.</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="removeProduct(index)"
|
||||||
|
:disabled="selectedProducts.length === 1"
|
||||||
|
class="w-full px-2 py-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:title="selectedProducts.length === 1 ? 'Debe haber al menos un producto' : 'Eliminar producto'"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="delete" class="text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resumen total -->
|
||||||
|
<div v-if="selectedProducts.length > 0" class="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Total de productos: {{ selectedProducts.length }}
|
||||||
|
</span>
|
||||||
|
<span class="font-bold text-red-900 dark:text-red-100">
|
||||||
|
Cantidad total: {{ totalQuantity }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormError :message="form.errors?.products" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Referencia -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
REFERENCIA
|
||||||
|
</label>
|
||||||
|
<FormInput
|
||||||
|
v-model="form.reference"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej: SAL-2026-001"
|
||||||
|
/>
|
||||||
|
<FormError :message="form.errors?.reference" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notas -->
|
<!-- Notas -->
|
||||||
<div class="col-span-2">
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
NOTAS <span class="text-gray-400 text-xs normal-case">(opcional)</span>
|
NOTAS <span class="text-gray-400 text-xs normal-case">(opcional)</span>
|
||||||
</label>
|
</label>
|
||||||
@ -173,13 +293,13 @@ watch(() => props.show, (isShown) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="closeModal"
|
@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"
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="form.processing"
|
:disabled="form.processing || selectedProducts.length === 0"
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-red-600 rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-red-600 rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<GoogleIcon name="remove_circle" class="text-lg" />
|
<GoogleIcon name="remove_circle" class="text-lg" />
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const showExitModal = ref(false);
|
|||||||
const showTransferModal = ref(false);
|
const showTransferModal = ref(false);
|
||||||
const showDetailModal = ref(false);
|
const showDetailModal = ref(false);
|
||||||
const selectedMovementId = ref(null);
|
const selectedMovementId = ref(null);
|
||||||
|
const selectedMovement = ref(null);
|
||||||
|
|
||||||
/** Filtros computados */
|
/** Filtros computados */
|
||||||
const filters = computed(() => {
|
const filters = computed(() => {
|
||||||
@ -36,6 +37,53 @@ const filters = computed(() => {
|
|||||||
return f;
|
return f;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Agrupar movimientos por invoice_reference para entradas múltiples */
|
||||||
|
const groupedMovements = computed(() => {
|
||||||
|
const data = movements.value?.data || [];
|
||||||
|
const grouped = [];
|
||||||
|
const processedRefs = new Set();
|
||||||
|
|
||||||
|
data.forEach(movement => {
|
||||||
|
// Si es una entrada con invoice_reference y no ha sido procesada
|
||||||
|
if (movement.movement_type === 'entry' && movement.invoice_reference && !processedRefs.has(movement.invoice_reference)) {
|
||||||
|
// Buscar todos los movimientos con el mismo invoice_reference
|
||||||
|
const relatedMovements = data.filter(m =>
|
||||||
|
m.movement_type === 'entry' &&
|
||||||
|
m.invoice_reference === movement.invoice_reference
|
||||||
|
);
|
||||||
|
|
||||||
|
if (relatedMovements.length > 1) {
|
||||||
|
// Crear un movimiento agrupado
|
||||||
|
grouped.push({
|
||||||
|
...movement,
|
||||||
|
is_grouped: true,
|
||||||
|
grouped_count: relatedMovements.length,
|
||||||
|
products: relatedMovements.map(m => ({
|
||||||
|
inventory: m.inventory,
|
||||||
|
quantity: m.quantity,
|
||||||
|
unit_cost: m.unit_cost || 0,
|
||||||
|
movement_id: m.id
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
processedRefs.add(movement.invoice_reference);
|
||||||
|
} else {
|
||||||
|
// Es una entrada individual
|
||||||
|
grouped.push(movement);
|
||||||
|
}
|
||||||
|
} else if (movement.movement_type !== 'entry' || !movement.invoice_reference) {
|
||||||
|
// Otros tipos de movimientos o entradas sin invoice_reference
|
||||||
|
if (!processedRefs.has(movement.invoice_reference)) {
|
||||||
|
grouped.push(movement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...movements.value,
|
||||||
|
data: grouped
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
/** Tipos de movimiento */
|
/** Tipos de movimiento */
|
||||||
const movementTypes = [
|
const movementTypes = [
|
||||||
{ value: '', label: 'Todos', icon: 'list', active: 'bg-gray-600 text-white shadow-sm', inactive: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-900/50' },
|
{ value: '', label: 'Todos', icon: 'list', active: 'bg-gray-600 text-white shadow-sm', inactive: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-900/50' },
|
||||||
@ -95,12 +143,14 @@ const selectType = (type) => {
|
|||||||
|
|
||||||
const openDetail = (movement) => {
|
const openDetail = (movement) => {
|
||||||
selectedMovementId.value = movement.id;
|
selectedMovementId.value = movement.id;
|
||||||
|
selectedMovement.value = movement;
|
||||||
showDetailModal.value = true;
|
showDetailModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDetailModal = () => {
|
const closeDetailModal = () => {
|
||||||
showDetailModal.value = false;
|
showDetailModal.value = false;
|
||||||
selectedMovementId.value = null;
|
selectedMovementId.value = null;
|
||||||
|
selectedMovement.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMovementCreated = () => {
|
const onMovementCreated = () => {
|
||||||
@ -213,7 +263,7 @@ onMounted(() => {
|
|||||||
<!-- Tabla -->
|
<!-- Tabla -->
|
||||||
<div class="pt-2 w-full">
|
<div class="pt-2 w-full">
|
||||||
<Table
|
<Table
|
||||||
:items="movements"
|
:items="groupedMovements"
|
||||||
@send-pagination="(page) => searcher.pagination(page, filters)"
|
@send-pagination="(page) => searcher.pagination(page, filters)"
|
||||||
>
|
>
|
||||||
<template #head>
|
<template #head>
|
||||||
@ -233,8 +283,23 @@ onMounted(() => {
|
|||||||
@click="openDetail(movement)"
|
@click="openDetail(movement)"
|
||||||
>
|
>
|
||||||
<td class="px-6 py-4 text-left">
|
<td class="px-6 py-4 text-left">
|
||||||
|
<!-- Múltiples productos -->
|
||||||
|
<div v-if="movement.products && movement.products.length > 0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<GoogleIcon name="inventory" class="text-indigo-600 dark:text-indigo-400 text-lg" />
|
||||||
|
<p class="text-sm font-semibold text-indigo-900 dark:text-indigo-100">
|
||||||
|
Productos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{{ movement.products.length }} producto(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Producto individual -->
|
||||||
|
<div v-else>
|
||||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.inventory?.name || 'N/A' }}</p>
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.inventory?.name || 'N/A' }}</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono">{{ movement.inventory?.sku || '' }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono">{{ movement.inventory?.sku || '' }}</p>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<span :class="['inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium', getTypeBadge(movement.movement_type).class]">
|
<span :class="['inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium', getTypeBadge(movement.movement_type).class]">
|
||||||
@ -242,7 +307,12 @@ onMounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<p class="text-sm font-bold text-gray-900 dark:text-gray-100">{{ movement.quantity }}</p>
|
<!-- Cantidad total para múltiples productos -->
|
||||||
|
<p v-if="movement.products && movement.products.length > 0" class="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ movement.products.reduce((sum, p) => sum + Number(p.quantity), 0) }}
|
||||||
|
</p>
|
||||||
|
<!-- Cantidad individual -->
|
||||||
|
<p v-else class="text-sm font-bold text-gray-900 dark:text-gray-100">{{ movement.quantity }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<p v-if="movement.warehouse_from" class="text-sm text-gray-700 dark:text-gray-300">{{ movement.warehouse_from.name }}</p>
|
<p v-if="movement.warehouse_from" class="text-sm text-gray-700 dark:text-gray-300">{{ movement.warehouse_from.name }}</p>
|
||||||
@ -298,6 +368,7 @@ onMounted(() => {
|
|||||||
<DetailModal
|
<DetailModal
|
||||||
:show="showDetailModal"
|
:show="showDetailModal"
|
||||||
:movement-id="selectedMovementId"
|
:movement-id="selectedMovementId"
|
||||||
|
:movement-data="selectedMovement"
|
||||||
@close="closeDetailModal"
|
@close="closeDetailModal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,16 +19,17 @@ const props = defineProps({
|
|||||||
const products = ref([]);
|
const products = ref([]);
|
||||||
const warehouses = ref([]);
|
const warehouses = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const selectedProducts = ref([]);
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
/** Formulario */
|
/** Formulario */
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
inventory_id: '',
|
|
||||||
warehouse_from_id: '',
|
warehouse_from_id: '',
|
||||||
warehouse_to_id: '',
|
warehouse_to_id: '',
|
||||||
quantity: '',
|
reference: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
products: []
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Computed */
|
/** Computed */
|
||||||
@ -36,16 +37,14 @@ const availableDestinations = computed(() => {
|
|||||||
return warehouses.value.filter(wh => wh.id !== Number(form.warehouse_from_id));
|
return warehouses.value.filter(wh => wh.id !== Number(form.warehouse_from_id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const totalQuantity = computed(() => {
|
||||||
|
return selectedProducts.value.reduce((sum, item) => sum + Number(item.quantity), 0);
|
||||||
|
});
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
const loadData = () => {
|
const loadData = () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
api.get(apiURL('inventario'), {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
products.value = data.products?.data || data.products || [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
api.get(apiURL('almacenes'), {
|
api.get(apiURL('almacenes'), {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
warehouses.value = data.warehouses?.data || data.data || [];
|
warehouses.value = data.warehouses?.data || data.data || [];
|
||||||
@ -56,7 +55,42 @@ const loadData = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadProducts = (warehouseId) => {
|
||||||
|
if (!warehouseId) {
|
||||||
|
products.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
api.get(apiURL(`inventario/almacen/${warehouseId}`), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
products.value = data.products || [];
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addProduct = () => {
|
||||||
|
selectedProducts.value.push({
|
||||||
|
inventory_id: '',
|
||||||
|
quantity: 1
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeProduct = (index) => {
|
||||||
|
selectedProducts.value.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
const createTransfer = () => {
|
const createTransfer = () => {
|
||||||
|
// Preparar datos del formulario
|
||||||
|
form.products = selectedProducts.value.map(item => ({
|
||||||
|
inventory_id: item.inventory_id,
|
||||||
|
quantity: Number(item.quantity)
|
||||||
|
}));
|
||||||
|
|
||||||
form.post(apiURL('movimientos/traspaso'), {
|
form.post(apiURL('movimientos/traspaso'), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
window.Notify.success('Traspaso registrado correctamente');
|
window.Notify.success('Traspaso registrado correctamente');
|
||||||
@ -74,6 +108,7 @@ const createTransfer = () => {
|
|||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
selectedProducts.value = [];
|
||||||
emit('close');
|
emit('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -81,14 +116,28 @@ const closeModal = () => {
|
|||||||
watch(() => props.show, (isShown) => {
|
watch(() => props.show, (isShown) => {
|
||||||
if (isShown) {
|
if (isShown) {
|
||||||
loadData();
|
loadData();
|
||||||
|
if (selectedProducts.value.length === 0) {
|
||||||
|
addProduct();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Limpiar destino si cambia el origen
|
// Limpiar destino si cambia el origen
|
||||||
watch(() => form.warehouse_from_id, () => {
|
watch(() => form.warehouse_from_id, (newWarehouseId, oldWarehouseId) => {
|
||||||
if (form.warehouse_to_id && Number(form.warehouse_to_id) === Number(form.warehouse_from_id)) {
|
if (form.warehouse_to_id && Number(form.warehouse_to_id) === Number(newWarehouseId)) {
|
||||||
form.warehouse_to_id = '';
|
form.warehouse_to_id = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cargar productos del almacén de origen
|
||||||
|
if (newWarehouseId !== oldWarehouseId) {
|
||||||
|
loadProducts(newWarehouseId);
|
||||||
|
|
||||||
|
// Limpiar productos seleccionados si había alguno y se cambió el almacén
|
||||||
|
if (oldWarehouseId && selectedProducts.value.length > 0) {
|
||||||
|
selectedProducts.value = [];
|
||||||
|
addProduct();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -117,24 +166,9 @@ watch(() => form.warehouse_from_id, () => {
|
|||||||
|
|
||||||
<!-- Formulario -->
|
<!-- Formulario -->
|
||||||
<form @submit.prevent="createTransfer" class="space-y-4">
|
<form @submit.prevent="createTransfer" class="space-y-4">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Almacenes -->
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<!-- Producto -->
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
|
||||||
PRODUCTO
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
v-model="form.inventory_id"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
||||||
>
|
|
||||||
<option value="">Seleccionar producto...</option>
|
|
||||||
<option v-for="product in products" :key="product.id" :value="product.id">
|
|
||||||
{{ product.name }} ({{ product.sku }})
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<FormError :message="form.errors?.inventory_id" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Almacén origen -->
|
<!-- Almacén origen -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
@ -168,23 +202,109 @@ watch(() => form.warehouse_from_id, () => {
|
|||||||
</select>
|
</select>
|
||||||
<FormError :message="form.errors?.warehouse_to_id" />
|
<FormError :message="form.errors?.warehouse_to_id" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista de productos -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase">
|
||||||
|
PRODUCTOS
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="addProduct"
|
||||||
|
class="flex items-center gap-1 px-2 py-1 text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="add" class="text-sm" />
|
||||||
|
Agregar producto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in selectedProducts"
|
||||||
|
:key="index"
|
||||||
|
class="p-3 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-12 gap-3 items-start">
|
||||||
|
<!-- Producto -->
|
||||||
|
<div class="col-span-12 sm:col-span-9">
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Producto
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="item.inventory_id"
|
||||||
|
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar...</option>
|
||||||
|
<option v-for="product in products" :key="product.id" :value="product.id">
|
||||||
|
{{ product.name }} ({{ product.sku }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Cantidad -->
|
<!-- Cantidad -->
|
||||||
<div class="col-span-2">
|
<div class="col-span-10 sm:col-span-2">
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
CANTIDAD
|
Cantidad
|
||||||
</label>
|
</label>
|
||||||
<FormInput
|
<input
|
||||||
v-model="form.quantity"
|
v-model="item.quantity"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
|
step="1"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
|
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
<FormError :message="form.errors?.quantity" />
|
</div>
|
||||||
|
|
||||||
|
<!-- Botón eliminar -->
|
||||||
|
<div class="col-span-2 sm:col-span-1">
|
||||||
|
<label class="block text-xs font-medium text-transparent mb-1">.</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="removeProduct(index)"
|
||||||
|
:disabled="selectedProducts.length === 1"
|
||||||
|
class="w-full px-2 py-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:title="selectedProducts.length === 1 ? 'Debe haber al menos un producto' : 'Eliminar producto'"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="delete" class="text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resumen total -->
|
||||||
|
<div v-if="selectedProducts.length > 0" class="mt-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Total de productos: {{ selectedProducts.length }}
|
||||||
|
</span>
|
||||||
|
<span class="font-bold text-blue-900 dark:text-blue-100">
|
||||||
|
Cantidad total: {{ totalQuantity }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormError :message="form.errors?.products" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Referencia -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
REFERENCIA
|
||||||
|
</label>
|
||||||
|
<FormInput
|
||||||
|
v-model="form.reference"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej: TRA-2026-001"
|
||||||
|
/>
|
||||||
|
<FormError :message="form.errors?.reference" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notas -->
|
<!-- Notas -->
|
||||||
<div class="col-span-2">
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
NOTAS <span class="text-gray-400 text-xs normal-case">(opcional)</span>
|
NOTAS <span class="text-gray-400 text-xs normal-case">(opcional)</span>
|
||||||
</label>
|
</label>
|
||||||
@ -203,13 +323,13 @@ watch(() => form.warehouse_from_id, () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="closeModal"
|
@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"
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="form.processing"
|
:disabled="form.processing || selectedProducts.length === 0"
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<GoogleIcon name="swap_horiz" class="text-lg" />
|
<GoogleIcon name="swap_horiz" class="text-lg" />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user