feat: reportes de movimientos, inventario por almacén y series
- Habilita vista de stock con filtros y selección de series en traspasos. - Implementa servicio de tickets PDF y corrige datos (ubicación/negocio). - Renombra botón a Reporte y elimina opción de almacén principal.
This commit is contained in:
parent
6c70d1ba4f
commit
093cea3c4c
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
import Modal from '@Holos/Modal.vue';
|
import Modal from '@Holos/Modal.vue';
|
||||||
import serialService from '@Services/serialService';
|
import serialService from '@Services/serialService';
|
||||||
@ -17,6 +17,10 @@ const props = defineProps({
|
|||||||
excludeSerials: {
|
excludeSerials: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
|
},
|
||||||
|
warehouseId: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -54,7 +58,7 @@ const canConfirm = computed(() => {
|
|||||||
const loadSerials = async () => {
|
const loadSerials = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await serialService.getAvailableSerials(props.product.id);
|
const response = await serialService.getAvailableSerials(props.product.id, props.warehouseId);
|
||||||
// Filtrar seriales que ya están en el carrito
|
// Filtrar seriales que ya están en el carrito
|
||||||
availableSerials.value = (response.serials?.data || []).filter(
|
availableSerials.value = (response.serials?.data || []).filter(
|
||||||
serial => !props.excludeSerials.includes(serial.serial_number)
|
serial => !props.excludeSerials.includes(serial.serial_number)
|
||||||
|
|||||||
@ -103,7 +103,7 @@ const importProducts = async () => {
|
|||||||
const { imported, skipped, errors } = data.data;
|
const { imported, skipped, errors } = data.data;
|
||||||
|
|
||||||
if (imported > 0) {
|
if (imported > 0) {
|
||||||
window.Notify.success(`${imported} producto(s) importado(s) exitosamente`);
|
window.Notify.success(`${imported} producto(s) creado(s)/actualizado(s) exitosamente. Recuerda registrar el stock mediante entradas de almacén.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skipped > 0) {
|
if (skipped > 0) {
|
||||||
@ -184,10 +184,18 @@ const closeModal = () => {
|
|||||||
<p class="font-semibold mb-2">Instrucciones:</p>
|
<p class="font-semibold mb-2">Instrucciones:</p>
|
||||||
<ol class="list-decimal ml-4 space-y-1">
|
<ol class="list-decimal ml-4 space-y-1">
|
||||||
<li>Descarga la plantilla de Excel haciendo clic en el botón de abajo</li>
|
<li>Descarga la plantilla de Excel haciendo clic en el botón de abajo</li>
|
||||||
<li>Completa la plantilla con los datos de tus productos</li>
|
<li>Completa la plantilla con los datos básicos de tus productos y precios</li>
|
||||||
<li>Guarda el archivo y súbelo usando el botón "Seleccionar archivo"</li>
|
<li>Guarda el archivo y súbelo usando el botón "Seleccionar archivo"</li>
|
||||||
<li>Haz clic en "Importar" para procesar el archivo</li>
|
<li>Haz clic en "Importar" para procesar el archivo</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
<div class="mt-3 pt-3 border-t border-blue-300 dark:border-blue-700">
|
||||||
|
<p class="font-semibold mb-1">⚠️ Importante:</p>
|
||||||
|
<ul class="list-disc ml-4 space-y-0.5">
|
||||||
|
<li>Esta importación solo crea/actualiza información básica de productos</li>
|
||||||
|
<li>El costo se inicializa en $0 y se actualiza con las entradas de almacén</li>
|
||||||
|
<li>Para gestionar stock y números de serie, utiliza "Movimientos" → "Entradas"</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref, computed } from 'vue';
|
import { onMounted, ref, computed } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useSearcher, useForm, apiURL } from '@Services/Api';
|
import { useSearcher, apiURL } from '@Services/Api';
|
||||||
import serialService from '@Services/serialService';
|
import serialService from '@Services/serialService';
|
||||||
|
|
||||||
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 Modal from '@Holos/Modal.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';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -21,28 +19,9 @@ const serials = ref({ data: [], total: 0 });
|
|||||||
const activeTab = ref('disponible');
|
const activeTab = ref('disponible');
|
||||||
|
|
||||||
// Modales
|
// Modales
|
||||||
const showCreateModal = ref(false);
|
|
||||||
const showEditModal = ref(false);
|
|
||||||
const showDeleteModal = ref(false);
|
const showDeleteModal = ref(false);
|
||||||
const showBulkModal = ref(false);
|
|
||||||
const editingSerial = ref(null);
|
|
||||||
const deletingSerial = 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 */
|
/** Buscador */
|
||||||
const searcher = useSearcher({
|
const searcher = useSearcher({
|
||||||
url: apiURL(`inventario/${route.params.id}/serials`),
|
url: apiURL(`inventario/${route.params.id}/serials`),
|
||||||
@ -76,62 +55,6 @@ const switchTab = (tab) => {
|
|||||||
loadSerials({ q: searcher.query });
|
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`);
|
|
||||||
|
|
||||||
form.post(url, {
|
|
||||||
onSuccess: (response) => {
|
|
||||||
window.Notify.success('Número de serie creado exitosamente');
|
|
||||||
if (response.inventory) {
|
|
||||||
inventory.value = response.inventory;
|
|
||||||
}
|
|
||||||
closeCreateModal();
|
|
||||||
loadSerials();
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
window.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: () => {
|
|
||||||
window.Notify.success('Número de serie actualizado');
|
|
||||||
closeEditModal();
|
|
||||||
loadSerials();
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
window.Notify.error('Error al actualizar el número de serie');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Eliminar serial
|
// Eliminar serial
|
||||||
const openDeleteModal = (serial) => {
|
const openDeleteModal = (serial) => {
|
||||||
deletingSerial.value = serial;
|
deletingSerial.value = serial;
|
||||||
@ -156,7 +79,7 @@ const confirmDelete = async () => {
|
|||||||
|
|
||||||
// Navegación
|
// Navegación
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
router.push({ name: 'pos.inventory.index' });
|
router.go(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Badge de estado
|
// Badge de estado
|
||||||
@ -220,16 +143,7 @@ onMounted(() => {
|
|||||||
title="Números de Serie"
|
title="Números de Serie"
|
||||||
placeholder="Buscar por número de serie..."
|
placeholder="Buscar por número de serie..."
|
||||||
@search="onSearch"
|
@search="onSearch"
|
||||||
>
|
/>
|
||||||
<button
|
|
||||||
v-if="activeTab === 'disponible'"
|
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Pestañas -->
|
<!-- Pestañas -->
|
||||||
<div class="mt-4 border-b border-gray-200 dark:border-gray-700">
|
<div class="mt-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
@ -318,14 +232,6 @@ 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
|
|
||||||
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
|
<button
|
||||||
v-if="serial.status === 'disponible'"
|
v-if="serial.status === 'disponible'"
|
||||||
@click="openDeleteModal(serial)"
|
@click="openDeleteModal(serial)"
|
||||||
@ -337,7 +243,7 @@ onMounted(() => {
|
|||||||
<span
|
<span
|
||||||
v-if="serial.status === 'vendido'"
|
v-if="serial.status === 'vendido'"
|
||||||
class="text-gray-400 text-xs"
|
class="text-gray-400 text-xs"
|
||||||
title="No se puede editar/eliminar un serial vendido"
|
title="No se puede eliminar un serial vendido"
|
||||||
>
|
>
|
||||||
Venta {{ serial.sale_detail?.sale?.invoice_number || `#${serial.sale_detail_id}` }}
|
Venta {{ serial.sale_detail?.sale?.invoice_number || `#${serial.sale_detail_id}` }}
|
||||||
</span>
|
</span>
|
||||||
@ -364,132 +270,6 @@ onMounted(() => {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</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 Eliminar Serial -->
|
||||||
<Modal :show="showDeleteModal" max-width="sm" @close="closeDeleteModal">
|
<Modal :show="showDeleteModal" max-width="sm" @close="closeDeleteModal">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed } 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, formatCurrency } from '@/utils/formatters';
|
||||||
import { hasPermission } from '@Plugins/RolePermission';
|
import { hasPermission } from '@Plugins/RolePermission';
|
||||||
|
import TicketDetailMovement from '@Services/TicketDetailMovement';
|
||||||
|
|
||||||
import Modal from '@Holos/Modal.vue';
|
import Modal from '@Holos/Modal.vue';
|
||||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
@ -110,6 +111,18 @@ const getTypeBadge = (type) => {
|
|||||||
return badges[type] || { label: type, class: 'bg-gray-100 text-gray-800', icon: 'help' };
|
return badges[type] || { label: type, class: 'bg-gray-100 text-gray-800', icon: 'help' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDownloadTicket = async () => {
|
||||||
|
try {
|
||||||
|
await TicketDetailMovement.generateMovementTicket(movement.value, {
|
||||||
|
autoDownload: true,
|
||||||
|
});
|
||||||
|
window.Notify.success('Ticket descargado correctamente');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generando ticket:', error);
|
||||||
|
window.Notify.error('Error al generar el ticket PDF');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Watchers */
|
/** Watchers */
|
||||||
watch(() => props.show, (isShown) => {
|
watch(() => props.show, (isShown) => {
|
||||||
if (isShown && props.movementId) {
|
if (isShown && props.movementId) {
|
||||||
@ -246,6 +259,10 @@ watch(() => props.show, (isShown) => {
|
|||||||
<span class="text-gray-500 dark:text-gray-400">Cantidad:</span>
|
<span class="text-gray-500 dark:text-gray-400">Cantidad:</span>
|
||||||
<p class="font-bold text-lg text-gray-900 dark:text-gray-100">{{ movement.quantity }}</p>
|
<p class="font-bold text-lg text-gray-900 dark:text-gray-100">{{ movement.quantity }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Costo:</span>
|
||||||
|
<p class="font-bold text-lg text-gray-900 dark:text-gray-100">{{ formatCurrency(movement.unit_cost) }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -313,6 +330,14 @@ watch(() => props.show, (isShown) => {
|
|||||||
<GoogleIcon name="edit" class="text-lg" />
|
<GoogleIcon name="edit" class="text-lg" />
|
||||||
Editar
|
Editar
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="handleDownloadTicket"
|
||||||
|
class="flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="download" class="text-lg" />
|
||||||
|
Descargar ticket
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -60,6 +60,12 @@ const loadData = () => {
|
|||||||
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 || [];
|
||||||
|
|
||||||
|
// Establecer el almacén principal por defecto
|
||||||
|
const mainWarehouse = warehouses.value.find(w => w.is_main || w.is_principal);
|
||||||
|
if (mainWarehouse && !form.warehouse_id) {
|
||||||
|
form.warehouse_id = mainWarehouse.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
api.get(apiURL('inventario'), {
|
api.get(apiURL('inventario'), {
|
||||||
@ -78,7 +84,10 @@ const addProduct = () => {
|
|||||||
product_name: '',
|
product_name: '',
|
||||||
product_sku: '',
|
product_sku: '',
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit_cost: 0
|
unit_cost: 0,
|
||||||
|
track_serials: false,
|
||||||
|
serial_numbers_text: '', // Texto con seriales separados por líneas
|
||||||
|
serial_validation_error: ''
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -150,6 +159,7 @@ const selectProduct = (product) => {
|
|||||||
selectedProducts.value[currentSearchIndex.value].inventory_id = product.id;
|
selectedProducts.value[currentSearchIndex.value].inventory_id = product.id;
|
||||||
selectedProducts.value[currentSearchIndex.value].product_name = product.name;
|
selectedProducts.value[currentSearchIndex.value].product_name = product.name;
|
||||||
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
|
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
|
||||||
|
selectedProducts.value[currentSearchIndex.value].track_serials = product.track_serials || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
productSearch.value = '';
|
productSearch.value = '';
|
||||||
@ -161,13 +171,57 @@ const selectProduct = (product) => {
|
|||||||
window.Notify.success(`Producto ${product.name} agregado`);
|
window.Notify.success(`Producto ${product.name} agregado`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actualizar cantidad según seriales ingresados
|
||||||
|
const updateQuantityFromSerials = (item) => {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
const createEntry = () => {
|
const createEntry = () => {
|
||||||
// Preparar datos del formulario
|
// Preparar datos del formulario
|
||||||
form.products = selectedProducts.value.map(item => ({
|
form.products = selectedProducts.value.map(item => {
|
||||||
inventory_id: item.inventory_id,
|
const productData = {
|
||||||
quantity: Number(item.quantity),
|
inventory_id: item.inventory_id,
|
||||||
unit_cost: Number(item.unit_cost)
|
quantity: Number(item.quantity),
|
||||||
}));
|
unit_cost: Number(item.unit_cost)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agregar seriales si hay texto ingresado
|
||||||
|
if (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())
|
||||||
|
.filter(s => s.length > 0)
|
||||||
|
.filter((s, index, self) => self.indexOf(s) === index); // Eliminar duplicados locales
|
||||||
|
|
||||||
|
if (serials.length > 0) {
|
||||||
|
productData.serial_numbers = serials;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return productData;
|
||||||
|
});
|
||||||
|
|
||||||
form.post(apiURL('movimientos/entrada'), {
|
form.post(apiURL('movimientos/entrada'), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@ -400,6 +454,30 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Números de serie -->
|
||||||
|
<div v-if="item.inventory_id" class="mt-3">
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
Números de Serie
|
||||||
|
<span v-if="item.track_serials" class="text-red-500">*</span>
|
||||||
|
<span v-else class="text-gray-500 font-normal">(opcional)</span>
|
||||||
|
<span class="text-gray-500 font-normal">- uno por línea, debe coincidir con la cantidad</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="item.serial_numbers_text"
|
||||||
|
@input="updateQuantityFromSerials(item)"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Ingresa los números de serie, uno por línea Ejemplo: IMEI-123456 IMEI-789012"
|
||||||
|
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 font-mono"
|
||||||
|
></textarea>
|
||||||
|
<div v-if="countSerials(item) > 0" class="mt-1 flex items-start gap-1 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<GoogleIcon name="qr_code_2" class="text-sm shrink-0" />
|
||||||
|
<span>{{ countSerials(item) }} serial(es) ingresado(s)</span>
|
||||||
|
</div>
|
||||||
|
<p v-else class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Si no ingresas seriales, será una entrada sin tracking de números de serie
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Subtotal del producto -->
|
<!-- 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">
|
<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">
|
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
|||||||
@ -7,6 +7,7 @@ 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';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import SerialSelector from '@Components/POS/SerialSelector.vue';
|
||||||
|
|
||||||
/** Eventos */
|
/** Eventos */
|
||||||
const emit = defineEmits(['close', 'created']);
|
const emit = defineEmits(['close', 'created']);
|
||||||
@ -34,6 +35,11 @@ const productSuggestions = ref([]);
|
|||||||
const showProductSuggestions = ref(false);
|
const showProductSuggestions = ref(false);
|
||||||
const currentSearchIndex = ref(null); // Índice del producto que se está buscando
|
const currentSearchIndex = ref(null); // Índice del producto que se está buscando
|
||||||
|
|
||||||
|
// Estado para selección de seriales
|
||||||
|
const showSerialSelector = ref(false);
|
||||||
|
const currentProductForSerials = ref(null);
|
||||||
|
const currentProductIndex = ref(null);
|
||||||
|
|
||||||
/** Formulario */
|
/** Formulario */
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
warehouse_id: '',
|
warehouse_id: '',
|
||||||
@ -94,7 +100,10 @@ const addProduct = () => {
|
|||||||
product_name: '',
|
product_name: '',
|
||||||
product_sku: '',
|
product_sku: '',
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit_cost: 0
|
unit_cost: 0,
|
||||||
|
track_serials: false,
|
||||||
|
selected_serials: [], // Array de números de serie seleccionados
|
||||||
|
available_serials_count: 0
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -175,6 +184,8 @@ const selectProduct = (product) => {
|
|||||||
selectedProducts.value[currentSearchIndex.value].inventory_id = product.id;
|
selectedProducts.value[currentSearchIndex.value].inventory_id = product.id;
|
||||||
selectedProducts.value[currentSearchIndex.value].product_name = product.name;
|
selectedProducts.value[currentSearchIndex.value].product_name = product.name;
|
||||||
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
|
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
|
||||||
|
selectedProducts.value[currentSearchIndex.value].track_serials = product.track_serials || false;
|
||||||
|
selectedProducts.value[currentSearchIndex.value].available_serials_count = product.warehouse_stock || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
productSearch.value = '';
|
productSearch.value = '';
|
||||||
@ -186,14 +197,66 @@ const selectProduct = (product) => {
|
|||||||
window.Notify.success(`Producto ${product.name} agregado`);
|
window.Notify.success(`Producto ${product.name} agregado`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Gestión de seriales
|
||||||
|
const openSerialSelector = (item, index) => {
|
||||||
|
// Crear objeto compatible con SerialSelector (necesita 'id' no 'inventory_id')
|
||||||
|
currentProductForSerials.value = {
|
||||||
|
id: item.inventory_id,
|
||||||
|
name: item.product_name,
|
||||||
|
sku: item.product_sku,
|
||||||
|
track_serials: item.track_serials
|
||||||
|
};
|
||||||
|
currentProductIndex.value = index;
|
||||||
|
showSerialSelector.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSerialSelector = () => {
|
||||||
|
showSerialSelector.value = false;
|
||||||
|
currentProductForSerials.value = null;
|
||||||
|
currentProductIndex.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSerialsConfirmed = ({ serialNumbers, quantity }) => {
|
||||||
|
if (currentProductIndex.value !== null) {
|
||||||
|
selectedProducts.value[currentProductIndex.value].selected_serials = serialNumbers;
|
||||||
|
selectedProducts.value[currentProductIndex.value].quantity = quantity;
|
||||||
|
}
|
||||||
|
closeSerialSelector();
|
||||||
|
};
|
||||||
|
|
||||||
const createExit = () => {
|
const createExit = () => {
|
||||||
|
// Validar que productos con seriales tengan seriales seleccionados
|
||||||
|
const invalidProducts = selectedProducts.value.filter(
|
||||||
|
item => item.track_serials && (!item.selected_serials || item.selected_serials.length === 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invalidProducts.length > 0) {
|
||||||
|
window.Notify.error('Debes seleccionar los números de serie para los productos que lo requieren');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Preparar datos del formulario
|
// Preparar datos del formulario
|
||||||
form.products = selectedProducts.value.map(item => ({
|
form.products = selectedProducts.value.map(item => {
|
||||||
inventory_id: item.inventory_id,
|
const productData = {
|
||||||
quantity: Number(item.quantity)
|
inventory_id: item.inventory_id,
|
||||||
}));
|
quantity: Number(item.quantity)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agregar seriales si el producto los requiere
|
||||||
|
if (item.track_serials && item.selected_serials && item.selected_serials.length > 0) {
|
||||||
|
productData.serial_numbers = item.selected_serials;
|
||||||
|
}
|
||||||
|
|
||||||
|
return productData;
|
||||||
|
});
|
||||||
|
|
||||||
form.post(apiURL('movimientos/salida'), {
|
form.post(apiURL('movimientos/salida'), {
|
||||||
|
data: {
|
||||||
|
warehouse_id: form.warehouse_id,
|
||||||
|
reference: form.reference,
|
||||||
|
notes: form.notes,
|
||||||
|
products: form.products
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
window.Notify.success('Salida registrada correctamente');
|
window.Notify.success('Salida registrada correctamente');
|
||||||
emit('created');
|
emit('created');
|
||||||
@ -403,8 +466,12 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
min="1"
|
min="1"
|
||||||
step="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"
|
:disabled="item.track_serials"
|
||||||
|
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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
|
<p v-if="item.track_serials" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Controlado por seriales
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Botón eliminar -->
|
<!-- Botón eliminar -->
|
||||||
@ -420,6 +487,52 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
<GoogleIcon name="delete" class="text-lg" />
|
<GoogleIcon name="delete" class="text-lg" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Selección de seriales (solo si el producto requiere seriales) -->
|
||||||
|
<div v-if="item.inventory_id && item.track_serials" class="col-span-12">
|
||||||
|
<div class="mt-2 p-2 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-2 flex-1">
|
||||||
|
<GoogleIcon name="qr_code_2" class="text-amber-600 dark:text-amber-400 text-lg" />
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-amber-900 dark:text-amber-100">
|
||||||
|
Este producto requiere números de serie
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
{{ item.selected_serials.length }} de {{ item.available_serials_count }} seriales seleccionados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="openSerialSelector(item, index)"
|
||||||
|
class="flex items-center gap-1 px-3 py-1.5 text-xs font-semibold text-white bg-amber-600 hover:bg-amber-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="qr_code_scanner" class="text-sm" />
|
||||||
|
{{ item.selected_serials.length > 0 ? 'Cambiar' : 'Seleccionar' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Lista de seriales seleccionados -->
|
||||||
|
<div v-if="item.selected_serials.length > 0" class="mt-2 pt-2 border-t border-amber-200 dark:border-amber-800">
|
||||||
|
<p class="text-xs font-medium text-amber-900 dark:text-amber-100 mb-1">Seriales seleccionados:</p>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="(serial, sIndex) in item.selected_serials.slice(0, 5)"
|
||||||
|
:key="sIndex"
|
||||||
|
class="inline-flex items-center px-2 py-0.5 text-xs font-mono bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200 rounded"
|
||||||
|
>
|
||||||
|
{{ serial }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="item.selected_serials.length > 5"
|
||||||
|
class="inline-flex items-center px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded"
|
||||||
|
>
|
||||||
|
+{{ item.selected_serials.length - 5 }} más
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -463,5 +576,16 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de Selección de Seriales -->
|
||||||
|
<SerialSelector
|
||||||
|
v-if="currentProductForSerials && currentProductIndex !== null"
|
||||||
|
:show="showSerialSelector"
|
||||||
|
:product="currentProductForSerials"
|
||||||
|
:warehouse-id="Number(form.warehouse_id)"
|
||||||
|
:initial-serials="selectedProducts[currentProductIndex]?.selected_serials || []"
|
||||||
|
@close="closeSerialSelector"
|
||||||
|
@confirm="handleSerialsConfirmed"
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -196,7 +196,7 @@ onMounted(() => {
|
|||||||
@click="showKardexModal = true"
|
@click="showKardexModal = true"
|
||||||
>
|
>
|
||||||
<GoogleIcon name="download" class="text-lg" />
|
<GoogleIcon name="download" class="text-lg" />
|
||||||
Kardex
|
Reporte
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="can('create')"
|
v-if="can('create')"
|
||||||
|
|||||||
@ -7,6 +7,7 @@ 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';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import SerialSelector from '@Components/POS/SerialSelector.vue';
|
||||||
|
|
||||||
/** Eventos */
|
/** Eventos */
|
||||||
const emit = defineEmits(['close', 'created']);
|
const emit = defineEmits(['close', 'created']);
|
||||||
@ -35,6 +36,11 @@ const productSuggestions = ref([]);
|
|||||||
const showProductSuggestions = ref(false);
|
const showProductSuggestions = ref(false);
|
||||||
const currentSearchIndex = ref(null); // Índice del producto que se está buscando
|
const currentSearchIndex = ref(null); // Índice del producto que se está buscando
|
||||||
|
|
||||||
|
// Estado para selección de seriales
|
||||||
|
const showSerialSelector = ref(false);
|
||||||
|
const currentProductForSerials = ref(null);
|
||||||
|
const currentProductIndex = ref(null);
|
||||||
|
|
||||||
/** Formulario */
|
/** Formulario */
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
warehouse_from_id: '',
|
warehouse_from_id: '',
|
||||||
@ -99,7 +105,12 @@ const loadProducts = (warehouseId, isOrigin = true) => {
|
|||||||
const addProduct = () => {
|
const addProduct = () => {
|
||||||
selectedProducts.value.push({
|
selectedProducts.value.push({
|
||||||
inventory_id: '',
|
inventory_id: '',
|
||||||
quantity: 1
|
product_name: '',
|
||||||
|
product_sku: '',
|
||||||
|
quantity: 1,
|
||||||
|
track_serials: false,
|
||||||
|
selected_serials: [], // Array de números de serie seleccionados
|
||||||
|
available_serials_count: 0
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -180,6 +191,8 @@ const selectProduct = (product) => {
|
|||||||
selectedProducts.value[currentSearchIndex.value].inventory_id = product.id;
|
selectedProducts.value[currentSearchIndex.value].inventory_id = product.id;
|
||||||
selectedProducts.value[currentSearchIndex.value].product_name = product.name;
|
selectedProducts.value[currentSearchIndex.value].product_name = product.name;
|
||||||
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
|
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
|
||||||
|
selectedProducts.value[currentSearchIndex.value].track_serials = product.track_serials || false;
|
||||||
|
selectedProducts.value[currentSearchIndex.value].available_serials_count = product.warehouse_stock || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
productSearch.value = '';
|
productSearch.value = '';
|
||||||
@ -191,14 +204,75 @@ const selectProduct = (product) => {
|
|||||||
window.Notify.success(`Producto ${product.name} agregado`);
|
window.Notify.success(`Producto ${product.name} agregado`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Gestión de seriales
|
||||||
|
const openSerialSelector = (item, index) => {
|
||||||
|
// Crear objeto compatible con SerialSelector (necesita 'id' no 'inventory_id')
|
||||||
|
currentProductForSerials.value = {
|
||||||
|
id: item.inventory_id,
|
||||||
|
name: item.product_name,
|
||||||
|
sku: item.product_sku,
|
||||||
|
track_serials: item.track_serials
|
||||||
|
};
|
||||||
|
currentProductIndex.value = index;
|
||||||
|
showSerialSelector.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSerialSelector = () => {
|
||||||
|
showSerialSelector.value = false;
|
||||||
|
currentProductForSerials.value = null;
|
||||||
|
currentProductIndex.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSerialsConfirmed = ({ serialNumbers, quantity }) => {
|
||||||
|
if (currentProductIndex.value !== null) {
|
||||||
|
selectedProducts.value[currentProductIndex.value].selected_serials = serialNumbers;
|
||||||
|
selectedProducts.value[currentProductIndex.value].quantity = quantity;
|
||||||
|
}
|
||||||
|
closeSerialSelector();
|
||||||
|
};
|
||||||
|
|
||||||
const createTransfer = () => {
|
const createTransfer = () => {
|
||||||
|
// Validar que productos con seriales tengan seriales seleccionados
|
||||||
|
const invalidProducts = selectedProducts.value.filter(
|
||||||
|
item => item.track_serials && (!item.selected_serials || item.selected_serials.length === 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invalidProducts.length > 0) {
|
||||||
|
window.Notify.error('Debes seleccionar los números de serie para los productos que lo requieren');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Preparar datos del formulario
|
// Preparar datos del formulario
|
||||||
form.products = selectedProducts.value.map(item => ({
|
form.products = selectedProducts.value.map(item => {
|
||||||
inventory_id: item.inventory_id,
|
const productData = {
|
||||||
quantity: Number(item.quantity)
|
inventory_id: item.inventory_id,
|
||||||
}));
|
quantity: Number(item.quantity)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agregar seriales si el producto los requiere
|
||||||
|
if (item.track_serials && item.selected_serials && item.selected_serials.length > 0) {
|
||||||
|
// Filtrar seriales válidos (no null, no undefined, no vacíos)
|
||||||
|
const validSerials = item.selected_serials.filter(s => s && s.trim());
|
||||||
|
|
||||||
|
if (validSerials.length !== item.quantity) {
|
||||||
|
window.Notify.error(`El producto "${item.product_name}" tiene ${validSerials.length} seriales pero cantidad ${item.quantity}`);
|
||||||
|
throw new Error('Serial count mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
productData.serial_numbers = validSerials;
|
||||||
|
}
|
||||||
|
|
||||||
|
return productData;
|
||||||
|
});
|
||||||
|
|
||||||
form.post(apiURL('movimientos/traspaso'), {
|
form.post(apiURL('movimientos/traspaso'), {
|
||||||
|
data: {
|
||||||
|
warehouse_from_id: form.warehouse_from_id,
|
||||||
|
warehouse_to_id: form.warehouse_to_id,
|
||||||
|
reference: form.reference,
|
||||||
|
notes: form.notes,
|
||||||
|
products: form.products
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
window.Notify.success('Traspaso registrado correctamente');
|
window.Notify.success('Traspaso registrado correctamente');
|
||||||
emit('created');
|
emit('created');
|
||||||
@ -448,8 +522,12 @@ watch(() => form.warehouse_to_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
min="1"
|
min="1"
|
||||||
step="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"
|
:disabled="item.track_serials"
|
||||||
|
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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
|
<p v-if="item.track_serials" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Controlado por seriales
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Botón eliminar -->
|
<!-- Botón eliminar -->
|
||||||
@ -465,6 +543,52 @@ watch(() => form.warehouse_to_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
<GoogleIcon name="delete" class="text-lg" />
|
<GoogleIcon name="delete" class="text-lg" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Selección de seriales (solo si el producto requiere seriales) -->
|
||||||
|
<div v-if="item.inventory_id && item.track_serials" class="col-span-12">
|
||||||
|
<div class="mt-2 p-2 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-2 flex-1">
|
||||||
|
<GoogleIcon name="qr_code_2" class="text-blue-600 dark:text-blue-400 text-lg" />
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-blue-900 dark:text-blue-100">
|
||||||
|
Este producto requiere números de serie
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||||
|
{{ item.selected_serials.length }} de {{ item.available_serials_count }} seriales seleccionados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="openSerialSelector(item, index)"
|
||||||
|
class="flex items-center gap-1 px-3 py-1.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="qr_code_scanner" class="text-sm" />
|
||||||
|
{{ item.selected_serials.length > 0 ? 'Cambiar' : 'Seleccionar' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Lista de seriales seleccionados -->
|
||||||
|
<div v-if="item.selected_serials.length > 0" class="mt-2 pt-2 border-t border-blue-200 dark:border-blue-800">
|
||||||
|
<p class="text-xs font-medium text-blue-900 dark:text-blue-100 mb-1">Seriales seleccionados:</p>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="(serial, sIndex) in item.selected_serials.slice(0, 5)"
|
||||||
|
:key="sIndex"
|
||||||
|
class="inline-flex items-center px-2 py-0.5 text-xs font-mono bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded"
|
||||||
|
>
|
||||||
|
{{ serial }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="item.selected_serials.length > 5"
|
||||||
|
class="inline-flex items-center px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded"
|
||||||
|
>
|
||||||
|
+{{ item.selected_serials.length - 5 }} más
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -508,5 +632,16 @@ watch(() => form.warehouse_to_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de Selección de Seriales -->
|
||||||
|
<SerialSelector
|
||||||
|
v-if="currentProductForSerials && currentProductIndex !== null"
|
||||||
|
:show="showSerialSelector"
|
||||||
|
:product="currentProductForSerials"
|
||||||
|
:warehouse-id="Number(form.warehouse_from_id)"
|
||||||
|
:initial-serials="selectedProducts[currentProductIndex]?.selected_serials || []"
|
||||||
|
@close="closeSerialSelector"
|
||||||
|
@confirm="handleSerialsConfirmed"
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -93,9 +93,6 @@ const getStatusLabel = (status) => {
|
|||||||
const handleDownloadTicket = (sale) => {
|
const handleDownloadTicket = (sale) => {
|
||||||
try {
|
try {
|
||||||
ticketService.generateSaleTicket(sale, {
|
ticketService.generateSaleTicket(sale, {
|
||||||
businessName: 'HIKVISION DISTRIBUIDOR',
|
|
||||||
businessAddress: 'Ciudad de México, México',
|
|
||||||
businessPhone: 'Tel: (55) 1234-5678',
|
|
||||||
autoDownload: true
|
autoDownload: true
|
||||||
});
|
});
|
||||||
window.Notify.success('Ticket descargado correctamente');
|
window.Notify.success('Ticket descargado correctamente');
|
||||||
|
|||||||
@ -89,21 +89,6 @@ const closeModal = () => {
|
|||||||
<FormError :message="form.errors?.name" />
|
<FormError :message="form.errors?.name" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Almacén Principal -->
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
v-model="form.is_main"
|
|
||||||
type="checkbox"
|
|
||||||
class="w-4 h-4 text-indigo-600 bg-gray-100 border-gray-300 rounded focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
|
||||||
/>
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Almacén principal
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<FormError :message="form.errors?.is_main" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Estado -->
|
<!-- Estado -->
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
@ -113,7 +98,7 @@ const closeModal = () => {
|
|||||||
class="w-4 h-4 text-indigo-600 bg-gray-100 border-gray-300 rounded focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
class="w-4 h-4 text-indigo-600 bg-gray-100 border-gray-300 rounded focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
Activar almacén inmediatamente
|
Activar almacén
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<FormError :message="form.errors?.is_active" />
|
<FormError :message="form.errors?.is_active" />
|
||||||
|
|||||||
@ -100,21 +100,6 @@ watch(() => props.warehouse, (newWarehouse) => {
|
|||||||
<FormError :message="form.errors?.name" />
|
<FormError :message="form.errors?.name" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Almacén Principal -->
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
v-model="form.is_main"
|
|
||||||
type="checkbox"
|
|
||||||
class="w-4 h-4 text-indigo-600 bg-gray-100 border-gray-300 rounded focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
|
||||||
/>
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Almacén principal
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<FormError :message="form.errors?.is_main" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Estado -->
|
<!-- Estado -->
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { api, useSearcher, apiURL } from '@Services/Api';
|
import { api, useSearcher, apiURL } from '@Services/Api';
|
||||||
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';
|
||||||
@ -75,6 +78,10 @@ const onWarehouseSaved = () => {
|
|||||||
searcher.search();
|
searcher.search();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const viewInventory = (warehouse) => {
|
||||||
|
router.push({ name: 'pos.warehouses.inventory', params: { id: warehouse.id } });
|
||||||
|
};
|
||||||
|
|
||||||
/** Ciclo de vida */
|
/** Ciclo de vida */
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
searcher.search();
|
searcher.search();
|
||||||
@ -114,7 +121,8 @@ onMounted(() => {
|
|||||||
<tr
|
<tr
|
||||||
v-for="warehouse in items"
|
v-for="warehouse in items"
|
||||||
:key="warehouse.id"
|
:key="warehouse.id"
|
||||||
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
@click="viewInventory(warehouse)"
|
||||||
|
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<p class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">{{ warehouse.code }}</p>
|
<p class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">{{ warehouse.code }}</p>
|
||||||
@ -144,11 +152,11 @@ onMounted(() => {
|
|||||||
{{ warehouse.is_active ? 'Activo' : 'Inactivo' }}
|
{{ warehouse.is_active ? 'Activo' : 'Inactivo' }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
<td class="px-6 py-4 whitespace-nowrap text-center" @click.stop>
|
||||||
<div class="flex items-center justify-center gap-2">
|
<div class="flex items-center justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
v-if="can('edit')"
|
v-if="can('edit')"
|
||||||
@click.stop="openEditModal(warehouse)"
|
@click="openEditModal(warehouse)"
|
||||||
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
||||||
title="Editar almacén"
|
title="Editar almacén"
|
||||||
>
|
>
|
||||||
@ -156,7 +164,7 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="can('destroy')"
|
v-if="can('destroy')"
|
||||||
@click.stop="openDeleteModal(warehouse)"
|
@click="openDeleteModal(warehouse)"
|
||||||
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||||
title="Eliminar almacén"
|
title="Eliminar almacén"
|
||||||
>
|
>
|
||||||
|
|||||||
288
src/pages/POS/Warehouses/Inventory.vue
Normal file
288
src/pages/POS/Warehouses/Inventory.vue
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, computed } from 'vue';
|
||||||
|
import { formatCurrency } from '@/utils/formatters';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useSearcher, apiURL } from '@Services/Api';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
import SearcherHead from '@Holos/Searcher.vue';
|
||||||
|
import Table from '@Holos/Table.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const warehouseId = computed(() => route.params.id);
|
||||||
|
const warehouse = ref(null);
|
||||||
|
const warehouses = ref([]);
|
||||||
|
const models = ref([]);
|
||||||
|
const categories = ref([]);
|
||||||
|
const selectedCategory = ref('');
|
||||||
|
const currentSearch = ref('');
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const searcher = useSearcher({
|
||||||
|
url: apiURL(`inventario/almacen/${warehouseId.value}`),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
|
||||||
|
// Procesar productos
|
||||||
|
let productsArray = [];
|
||||||
|
if (r.products && Array.isArray(r.products)) {
|
||||||
|
productsArray = r.products;
|
||||||
|
} else if (r.data?.products && Array.isArray(r.data.products)) {
|
||||||
|
productsArray = r.data.products;
|
||||||
|
}
|
||||||
|
|
||||||
|
models.value = {
|
||||||
|
data: productsArray,
|
||||||
|
total: productsArray.length
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener warehouse desde la lista de almacenes cargados
|
||||||
|
const whId = r.warehouse_id || r.data?.warehouse_id;
|
||||||
|
if (whId && warehouses.value.length > 0) {
|
||||||
|
warehouse.value = warehouses.value.find(w => w.id === whId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('❌ Error al cargar:', error);
|
||||||
|
models.value = { data: [], total: 0 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiURL('categorias'), {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${sessionStorage.token}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.data && result.data.categories && result.data.categories.data) {
|
||||||
|
categories.value = result.data.categories.data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al cargar categorías:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadWarehouses = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiURL('almacenes'), {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${sessionStorage.token}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.data && result.data.warehouses && result.data.warehouses.data) {
|
||||||
|
warehouses.value = result.data.warehouses.data;
|
||||||
|
} else if (result.data && Array.isArray(result.data)) {
|
||||||
|
warehouses.value = result.data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al cargar almacenes:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFilters = () => {
|
||||||
|
const filters = {
|
||||||
|
category_id: selectedCategory.value || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
searcher.search('', filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryChange = () => {
|
||||||
|
applyFilters();
|
||||||
|
};
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.push({ name: 'pos.warehouses.index' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSerials = (product) => {
|
||||||
|
router.push({ name: 'pos.inventory.serials', params: { id: product.id } });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ciclos */
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
loadWarehouses(),
|
||||||
|
loadCategories()
|
||||||
|
]);
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
</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 Almacenes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info del almacén -->
|
||||||
|
<div v-if="warehouse" 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">
|
||||||
|
{{ warehouse.name }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Código: {{ warehouse.code }}
|
||||||
|
<span v-if="warehouse.is_main" class="ml-2 px-2 py-0.5 text-xs font-semibold rounded bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300">
|
||||||
|
Principal
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Productos</p>
|
||||||
|
<p class="text-2xl font-bold text-indigo-600 dark:text-indigo-400">
|
||||||
|
{{ models.total || 0 }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SearcherHead
|
||||||
|
title="Inventario del Almacén"
|
||||||
|
placeholder="Buscar por nombre, SKU o código de barras..."
|
||||||
|
@search="(x) => {
|
||||||
|
currentSearch = x;
|
||||||
|
const filters = { category_id: selectedCategory || '' };
|
||||||
|
searcher.search(x, filters);
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
</SearcherHead>
|
||||||
|
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="pt-4 pb-2">
|
||||||
|
<div class="flex items-end gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Filtrar por categoría:</label>
|
||||||
|
<select
|
||||||
|
v-model="selectedCategory"
|
||||||
|
@change="handleCategoryChange"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">Todas las categorías</option>
|
||||||
|
<option v-for="category in categories" :key="category.id" :value="category.id">
|
||||||
|
{{ category.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
@click="selectedCategory = ''; currentSearch = ''; applyFilters();"
|
||||||
|
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors bg-gray-100 dark:bg-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-6 w-full">
|
||||||
|
<Table
|
||||||
|
:items="models"
|
||||||
|
:processing="searcher.processing"
|
||||||
|
@send-pagination="(page) => searcher.pagination(page)"
|
||||||
|
>
|
||||||
|
<template #head>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">SKU / CÓDIGO</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">PRODUCTO</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CATEGORÍA</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">PRECIO</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">STOCK</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">TOTAL</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="model in items"
|
||||||
|
:key="model.id"
|
||||||
|
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ model.sku }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ model.name }}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ model.category?.name || '-' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<div class="text-sm">
|
||||||
|
<p class="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatCurrency(model.price?.retail_price) }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Costo: {{ formatCurrency(model.price?.cost) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<span
|
||||||
|
class="font-bold text-base"
|
||||||
|
:class="{
|
||||||
|
'text-red-500': model.warehouse_stock < 10,
|
||||||
|
'text-green-600': model.warehouse_stock >= 10
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ model.warehouse_stock }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<div class="text-sm">
|
||||||
|
<p class="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatCurrency((model.price?.cost || 0) * (model.warehouse_stock || 0)) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<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="Ver números de serie"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="qr_code_2" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
<td colspan="7" class="table-cell text-center">
|
||||||
|
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
|
<GoogleIcon
|
||||||
|
name="inventory_2"
|
||||||
|
class="text-6xl mb-2 opacity-50"
|
||||||
|
/>
|
||||||
|
<p class="font-semibold">
|
||||||
|
No hay productos con stock en este almacén
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mt-2">
|
||||||
|
Los productos aparecerán aquí cuando tengan stock mayor a 0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -101,6 +101,12 @@ const router = createRouter({
|
|||||||
beforeEnter: (to, from, next) => can(next, 'warehouses.index'),
|
beforeEnter: (to, from, next) => can(next, 'warehouses.index'),
|
||||||
component: () => import('@Pages/POS/Warehouses/Index.vue')
|
component: () => import('@Pages/POS/Warehouses/Index.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'warehouses/:id/inventory',
|
||||||
|
name: 'pos.warehouses.inventory',
|
||||||
|
beforeEnter: (to, from, next) => can(next, 'warehouses.index'),
|
||||||
|
component: () => import('@Pages/POS/Warehouses/Inventory.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'movements',
|
path: 'movements',
|
||||||
name: 'pos.movements.index',
|
name: 'pos.movements.index',
|
||||||
|
|||||||
330
src/services/TicketDetailMovement.js
Normal file
330
src/services/TicketDetailMovement.js
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
import jsPDF from 'jspdf';
|
||||||
|
import { formatMoney } from '@/utils/formatters';
|
||||||
|
import printService from '@Services/printService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servicio para generar tickets de movimientos de inventario en formato PDF
|
||||||
|
*/
|
||||||
|
const TicketDetailMovement = {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detectar ubicación del usuario (ciudad y estado)
|
||||||
|
*/
|
||||||
|
async getUserLocation() {
|
||||||
|
return {
|
||||||
|
city: import.meta.env.VITE_BUSINESS_CITY || 'Villahermosa',
|
||||||
|
state: import.meta.env.VITE_BUSINESS_STATE || 'Tabasco',
|
||||||
|
country: import.meta.env.VITE_BUSINESS_COUNTRY || 'México',
|
||||||
|
phone: import.meta.env.VITE_BUSINESS_PHONE || 'Tel: (52) 0000-0000'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar ticket de movimiento de inventario
|
||||||
|
* @param {Object} movementData - Datos del movimiento
|
||||||
|
* @param {Object} options - Opciones de configuración
|
||||||
|
* @param {boolean} options.autoDownload - Descargar automáticamente el PDF
|
||||||
|
*/
|
||||||
|
async generateMovementTicket(movementData, options = {}) {
|
||||||
|
const {
|
||||||
|
businessName = 'HIKVISION DISTRIBUIDOR',
|
||||||
|
autoDownload = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Detectar ubicación del usuario
|
||||||
|
const location = await this.getUserLocation();
|
||||||
|
const businessAddress = `${location.city}, ${location.state}, ${location.country}`;
|
||||||
|
const businessPhone = location.phone;
|
||||||
|
|
||||||
|
// Crear documento PDF - Ticket térmico 80mm de ancho
|
||||||
|
const doc = new jsPDF({
|
||||||
|
orientation: 'portrait',
|
||||||
|
unit: 'mm',
|
||||||
|
format: [80, 200]
|
||||||
|
});
|
||||||
|
|
||||||
|
let yPosition = 10;
|
||||||
|
const leftMargin = 5;
|
||||||
|
const rightMargin = 75;
|
||||||
|
const centerX = 40;
|
||||||
|
|
||||||
|
// Colores
|
||||||
|
const blackColor = [0, 0, 0];
|
||||||
|
const darkGrayColor = [80, 80, 80];
|
||||||
|
|
||||||
|
// Configuración por tipo
|
||||||
|
const typeBadges = {
|
||||||
|
entry: { label: 'ENTRADA', color: [34, 197, 94] },
|
||||||
|
exit: { label: 'SALIDA', color: [239, 68, 68] },
|
||||||
|
transfer: { label: 'TRASPASO', color: [59, 130, 246] },
|
||||||
|
};
|
||||||
|
const badge = typeBadges[movementData.movement_type] || {
|
||||||
|
label: movementData.movement_type?.toUpperCase(),
|
||||||
|
color: darkGrayColor
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== HEADER =====
|
||||||
|
doc.setFontSize(14);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
doc.text(businessName, centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
doc.text(businessAddress, centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 4;
|
||||||
|
doc.text(businessPhone, centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
// Línea separadora
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.setDrawColor(...blackColor);
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// ===== TÍTULO CON TIPO =====
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...badge.color);
|
||||||
|
doc.text(badge.label, centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
doc.text('MOVIMIENTO DE INVENTARIO', centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 7;
|
||||||
|
|
||||||
|
// ===== INFORMACIÓN DEL MOVIMIENTO =====
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
|
||||||
|
// Fecha
|
||||||
|
const movementDate = new Date(movementData.created_at || Date.now());
|
||||||
|
const formattedDate = movementDate.toLocaleDateString('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
doc.text(`Fecha: ${formattedDate}`, leftMargin, yPosition);
|
||||||
|
yPosition += 4;
|
||||||
|
|
||||||
|
// Hora
|
||||||
|
const formattedTime = movementDate.toLocaleTimeString('es-MX', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
doc.text(`Hora: ${formattedTime}`, leftMargin, yPosition);
|
||||||
|
yPosition += 4;
|
||||||
|
|
||||||
|
// Usuario
|
||||||
|
if (movementData.user?.name) {
|
||||||
|
doc.text(`Usuario: ${movementData.user.name}`, leftMargin, yPosition);
|
||||||
|
yPosition += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Referencia de factura (si existe)
|
||||||
|
if (movementData.invoice_reference) {
|
||||||
|
doc.text(`Factura: ${movementData.invoice_reference}`, leftMargin, yPosition);
|
||||||
|
yPosition += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 2;
|
||||||
|
|
||||||
|
// ===== ALMACENES =====
|
||||||
|
// Almacén origen (si existe)
|
||||||
|
if (movementData.warehouse_from) {
|
||||||
|
doc.setFontSize(7);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...[239, 68, 68]); // red
|
||||||
|
doc.text('ORIGEN:', leftMargin, yPosition);
|
||||||
|
yPosition += 3;
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
doc.text(`${movementData.warehouse_from.name} (${movementData.warehouse_from.code})`, leftMargin + 2, yPosition);
|
||||||
|
yPosition += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Almacén destino (si existe)
|
||||||
|
if (movementData.warehouse_to) {
|
||||||
|
doc.setFontSize(7);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...[34, 197, 94]); // green
|
||||||
|
doc.text('DESTINO:', leftMargin, yPosition);
|
||||||
|
yPosition += 3;
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
doc.text(`${movementData.warehouse_to.name} (${movementData.warehouse_to.code})`, leftMargin + 2, yPosition);
|
||||||
|
yPosition += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 2;
|
||||||
|
|
||||||
|
// Línea separadora
|
||||||
|
doc.setDrawColor(...blackColor);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// ===== PRODUCTOS =====
|
||||||
|
const isMultiProduct = movementData.products && movementData.products.length > 0;
|
||||||
|
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
doc.text('PRODUCTOS', centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
if (isMultiProduct) {
|
||||||
|
// Múltiples productos
|
||||||
|
doc.setFontSize(7);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
doc.text('DESCRIPCIÓN', leftMargin, yPosition);
|
||||||
|
doc.text('CANT', 50, yPosition, { align: 'center' });
|
||||||
|
doc.text('COSTO', rightMargin, yPosition, { align: 'right' });
|
||||||
|
yPosition += 4;
|
||||||
|
|
||||||
|
// Línea bajo header
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 4;
|
||||||
|
|
||||||
|
// Iterar sobre los productos
|
||||||
|
movementData.products.forEach((product) => {
|
||||||
|
// Nombre del producto
|
||||||
|
const productName = product.inventory?.name || 'Producto';
|
||||||
|
const nameLines = doc.splitTextToSize(productName, 40);
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
doc.text(nameLines, leftMargin, yPosition);
|
||||||
|
yPosition += nameLines.length * 3.5;
|
||||||
|
|
||||||
|
// SKU
|
||||||
|
if (product.inventory?.sku) {
|
||||||
|
doc.setFontSize(7);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
doc.text(`SKU: ${product.inventory.sku}`, leftMargin, yPosition);
|
||||||
|
yPosition += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cantidad y Costo
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
|
||||||
|
doc.text(String(product.quantity), 50, yPosition, { align: 'center' });
|
||||||
|
doc.text(`$${formatMoney(product.unit_cost || 0)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
|
yPosition += 5;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Total
|
||||||
|
const totalQuantity = movementData.products.reduce((sum, p) => sum + Number(p.quantity), 0);
|
||||||
|
const totalCost = movementData.products.reduce((sum, p) => sum + (Number(p.quantity) * Number(p.unit_cost || 0)), 0);
|
||||||
|
|
||||||
|
yPosition += 2;
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('TOTAL:', leftMargin, yPosition);
|
||||||
|
doc.text(String(totalQuantity), 50, yPosition, { align: 'center' });
|
||||||
|
doc.text(`$${formatMoney(totalCost)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Producto individual
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
|
||||||
|
const productName = movementData.inventory?.name || 'Producto';
|
||||||
|
const nameLines = doc.splitTextToSize(productName, 65);
|
||||||
|
doc.text(nameLines, leftMargin, yPosition);
|
||||||
|
yPosition += nameLines.length * 4;
|
||||||
|
|
||||||
|
if (movementData.inventory?.sku) {
|
||||||
|
doc.setFontSize(7);
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
doc.text(`SKU: ${movementData.inventory.sku}`, leftMargin, yPosition);
|
||||||
|
yPosition += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
doc.text(`Cantidad: ${movementData.quantity}`, leftMargin, yPosition);
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
if (movementData.unit_cost) {
|
||||||
|
doc.text(`Costo unitario: $${formatMoney(movementData.unit_cost)}`, leftMargin, yPosition);
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
const total = Number(movementData.quantity) * Number(movementData.unit_cost);
|
||||||
|
doc.text(`Total: $${formatMoney(total)}`, leftMargin, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
} else {
|
||||||
|
yPosition += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notas (si existen)
|
||||||
|
if (movementData.notes) {
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
doc.setDrawColor(...darkGrayColor);
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
doc.setFontSize(7);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
doc.text('NOTAS:', leftMargin, yPosition);
|
||||||
|
yPosition += 3;
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
const notesLines = doc.splitTextToSize(movementData.notes, 65);
|
||||||
|
doc.text(notesLines, leftMargin, yPosition);
|
||||||
|
yPosition += notesLines.length * 3.5 + 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Línea decorativa doble
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.setDrawColor(...darkGrayColor);
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 0.5;
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
// Información de impresión
|
||||||
|
doc.setFontSize(7);
|
||||||
|
const currentDate = new Date().toLocaleString('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
doc.text(`Impreso: ${currentDate}`, centerX, yPosition, { align: 'center' });
|
||||||
|
|
||||||
|
// Guardar o retornar el PDF
|
||||||
|
if (autoDownload) {
|
||||||
|
const fileName = `movimiento-${badge.label.toLowerCase()}-${movementData.id}.pdf`;
|
||||||
|
doc.save(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TicketDetailMovement;
|
||||||
@ -27,10 +27,15 @@ const serialService = {
|
|||||||
/**
|
/**
|
||||||
* Obtener solo seriales disponibles de un producto
|
* Obtener solo seriales disponibles de un producto
|
||||||
* @param {Number} inventoryId - ID del inventario
|
* @param {Number} inventoryId - ID del inventario
|
||||||
|
* @param {Number} warehouseId - ID del almacén (opcional)
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
async getAvailableSerials(inventoryId) {
|
async getAvailableSerials(inventoryId, warehouseId = null) {
|
||||||
return this.getSerials(inventoryId, { status: 'disponible' });
|
const filters = { status: 'disponible' };
|
||||||
|
if (warehouseId) {
|
||||||
|
filters.warehouse_id = warehouseId;
|
||||||
|
}
|
||||||
|
return this.getSerials(inventoryId, filters);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,67 +57,6 @@ const serialService = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
* Eliminar un serial
|
||||||
* @param {Number} inventoryId - ID del inventario
|
* @param {Number} inventoryId - ID del inventario
|
||||||
|
|||||||
@ -13,8 +13,8 @@ const ticketService = {
|
|||||||
*/
|
*/
|
||||||
async getUserLocation() {
|
async getUserLocation() {
|
||||||
return {
|
return {
|
||||||
city: import.meta.env.VITE_BUSINESS_CITY || 'Ciudad',
|
city: import.meta.env.VITE_BUSINESS_CITY || 'Villahermosa',
|
||||||
state: import.meta.env.VITE_BUSINESS_STATE || 'Estado',
|
state: import.meta.env.VITE_BUSINESS_STATE || 'Tabasco',
|
||||||
country: import.meta.env.VITE_BUSINESS_COUNTRY || 'México',
|
country: import.meta.env.VITE_BUSINESS_COUNTRY || 'México',
|
||||||
phone: import.meta.env.VITE_BUSINESS_PHONE || 'Tel: (52) 0000-0000'
|
phone: import.meta.env.VITE_BUSINESS_PHONE || 'Tel: (52) 0000-0000'
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user