diff --git a/src/components/POS/BundleSerialSelector.vue b/src/components/POS/BundleSerialSelector.vue new file mode 100644 index 0000000..0eb3732 --- /dev/null +++ b/src/components/POS/BundleSerialSelector.vue @@ -0,0 +1,296 @@ + + + diff --git a/src/components/POS/SerialInputList.vue b/src/components/POS/SerialInputList.vue new file mode 100644 index 0000000..b8240cd --- /dev/null +++ b/src/components/POS/SerialInputList.vue @@ -0,0 +1,174 @@ + + + diff --git a/src/layouts/AppLayout.vue b/src/layouts/AppLayout.vue index 9382575..871f77f 100644 --- a/src/layouts/AppLayout.vue +++ b/src/layouts/AppLayout.vue @@ -48,10 +48,15 @@ onMounted(() => { name="Catálogos" > + { to="pos.bundles.index" /> { to="pos.suppliers.index" /> - props.bundle, (bundle) => { /> - - -
- -
diff --git a/src/pages/POS/Inventory/Serials.vue b/src/pages/POS/Inventory/Serials.vue index cbb665d..22b09bc 100644 --- a/src/pages/POS/Inventory/Serials.vue +++ b/src/pages/POS/Inventory/Serials.vue @@ -2,11 +2,8 @@ import { onMounted, ref, computed } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { useSearcher, apiURL } from '@Services/Api'; -import serialService from '@Services/serialService'; - import SearcherHead from '@Holos/Searcher.vue'; import Table from '@Holos/Table.vue'; -import Modal from '@Holos/Modal.vue'; import GoogleIcon from '@Shared/GoogleIcon.vue'; const route = useRoute(); @@ -18,10 +15,6 @@ const inventory = ref(null); const serials = ref({ data: [], total: 0 }); const activeTab = ref('disponible'); -// Modales -const showDeleteModal = ref(false); -const deletingSerial = ref(null); - /** Buscador */ const searcher = useSearcher({ url: apiURL(`inventario/${route.params.id}/serials`), @@ -55,28 +48,6 @@ const switchTab = (tab) => { loadSerials({ q: searcher.query }); }; -// Eliminar serial -const openDeleteModal = (serial) => { - deletingSerial.value = serial; - showDeleteModal.value = true; -}; - -const closeDeleteModal = () => { - showDeleteModal.value = false; - deletingSerial.value = null; -}; - -const confirmDelete = async () => { - try { - await serialService.deleteSerial(inventoryId.value, deletingSerial.value.id); - window.Notify.success('Número de serie eliminado'); - closeDeleteModal(); - loadSerials(); - } catch (error) { - window.Notify.error('Error al eliminar el número de serie'); - } -}; - // Navegación const goBack = () => { router.go(-1); @@ -195,11 +166,11 @@ onMounted(() => { @send-pagination="(page) => searcher.pagination(page, { status: activeTab })" > diff --git a/src/pages/POS/Movements/Edit.vue b/src/pages/POS/Movements/Edit.vue index b12b472..e645e1b 100644 --- a/src/pages/POS/Movements/Edit.vue +++ b/src/pages/POS/Movements/Edit.vue @@ -7,6 +7,7 @@ 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 SerialInputList from '@Components/POS/SerialInputList.vue'; /** Eventos */ const emit = defineEmits(['close', 'updated']); @@ -21,6 +22,8 @@ const props = defineProps({ const warehouses = ref([]); const suppliers = ref([]); const loading = ref(false); +const serialsText = ref(''); +const serialNumbers = ref([]); const api = useApi(); /** Formulario */ @@ -37,7 +40,7 @@ const form = useForm({ }); /** Estado para manejo de seriales */ -const serialsText = ref(''); // Texto editable (uno por línea) +const serialsList = ref([]); // Array de { serial_number, locked } /** Computed */ const movementTypeInfo = computed(() => { @@ -80,9 +83,8 @@ const hasSerials = computed(() => { }); const serialsArray = computed(() => { - return serialsText.value - .split('\n') - .map(s => s.trim()) + return serialsList.value + .map(s => s.serial_number.trim()) .filter(s => s.length > 0); }); @@ -174,19 +176,9 @@ const updateMovement = () => { data.warehouse_to_id = form.destination_warehouse_id; } - console.log('📤 Datos a enviar al backend:', { - movement_id: props.movement.id, - data: data, - serial_numbers_count: data.serial_numbers?.length || 0, - hasSerials: hasSerials.value - }); - api.put(apiURL(`movimientos/${props.movement.id}`), { data, onSuccess: (response) => { - console.log('✅ Respuesta del backend (completa):', JSON.stringify(response, null, 2)); - console.log('✅ Tipo de respuesta:', typeof response); - console.log('✅ Keys:', Object.keys(response || {})); window.Notify.success('Movimiento actualizado correctamente'); emit('updated'); closeModal(); @@ -219,18 +211,18 @@ watch(() => props.show, (isShown) => { form.notes = props.movement.notes || ''; // Cargar números de serie si existen - let serialNumbers = []; - - if (props.movement.serial_numbers && props.movement.serial_numbers.length > 0) { - serialNumbers = props.movement.serial_numbers; - } else if (props.movement.serials && props.movement.serials.length > 0) { - serialNumbers = props.movement.serials.map(s => s.serial_number); - } - - if (serialNumbers.length > 0) { - serialsText.value = serialNumbers.join('\n'); + if (props.movement.serials && props.movement.serials.length > 0) { + serialsList.value = props.movement.serials.map(s => ({ + serial_number: s.serial_number, + locked: s.status === 'vendido' + })); + } else if (props.movement.serial_numbers && props.movement.serial_numbers.length > 0) { + serialsList.value = props.movement.serial_numbers.map(sn => ({ + serial_number: sn, + locked: false + })); } else { - serialsText.value = ''; + serialsList.value = [{ serial_number: '', locked: false }]; } // Almacenes según tipo @@ -242,15 +234,6 @@ watch(() => props.show, (isShown) => { form.origin_warehouse_id = props.movement.origin_warehouse_id || ''; form.destination_warehouse_id = props.movement.destination_warehouse_id || ''; } - console.log('🔍 Debug Movement:', { - movement: props.movement, - has_inventory: !!props.movement.inventory, - track_serials: props.movement?.inventory?.track_serials, - serials_relation: props.movement.serials, - hasSerials_computed: hasSerials.value, - serialNumbers_loaded: serialNumbers, - serialsText_final: serialsText.value - }); } } else { // Limpiar seriales al cerrar @@ -338,26 +321,15 @@ watch(() => props.show, (isShown) => { NÚMEROS DE SERIE -

- Ingresa un número de serie por línea. Debe coincidir con la cantidad ({{ form.quantity }}). -

- + - +

{{ serialsArray.length }} de {{ form.quantity }} seriales

{{ serialsValidation.message }} diff --git a/src/pages/POS/Movements/EntryModal.vue b/src/pages/POS/Movements/EntryModal.vue index 0c2a260..19d636c 100644 --- a/src/pages/POS/Movements/EntryModal.vue +++ b/src/pages/POS/Movements/EntryModal.vue @@ -7,6 +7,7 @@ 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 SerialInputList from '@Components/POS/SerialInputList.vue'; /** Eventos */ const emit = defineEmits(['close', 'created']); @@ -101,7 +102,7 @@ const addProduct = () => { track_serials: false, unit_of_measure: null, allows_decimals: false, - serial_numbers_text: '', // Texto con seriales separados por líneas + serial_numbers_list: [{ serial_number: '', locked: false }], // Inputs individuales de seriales serial_validation_error: '' }); }; @@ -116,7 +117,7 @@ const onProductInput = (index) => { productNotFound.value = false; const searchValue = productSearch.value?.trim(); - if (!searchValue || searchValue.length < 2) { + if (!searchValue || searchValue.length < 1) { productSuggestions.value = []; showProductSuggestions.value = false; return; @@ -180,7 +181,7 @@ const selectProduct = (product) => { // Limpiar seriales si la unidad permite decimales if (product.unit_of_measure?.allows_decimals) { - selectedProducts.value[currentSearchIndex.value].serial_numbers_text = ''; + selectedProducts.value[currentSearchIndex.value].serial_numbers_list = [{ serial_number: '', locked: false }]; } } @@ -195,30 +196,22 @@ const selectProduct = (product) => { // Contar seriales ingresados (solo para mostrar feedback visual) const countSerials = (item) => { - if (!item.serial_numbers_text) return 0; - const serials = item.serial_numbers_text - .split('\n') - .map(s => s.trim()) - .filter(s => s.length > 0) - .filter((s, index, self) => self.indexOf(s) === index); // Eliminar duplicados (igual que createEntry) - return serials.length; + if (!item.serial_numbers_list) return 0; + return item.serial_numbers_list.filter(s => s.serial_number.trim()).length; }; -// Actualizar cantidad según seriales ingresados +// Actualizar cantidad según seriales ingresados (llamado por el watcher del v-model) const updateQuantityFromSerials = (item) => { // Solo actualizar cantidad automáticamente si el producto requiere seriales y puede usarlos if (!item.track_serials || !canUseSerials(item)) { return; } - // Contar seriales válidos (sin líneas vacías) const serialCount = countSerials(item); - // Actualizar cantidad siempre basado en el conteo actual if (serialCount > 0) { item.quantity = serialCount; } else { - // Si no hay seriales, resetear a 1 item.quantity = 1; } }; @@ -233,14 +226,12 @@ const createEntry = () => { serial_numbers: [] // Inicializar siempre como array vacío }; - // Agregar seriales solo si la unidad lo permite y hay texto ingresado - if (canUseSerials(item) && item.serial_numbers_text && item.serial_numbers_text.trim()) { - // Limpiar y filtrar seriales - eliminar líneas vacías, espacios, y duplicados - const serials = item.serial_numbers_text - .split('\n') - .map(s => s.trim()) + // Agregar seriales solo si la unidad lo permite y hay seriales ingresados + if (canUseSerials(item) && item.serial_numbers_list) { + const serials = item.serial_numbers_list + .map(s => s.serial_number.trim()) .filter(s => s.length > 0) - .filter((s, index, self) => self.indexOf(s) === index); // Eliminar duplicados locales + .filter((s, index, self) => self.indexOf(s) === index); if (serials.length > 0) { productData.serial_numbers = serials; @@ -410,7 +401,7 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => { :class="{ 'border-red-500 focus:ring-red-500 focus:border-red-500': productNotFound && currentSearchIndex === index }" - :disabled="searchingProduct" + :readonly="searchingProduct" />

@@ -507,7 +498,6 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => { Números de Serie * (opcional) - - uno por línea, debe coincidir con la cantidad @@ -520,18 +510,11 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
- -
- - {{ countSerials(item) }} serial(es) ingresado(s) -
+ @update:model-value="updateQuantityFromSerials(item)" + /> diff --git a/src/pages/POS/Point.vue b/src/pages/POS/Point.vue index 06599fc..e6ea2f0 100644 --- a/src/pages/POS/Point.vue +++ b/src/pages/POS/Point.vue @@ -17,6 +17,7 @@ import CheckoutModal from '@Components/POS/CheckoutModal.vue'; import ClientModal from '@Components/POS/ClientModal.vue'; import QRscan from '@Components/POS/QRscan.vue'; import SerialSelector from '@Components/POS/SerialSelector.vue'; +import BundleSerialSelector from '@Components/POS/BundleSerialSelector.vue'; /** i18n */ const { t } = useI18n(); @@ -41,6 +42,10 @@ const lastSaleData = ref(null); const showSerialSelector = ref(false); const serialSelectorProduct = ref(null); +// Estado para selector de seriales de bundles +const showBundleSerialSelector = ref(false); +const bundleSerialSelectorBundle = ref(null); + /** Buscador de productos */ const searcher = useSearcher({ url: apiURL('inventario'), @@ -110,7 +115,25 @@ const addBundleToCart = async (bundle) => { return; } - // Agregar bundle/kit al carrito + // Verificar si el bundle tiene componentes con seriales + try { + const components = await serialService.getBundleComponents(bundle.id); + const hasSerialComponents = components.some(item => { + const inv = item.inventory || item.product || {}; + return inv.track_serials || item.track_serials; + }); + + if (hasSerialComponents) { + // Abrir selector de seriales para el bundle + bundleSerialSelectorBundle.value = bundle; + showBundleSerialSelector.value = true; + return; + } + } catch { + // Si falla la consulta de componentes, agregar sin seriales + } + + // Agregar bundle/kit al carrito sin seriales cart.addBundle(bundle); window.Notify.success(`${bundle.name} agregado al carrito`); } catch (error) { @@ -160,6 +183,19 @@ const handleSerialConfirm = (serialConfig) => { closeSerialSelector(); }; +const closeBundleSerialSelector = () => { + showBundleSerialSelector.value = false; + bundleSerialSelectorBundle.value = null; +}; + +const handleBundleSerialConfirm = (serialConfig) => { + if (!bundleSerialSelectorBundle.value) return; + + cart.addBundle(bundleSerialSelectorBundle.value, serialConfig); + window.Notify.success(`${bundleSerialSelectorBundle.value.name} agregado al carrito`); + closeBundleSerialSelector(); +}; + const handleClearCart = () => { if (confirm(t('cart.clearConfirm'))) { cart.clear(); @@ -328,11 +364,15 @@ const handleConfirmSale = async (paymentData) => { payment_method: paymentData.paymentMethod, items: cart.items.map(item => { if (item.is_bundle) { - return { + const bundleItem = { type: 'bundle', bundle_id: item.bundle_id, quantity: item.quantity, }; + if (item.track_serials && item.serial_numbers && Object.keys(item.serial_numbers).length > 0) { + bundleItem.serial_numbers = item.serial_numbers; + } + return bundleItem; } return { type: 'product', @@ -801,5 +841,15 @@ watch(activeTab, (newTab) => { @close="closeSerialSelector" @confirm="handleSerialConfirm" /> + + + diff --git a/src/pages/POS/Returns/ReturnDetail.vue b/src/pages/POS/Returns/ReturnDetail.vue index 70755cb..1419bbf 100644 --- a/src/pages/POS/Returns/ReturnDetail.vue +++ b/src/pages/POS/Returns/ReturnDetail.vue @@ -91,21 +91,6 @@ const handleClose = () => { emit('close'); }; -const handleCancelReturn = async () => { - if (!confirm('¿Cancelar esta devolución? Los productos volverán a estado vendido y el stock se revertirá.')) { - return; - } - - try { - const response = await returnsService.cancelReturn(props.returnData.id); - window.Notify.success(response.message || 'Devolución cancelada exitosamente'); - emit('cancelled'); - emit('close'); - } catch (error) { - console.error('Error al cancelar devolución:', error); - window.Notify.error('Error al cancelar la devolución'); - } -};