feat: enhance fine management features

- Updated Searcher component styles for improved UI.
- Added a new Dashboard for fines with statistics and filters.
- Introduced FineResultCard and FinePaymentSummary components for displaying fine details and payment summaries.
- Implemented FineSearchPanel for searching fines by folio or CURP.
- Added download functionality for fine tickets and receipts.
- Updated routing to include a dashboard view for fines.
- Integrated user search functionality for filtering fines by agent.
- Improved overall layout and organization of fine-related components.
This commit is contained in:
Juan Felipe Zapata Moreno 2026-03-28 14:05:21 -06:00
parent 77e6e796d5
commit 0d1ccf9413
12 changed files with 656 additions and 382 deletions

View File

@ -0,0 +1,109 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
selectedFines: { type: Array, default: () => [] },
isProcessingPayment: { type: Boolean, default: false },
});
const emit = defineEmits(["pay"]);
const hasSelection = computed(() => props.selectedFines.length > 0);
const totalSelected = computed(() =>
props.selectedFines.reduce((sum, f) => sum + parseFloat(f.total_amount || 0), 0)
);
const getConcepts = (fine) => {
if (!fine.charge_concepts?.length) return [];
return fine.charge_concepts.map((c) => c.short_name || c.name || "").filter(Boolean);
};
</script>
<template>
<div class="w-72 shrink-0 bg-primary rounded-xl 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-white/70" 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-white/50"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 text-white/30" 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-white/50 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-white/60 mb-2">
Infracciones seleccionadas ({{ selectedFines.length }})
</p>
<div class="space-y-2">
<div
v-for="fine in selectedFines"
:key="fine.id"
class="bg-white/10 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-white/60"
>
<span class="text-white/40 shrink-0 mt-0.5"></span>
<span>{{ concept }}</span>
</li>
<li v-if="!getConcepts(fine).length" class="text-xs text-white/40 italic">
Sin concepto registrado
</li>
</ul>
</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>
<button
@click="emit('pay')"
: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-success hover:bg-success/90 text-white shadow-lg shadow-black/20'
: 'bg-white/10 text-white/30 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>
</template>

View File

@ -0,0 +1,126 @@
<script setup>
import GoogleIcon from '@Shared/GoogleIcon.vue';
import { downloadFineTicket, downloadFineReceipt } from '@/services/App/FineService.js';
const props = defineProps({
fine: { type: Object, required: true },
isSelected: { type: Boolean, default: false },
selectable: { type: Boolean, default: false },
});
const emit = defineEmits(["toggle"]);
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);
};
</script>
<template>
<div
@click="selectable && fine.status !== 'paid' ? emit('toggle', fine) : null"
:class="[
'rounded-xl border-2 p-4 transition-all',
selectable && fine.status !== 'paid' ? 'cursor-pointer' : '',
isSelected
? 'border-primary bg-primary/5'
: 'border-gray-200 hover:border-gray-300',
fine.status === 'paid' ? 'opacity-60' : '',
]"
>
<!-- Encabezado -->
<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',
isSelected ? 'border-primary bg-primary' : 'border-gray-300 bg-white',
]"
>
<div v-if="isSelected" 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>
<div class="flex items-center gap-2">
<span class="font-semibold text-gray-800 text-lg">
${{ parseFloat(fine.total_amount || 0).toFixed(2) }}
</span>
<button
v-if="fine.pdf_path"
@click.stop="downloadFineTicket(fine)"
class="p-2 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-primary transition-colors"
title="Descargar boleta de multa"
>
<GoogleIcon class="text-xl" name="download" />
</button>
<button
v-if="fine.status === 'paid' && fine.payments?.[0]?.receipt_pdf_path"
@click.stop="downloadFineReceipt(fine)"
class="p-2 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-green-600 transition-colors"
title="Descargar recibo de pago"
>
<GoogleIcon class="text-xl" name="receipt_long" />
</button>
</div>
</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">
<GoogleIcon class="w-3.5 h-3.5 text-gray-400 shrink-0" name="person" />
<span class="truncate">{{ fine.name || "-" }}</span>
</div>
<!-- Fecha -->
<div class="flex items-center gap-2 text-gray-600">
<GoogleIcon class="w-3.5 h-3.5 text-gray-400 shrink-0" name="calendar_month" />
<span>{{ formatDate(fine.created_at) }}</span>
</div>
<!-- CURP -->
<div class="flex items-center gap-2 text-gray-600 min-w-0 col-span-2">
<GoogleIcon class="w-3.5 h-3.5 text-gray-400 shrink-0" name="id_card" />
<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">
<GoogleIcon class="w-3.5 h-3.5 text-gray-400 shrink-0" name="airport_shuttle" />
<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">
<GoogleIcon class="w-3.5 h-3.5 text-red-400 shrink-0 mt-0.5" name="warning" />
<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>
</template>

View File

@ -0,0 +1,126 @@
<script setup>
import QRscan from "./QRscan.vue";
const props = defineProps({
activeTab: { type: String, required: true },
isSearching: { type: Boolean, default: false },
folioQuery: { type: String, default: "" },
curpQuery: { type: String, default: "" },
});
const emit = defineEmits([
"update:folioQuery",
"update:curpQuery",
"tab-change",
"search-folio",
"search-curp",
"qr-detected",
]);
const tabs = [
{ id: "qr", label: "Escanear Boleta" },
{ id: "folio", label: "Buscar por Folio" },
{ id: "curp", label: "Buscar por CURP" },
];
</script>
<template>
<div class="rounded-xl bg-white shadow-lg overflow-hidden">
<!-- Pestañas -->
<div class="flex border-b border-gray-200">
<button
v-for="tab in tabs"
:key="tab.id"
@click="emit('tab-change', tab.id)"
:class="[
'flex items-center gap-2 px-6 py-4 text-sm font-medium transition-colors border-b-2 -mb-px',
activeTab === tab.id
? 'border-primary text-primary bg-white'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50',
]"
>
<!-- Ícono QR -->
<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">
<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" />
<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" />
<path d="M14 14h3v3h-3zM17 17h3v3h-3zM14 20h3" />
</svg>
<!-- Ícono Documento -->
<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" />
</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>
<!-- Escanear QR -->
<div v-if="activeTab === 'qr'" class="p-6">
<div class="w-full h-72 bg-gray-900 rounded-xl overflow-hidden mb-4">
<QRscan @qr-detected="emit('qr-detected', $event)" />
</div>
<p class="text-gray-500 text-sm text-center">
Posicione el código QR de la boleta física de infracción frente al lector.
</p>
</div>
<!-- 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-primary focus-within:border-primary 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
:value="folioQuery"
@input="emit('update:folioQuery', $event.target.value)"
@keyup.enter="emit('search-folio')"
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="emit('search-folio')"
:disabled="isSearching"
type="button"
class="px-6 py-2.5 bg-primary hover:bg-primary/90 disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors min-w-[90px]"
>
{{ isSearching ? "Buscando..." : "Buscar" }}
</button>
</div>
</div>
<!-- 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-primary focus-within:border-primary 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
:value="curpQuery"
@input="emit('update:curpQuery', $event.target.value)"
@keyup.enter="emit('search-curp')"
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="emit('search-curp')"
:disabled="isSearching"
type="button"
class="px-6 py-2.5 bg-primary hover:bg-primary/90 disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors min-w-[90px]"
>
{{ isSearching ? "Buscando..." : "Buscar" }}
</button>
</div>
</div>
</div>
</template>

View File

@ -1,50 +1,27 @@
<script setup>
import { ref, computed } from "vue";
import { useApi, apiURL } from "@/services/Api.js";
import QRscan from "./QRscan.vue";
import FineSearchPanel from "./FineSearchPanel.vue";
import FineResultCard from "./FineResultCard.vue";
import FinePaymentSummary from "./FinePaymentSummary.vue";
const emit = defineEmits(["fine-searched", "payment-processed"]);
const api = useApi();
const api = useApi();
// Pestañas
const tabs = [
{ id: "qr", label: "Escanear Boleta" },
{ id: "folio", label: "Buscar por Folio" },
{ id: "curp", label: "Buscar por CURP" },
];
const activeTab = ref("qr");
// Estado de búsqueda
const folioQuery = ref("");
const curpQuery = ref("");
const isSearching = ref(false);
const searchResults = ref([]);
const selectedFines = ref([]);
const searchMessage = ref("");
// Simulador QR
const showSimulator = ref(false);
const simulatorToken = ref("");
const folioQuery = ref("");
const curpQuery = ref("");
const isSearching = ref(false);
const searchResults = ref([]);
const selectedFines = ref([]);
const searchMessage = 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;
@ -61,7 +38,7 @@ const processSingleResult = (data) => {
};
const processMultipleResults = (data) => {
const fines = data.models || [];
const fines = data.models || [];
searchResults.value = fines;
selectedFines.value = [];
const pending = fines.filter((f) => f.status !== "paid");
@ -74,11 +51,11 @@ const processMultipleResults = (data) => {
// Búsquedas
const searchByQR = async (qrToken) => {
if (!qrToken?.trim()) return;
isSearching.value = true;
isSearching.value = true;
searchResults.value = [];
selectedFines.value = [];
await api.get(apiURL("fines/search"), {
params: { qr_token: qrToken.trim() },
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"),
@ -91,11 +68,11 @@ const searchByFolio = async () => {
Notify.warning("Por favor ingresa un folio");
return;
}
isSearching.value = true;
isSearching.value = true;
searchResults.value = [];
selectedFines.value = [];
await api.get(apiURL("fines/search"), {
params: { id: folioQuery.value.trim() },
params: { id: folioQuery.value.trim() },
onSuccess: processSingleResult,
onFail: (e) => Notify.error(e.message || "Error al buscar la multa"),
onError: () => Notify.error("Ocurrió un error al buscar la multa"),
@ -108,11 +85,11 @@ const searchByCurp = async () => {
Notify.warning("Por favor ingresa un CURP o nombre");
return;
}
isSearching.value = true;
isSearching.value = true;
searchResults.value = [];
selectedFines.value = [];
await api.get(apiURL("fines/search"), {
params: { curp: curpQuery.value.trim() },
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"),
@ -128,18 +105,7 @@ const toggleFineSelection = (fine) => {
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 = "";
};
const isFineSelected = (fine) => selectedFines.value.some((f) => f.id === fine.id);
// Cobro
const isProcessingPayment = ref(false);
@ -173,348 +139,57 @@ const handlePayment = async () => {
// Cambio de pestaña
const switchTab = (tab) => {
activeTab.value = tab;
searchResults.value = [];
selectedFines.value = [];
searchMessage.value = "";
folioQuery.value = "";
curpQuery.value = "";
showSimulator.value = false;
simulatorToken.value = "";
activeTab.value = tab;
searchResults.value = [];
selectedFines.value = [];
searchMessage.value = "";
folioQuery.value = "";
curpQuery.value = "";
};
</script>
<template>
<div class="flex gap-5 p-5 min-h-[calc(100vh-80px)]">
<div class="flex gap-5 p-6 min-h-[calc(100vh-80px)]">
<!-- Panel izquierdo -->
<!-- Panel izquierdo -->
<div class="flex-1 flex flex-col gap-4 min-w-0">
<!-- Tarjeta de búsqueda -->
<div class="bg-white rounded-2xl shadow-sm overflow-hidden">
<FineSearchPanel
:active-tab="activeTab"
:is-searching="isSearching"
v-model:folioQuery="folioQuery"
v-model:curpQuery="curpQuery"
@tab-change="switchTab"
@search-folio="searchByFolio"
@search-curp="searchByCurp"
@qr-detected="searchByQR"
/>
<!-- Pestañas -->
<div class="flex border-b border-gray-200">
<button
v-for="tab in tabs"
:key="tab.id"
@click="switchTab(tab.id)"
:class="[
'flex items-center gap-2 px-6 py-4 text-sm font-medium transition-colors border-b-2 -mb-px',
activeTab === tab.id
? 'border-blue-600 text-blue-600 bg-white'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50',
]"
>
<!-- Ícono QR -->
<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">
<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" />
<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" />
<path d="M14 14h3v3h-3zM17 17h3v3h-3zM14 20h3" />
</svg>
<!-- Ícono Documento -->
<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" />
</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>
<!-- Contenido: Escanear QR -->
<div v-if="activeTab === 'qr'" class="p-6">
<!-- Escáner de cámara real -->
<div class="w-full h-72 bg-gray-900 rounded-2xl overflow-hidden mb-4">
<QRscan @qr-detected="handleQRDetected" />
</div>
<p class="text-gray-500 text-sm text-center mb-4">
Posicione el código QR de la boleta física de infracción frente al lector.
</p>
<!-- Botón simulador -->
<div class="flex justify-center mb-2">
<button
@click="showSimulator = !showSimulator"
type="button"
class="px-6 py-2.5 bg-gray-900 hover:bg-gray-700 text-white text-sm font-medium rounded-lg transition-colors"
>
Simular Escaneo de Lector
</button>
</div>
<!-- Input simulador -->
<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
@click="simulateQRScan"
:disabled="isSearching"
type="button"
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"
>
{{ isSearching ? "..." : "Simular" }}
</button>
</div>
</div>
<!-- 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">
<!-- Resultados de búsqueda -->
<div v-if="searchResults.length > 0" class="rounded-xl bg-white p-6 shadow-lg">
<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
<FineResultCard
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>
:fine="fine"
:is-selected="isFineSelected(fine)"
:selectable="activeTab === 'curp'"
@toggle="toggleFineSelection"
/>
</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">
<!-- Panel derecho: Resumen de Cobro -->
<FinePaymentSummary
:selected-fines="selectedFines"
:is-processing-payment="isProcessingPayment"
@pay="handlePayment"
/>
<!-- 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>
</template>

View File

@ -38,7 +38,7 @@ const clear = () => {
v-text="title"
/>
</div>
<div class="flex w-full justify-between items-center border-y-2 border-page-t dark:border-page-dt">
<div class="flex w-full pl-10 pb-2 items-center">
<div>
<div class="relative py-1 z-0">
<div @click="search" class="absolute inset-y-0 right-2 flex items-center pl-3 cursor-pointer">
@ -57,7 +57,7 @@ const clear = () => {
</div>
<input
id="search"
class="bg-gray-100 border border-gray-300 text-gray-700 text-sm rounded-sm outline-0 focus:ring-primary focus:border-primary block sm:w-56 md:w-72 lg:w-80 pr-10 px-2.5 py-1"
class="bg-gray-100 border border-gray-900 text-gray-700 text-sm rounded-sm outline-0 focus:ring-primary focus:border-primary block sm:w-56 md:w-72 lg:w-80 pr-10 px-2.5 py-1"
autocomplete="off"
:placeholder="placeholder"
required

View File

@ -44,6 +44,11 @@ onMounted(() => {
name="Gestionar Multas"
to="fine.index"
/>
<Link
icon="bar_chart"
name="Dashboard de Multas"
to="fine.dashboard"
/>
<Link
icon="car_tag"
name="Autorizar Descuentos"

View File

@ -10,6 +10,7 @@ import ConceptForm from "@App/ConceptSection.vue";
import DestroyView from "@Holos/Modal/Template/Destroy.vue";
import ModalController from "@Controllers/ModalController.js";
import EditModal from "./Modal/Edit.vue";
import Searcher from "@Holos/Searcher.vue";
/** Controladores */
const Modal = new ModalController();
@ -76,6 +77,7 @@ const handleConceptUpdated = () => {
<template>
<PageHeader :title="transl('title')" />
<ConceptForm @concept-created="handleConceptCreated" />
<Searcher title="Cobro de Membresía" @search="(x) => searcher.search(x)"></Searcher>
<div class="mx-4 mb-4">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<Table

View File

@ -0,0 +1,194 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { DateTime } from 'luxon';
import PageHeader from '@Holos/PageHeader.vue';
import IndicatorCard from '@Holos/Card/Indicator.vue';
import FineResultCard from '@App/FineResultCard.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import { useApi, useSearcher } from '@Services/Api';
import { apiTo, viewTo } from './DashboardModule.js';
const api = useApi();
const processing = ref(false);
const fines = ref([]);
const stats = ref({ total_fines: 0, total_paid: 0, total_unpaid: 0, top_concepts: [] });
const users = ref([]);
const today = DateTime.now().toISODate();
const filters = ref({
date_from: today,
date_to: today,
creator_id: '',
});
// useSearcher crea una instancia independiente, evitando conflicto con el singleton de useApi
const usersSearcher = useSearcher({
url: route('admin.users.index'),
onSuccess: (data) => {
const list = Array.isArray(data.models) ? data.models : (data.models?.data ?? []);
users.value = list.filter(u => u);
},
});
const load = () => {
processing.value = true;
const params = { date_from: filters.value.date_from, date_to: filters.value.date_to };
if (filters.value.creator_id) params.creator_id = filters.value.creator_id;
api.get(apiTo('dashboard'), {
params,
onSuccess: (data) => {
fines.value = data.today_fines ?? [];
stats.value = data.stats ?? {};
processing.value = false;
},
onError: () => { processing.value = false; },
});
};
const collectedAmount = computed(() =>
fines.value
.filter(f => f.status === 'paid')
.reduce((s, f) => s + parseFloat(f.total_amount || 0), 0)
);
const pendingAmount = computed(() =>
fines.value
.filter(f => f.status !== 'paid')
.reduce((s, f) => s + parseFloat(f.total_amount || 0), 0)
);
const formatCurrency = (n) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(n);
onMounted(() => { usersSearcher.search(); load(); });
</script>
<template>
<PageHeader title="Dashboard de Multas" />
<!-- Filtros -->
<div class="flex flex-wrap gap-4 mb-6 items-end">
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Fecha inicio</label>
<input
v-model="filters.date_from"
type="date"
class="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Fecha fin</label>
<input
v-model="filters.date_to"
type="date"
class="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Agente</label>
<select
v-model="filters.creator_id"
class="px-6 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Todos</option>
<option v-for="user in users.filter(u => u)" :key="user.id" :value="user.id">
{{ user.full_name || `${user.name} ${user.paternal}` }}
</option>
</select>
</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"
>
Buscar
</button>
</div>
<!-- KPIs de conteo -->
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
<IndicatorCard
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>
<!-- KPIs de montos -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
<div class="relative flex-1 flex flex-col gap-2 p-4 rounded-sm bg-gray-200 dark:bg-transparent dark:border">
<label class="text-base font-semibold tracking-wider">Monto Recaudado</label>
<label class="text-primary dark:text-primary-dt text-3xl font-bold">
{{ formatCurrency(collectedAmount) }}
</label>
<div class="absolute bg-primary dark:bg-primary-d rounded-md p-2 right-4 bottom-4">
<GoogleIcon class="text-3xl text-gray-100" name="payments" :fill="true" />
</div>
</div>
<div class="relative flex-1 flex flex-col gap-2 p-4 rounded-sm bg-gray-200 dark:bg-transparent dark:border">
<label class="text-base font-semibold tracking-wider">Monto Pendiente</label>
<label class="text-primary dark:text-primary-dt text-3xl font-bold">
{{ formatCurrency(pendingAmount) }}
</label>
<div class="absolute bg-primary dark:bg-primary-d rounded-md p-2 right-4 bottom-4">
<GoogleIcon class="text-3xl text-gray-100" name="money_off" :fill="true" />
</div>
</div>
</div>
<!-- Infracciones frecuentes + lista de multas -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h2 class="text-base font-semibold mb-3 tracking-wide">Infracciones más frecuentes</h2>
<div v-if="!processing && stats.top_concepts?.length" class="flex flex-col gap-2">
<div
v-for="concept in stats.top_concepts"
:key="concept.id"
class="flex items-center justify-between p-3 rounded-lg bg-gray-100 dark:bg-gray-800"
>
<span class="text-sm text-gray-700 dark:text-gray-300 truncate pr-4">
{{ concept.short_name || concept.name }}
</span>
<span class="shrink-0 text-sm font-bold text-primary dark:text-primary-dt bg-primary/10 px-2 py-0.5 rounded-full">
{{ concept.total }}
</span>
</div>
</div>
<p v-else-if="!processing" class="text-sm text-gray-400">Sin datos para el periodo.</p>
<p v-else class="text-sm text-gray-400">Cargando...</p>
</div>
<div>
<h2 class="text-base font-semibold mb-3 tracking-wide">
Multas del periodo
<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
v-for="fine in fines"
:key="fine.id"
:fine="fine"
:selectable="false"
/>
</div>
<p v-else-if="!processing" class="text-sm text-gray-400">Sin multas en este periodo.</p>
<p v-else class="text-sm text-gray-400">Cargando...</p>
</div>
</div>
</template>

View File

@ -0,0 +1,21 @@
import { lang } from '@Lang/i18n';
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`fines.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `fine.${name}`, params, query })
// Obtener traducción del componente
const transl = (str) => lang(`fine.${str}`)
// Determina si un usuario puede hacer algo no en base a los permisos
const can = (permission) => hasPermission(`fine.${permission}`)
export {
can,
viewTo,
apiTo,
transl
}

View File

@ -1,6 +1,6 @@
<script setup>
import PageHeader from "@Holos/PageHeader.vue";
import FineSection from '@App/FineSection.vue';
import PageHeader from "@Holos/PageHeader.vue";
import FineSection from '@App/FineSection.vue';
</script>
<template>

View File

@ -75,7 +75,12 @@ const router = createRouter({
path: '',
name: 'fine.index',
component: () => import('@Pages/App/Fine/Index.vue')
}
},
{
path: '',
name: 'fine.dashboard',
component: () => import('@Pages/App/Fine/Dashboard.vue')
},
]
},
{

View File

@ -0,0 +1,11 @@
const downloadFineTicket = (fine) => {
if (!fine.pdf_path) return;
window.open(fine.pdf_path, '_blank');
};
const downloadFineReceipt = (fine) => {
if (!fine.payments?.[0]?.receipt_pdf_path) return;
window.open(fine.payments[0].receipt_pdf_path, '_blank');
};
export { downloadFineTicket, downloadFineReceipt };