feat: agregar métodos de pago y mejorar la visualización de descuentos en multas
This commit is contained in:
parent
0d1ccf9413
commit
5ee73c309f
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
selectedFines: { type: Array, default: () => [] },
|
||||
@ -8,16 +8,44 @@ const props = defineProps({
|
||||
|
||||
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 canPay = computed(() => hasSelection.value && selectedPaymentMethod.value !== null);
|
||||
|
||||
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)
|
||||
);
|
||||
|
||||
const hasAnyDiscount = computed(() =>
|
||||
props.selectedFines.some((f) => f.discount != null)
|
||||
);
|
||||
|
||||
const totalSavings = computed(() => originalTotal.value - totalSelected.value);
|
||||
|
||||
const getConcepts = (fine) => {
|
||||
if (!fine.charge_concepts?.length) return [];
|
||||
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>
|
||||
|
||||
<template>
|
||||
@ -57,9 +85,17 @@ const getConcepts = (fine) => {
|
||||
>
|
||||
<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-semibold text-white shrink-0 ml-2">
|
||||
${{ parseFloat(fine.total_amount || 0).toFixed(2) }}
|
||||
</span>
|
||||
<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) }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-white leading-none">
|
||||
${{ parseFloat(fine.discount ?? fine.total_amount ?? 0).toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
<li
|
||||
@ -76,24 +112,60 @@ const getConcepts = (fine) => {
|
||||
</ul>
|
||||
</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>
|
||||
|
||||
<!-- Totales y botón -->
|
||||
<div class="mt-auto pt-4 border-t border-white/20">
|
||||
<div class="flex justify-between text-sm text-white/60 mb-2">
|
||||
<span>Subtotal</span>
|
||||
<span>${{ totalSelected.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 v-if="!hasAnyDiscount">
|
||||
<div class="flex justify-between text-sm text-white/60 mb-2">
|
||||
<span>Subtotal</span>
|
||||
<span>${{ totalSelected.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>
|
||||
<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
|
||||
@click="emit('pay')"
|
||||
:disabled="!hasSelection || isProcessingPayment"
|
||||
@click="handlePay"
|
||||
:disabled="!canPay || isProcessingPayment"
|
||||
:class="[
|
||||
'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-white/10 text-white/30 cursor-not-allowed',
|
||||
]"
|
||||
@ -103,6 +175,9 @@ const getConcepts = (fine) => {
|
||||
</svg>
|
||||
{{ isProcessingPayment ? "Procesando..." : "Cobrar Total" }}
|
||||
</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>
|
||||
|
||||
@ -15,6 +15,17 @@ const formatDate = (dateStr) => {
|
||||
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) => {
|
||||
if (!fine.charge_concepts?.length) return [];
|
||||
return fine.charge_concepts.map((c) => c.short_name || c.name || "").filter(Boolean);
|
||||
@ -52,11 +63,30 @@ const getConcepts = (fine) => {
|
||||
>
|
||||
Pagada
|
||||
</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 class="flex items-center gap-2">
|
||||
<span class="font-semibold text-gray-800 text-lg">
|
||||
${{ parseFloat(fine.total_amount || 0).toFixed(2) }}
|
||||
</span>
|
||||
<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) }}
|
||||
</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
|
||||
v-if="fine.pdf_path"
|
||||
@click.stop="downloadFineTicket(fine)"
|
||||
|
||||
@ -110,31 +110,45 @@ const isFineSelected = (fine) => selectedFines.value.some((f) => f.id === fine.i
|
||||
// ─── Cobro ────────────────────────────────────────────────────────────────────
|
||||
const isProcessingPayment = ref(false);
|
||||
|
||||
const handlePayment = async () => {
|
||||
if (!hasSelection.value) return;
|
||||
const handlePayment = (paymentMethod) => {
|
||||
if (!hasSelection.value || !paymentMethod) return;
|
||||
isProcessingPayment.value = true;
|
||||
let successCount = 0;
|
||||
for (const fine of selectedFines.value) {
|
||||
await api.post(apiURL(`fines/${fine.id}/mark-as-paid`), {
|
||||
|
||||
const finesToPay = [...selectedFines.value];
|
||||
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) => {
|
||||
successCount++;
|
||||
paidCount++;
|
||||
const index = searchResults.value.findIndex(f => f.id === fine.id);
|
||||
if (index !== -1) {
|
||||
searchResults.value[index] = data.fine;
|
||||
}
|
||||
selectedFines.value = selectedFines.value.filter(f => f.id !== fine.id);
|
||||
emit("payment-processed", { fine, paymentData: data });
|
||||
if (paidCount === total) {
|
||||
isProcessingPayment.value = false;
|
||||
Notify.success(
|
||||
total === 1
|
||||
? "Multa cobrada exitosamente"
|
||||
: `${total} multas cobradas exitosamente`
|
||||
);
|
||||
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");
|
||||
},
|
||||
onFail: (e) => Notify.error(e.message || "Error al procesar el pago"),
|
||||
onError: () => Notify.error("Ocurrió un error al procesar el pago"),
|
||||
});
|
||||
}
|
||||
isProcessingPayment.value = false;
|
||||
if (successCount > 0) {
|
||||
Notify.success(
|
||||
successCount === 1
|
||||
? "Multa cobrada exitosamente"
|
||||
: `${successCount} multas cobradas exitosamente`
|
||||
);
|
||||
searchResults.value = [];
|
||||
selectedFines.value = [];
|
||||
searchMessage.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Cambio de pestaña ────────────────────────────────────────────────────────
|
||||
|
||||
@ -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 = () => {
|
||||
processing.value = true;
|
||||
const params = { date_from: filters.value.date_from, date_to: filters.value.date_to };
|
||||
@ -39,8 +53,8 @@ const load = () => {
|
||||
api.get(apiTo('dashboard'), {
|
||||
params,
|
||||
onSuccess: (data) => {
|
||||
fines.value = data.today_fines ?? [];
|
||||
stats.value = data.stats ?? {};
|
||||
fines.value = data.fines ?? [];
|
||||
stats.value = data.stats ?? {};
|
||||
processing.value = false;
|
||||
},
|
||||
onError: () => { processing.value = false; },
|
||||
@ -62,6 +76,15 @@ const pendingAmount = computed(() =>
|
||||
const formatCurrency = (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(); });
|
||||
</script>
|
||||
|
||||
@ -98,6 +121,23 @@ onMounted(() => { usersSearcher.search(); load(); });
|
||||
</option>
|
||||
</select>
|
||||
</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
|
||||
@click="load"
|
||||
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"
|
||||
title="Total Multas"
|
||||
:value="stats.total_fines ?? 0"
|
||||
:to="viewTo({ name: 'index' })"
|
||||
/>
|
||||
<IndicatorCard
|
||||
icon="check_circle"
|
||||
title="Pagadas"
|
||||
:value="stats.total_paid ?? 0"
|
||||
:to="viewTo({ name: 'index' })"
|
||||
/>
|
||||
<IndicatorCard
|
||||
icon="pending_actions"
|
||||
title="Pendientes"
|
||||
:value="stats.total_unpaid ?? 0"
|
||||
:to="viewTo({ name: 'index' })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -175,8 +212,8 @@ onMounted(() => { usersSearcher.search(); load(); });
|
||||
|
||||
<div>
|
||||
<h2 class="text-base font-semibold mb-3 tracking-wide">
|
||||
Multas del periodo
|
||||
<span class="text-gray-400 font-normal">({{ fines.length }})</span>
|
||||
Multas de <span class="text-primary dark:text-primary-dt">{{ periodLabel }}</span>
|
||||
<span class="text-gray-400 font-normal"> ({{ fines.length }})</span>
|
||||
</h2>
|
||||
<div v-if="!processing && fines.length" class="flex flex-col gap-3 max-h-[520px] overflow-y-auto pr-1">
|
||||
<FineResultCard
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user