add: cambio al pagar con efectivo

This commit is contained in:
Juan Felipe Zapata Moreno 2026-01-04 17:12:12 -06:00
parent cabba3621e
commit 708cc31496
4 changed files with 203 additions and 22 deletions

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue'; import GoogleIcon from '@Shared/GoogleIcon.vue';
import Modal from '@Holos/Modal.vue'; import Modal from '@Holos/Modal.vue';
@ -24,6 +24,7 @@ const emit = defineEmits(['close', 'confirm']);
/** Estado */ /** Estado */
const selectedMethod = ref('cash'); const selectedMethod = ref('cash');
const cashReceived = ref(0);
/** Computados */ /** Computados */
const formattedSubtotal = computed(() => { const formattedSubtotal = computed(() => {
@ -47,6 +48,30 @@ const formattedTotal = computed(() => {
}).format(props.cart.total); }).format(props.cart.total);
}); });
// Cálculo de cambio
const change = computed(() => {
if (selectedMethod.value === 'cash' && cashReceived.value > 0) {
return cashReceived.value - props.cart.total;
}
return 0;
});
// Validar que el efectivo sea suficiente
const isValidCash = computed(() => {
if (selectedMethod.value === 'cash') {
return cashReceived.value >= props.cart.total;
}
return true; // Tarjetas siempre válidas
});
// Puede confirmar si no es efectivo o si el efectivo es válido
const canConfirm = computed(() => {
if (selectedMethod.value === 'cash') {
return cashReceived.value > 0 && isValidCash.value;
}
return true;
});
const paymentMethods = [ const paymentMethods = [
{ {
value: 'cash', value: 'cash',
@ -71,19 +96,53 @@ const paymentMethods = [
} }
]; ];
/** Watchers */
// Limpiar cashReceived cuando cambia el método de pago
watch(selectedMethod, (newMethod) => {
if (newMethod !== 'cash') {
cashReceived.value = 0;
}
});
// Resetear cashReceived cuando se abre el modal
watch(() => props.show, (isShown) => {
if (isShown) {
cashReceived.value = 0;
}
});
/** Métodos */ /** Métodos */
const handleConfirm = () => { const handleConfirm = () => {
// Validar método de pago seleccionado
if (!selectedMethod.value) { if (!selectedMethod.value) {
window.Notify.error('Selecciona un método de pago'); window.Notify.error('Selecciona un método de pago');
return; return;
} }
emit('confirm', selectedMethod.value); // Validaciones específicas para efectivo
if (selectedMethod.value === 'cash') {
if (!cashReceived.value || cashReceived.value <= 0) {
window.Notify.error('Ingresa el efectivo recibido');
return;
}
if (cashReceived.value < props.cart.total) {
window.Notify.error(`El efectivo recibido es insuficiente. Faltan $${(props.cart.total - cashReceived.value).toFixed(2)}`);
return;
}
}
// Emitir evento con datos completos
emit('confirm', {
paymentMethod: selectedMethod.value,
cashReceived: selectedMethod.value === 'cash' ? parseFloat(cashReceived.value) : null
});
}; };
const handleClose = () => { const handleClose = () => {
if (!props.processing) { if (!props.processing) {
selectedMethod.value = 'cash'; selectedMethod.value = 'cash';
cashReceived.value = 0;
emit('close'); emit('close');
} }
}; };
@ -210,6 +269,80 @@ const handleClose = () => {
</button> </button>
</div> </div>
</div> </div>
<!-- Sección de efectivo -->
<div v-if="selectedMethod === 'cash'" class="space-y-4">
<!-- Input de efectivo recibido -->
<div>
<label class="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">
Efectivo Recibido *
</label>
<div class="relative">
<span class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 text-xl font-semibold">$</span>
<input
v-model.number="cashReceived"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full pl-10 pr-4 py-3 text-xl font-bold 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': cashReceived > 0 && !isValidCash,
'border-green-500 focus:ring-green-500 focus:border-green-500': cashReceived > 0 && isValidCash
}"
autofocus
/>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
Ingresa el monto en efectivo que recibiste del cliente
</p>
</div>
<!-- Mostrar cambio -->
<div v-if="cashReceived > 0" 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">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
{{ change >= 0 ? 'Cambio a devolver' : 'Falta' }}
</p>
<p
class="text-4xl font-bold"
:class="{
'text-green-600 dark:text-green-400': change >= 0,
'text-red-600 dark:text-red-400': change < 0
}"
>
${{ Math.abs(change).toFixed(2) }}
</p>
</div>
<div>
<GoogleIcon
:name="change >= 0 ? 'check_circle' : 'error'"
class="text-5xl"
:class="{
'text-green-500': change >= 0,
'text-red-500': change < 0
}"
/>
</div>
</div>
</div>
<!-- Advertencia de dinero insuficiente -->
<div v-if="cashReceived > 0 && !isValidCash" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div class="flex gap-3">
<GoogleIcon name="warning" class="text-red-600 dark:text-red-400 text-xl shrink-0" />
<div>
<p class="text-sm font-semibold text-red-800 dark:text-red-300">
Efectivo insuficiente
</p>
<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) }}
</p>
</div>
</div>
</div>
</div>
</div> </div>
<!-- Footer --> <!-- Footer -->
@ -225,7 +358,7 @@ const handleClose = () => {
<button <button
type="button" type="button"
@click="handleConfirm" @click="handleConfirm"
:disabled="processing" :disabled="processing || !canConfirm"
class="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-600 shadow-lg shadow-indigo-600/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none" class="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-600 shadow-lg shadow-indigo-600/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none"
> >
<GoogleIcon <GoogleIcon

View File

@ -1,7 +1,6 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import Modal from '@Holos/Modal.vue'; import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue'; import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */ /** Props */
@ -35,6 +34,14 @@ const expectedCash = computed(() => initialCash.value + cashSales.value);
const difference = computed(() => finalCash.value - expectedCash.value); const difference = computed(() => finalCash.value - expectedCash.value);
const hasDifference = computed(() => Math.abs(difference.value) > 0.01); const hasDifference = computed(() => Math.abs(difference.value) > 0.01);
const totalCashReceived = computed(() =>
parseFloat(props.cashRegister?.total_cash_received || 0)
);
const totalChangeGiven = computed(() =>
parseFloat(props.cashRegister?.total_change_given || 0)
);
/** Métodos */ /** Métodos */
const handleSubmit = () => { const handleSubmit = () => {
emit('confirm', { emit('confirm', {
@ -122,29 +129,48 @@ const handleClose = () => {
</div> </div>
<!-- Cálculo de Efectivo --> <!-- Cálculo de Efectivo -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-5 space-y-4 border border-gray-200 dark:border-gray-700"> <div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-5 space-y-4">
<h4 class="text-sm font-bold text-gray-900 dark:text-gray-100 uppercase tracking-wide"> <h4 class="text-sm font-bold text-gray-900 dark:text-gray-100">
Resumen de Efectivo Resumen de Efectivo
</h4> </h4>
<div class="grid grid-cols-2 gap-4"> <div class="space-y-3">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Efectivo Inicial:</span> <span class="text-sm text-gray-600 dark:text-gray-400">Efectivo Inicial:</span>
<span class="text-base font-semibold text-gray-900 dark:text-gray-100"> <span class="text-base font-semibold text-gray-900 dark:text-gray-100">
${{ (initialCash || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }} ${{ (initialCash || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span> </span>
</div> </div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Ventas en Efectivo:</span> <!-- Desglose de flujo de efectivo -->
<span class="text-base font-semibold text-green-600 dark:text-green-400"> <div class="pl-4 border-l-2 border-green-500 space-y-2">
+ ${{ (cashSales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }} <div class="flex justify-between items-center text-sm">
<span class="text-gray-600 dark:text-gray-400">Efectivo Recibido:</span>
<span class="text-green-600 dark:text-green-400 font-semibold">
+ ${{ (totalCashReceived || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600 dark:text-gray-400">Cambio Devuelto:</span>
<span class="text-red-600 dark:text-red-400 font-semibold">
- ${{ (totalChangeGiven || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span> </span>
</div> </div>
</div> </div>
<div class="border-t border-gray-300 dark:border-gray-600 pt-3"> <div class="flex justify-between items-center pt-2 border-t border-gray-300">
<span class="text-sm text-gray-600 dark:text-gray-400">Ventas en Efectivo (Neto):</span>
<span class="text-base font-semibold text-green-600 dark:text-green-400">
= ${{ (cashSales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span>
</div>
</div>
<div class="border-t-2 border-gray-300 dark:border-gray-600 pt-3">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="text-base font-bold text-gray-900 dark:text-gray-100">Efectivo Esperado:</span> <span class="text-base font-bold text-gray-900 dark:text-gray-100">
Efectivo Esperado:
</span>
<span class="text-xl font-bold text-blue-600 dark:text-blue-400"> <span class="text-xl font-bold text-blue-600 dark:text-blue-400">
${{ (expectedCash || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }} ${{ (expectedCash || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span> </span>
@ -158,16 +184,16 @@ const handleClose = () => {
Efectivo Real en Caja * Efectivo Real en Caja *
</label> </label>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<span class="text-gray-500 dark:text-gray-400 text-lg font-semibold">$</span> <span class="text-gray-500 dark:text-gray-400 text-lg font-semibold">$</span>
</div> </div>
<FormInput <input
v-model.number="finalCash" v-model.number="finalCash"
type="number" type="number"
min="0" min="0"
step="0.01" step="0.01"
placeholder="0.00" placeholder="0.00"
class="pl-8 text-lg font-semibold" class="w-full pl-7 pr-4 py-2.5 text-lg font-semibold border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
required required
autofocus autofocus
/> />

View File

@ -79,12 +79,12 @@ const closeCheckout = () => {
showCheckoutModal.value = false; showCheckoutModal.value = false;
}; };
const handleConfirmSale = async (paymentMethod) => { const handleConfirmSale = async (paymentData) => {
processingPayment.value = true; processingPayment.value = true;
try { try {
// Establecer método de pago // Establecer método de pago
cart.setPaymentMethod(paymentMethod); cart.setPaymentMethod(paymentData.paymentMethod);
// Preparar datos de la venta // Preparar datos de la venta
const saleData = { const saleData = {
@ -92,7 +92,7 @@ const handleConfirmSale = async (paymentMethod) => {
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)),
payment_method: 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,
product_name: item.product_name, product_name: item.product_name,
@ -102,12 +102,25 @@ const handleConfirmSale = async (paymentMethod) => {
})) }))
}; };
// Agregar cash_received si es pago en efectivo
if (paymentData.paymentMethod === 'cash' && paymentData.cashReceived) {
saleData.cash_received = parseFloat(paymentData.cashReceived.toFixed(2));
}
// Crear venta // Crear venta
window.Notify.info('Procesando venta...'); window.Notify.info('Procesando venta...');
const response = await salesService.createSale(saleData); const response = await salesService.createSale(saleData);
// Éxito // Mostrar mensaje de éxito con cambio si es efectivo
if (paymentData.paymentMethod === 'cash' && response.change !== undefined) {
window.Notify.success(
`¡Venta realizada! Cambio: $${parseFloat(response.change).toFixed(2)}`,
'',
8
);
} else {
window.Notify.success('¡Venta realizada exitosamente!'); window.Notify.success('¡Venta realizada exitosamente!');
}
if (response && response.id) { if (response && response.id) {
try { try {
@ -139,7 +152,14 @@ const handleConfirmSale = async (paymentMethod) => {
} catch (error) { } catch (error) {
console.error('Error en venta:', error); console.error('Error en venta:', error);
// Manejar errores de validación del backend (422)
if (error.status === 422 && error.errors) {
const errorMessages = Object.values(error.errors).flat();
errorMessages.forEach(msg => window.Notify.error(msg));
} else {
window.Notify.error(error.message || t('cart.error')); window.Notify.error(error.message || t('cart.error'));
}
} finally { } finally {
processingPayment.value = false; processingPayment.value = false;
} }

View File

@ -14,7 +14,9 @@ const salesService = {
api.post(apiURL('sales'), { api.post(apiURL('sales'), {
data: saleData, data: saleData,
onSuccess: (response) => { onSuccess: (response) => {
resolve(response.model); // El backend puede devolver: sale, model, o el objeto directo
// También incluye change, cash_received para pagos en efectivo
resolve(response.sale || response.model || response);
}, },
onError: (error) => { onError: (error) => {
reject(error); reject(error);