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>
|
<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>
|
||||||
|
|||||||
@ -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)"
|
||||||
|
|||||||
@ -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 ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -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,7 +212,7 @@ 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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user