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,17 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { useApi, apiURL } from "@/services/Api.js";
|
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 emit = defineEmits(["fine-searched", "payment-processed"]);
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
// ─── Pestañas ────────────────────────────────────────────────────────────────
|
// ─── 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");
|
const activeTab = ref("qr");
|
||||||
|
|
||||||
// ─── Estado de búsqueda ───────────────────────────────────────────────────────
|
// ─── Estado de búsqueda ───────────────────────────────────────────────────────
|
||||||
@ -22,29 +19,9 @@ const searchResults = ref([]);
|
|||||||
const selectedFines = ref([]);
|
const selectedFines = ref([]);
|
||||||
const searchMessage = ref("");
|
const searchMessage = ref("");
|
||||||
|
|
||||||
// ─── Simulador QR ─────────────────────────────────────────────────────────────
|
|
||||||
const showSimulator = ref(false);
|
|
||||||
const simulatorToken = ref("");
|
|
||||||
|
|
||||||
// ─── Computed ─────────────────────────────────────────────────────────────────
|
// ─── Computed ─────────────────────────────────────────────────────────────────
|
||||||
const totalSelected = computed(() =>
|
|
||||||
selectedFines.value.reduce((sum, f) => sum + parseFloat(f.total_amount || 0), 0)
|
|
||||||
);
|
|
||||||
const hasSelection = computed(() => selectedFines.value.length > 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 ──────────────────────────────────────────────────
|
// ─── Procesado de respuestas ──────────────────────────────────────────────────
|
||||||
const processSingleResult = (data) => {
|
const processSingleResult = (data) => {
|
||||||
const fine = data.model;
|
const fine = data.model;
|
||||||
@ -128,18 +105,7 @@ const toggleFineSelection = (fine) => {
|
|||||||
else selectedFines.value.push(fine);
|
else selectedFines.value.push(fine);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── QR Simulator ─────────────────────────────────────────────────────────────
|
const isFineSelected = (fine) => selectedFines.value.some((f) => f.id === fine.id);
|
||||||
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 ────────────────────────────────────────────────────────────────────
|
// ─── Cobro ────────────────────────────────────────────────────────────────────
|
||||||
const isProcessingPayment = ref(false);
|
const isProcessingPayment = ref(false);
|
||||||
@ -179,342 +145,51 @@ const switchTab = (tab) => {
|
|||||||
searchMessage.value = "";
|
searchMessage.value = "";
|
||||||
folioQuery.value = "";
|
folioQuery.value = "";
|
||||||
curpQuery.value = "";
|
curpQuery.value = "";
|
||||||
showSimulator.value = false;
|
|
||||||
simulatorToken.value = "";
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div class="flex-1 flex flex-col gap-4 min-w-0">
|
||||||
|
|
||||||
<!-- Tarjeta de búsqueda -->
|
<FineSearchPanel
|
||||||
<div class="bg-white rounded-2xl shadow-sm overflow-hidden">
|
:active-tab="activeTab"
|
||||||
|
:is-searching="isSearching"
|
||||||
<!-- Pestañas -->
|
v-model:folioQuery="folioQuery"
|
||||||
<div class="flex border-b border-gray-200">
|
v-model:curpQuery="curpQuery"
|
||||||
<button
|
@tab-change="switchTab"
|
||||||
v-for="tab in tabs"
|
@search-folio="searchByFolio"
|
||||||
:key="tab.id"
|
@search-curp="searchByCurp"
|
||||||
@click="switchTab(tab.id)"
|
@qr-detected="searchByQR"
|
||||||
: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 ────────────────────────────── -->
|
<!-- Resultados de búsqueda -->
|
||||||
<div v-else-if="activeTab === 'folio'" class="p-6">
|
<div v-if="searchResults.length > 0" class="rounded-xl bg-white p-6 shadow-lg">
|
||||||
<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>
|
<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>
|
<p class="text-sm text-gray-400 mb-4">{{ searchMessage }}</p>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div
|
<FineResultCard
|
||||||
v-for="fine in searchResults"
|
v-for="fine in searchResults"
|
||||||
:key="fine.id"
|
:key="fine.id"
|
||||||
@click="activeTab === 'curp' ? toggleFineSelection(fine) : null"
|
:fine="fine"
|
||||||
:class="[
|
:is-selected="isFineSelected(fine)"
|
||||||
'rounded-xl border-2 p-4 transition-all',
|
:selectable="activeTab === 'curp'"
|
||||||
activeTab === 'curp' && fine.status !== 'paid' ? 'cursor-pointer' : '',
|
@toggle="toggleFineSelection"
|
||||||
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>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Conceptos de infracción -->
|
<!-- Panel derecho: Resumen de Cobro -->
|
||||||
<div v-if="getConcepts(fine).length" class="pt-2 border-t border-gray-100">
|
<FinePaymentSummary
|
||||||
<div class="flex items-start gap-2 mb-1">
|
:selected-fines="selectedFines"
|
||||||
<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">
|
:is-processing-payment="isProcessingPayment"
|
||||||
<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"/>
|
@pay="handlePayment"
|
||||||
</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>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -38,7 +38,7 @@ const clear = () => {
|
|||||||
v-text="title"
|
v-text="title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
<div class="relative py-1 z-0">
|
<div class="relative py-1 z-0">
|
||||||
<div @click="search" class="absolute inset-y-0 right-2 flex items-center pl-3 cursor-pointer">
|
<div @click="search" class="absolute inset-y-0 right-2 flex items-center pl-3 cursor-pointer">
|
||||||
@ -57,7 +57,7 @@ const clear = () => {
|
|||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="search"
|
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"
|
autocomplete="off"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
required
|
required
|
||||||
|
|||||||
@ -44,6 +44,11 @@ onMounted(() => {
|
|||||||
name="Gestionar Multas"
|
name="Gestionar Multas"
|
||||||
to="fine.index"
|
to="fine.index"
|
||||||
/>
|
/>
|
||||||
|
<Link
|
||||||
|
icon="bar_chart"
|
||||||
|
name="Dashboard de Multas"
|
||||||
|
to="fine.dashboard"
|
||||||
|
/>
|
||||||
<Link
|
<Link
|
||||||
icon="car_tag"
|
icon="car_tag"
|
||||||
name="Autorizar Descuentos"
|
name="Autorizar Descuentos"
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import ConceptForm from "@App/ConceptSection.vue";
|
|||||||
import DestroyView from "@Holos/Modal/Template/Destroy.vue";
|
import DestroyView from "@Holos/Modal/Template/Destroy.vue";
|
||||||
import ModalController from "@Controllers/ModalController.js";
|
import ModalController from "@Controllers/ModalController.js";
|
||||||
import EditModal from "./Modal/Edit.vue";
|
import EditModal from "./Modal/Edit.vue";
|
||||||
|
import Searcher from "@Holos/Searcher.vue";
|
||||||
|
|
||||||
/** Controladores */
|
/** Controladores */
|
||||||
const Modal = new ModalController();
|
const Modal = new ModalController();
|
||||||
@ -76,6 +77,7 @@ const handleConceptUpdated = () => {
|
|||||||
<template>
|
<template>
|
||||||
<PageHeader :title="transl('title')" />
|
<PageHeader :title="transl('title')" />
|
||||||
<ConceptForm @concept-created="handleConceptCreated" />
|
<ConceptForm @concept-created="handleConceptCreated" />
|
||||||
|
<Searcher title="Cobro de Membresía" @search="(x) => searcher.search(x)"></Searcher>
|
||||||
<div class="mx-4 mb-4">
|
<div class="mx-4 mb-4">
|
||||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
<Table
|
<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
|
||||||
|
}
|
||||||
@ -75,7 +75,12 @@ const router = createRouter({
|
|||||||
path: '',
|
path: '',
|
||||||
name: 'fine.index',
|
name: 'fine.index',
|
||||||
component: () => import('@Pages/App/Fine/Index.vue')
|
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