401 lines
20 KiB
Vue
401 lines
20 KiB
Vue
<script setup>
|
|
import { ref, watch, computed } from 'vue';
|
|
import { useApi, apiURL } from '@Services/Api';
|
|
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';
|
|
import Loader from '@Shared/Loader.vue';
|
|
|
|
/** Propiedades */
|
|
const props = defineProps({
|
|
show: Boolean,
|
|
movementId: Number,
|
|
movementData: Object
|
|
});
|
|
|
|
/** Eventos */
|
|
const emit = defineEmits(['close', 'edit']);
|
|
|
|
/** Estado */
|
|
const movement = ref(null);
|
|
const loading = ref(false);
|
|
|
|
const api = useApi();
|
|
|
|
/** Computed */
|
|
const isMultiProduct = computed(() => {
|
|
return movement.value?.products && movement.value.products.length > 0;
|
|
});
|
|
|
|
const totalQuantity = computed(() => {
|
|
if (!isMultiProduct.value) return movement.value?.quantity || 0;
|
|
return movement.value.products.reduce((sum, p) => sum + Number(p.quantity), 0);
|
|
});
|
|
|
|
const totalCost = computed(() => {
|
|
if (!isMultiProduct.value) return 0;
|
|
return movement.value.products.reduce((sum, p) => sum + (Number(p.quantity) * Number(p.unit_cost || 0)), 0);
|
|
});
|
|
|
|
const canEdit = computed(() => {
|
|
return hasPermission('movements.edit');
|
|
});
|
|
|
|
const canEditSingle = computed(() => {
|
|
return hasPermission('movements.edit') && !isMultiProduct.value;
|
|
});
|
|
|
|
/** Métodos */
|
|
const fetchDetail = () => {
|
|
if (!props.movementId) return;
|
|
|
|
if(props.movementData && props.movementData.products && props.movementData.products.length > 0) {
|
|
movement.value = props.movementData;
|
|
return;
|
|
}
|
|
|
|
loading.value = true;
|
|
movement.value = null;
|
|
|
|
api.get(apiURL(`movimientos/${props.movementId}`), {
|
|
onSuccess: (data) => {
|
|
movement.value = data.movement || data;
|
|
},
|
|
onError: () => {
|
|
window.Notify.error('Error al cargar el detalle del movimiento');
|
|
},
|
|
onFinish: () => {
|
|
loading.value = false;
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleClose = () => {
|
|
emit('close');
|
|
};
|
|
|
|
const handleEdit = () => {
|
|
// Si es un movimiento múltiple, usar el valor actual
|
|
if (isMultiProduct.value) {
|
|
emit('edit', movement.value);
|
|
return;
|
|
}
|
|
|
|
// Para movimientos individuales, hacer fetch para obtener datos completos (incluidos seriales)
|
|
loading.value = true;
|
|
api.get(apiURL(`movimientos/${movement.value.id}`), {
|
|
onSuccess: (data) => {
|
|
emit('edit', data.movement || data);
|
|
},
|
|
onError: () => {
|
|
window.Notify.error('Error al cargar el movimiento');
|
|
},
|
|
onFinish: () => {
|
|
loading.value = false;
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleEditProduct = (product) => {
|
|
// Crear un objeto de movimiento individual a partir del producto
|
|
const individualMovement = {
|
|
id: product.movement_id,
|
|
movement_type: movement.value.movement_type,
|
|
quantity: product.quantity,
|
|
unit_cost: product.unit_cost,
|
|
warehouse_id: movement.value.warehouse_to?.id,
|
|
invoice_reference: movement.value.invoice_reference,
|
|
notes: movement.value.notes,
|
|
inventory: product.inventory,
|
|
warehouse_to: movement.value.warehouse_to,
|
|
warehouse_from: movement.value.warehouse_from,
|
|
user: movement.value.user,
|
|
created_at: movement.value.created_at
|
|
};
|
|
emit('edit', individualMovement);
|
|
};
|
|
|
|
const getTypeBadge = (type) => {
|
|
const badges = {
|
|
entry: { label: 'Entrada', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: 'add_circle' },
|
|
exit: { label: 'Salida', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', icon: 'remove_circle' },
|
|
transfer: { label: 'Traspaso', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', icon: 'swap_horiz' },
|
|
sale: { label: 'Venta', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', icon: 'point_of_sale' },
|
|
return: { label: 'Devolución', class: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', icon: 'undo' },
|
|
};
|
|
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) {
|
|
fetchDetail();
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Modal :show="show" max-width="2xl" @close="handleClose">
|
|
<div class="p-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
|
Detalle del Movimiento
|
|
</h3>
|
|
<button
|
|
@click="handleClose"
|
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="loading" class="flex justify-center py-12">
|
|
<Loader />
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div v-else-if="movement" class="space-y-5">
|
|
<!-- Tipo badge -->
|
|
<div class="flex items-center gap-3">
|
|
<span :class="['inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-semibold', getTypeBadge(movement.movement_type).class]">
|
|
<GoogleIcon :name="getTypeBadge(movement.movement_type).icon" class="text-lg" />
|
|
{{ getTypeBadge(movement.movement_type).label }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Productos (múltiples) -->
|
|
<div v-if="isMultiProduct" class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-xl p-5 border border-gray-200 dark:border-gray-700">
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<GoogleIcon name="inventory_2" class="text-lg text-gray-600 dark:text-gray-400" />
|
|
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 uppercase">Productos</h4>
|
|
<span class="ml-auto text-xs font-semibold text-gray-500 dark:text-gray-400">
|
|
{{ movement.products.length }} producto(s)
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Tabla de productos -->
|
|
<div class="space-y-2">
|
|
<div
|
|
v-for="(product, index) in movement.products"
|
|
:key="index"
|
|
class="p-3 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex-1 grid grid-cols-12 gap-3 items-center text-sm">
|
|
<div class="col-span-12 sm:col-span-5">
|
|
<p class="font-semibold text-gray-900 dark:text-gray-100">
|
|
{{ product.inventory?.name || 'N/A' }}
|
|
</p>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
|
{{ product.inventory?.sku || 'N/A' }}
|
|
</p>
|
|
</div>
|
|
<div class="col-span-4 sm:col-span-2 text-center">
|
|
<span class="text-gray-500 dark:text-gray-400 text-xs">Cantidad:</span>
|
|
<p class="font-bold text-gray-900 dark:text-gray-100">{{ product.quantity }}</p>
|
|
</div>
|
|
<div class="col-span-4 sm:col-span-2 text-center">
|
|
<span class="text-gray-500 dark:text-gray-400 text-xs">Costo unit.:</span>
|
|
<p class="font-semibold text-gray-900 dark:text-gray-100">
|
|
${{ Number(product.unit_cost || 0).toFixed(2) }}
|
|
</p>
|
|
</div>
|
|
<div class="col-span-4 sm:col-span-3 text-right">
|
|
<span class="text-gray-500 dark:text-gray-400 text-xs">Subtotal:</span>
|
|
<p class="font-bold text-indigo-900 dark:text-indigo-100">
|
|
${{ (product.quantity * Number(product.unit_cost || 0)).toFixed(2) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<!-- Botón editar -->
|
|
<button
|
|
v-if="canEdit"
|
|
type="button"
|
|
@click="handleEditProduct(product)"
|
|
class="flex items-center justify-center w-8 h-8 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-lg transition-colors shrink-0"
|
|
title="Editar producto"
|
|
>
|
|
<GoogleIcon name="edit" class="text-lg" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Total -->
|
|
<div class="mt-4 pt-4 border-t border-gray-300 dark:border-gray-600">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-4">
|
|
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
|
Cantidad total: <span class="text-gray-900 dark:text-gray-100">{{ totalQuantity }}</span>
|
|
</span>
|
|
</div>
|
|
<div class="text-right">
|
|
<span class="text-sm text-gray-600 dark:text-gray-400">Costo total:</span>
|
|
<p class="text-xl font-bold text-indigo-900 dark:text-indigo-100">
|
|
${{ totalCost.toFixed(2) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Producto (individual) -->
|
|
<div v-else class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-xl p-5 border border-gray-200 dark:border-gray-700">
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<GoogleIcon name="inventory_2" class="text-lg text-gray-600 dark:text-gray-400" />
|
|
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 uppercase">Producto</h4>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-3 text-sm">
|
|
<div>
|
|
<span class="text-gray-500 dark:text-gray-400">Nombre:</span>
|
|
<p class="font-semibold text-gray-900 dark:text-gray-100">{{ movement.inventory?.name || 'N/A' }}</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500 dark:text-gray-400">SKU:</span>
|
|
<p class="font-mono font-semibold text-gray-900 dark:text-gray-100">{{ movement.inventory?.sku || 'N/A' }}</p>
|
|
</div>
|
|
<div>
|
|
<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>
|
|
|
|
<!-- Almacenes -->
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<!-- Origen -->
|
|
<div v-if="movement.warehouse_from" class="bg-red-50 dark:bg-red-900/10 rounded-xl p-4 border border-red-200 dark:border-red-800">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<GoogleIcon name="logout" class="text-lg text-red-600 dark:text-red-400" />
|
|
<h4 class="text-xs font-bold text-red-700 dark:text-red-300 uppercase">Origen</h4>
|
|
</div>
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.warehouse_from.name }}</p>
|
|
<p class="text-xs font-mono text-gray-500 dark:text-gray-400">{{ movement.warehouse_from.code }}</p>
|
|
</div>
|
|
|
|
<!-- Destino -->
|
|
<div v-if="movement.warehouse_to" class="bg-green-50 dark:bg-green-900/10 rounded-xl p-4 border border-green-200 dark:border-green-800">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<GoogleIcon name="login" class="text-lg text-green-600 dark:text-green-400" />
|
|
<h4 class="text-xs font-bold text-green-700 dark:text-green-300 uppercase">Destino</h4>
|
|
</div>
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.warehouse_to.name }}</p>
|
|
<p class="text-xs font-mono text-gray-500 dark:text-gray-400">{{ movement.warehouse_to.code }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info adicional -->
|
|
<div class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-xl p-5 border border-gray-200 dark:border-gray-700">
|
|
<div class="grid grid-cols-2 gap-3 text-sm">
|
|
<div>
|
|
<span class="text-gray-500 dark:text-gray-400">Usuario:</span>
|
|
<p class="font-semibold text-gray-900 dark:text-gray-100">{{ movement.user?.name || 'N/A' }}</p>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500 dark:text-gray-400">Fecha:</span>
|
|
<p class="font-semibold text-gray-900 dark:text-gray-100">{{ formatDate(movement.created_at) }}</p>
|
|
</div>
|
|
<div v-if="movement.notes" class="col-span-2">
|
|
<span class="text-gray-500 dark:text-gray-400">Notas:</span>
|
|
<p class="text-gray-900 dark:text-gray-100 italic">{{ movement.notes }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Referencia de factura -->
|
|
<div v-if="movement.invoice_reference" class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-xl p-5 border border-gray-200 dark:border-gray-700">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<GoogleIcon name="receipt" class="text-lg text-gray-600 dark:text-gray-400" />
|
|
<h4 class="text-xs font-bold text-gray-700 dark:text-gray-300 uppercase">Referencia de Factura</h4>
|
|
</div>
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.invoice_reference }}</p>
|
|
</div>
|
|
|
|
<!-- Proveedor -->
|
|
<div v-if="movement.supplier" class="bg-gradient-to-br from-indigo-50 to-indigo-100 dark:from-indigo-900/20 dark:to-indigo-800/20 rounded-xl p-5 border border-indigo-200 dark:border-indigo-700">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<GoogleIcon name="store" class="text-lg text-indigo-600 dark:text-indigo-400" />
|
|
<h4 class="text-xs font-bold text-indigo-700 dark:text-indigo-300 uppercase">Proveedor</h4>
|
|
</div>
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.supplier.business_name }}</p>
|
|
<p v-if="movement.supplier.rfc" class="text-xs text-gray-500 dark:text-gray-400 mt-1">RFC: {{ movement.supplier.rfc }}</p>
|
|
</div>
|
|
|
|
<!-- Almacenes -->
|
|
<div v-if="movement.movement_type === 'transfer' && (movement.warehouse_from || movement.warehouse_to)" class="grid grid-cols-2 gap-3">
|
|
<!-- Origen -->
|
|
<div v-if="movement.warehouse_from" class="bg-red-50 dark:bg-red-900/10 rounded-xl p-4 border border-red-200 dark:border-red-800">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<GoogleIcon name="logout" class="text-lg text-red-600 dark:text-red-400" />
|
|
<h4 class="text-xs font-bold text-red-700 dark:text-red-300 uppercase">Origen</h4>
|
|
</div>
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.warehouse_from.name }}</p>
|
|
<p class="text-xs font-mono text-gray-500 dark:text-gray-400">{{ movement.warehouse_from.code }}</p>
|
|
</div>
|
|
|
|
<!-- Destino -->
|
|
<div v-if="movement.warehouse_to" class="bg-green-50 dark:bg-green-900/10 rounded-xl p-4 border border-green-200 dark:border-green-800">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<GoogleIcon name="login" class="text-lg text-green-600 dark:text-green-400" />
|
|
<h4 class="text-xs font-bold text-green-700 dark:text-green-300 uppercase">Destino</h4>
|
|
</div>
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.warehouse_to.name }}</p>
|
|
<p class="text-xs font-mono text-gray-500 dark:text-gray-400">{{ movement.warehouse_to.code }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<button
|
|
type="button"
|
|
@click="handleClose"
|
|
class="px-5 py-2.5 text-sm font-semibold text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
>
|
|
Cerrar
|
|
</button>
|
|
<button
|
|
v-if="canEditSingle"
|
|
type="button"
|
|
@click="handleEdit"
|
|
class="flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
|
|
>
|
|
<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>
|
|
</template>
|