add: numero de serie
This commit is contained in:
parent
7a28a35f60
commit
46b155c2c8
390
src/components/POS/SerialSelector.vue
Normal file
390
src/components/POS/SerialSelector.vue
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import Modal from '@Holos/Modal.vue';
|
||||||
|
import serialService from '@Services/serialService';
|
||||||
|
|
||||||
|
/** Props */
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
product: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
quantity: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
excludeSerials: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emits */
|
||||||
|
const emit = defineEmits(['close', 'confirm']);
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const loading = ref(false);
|
||||||
|
const availableSerials = ref([]);
|
||||||
|
const selectedSerials = ref([]);
|
||||||
|
const selectionMode = ref('auto'); // 'auto' | 'manual'
|
||||||
|
const searchQuery = ref('');
|
||||||
|
|
||||||
|
/** Computados */
|
||||||
|
const filteredSerials = computed(() => {
|
||||||
|
if (!searchQuery.value) {
|
||||||
|
return availableSerials.value;
|
||||||
|
}
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
return availableSerials.value.filter(serial =>
|
||||||
|
serial.serial_number.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedCount = computed(() => selectedSerials.value.length);
|
||||||
|
|
||||||
|
const isComplete = computed(() => {
|
||||||
|
if (selectionMode.value === 'auto') {
|
||||||
|
return availableSerials.value.length >= props.quantity;
|
||||||
|
}
|
||||||
|
return selectedSerials.value.length === props.quantity;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canConfirm = computed(() => {
|
||||||
|
if (selectionMode.value === 'auto') {
|
||||||
|
return availableSerials.value.length >= props.quantity;
|
||||||
|
}
|
||||||
|
return selectedSerials.value.length === props.quantity;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasEnoughStock = computed(() => {
|
||||||
|
return availableSerials.value.length >= props.quantity;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const loadSerials = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await serialService.getAvailableSerials(props.product.id);
|
||||||
|
// Filtrar seriales que ya están en el carrito
|
||||||
|
availableSerials.value = (response.serials?.data || []).filter(
|
||||||
|
serial => !props.excludeSerials.includes(serial.serial_number)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading serials:', error);
|
||||||
|
availableSerials.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSerial = (serial) => {
|
||||||
|
const index = selectedSerials.value.findIndex(s => s.id === serial.id);
|
||||||
|
if (index > -1) {
|
||||||
|
selectedSerials.value.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
if (selectedSerials.value.length < props.quantity) {
|
||||||
|
selectedSerials.value.push(serial);
|
||||||
|
} else {
|
||||||
|
Notify.warning(`Solo puedes seleccionar ${props.quantity} serial(es)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelected = (serial) => {
|
||||||
|
return selectedSerials.value.some(s => s.id === serial.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
selectedSerials.value = availableSerials.value.slice(0, props.quantity);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
selectedSerials.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
let serialNumbers = [];
|
||||||
|
|
||||||
|
if (selectionMode.value === 'auto') {
|
||||||
|
// En modo automático, el backend asignará los seriales
|
||||||
|
serialNumbers = null;
|
||||||
|
} else {
|
||||||
|
// En modo manual, enviamos los seriales seleccionados
|
||||||
|
serialNumbers = selectedSerials.value.map(s => s.serial_number);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('confirm', {
|
||||||
|
selectionMode: selectionMode.value,
|
||||||
|
serialNumbers: serialNumbers
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
selectedSerials.value = [];
|
||||||
|
searchQuery.value = '';
|
||||||
|
selectionMode.value = 'auto';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Watchers */
|
||||||
|
watch(() => props.show, (isShown) => {
|
||||||
|
if (isShown) {
|
||||||
|
resetState();
|
||||||
|
loadSerials();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(selectionMode, (newMode) => {
|
||||||
|
if (newMode === 'auto') {
|
||||||
|
selectedSerials.value = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :show="show" max-width="lg" @close="handleClose">
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||||
|
<GoogleIcon name="qr_code_2" class="text-2xl text-emerald-600 dark:text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Seleccionar Números de Serie
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ product.name }} - Cantidad: {{ quantity }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="handleClose"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="close" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||||
|
<GoogleIcon name="hourglass_empty" class="text-4xl text-gray-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sin seriales disponibles -->
|
||||||
|
<div v-else-if="!hasEnoughStock" class="text-center py-8">
|
||||||
|
<GoogleIcon name="error" class="text-5xl text-red-400 mb-3" />
|
||||||
|
<p class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Stock insuficiente
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
Solo hay {{ availableSerials.length }} serial(es) disponible(s), pero necesitas {{ quantity }}.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
@click="handleClose"
|
||||||
|
class="mt-4 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<!-- Modo de selección -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Modo de asignación
|
||||||
|
</h4>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative flex items-center gap-3 p-4 rounded-xl border-2 transition-all"
|
||||||
|
:class="{
|
||||||
|
'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20 ring-2 ring-emerald-500': selectionMode === 'auto',
|
||||||
|
'border-gray-200 dark:border-gray-700 hover:border-gray-300': selectionMode !== 'auto'
|
||||||
|
}"
|
||||||
|
@click="selectionMode = 'auto'"
|
||||||
|
>
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-emerald-500 flex items-center justify-center">
|
||||||
|
<GoogleIcon name="auto_awesome" class="text-xl text-white" />
|
||||||
|
</div>
|
||||||
|
<div class="text-left">
|
||||||
|
<p class="text-sm font-bold text-gray-800 dark:text-gray-200">
|
||||||
|
Automático
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Asignar primeros disponibles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectionMode === 'auto'" class="absolute top-2 right-2">
|
||||||
|
<GoogleIcon name="check_circle" class="text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative flex items-center gap-3 p-4 rounded-xl border-2 transition-all"
|
||||||
|
:class="{
|
||||||
|
'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 ring-2 ring-indigo-500': selectionMode === 'manual',
|
||||||
|
'border-gray-200 dark:border-gray-700 hover:border-gray-300': selectionMode !== 'manual'
|
||||||
|
}"
|
||||||
|
@click="selectionMode = 'manual'"
|
||||||
|
>
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-indigo-500 flex items-center justify-center">
|
||||||
|
<GoogleIcon name="touch_app" class="text-xl text-white" />
|
||||||
|
</div>
|
||||||
|
<div class="text-left">
|
||||||
|
<p class="text-sm font-bold text-gray-800 dark:text-gray-200">
|
||||||
|
Manual
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Elegir seriales específicos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectionMode === 'manual'" class="absolute top-2 right-2">
|
||||||
|
<GoogleIcon name="check_circle" class="text-indigo-500" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selección manual -->
|
||||||
|
<div v-if="selectionMode === 'manual'" class="space-y-4">
|
||||||
|
<!-- Buscador y acciones -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex-1 relative">
|
||||||
|
<GoogleIcon
|
||||||
|
name="search"
|
||||||
|
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar número de serie..."
|
||||||
|
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="selectedCount < quantity"
|
||||||
|
@click="selectAll"
|
||||||
|
class="px-3 py-2 text-xs font-medium text-indigo-600 bg-indigo-50 rounded-lg hover:bg-indigo-100 transition-colors"
|
||||||
|
>
|
||||||
|
Seleccionar primeros {{ quantity }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="selectedCount > 0"
|
||||||
|
@click="clearSelection"
|
||||||
|
class="px-3 py-2 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Limpiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contador -->
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">
|
||||||
|
{{ availableSerials.length }} serial(es) disponible(s)
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="font-semibold"
|
||||||
|
:class="{
|
||||||
|
'text-green-600': selectedCount === quantity,
|
||||||
|
'text-amber-600': selectedCount > 0 && selectedCount < quantity,
|
||||||
|
'text-gray-500': selectedCount === 0
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ selectedCount }} / {{ quantity }} seleccionado(s)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista de seriales -->
|
||||||
|
<div class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
<button
|
||||||
|
v-for="serial in filteredSerials"
|
||||||
|
:key="serial.id"
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-3 p-3 text-left transition-colors"
|
||||||
|
:class="{
|
||||||
|
'bg-indigo-50 dark:bg-indigo-900/20': isSelected(serial),
|
||||||
|
'hover:bg-gray-50 dark:hover:bg-gray-800': !isSelected(serial)
|
||||||
|
}"
|
||||||
|
@click="toggleSerial(serial)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-5 h-5 rounded border-2 flex items-center justify-center transition-colors"
|
||||||
|
:class="{
|
||||||
|
'border-indigo-500 bg-indigo-500': isSelected(serial),
|
||||||
|
'border-gray-300 dark:border-gray-600': !isSelected(serial)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<GoogleIcon
|
||||||
|
v-if="isSelected(serial)"
|
||||||
|
name="check"
|
||||||
|
class="text-xs text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ serial.serial_number }}
|
||||||
|
</p>
|
||||||
|
<p v-if="serial.notes" class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{{ serial.notes }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Sin resultados -->
|
||||||
|
<div v-if="filteredSerials.length === 0" class="p-8 text-center text-gray-500">
|
||||||
|
<GoogleIcon name="search_off" class="text-3xl mb-2" />
|
||||||
|
<p class="text-sm">No se encontraron seriales</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info modo automático -->
|
||||||
|
<div v-else class="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-lg p-4">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<GoogleIcon name="info" class="text-emerald-600 dark:text-emerald-400 text-xl shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-emerald-800 dark:text-emerald-300">
|
||||||
|
Asignación automática
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">
|
||||||
|
Se asignarán automáticamente los primeros {{ quantity }} número(s) de serie disponible(s) al confirmar la venta.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div v-if="hasEnoughStock && !loading" class="flex items-center justify-end gap-3 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="handleClose"
|
||||||
|
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 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="handleConfirm"
|
||||||
|
:disabled="!canConfirm"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="check" class="text-lg" />
|
||||||
|
Confirmar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@ -1,10 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { useForm, apiURL } from '@Services/Api';
|
import { useForm, apiURL } from '@Services/Api';
|
||||||
|
|
||||||
import Modal from '@Holos/Modal.vue';
|
import Modal from '@Holos/Modal.vue';
|
||||||
import FormInput from '@Holos/Form/Input.vue';
|
import FormInput from '@Holos/Form/Input.vue';
|
||||||
import FormError from '@Holos/Form/Elements/Error.vue';
|
import FormError from '@Holos/Form/Elements/Error.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
/** Eventos */
|
/** Eventos */
|
||||||
const emit = defineEmits(['close', 'updated']);
|
const emit = defineEmits(['close', 'updated']);
|
||||||
@ -66,6 +70,11 @@ const closeModal = () => {
|
|||||||
emit('close');
|
emit('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openSerials = () => {
|
||||||
|
closeModal();
|
||||||
|
router.push({ name: 'pos.inventory.serials', params: { id: props.product.id } });
|
||||||
|
};
|
||||||
|
|
||||||
/** Observadores */
|
/** Observadores */
|
||||||
watch(() => props.product, (newProduct) => {
|
watch(() => props.product, (newProduct) => {
|
||||||
if (newProduct) {
|
if (newProduct) {
|
||||||
@ -238,7 +247,16 @@ watch(() => props.show, (newValue) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Botones -->
|
<!-- Botones -->
|
||||||
<div class="flex items-center justify-end gap-3 mt-6">
|
<div class="flex items-center justify-between mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="openSerials"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-lg hover:bg-emerald-100 transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="qr_code_2" class="text-lg" />
|
||||||
|
Gestionar Seriales
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="closeModal"
|
@click="closeModal"
|
||||||
@ -255,6 +273,7 @@ watch(() => props.show, (newValue) => {
|
|||||||
<span v-else>Actualizar</span>
|
<span v-else>Actualizar</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { useSearcher, apiURL } from '@Services/Api';
|
import { useSearcher, apiURL } from '@Services/Api';
|
||||||
import { formatCurrency } from '@/utils/formatters';
|
import { formatCurrency } from '@/utils/formatters';
|
||||||
import { can } from './Module.js';
|
import { can } from './Module.js';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
import SearcherHead from '@Holos/Searcher.vue';
|
import SearcherHead from '@Holos/Searcher.vue';
|
||||||
import Table from '@Holos/Table.vue';
|
import Table from '@Holos/Table.vue';
|
||||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
@ -76,6 +79,10 @@ const onProductsImported = () => {
|
|||||||
searcher.search();
|
searcher.search();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openSerials = (product) => {
|
||||||
|
router.push({ name: 'pos.inventory.serials', params: { id: product.id } });
|
||||||
|
};
|
||||||
|
|
||||||
const confirmDelete = async (id) => {
|
const confirmDelete = async (id) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(apiURL(`inventario/${id}`), {
|
const response = await fetch(apiURL(`inventario/${id}`), {
|
||||||
@ -188,6 +195,13 @@ onMounted(() => {
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
<div class="flex items-center justify-center gap-2">
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="openSerials(model)"
|
||||||
|
class="text-emerald-600 hover:text-emerald-900 dark:text-emerald-400 dark:hover:text-emerald-300 transition-colors"
|
||||||
|
title="Gestionar números de serie"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="qr_code_2" class="text-xl" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="can('edit')"
|
v-if="can('edit')"
|
||||||
@click="openEditModal(model)"
|
@click="openEditModal(model)"
|
||||||
|
|||||||
609
src/pages/POS/Inventory/Serials.vue
Normal file
609
src/pages/POS/Inventory/Serials.vue
Normal file
@ -0,0 +1,609 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, computed } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useSearcher, useForm, 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 FormInput from '@Holos/Form/Input.vue';
|
||||||
|
import FormError from '@Holos/Form/Elements/Error.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const inventoryId = computed(() => route.params.id);
|
||||||
|
const inventory = ref(null);
|
||||||
|
const serials = ref({ data: [], total: 0 });
|
||||||
|
const statusFilter = ref('');
|
||||||
|
|
||||||
|
// Modales
|
||||||
|
const showCreateModal = ref(false);
|
||||||
|
const showEditModal = ref(false);
|
||||||
|
const showDeleteModal = ref(false);
|
||||||
|
const showBulkModal = ref(false);
|
||||||
|
const editingSerial = ref(null);
|
||||||
|
const deletingSerial = ref(null);
|
||||||
|
|
||||||
|
// Bulk import
|
||||||
|
const bulkSerials = ref('');
|
||||||
|
const bulkProcessing = ref(false);
|
||||||
|
|
||||||
|
/** Formularios */
|
||||||
|
const form = useForm({
|
||||||
|
serial_number: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const editForm = useForm({
|
||||||
|
serial_number: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Buscador */
|
||||||
|
const searcher = useSearcher({
|
||||||
|
url: apiURL(`inventario/${route.params.id}/serials`),
|
||||||
|
filters: {},
|
||||||
|
onSuccess: (r) => {
|
||||||
|
serials.value = r.serials || { data: [], total: 0 };
|
||||||
|
inventory.value = r.inventory || null;
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
serials.value = { data: [], total: 0 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const loadSerials = (filters = {}) => {
|
||||||
|
searcher.load({
|
||||||
|
url: apiURL(`inventario/${inventoryId.value}/serials`),
|
||||||
|
filters: {
|
||||||
|
...filters,
|
||||||
|
status: statusFilter.value || undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSearch = (query) => {
|
||||||
|
searcher.search(query, { status: statusFilter.value || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStatusChange = () => {
|
||||||
|
loadSerials({ q: searcher.query });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Crear serial
|
||||||
|
const openCreateModal = () => {
|
||||||
|
form.reset();
|
||||||
|
showCreateModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCreateModal = () => {
|
||||||
|
showCreateModal.value = false;
|
||||||
|
form.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSerial = () => {
|
||||||
|
const url = apiURL(`inventario/${inventoryId.value}/serials`);
|
||||||
|
console.log('Creating serial at URL:', url, 'inventoryId:', inventoryId.value);
|
||||||
|
|
||||||
|
form.post(url, {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
Notify.success('Número de serie creado exitosamente');
|
||||||
|
if (response.inventory) {
|
||||||
|
inventory.value = response.inventory;
|
||||||
|
}
|
||||||
|
closeCreateModal();
|
||||||
|
loadSerials();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error('Error al crear el número de serie');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Editar serial
|
||||||
|
const openEditModal = (serial) => {
|
||||||
|
editingSerial.value = serial;
|
||||||
|
editForm.serial_number = serial.serial_number;
|
||||||
|
editForm.notes = serial.notes || '';
|
||||||
|
showEditModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditModal = () => {
|
||||||
|
showEditModal.value = false;
|
||||||
|
editingSerial.value = null;
|
||||||
|
editForm.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSerial = () => {
|
||||||
|
editForm.put(apiURL(`inventario/${inventoryId.value}/serials/${editingSerial.value.id}`), {
|
||||||
|
onSuccess: () => {
|
||||||
|
Notify.success('Número de serie actualizado');
|
||||||
|
closeEditModal();
|
||||||
|
loadSerials();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error('Error al actualizar el número de serie');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
Notify.success('Número de serie eliminado');
|
||||||
|
closeDeleteModal();
|
||||||
|
loadSerials();
|
||||||
|
} catch (error) {
|
||||||
|
Notify.error('Error al eliminar el número de serie');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Importación masiva
|
||||||
|
const openBulkModal = () => {
|
||||||
|
bulkSerials.value = '';
|
||||||
|
showBulkModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeBulkModal = () => {
|
||||||
|
showBulkModal.value = false;
|
||||||
|
bulkSerials.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkImport = async () => {
|
||||||
|
const lines = bulkSerials.value
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line.length > 0);
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
Notify.warning('Ingresa al menos un número de serie');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkProcessing.value = true;
|
||||||
|
try {
|
||||||
|
const response = await serialService.bulkImport(inventoryId.value, lines);
|
||||||
|
Notify.success(`${response.count || lines.length} números de serie importados`);
|
||||||
|
if (response.inventory) {
|
||||||
|
inventory.value = response.inventory;
|
||||||
|
}
|
||||||
|
closeBulkModal();
|
||||||
|
loadSerials();
|
||||||
|
} catch (error) {
|
||||||
|
Notify.error('Error al importar números de serie');
|
||||||
|
} finally {
|
||||||
|
bulkProcessing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkCount = computed(() => {
|
||||||
|
return bulkSerials.value
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line.length > 0).length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navegación
|
||||||
|
const goBack = () => {
|
||||||
|
router.push({ name: 'pos.inventory.index' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Badge de estado
|
||||||
|
const getStatusBadge = (status) => {
|
||||||
|
if (status === 'disponible') {
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||||
|
}
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status) => {
|
||||||
|
return status === 'disponible' ? 'Disponible' : 'Vendido';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ciclos */
|
||||||
|
onMounted(() => {
|
||||||
|
loadSerials();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Header con navegación -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<button
|
||||||
|
@click="goBack"
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="arrow_back" class="text-xl" />
|
||||||
|
Volver a Inventario
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info del producto -->
|
||||||
|
<div v-if="inventory" class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ inventory.name }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
SKU: {{ inventory.sku }}
|
||||||
|
<span v-if="inventory.category">
|
||||||
|
| Categoría: {{ inventory.category.name }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-2xl font-bold text-indigo-600 dark:text-indigo-400">
|
||||||
|
{{ inventory.stock }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Disponibles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buscador y filtros -->
|
||||||
|
<SearcherHead
|
||||||
|
title="Números de Serie"
|
||||||
|
placeholder="Buscar por número de serie..."
|
||||||
|
@search="onSearch"
|
||||||
|
>
|
||||||
|
<!-- Filtro de estado -->
|
||||||
|
<select
|
||||||
|
v-model="statusFilter"
|
||||||
|
@change="onStatusChange"
|
||||||
|
class="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
<option value="disponible">Disponible</option>
|
||||||
|
<option value="vendido">Vendido</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
||||||
|
@click="openBulkModal"
|
||||||
|
title="Importar múltiples números de serie"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="upload" class="text-xl" />
|
||||||
|
Importar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
||||||
|
@click="openCreateModal"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="add" class="text-xl" />
|
||||||
|
Agregar Serial
|
||||||
|
</button>
|
||||||
|
</SearcherHead>
|
||||||
|
|
||||||
|
<!-- Tabla de seriales -->
|
||||||
|
<div class="pt-2 w-full">
|
||||||
|
<Table
|
||||||
|
:items="serials"
|
||||||
|
:processing="searcher.processing"
|
||||||
|
@send-pagination="(page) => searcher.pagination(page, { status: statusFilter || undefined })"
|
||||||
|
>
|
||||||
|
<template #head>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NÚMERO DE SERIE</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ESTADO</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NOTAS</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FECHA</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ACCIONES</th>
|
||||||
|
</template>
|
||||||
|
<template #body="{items}">
|
||||||
|
<tr
|
||||||
|
v-for="serial in items"
|
||||||
|
:key="serial.id"
|
||||||
|
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ serial.serial_number }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
:class="getStatusBadge(serial.status)"
|
||||||
|
>
|
||||||
|
{{ getStatusLabel(serial.status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ serial.notes || '-' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ new Date(serial.created_at).toLocaleDateString('es-MX') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
v-if="serial.status === 'disponible'"
|
||||||
|
@click="openEditModal(serial)"
|
||||||
|
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
||||||
|
title="Editar serial"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="edit" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="serial.status === 'disponible'"
|
||||||
|
@click="openDeleteModal(serial)"
|
||||||
|
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||||
|
title="Eliminar serial"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="delete" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-if="serial.status === 'vendido'"
|
||||||
|
class="text-gray-400 text-xs"
|
||||||
|
title="No se puede editar/eliminar un serial vendido"
|
||||||
|
>
|
||||||
|
Venta #{{ serial.sale_detail_id }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
<td colspan="5" class="table-cell text-center">
|
||||||
|
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
|
<GoogleIcon
|
||||||
|
name="qr_code_2"
|
||||||
|
class="text-6xl mb-2 opacity-50"
|
||||||
|
/>
|
||||||
|
<p class="font-semibold">
|
||||||
|
No hay números de serie registrados
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mt-1">
|
||||||
|
Agrega seriales individuales o importa múltiples
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Crear Serial -->
|
||||||
|
<Modal :show="showCreateModal" max-width="sm" @close="closeCreateModal">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Agregar Número de Serie
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
@click="closeCreateModal"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="close" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="createSerial" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
NÚMERO DE SERIE *
|
||||||
|
</label>
|
||||||
|
<FormInput
|
||||||
|
v-model="form.serial_number"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej: ABC123456789"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError :message="form.errors?.serial_number" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
NOTAS (OPCIONAL)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.notes"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Notas adicionales..."
|
||||||
|
></textarea>
|
||||||
|
<FormError :message="form.errors?.notes" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeCreateModal"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="px-4 py-2 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 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>
|
||||||
|
|
||||||
|
<!-- Modal Editar Serial -->
|
||||||
|
<Modal :show="showEditModal" max-width="sm" @close="closeEditModal">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Editar Número de Serie
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
@click="closeEditModal"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="close" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="updateSerial" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
NÚMERO DE SERIE *
|
||||||
|
</label>
|
||||||
|
<FormInput
|
||||||
|
v-model="editForm.serial_number"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej: ABC123456789"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError :message="editForm.errors?.serial_number" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
NOTAS (OPCIONAL)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="editForm.notes"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Notas adicionales..."
|
||||||
|
></textarea>
|
||||||
|
<FormError :message="editForm.errors?.notes" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeEditModal"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="editForm.processing"
|
||||||
|
class="px-4 py-2 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<span v-if="editForm.processing">Actualizando...</span>
|
||||||
|
<span v-else>Actualizar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Modal Eliminar Serial -->
|
||||||
|
<Modal :show="showDeleteModal" max-width="sm" @close="closeDeleteModal">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Eliminar Número de Serie
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
@click="closeDeleteModal"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="close" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">
|
||||||
|
¿Estás seguro de eliminar el número de serie
|
||||||
|
<span class="font-mono font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ deletingSerial?.serial_number }}
|
||||||
|
</span>?
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-red-600 mt-2">
|
||||||
|
Esta acción no se puede deshacer y reducirá el stock del producto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
@click="closeDeleteModal"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="confirmDelete"
|
||||||
|
class="px-4 py-2 text-sm font-semibold text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Modal Importar Múltiples -->
|
||||||
|
<Modal :show="showBulkModal" max-width="md" @close="closeBulkModal">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Importar Números de Serie
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
@click="closeBulkModal"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="close" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
NÚMEROS DE SERIE (UNO POR LÍNEA)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="bulkSerials"
|
||||||
|
class="w-full px-3 py-2 text-sm font-mono border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100"
|
||||||
|
rows="10"
|
||||||
|
placeholder="ABC123456789 DEF987654321 GHI456123789 ..."
|
||||||
|
></textarea>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
{{ bulkCount }} número(s) de serie detectado(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
@click="closeBulkModal"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="bulkImport"
|
||||||
|
:disabled="bulkProcessing || bulkCount === 0"
|
||||||
|
class="px-4 py-2 text-sm font-semibold text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<span v-if="bulkProcessing">Importando...</span>
|
||||||
|
<span v-else>Importar {{ bulkCount }} serial(es)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -14,6 +14,7 @@ import CartItem from '@Components/POS/CartItem.vue';
|
|||||||
import CheckoutModal from '@Components/POS/CheckoutModal.vue';
|
import CheckoutModal from '@Components/POS/CheckoutModal.vue';
|
||||||
import ClientModal from '@Components/POS/ClientModal.vue';
|
import ClientModal from '@Components/POS/ClientModal.vue';
|
||||||
import QRscan from '@Components/POS/QRscan.vue';
|
import QRscan from '@Components/POS/QRscan.vue';
|
||||||
|
import SerialSelector from '@Components/POS/SerialSelector.vue';
|
||||||
|
|
||||||
/** i18n */
|
/** i18n */
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@ -30,6 +31,11 @@ const scanMode = ref(false);
|
|||||||
const showClientModal = ref(false);
|
const showClientModal = ref(false);
|
||||||
const lastSaleData = ref(null);
|
const lastSaleData = ref(null);
|
||||||
|
|
||||||
|
// Estado para selector de seriales
|
||||||
|
const showSerialSelector = ref(false);
|
||||||
|
const serialSelectorProduct = ref(null);
|
||||||
|
const serialSelectorQuantity = ref(1);
|
||||||
|
|
||||||
/** Buscador de productos */
|
/** Buscador de productos */
|
||||||
const searcher = useSearcher({
|
const searcher = useSearcher({
|
||||||
url: apiURL('inventario'),
|
url: apiURL('inventario'),
|
||||||
@ -60,6 +66,14 @@ const filteredProducts = computed(() => {
|
|||||||
/** Métodos */
|
/** Métodos */
|
||||||
const addToCart = (product) => {
|
const addToCart = (product) => {
|
||||||
try {
|
try {
|
||||||
|
// Si el producto tiene seriales, mostrar selector
|
||||||
|
if (product.has_serials) {
|
||||||
|
serialSelectorProduct.value = product;
|
||||||
|
serialSelectorQuantity.value = 1;
|
||||||
|
showSerialSelector.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
cart.addProduct(product);
|
cart.addProduct(product);
|
||||||
window.Notify.success(`${product.name} agregado al carrito`);
|
window.Notify.success(`${product.name} agregado al carrito`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -67,6 +81,25 @@ const addToCart = (product) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeSerialSelector = () => {
|
||||||
|
showSerialSelector.value = false;
|
||||||
|
serialSelectorProduct.value = null;
|
||||||
|
serialSelectorQuantity.value = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSerialConfirm = (serialConfig) => {
|
||||||
|
if (!serialSelectorProduct.value) return;
|
||||||
|
|
||||||
|
cart.addProductWithSerials(
|
||||||
|
serialSelectorProduct.value,
|
||||||
|
serialSelectorQuantity.value,
|
||||||
|
serialConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
window.Notify.success(`${serialSelectorProduct.value.name} agregado al carrito`);
|
||||||
|
closeSerialSelector();
|
||||||
|
};
|
||||||
|
|
||||||
const handleClearCart = () => {
|
const handleClearCart = () => {
|
||||||
if (confirm(t('cart.clearConfirm'))) {
|
if (confirm(t('cart.clearConfirm'))) {
|
||||||
cart.clear();
|
cart.clear();
|
||||||
@ -154,7 +187,8 @@ const handleConfirmSale = async (paymentData) => {
|
|||||||
product_name: item.product_name,
|
product_name: item.product_name,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit_price: item.unit_price,
|
unit_price: item.unit_price,
|
||||||
subtotal: parseFloat((item.quantity * item.unit_price).toFixed(2))
|
subtotal: parseFloat((item.quantity * item.unit_price).toFixed(2)),
|
||||||
|
serial_numbers: item.has_serials ? (item.serial_numbers || []) : undefined
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -436,5 +470,16 @@ onMounted(() => {
|
|||||||
@close="closeClientModal"
|
@close="closeClientModal"
|
||||||
@save="handleClientSave"
|
@save="handleClientSave"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Modal de Selección de Seriales -->
|
||||||
|
<SerialSelector
|
||||||
|
v-if="serialSelectorProduct"
|
||||||
|
:show="showSerialSelector"
|
||||||
|
:product="serialSelectorProduct"
|
||||||
|
:quantity="serialSelectorQuantity"
|
||||||
|
:exclude-serials="cart.getSelectedSerials()"
|
||||||
|
@close="closeSerialSelector"
|
||||||
|
@confirm="handleSerialConfirm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -42,6 +42,12 @@ const router = createRouter({
|
|||||||
beforeEnter: (to, from, next) => can(next, 'inventario.index'),
|
beforeEnter: (to, from, next) => can(next, 'inventario.index'),
|
||||||
component: () => import('@Pages/POS/Inventory/Index.vue')
|
component: () => import('@Pages/POS/Inventory/Index.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'inventory/:id/serials',
|
||||||
|
name: 'pos.inventory.serials',
|
||||||
|
beforeEnter: (to, from, next) => can(next, 'inventario.index'),
|
||||||
|
component: () => import('@Pages/POS/Inventory/Serials.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'point',
|
path: 'point',
|
||||||
name: 'pos.point',
|
name: 'pos.point',
|
||||||
|
|||||||
136
src/services/serialService.js
Normal file
136
src/services/serialService.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { api, apiURL } from '@Services/Api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servicio para gestionar números de serie de inventario
|
||||||
|
*/
|
||||||
|
const serialService = {
|
||||||
|
/**
|
||||||
|
* Obtener lista de seriales de un producto
|
||||||
|
* @param {Number} inventoryId - ID del inventario
|
||||||
|
* @param {Object} filters - Filtros opcionales (status, q, page)
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async getSerials(inventoryId, filters = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.get(apiURL(`inventario/${inventoryId}/serials`), {
|
||||||
|
params: filters,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener solo seriales disponibles de un producto
|
||||||
|
* @param {Number} inventoryId - ID del inventario
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async getAvailableSerials(inventoryId) {
|
||||||
|
return this.getSerials(inventoryId, { status: 'disponible' });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener un serial específico
|
||||||
|
* @param {Number} inventoryId - ID del inventario
|
||||||
|
* @param {Number} serialId - ID del serial
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async getSerial(inventoryId, serialId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.get(apiURL(`inventario/${inventoryId}/serials/${serialId}`), {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response.serial || response);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear un nuevo serial
|
||||||
|
* @param {Number} inventoryId - ID del inventario
|
||||||
|
* @param {Object} data - Datos del serial (serial_number, notes)
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async createSerial(inventoryId, data) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.post(apiURL(`inventario/${inventoryId}/serials`), {
|
||||||
|
data: data,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importar múltiples seriales
|
||||||
|
* @param {Number} inventoryId - ID del inventario
|
||||||
|
* @param {Array} serialNumbers - Array de números de serie
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async bulkImport(inventoryId, serialNumbers) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.post(apiURL(`inventario/${inventoryId}/serials/bulk`), {
|
||||||
|
data: { serial_numbers: serialNumbers },
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar un serial
|
||||||
|
* @param {Number} inventoryId - ID del inventario
|
||||||
|
* @param {Number} serialId - ID del serial
|
||||||
|
* @param {Object} data - Datos a actualizar (serial_number, status, notes)
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async updateSerial(inventoryId, serialId, data) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.put(apiURL(`inventario/${inventoryId}/serials/${serialId}`), {
|
||||||
|
data: data,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar un serial
|
||||||
|
* @param {Number} inventoryId - ID del inventario
|
||||||
|
* @param {Number} serialId - ID del serial
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async deleteSerial(inventoryId, serialId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.delete(apiURL(`inventario/${inventoryId}/serials/${serialId}`), {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default serialService;
|
||||||
@ -180,7 +180,23 @@ const ticketService = {
|
|||||||
doc.text(String(quantity), 52, yPosition, { align: 'center' });
|
doc.text(String(quantity), 52, yPosition, { align: 'center' });
|
||||||
doc.text(`$${unitPrice}`, rightMargin, yPosition, { align: 'right' });
|
doc.text(`$${unitPrice}`, rightMargin, yPosition, { align: 'right' });
|
||||||
|
|
||||||
yPosition += 5;
|
yPosition += 4;
|
||||||
|
|
||||||
|
// Números de serie (si existen)
|
||||||
|
const serials = item.serial_numbers || item.serials || [];
|
||||||
|
if (serials.length > 0) {
|
||||||
|
doc.setFontSize(6);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
|
||||||
|
serials.forEach((serial) => {
|
||||||
|
const serialNumber = typeof serial === 'string' ? serial : serial.serial_number;
|
||||||
|
doc.text(` S/N: ${serialNumber}`, leftMargin, yPosition);
|
||||||
|
yPosition += 3;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 2;
|
||||||
});
|
});
|
||||||
|
|
||||||
yPosition += 2;
|
yPosition += 2;
|
||||||
|
|||||||
@ -39,13 +39,18 @@ const useCart = defineStore('cart', {
|
|||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
// Agregar producto al carrito
|
// Agregar producto al carrito
|
||||||
addProduct(product) {
|
addProduct(product, serialConfig = null) {
|
||||||
const existingItem = this.items.find(item => item.inventory_id === product.id);
|
const existingItem = this.items.find(item => item.inventory_id === product.id);
|
||||||
|
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
// Si ya existe, incrementar cantidad
|
// Si ya existe, incrementar cantidad
|
||||||
if (existingItem.quantity < product.stock) {
|
if (existingItem.quantity < product.stock) {
|
||||||
existingItem.quantity++;
|
existingItem.quantity++;
|
||||||
|
// Si tiene seriales, limpiar para que el usuario vuelva a seleccionar
|
||||||
|
if (existingItem.has_serials) {
|
||||||
|
existingItem.serial_numbers = [];
|
||||||
|
existingItem.serial_selection_mode = null;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
window.Notify.warning('No hay suficiente stock disponible');
|
window.Notify.warning('No hay suficiente stock disponible');
|
||||||
}
|
}
|
||||||
@ -58,11 +63,62 @@ const useCart = defineStore('cart', {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit_price: parseFloat(product.price?.retail_price || 0),
|
unit_price: parseFloat(product.price?.retail_price || 0),
|
||||||
tax_rate: parseFloat(product.price?.tax || 16),
|
tax_rate: parseFloat(product.price?.tax || 16),
|
||||||
max_stock: product.stock
|
max_stock: product.stock,
|
||||||
|
// Campos para seriales
|
||||||
|
has_serials: product.has_serials || false,
|
||||||
|
serial_numbers: serialConfig?.serialNumbers || [],
|
||||||
|
serial_selection_mode: serialConfig?.selectionMode || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Agregar producto con seriales ya configurados
|
||||||
|
addProductWithSerials(product, quantity, serialConfig) {
|
||||||
|
// Eliminar item existente si hay
|
||||||
|
this.removeProduct(product.id);
|
||||||
|
|
||||||
|
// Agregar nuevo item con seriales
|
||||||
|
this.items.push({
|
||||||
|
inventory_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
sku: product.sku,
|
||||||
|
quantity: quantity,
|
||||||
|
unit_price: parseFloat(product.price?.retail_price || 0),
|
||||||
|
tax_rate: parseFloat(product.price?.tax || 16),
|
||||||
|
max_stock: product.stock,
|
||||||
|
has_serials: true,
|
||||||
|
serial_numbers: serialConfig.serialNumbers || [],
|
||||||
|
serial_selection_mode: serialConfig.selectionMode
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Actualizar seriales de un item
|
||||||
|
updateSerials(inventoryId, serialConfig) {
|
||||||
|
const item = this.items.find(i => i.inventory_id === inventoryId);
|
||||||
|
if (item) {
|
||||||
|
item.serial_numbers = serialConfig.serialNumbers || [];
|
||||||
|
item.serial_selection_mode = serialConfig.selectionMode;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Obtener seriales ya seleccionados (para excluir del selector)
|
||||||
|
getSelectedSerials() {
|
||||||
|
return this.items
|
||||||
|
.filter(item => item.has_serials && item.serial_numbers?.length > 0)
|
||||||
|
.flatMap(item => item.serial_numbers);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Verificar si un item necesita selección de seriales
|
||||||
|
needsSerialSelection(inventoryId) {
|
||||||
|
const item = this.items.find(i => i.inventory_id === inventoryId);
|
||||||
|
if (!item || !item.has_serials) return false;
|
||||||
|
// Necesita selección si es manual y no tiene suficientes seriales
|
||||||
|
if (item.serial_selection_mode === 'manual') {
|
||||||
|
return (item.serial_numbers?.length || 0) < item.quantity;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
// Actualizar cantidad de un item
|
// Actualizar cantidad de un item
|
||||||
updateQuantity(inventoryId, quantity) {
|
updateQuantity(inventoryId, quantity) {
|
||||||
const item = this.items.find(i => i.inventory_id === inventoryId);
|
const item = this.items.find(i => i.inventory_id === inventoryId);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user