FIX:Intefaz de cobro de multa
This commit is contained in:
parent
f53d0ff457
commit
04fcb9df08
@ -1,268 +1,520 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { useApi, apiURL } from "@/services/Api.js";
|
import { useApi, apiURL } from "@/services/Api.js";
|
||||||
|
|
||||||
import Input from "@Holos/Form/Input.vue";
|
|
||||||
import QRscan from "./QRscan.vue";
|
import QRscan from "./QRscan.vue";
|
||||||
|
|
||||||
/** Eventos */
|
const emit = defineEmits(["fine-searched", "payment-processed"]);
|
||||||
const emit = defineEmits(["fine-searched"]);
|
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
/** Refs */
|
// ─── Pestañas ────────────────────────────────────────────────────────────────
|
||||||
const fineNumber = ref("");
|
const tabs = [
|
||||||
const fineData = ref({
|
{ id: "qr", label: "Escanear Boleta" },
|
||||||
fecha: "",
|
{ id: "folio", label: "Buscar por Folio" },
|
||||||
placa: "",
|
{ id: "curp", label: "Buscar por CURP" },
|
||||||
vin: "",
|
];
|
||||||
licencia: "",
|
const activeTab = ref("qr");
|
||||||
tarjeta: "",
|
|
||||||
rfc: "",
|
|
||||||
nombre: "",
|
|
||||||
monto: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Métodos */
|
// ─── Estado de búsqueda ───────────────────────────────────────────────────────
|
||||||
const handleSearch = async () => {
|
const folioQuery = ref("");
|
||||||
if (!fineNumber.value.trim()) {
|
const curpQuery = ref("");
|
||||||
Notify.warning("Por favor ingresa o escanea una multa");
|
const isSearching = ref(false);
|
||||||
|
const searchResults = ref([]);
|
||||||
|
const selectedFines = ref([]);
|
||||||
|
const searchMessage = ref("");
|
||||||
|
|
||||||
|
// ─── Simulador QR ─────────────────────────────────────────────────────────────
|
||||||
|
const showSimulator = ref(false);
|
||||||
|
const simulatorToken = ref("");
|
||||||
|
|
||||||
|
// ─── Computed ─────────────────────────────────────────────────────────────────
|
||||||
|
const totalSelected = computed(() =>
|
||||||
|
selectedFines.value.reduce((sum, f) => sum + parseFloat(f.total_amount || 0), 0)
|
||||||
|
);
|
||||||
|
const hasSelection = computed(() => selectedFines.value.length > 0);
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return "-";
|
||||||
|
return new Date(dateStr).toLocaleDateString("es-MX");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConcepts = (fine) => {
|
||||||
|
if (!fine.charge_concepts?.length) return [];
|
||||||
|
return fine.charge_concepts.map((c) => c.short_name || c.name || "").filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFineSelected = (fine) => selectedFines.value.some((f) => f.id === fine.id);
|
||||||
|
|
||||||
|
// ─── Procesado de respuestas ──────────────────────────────────────────────────
|
||||||
|
const processSingleResult = (data) => {
|
||||||
|
const fine = data.model;
|
||||||
|
searchResults.value = [fine];
|
||||||
|
if (fine.status === "paid") {
|
||||||
|
searchMessage.value = "Se encontró 1 multa (ya pagada).";
|
||||||
|
selectedFines.value = [];
|
||||||
|
Notify.info("La multa ya ha sido pagada previamente");
|
||||||
|
} else {
|
||||||
|
searchMessage.value = "Se encontraron 1 infracciones pendientes.";
|
||||||
|
selectedFines.value = [fine];
|
||||||
|
emit("fine-searched", { rawData: data });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processMultipleResults = (data) => {
|
||||||
|
const fines = data.models || [];
|
||||||
|
searchResults.value = fines;
|
||||||
|
selectedFines.value = [];
|
||||||
|
const pending = fines.filter((f) => f.status !== "paid");
|
||||||
|
searchMessage.value =
|
||||||
|
pending.length > 0
|
||||||
|
? `Se encontraron ${pending.length} infracciones pendientes.`
|
||||||
|
: "No se encontraron infracciones pendientes.";
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Búsquedas ────────────────────────────────────────────────────────────────
|
||||||
|
const searchByQR = async (qrToken) => {
|
||||||
|
if (!qrToken?.trim()) return;
|
||||||
|
isSearching.value = true;
|
||||||
|
searchResults.value = [];
|
||||||
|
selectedFines.value = [];
|
||||||
|
await api.get(apiURL("fines/search"), {
|
||||||
|
params: { qr_token: qrToken.trim() },
|
||||||
|
onSuccess: processSingleResult,
|
||||||
|
onFail: (e) => Notify.error(e.message || "Error al buscar la multa"),
|
||||||
|
onError: () => Notify.error("Ocurrió un error al buscar la multa"),
|
||||||
|
});
|
||||||
|
isSearching.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchByFolio = async () => {
|
||||||
|
if (!folioQuery.value.trim()) {
|
||||||
|
Notify.warning("Por favor ingresa un folio");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
isSearching.value = true;
|
||||||
await api.get(apiURL(`fines/search`), {
|
searchResults.value = [];
|
||||||
params: {
|
selectedFines.value = [];
|
||||||
qr_token: fineNumber.value.trim(),
|
await api.get(apiURL("fines/search"), {
|
||||||
},
|
params: { id: folioQuery.value.trim() },
|
||||||
onSuccess: (data) => {
|
onSuccess: processSingleResult,
|
||||||
const model = data.model;
|
onFail: (e) => Notify.error(e.message || "Error al buscar la multa"),
|
||||||
|
onError: () => Notify.error("Ocurrió un error al buscar la multa"),
|
||||||
if (model.status === "paid") {
|
|
||||||
Notify.info("La multa ya ha sido pagada previamente");
|
|
||||||
|
|
||||||
fineData.value = {
|
|
||||||
id: model.id,
|
|
||||||
fecha: new Date(model.created_at).toLocaleDateString("es-MX"),
|
|
||||||
placa: model.plate || "",
|
|
||||||
vin: model.vin || "",
|
|
||||||
licencia: model.license || "",
|
|
||||||
tarjeta: model.circulation || "",
|
|
||||||
rfc: model.rfc || "",
|
|
||||||
nombre: model.name || "",
|
|
||||||
monto: `$${data.total_to_pay.toFixed(2)}`,
|
|
||||||
isPaid: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multa pendiente
|
|
||||||
fineData.value = {
|
|
||||||
id: model.id,
|
|
||||||
fecha: new Date(model.created_at).toLocaleDateString("es-MX"),
|
|
||||||
placa: model.plate || "",
|
|
||||||
vin: model.vin || "",
|
|
||||||
licencia: model.license || "",
|
|
||||||
tarjeta: model.circulation || "",
|
|
||||||
rfc: model.rfc || "",
|
|
||||||
nombre: model.name || "",
|
|
||||||
monto: `$${data.total_to_pay.toFixed(2)}`,
|
|
||||||
isPaid: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
emit("fine-searched", {
|
|
||||||
...fineData.value,
|
|
||||||
rawData: data,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onFail: (error) => {
|
|
||||||
Notify.error(error.message || "Error al buscar la multa");
|
|
||||||
fineData.value = {
|
|
||||||
fecha: "",
|
|
||||||
placa: "",
|
|
||||||
vin: "",
|
|
||||||
licencia: "",
|
|
||||||
tarjeta: "",
|
|
||||||
rfc: "",
|
|
||||||
nombre: "",
|
|
||||||
monto: "",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Error al buscar multa:", error);
|
|
||||||
Notify.error("Ocurrió un error al buscar la multa");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
isSearching.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const searchByCurp = async () => {
|
||||||
|
if (!curpQuery.value.trim()) {
|
||||||
|
Notify.warning("Por favor ingresa un CURP o nombre");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isSearching.value = true;
|
||||||
|
searchResults.value = [];
|
||||||
|
selectedFines.value = [];
|
||||||
|
await api.get(apiURL("fines/search"), {
|
||||||
|
params: { curp: curpQuery.value.trim() },
|
||||||
|
onSuccess: processMultipleResults,
|
||||||
|
onFail: (e) => Notify.error(e.message || "Error al buscar multas"),
|
||||||
|
onError: () => Notify.error("Ocurrió un error al buscar multas"),
|
||||||
|
});
|
||||||
|
isSearching.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Selección ────────────────────────────────────────────────────────────────
|
||||||
|
const toggleFineSelection = (fine) => {
|
||||||
|
if (fine.status === "paid") return;
|
||||||
|
const idx = selectedFines.value.findIndex((f) => f.id === fine.id);
|
||||||
|
if (idx >= 0) selectedFines.value.splice(idx, 1);
|
||||||
|
else selectedFines.value.push(fine);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── QR Simulator ─────────────────────────────────────────────────────────────
|
||||||
|
const handleQRDetected = (qrToken) => searchByQR(qrToken);
|
||||||
|
|
||||||
|
const simulateQRScan = () => {
|
||||||
|
if (!simulatorToken.value.trim()) {
|
||||||
|
Notify.warning("Ingresa un QR token para simular");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchByQR(simulatorToken.value.trim());
|
||||||
|
showSimulator.value = false;
|
||||||
|
simulatorToken.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Cobro ────────────────────────────────────────────────────────────────────
|
||||||
|
const isProcessingPayment = ref(false);
|
||||||
|
|
||||||
const handlePayment = async () => {
|
const handlePayment = async () => {
|
||||||
if (!fineData.value.monto) {
|
if (!hasSelection.value) return;
|
||||||
Notify.warning("No hay multa cargada para cobrar");
|
isProcessingPayment.value = true;
|
||||||
return;
|
let successCount = 0;
|
||||||
|
for (const fine of selectedFines.value) {
|
||||||
|
await api.post(apiURL(`fines/${fine.id}/mark-as-paid`), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
successCount++;
|
||||||
|
emit("payment-processed", { fine, paymentData: data });
|
||||||
|
},
|
||||||
|
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 (fineData.value.isPaid) {
|
if (successCount > 0) {
|
||||||
Notify.warning("Esta multa ya ha sido pagada previamente");
|
Notify.success(
|
||||||
return;
|
successCount === 1
|
||||||
|
? "Multa cobrada exitosamente"
|
||||||
|
: `${successCount} multas cobradas exitosamente`
|
||||||
|
);
|
||||||
|
searchResults.value = [];
|
||||||
|
selectedFines.value = [];
|
||||||
|
searchMessage.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.post(apiURL(`fines/${fineData.value.id}/mark-as-paid`), {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
Notify.success("Multa cobrada exitosamente");
|
|
||||||
|
|
||||||
emit("payment-processed", { ...fineData.value, paymentData: data });
|
|
||||||
|
|
||||||
fineData.value.isPaid = true;
|
|
||||||
},
|
|
||||||
onFail: (error) => {
|
|
||||||
Notify.error(
|
|
||||||
error.message || "Error al procesar el pago de la multa"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
Notify.error("Ocurrió un error al procesar el pago de la multa");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearForm = () => {
|
// ─── Cambio de pestaña ────────────────────────────────────────────────────────
|
||||||
fineData.value = {
|
const switchTab = (tab) => {
|
||||||
id: null,
|
activeTab.value = tab;
|
||||||
fecha: "",
|
searchResults.value = [];
|
||||||
placa: "",
|
selectedFines.value = [];
|
||||||
vin: "",
|
searchMessage.value = "";
|
||||||
licencia: "",
|
folioQuery.value = "";
|
||||||
tarjeta: "",
|
curpQuery.value = "";
|
||||||
rfc: "",
|
showSimulator.value = false;
|
||||||
nombre: "",
|
simulatorToken.value = "";
|
||||||
monto: "",
|
|
||||||
};
|
|
||||||
fineNumber.value = "";
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 p-6">
|
<div class="flex gap-5 p-5 min-h-[calc(100vh-80px)]">
|
||||||
<div class="bg-white rounded-xl p-6 shadow-lg">
|
|
||||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">Buscar Multa</h3>
|
|
||||||
|
|
||||||
<div class="mb-6">
|
<!-- ── Panel izquierdo ───────────────────────────────────────────────── -->
|
||||||
<label class="block text-sm text-gray-600 font-medium mb-2">
|
<div class="flex-1 flex flex-col gap-4 min-w-0">
|
||||||
Escanear Código QR
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
class="w-full h-80 bg-gray-800 rounded-lg flex flex-col items-center justify-center"
|
|
||||||
>
|
|
||||||
<!-- QR -->
|
|
||||||
<QRscan
|
|
||||||
v-model="fineNumber"
|
|
||||||
@qr-detected="handleSearch"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-5">
|
<!-- Tarjeta de búsqueda -->
|
||||||
<Input
|
<div class="bg-white rounded-2xl shadow-sm overflow-hidden">
|
||||||
v-model="fineNumber"
|
|
||||||
id="Número de Multa"
|
|
||||||
type="text"
|
|
||||||
placeholder="Ingrese el número de multa"
|
|
||||||
@keyup.enter="handleSearch"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<!-- Pestañas -->
|
||||||
@click="handleSearch"
|
<div class="flex border-b border-gray-200">
|
||||||
type="button"
|
<button
|
||||||
class="w-full bg-[#7a0b3a] hover:bg-[#68082e] text-white font-medium py-3.5 rounded-lg transition-colors"
|
v-for="tab in tabs"
|
||||||
>
|
:key="tab.id"
|
||||||
Buscar
|
@click="switchTab(tab.id)"
|
||||||
</button>
|
:class="[
|
||||||
</div>
|
'flex items-center gap-2 px-6 py-4 text-sm font-medium transition-colors border-b-2 -mb-px',
|
||||||
<div class="bg-white rounded-xl p-6 shadow-lg">
|
activeTab === tab.id
|
||||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">
|
? 'border-blue-600 text-blue-600 bg-white'
|
||||||
Detalles de la Multa
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50',
|
||||||
</h3>
|
]"
|
||||||
|
>
|
||||||
<form @submit.prevent="handlePayment">
|
<!-- Ícono QR -->
|
||||||
<!-- Fecha y Placa -->
|
<svg v-if="tab.id === 'qr'" xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<div class="grid grid-cols-2 gap-4 mb-5">
|
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" />
|
||||||
<Input
|
<rect x="5" y="5" width="3" height="3" fill="currentColor" stroke="none" /><rect x="16" y="5" width="3" height="3" fill="currentColor" stroke="none" /><rect x="5" y="16" width="3" height="3" fill="currentColor" stroke="none" />
|
||||||
v-model="fineData.fecha"
|
<path d="M14 14h3v3h-3zM17 17h3v3h-3zM14 20h3" />
|
||||||
id="Fecha de Multa"
|
</svg>
|
||||||
type="text"
|
<!-- Ícono Documento -->
|
||||||
disabled
|
<svg v-else-if="tab.id === 'folio'" xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
/>
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14,2 14,8 20,8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10,9 9,9 8,9" />
|
||||||
<Input v-model="fineData.placa" id="Placa" type="text" disabled />
|
</svg>
|
||||||
|
<!-- Ícono Persona -->
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- VIN y Licencia -->
|
<!-- ── Contenido: Escanear QR ──────────────────────────────────── -->
|
||||||
<div class="grid grid-cols-2 gap-4 mb-5">
|
<div v-if="activeTab === 'qr'" class="p-6">
|
||||||
<Input v-model="fineData.vin" id="VIN" type="text" disabled />
|
<!-- Escáner de cámara real -->
|
||||||
<Input
|
<div class="w-full h-72 bg-gray-900 rounded-2xl overflow-hidden mb-4">
|
||||||
v-model="fineData.licencia"
|
<QRscan @qr-detected="handleQRDetected" />
|
||||||
id="Licencia"
|
</div>
|
||||||
type="text"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tarjeta de Circulación y RFC -->
|
<p class="text-gray-500 text-sm text-center mb-4">
|
||||||
<div class="grid grid-cols-2 gap-4 mb-5">
|
Posicione el código QR de la boleta física de infracción frente al lector.
|
||||||
<Input
|
</p>
|
||||||
v-model="fineData.tarjeta"
|
|
||||||
id="Tarjeta de Circulación"
|
|
||||||
type="text"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<Input v-model="fineData.rfc" id="RFC" type="text" disabled />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nombre -->
|
<!-- Botón simulador -->
|
||||||
<div class="mb-5">
|
<div class="flex justify-center mb-2">
|
||||||
<Input v-model="fineData.nombre" id="Nombre" type="text" disabled />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Línea separadora -->
|
|
||||||
<hr class="my-6 border-gray-300" />
|
|
||||||
|
|
||||||
<!-- Monto a Pagar -->
|
|
||||||
<div class="mb-5">
|
|
||||||
<Input
|
|
||||||
v-model="fineData.monto"
|
|
||||||
id="Monto a Pagar"
|
|
||||||
type="text"
|
|
||||||
disabled
|
|
||||||
class="text-lg font-semibold"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div v-if="fineData.isPaid">
|
|
||||||
<button
|
<button
|
||||||
|
@click="showSimulator = !showSimulator"
|
||||||
type="button"
|
type="button"
|
||||||
@click="clearForm"
|
class="px-6 py-2.5 bg-gray-900 hover:bg-gray-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-3.5 rounded-lg transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
>
|
||||||
Limpiar y buscar otra multa
|
Simular Escaneo de Lector
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Botón Cobrar -->
|
<!-- Input simulador -->
|
||||||
<div>
|
<div v-if="showSimulator" class="mt-3 flex gap-2">
|
||||||
|
<div class="flex-1 flex items-center gap-2 border border-gray-300 rounded-lg px-3 py-2.5 bg-white focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-blue-500 transition-all">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
v-model="simulatorToken"
|
||||||
|
@keyup.enter="simulateQRScan"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ingresa el QR token a simular..."
|
||||||
|
class="flex-1 text-sm outline-none bg-transparent text-gray-800 placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
@click="simulateQRScan"
|
||||||
:disabled="fineData.isPaid"
|
:disabled="isSearching"
|
||||||
:class="[
|
type="button"
|
||||||
'w-full font-medium py-3.5 rounded-lg transition-colors',
|
class="px-5 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors"
|
||||||
fineData.isPaid
|
|
||||||
? 'bg-gray-400 cursor-not-allowed'
|
|
||||||
: 'bg-green-700 hover:bg-green-600 text-white',
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
Cobrar
|
{{ isSearching ? "..." : "Simular" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
<!-- ── Contenido: Buscar por Folio ────────────────────────────── -->
|
||||||
|
<div v-else-if="activeTab === 'folio'" class="p-6">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="flex-1 flex items-center gap-2 border border-gray-300 rounded-lg px-3 py-2.5 bg-white focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-blue-500 transition-all">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
v-model="folioQuery"
|
||||||
|
@keyup.enter="searchByFolio"
|
||||||
|
type="text"
|
||||||
|
placeholder="Número de folio..."
|
||||||
|
class="flex-1 text-sm outline-none bg-transparent text-gray-800 placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="searchByFolio"
|
||||||
|
:disabled="isSearching"
|
||||||
|
type="button"
|
||||||
|
class="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors min-w-[90px]"
|
||||||
|
>
|
||||||
|
{{ isSearching ? "Buscando..." : "Buscar" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Contenido: Buscar por CURP / Nombre ─────────────────────── -->
|
||||||
|
<div v-else class="p-6">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="flex-1 flex items-center gap-2 border border-gray-300 rounded-lg px-3 py-2.5 bg-white focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-blue-500 transition-all">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
v-model="curpQuery"
|
||||||
|
@keyup.enter="searchByCurp"
|
||||||
|
type="text"
|
||||||
|
placeholder="CURP o nombre del infractor..."
|
||||||
|
class="flex-1 text-sm outline-none bg-transparent text-gray-800 placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="searchByCurp"
|
||||||
|
:disabled="isSearching"
|
||||||
|
type="button"
|
||||||
|
class="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors min-w-[90px]"
|
||||||
|
>
|
||||||
|
{{ isSearching ? "Buscando..." : "Buscar" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Resultados de búsqueda ────────────────────────────────────────── -->
|
||||||
|
<div v-if="searchResults.length > 0" class="bg-white rounded-2xl shadow-sm p-6">
|
||||||
|
<h3 class="font-semibold text-gray-800 mb-0.5">Resultados de la búsqueda</h3>
|
||||||
|
<p class="text-sm text-gray-400 mb-4">{{ searchMessage }}</p>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="fine in searchResults"
|
||||||
|
:key="fine.id"
|
||||||
|
@click="activeTab === 'curp' ? toggleFineSelection(fine) : null"
|
||||||
|
:class="[
|
||||||
|
'rounded-xl border-2 p-4 transition-all',
|
||||||
|
activeTab === 'curp' && fine.status !== 'paid' ? 'cursor-pointer' : '',
|
||||||
|
isFineSelected(fine)
|
||||||
|
? 'border-blue-500 bg-blue-50/20'
|
||||||
|
: 'border-gray-200 hover:border-gray-300',
|
||||||
|
fine.status === 'paid' ? 'opacity-60' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- Encabezado de tarjeta -->
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Indicador de selección -->
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors',
|
||||||
|
isFineSelected(fine)
|
||||||
|
? 'border-blue-500 bg-blue-500'
|
||||||
|
: 'border-gray-300 bg-white',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div v-if="isFineSelected(fine)" class="w-2 h-2 bg-white rounded-full" />
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold text-gray-800">Folio: {{ fine.id }}</span>
|
||||||
|
<span
|
||||||
|
v-if="fine.status === 'paid'"
|
||||||
|
class="text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full"
|
||||||
|
>
|
||||||
|
Pagada
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold text-gray-800 text-base">
|
||||||
|
${{ parseFloat(fine.total_amount || 0).toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid de datos -->
|
||||||
|
<div class="grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm mb-2">
|
||||||
|
<!-- Nombre -->
|
||||||
|
<div class="flex items-center gap-2 text-gray-600 min-w-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
|
||||||
|
</svg>
|
||||||
|
<span class="truncate">{{ fine.name || "-" }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Fecha -->
|
||||||
|
<div class="flex items-center gap-2 text-gray-600">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ formatDate(fine.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- CURP -->
|
||||||
|
<div class="flex items-center gap-2 text-gray-600 min-w-0 col-span-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14,2 14,8 20,8"/>
|
||||||
|
</svg>
|
||||||
|
<span class="truncate">CURP: {{ fine.curp || "-" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vehículo -->
|
||||||
|
<div v-if="fine.plate || fine.vin" class="flex items-center gap-2 text-sm text-gray-600 mb-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M5 17H3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v9a2 2 0 0 1-2 2h-2"/><circle cx="7.5" cy="17.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-500">
|
||||||
|
{{ [fine.plate ? `Placas: ${fine.plate}` : null, fine.vin ? `VIN: ${fine.vin}` : null].filter(Boolean).join(" · ") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conceptos de infracción -->
|
||||||
|
<div v-if="getConcepts(fine).length" class="pt-2 border-t border-gray-100">
|
||||||
|
<div class="flex items-start gap-2 mb-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 text-red-400 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs font-medium text-red-500 uppercase tracking-wide">
|
||||||
|
{{ getConcepts(fine).length === 1 ? "Infracción" : "Infracciones" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-1 pl-5">
|
||||||
|
<li
|
||||||
|
v-for="(concept, i) in getConcepts(fine)"
|
||||||
|
:key="i"
|
||||||
|
class="text-sm text-red-500 flex items-start gap-1.5"
|
||||||
|
>
|
||||||
|
<span class="text-red-300 mt-1 shrink-0">•</span>
|
||||||
|
<span>{{ concept }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Panel derecho: Resumen de Cobro ────────────────────────────────── -->
|
||||||
|
<div class="w-72 shrink-0 bg-[#1a1f2e] rounded-2xl p-5 flex flex-col text-white self-start sticky top-5">
|
||||||
|
|
||||||
|
<!-- Título -->
|
||||||
|
<div class="flex items-center gap-2 font-semibold text-base mb-5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
|
||||||
|
</svg>
|
||||||
|
Resumen de Cobro
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Estado vacío -->
|
||||||
|
<div
|
||||||
|
v-if="!hasSelection"
|
||||||
|
class="flex flex-col items-center justify-center py-10 gap-3 text-gray-500"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-center text-xs text-gray-500 leading-relaxed">
|
||||||
|
Seleccione al menos una infracción para procesar el cobro.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Multas seleccionadas -->
|
||||||
|
<div v-else class="flex-1 mb-4">
|
||||||
|
<p class="text-xs text-gray-400 mb-2">
|
||||||
|
Infracciones seleccionadas ({{ selectedFines.length }})
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="fine in selectedFines"
|
||||||
|
:key="fine.id"
|
||||||
|
class="bg-[#252b3b] rounded-xl p-3"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="(concept, i) in getConcepts(fine)"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-start gap-1.5 text-xs text-gray-400"
|
||||||
|
>
|
||||||
|
<span class="text-gray-600 shrink-0 mt-0.5">•</span>
|
||||||
|
<span>{{ concept }}</span>
|
||||||
|
</li>
|
||||||
|
<li v-if="!getConcepts(fine).length" class="text-xs text-gray-500 italic">
|
||||||
|
Sin concepto registrado
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Totales y botón -->
|
||||||
|
<div class="mt-auto pt-4 border-t border-gray-700">
|
||||||
|
<div class="flex justify-between text-sm text-gray-400 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>
|
||||||
|
<button
|
||||||
|
@click="handlePayment"
|
||||||
|
:disabled="!hasSelection || isProcessingPayment"
|
||||||
|
:class="[
|
||||||
|
'w-full py-3 rounded-xl font-semibold text-sm flex items-center justify-center gap-2 transition-all',
|
||||||
|
hasSelection && !isProcessingPayment
|
||||||
|
? 'bg-green-500 hover:bg-green-600 text-white shadow-lg shadow-green-500/20'
|
||||||
|
: 'bg-[#252b3b] text-gray-500 cursor-not-allowed',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
|
||||||
|
</svg>
|
||||||
|
{{ isProcessingPayment ? "Procesando..." : "Cobrar Total" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user