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:
parent
77e6e796d5
commit
0d1ccf9413
109
src/components/App/FinePaymentSummary.vue
Normal file
109
src/components/App/FinePaymentSummary.vue
Normal 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>
|
||||
126
src/components/App/FineResultCard.vue
Normal file
126
src/components/App/FineResultCard.vue
Normal 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>
|
||||
126
src/components/App/FineSearchPanel.vue
Normal file
126
src/components/App/FineSearchPanel.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -41,9 +41,14 @@ onMounted(() => {
|
||||
/>
|
||||
<Link
|
||||
icon="receipt_long"
|
||||
name="Gestionar Multas"
|
||||
name="Gestionar Multas"
|
||||
to="fine.index"
|
||||
/>
|
||||
<Link
|
||||
icon="bar_chart"
|
||||
name="Dashboard de Multas"
|
||||
to="fine.dashboard"
|
||||
/>
|
||||
<Link
|
||||
icon="car_tag"
|
||||
name="Autorizar Descuentos"
|
||||
|
||||
@ -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
|
||||
|
||||
194
src/pages/App/Fine/Dashboard.vue
Normal file
194
src/pages/App/Fine/Dashboard.vue
Normal 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>
|
||||
21
src/pages/App/Fine/DashboardModule.js
Normal file
21
src/pages/App/Fine/DashboardModule.js
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
11
src/services/App/FineService.js
Normal file
11
src/services/App/FineService.js
Normal 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 };
|
||||
Loading…
x
Reference in New Issue
Block a user