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:
Juan Felipe Zapata Moreno 2026-02-08 20:26:59 -06:00
parent 6c70d1ba4f
commit 093cea3c4c
17 changed files with 1048 additions and 351 deletions

View File

@ -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)

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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&#10;Ejemplo:&#10;IMEI-123456&#10;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">

View File

@ -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>

View File

@ -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')"

View File

@ -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>

View File

@ -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');

View File

@ -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" />

View File

@ -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">

View File

@ -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"
> >

View 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>

View File

@ -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',

View 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;

View File

@ -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

View File

@ -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'
}; };