feat: agregar métodos de pago y mejorar la visualización de descuentos en multas

This commit is contained in:
Juan Felipe Zapata Moreno 2026-03-29 16:06:43 -06:00
parent 0d1ccf9413
commit 5ee73c309f
4 changed files with 200 additions and 44 deletions

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed } from "vue"; import { computed, ref, watch } from "vue";
const props = defineProps({ const props = defineProps({
selectedFines: { type: Array, default: () => [] }, selectedFines: { type: Array, default: () => [] },
@ -8,16 +8,44 @@ const props = defineProps({
const emit = defineEmits(["pay"]); const emit = defineEmits(["pay"]);
const selectedPaymentMethod = ref(null);
const paymentMethods = [
{ value: "cash", label: "Efectivo" },
{ value: "debit_card", label: "Tarjeta de Débito" },
{ value: "credit_card", label: "Tarjeta de Crédito" },
];
const hasSelection = computed(() => props.selectedFines.length > 0); const hasSelection = computed(() => props.selectedFines.length > 0);
const canPay = computed(() => hasSelection.value && selectedPaymentMethod.value !== null);
const totalSelected = computed(() => const totalSelected = computed(() =>
props.selectedFines.reduce((sum, f) => sum + parseFloat(f.discount ?? f.total_amount ?? 0), 0)
);
const originalTotal = computed(() =>
props.selectedFines.reduce((sum, f) => sum + parseFloat(f.total_amount || 0), 0) props.selectedFines.reduce((sum, f) => sum + parseFloat(f.total_amount || 0), 0)
); );
const hasAnyDiscount = computed(() =>
props.selectedFines.some((f) => f.discount != null)
);
const totalSavings = computed(() => originalTotal.value - totalSelected.value);
const getConcepts = (fine) => { const getConcepts = (fine) => {
if (!fine.charge_concepts?.length) return []; if (!fine.charge_concepts?.length) return [];
return fine.charge_concepts.map((c) => c.short_name || c.name || "").filter(Boolean); return fine.charge_concepts.map((c) => c.short_name || c.name || "").filter(Boolean);
}; };
watch(() => props.selectedFines.length, (len) => {
if (len === 0) selectedPaymentMethod.value = null;
});
const handlePay = () => {
if (!canPay.value) return;
emit("pay", selectedPaymentMethod.value);
};
</script> </script>
<template> <template>
@ -57,9 +85,17 @@ const getConcepts = (fine) => {
> >
<div class="flex justify-between items-start mb-2"> <div class="flex justify-between items-start mb-2">
<span class="text-sm font-medium text-white">Folio: {{ fine.id }}</span> <span class="text-sm font-medium text-white">Folio: {{ fine.id }}</span>
<span class="text-sm font-semibold text-white shrink-0 ml-2"> <div class="flex flex-col items-end shrink-0 ml-2">
<span
v-if="fine.discount != null"
class="text-xs text-white/40 line-through leading-none mb-0.5"
>
${{ parseFloat(fine.total_amount || 0).toFixed(2) }} ${{ parseFloat(fine.total_amount || 0).toFixed(2) }}
</span> </span>
<span class="text-sm font-semibold text-white leading-none">
${{ parseFloat(fine.discount ?? fine.total_amount ?? 0).toFixed(2) }}
</span>
</div>
</div> </div>
<ul class="space-y-1"> <ul class="space-y-1">
<li <li
@ -76,10 +112,31 @@ const getConcepts = (fine) => {
</ul> </ul>
</div> </div>
</div> </div>
<!-- Método de pago -->
<div class="mt-4">
<p class="text-xs text-white/60 mb-2">Método de pago</p>
<div class="flex flex-col gap-2">
<button
v-for="method in paymentMethods"
:key="method.value"
@click="selectedPaymentMethod = method.value"
:class="[
'w-full px-3 py-2.5 rounded-xl text-sm font-medium transition-all text-left',
selectedPaymentMethod === method.value
? 'bg-white text-primary shadow-md'
: 'bg-white/10 text-white/70 hover:bg-white/20',
]"
>
{{ method.label }}
</button>
</div>
</div>
</div> </div>
<!-- Totales y botón --> <!-- Totales y botón -->
<div class="mt-auto pt-4 border-t border-white/20"> <div class="mt-auto pt-4 border-t border-white/20">
<template v-if="!hasAnyDiscount">
<div class="flex justify-between text-sm text-white/60 mb-2"> <div class="flex justify-between text-sm text-white/60 mb-2">
<span>Subtotal</span> <span>Subtotal</span>
<span>${{ totalSelected.toFixed(2) }}</span> <span>${{ totalSelected.toFixed(2) }}</span>
@ -88,12 +145,27 @@ const getConcepts = (fine) => {
<span>Total</span> <span>Total</span>
<span>${{ totalSelected.toFixed(2) }}</span> <span>${{ totalSelected.toFixed(2) }}</span>
</div> </div>
</template>
<template v-else>
<div class="flex justify-between text-sm text-white/60 mb-1">
<span>Subtotal original</span>
<span>${{ originalTotal.toFixed(2) }}</span>
</div>
<div class="flex justify-between text-sm text-success mb-2">
<span>Descuento</span>
<span>-${{ totalSavings.toFixed(2) }}</span>
</div>
<div class="flex justify-between font-bold text-white text-base mb-4">
<span>Total</span>
<span>${{ totalSelected.toFixed(2) }}</span>
</div>
</template>
<button <button
@click="emit('pay')" @click="handlePay"
:disabled="!hasSelection || isProcessingPayment" :disabled="!canPay || isProcessingPayment"
:class="[ :class="[
'w-full py-3 rounded-xl font-semibold text-sm flex items-center justify-center gap-2 transition-all', 'w-full py-3 rounded-xl font-semibold text-sm flex items-center justify-center gap-2 transition-all',
hasSelection && !isProcessingPayment canPay && !isProcessingPayment
? 'bg-success hover:bg-success/90 text-white shadow-lg shadow-black/20' ? 'bg-success hover:bg-success/90 text-white shadow-lg shadow-black/20'
: 'bg-white/10 text-white/30 cursor-not-allowed', : 'bg-white/10 text-white/30 cursor-not-allowed',
]" ]"
@ -103,6 +175,9 @@ const getConcepts = (fine) => {
</svg> </svg>
{{ isProcessingPayment ? "Procesando..." : "Cobrar Total" }} {{ isProcessingPayment ? "Procesando..." : "Cobrar Total" }}
</button> </button>
<p v-if="hasSelection && !selectedPaymentMethod" class="text-xs text-white/40 text-center mt-2">
Seleccione un método de pago para continuar
</p>
</div> </div>
</div> </div>

View File

@ -15,6 +15,17 @@ const formatDate = (dateStr) => {
return new Date(dateStr).toLocaleDateString("es-MX"); return new Date(dateStr).toLocaleDateString("es-MX");
}; };
const paymentMethodLabels = {
cash: "Efectivo",
debit_card: "T. Débito",
credit_card: "T. Crédito",
};
const getPaidPaymentMethod = (fine) => {
const payment = fine.payments?.find(p => p.status === "paid");
return payment?.payment_method ? (paymentMethodLabels[payment.payment_method] ?? null) : null;
};
const getConcepts = (fine) => { const getConcepts = (fine) => {
if (!fine.charge_concepts?.length) return []; if (!fine.charge_concepts?.length) return [];
return fine.charge_concepts.map((c) => c.short_name || c.name || "").filter(Boolean); return fine.charge_concepts.map((c) => c.short_name || c.name || "").filter(Boolean);
@ -52,11 +63,30 @@ const getConcepts = (fine) => {
> >
Pagada Pagada
</span> </span>
<span
v-if="fine.status === 'paid' && getPaidPaymentMethod(fine)"
class="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full"
>
{{ getPaidPaymentMethod(fine) }}
</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-semibold text-gray-800 text-lg"> <div class="flex flex-col items-end">
<span
v-if="fine.discount != null"
class="text-xs text-gray-400 line-through leading-none mb-0.5"
>
${{ parseFloat(fine.total_amount || 0).toFixed(2) }} ${{ parseFloat(fine.total_amount || 0).toFixed(2) }}
</span> </span>
<span
:class="[
'font-semibold text-lg leading-none',
fine.discount != null ? 'text-success' : 'text-gray-800',
]"
>
${{ parseFloat(fine.discount ?? fine.total_amount ?? 0).toFixed(2) }}
</span>
</div>
<button <button
v-if="fine.pdf_path" v-if="fine.pdf_path"
@click.stop="downloadFineTicket(fine)" @click.stop="downloadFineTicket(fine)"

View File

@ -110,31 +110,45 @@ const isFineSelected = (fine) => selectedFines.value.some((f) => f.id === fine.i
// Cobro // Cobro
const isProcessingPayment = ref(false); const isProcessingPayment = ref(false);
const handlePayment = async () => { const handlePayment = (paymentMethod) => {
if (!hasSelection.value) return; if (!hasSelection.value || !paymentMethod) return;
isProcessingPayment.value = true; isProcessingPayment.value = true;
let successCount = 0;
for (const fine of selectedFines.value) { const finesToPay = [...selectedFines.value];
await api.post(apiURL(`fines/${fine.id}/mark-as-paid`), { const total = finesToPay.length;
let paidCount = 0;
for (const fine of finesToPay) {
api.post(apiURL(`fines/${fine.id}/mark-as-paid`), {
data: { payment_method: paymentMethod },
onSuccess: (data) => { onSuccess: (data) => {
successCount++; paidCount++;
emit("payment-processed", { fine, paymentData: data }); const index = searchResults.value.findIndex(f => f.id === fine.id);
}, if (index !== -1) {
onFail: (e) => Notify.error(e.message || "Error al procesar el pago"), searchResults.value[index] = data.fine;
onError: () => Notify.error("Ocurrió un error al procesar el pago"),
});
} }
selectedFines.value = selectedFines.value.filter(f => f.id !== fine.id);
emit("payment-processed", { fine, paymentData: data });
if (paidCount === total) {
isProcessingPayment.value = false; isProcessingPayment.value = false;
if (successCount > 0) {
Notify.success( Notify.success(
successCount === 1 total === 1
? "Multa cobrada exitosamente" ? "Multa cobrada exitosamente"
: `${successCount} multas cobradas exitosamente` : `${total} multas cobradas exitosamente`
); );
searchResults.value = [];
selectedFines.value = [];
searchMessage.value = ""; searchMessage.value = "";
} }
},
onFail: (e) => {
isProcessingPayment.value = false;
Notify.error(e.message || "Error al procesar el pago");
},
onError: () => {
isProcessingPayment.value = false;
Notify.error("Ocurrió un error al procesar el pago");
},
});
}
}; };
// Cambio de pestaña // Cambio de pestaña

View File

@ -31,6 +31,20 @@ const usersSearcher = useSearcher({
}, },
}); });
const setPresetToday = () => {
const d = DateTime.now().toISODate();
filters.value.date_from = d;
filters.value.date_to = d;
load();
};
const setPresetThisMonth = () => {
const now = DateTime.now();
filters.value.date_from = now.startOf('month').toISODate();
filters.value.date_to = now.endOf('month').toISODate();
load();
};
const load = () => { const load = () => {
processing.value = true; processing.value = true;
const params = { date_from: filters.value.date_from, date_to: filters.value.date_to }; const params = { date_from: filters.value.date_from, date_to: filters.value.date_to };
@ -39,7 +53,7 @@ const load = () => {
api.get(apiTo('dashboard'), { api.get(apiTo('dashboard'), {
params, params,
onSuccess: (data) => { onSuccess: (data) => {
fines.value = data.today_fines ?? []; fines.value = data.fines ?? [];
stats.value = data.stats ?? {}; stats.value = data.stats ?? {};
processing.value = false; processing.value = false;
}, },
@ -62,6 +76,15 @@ const pendingAmount = computed(() =>
const formatCurrency = (n) => const formatCurrency = (n) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(n); new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(n);
const periodLabel = computed(() => {
const from = filters.value.date_from;
const to = filters.value.date_to;
const todayIso = DateTime.now().toISODate();
if (from === todayIso && to === todayIso) return 'hoy';
if (from === to) return DateTime.fromISO(from).toFormat('dd/MM/yyyy');
return `${DateTime.fromISO(from).toFormat('dd/MM/yyyy')} ${DateTime.fromISO(to).toFormat('dd/MM/yyyy')}`;
});
onMounted(() => { usersSearcher.search(); load(); }); onMounted(() => { usersSearcher.search(); load(); });
</script> </script>
@ -98,6 +121,23 @@ onMounted(() => { usersSearcher.search(); load(); });
</option> </option>
</select> </select>
</div> </div>
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Periodo rápido</label>
<div class="flex gap-2">
<button
@click="setPresetToday"
class="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer"
>
Hoy
</button>
<button
@click="setPresetThisMonth"
class="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer"
>
Este mes
</button>
</div>
</div>
<button <button
@click="load" @click="load"
class="px-4 py-2 rounded-md bg-primary text-white text-sm font-medium hover:opacity-90 transition-opacity cursor-pointer" class="px-4 py-2 rounded-md bg-primary text-white text-sm font-medium hover:opacity-90 transition-opacity cursor-pointer"
@ -112,19 +152,16 @@ onMounted(() => { usersSearcher.search(); load(); });
icon="receipt_long" icon="receipt_long"
title="Total Multas" title="Total Multas"
:value="stats.total_fines ?? 0" :value="stats.total_fines ?? 0"
:to="viewTo({ name: 'index' })"
/> />
<IndicatorCard <IndicatorCard
icon="check_circle" icon="check_circle"
title="Pagadas" title="Pagadas"
:value="stats.total_paid ?? 0" :value="stats.total_paid ?? 0"
:to="viewTo({ name: 'index' })"
/> />
<IndicatorCard <IndicatorCard
icon="pending_actions" icon="pending_actions"
title="Pendientes" title="Pendientes"
:value="stats.total_unpaid ?? 0" :value="stats.total_unpaid ?? 0"
:to="viewTo({ name: 'index' })"
/> />
</div> </div>
@ -175,8 +212,8 @@ onMounted(() => { usersSearcher.search(); load(); });
<div> <div>
<h2 class="text-base font-semibold mb-3 tracking-wide"> <h2 class="text-base font-semibold mb-3 tracking-wide">
Multas del periodo Multas de <span class="text-primary dark:text-primary-dt">{{ periodLabel }}</span>
<span class="text-gray-400 font-normal">({{ fines.length }})</span> <span class="text-gray-400 font-normal"> ({{ fines.length }})</span>
</h2> </h2>
<div v-if="!processing && fines.length" class="flex flex-col gap-3 max-h-[520px] overflow-y-auto pr-1"> <div v-if="!processing && fines.length" class="flex flex-col gap-3 max-h-[520px] overflow-y-auto pr-1">
<FineResultCard <FineResultCard