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>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
import Modal from '@Holos/Modal.vue';
|
||||
import serialService from '@Services/serialService';
|
||||
@ -17,6 +17,10 @@ const props = defineProps({
|
||||
excludeSerials: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
warehouseId: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
@ -54,7 +58,7 @@ const canConfirm = computed(() => {
|
||||
const loadSerials = async () => {
|
||||
loading.value = true;
|
||||
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
|
||||
availableSerials.value = (response.serials?.data || []).filter(
|
||||
serial => !props.excludeSerials.includes(serial.serial_number)
|
||||
|
||||
@ -103,7 +103,7 @@ const importProducts = async () => {
|
||||
const { imported, skipped, errors } = data.data;
|
||||
|
||||
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) {
|
||||
@ -184,10 +184,18 @@ const closeModal = () => {
|
||||
<p class="font-semibold mb-2">Instrucciones:</p>
|
||||
<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>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>Haz clic en "Importar" para procesar el archivo</li>
|
||||
</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>
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
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 SearcherHead from '@Holos/Searcher.vue';
|
||||
import Table from '@Holos/Table.vue';
|
||||
import Modal from '@Holos/Modal.vue';
|
||||
import FormInput from '@Holos/Form/Input.vue';
|
||||
import FormError from '@Holos/Form/Elements/Error.vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
|
||||
const route = useRoute();
|
||||
@ -21,28 +19,9 @@ const serials = ref({ data: [], total: 0 });
|
||||
const activeTab = ref('disponible');
|
||||
|
||||
// Modales
|
||||
const showCreateModal = ref(false);
|
||||
const showEditModal = ref(false);
|
||||
const showDeleteModal = ref(false);
|
||||
const showBulkModal = ref(false);
|
||||
const editingSerial = ref(null);
|
||||
const deletingSerial = ref(null);
|
||||
|
||||
// Bulk import
|
||||
const bulkSerials = ref('');
|
||||
const bulkProcessing = ref(false);
|
||||
|
||||
/** Formularios */
|
||||
const form = useForm({
|
||||
serial_number: '',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
const editForm = useForm({
|
||||
serial_number: '',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
/** Buscador */
|
||||
const searcher = useSearcher({
|
||||
url: apiURL(`inventario/${route.params.id}/serials`),
|
||||
@ -76,62 +55,6 @@ const switchTab = (tab) => {
|
||||
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
|
||||
const openDeleteModal = (serial) => {
|
||||
deletingSerial.value = serial;
|
||||
@ -156,7 +79,7 @@ const confirmDelete = async () => {
|
||||
|
||||
// Navegación
|
||||
const goBack = () => {
|
||||
router.push({ name: 'pos.inventory.index' });
|
||||
router.go(-1);
|
||||
};
|
||||
|
||||
// Badge de estado
|
||||
@ -220,16 +143,7 @@ onMounted(() => {
|
||||
title="Números de Serie"
|
||||
placeholder="Buscar por número de serie..."
|
||||
@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 -->
|
||||
<div class="mt-4 border-b border-gray-200 dark:border-gray-700">
|
||||
@ -318,14 +232,6 @@ onMounted(() => {
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button
|
||||
v-if="serial.status === 'disponible'"
|
||||
@click="openEditModal(serial)"
|
||||
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
||||
title="Editar serial"
|
||||
>
|
||||
<GoogleIcon name="edit" class="text-xl" />
|
||||
</button>
|
||||
<button
|
||||
v-if="serial.status === 'disponible'"
|
||||
@click="openDeleteModal(serial)"
|
||||
@ -337,7 +243,7 @@ onMounted(() => {
|
||||
<span
|
||||
v-if="serial.status === 'vendido'"
|
||||
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}` }}
|
||||
</span>
|
||||
@ -364,132 +270,6 @@ onMounted(() => {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Modal Crear Serial -->
|
||||
<Modal :show="showCreateModal" max-width="sm" @close="closeCreateModal">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
Agregar Número de Serie
|
||||
</h3>
|
||||
<button
|
||||
@click="closeCreateModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<GoogleIcon name="close" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="createSerial" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
NÚMERO DE SERIE *
|
||||
</label>
|
||||
<FormInput
|
||||
v-model="form.serial_number"
|
||||
type="text"
|
||||
placeholder="Ej: ABC123456789"
|
||||
required
|
||||
/>
|
||||
<FormError :message="form.errors?.serial_number" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
NOTAS (OPCIONAL)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.notes"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100"
|
||||
rows="2"
|
||||
placeholder="Notas adicionales..."
|
||||
></textarea>
|
||||
<FormError :message="form.errors?.notes" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeCreateModal"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="px-4 py-2 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span v-if="form.processing">Guardando...</span>
|
||||
<span v-else>Guardar</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Modal Editar Serial -->
|
||||
<Modal :show="showEditModal" max-width="sm" @close="closeEditModal">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
Editar Número de Serie
|
||||
</h3>
|
||||
<button
|
||||
@click="closeEditModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<GoogleIcon name="close" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="updateSerial" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
NÚMERO DE SERIE *
|
||||
</label>
|
||||
<FormInput
|
||||
v-model="editForm.serial_number"
|
||||
type="text"
|
||||
placeholder="Ej: ABC123456789"
|
||||
required
|
||||
/>
|
||||
<FormError :message="editForm.errors?.serial_number" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
NOTAS (OPCIONAL)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="editForm.notes"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100"
|
||||
rows="2"
|
||||
placeholder="Notas adicionales..."
|
||||
></textarea>
|
||||
<FormError :message="editForm.errors?.notes" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeEditModal"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="editForm.processing"
|
||||
class="px-4 py-2 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span v-if="editForm.processing">Actualizando...</span>
|
||||
<span v-else>Actualizar</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Modal Eliminar Serial -->
|
||||
<Modal :show="showDeleteModal" max-width="sm" @close="closeDeleteModal">
|
||||
<div class="p-6">
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useApi, apiURL } from '@Services/Api';
|
||||
import { formatDate } from '@/utils/formatters';
|
||||
import { formatDate, formatCurrency } from '@/utils/formatters';
|
||||
import { hasPermission } from '@Plugins/RolePermission';
|
||||
import TicketDetailMovement from '@Services/TicketDetailMovement';
|
||||
|
||||
import Modal from '@Holos/Modal.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' };
|
||||
};
|
||||
|
||||
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 */
|
||||
watch(() => props.show, (isShown) => {
|
||||
if (isShown && props.movementId) {
|
||||
@ -246,6 +259,10 @@ watch(() => props.show, (isShown) => {
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@ -313,6 +330,14 @@ watch(() => props.show, (isShown) => {
|
||||
<GoogleIcon name="edit" class="text-lg" />
|
||||
Editar
|
||||
</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>
|
||||
</Modal>
|
||||
|
||||
@ -60,6 +60,12 @@ const loadData = () => {
|
||||
api.get(apiURL('almacenes'), {
|
||||
onSuccess: (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'), {
|
||||
@ -78,7 +84,10 @@ const addProduct = () => {
|
||||
product_name: '',
|
||||
product_sku: '',
|
||||
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].product_name = product.name;
|
||||
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
|
||||
selectedProducts.value[currentSearchIndex.value].track_serials = product.track_serials || false;
|
||||
}
|
||||
|
||||
productSearch.value = '';
|
||||
@ -161,13 +171,57 @@ const selectProduct = (product) => {
|
||||
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 = () => {
|
||||
// Preparar datos del formulario
|
||||
form.products = selectedProducts.value.map(item => ({
|
||||
inventory_id: item.inventory_id,
|
||||
quantity: Number(item.quantity),
|
||||
unit_cost: Number(item.unit_cost)
|
||||
}));
|
||||
form.products = selectedProducts.value.map(item => {
|
||||
const productData = {
|
||||
inventory_id: item.inventory_id,
|
||||
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'), {
|
||||
onSuccess: () => {
|
||||
@ -400,6 +454,30 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
||||
</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 -->
|
||||
<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">
|
||||
|
||||
@ -7,6 +7,7 @@ import Modal from '@Holos/Modal.vue';
|
||||
import FormInput from '@Holos/Form/Input.vue';
|
||||
import FormError from '@Holos/Form/Elements/Error.vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
import SerialSelector from '@Components/POS/SerialSelector.vue';
|
||||
|
||||
/** Eventos */
|
||||
const emit = defineEmits(['close', 'created']);
|
||||
@ -34,6 +35,11 @@ const productSuggestions = ref([]);
|
||||
const showProductSuggestions = ref(false);
|
||||
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 */
|
||||
const form = useForm({
|
||||
warehouse_id: '',
|
||||
@ -94,7 +100,10 @@ const addProduct = () => {
|
||||
product_name: '',
|
||||
product_sku: '',
|
||||
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].product_name = product.name;
|
||||
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 = '';
|
||||
@ -186,14 +197,66 @@ const selectProduct = (product) => {
|
||||
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 = () => {
|
||||
// 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
|
||||
form.products = selectedProducts.value.map(item => ({
|
||||
inventory_id: item.inventory_id,
|
||||
quantity: Number(item.quantity)
|
||||
}));
|
||||
form.products = selectedProducts.value.map(item => {
|
||||
const productData = {
|
||||
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'), {
|
||||
data: {
|
||||
warehouse_id: form.warehouse_id,
|
||||
reference: form.reference,
|
||||
notes: form.notes,
|
||||
products: form.products
|
||||
},
|
||||
onSuccess: () => {
|
||||
window.Notify.success('Salida registrada correctamente');
|
||||
emit('created');
|
||||
@ -403,8 +466,12 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
||||
min="1"
|
||||
step="1"
|
||||
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>
|
||||
|
||||
<!-- Botón eliminar -->
|
||||
@ -420,6 +487,52 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
||||
<GoogleIcon name="delete" class="text-lg" />
|
||||
</button>
|
||||
</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>
|
||||
@ -463,5 +576,16 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -196,7 +196,7 @@ onMounted(() => {
|
||||
@click="showKardexModal = true"
|
||||
>
|
||||
<GoogleIcon name="download" class="text-lg" />
|
||||
Kardex
|
||||
Reporte
|
||||
</button>
|
||||
<button
|
||||
v-if="can('create')"
|
||||
|
||||
@ -7,6 +7,7 @@ import Modal from '@Holos/Modal.vue';
|
||||
import FormInput from '@Holos/Form/Input.vue';
|
||||
import FormError from '@Holos/Form/Elements/Error.vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
import SerialSelector from '@Components/POS/SerialSelector.vue';
|
||||
|
||||
/** Eventos */
|
||||
const emit = defineEmits(['close', 'created']);
|
||||
@ -35,6 +36,11 @@ const productSuggestions = ref([]);
|
||||
const showProductSuggestions = ref(false);
|
||||
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 */
|
||||
const form = useForm({
|
||||
warehouse_from_id: '',
|
||||
@ -99,7 +105,12 @@ const loadProducts = (warehouseId, isOrigin = true) => {
|
||||
const addProduct = () => {
|
||||
selectedProducts.value.push({
|
||||
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].product_name = product.name;
|
||||
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 = '';
|
||||
@ -191,14 +204,75 @@ const selectProduct = (product) => {
|
||||
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 = () => {
|
||||
// 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
|
||||
form.products = selectedProducts.value.map(item => ({
|
||||
inventory_id: item.inventory_id,
|
||||
quantity: Number(item.quantity)
|
||||
}));
|
||||
form.products = selectedProducts.value.map(item => {
|
||||
const productData = {
|
||||
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'), {
|
||||
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: () => {
|
||||
window.Notify.success('Traspaso registrado correctamente');
|
||||
emit('created');
|
||||
@ -448,8 +522,12 @@ watch(() => form.warehouse_to_id, (newWarehouseId, oldWarehouseId) => {
|
||||
min="1"
|
||||
step="1"
|
||||
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>
|
||||
|
||||
<!-- Botón eliminar -->
|
||||
@ -465,6 +543,52 @@ watch(() => form.warehouse_to_id, (newWarehouseId, oldWarehouseId) => {
|
||||
<GoogleIcon name="delete" class="text-lg" />
|
||||
</button>
|
||||
</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>
|
||||
@ -508,5 +632,16 @@ watch(() => form.warehouse_to_id, (newWarehouseId, oldWarehouseId) => {
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -93,9 +93,6 @@ const getStatusLabel = (status) => {
|
||||
const handleDownloadTicket = (sale) => {
|
||||
try {
|
||||
ticketService.generateSaleTicket(sale, {
|
||||
businessName: 'HIKVISION DISTRIBUIDOR',
|
||||
businessAddress: 'Ciudad de México, México',
|
||||
businessPhone: 'Tel: (55) 1234-5678',
|
||||
autoDownload: true
|
||||
});
|
||||
window.Notify.success('Ticket descargado correctamente');
|
||||
|
||||
@ -89,21 +89,6 @@ const closeModal = () => {
|
||||
<FormError :message="form.errors?.name" />
|
||||
</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 -->
|
||||
<div class="col-span-2">
|
||||
<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"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Activar almacén inmediatamente
|
||||
Activar almacén
|
||||
</span>
|
||||
</label>
|
||||
<FormError :message="form.errors?.is_active" />
|
||||
|
||||
@ -100,21 +100,6 @@ watch(() => props.warehouse, (newWarehouse) => {
|
||||
<FormError :message="form.errors?.name" />
|
||||
</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 -->
|
||||
<div class="col-span-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { api, useSearcher, apiURL } from '@Services/Api';
|
||||
import { can } from './Module.js';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
import SearcherHead from '@Holos/Searcher.vue';
|
||||
import Table from '@Holos/Table.vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
@ -75,6 +78,10 @@ const onWarehouseSaved = () => {
|
||||
searcher.search();
|
||||
};
|
||||
|
||||
const viewInventory = (warehouse) => {
|
||||
router.push({ name: 'pos.warehouses.inventory', params: { id: warehouse.id } });
|
||||
};
|
||||
|
||||
/** Ciclo de vida */
|
||||
onMounted(() => {
|
||||
searcher.search();
|
||||
@ -114,7 +121,8 @@ onMounted(() => {
|
||||
<tr
|
||||
v-for="warehouse in items"
|
||||
: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">
|
||||
<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' }}
|
||||
</span>
|
||||
</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">
|
||||
<button
|
||||
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"
|
||||
title="Editar almacén"
|
||||
>
|
||||
@ -156,7 +164,7 @@ onMounted(() => {
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
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'),
|
||||
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',
|
||||
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
|
||||
* @param {Number} inventoryId - ID del inventario
|
||||
* @param {Number} warehouseId - ID del almacén (opcional)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async getAvailableSerials(inventoryId) {
|
||||
return this.getSerials(inventoryId, { status: 'disponible' });
|
||||
async getAvailableSerials(inventoryId, warehouseId = null) {
|
||||
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
|
||||
* @param {Number} inventoryId - ID del inventario
|
||||
|
||||
@ -13,8 +13,8 @@ const ticketService = {
|
||||
*/
|
||||
async getUserLocation() {
|
||||
return {
|
||||
city: import.meta.env.VITE_BUSINESS_CITY || 'Ciudad',
|
||||
state: import.meta.env.VITE_BUSINESS_STATE || 'Estado',
|
||||
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'
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user