feat: Implementar funcionalidad de descuento de cliente en CheckoutModal
Se agregó la funcionalidad de búsqueda de clientes para recuperar sus detalles y descuentos. Se calcula el total estimado aplicando el descuento del cliente. Se actualizó la visualización del total para mostrar los montos con descuento cuando corresponda. Se mejoró la validación del pago para considerar los descuentos en los pagos en efectivo. Se emiten los datos del cliente durante la confirmación del pago para su procesamiento en el backend. feat: Crear componente ToggleButton para la activación de niveles (tiers) Se implementó el componente ToggleButton para activar/desactivar los niveles de los clientes. Se integró la funcionalidad de alternancia (toggle) con la API para actualizar el estado del nivel. refactor: Actualizar páginas de Clientes y Niveles para la nueva estructura de niveles Se ajustaron las páginas de Clientes y Niveles para reflejar las nuevas propiedades y estructura de los niveles. Se actualizaron los elementos de la interfaz (UI) para mostrar información del nivel, incluyendo descuentos y límites de compra. Se mejoró el modal de confirmación de eliminación para reflejar el contexto de la eliminación del nivel. fix: Formatear y mostrar correctamente la información del nivel en las vistas Stats e Index Se corrigió la visualización de los nombres de los niveles y los porcentajes de descuento en varios componentes. Se aseguró el manejo adecuado de los datos de los niveles en formularios y modales. chore: Actualizar ticketService para incluir información de descuentos en los recibos Se agregó lógica para mostrar los detalles del descuento en los tickets impresos si corresponde.
This commit is contained in:
parent
fb2f7cb068
commit
27825a7dd4
@ -1,5 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { useApi, apiURL } from '@Services/Api';
|
||||||
|
import { formatCurrency } from '@/utils/formatters';
|
||||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
import Modal from '@Holos/Modal.vue';
|
import Modal from '@Holos/Modal.vue';
|
||||||
|
|
||||||
@ -25,6 +27,10 @@ const emit = defineEmits(['close', 'confirm']);
|
|||||||
/** Estado */
|
/** Estado */
|
||||||
const selectedMethod = ref('cash');
|
const selectedMethod = ref('cash');
|
||||||
const cashReceived = ref(0);
|
const cashReceived = ref(0);
|
||||||
|
const clientNumber = ref('');
|
||||||
|
const selectedClient = ref(null);
|
||||||
|
const searchingClient = ref(false);
|
||||||
|
const clientNotFound = ref(false);
|
||||||
|
|
||||||
/** Computados */
|
/** Computados */
|
||||||
const formattedSubtotal = computed(() => {
|
const formattedSubtotal = computed(() => {
|
||||||
@ -41,6 +47,19 @@ const formattedTax = computed(() => {
|
|||||||
}).format(props.cart.tax);
|
}).format(props.cart.tax);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Descuento del cliente
|
||||||
|
const clientDiscount = computed(() => {
|
||||||
|
if (selectedClient.value?.tier?.discount_percentage) {
|
||||||
|
return parseFloat(selectedClient.value.tier.discount_percentage);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const estimatedDiscountAmount = computed(() => {
|
||||||
|
return (props.cart.subtotal * clientDiscount.value) / 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Total sin descuento
|
||||||
const formattedTotal = computed(() => {
|
const formattedTotal = computed(() => {
|
||||||
return new Intl.NumberFormat('es-MX', {
|
return new Intl.NumberFormat('es-MX', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@ -48,10 +67,10 @@ const formattedTotal = computed(() => {
|
|||||||
}).format(props.cart.total);
|
}).format(props.cart.total);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cálculo de cambio
|
// Cálculo de cambio (usando total sin descuento)
|
||||||
const change = computed(() => {
|
const change = computed(() => {
|
||||||
if (selectedMethod.value === 'cash' && cashReceived.value > 0) {
|
if (selectedMethod.value === 'cash' && cashReceived.value > 0) {
|
||||||
return cashReceived.value - props.cart.total;
|
return cashReceived.value - estimatedTotalWithDiscount.value;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
@ -59,7 +78,7 @@ const change = computed(() => {
|
|||||||
// Validar que el efectivo sea suficiente
|
// Validar que el efectivo sea suficiente
|
||||||
const isValidCash = computed(() => {
|
const isValidCash = computed(() => {
|
||||||
if (selectedMethod.value === 'cash') {
|
if (selectedMethod.value === 'cash') {
|
||||||
return cashReceived.value >= props.cart.total;
|
return cashReceived.value >= estimatedTotalWithDiscount.value;
|
||||||
}
|
}
|
||||||
return true; // Tarjetas siempre válidas
|
return true; // Tarjetas siempre válidas
|
||||||
});
|
});
|
||||||
@ -96,6 +115,54 @@ const paymentMethods = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Métodos de búsqueda de cliente */
|
||||||
|
const searchClient = () => {
|
||||||
|
if (!clientNumber.value || clientNumber.value.trim() === '') {
|
||||||
|
selectedClient.value = null;
|
||||||
|
clientNotFound.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchingClient.value = true;
|
||||||
|
clientNotFound.value = false;
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
let urlParams = `client_number=${clientNumber.value.trim()}&with=tier`;
|
||||||
|
api.get(apiURL(`clients?${urlParams}`), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.clients && data.clients.data.length > 0) {
|
||||||
|
const client = data.clients.data[0];
|
||||||
|
selectedClient.value = client;
|
||||||
|
clientNotFound.value = false;
|
||||||
|
window.Notify.success(`Cliente ${client.name} encontrado`);
|
||||||
|
} else {
|
||||||
|
selectedClient.value = null;
|
||||||
|
clientNotFound.value = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFail: (data) => {
|
||||||
|
selectedClient.value = null;
|
||||||
|
clientNotFound.value = true;
|
||||||
|
window.Notify.error(data.message || 'Error al buscar cliente');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
selectedClient.value = null;
|
||||||
|
clientNotFound.value = true;
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
searchingClient.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearClient = () => {
|
||||||
|
clientNumber.value = '';
|
||||||
|
selectedClient.value = null;
|
||||||
|
clientNotFound.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** Watchers */
|
/** Watchers */
|
||||||
// Limpiar cashReceived cuando cambia el método de pago
|
// Limpiar cashReceived cuando cambia el método de pago
|
||||||
watch(selectedMethod, (newMethod) => {
|
watch(selectedMethod, (newMethod) => {
|
||||||
@ -104,10 +171,13 @@ watch(selectedMethod, (newMethod) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resetear cashReceived cuando se abre el modal
|
// Resetear cuando se abre el modal
|
||||||
watch(() => props.show, (isShown) => {
|
watch(() => props.show, (isShown) => {
|
||||||
if (isShown) {
|
if (isShown) {
|
||||||
cashReceived.value = 0;
|
cashReceived.value = 0;
|
||||||
|
clientNumber.value = '';
|
||||||
|
selectedClient.value = null;
|
||||||
|
clientNotFound.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -119,7 +189,7 @@ const handleConfirm = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validaciones específicas para efectivo
|
// Validaciones específicas para efectivo (usando total sin descuento)
|
||||||
if (selectedMethod.value === 'cash') {
|
if (selectedMethod.value === 'cash') {
|
||||||
if (!cashReceived.value || cashReceived.value <= 0) {
|
if (!cashReceived.value || cashReceived.value <= 0) {
|
||||||
window.Notify.error('Ingresa el efectivo recibido');
|
window.Notify.error('Ingresa el efectivo recibido');
|
||||||
@ -132,10 +202,12 @@ const handleConfirm = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emitir evento con datos completos
|
// Emitir evento con client_number para que el backend aplique el descuento automáticamente
|
||||||
emit('confirm', {
|
emit('confirm', {
|
||||||
paymentMethod: selectedMethod.value,
|
paymentMethod: selectedMethod.value,
|
||||||
cashReceived: selectedMethod.value === 'cash' ? parseFloat(cashReceived.value) : null
|
cashReceived: selectedMethod.value === 'cash' ? parseFloat(cashReceived.value) : null,
|
||||||
|
clientNumber: selectedClient.value ? selectedClient.value.client_number : null,
|
||||||
|
clientData: selectedClient.value // Enviar datos completos del cliente para referencia
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -143,9 +215,25 @@ const handleClose = () => {
|
|||||||
if (!props.processing) {
|
if (!props.processing) {
|
||||||
selectedMethod.value = 'cash';
|
selectedMethod.value = 'cash';
|
||||||
cashReceived.value = 0;
|
cashReceived.value = 0;
|
||||||
|
clearClient();
|
||||||
emit('close');
|
emit('close');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Total estimado con descuento (para mostrar al operador)
|
||||||
|
const estimatedTotalWithDiscount = computed(() => {
|
||||||
|
if (clientDiscount.value > 0) {
|
||||||
|
return props.cart.total - estimatedDiscountAmount.value;
|
||||||
|
}
|
||||||
|
return props.cart.total;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedEstimatedTotal = computed(() => {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'MXN'
|
||||||
|
}).format(estimatedTotalWithDiscount.value);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -216,11 +304,137 @@ const handleClose = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Descuento del cliente (informativo) -->
|
||||||
|
<div v-if="selectedClient && clientDiscount > 0" class="pt-3 border-t border-gray-300 dark:border-gray-600">
|
||||||
|
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-lg p-3">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<GoogleIcon name="info" class="text-green-600 dark:text-green-400 text-sm" />
|
||||||
|
<span class="text-xs text-green-700 dark:text-green-300 font-semibold">Descuento a aplicar</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-green-600 dark:text-green-400 font-semibold">{{ clientDiscount.toFixed(2) }}% ({{ selectedClient.tier?.tier_name }})</span>
|
||||||
|
<span class="font-semibold text-green-600 dark:text-green-400">≈ -${{ estimatedDiscountAmount.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-green-700 dark:text-green-400 mt-1">
|
||||||
|
El descuento se aplicará automáticamente al procesar la venta
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Total a pagar -->
|
<!-- Total a pagar -->
|
||||||
<div class="pt-3 border-t-2 border-gray-300 dark:border-gray-600">
|
<div class="pt-3 border-t-2 border-gray-300 dark:border-gray-600 space-y-2">
|
||||||
|
<!-- Total original (tachado si hay descuento) -->
|
||||||
|
<div v-if="selectedClient && clientDiscount > 0" class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">Total sin descuento</span>
|
||||||
|
<span class="text-lg text-gray-400 line-through">{{ formattedTotal }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total final (con o sin descuento) -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-base font-bold text-gray-800 dark:text-gray-200">Total a pagar</span>
|
<span class="text-base font-bold text-gray-800 dark:text-gray-200">
|
||||||
<span class="text-3xl font-bold text-indigo-600 dark:text-indigo-400">{{ formattedTotal }}</span>
|
{{ selectedClient && clientDiscount > 0 ? 'Total con descuento' : 'Total a pagar' }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="text-3xl font-bold"
|
||||||
|
:class="selectedClient && clientDiscount > 0
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: 'text-indigo-600 dark:text-indigo-400'"
|
||||||
|
>
|
||||||
|
{{ selectedClient && clientDiscount > 0 ? formattedEstimatedTotal : formattedTotal }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sección de Cliente -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Cliente (Opcional)
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<!-- Input de búsqueda de cliente -->
|
||||||
|
<div v-if="!selectedClient" class="space-y-2">
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="clientNumber"
|
||||||
|
@keyup.enter="searchClient"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ingresa el código de cliente (ej: CLI-0001)"
|
||||||
|
class="w-full px-4 py-3 pr-24 border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
||||||
|
:class="{
|
||||||
|
'border-red-500 focus:ring-red-500 focus:border-red-500': clientNotFound
|
||||||
|
}"
|
||||||
|
:disabled="searchingClient"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="searchClient"
|
||||||
|
:disabled="searchingClient || !clientNumber"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{{ searchingClient ? 'Buscando...' : 'Buscar' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error de cliente no encontrado -->
|
||||||
|
<div v-if="clientNotFound" class="flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||||
|
<GoogleIcon name="error" class="text-red-600 dark:text-red-400 text-lg shrink-0 mt-0.5" />
|
||||||
|
<p class="text-sm text-red-800 dark:text-red-300">
|
||||||
|
Cliente no encontrado. Verifica el código e intenta nuevamente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cliente seleccionado -->
|
||||||
|
<div v-else class="bg-linear-to-br from-indigo-50 to-indigo-100 dark:from-indigo-900/20 dark:to-indigo-800/20 border border-indigo-200 dark:border-indigo-800 rounded-xl p-4 space-y-3">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-indigo-600 flex items-center justify-center text-white font-bold text-lg">
|
||||||
|
{{ selectedClient.name.charAt(0).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-base font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ selectedClient.name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ selectedClient.client_number }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="clearClient"
|
||||||
|
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||||
|
title="Quitar cliente"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="close" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tier y estadísticas -->
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div class="bg-white/50 dark:bg-gray-900/20 rounded-lg p-2">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400">Tier actual</p>
|
||||||
|
<p class="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ selectedClient.tier?.tier_name || 'Sin tier' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white/50 dark:bg-gray-900/20 rounded-lg p-2">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400">Descuento</p>
|
||||||
|
<p class="text-sm font-bold text-green-600 dark:text-green-400">
|
||||||
|
{{ clientDiscount.toFixed(2) }}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedClient.total_purchases !== undefined" class="bg-white/50 dark:bg-gray-900/20 rounded-lg p-2">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400">Compras acumuladas</p>
|
||||||
|
<p class="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ formatCurrency(selectedClient.total_purchases) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedClient.total_transactions !== undefined" class="bg-white/50 dark:bg-gray-900/20 rounded-lg p-2">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400">Transacciones</p>
|
||||||
|
<p class="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ selectedClient.total_transactions || 0 }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -337,7 +551,7 @@ const handleClose = () => {
|
|||||||
Efectivo insuficiente
|
Efectivo insuficiente
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-red-700 dark:text-red-400 mt-1">
|
<p class="text-xs text-red-700 dark:text-red-400 mt-1">
|
||||||
El dinero recibido debe ser mayor o igual al total de ${{ cart.total.toFixed(2) }}
|
El dinero recibido debe ser mayor o igual al total de {{ formattedEstimatedTotal }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
84
src/components/POS/Tiers/ToggleButton.vue
Normal file
84
src/components/POS/Tiers/ToggleButton.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { apiURL } from '@Services/Api';
|
||||||
|
|
||||||
|
/** Props */
|
||||||
|
const props = defineProps({
|
||||||
|
tier: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emits */
|
||||||
|
const emit = defineEmits(['toggled']);
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const isProcessing = ref(false);
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const toggleActive = async () => {
|
||||||
|
if (isProcessing.value) return;
|
||||||
|
|
||||||
|
isProcessing.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await axios({
|
||||||
|
method: 'patch',
|
||||||
|
url: apiURL(`client-tiers/${props.tier.id}/toggle-active`),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Authorization': `Bearer ${sessionStorage.token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
window.Notify.success(`Nivel ${props.tier.is_active ? 'desactivado' : 'activado'} exitosamente`);
|
||||||
|
emit('toggled');
|
||||||
|
} else {
|
||||||
|
window.Notify.error(data.message || 'Error al cambiar el estado del nivel');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling tier:', error);
|
||||||
|
const message = error.response?.data?.data?.message || error.response?.data?.message || 'Error de conexión al cambiar el estado';
|
||||||
|
window.Notify.error(message);
|
||||||
|
} finally {
|
||||||
|
isProcessing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex justify-center items-center w-full">
|
||||||
|
<button
|
||||||
|
@click="toggleActive"
|
||||||
|
:disabled="isProcessing"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex items-center h-7 rounded-full w-20 transition-colors duration-200 focus:outline-none',
|
||||||
|
props.tier.is_active ? 'bg-green-500' : 'bg-gray-400',
|
||||||
|
isProcessing ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
|
||||||
|
]"
|
||||||
|
:title="props.tier.is_active ? 'Click para desactivar' : 'Click para activar'"
|
||||||
|
>
|
||||||
|
<!-- Texto -->
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'absolute text-white font-semibold text-[8px] tracking-wide transition-all duration-200',
|
||||||
|
props.tier.is_active ? 'left-2' : 'right-2'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ props.tier.is_active ? 'ACTIVO' : 'INACTIVO' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Círculo deslizable -->
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-block h-5 w-5 rounded-full bg-white shadow transition-transform duration-200',
|
||||||
|
props.tier.is_active ? 'translate-x-[54px]' : 'translate-x-1'
|
||||||
|
]"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -211,7 +211,7 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<td colspan="6" class="table-cell text-center">
|
<td colspan="7" class="table-cell text-center">
|
||||||
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
<GoogleIcon
|
<GoogleIcon
|
||||||
name="person"
|
name="person"
|
||||||
|
|||||||
@ -128,17 +128,17 @@ watch(() => props.show, (newVal) => {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm opacity-90 mb-1">Nivel Actual</p>
|
<p class="text-sm opacity-90 mb-1">Nivel Actual</p>
|
||||||
<h3 class="text-2xl font-bold">{{ stats.current_tier.tier_name }}</h3>
|
<h3 class="text-2xl font-bold">{{ stats.current_tier.name }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="text-sm opacity-90 mb-1">Descuento</p>
|
<p class="text-sm opacity-90 mb-1">Descuento</p>
|
||||||
<h3 class="text-3xl font-bold">{{ stats.current_tier.discount_percentage }}%</h3>
|
<h3 class="text-3xl font-bold">{{ stats.current_tier.discount }}%</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Resumen de Compras -->
|
<!-- Resumen de Compras -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<!-- Total Compras -->
|
<!-- Total Compras -->
|
||||||
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
|
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
@ -177,19 +177,6 @@ watch(() => props.show, (newVal) => {
|
|||||||
<GoogleIcon name="payments" class="text-2xl text-blue-600 dark:text-blue-400" />
|
<GoogleIcon name="payments" class="text-2xl text-blue-600 dark:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Descuentos Recibidos -->
|
|
||||||
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4 border border-purple-200 dark:border-purple-800">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-purple-700 dark:text-purple-400 mb-1">Descuentos Recibidos</p>
|
|
||||||
<p class="text-2xl font-bold text-purple-900 dark:text-purple-300">
|
|
||||||
{{ formatCurrency(stats.total_discounts_received) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<GoogleIcon name="discount" class="text-2xl text-purple-600 dark:text-purple-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Información Adicional -->
|
<!-- Información Adicional -->
|
||||||
|
|||||||
@ -142,7 +142,7 @@ const handleCodeDetected = async (barcode) => {
|
|||||||
try {
|
try {
|
||||||
window.Notify.info('Buscando producto...');
|
window.Notify.info('Buscando producto...');
|
||||||
|
|
||||||
// Buscar producto por código de barras usando el API
|
// Buscar producto por código de barras
|
||||||
const response = await fetch(apiURL(`inventario?q=${encodeURIComponent(barcode)}`), {
|
const response = await fetch(apiURL(`inventario?q=${encodeURIComponent(barcode)}`), {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${sessionStorage.token}`,
|
'Authorization': `Bearer ${sessionStorage.token}`,
|
||||||
@ -185,7 +185,7 @@ const handleConfirmSale = async (paymentData) => {
|
|||||||
user_id: page.user.id,
|
user_id: page.user.id,
|
||||||
subtotal: parseFloat(cart.subtotal.toFixed(2)),
|
subtotal: parseFloat(cart.subtotal.toFixed(2)),
|
||||||
tax: parseFloat(cart.tax.toFixed(2)),
|
tax: parseFloat(cart.tax.toFixed(2)),
|
||||||
total: parseFloat(cart.total.toFixed(2)),
|
total: parseFloat(cart.total.toFixed(2)), // El backend recalculará con descuento
|
||||||
payment_method: paymentData.paymentMethod,
|
payment_method: paymentData.paymentMethod,
|
||||||
items: cart.items.map(item => ({
|
items: cart.items.map(item => ({
|
||||||
inventory_id: item.inventory_id,
|
inventory_id: item.inventory_id,
|
||||||
@ -196,6 +196,12 @@ const handleConfirmSale = async (paymentData) => {
|
|||||||
serial_numbers: item.track_serials ? (item.serial_numbers || []) : undefined
|
serial_numbers: item.track_serials ? (item.serial_numbers || []) : undefined
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Agregar client_number si se seleccionó un cliente
|
||||||
|
if (paymentData.clientNumber) {
|
||||||
|
saleData.client_number = paymentData.clientNumber;
|
||||||
|
}
|
||||||
|
|
||||||
// Agregar cash_received si es pago en efectivo
|
// Agregar cash_received si es pago en efectivo
|
||||||
if (paymentData.paymentMethod === 'cash' && paymentData.cashReceived) {
|
if (paymentData.paymentMethod === 'cash' && paymentData.cashReceived) {
|
||||||
saleData.cash_received = parseFloat(paymentData.cashReceived.toFixed(2));
|
saleData.cash_received = parseFloat(paymentData.cashReceived.toFixed(2));
|
||||||
@ -205,6 +211,29 @@ const handleConfirmSale = async (paymentData) => {
|
|||||||
window.Notify.info('Procesando venta...');
|
window.Notify.info('Procesando venta...');
|
||||||
const response = await salesService.createSale(saleData);
|
const response = await salesService.createSale(saleData);
|
||||||
|
|
||||||
|
// Mostrar información del descuento aplicado si hay
|
||||||
|
if (response.discount_amount && parseFloat(response.discount_amount) > 0) {
|
||||||
|
window.Notify.success(
|
||||||
|
`¡Descuento aplicado! -$${parseFloat(response.discount_amount).toFixed(2)} (${parseFloat(response.discount_percentage).toFixed(2)}%)`,
|
||||||
|
'',
|
||||||
|
5
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detectar upgrade/downgrade de tier
|
||||||
|
if (paymentData.clientData?.tier?.tier_name && response.client?.tier?.tier_name) {
|
||||||
|
const oldTier = paymentData.clientData.tier.tier_name;
|
||||||
|
const newTier = response.client.tier.tier_name;
|
||||||
|
|
||||||
|
if (oldTier !== newTier) {
|
||||||
|
window.Notify.success(
|
||||||
|
`¡El cliente subió de nivel! ${oldTier} → ${newTier} (${response.client.tier.discount_percentage}% descuento)`,
|
||||||
|
'',
|
||||||
|
8
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mostrar mensaje de éxito con cambio si es efectivo
|
// Mostrar mensaje de éxito con cambio si es efectivo
|
||||||
if (paymentData.paymentMethod === 'cash' && response.change !== undefined) {
|
if (paymentData.paymentMethod === 'cash' && response.change !== undefined) {
|
||||||
window.Notify.success(
|
window.Notify.success(
|
||||||
@ -250,12 +279,15 @@ const handleConfirmSale = async (paymentData) => {
|
|||||||
// Recargar productos para actualizar stock
|
// Recargar productos para actualizar stock
|
||||||
searcher.search();
|
searcher.search();
|
||||||
|
|
||||||
showClientModal.value = true;
|
// Solo mostrar modal de registro de cliente si NO se asoció un cliente a la venta
|
||||||
|
if (!paymentData.clientNumber) {
|
||||||
|
showClientModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error en venta:', error);
|
console.error('Error en venta:', error);
|
||||||
|
|
||||||
// Manejar errores de validación del backend (422)
|
// Manejar errores de validación
|
||||||
if (error.status === 422 && error.errors) {
|
if (error.status === 422 && error.errors) {
|
||||||
const errorMessages = Object.values(error.errors).flat();
|
const errorMessages = Object.values(error.errors).flat();
|
||||||
errorMessages.forEach(msg => window.Notify.error(msg));
|
errorMessages.forEach(msg => window.Notify.error(msg));
|
||||||
|
|||||||
@ -16,8 +16,8 @@ const props = defineProps({
|
|||||||
/** Formulario */
|
/** Formulario */
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
tier_name: '',
|
tier_name: '',
|
||||||
min_purchases_amount: '',
|
min_purchase_amount: '',
|
||||||
max_purchases_amount: '',
|
max_purchase_amount: '',
|
||||||
discount_percentage: '',
|
discount_percentage: '',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
});
|
});
|
||||||
@ -84,50 +84,59 @@ const closeModal = () => {
|
|||||||
MONTO MÍNIMO ACUMULADO
|
MONTO MÍNIMO ACUMULADO
|
||||||
</label>
|
</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
v-model="form.min_purchases_amount"
|
v-model="form.min_purchase_amount"
|
||||||
placeholder="Monto mínimo acumulado"
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.00"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<FormError :message="form.errors?.min_purchases_amount" />
|
<FormError :message="form.errors?.min_purchase_amount" />
|
||||||
</div>
|
</div>
|
||||||
<!-- Monto Máximo-->
|
<!-- Monto Máximo-->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
MONTO MÁXIMO ACUMULADO
|
MONTO MÁXIMO ACUMULADO <span class="text-gray-400 text-xs normal-case">(opcional)</span>
|
||||||
</label>
|
</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
v-model="form.max_purchases_amount"
|
v-model="form.max_purchase_amount"
|
||||||
placeholder="Monto máximo acumulado"
|
type="number"
|
||||||
required
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="Sin límite"
|
||||||
/>
|
/>
|
||||||
<FormError :message="form.errors?.max_purchases_amount" />
|
<FormError :message="form.errors?.max_purchase_amount" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Descuento -->
|
<!-- Descuento -->
|
||||||
<div>
|
<div class="col-span-2">
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
DESCUENTO
|
DESCUENTO (%)
|
||||||
</label>
|
</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
v-model="form.discount_percentage"
|
v-model="form.discount_percentage"
|
||||||
type="text"
|
type="number"
|
||||||
placeholder="Descuento"
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
placeholder="0.00"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<FormError :message="form.errors?.discount_percentage" />
|
<FormError :message="form.errors?.discount_percentage" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Estado -->
|
<!-- Estado -->
|
||||||
<div>
|
<div class="col-span-2">
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
ESTADO
|
<input
|
||||||
|
v-model="form.is_active"
|
||||||
|
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">
|
||||||
|
Activar nivel inmediatamente
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<FormInput
|
|
||||||
v-model="form.is_active"
|
|
||||||
type="checkbox"
|
|
||||||
placeholder="Estado"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormError :message="form.errors?.is_active" />
|
<FormError :message="form.errors?.is_active" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -37,7 +37,7 @@ const handleClose = () => {
|
|||||||
<GoogleIcon name="delete_forever" class="text-2xl text-red-600 dark:text-red-400" />
|
<GoogleIcon name="delete_forever" class="text-2xl text-red-600 dark:text-red-400" />
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
Eliminar Cliente
|
Eliminar Nivel de Cliente
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -53,19 +53,33 @@ const handleClose = () => {
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<p class="text-gray-700 dark:text-gray-300 text-base">
|
<p class="text-gray-700 dark:text-gray-300 text-base">
|
||||||
¿Estás seguro de que deseas eliminar este cliente?
|
¿Estás seguro de que deseas eliminar este nivel de cliente?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="client" class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-5 space-y-3">
|
<div v-if="client" class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-5 space-y-3">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="text-base font-bold text-gray-900 dark:text-gray-100 mb-1">
|
<p class="text-base font-bold text-gray-900 dark:text-gray-100 mb-1">
|
||||||
{{ client.name }}
|
{{ client.tier_name }}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<p>Descuento: {{ parseFloat(client.discount_percentage).toFixed(2) }}%</p>
|
||||||
|
<p>Rango: ${{ parseFloat(client.min_purchase_amount).toFixed(2) }} - {{ client.max_purchase_amount ? '$' + parseFloat(client.max_purchase_amount).toFixed(2) : 'Sin límite' }}</p>
|
||||||
|
<p v-if="client.clients_count !== undefined" class="font-semibold" :class="client.clients_count > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-600 dark:text-gray-400'">
|
||||||
|
Clientes asignados: {{ client.clients_count || 0 }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="client && client.clients_count > 0" class="flex items-start gap-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||||
|
<GoogleIcon name="warning" class="text-amber-600 dark:text-amber-400 text-xl flex-shrink-0 mt-0.5" />
|
||||||
|
<p class="text-sm text-amber-800 dark:text-amber-300 font-medium">
|
||||||
|
Este nivel tiene {{ client.clients_count }} cliente(s) asignado(s). No podrás eliminarlo hasta que todos los clientes sean reasignados a otro nivel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
<div class="flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||||
<GoogleIcon name="info" class="text-red-600 dark:text-red-400 text-xl flex-shrink-0 mt-0.5" />
|
<GoogleIcon name="info" class="text-red-600 dark:text-red-400 text-xl flex-shrink-0 mt-0.5" />
|
||||||
<p class="text-sm text-red-800 dark:text-red-300 font-medium">
|
<p class="text-sm text-red-800 dark:text-red-300 font-medium">
|
||||||
@ -86,10 +100,11 @@ const handleClose = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="handleConfirm"
|
@click="handleConfirm"
|
||||||
class="flex items-center gap-2 px-5 py-2.5 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-600 shadow-lg shadow-red-600/30 transition-all"
|
:disabled="client && client.clients_count > 0"
|
||||||
|
class="flex items-center gap-2 px-5 py-2.5 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-600 shadow-lg shadow-red-600/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-red-600"
|
||||||
>
|
>
|
||||||
<GoogleIcon name="delete" class="text-xl" />
|
<GoogleIcon name="delete" class="text-xl" />
|
||||||
Eliminar Cliente
|
Eliminar Nivel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -17,23 +17,23 @@ const props = defineProps({
|
|||||||
|
|
||||||
/** Formulario */
|
/** Formulario */
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
name: '',
|
tier_name: '',
|
||||||
email: '',
|
min_purchase_amount: '',
|
||||||
phone: '',
|
max_purchase_amount: '',
|
||||||
address: '',
|
discount_percentage: '',
|
||||||
rfc: '',
|
is_active: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
const updateClient = () => {
|
const updateTier = () => {
|
||||||
form.put(apiURL(`clients/${props.client.id}`), {
|
form.put(apiURL(`client-tiers/${props.client.id}`), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
window.Notify.success('Cliente actualizado exitosamente');
|
window.Notify.success('Nivel de cliente actualizado exitosamente');
|
||||||
emit('updated');
|
emit('updated');
|
||||||
closeModal();
|
closeModal();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
window.Notify.error('Error al actualizar el cliente');
|
window.Notify.error('Error al actualizar el nivel de cliente');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -44,13 +44,13 @@ const closeModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Observadores */
|
/** Observadores */
|
||||||
watch(() => props.client, (newClient) => {
|
watch(() => props.client, (newTier) => {
|
||||||
if (newClient) {
|
if (newTier) {
|
||||||
form.name = newClient.name || '';
|
form.tier_name = newTier.tier_name || '';
|
||||||
form.email = newClient.email || '';
|
form.min_purchase_amount = newTier.min_purchase_amount || '';
|
||||||
form.phone = newClient.phone || '';
|
form.max_purchase_amount = newTier.max_purchase_amount || '';
|
||||||
form.address = newClient.address || '';
|
form.discount_percentage = newTier.discount_percentage || '';
|
||||||
form.rfc = newClient.rfc || '';
|
form.is_active = newTier.is_active ?? true;
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
@ -61,7 +61,7 @@ watch(() => props.client, (newClient) => {
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
Editar Cliente
|
Editar Nivel de Cliente
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
@click="closeModal"
|
@click="closeModal"
|
||||||
@ -74,7 +74,7 @@ watch(() => props.client, (newClient) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Formulario -->
|
<!-- Formulario -->
|
||||||
<form @submit.prevent="updateClient" class="space-y-4">
|
<form @submit.prevent="updateTier" class="space-y-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<!-- Nombre -->
|
<!-- Nombre -->
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
@ -82,91 +82,94 @@ watch(() => props.client, (newClient) => {
|
|||||||
NOMBRE
|
NOMBRE
|
||||||
</label>
|
</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
v-model="form.name"
|
v-model="form.tier_name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Nombre del cliente"
|
placeholder="Nombre del nivel"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<FormError :message="form.errors?.name" />
|
<FormError :message="form.errors?.tier_name" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- EMAIL -->
|
<!-- Monto Mínimo-->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
EMAIL
|
MONTO MÍNIMO ACUMULADO
|
||||||
</label>
|
</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
v-model="form.email"
|
v-model="form.min_purchase_amount"
|
||||||
type="email"
|
type="number"
|
||||||
placeholder="Correo electrónico"
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.00"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<FormError :message="form.errors?.email" />
|
<FormError :message="form.errors?.min_purchase_amount" />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Monto Máximo-->
|
||||||
<!-- Teléfono -->
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
TELÉFONO
|
MONTO MÁXIMO ACUMULADO <span class="text-gray-400 text-xs normal-case">(opcional)</span>
|
||||||
</label>
|
</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
v-model="form.phone"
|
v-model="form.max_purchase_amount"
|
||||||
type="tel"
|
type="number"
|
||||||
placeholder="9922334455"
|
step="0.01"
|
||||||
maxlength="10"
|
min="0"
|
||||||
|
placeholder="Sin límite"
|
||||||
/>
|
/>
|
||||||
<FormError :message="form.errors?.phone" />
|
<FormError :message="form.errors?.max_purchase_amount" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dirección -->
|
<!-- Descuento -->
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
DIRECCIÓN
|
DESCUENTO (%)
|
||||||
</label>
|
</label>
|
||||||
<FormInput
|
<FormInput
|
||||||
v-model="form.address"
|
v-model="form.discount_percentage"
|
||||||
type="text"
|
type="number"
|
||||||
placeholder="Dirección del cliente"
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
placeholder="0.00"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<FormError :message="form.errors?.address" />
|
<FormError :message="form.errors?.discount_percentage" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RFC -->
|
<!-- Estado -->
|
||||||
<div>
|
<div class="col-span-2">
|
||||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
RFC
|
<input
|
||||||
|
v-model="form.is_active"
|
||||||
|
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">
|
||||||
|
Nivel activo
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<FormInput
|
<FormError :message="form.errors?.is_active" />
|
||||||
v-model="form.rfc"
|
|
||||||
type="text"
|
|
||||||
maxlength="13"
|
|
||||||
placeholder="RFC del cliente"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<FormError :message="form.errors?.rfc" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Botones -->
|
<!-- Botones -->
|
||||||
<div class="flex items-center justify-between mt-6">
|
<div class="flex items-center justify-end gap-3 mt-6">
|
||||||
<div class="flex items-center gap-3">
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
@click="closeModal"
|
||||||
@click="closeModal"
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
>
|
||||||
>
|
Cancelar
|
||||||
Cancelar
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
type="submit"
|
||||||
type="submit"
|
:disabled="form.processing"
|
||||||
:disabled="form.processing"
|
class="px-4 py-2 text-sm font-semibold text-white bg-gray-900 rounded-lg hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
class="px-4 py-2 text-sm font-semibold text-white bg-gray-900 rounded-lg hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
>
|
||||||
>
|
<span v-if="form.processing">Actualizando...</span>
|
||||||
<span v-if="form.processing">Actualizando...</span>
|
<span v-else>Actualizar</span>
|
||||||
<span v-else>Actualizar</span>
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { api, useSearcher, apiURL } from '@Services/Api';
|
import { api, useSearcher, apiURL } from '@Services/Api';
|
||||||
import { can } from './Module.js';
|
import { can } from './Module.js';
|
||||||
|
import { formatCurrency, formatPercent } from '@/utils/formatters.js';
|
||||||
|
|
||||||
import SearcherHead from '@Holos/Searcher.vue';
|
import SearcherHead from '@Holos/Searcher.vue';
|
||||||
import Table from '@Holos/Table.vue';
|
import Table from '@Holos/Table.vue';
|
||||||
@ -9,6 +10,7 @@ import GoogleIcon from '@Shared/GoogleIcon.vue';
|
|||||||
import CreateModal from './Create.vue';
|
import CreateModal from './Create.vue';
|
||||||
import EditModal from './Edit.vue';
|
import EditModal from './Edit.vue';
|
||||||
import DeleteModal from './Delete.vue';
|
import DeleteModal from './Delete.vue';
|
||||||
|
import ToggleButton from '@Components/POS/Tiers/ToggleButton.vue';
|
||||||
|
|
||||||
|
|
||||||
/** Estado */
|
/** Estado */
|
||||||
@ -29,6 +31,10 @@ const searcher = useSearcher({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Métodos auxiliares */
|
/** Métodos auxiliares */
|
||||||
|
const onToggled = () => {
|
||||||
|
searcher.search();
|
||||||
|
};
|
||||||
|
|
||||||
const confirmDelete = (id) => {
|
const confirmDelete = (id) => {
|
||||||
api.delete(apiURL(`client-tiers/${id}`), {
|
api.delete(apiURL(`client-tiers/${id}`), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@ -36,8 +42,8 @@ const confirmDelete = (id) => {
|
|||||||
closeDeleteModal();
|
closeDeleteModal();
|
||||||
searcher.search();
|
searcher.search();
|
||||||
},
|
},
|
||||||
onFail: () => {
|
onFail: (data) => {
|
||||||
window.Notify.error('Error al eliminar el nivel');
|
window.Notify.error(data.message || 'No se puede eliminar el nivel porque tiene clientes asignados');
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
window.Notify.error('Error de conexión al eliminar el nivel');
|
window.Notify.error('Error de conexión al eliminar el nivel');
|
||||||
@ -110,7 +116,9 @@ onMounted(() => {
|
|||||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CANTIDAD MINIMA ACUMULADA</th>
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CANTIDAD MINIMA ACUMULADA</th>
|
||||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CANTIDAD MÁXIMA ACUMULADA</th>
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CANTIDAD MÁXIMA ACUMULADA</th>
|
||||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">DESCUENTOS</th>
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">DESCUENTOS</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CLIENTES</th>
|
||||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ESTADO</th>
|
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ESTADO</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>
|
||||||
<template #body="{items}">
|
<template #body="{items}">
|
||||||
<tr
|
<tr
|
||||||
@ -119,35 +127,35 @@ onMounted(() => {
|
|||||||
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
>
|
>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ tier.name }}</p>
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ tier.tier_name }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ tier.minimum_purchases_amount }}</p>
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ formatCurrency(tier.min_purchase_amount) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ tier.maximum_purchases_amount }}</p>
|
<p class="text-sm text-gray-700 dark:text-gray-300">{{ tier.max_purchase_amount ? formatCurrency(tier.max_purchase_amount) : 'Sin límite' }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ tier.discount_percentage }}</p>
|
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatPercent(tier.discount_percentage) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ tier.is_active ? 'Activo' : 'Inactivo' }}</p>
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200">
|
||||||
|
{{ tier.clients_count || 0 }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-center">
|
||||||
|
<ToggleButton
|
||||||
|
:tier="tier"
|
||||||
|
@toggled="onToggled"
|
||||||
|
/>
|
||||||
</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="can('create')"
|
|
||||||
@click.stop="openCreateModal(tier)"
|
|
||||||
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
|
||||||
title="Editar cliente"
|
|
||||||
>
|
|
||||||
<GoogleIcon name="edit" class="text-xl" />
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
v-if="can('edit')"
|
v-if="can('edit')"
|
||||||
@click.stop="openEditModal(tier)"
|
@click.stop="openEditModal(tier)"
|
||||||
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 cliente"
|
title="Editar nivel"
|
||||||
>
|
>
|
||||||
<GoogleIcon name="edit" class="text-xl" />
|
<GoogleIcon name="edit" class="text-xl" />
|
||||||
</button>
|
</button>
|
||||||
@ -155,7 +163,7 @@ onMounted(() => {
|
|||||||
v-if="can('destroy')"
|
v-if="can('destroy')"
|
||||||
@click.stop="openDeleteModal(tier)"
|
@click.stop="openDeleteModal(tier)"
|
||||||
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 cliente"
|
title="Eliminar nivel"
|
||||||
>
|
>
|
||||||
<GoogleIcon name="delete" class="text-xl" />
|
<GoogleIcon name="delete" class="text-xl" />
|
||||||
</button>
|
</button>
|
||||||
@ -164,7 +172,7 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<td colspan="6" class="table-cell text-center">
|
<td colspan="7" class="table-cell text-center">
|
||||||
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
<GoogleIcon
|
<GoogleIcon
|
||||||
name="person"
|
name="person"
|
||||||
|
|||||||
@ -69,8 +69,8 @@ const ticketService = {
|
|||||||
yPosition += 6;
|
yPosition += 6;
|
||||||
|
|
||||||
// Línea separadora
|
// Línea separadora
|
||||||
doc.setDrawColor(...darkGrayColor);
|
|
||||||
doc.setLineWidth(0.3);
|
doc.setLineWidth(0.3);
|
||||||
|
doc.setDrawColor(...blackColor);
|
||||||
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
yPosition += 5;
|
yPosition += 5;
|
||||||
|
|
||||||
@ -220,7 +220,20 @@ const ticketService = {
|
|||||||
// IVA
|
// IVA
|
||||||
doc.text('IVA:', leftMargin, yPosition);
|
doc.text('IVA:', leftMargin, yPosition);
|
||||||
doc.text(`$${formatMoney(saleData.tax)}`, rightMargin, yPosition, { align: 'right' });
|
doc.text(`$${formatMoney(saleData.tax)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
yPosition += 6;
|
yPosition += 5;
|
||||||
|
|
||||||
|
// Descuento (si existe)
|
||||||
|
if (saleData.discount_amount && parseFloat(saleData.discount_amount) > 0) {
|
||||||
|
const discountPercent = saleData.discount_percentage || 0;
|
||||||
|
const tierName = saleData.client_tier_name || 'Cliente';
|
||||||
|
|
||||||
|
doc.text(`Descuento (${tierName} ${discountPercent}%):`, leftMargin, yPosition);
|
||||||
|
doc.text(`-$${formatMoney(saleData.discount_amount)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
|
yPosition += 5;
|
||||||
|
doc.setTextColor(...darkGrayColor); // Restaurar color
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 1;
|
||||||
|
|
||||||
// Línea antes del total
|
// Línea antes del total
|
||||||
doc.setLineWidth(0.4);
|
doc.setLineWidth(0.4);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user