Se agregó las gráficas a Tramites #1
@ -1,4 +1,6 @@
|
|||||||
<?php namespace App\Http\Controllers\Dashboard;
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Dashboard;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
@ -24,42 +26,196 @@ public function Atencion(Request $request)
|
|||||||
|
|
||||||
$baseUrl = 'https://apoyos.comalcalco.gob.mx/beneficiaries/stats-by-date-range';
|
$baseUrl = 'https://apoyos.comalcalco.gob.mx/beneficiaries/stats-by-date-range';
|
||||||
$countsUrl = 'https://apoyos.comalcalco.gob.mx/beneficiaries/counts-by-date';
|
$countsUrl = 'https://apoyos.comalcalco.gob.mx/beneficiaries/counts-by-date';
|
||||||
|
$dashboardUrl = 'https://apoyos.comalcalco.gob.mx/beneficiaries/dashboard/api';
|
||||||
|
$dashboardUrl2 = 'https://apoyos.comalcalco.gob.mx/beneficiaries/dashboard/api?type=servicio';
|
||||||
|
|
||||||
try{
|
try {
|
||||||
$response = Http::get($baseUrl, $baseParams);
|
$response = Http::get($baseUrl, $baseParams);
|
||||||
|
|
||||||
if(!$response->successful()){
|
if (!$response->successful()) {
|
||||||
return response()->json(['error' => 'Error del servicio'], $response->status());
|
return response()->json(['error' => 'Error del servicio'], $response->status());
|
||||||
}
|
}
|
||||||
|
|
||||||
$mainData = $response->json();
|
$mainData = $response->json();
|
||||||
$countsAc = [];
|
$countsAc = [];
|
||||||
try{
|
try {
|
||||||
$countsResp = Http::get($countsUrl, [
|
$countsResp = Http::get($countsUrl, [
|
||||||
'start_date' => $startDate,
|
'start_date' => $startDate,
|
||||||
'end_date' => $endDate,
|
'end_date' => $endDate,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if($countsResp->successful()){
|
if ($countsResp->successful()) {
|
||||||
$countsAc = $countsResp->json();
|
$countsAc = $countsResp->json();
|
||||||
} else {
|
} else {
|
||||||
Log::error('Error al obtener los conteos de atención', ['status' => $countsResp->status()]);
|
Log::error('Error al obtener los conteos de atención', ['status' => $countsResp->status()]);
|
||||||
}
|
}
|
||||||
}catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Error al obtener los conteos de atención', ['exception' => $e->getMessage()]);
|
Log::error('Error al obtener los conteos de atención', ['exception' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$dashboardData = [];
|
||||||
|
try {
|
||||||
|
$dashboardResp = Http::get($dashboardUrl);
|
||||||
|
|
||||||
|
if ($dashboardResp->successful()) {
|
||||||
|
$dashboardData = $dashboardResp->json();
|
||||||
|
} else {
|
||||||
|
Log::error('Error al obtener datos del dashboard', ['status' => $dashboardResp->status()]);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error al obtener datos del dashboard', ['exception' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dashboardServicioData = [];
|
||||||
|
try {
|
||||||
|
$dashboardResp2 = Http::get($dashboardUrl2);
|
||||||
|
|
||||||
|
if ($dashboardResp2->successful()) {
|
||||||
|
$dashboardServicioData = $dashboardResp2->json();
|
||||||
|
} else {
|
||||||
|
Log::error('Error al obtener datos del dashboard servicio', ['status' => $dashboardResp2->status()]);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error al obtener datos del dashboard servicio', ['exception' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combina todos los resultados
|
||||||
$combinedData = array_merge(
|
$combinedData = array_merge(
|
||||||
is_array($mainData) ? $mainData : [],
|
is_array($mainData) ? $mainData : [],
|
||||||
[
|
[
|
||||||
'counts' => $countsAc,
|
'counts' => $countsAc,
|
||||||
|
'dashboard' => $dashboardData,
|
||||||
|
'dashboard_servicio' => $dashboardServicioData,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
return response()->json($combinedData, 200)
|
return response()->json($combinedData, 200)
|
||||||
->header('Content-Type', 'application/json');
|
->header('Content-Type', 'application/json');
|
||||||
}catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Error en la consulta de atención', ['exception' => $e->getMessage()]);
|
Log::error('Error en la consulta de atención', ['exception' => $e->getMessage()]);
|
||||||
return response()->json(['error' => 'Error en la consulta de atención'], 500);
|
return response()->json(['error' => 'Error en la consulta de atención'], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getSupportOptions(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$typeId = $request->query('type_id');
|
||||||
|
|
||||||
|
if (!$typeId) {
|
||||||
|
return response()->json(['error' => 'type_id es requerido'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = 'https://apoyos.comalcalco.gob.mx/beneficiaries/export/support-options';
|
||||||
|
|
||||||
|
$response = Http::timeout(30)->get($url, [
|
||||||
|
'type_id' => $typeId
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$response->successful()) {
|
||||||
|
Log::error('Error al obtener opciones de apoyo', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
'type_id' => $typeId
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Error al obtener opciones de apoyo'
|
||||||
|
], $response->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($response->json());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error en getSupportOptions', [
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
'type_id' => $request->query('type_id')
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Error interno del servidor'
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportExcel(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// CAMBIO: Usar los parámetros correctos que espera la API
|
||||||
|
$params = $request->only([
|
||||||
|
'start_date',
|
||||||
|
'end_date',
|
||||||
|
'type_id',
|
||||||
|
'support_id'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Validaciones básicas
|
||||||
|
if (!$params['start_date'] || !$params['end_date']) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Las fechas son requeridas'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$params['type_id']) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'El type_id es requerido'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = 'https://apoyos.comalcalco.gob.mx/beneficiaries/export/excel';
|
||||||
|
|
||||||
|
// Log de parámetros para debug
|
||||||
|
Log::info('Exportando Excel con parámetros:', $params);
|
||||||
|
|
||||||
|
// IMPORTANTE: Hacer la petición sin timeout muy alto y con withoutVerifying para SSL
|
||||||
|
$response = Http::withoutVerifying()
|
||||||
|
->timeout(60)
|
||||||
|
->get($url, $params);
|
||||||
|
|
||||||
|
if (!$response->successful()) {
|
||||||
|
Log::error('Error al exportar Excel', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
'params' => $params
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Error al generar el archivo Excel: ' . $response->body()
|
||||||
|
], $response->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORREGIR: Verificar que la respuesta sea realmente un Excel
|
||||||
|
$contentType = $response->header('Content-Type');
|
||||||
|
|
||||||
|
if (!str_contains($contentType, 'spreadsheet') && !str_contains($contentType, 'excel')) {
|
||||||
|
Log::error('Respuesta no es un archivo Excel', [
|
||||||
|
'content_type' => $contentType,
|
||||||
|
'body_preview' => substr($response->body(), 0, 200)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'El servidor no devolvió un archivo Excel válido'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retornar el archivo Excel directamente con headers correctos
|
||||||
|
return response($response->body(), 200, [
|
||||||
|
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'Content-Disposition' => 'attachment; filename="beneficiarios_' .
|
||||||
|
$params['start_date'] . '_' . $params['end_date'] . '.xlsx"',
|
||||||
|
'Content-Length' => strlen($response->body()),
|
||||||
|
'Cache-Control' => 'no-cache, no-store, must-revalidate',
|
||||||
|
'Pragma' => 'no-cache',
|
||||||
|
'Expires' => '0'
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error en exportExcel', [
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
'params' => $request->all()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Error interno del servidor: ' . $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,7 +61,7 @@ .btn-icon-secondary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-icon {
|
.header-icon {
|
||||||
@apply hover:text-yellow-500 focus:text-yellow-600 hover:scale-105 hover:transition hover:duration-300;
|
@apply hover:text-primary focus:text-primary hover:scale-105 hover:transition hover:duration-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-primary {
|
.input-primary {
|
||||||
|
|||||||
125
resources/js/Components/Dashboard/Footer.vue
Normal file
125
resources/js/Components/Dashboard/Footer.vue
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<footer class="bg-[#58595b] text-white py-8">
|
||||||
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<!-- Logo y información de contacto -->
|
||||||
|
<div class="flex flex-col md:flex-row gap-6">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src="https://apoyos.comalcalco.gob.mx/images/logo_blanco.png"
|
||||||
|
alt="Comalcalco Logo"
|
||||||
|
class="h-20 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Información de contacto -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Dirección -->
|
||||||
|
<div class="flex items-start space-x-3">
|
||||||
|
<div class="bg-yellow-600 rounded-full p-2 flex-shrink-0">
|
||||||
|
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-300">
|
||||||
|
Plaza Juárez S/N, Centro, C.P. 86300, Comalcalco,<br>
|
||||||
|
Tabasco, México.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Teléfono -->
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="bg-yellow-600 rounded-full p-2 flex-shrink-0">
|
||||||
|
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-300">9331140000</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Horarios -->
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="bg-yellow-600 rounded-full p-2 flex-shrink-0">
|
||||||
|
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-300">Lunes a Viernes de 8:00 a 16:00 horas</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="bg-yellow-600 rounded-full p-2 flex-shrink-0">
|
||||||
|
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
|
||||||
|
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-300">
|
||||||
|
<a href="mailto:mejoraregulatoria@comalcalco.gob.mx" class="hover:text-yellow-400 transition-colors">
|
||||||
|
mejoraregulatoria@comalcalco.gob.mx
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Servicios de emergencia -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Emergencias -->
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="bg-yellow-600 rounded-full p-2 flex-shrink-0">
|
||||||
|
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-yellow-400 font-semibold text-sm">Emergencias</p>
|
||||||
|
<p class="text-white text-lg font-bold">911</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bomberos -->
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="bg-yellow-600 rounded-full p-2 flex-shrink-0">
|
||||||
|
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M12.395 2.553a1 1 0 00-1.45-.385c-.345.23-.614.558-.822.88-.214.33-.403.713-.57 1.116-.334.804-.614 1.768-.84 2.734a31.365 31.365 0 00-.613 3.58 2.64 2.64 0 01-.945-1.067c-.328-.68-.398-1.534-.398-2.654A1 1 0 005.05 6.05 6.981 6.981 0 003 11a7 7 0 1011.95-4.95c-.592-.591-.98-.985-1.348-1.467-.363-.476-.724-1.063-1.207-2.03zM12.12 15.12A3 3 0 017 13s.879.5 2.5.5c0-1 .5-4 1.25-4.5.5 1 .786 1.293 1.371 1.879A2.99 2.99 0 0113 13a2.99 2.99 0 01-.879 2.121z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-yellow-400 font-semibold text-sm">Bomberos</p>
|
||||||
|
<p class="text-white text-lg font-bold">993 315 5670</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cruz Roja -->
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="bg-yellow-600 rounded-full p-2 flex-shrink-0">
|
||||||
|
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V4a2 2 0 00-2-2H6zm1 2a1 1 0 000 2h6a1 1 0 100-2H7zm6 7a1 1 0 011 1v3a1 1 0 11-2 0v-3a1 1 0 011-1zm-3 3a1 1 0 100 2h.01a1 1 0 100-2H10zm-4 1a1 1 0 011-1h.01a1 1 0 110 2H7a1 1 0 01-1-1zm1-4a1 1 0 100 2h.01a1 1 0 100-2H7zm2 1a1 1 0 011-1h.01a1 1 0 110 2H10a1 1 0 01-1-1zm4-4a1 1 0 100 2h.01a1 1 0 100-2H13zM9 9a1 1 0 011-1h.01a1 1 0 110 2H10a1 1 0 01-1-1zM7 8a1 1 0 000 2h.01a1 1 0 000-2H7z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-yellow-400 font-semibold text-sm">Cruz Roja Mexicana</p>
|
||||||
|
<p class="text-white text-lg font-bold">993 334 3004</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Copyright -->
|
||||||
|
<div class="mt-8 pt-6 border-t border-gray-600 text-center">
|
||||||
|
<div class="flex flex-col md:flex-row justify-center items-center space-y-2 md:space-y-0 md:space-x-6">
|
||||||
|
<p class="text-sm text-gray-300">
|
||||||
|
© Ayuntamiento de Comalcalco 2024-2027
|
||||||
|
</p>
|
||||||
|
<span class="hidden md:block text-gray-500">|</span>
|
||||||
|
<a href="#" class="text-sm text-gray-300 hover:text-yellow-400 transition-colors">
|
||||||
|
Aviso de Privacidad
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
387
resources/js/Components/Dashboard/Modal/ExportModal.vue
Normal file
387
resources/js/Components/Dashboard/Modal/ExportModal.vue
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted } from "vue";
|
||||||
|
import axios from "axios";
|
||||||
|
import DateRange from "@/Components/Dashboard/Form/DateRange.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadingOptions = ref(false);
|
||||||
|
const form = ref({
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
endDate: new Date().toISOString().slice(0, 10),
|
||||||
|
supportType: '', // almacen o servicio
|
||||||
|
typeId: '', // ID del tipo específico
|
||||||
|
supportId: '' // Opciones específicas seleccionadas
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateRange = ref({
|
||||||
|
start: new Date().toISOString().slice(0, 10),
|
||||||
|
end: new Date().toISOString().slice(0, 10)
|
||||||
|
});
|
||||||
|
|
||||||
|
const supportTypes = [
|
||||||
|
{ id: '', name: 'Seleccionar tipo de apoyo' },
|
||||||
|
{ id: 'almacen', name: 'Almacén', api_types: [1, 2, 3, 4, 5, 6, 11, 12, 13] },
|
||||||
|
{ id: 'servicio', name: 'Servicio', api_types: [7, 8, 9, 10, 14] }
|
||||||
|
];
|
||||||
|
|
||||||
|
const typeOptions = ref([]);
|
||||||
|
const supportOptionsData = ref([]);
|
||||||
|
|
||||||
|
// Watch para actualizar fechas desde DateRange
|
||||||
|
watch(dateRange, (newRange) => {
|
||||||
|
form.value.startDate = newRange.start;
|
||||||
|
form.value.endDate = newRange.end;
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// Watch para cargar opciones cuando cambia el tipo de apoyo
|
||||||
|
watch(() => form.value.supportType, async (newType) => {
|
||||||
|
if (newType && newType !== '') {
|
||||||
|
await loadSupportOptions(newType);
|
||||||
|
} else {
|
||||||
|
typeOptions.value = [];
|
||||||
|
supportOptionsData.value = [];
|
||||||
|
form.value.typeId = '';
|
||||||
|
form.value.supportOptions = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch para cargar opciones específicas cuando cambia el typeId
|
||||||
|
watch(() => form.value.typeId, async (newTypeId) => {
|
||||||
|
if (newTypeId && newTypeId !== '') {
|
||||||
|
await loadSpecificOptions(newTypeId);
|
||||||
|
} else {
|
||||||
|
supportOptionsData.value = [];
|
||||||
|
form.value.supportOptions = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadSupportOptions = async (supportType) => {
|
||||||
|
loadingOptions.value = true;
|
||||||
|
try {
|
||||||
|
const selectedType = supportTypes.find(type => type.id === supportType);
|
||||||
|
if (selectedType && selectedType.api_types) {
|
||||||
|
// Cargar tipos disponibles para este tipo de apoyo
|
||||||
|
const options = selectedType.api_types.map(id => ({
|
||||||
|
id: id,
|
||||||
|
name: getTypeName(id)
|
||||||
|
}));
|
||||||
|
|
||||||
|
typeOptions.value = [
|
||||||
|
{ id: '', name: 'Seleccionar categoría específica' },
|
||||||
|
...options
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cargando opciones de tipo:', error);
|
||||||
|
} finally {
|
||||||
|
loadingOptions.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSpecificOptions = async (typeId) => {
|
||||||
|
loadingOptions.value = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/support-options', {
|
||||||
|
params: { type_id: typeId },
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.options) {
|
||||||
|
supportOptionsData.value = response.data.options;
|
||||||
|
// Pre-seleccionar "Todos los enlistados" si existe
|
||||||
|
const allOption = response.data.options.find(opt => opt.id === 'all');
|
||||||
|
if (allOption) {
|
||||||
|
form.value.supportOptions = ['all'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cargando opciones específicas:', error);
|
||||||
|
supportOptionsData.value = [];
|
||||||
|
|
||||||
|
// Mostrar mensaje de error más específico
|
||||||
|
if (error.response?.data?.error) {
|
||||||
|
alert(`Error: ${error.response.data.error}`);
|
||||||
|
} else {
|
||||||
|
alert('Error al cargar las opciones de apoyo');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingOptions.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeName = (id) => {
|
||||||
|
const names = {
|
||||||
|
1: "Medicamentos",
|
||||||
|
2: "Material de Construcción",
|
||||||
|
3: "Aparatos Ortopédicos",
|
||||||
|
4: "Despensas",
|
||||||
|
5: "Útiles Escolares",
|
||||||
|
6: "Pañales",
|
||||||
|
7: "Análisis Clínicos",
|
||||||
|
8: "Traslados",
|
||||||
|
9: "Monederos",
|
||||||
|
10: "Apoyo social DIF",
|
||||||
|
11: "Alimentos",
|
||||||
|
12: "Ataúdes",
|
||||||
|
13: "Ventiladores",
|
||||||
|
14: "Apoyo a enfermedades de la vista"
|
||||||
|
};
|
||||||
|
return names[id] || `Tipo ${id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportToExcel = async () => {
|
||||||
|
if (!form.value.supportType || !form.value.typeId) {
|
||||||
|
alert('Por favor selecciona el tipo de apoyo y la categoría específica');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
// CAMBIO: Usar los parámetros correctos que espera la API
|
||||||
|
const params = {
|
||||||
|
start_date: form.value.startDate,
|
||||||
|
end_date: form.value.endDate,
|
||||||
|
type_id: form.value.typeId,
|
||||||
|
support_id: form.value.supportId || 'all' // Si no hay supportId, usar 'all'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Enviando parámetros:', params); // Debug
|
||||||
|
|
||||||
|
// CAMBIO: Configuración más específica para descarga de archivos
|
||||||
|
const response = await axios({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/export-excel',
|
||||||
|
params: params,
|
||||||
|
responseType: 'blob', // Importante para archivos binarios
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
},
|
||||||
|
timeout: 60000 // 60 segundos timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
// VERIFICAR que la respuesta sea un blob válido
|
||||||
|
if (!response.data || response.data.size === 0) {
|
||||||
|
throw new Error('El archivo descargado está vacío');
|
||||||
|
}
|
||||||
|
|
||||||
|
// VERIFICAR el tipo de contenido
|
||||||
|
const contentType = response.headers['content-type'] || '';
|
||||||
|
if (!contentType.includes('spreadsheet') && !contentType.includes('excel') && !contentType.includes('octet-stream')) {
|
||||||
|
// Si no es Excel, probablemente sea un JSON con error
|
||||||
|
const text = await response.data.text();
|
||||||
|
console.error('Respuesta inesperada:', text);
|
||||||
|
throw new Error('El servidor no devolvió un archivo Excel válido');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear y descargar el archivo
|
||||||
|
const blob = new Blob([response.data], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generar nombre de archivo único
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||||
|
const filename = `beneficiarios_${form.value.startDate}_${form.value.endDate}_${timestamp}.xlsx`;
|
||||||
|
|
||||||
|
// MEJORAR la descarga
|
||||||
|
if (window.navigator && window.navigator.msSaveOrOpenBlob) {
|
||||||
|
// Para Internet Explorer
|
||||||
|
window.navigator.msSaveOrOpenBlob(blob, filename);
|
||||||
|
} else {
|
||||||
|
// Para navegadores modernos
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.style.display = 'none';
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cerrar modal y mostrar éxito
|
||||||
|
closeModal();
|
||||||
|
alert('Archivo Excel descargado exitosamente');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exportando Excel:', error);
|
||||||
|
|
||||||
|
let errorMessage = 'Error al exportar el archivo Excel';
|
||||||
|
|
||||||
|
if (error.response?.status === 400 && error.response?.data?.error) {
|
||||||
|
errorMessage = error.response.data.error;
|
||||||
|
} else if (error.response?.status === 500) {
|
||||||
|
errorMessage = 'Error interno del servidor. Intente nuevamente.';
|
||||||
|
} else if (error.message) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
} else if (error.code === 'ECONNABORTED') {
|
||||||
|
errorMessage = 'La descarga tardó demasiado. Intente con un rango de fechas menor.';
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(errorMessage);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
// Resetear formulario
|
||||||
|
form.value = {
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
endDate: new Date().toISOString().slice(0, 10),
|
||||||
|
supportType: '',
|
||||||
|
typeId: '',
|
||||||
|
supportOptions: []
|
||||||
|
};
|
||||||
|
|
||||||
|
dateRange.value = {
|
||||||
|
start: new Date().toISOString().slice(0, 10),
|
||||||
|
end: new Date().toISOString().slice(0, 10)
|
||||||
|
};
|
||||||
|
|
||||||
|
typeOptions.value = [];
|
||||||
|
supportOptionsData.value = [];
|
||||||
|
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Modal Backdrop -->
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||||
|
@click.self="closeModal"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="p-6 border-b border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">
|
||||||
|
Exportar a Excel
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
@click="closeModal"
|
||||||
|
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 mt-2">
|
||||||
|
Configure los filtros para exportar los datos de beneficiarios
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<!-- Rango de Fechas -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Rango de Fechas
|
||||||
|
</label>
|
||||||
|
<DateRange
|
||||||
|
v-model="dateRange"
|
||||||
|
:presets="true"
|
||||||
|
title-start="Fecha inicio"
|
||||||
|
title-end="Fecha fin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tipo de Apoyo -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Tipo de Apoyo
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="form.supportType"
|
||||||
|
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option v-for="type in supportTypes" :key="type.id" :value="type.id">
|
||||||
|
{{ type.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categoría Específica -->
|
||||||
|
<div v-if="form.supportType && typeOptions.length > 0">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Categoría Específica
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="form.typeId"
|
||||||
|
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
|
:disabled="loadingOptions"
|
||||||
|
>
|
||||||
|
<option v-for="option in typeOptions" :key="option.id" :value="option.id">
|
||||||
|
{{ option.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div v-if="loadingOptions" class="mt-2 text-sm text-blue-600">
|
||||||
|
Cargando opciones...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Opciones Específicas de Apoyo -->
|
||||||
|
<div v-if="supportOptionsData.length > 0">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Seleccionar Apoyos Específicos
|
||||||
|
</label>
|
||||||
|
<div class="max-h-64 overflow-y-auto border border-gray-200 rounded-md p-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label
|
||||||
|
v-for="option in supportOptionsData"
|
||||||
|
:key="option.id"
|
||||||
|
class="flex items-center space-x-2 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="option.id"
|
||||||
|
v-model="form.supportOptions"
|
||||||
|
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span class="flex-1">{{ option.name }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
Seleccionados: {{ form.supportOptions.length }} de {{ supportOptionsData.length }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="p-6 border-t border-gray-200 flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
@click="closeModal"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="exportToExcel"
|
||||||
|
:disabled="loading || !form.supportType || !form.typeId"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors flex items-center"
|
||||||
|
>
|
||||||
|
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
||||||
|
</svg>
|
||||||
|
{{ loading ? 'Exportando...' : 'Exportar Excel' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
95
resources/js/Layouts/AppLayout.vue
Normal file
95
resources/js/Layouts/AppLayout.vue
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { Head } from '@inertiajs/vue3';
|
||||||
|
import { sidebarSwitch, sidebar } from '@/sidebar'
|
||||||
|
|
||||||
|
import Header from '@/Components/Dashboard/Skeleton/Header.vue';
|
||||||
|
import Sidebar from '@/Components/Dashboard/Skeleton/Sidebar.vue';
|
||||||
|
import Link from '@/Components/Dashboard/Skeleton/Sidebar/Link.vue';
|
||||||
|
import Section from '@/Components/Dashboard/Skeleton/Sidebar/Section.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
title: String,
|
||||||
|
titlePage: {
|
||||||
|
default: true,
|
||||||
|
type: Boolean
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sidebarStatus = ref(sidebar);
|
||||||
|
|
||||||
|
onMounted(()=> {
|
||||||
|
if (!sessionFresh.isLayoutInitialized()) {
|
||||||
|
sessionFresh.startLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(TwScreen.isDevice('phone')) {
|
||||||
|
sidebarSwitch(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head
|
||||||
|
:title="title"
|
||||||
|
/>
|
||||||
|
<div class="flex w-full h-screen">
|
||||||
|
<div
|
||||||
|
id="sidebar"
|
||||||
|
class="fixed w-fit h-screen transition-all duration-300 z-10"
|
||||||
|
:class="{'-translate-x-[16.5rem] md:-translate-x-0':sidebarStatus, '-translate-x-0 md:-translate-x-64':!sidebarStatus}"
|
||||||
|
>
|
||||||
|
<Sidebar
|
||||||
|
:sidebar="sidebarStatus"
|
||||||
|
@open="sidebarSwitch()"
|
||||||
|
>
|
||||||
|
<Section name="Reportes">
|
||||||
|
<Link
|
||||||
|
icon="assignment"
|
||||||
|
name="Trámites"
|
||||||
|
to="app.tramites"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
icon="support_agent"
|
||||||
|
name="Atención Ciudadana"
|
||||||
|
to="app.atencion"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
icon="construction"
|
||||||
|
name="Obras"
|
||||||
|
to="app.obras"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section name="Acceso Rápido">
|
||||||
|
<Link
|
||||||
|
icon="dashboard"
|
||||||
|
name="Inicio Dashboard"
|
||||||
|
to="dashboard.index"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</Sidebar>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col w-full transition-all duration-300"
|
||||||
|
:class="{'md:w-[calc(100vw-rem)] md:ml-64':sidebarStatus, 'md:w-screen md:ml-0':!sidebarStatus}"
|
||||||
|
>
|
||||||
|
<div class="h-2 md:h-14">
|
||||||
|
<Header
|
||||||
|
:sidebar="sidebarStatus"
|
||||||
|
@open="sidebarSwitch()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<main id="page" class="flex h-full justify-center md:p-2">
|
||||||
|
<div class="mt-14 md:mt-0 w-full shadow-lg h-fit md:h-[calc(100vh-4.5rem)] px-2 md:px-8 pb-4 sm:px-6 lg:px-8 md:rounded-lg bg-main text-main-on dark:bg-main-dark dark:text-main-dark-on lg:py-4 md:overflow-y-auto md:overflow-x-auto transition-colors duration-300">
|
||||||
|
<div v-if="titlePage" class="flex w-full justify-center pt-4 lg:pt-0">
|
||||||
|
<h2
|
||||||
|
class="font-bold text-xl uppercase text-primary dark:text-primary-dark"
|
||||||
|
v-text="title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
145
resources/js/Pages/App/AccesoRapido.vue
Normal file
145
resources/js/Pages/App/AccesoRapido.vue
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
<script setup>
|
||||||
|
import { router } from "@inertiajs/vue3";
|
||||||
|
|
||||||
|
import GoogleIcon from "@/Components/Shared/GoogleIcon.vue";
|
||||||
|
import NotificationController from "@/Controllers/NotificationController";
|
||||||
|
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||||
|
|
||||||
|
const notificationCtl = NotificationController;
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
router.post(
|
||||||
|
route("logout"),
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onBefore: () => {
|
||||||
|
notificationCtl.stop();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div
|
||||||
|
class="min-h-screen font-sans bg-gradient-to-br from-[#621132] via-[#621132] to-[#621132] text-[#621132] antialiased"
|
||||||
|
>
|
||||||
|
<main class="grid min-h-screen place-items-center p-4">
|
||||||
|
<!-- Tarjeta -->
|
||||||
|
<section
|
||||||
|
class="w-full max-w-2xl rounded-3xl border border-white/10 bg-white/5 p-6 sm:p-8 shadow-2xl backdrop-blur supports-[backdrop-filter]:bg-white/10"
|
||||||
|
>
|
||||||
|
<!-- Encabezado -->
|
||||||
|
<header class="mb-6 sm:mb-8">
|
||||||
|
<h1
|
||||||
|
class="text-2xl sm:text-3xl font-bold tracking-tight text-white"
|
||||||
|
>
|
||||||
|
Accesos rápidos
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm sm:text-base text-white">
|
||||||
|
Selecciona un destino.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Grid de botones -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-1 gap-3 sm:gap-4">
|
||||||
|
<!-- Botón 1 -->
|
||||||
|
<a
|
||||||
|
:href="route('app.tramites')"
|
||||||
|
target="_blank"
|
||||||
|
class="group relative overflow-hidden rounded-2xl border border-white/10 bg-white/10 p-4 sm:p-5 transition hover:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 active:scale-[0.99]"
|
||||||
|
aria-label="Ir al sitio 1"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Ícono externo simple -->
|
||||||
|
<span class="rounded-xl bg-white/10 p-2">
|
||||||
|
<GoogleIcon name="open_in_new" class="text-white" />
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="font-semibold text-white">
|
||||||
|
Reporte Especial de Trámites
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-slate-300">
|
||||||
|
Resumen de trámites por unidad administrativa
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Efecto -->
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
||||||
|
style="
|
||||||
|
background: radial-gradient(
|
||||||
|
600px circle at var(--x, 50%) var(--y, 50%),
|
||||||
|
rgba(199, 12, 12, 0.12),
|
||||||
|
transparent 40%
|
||||||
|
);
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Botón 2 -->
|
||||||
|
<a
|
||||||
|
:href="route('app.atencion')"
|
||||||
|
target="_blank"
|
||||||
|
class="group relative overflow-hidden rounded-2xl border border-white/10 bg-white/10 p-4 sm:p-5 transition hover:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 active:scale-[0.99]"
|
||||||
|
aria-label="Ir al sitio 2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="rounded-xl bg-white/10 p-2">
|
||||||
|
<GoogleIcon name="open_in_new" class="text-white" />
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="font-semibold text-white">
|
||||||
|
Apoyo a Beneficiarios y DIF
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-slate-300">
|
||||||
|
Gráficas de apoyo a Beneficiarios y DIF
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
||||||
|
style="
|
||||||
|
background: radial-gradient(
|
||||||
|
600px circle at var(--x, 50%) var(--y, 50%),
|
||||||
|
rgba(98, 17, 50, 0.12),
|
||||||
|
transparent 40%
|
||||||
|
);
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Botón 3 -->
|
||||||
|
<a
|
||||||
|
:href="route('app.obras')"
|
||||||
|
target="_blank"
|
||||||
|
class="group relative overflow-hidden rounded-2xl border border-white/10 bg-white/10 p-4 sm:p-5 transition hover:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-fuchsia-400 active:scale-[0.99]"
|
||||||
|
aria-label="Ir al sitio 3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="rounded-xl bg-white/10 p-2">
|
||||||
|
<GoogleIcon name="open_in_new" class="text-white" />
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="font-semibold text-white">Información de Obras</h2>
|
||||||
|
<p class="text-sm text-slate-300">Proyectos en seguimiento</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
||||||
|
style="
|
||||||
|
background: radial-gradient(
|
||||||
|
600px circle at var(--x, 50%) var(--y, 50%),
|
||||||
|
rgba(255, 255, 255, 0.12),
|
||||||
|
transparent 40%
|
||||||
|
);
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
984
resources/js/Pages/App/AtencionCiudadana.vue
Normal file
984
resources/js/Pages/App/AtencionCiudadana.vue
Normal file
@ -0,0 +1,984 @@
|
|||||||
|
<script setup>
|
||||||
|
// filepath: /var/www/maquetador-graficas/resources/js/Pages/App/AtencionCiudadana.vue
|
||||||
|
import { ref, watch, onMounted, computed } from "vue";
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import Bars from "@/Components/Dashboard/Charts/Bars.vue";
|
||||||
|
import Pie from "@/Components/Dashboard/Charts/Pie.vue";
|
||||||
|
import Lines from "@/Components/Dashboard/Charts/Lines.vue";
|
||||||
|
import Footer from "@/Components/Dashboard/Footer.vue";
|
||||||
|
import DateRange from "@/Components/Dashboard/Form/DateRange.vue";
|
||||||
|
import ExportModal from "@/Components/Dashboard/Modal/ExportModal.vue";
|
||||||
|
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||||
|
|
||||||
|
const selectedDepartment = ref("");
|
||||||
|
const selectedType = ref("almacen");
|
||||||
|
const selectedPeriod = ref("hoy");
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const dateRange = ref({ start: today, end: today });
|
||||||
|
|
||||||
|
const showExportModal = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
const expandedGroups = ref(new Set());
|
||||||
|
let debounceId = null;
|
||||||
|
let cancelTokenSource = null;
|
||||||
|
let chartsDebounceId = null;
|
||||||
|
|
||||||
|
const cache = new Map();
|
||||||
|
const getCacheKey = () =>
|
||||||
|
`${selectedDepartment.value}-${selectedType.value}-${selectedPeriod.value}`;
|
||||||
|
|
||||||
|
// NUEVO: Cache separado para gráficas con rango de fechas
|
||||||
|
const chartsCache = new Map();
|
||||||
|
const getChartsCacheKey = () =>
|
||||||
|
`${dateRange.value.start}-${dateRange.value.end}`;
|
||||||
|
|
||||||
|
const totals = ref({ day: null, week: null, month: null });
|
||||||
|
const beneficiariesList = ref([]); // Lista de beneficiarios
|
||||||
|
const beneficiariesByType = ref({}); // Beneficiarios agrupados por tipo
|
||||||
|
|
||||||
|
const tipoChart = ref({ labels: [], datasets: [] });
|
||||||
|
const generoChart = ref({ labels: [], datasets: [] });
|
||||||
|
const edadChart = ref({ labels: [], datasets: [] });
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
x: { beginAtZero: true },
|
||||||
|
y: { beginAtZero: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const departmentOptions = [
|
||||||
|
{ id: "", name: "Todos los departamentos" },
|
||||||
|
{ id: "1", name: "Atención Ciudadana" },
|
||||||
|
{ id: "3", name: "DIF" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const typeOptions = [
|
||||||
|
{ id: "", name: "Todos los tipos" },
|
||||||
|
{ id: "almacen", name: "Almacén" },
|
||||||
|
{ id: "servicio", name: "Servicio" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const periodOptions = [
|
||||||
|
{ id: "hoy", name: "Hoy" },
|
||||||
|
{ id: "semana", name: "Esta Semana" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// NUEVO: Función para cargar datos de gráficas
|
||||||
|
const loadChartsData = async () => {
|
||||||
|
const cacheKey = getChartsCacheKey();
|
||||||
|
|
||||||
|
// Verificar cache de gráficas
|
||||||
|
if (chartsCache.has(cacheKey)) {
|
||||||
|
const cachedData = chartsCache.get(cacheKey);
|
||||||
|
buildChartsFromData(cachedData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
start: dateRange.value.start,
|
||||||
|
end: dateRange.value.end,
|
||||||
|
charts_only: true, // Indicador para el backend
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await axios.get("/api/reporte-atencion", {
|
||||||
|
params,
|
||||||
|
timeout: 10000,
|
||||||
|
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = res.data || {};
|
||||||
|
|
||||||
|
// Guardar en cache de gráficas
|
||||||
|
chartsCache.set(cacheKey, payload);
|
||||||
|
|
||||||
|
buildChartsFromData(payload);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error cargando datos de gráficas:", e);
|
||||||
|
// En caso de error, mantener gráficas actuales o limpiar
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// NUEVO: Función para construir gráficas desde datos
|
||||||
|
const buildChartsFromData = (payload) => {
|
||||||
|
const { porTipo, porGenero, porEdad } = normalizeResponse(payload);
|
||||||
|
|
||||||
|
buildTipoChart(porTipo);
|
||||||
|
buildGeneroChart(porGenero);
|
||||||
|
buildEdadChart(porEdad);
|
||||||
|
};
|
||||||
|
|
||||||
|
// FUNCIÓN CORREGIDA: normalizar respuesta
|
||||||
|
const normalizeResponse = (res) => {
|
||||||
|
const main = res?.data ?? {};
|
||||||
|
const counts = res?.counts ?? {};
|
||||||
|
const countsData = counts?.data_ac ?? counts?.data ?? {};
|
||||||
|
|
||||||
|
const porTipo = main?.por_tipo_apoyo ?? {};
|
||||||
|
const porGenero = main?.por_genero ?? {};
|
||||||
|
const porEdad = main?.por_rango_edad ?? {};
|
||||||
|
|
||||||
|
const totalsShape = {
|
||||||
|
today: countsData?.today ?? countsData?.dia ?? 0,
|
||||||
|
week: countsData?.week ?? countsData?.semana ?? 0,
|
||||||
|
month: countsData?.month ?? countsData?.mes ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
porGenero,
|
||||||
|
porEdad,
|
||||||
|
porTipo,
|
||||||
|
totalsShape,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const cacheKey = getCacheKey();
|
||||||
|
|
||||||
|
// Verificar cache primero
|
||||||
|
if (cache.has(cacheKey)) {
|
||||||
|
const cachedData = cache.get(cacheKey);
|
||||||
|
applyDataToComponents(cachedData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
// Cancelar petición anterior
|
||||||
|
if (cancelTokenSource) {
|
||||||
|
cancelTokenSource.cancel("cancel");
|
||||||
|
}
|
||||||
|
cancelTokenSource = axios.CancelToken.source();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
period: selectedPeriod.value,
|
||||||
|
// NO incluir fechas personalizadas aquí
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await axios.get("/api/reporte-atencion", {
|
||||||
|
params,
|
||||||
|
timeout: 10000,
|
||||||
|
cancelToken: cancelTokenSource.token,
|
||||||
|
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = res.data || {};
|
||||||
|
|
||||||
|
// Guardar en cache
|
||||||
|
cache.set(cacheKey, payload);
|
||||||
|
|
||||||
|
// Aplicar datos (SIN gráficas)
|
||||||
|
applyDataToComponents(payload, false);
|
||||||
|
} catch (e) {
|
||||||
|
if (!axios.isCancel(e)) {
|
||||||
|
console.error("Error en la petición:", e);
|
||||||
|
error.value =
|
||||||
|
e.response?.data?.error || e.message || "Error al cargar datos";
|
||||||
|
clearComponentData();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
cancelTokenSource = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDataToComponents = (payload, includeCharts = true) => {
|
||||||
|
const { porTipo, porGenero, porEdad, totalsShape } =
|
||||||
|
normalizeResponse(payload);
|
||||||
|
|
||||||
|
// Actualizar totales
|
||||||
|
totals.value.day = totalsShape?.today ?? 0;
|
||||||
|
totals.value.week = totalsShape?.week ?? 0;
|
||||||
|
totals.value.month = totalsShape?.month ?? 0;
|
||||||
|
|
||||||
|
// Procesar beneficiarios
|
||||||
|
processBeneficiariesAsync(payload);
|
||||||
|
|
||||||
|
// Solo construir gráficas si se solicita (para evitar conflicto con DateRange)
|
||||||
|
if (includeCharts) {
|
||||||
|
buildTipoChart(porTipo);
|
||||||
|
buildGeneroChart(porGenero);
|
||||||
|
buildEdadChart(porEdad);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processBeneficiariesAsync = async (payload) => {
|
||||||
|
// Usar setTimeout para no bloquear el hilo principal
|
||||||
|
setTimeout(() => {
|
||||||
|
const dashboardAlmacenData =
|
||||||
|
payload.dashboard?.data ?? payload.dashboard ?? {};
|
||||||
|
const dashboardServicioData =
|
||||||
|
payload.dashboard_servicio?.data ?? payload.dashboard_servicio ?? {};
|
||||||
|
|
||||||
|
const periodKey =
|
||||||
|
selectedPeriod.value === "hoy"
|
||||||
|
? "hoy"
|
||||||
|
: selectedPeriod.value === "semana"
|
||||||
|
? "semana"
|
||||||
|
: "mes";
|
||||||
|
|
||||||
|
const beneficiariesAlmacen = dashboardAlmacenData[periodKey] || [];
|
||||||
|
const beneficiariesServicio = dashboardServicioData[periodKey] || [];
|
||||||
|
|
||||||
|
// Combinar y filtrar datos
|
||||||
|
let beneficiariesData = [...beneficiariesAlmacen, ...beneficiariesServicio];
|
||||||
|
|
||||||
|
beneficiariesData = filterBeneficiariesByDepartment(
|
||||||
|
beneficiariesData,
|
||||||
|
selectedDepartment.value
|
||||||
|
);
|
||||||
|
|
||||||
|
beneficiariesData = filterBeneficiariesByType(
|
||||||
|
beneficiariesData,
|
||||||
|
selectedType.value
|
||||||
|
);
|
||||||
|
|
||||||
|
beneficiariesList.value = beneficiariesData;
|
||||||
|
beneficiariesByType.value = groupBeneficiariesByType(beneficiariesData);
|
||||||
|
expandedGroups.value = new Set();
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearComponentData = () => {
|
||||||
|
tipoChart.value = { labels: [], datasets: [] };
|
||||||
|
generoChart.value = { labels: [], datasets: [] };
|
||||||
|
edadChart.value = { labels: [], datasets: [] };
|
||||||
|
totals.value = { day: null, week: null, month: null };
|
||||||
|
beneficiariesList.value = [];
|
||||||
|
beneficiariesByType.value = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
watch([selectedDepartment, selectedType, selectedPeriod], () => {
|
||||||
|
clearTimeout(debounceId);
|
||||||
|
|
||||||
|
// Limpiar cache cuando cambian los filtros principales
|
||||||
|
if (selectedPeriod.value !== selectedPeriod.value) {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce más corto para mejor UX
|
||||||
|
debounceId = setTimeout(load, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
// NUEVO: Watcher para rango de fechas (solo gráficas)
|
||||||
|
watch(
|
||||||
|
dateRange,
|
||||||
|
() => {
|
||||||
|
clearTimeout(chartsDebounceId);
|
||||||
|
chartsDebounceId = setTimeout(loadChartsData, 350);
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Cargar datos iniciales
|
||||||
|
load();
|
||||||
|
// Cargar gráficas iniciales
|
||||||
|
loadChartsData();
|
||||||
|
|
||||||
|
// Limpiar caches cada 5 minutos
|
||||||
|
setInterval(() => {
|
||||||
|
cache.clear();
|
||||||
|
chartsCache.clear();
|
||||||
|
}, 300000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterBeneficiariesByDepartment = (beneficiaries, departmentId) => {
|
||||||
|
if (!departmentId || departmentId === "" || !Array.isArray(beneficiaries)) {
|
||||||
|
return beneficiaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usar filter nativo que es más rápido
|
||||||
|
return beneficiaries.filter(
|
||||||
|
(item) =>
|
||||||
|
item.department_ek && item.department_ek.toString() === departmentId
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterBeneficiariesByType = (beneficiaries, typeFilter) => {
|
||||||
|
if (!typeFilter || typeFilter === "" || !Array.isArray(beneficiaries)) {
|
||||||
|
return beneficiaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
return beneficiaries.filter((item) => {
|
||||||
|
return typeFilter === "almacen"
|
||||||
|
? item.warehouse_id !== null
|
||||||
|
: item.service_id !== null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupBeneficiariesByType = (beneficiaries) => {
|
||||||
|
if (!Array.isArray(beneficiaries) || beneficiaries.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usar reduce que es más eficiente para agrupación
|
||||||
|
return beneficiaries.reduce((grouped, item) => {
|
||||||
|
const typeName =
|
||||||
|
item.warehouse?.name ||
|
||||||
|
item.warehouse?.type_name ||
|
||||||
|
item.service?.name ||
|
||||||
|
"Sin categoría";
|
||||||
|
|
||||||
|
if (!grouped[typeName]) {
|
||||||
|
grouped[typeName] = [];
|
||||||
|
}
|
||||||
|
grouped[typeName].push(item);
|
||||||
|
return grouped;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTipoChart = (porTipo) => {
|
||||||
|
const labels = Object.keys(porTipo || {});
|
||||||
|
const values = labels.map((k) => Number(porTipo[k] ?? 0));
|
||||||
|
|
||||||
|
tipoChart.value = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Apoyos entregados",
|
||||||
|
data: values,
|
||||||
|
backgroundColor: [
|
||||||
|
"#3b82f6",
|
||||||
|
"#ef4444",
|
||||||
|
"#10b981",
|
||||||
|
"#f59e0b",
|
||||||
|
"#8b5cf6",
|
||||||
|
"#06b6d4",
|
||||||
|
"#84cc16",
|
||||||
|
"#f97316",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildGeneroChart = (porGenero) => {
|
||||||
|
const map = {
|
||||||
|
hombres: porGenero?.hombres ?? porGenero?.Hombres ?? porGenero?.Hombre ?? 0,
|
||||||
|
mujeres: porGenero?.mujeres ?? porGenero?.Mujeres ?? porGenero?.Mujer ?? 0,
|
||||||
|
};
|
||||||
|
const labels = ["Hombres", "Mujeres"];
|
||||||
|
const values = [Number(map.hombres || 0), Number(map.mujeres || 0)];
|
||||||
|
|
||||||
|
generoChart.value = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Beneficiarios por género",
|
||||||
|
data: values,
|
||||||
|
backgroundColor: ["#3b82f6", "#fb7185"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildEdadChart = (porEdad) => {
|
||||||
|
const ORDER = [
|
||||||
|
"0-17",
|
||||||
|
"18-29",
|
||||||
|
"30-39",
|
||||||
|
"40-49",
|
||||||
|
"50-59",
|
||||||
|
"60-69",
|
||||||
|
"70+",
|
||||||
|
"sin_fecha",
|
||||||
|
];
|
||||||
|
const labels = ORDER.filter((k) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(porEdad || {}, k)
|
||||||
|
);
|
||||||
|
const finalLabels = labels.length ? labels : Object.keys(porEdad || {});
|
||||||
|
const values = finalLabels.map((k) => Number(porEdad[k] ?? 0));
|
||||||
|
const color = "#10B981";
|
||||||
|
|
||||||
|
edadChart.value = {
|
||||||
|
labels: finalLabels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Beneficiarios por rango de edad",
|
||||||
|
data: values,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: "rgba(16,185,129,0.12)",
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const openExportModal = () => {
|
||||||
|
showExportModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeExportModal = () => {
|
||||||
|
showExportModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watchers para recargar datos cuando cambien los filtros
|
||||||
|
watch([selectedDepartment, selectedType, selectedPeriod], () => {
|
||||||
|
clearTimeout(debounceId);
|
||||||
|
debounceId = setTimeout(load, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50">
|
||||||
|
<header class="relative">
|
||||||
|
<div class="bg-gradient-to-r from-gray-100 to-gray-50 py-3 shadow-sm">
|
||||||
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-bold text-gray-800 tracking-wide"
|
||||||
|
>COMALCALCO.GOB.MX</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
#621132 0%,
|
||||||
|
#7d1a42 50%,
|
||||||
|
#621132 100%
|
||||||
|
);
|
||||||
|
"
|
||||||
|
class="shadow-xl relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Patrón decorativo de fondo -->
|
||||||
|
<div class="absolute inset-0 opacity-10">
|
||||||
|
<div
|
||||||
|
class="absolute top-0 left-0 w-full h-full"
|
||||||
|
style="
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
transparent,
|
||||||
|
transparent 35px,
|
||||||
|
rgba(255, 255, 255, 0.1) 35px,
|
||||||
|
rgba(255, 255, 255, 0.1) 70px
|
||||||
|
);
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8 relative">
|
||||||
|
<div class="flex items-center justify-between h-32">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<img
|
||||||
|
src="https://apoyos.comalcalco.gob.mx/images/logo_blanco.png"
|
||||||
|
alt="Logo Comalcalco"
|
||||||
|
class="h-20 w-auto object-contain filter drop-shadow-lg transition-transform hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<h1 class="text-2xl font-bold text-white">
|
||||||
|
Dashboard de Beneficiarios
|
||||||
|
</h1>
|
||||||
|
<p class="text-blue-100 text-sm">
|
||||||
|
Sistema de seguimiento de apoyos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Filtros para Lista de Beneficiarios -->
|
||||||
|
<div
|
||||||
|
class="mb-8 bg-white rounded-xl shadow-sm border border-gray-100 p-6"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">
|
||||||
|
Filtros de Lista
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
@click="openExportModal"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-green-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-green-700 active:bg-green-900 focus:outline-none focus:border-green-900 focus:ring ring-green-300 disabled:opacity-25 transition ease-in-out duration-150"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Exportar Excel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<!-- Departamento -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Departamento</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
v-model="selectedDepartment"
|
||||||
|
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="dept in departmentOptions"
|
||||||
|
:key="dept.id"
|
||||||
|
:value="dept.id"
|
||||||
|
>
|
||||||
|
{{ dept.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tipo de Apoyo -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Tipo de Apoyo</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
v-model="selectedType"
|
||||||
|
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="type in typeOptions"
|
||||||
|
:key="type.id"
|
||||||
|
:value="type.id"
|
||||||
|
>
|
||||||
|
{{ type.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Período -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Período</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
v-model="selectedPeriod"
|
||||||
|
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="period in periodOptions"
|
||||||
|
:key="period.id"
|
||||||
|
:value="period.id"
|
||||||
|
>
|
||||||
|
{{ period.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4 mt-4">
|
||||||
|
<div v-if="error" class="flex items-center text-red-600">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium">{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center text-gray-500 text-xs">
|
||||||
|
<span>
|
||||||
|
Departamento: {{ selectedDepartment || "Todos" }} | Tipo:
|
||||||
|
{{ selectedType || "Todos" }} | Período: {{ selectedPeriod }} |
|
||||||
|
Beneficiarios: {{ beneficiariesList.length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cards de estadísticas -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-8">
|
||||||
|
<!-- Card: Entregados Hoy -->
|
||||||
|
<div class="bg-slate-500 rounded-lg shadow-lg p-6 text-white">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold opacity-90">Entregados Hoy</h3>
|
||||||
|
<p class="text-3xl font-bold">{{ totals.day ?? "0" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white/20 rounded-full p-3">
|
||||||
|
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Esta Semana -->
|
||||||
|
<div class="bg-green-500 rounded-lg shadow-lg p-6 text-white">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold opacity-90">Esta Semana</h3>
|
||||||
|
<p class="text-3xl font-bold">{{ totals.week ?? "0" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white/20 rounded-full p-3">
|
||||||
|
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista de Beneficiarios -->
|
||||||
|
<div
|
||||||
|
class="mb-8 bg-white rounded-xl shadow-sm border border-gray-100 p-6"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 inline-block mr-2 text-blue-600"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Entregados
|
||||||
|
{{
|
||||||
|
selectedPeriod === "hoy"
|
||||||
|
? "Hoy"
|
||||||
|
: selectedPeriod === "semana"
|
||||||
|
? "Esta Semana"
|
||||||
|
: "Este Mes"
|
||||||
|
}}
|
||||||
|
({{ beneficiariesList.length }})
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Lista agrupada por tipo -->
|
||||||
|
<div
|
||||||
|
v-if="Object.keys(beneficiariesByType).length > 0"
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(items, typeName) in beneficiariesByType"
|
||||||
|
:key="typeName"
|
||||||
|
class="border rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- header clicable -->
|
||||||
|
<div
|
||||||
|
class="bg-gray-50 px-4 py-3 border-b border-gray-200 cursor-pointer"
|
||||||
|
@click="toggleGroup(typeName)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3
|
||||||
|
class="flex items-center text-md font-semibold text-gray-900"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 mr-2 text-gray-600"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ typeName }}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium text-gray-600 bg-white px-3 py-1 rounded-full"
|
||||||
|
>
|
||||||
|
{{ items.length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-show="expandedGroups.has(typeName)"
|
||||||
|
class="divide-y divide-gray-100"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
class="p-4 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center space-x-3 mb-2">
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 text-gray-400"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h4 class="font-semibold text-gray-900">
|
||||||
|
{{ item.citizen?.name }} {{ item.citizen?.paternal }}
|
||||||
|
{{ item.citizen?.maternal }}
|
||||||
|
</h4>
|
||||||
|
<span
|
||||||
|
class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
item.created_at
|
||||||
|
? new Date(item.created_at).toLocaleDateString()
|
||||||
|
: "Sin fecha"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<!-- BADGE DEL DEPARTAMENTO -->
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-1 rounded-full"
|
||||||
|
:class="
|
||||||
|
item.department_ek === 1
|
||||||
|
? 'bg-purple-100 text-purple-800'
|
||||||
|
: 'bg-green-100 text-green-800'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
item.department_ek === 1
|
||||||
|
? "Atención Ciudadana"
|
||||||
|
: "DIF"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<span class="font-medium">CURP:</span>
|
||||||
|
{{ item.citizen?.curp || "N/A" }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="font-medium">Dirección:</span>
|
||||||
|
{{ item.citizen?.address || "N/A" }}
|
||||||
|
</p>
|
||||||
|
<p v-if="item.citizen?.locality">
|
||||||
|
<span class="font-medium">Localidad:</span>
|
||||||
|
{{ item.citizen.locality }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<span class="font-medium">Apoyo:</span>
|
||||||
|
{{
|
||||||
|
item.warehouse?.name ||
|
||||||
|
item.service?.name ||
|
||||||
|
"N/A"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p v-if="item.quantity">
|
||||||
|
<span class="font-medium">Cantidad:</span>
|
||||||
|
{{ item.quantity }}
|
||||||
|
</p>
|
||||||
|
<p v-if="item.deceased_name">
|
||||||
|
<span class="font-medium">Fallecido:</span>
|
||||||
|
{{ item.deceased_name }}
|
||||||
|
</p>
|
||||||
|
<p v-if="item.requester_name">
|
||||||
|
<span class="font-medium">Solicitante:</span>
|
||||||
|
{{ item.requester_name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="item.observations" class="mt-2">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
<span class="font-medium">Observaciones:</span>
|
||||||
|
{{ item.observations }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mensaje cuando no hay datos -->
|
||||||
|
<div v-else-if="!loading" class="text-center py-12">
|
||||||
|
<svg
|
||||||
|
class="w-12 h-12 text-gray-400 mx-auto mb-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 text-lg">No hay registros para mostrar</p>
|
||||||
|
<p class="text-gray-400 text-sm">
|
||||||
|
Ajusta los filtros para ver los beneficiarios
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NUEVA SECCIÓN: Filtros para Gráficas -->
|
||||||
|
<div
|
||||||
|
class="mb-8 bg-white rounded-xl shadow-sm border border-gray-100 p-6"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">
|
||||||
|
Filtros de Gráficas
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Selecciona el rango de fechas para análisis gráfico
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DateRange Component -->
|
||||||
|
<DateRange
|
||||||
|
v-model="dateRange"
|
||||||
|
:presets="true"
|
||||||
|
title-start="Fecha inicio"
|
||||||
|
title-end="Fecha fin"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex items-center text-gray-500 text-xs">
|
||||||
|
<span>
|
||||||
|
Rango de gráficas: {{ dateRange.start }} a {{ dateRange.end }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenedor de gráficas mejorado -->
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<!-- Gráfica principal -->
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-2xl shadow-xl border border-gray-100 p-8 mb-8 transition-all duration-300 hover:shadow-2xl"
|
||||||
|
>
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-r from-blue-500 to-indigo-600 rounded-2xl mb-4 shadow-lg"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-8 h-8 text-white"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
Distribución por Tipo de Apoyo
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Análisis detallado de los tipos de apoyos otorgados
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 mt-2">
|
||||||
|
Período: {{ dateRange.start }} - {{ dateRange.end }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="h-96">
|
||||||
|
<Bars :chartData="tipoChart" :chartOptions="chartOptions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gráficas secundarias -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-2xl shadow-xl border border-gray-100 p-6 transition-all duration-300 hover:shadow-2xl"
|
||||||
|
>
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-pink-500 to-rose-600 rounded-xl mb-4 shadow-lg"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-white"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 mb-1">
|
||||||
|
Distribución por Género
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600">Beneficiarios por género</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Pie
|
||||||
|
:chartData="generoChart"
|
||||||
|
:chartOptions="{
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-2xl shadow-xl border border-gray-100 p-6 transition-all duration-300 hover:shadow-2xl"
|
||||||
|
>
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-emerald-500 to-teal-600 rounded-xl mb-4 shadow-lg"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-white"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 mb-1">
|
||||||
|
Distribución por Edad
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Beneficiarios por rango etario
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="h-64">
|
||||||
|
<Lines
|
||||||
|
:chartData="edadChart"
|
||||||
|
:chartOptions="{
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { position: 'bottom' } },
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<ExportModal :show="showExportModal" @close="closeExportModal" />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
1373
resources/js/Pages/App/Obras.vue
Normal file
1373
resources/js/Pages/App/Obras.vue
Normal file
File diff suppressed because it is too large
Load Diff
584
resources/js/Pages/App/Tramites.vue
Normal file
584
resources/js/Pages/App/Tramites.vue
Normal file
@ -0,0 +1,584 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted } from "vue";
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import Bars from "@/Components/Dashboard/Charts/Bars.vue";
|
||||||
|
import Pie from "@/Components/Dashboard/Charts/Pie.vue";
|
||||||
|
import DateRange from "@/Components/Dashboard/Form/DateRange.vue";
|
||||||
|
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||||
|
|
||||||
|
const data = ref(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref(null);
|
||||||
|
|
||||||
|
// Rango inicial: Hoy→Hoy (coincide con preset del DateRange)
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const dateRange = ref({ start: today, end: today });
|
||||||
|
|
||||||
|
// toggles
|
||||||
|
const showBars = ref(false);
|
||||||
|
const showPie = ref(false);
|
||||||
|
|
||||||
|
// datasets
|
||||||
|
const barChartData = ref({ labels: [], datasets: [] });
|
||||||
|
const pieChartData = ref({ labels: [], datasets: [] });
|
||||||
|
|
||||||
|
// opciones reutilizables
|
||||||
|
const barChartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
makeDataset: (label, values) => ({
|
||||||
|
label,
|
||||||
|
data: values,
|
||||||
|
backgroundColor: "rgba(75,192,192,0.2)",
|
||||||
|
borderColor: "rgba(75,192,192,1)",
|
||||||
|
borderWidth: 1,
|
||||||
|
}),
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
autoSkip: false,
|
||||||
|
maxRotation: 0,
|
||||||
|
minRotation: 0,
|
||||||
|
callback: (_value, index) => {
|
||||||
|
const lab = barChartData.value.labels[index] || "";
|
||||||
|
return lab.split(" ").map((w) => w.toUpperCase());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const pieChartOptions = { responsive: true, maintainAspectRatio: false };
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
const diffDays = (a, b) => {
|
||||||
|
if (!a || !b) return 0;
|
||||||
|
const A = new Date(a + "T00:00:00");
|
||||||
|
const B = new Date(b + "T00:00:00");
|
||||||
|
return Math.max(0, Math.round((B - A) / (1000 * 60 * 60 * 24)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const mode = computed(() => {
|
||||||
|
const d = diffDays(dateRange.value.start, dateRange.value.end);
|
||||||
|
if (d === 0) return "día";
|
||||||
|
if (d <= 7) return "semana";
|
||||||
|
return "rango";
|
||||||
|
});
|
||||||
|
|
||||||
|
let cancelTokenSource = null;
|
||||||
|
let debounceId = null;
|
||||||
|
|
||||||
|
const fetchReport = async ({ start, end }) => {
|
||||||
|
// cancelar petición previa
|
||||||
|
if (cancelTokenSource) {
|
||||||
|
try {
|
||||||
|
cancelTokenSource.cancel("cancel");
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
cancelTokenSource = axios.CancelToken.source();
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
if (start) params.start = start;
|
||||||
|
if (end) params.end = end;
|
||||||
|
|
||||||
|
const res = await axios.get("/api/reporte-especial", {
|
||||||
|
params,
|
||||||
|
cancelToken: cancelTokenSource.token,
|
||||||
|
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// axios lanza en error si status >= 400, aquí devolvemos data directamente
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const payload = await fetchReport({
|
||||||
|
start: dateRange.value.start,
|
||||||
|
end: dateRange.value.end,
|
||||||
|
});
|
||||||
|
data.value = payload;
|
||||||
|
mapToCharts(payload, mode.value);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name !== "AbortError") {
|
||||||
|
console.error(e);
|
||||||
|
error.value = e.message || "Error desconocido";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
dateRange,
|
||||||
|
() => {
|
||||||
|
clearTimeout(debounceId);
|
||||||
|
debounceId = setTimeout(load, 350);
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
|
||||||
|
// --- mapping charts: usa SIEMPRE el rango ---
|
||||||
|
function mapToCharts(api, currentMode) {
|
||||||
|
const list = Array.isArray(api?.movements_by_procedure)
|
||||||
|
? api.movements_by_procedure
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const labels = list.map((it) => (it.name || "").toUpperCase());
|
||||||
|
|
||||||
|
// Pie: total (opened + closed). Si tu API devuelve valores por rango, puedes sustituir aquí.
|
||||||
|
const pieValues = list.map((it) => (it.opened || 0) + (it.closed || 0));
|
||||||
|
pieChartData.value = {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Recaudados",
|
||||||
|
data: pieValues,
|
||||||
|
backgroundColor: labels.map(
|
||||||
|
(_, i) => `hsl(${(i * 360) / Math.max(1, labels.length)}, 70%, 50%)`
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bars: dataset según modo
|
||||||
|
let barLabel = "";
|
||||||
|
let barValues = [];
|
||||||
|
|
||||||
|
if (currentMode === "día") {
|
||||||
|
barLabel = "Solicitados Hoy";
|
||||||
|
barValues = list.map(
|
||||||
|
(it) => it.procedures_opened_today ?? it.opened_today ?? 0
|
||||||
|
);
|
||||||
|
} else if (currentMode === "semana") {
|
||||||
|
barLabel = "Solicitados Semana";
|
||||||
|
barValues = list.map((it) => it.total ?? 0);
|
||||||
|
} else {
|
||||||
|
barLabel = "Solicitados en Rango";
|
||||||
|
barValues = list.map((it) => {
|
||||||
|
if (typeof it.range_total === "number") return it.range_total;
|
||||||
|
return (it.opened || 0) + (it.closed || 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
barChartData.value = {
|
||||||
|
labels,
|
||||||
|
datasets: [barChartOptions.makeDataset(barLabel, barValues)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI helpers
|
||||||
|
const toggleBars = () => (showBars.value = !showBars.value);
|
||||||
|
const togglePie = () => (showPie.value = !showPie.value);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div>
|
||||||
|
<header class="relative">
|
||||||
|
<div class="bg-gradient-to-r from-gray-100 to-gray-50 py-3 shadow-sm">
|
||||||
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-bold text-gray-800 tracking-wide"
|
||||||
|
>COMALCALCO.GOB.MX</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
#621132 0%,
|
||||||
|
#7d1a42 50%,
|
||||||
|
#621132 100%
|
||||||
|
);
|
||||||
|
"
|
||||||
|
class="shadow-xl relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Patrón decorativo de fondo -->
|
||||||
|
<div class="absolute inset-0 opacity-10">
|
||||||
|
<div
|
||||||
|
class="absolute top-0 left-0 w-full h-full"
|
||||||
|
style="
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
transparent,
|
||||||
|
transparent 35px,
|
||||||
|
rgba(255, 255, 255, 0.1) 35px,
|
||||||
|
rgba(255, 255, 255, 0.1) 70px
|
||||||
|
);
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8 relative">
|
||||||
|
<div class="flex items-center justify-between h-32">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<img
|
||||||
|
src="https://apoyos.comalcalco.gob.mx/images/logo_blanco.png"
|
||||||
|
alt="Logo Comalcalco"
|
||||||
|
class="h-20 w-auto object-contain filter drop-shadow-lg transition-transform hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<h1 class="text-2xl font-bold text-white">
|
||||||
|
Reporte Especial de Trámites
|
||||||
|
</h1>
|
||||||
|
<p class="text-blue-100 text-sm">
|
||||||
|
Sistema de seguimiento de trámites
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="fixed inset-0 bg-white/95 backdrop-blur-sm flex items-center justify-center z-50 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
class="animate-spin rounded-full h-16 w-16 border-4 border-blue-200 border-t-blue-600 mx-auto"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-6 text-gray-700 font-medium">Cargando datos...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex px-4 sm:px-6 lg:px-8 mb-4 items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
Reporte Especial de Trámites
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600">Resumen por unidad administrativa</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div class="ml-auto flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
@click="toggleBars"
|
||||||
|
class="bg-primary text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Gráfica de Barras
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="togglePie"
|
||||||
|
class="bg-primary text-white px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
Más recaudados
|
||||||
|
</button>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Estadísticas del día -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg shadow-sm border border-gray-200 p-6"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">
|
||||||
|
Trámites Abiertos Hoy
|
||||||
|
</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">
|
||||||
|
{{
|
||||||
|
new Intl.NumberFormat().format(
|
||||||
|
data.procedures_opened_today
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg shadow-sm border border-gray-200 p-6"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">
|
||||||
|
Con Movimientos Hoy
|
||||||
|
</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">
|
||||||
|
{{
|
||||||
|
new Intl.NumberFormat().format(
|
||||||
|
data.procedures_with_movements_today
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla de trámites por administración -->
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden w-full"
|
||||||
|
>
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">
|
||||||
|
Trámites por Unidad Administrativa
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto w-full">
|
||||||
|
<table class="w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Unidad Administrativa
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Abiertos
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Movimientos Hoy
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Cerrados
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Total
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr
|
||||||
|
v-for="administration in data.procedures_by_administration ||
|
||||||
|
[]"
|
||||||
|
:key="administration.id"
|
||||||
|
class="hover:bg-gray-50 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm font-medium text-gray-900">
|
||||||
|
{{ administration.name }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
new Intl.NumberFormat().format(
|
||||||
|
administration.opened
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
new Intl.NumberFormat().format(
|
||||||
|
administration.movement_today
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
new Intl.NumberFormat().format(
|
||||||
|
administration.closed
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm font-semibold text-gray-900">
|
||||||
|
{{
|
||||||
|
new Intl.NumberFormat().format(
|
||||||
|
administration.opened + administration.closed
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
v-if="
|
||||||
|
!(
|
||||||
|
data.procedures_by_administration &&
|
||||||
|
data.procedures_by_administration.length
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<td colspan="5" class="px-6 py-8 text-center">
|
||||||
|
<div class="text-gray-500">
|
||||||
|
No hay datos disponibles
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla de trámites por procedimiento -->
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden w-full mt-8"
|
||||||
|
>
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">
|
||||||
|
Trámites por Procedimiento
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto w-full">
|
||||||
|
<table class="w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Procedimiento
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Abiertos
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Movimientos Hoy
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Cerrados
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Total
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr
|
||||||
|
v-for="procedure in data.movements_by_procedure || []"
|
||||||
|
:key="procedure.id"
|
||||||
|
class="hover:bg-gray-50 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm font-medium text-gray-900">
|
||||||
|
{{ procedure.name }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||||
|
>
|
||||||
|
{{ new Intl.NumberFormat().format(procedure.opened) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
new Intl.NumberFormat().format(
|
||||||
|
procedure.movement_today
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||||
|
>
|
||||||
|
{{ new Intl.NumberFormat().format(procedure.closed) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm font-semibold text-gray-900">
|
||||||
|
{{
|
||||||
|
new Intl.NumberFormat().format(
|
||||||
|
procedure.opened + procedure.closed
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
v-if="
|
||||||
|
!(
|
||||||
|
data.movements_by_procedure &&
|
||||||
|
data.movements_by_procedure.length
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<td colspan="5" class="px-6 py-8 text-center">
|
||||||
|
<div class="text-gray-500">
|
||||||
|
No hay datos disponibles
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bars -->
|
||||||
|
<div
|
||||||
|
v-if="showBars"
|
||||||
|
class="max-w-7xl mx-auto mt-12 px-4 sm:px-6 lg:px-8 mb-8 bg-white rounded-lg shadow p-6"
|
||||||
|
>
|
||||||
|
<!-- DateRange mejorado -->
|
||||||
|
<DateRange v-model="dateRange" :presets="true" class="mb-12" />
|
||||||
|
<Bars :chartData="barChartData" :chartOptions="barChartOptions" />
|
||||||
|
<p class="text-sm text-gray-500 mt-2">Modo actual: {{ mode }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pie -->
|
||||||
|
<div
|
||||||
|
v-if="showPie"
|
||||||
|
class="max-w-7xl mx-auto mt-8 px-4 sm:px-6 lg:px-8 bg-white rounded-lg shadow p-6"
|
||||||
|
>
|
||||||
|
<Pie :chartData="pieChartData" :chartOptions="pieChartOptions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 text-center">
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Reporte generado el {{ new Date().toLocaleString("es-MX") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@ -1,406 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, watch, onMounted } from "vue";
|
|
||||||
|
|
||||||
import axios from "axios";
|
|
||||||
import DateRange from "@/Components/Dashboard/Form/DateRange.vue";
|
|
||||||
import Bars from "@/Components/Dashboard/Charts/Bars.vue";
|
|
||||||
import Pie from "@/Components/Dashboard/Charts/Pie.vue";
|
|
||||||
import Lines from "@/Components/Dashboard/Charts/Lines.vue";
|
|
||||||
|
|
||||||
const fmtToday = () => new Date().toISOString().slice(0, 10);
|
|
||||||
const today = fmtToday();
|
|
||||||
|
|
||||||
const dateRange = ref({ start: today, end: today });
|
|
||||||
|
|
||||||
const loading = ref(false);
|
|
||||||
const error = ref(null);
|
|
||||||
let debounceId = null;
|
|
||||||
let cancelTokenSource = null;
|
|
||||||
|
|
||||||
const totals = ref({ day: null, week: null, month: null });
|
|
||||||
|
|
||||||
const tipoChart = ref({ labels: [], datasets: [] });
|
|
||||||
const generoChart = ref({ labels: [], datasets: [] });
|
|
||||||
const edadChart = ref({ labels: [], datasets: [] });
|
|
||||||
const chartOptions = {
|
|
||||||
responsive: true,
|
|
||||||
scales: {
|
|
||||||
x: { beginAtZero: true },
|
|
||||||
y: { beginAtZero: true },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeResponse = (res) => {
|
|
||||||
const main = res?.data ?? {};
|
|
||||||
const counts = res?.counts ?? res?.counts_ac ?? [];
|
|
||||||
|
|
||||||
const porTipo =
|
|
||||||
main?.por_tipo_apoyo ??
|
|
||||||
main?.data?.por_tipo_apoyo ??
|
|
||||||
counts?.data?.por_tipo_apoyo ??
|
|
||||||
counts?.por_tipo_apoyo ??
|
|
||||||
{};
|
|
||||||
|
|
||||||
const porGenero =
|
|
||||||
main?.por_genero ??
|
|
||||||
main?.data?.por_genero ??
|
|
||||||
counts?.data?.por_genero ??
|
|
||||||
{};
|
|
||||||
const porEdad =
|
|
||||||
main?.por_rango_edad ??
|
|
||||||
main?.data?.por_rango_edad ??
|
|
||||||
counts?.data?.por_rango_edad ??
|
|
||||||
{};
|
|
||||||
const totalsShape = counts?.data_ac ?? counts?.data ?? counts ?? {};
|
|
||||||
return {
|
|
||||||
porGenero,
|
|
||||||
porEdad,
|
|
||||||
porTipo,
|
|
||||||
totalsShape,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildTipoChart = (porTipo) => {
|
|
||||||
const labels = Object.keys(porTipo || {});
|
|
||||||
const values = labels.map((k) => Number(porTipo[k] ?? 0));
|
|
||||||
tipoChart.value = {
|
|
||||||
labels,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: "Apoyos entregados",
|
|
||||||
data: values,
|
|
||||||
backgroundColor: ["#3b82f6"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildGeneroChart = (porGenero) => {
|
|
||||||
const map = {
|
|
||||||
hombres: porGenero?.hombres ?? porGenero?.Hombres ?? porGenero?.Hombre ?? 0,
|
|
||||||
mujeres: porGenero?.mujeres ?? porGenero?.Mujeres ?? porGenero?.Mujer ?? 0,
|
|
||||||
};
|
|
||||||
const labels = ["Hombres", "Mujeres"];
|
|
||||||
const values = [Number(map.hombres || 0), Number(map.mujeres || 0)];
|
|
||||||
generoChart.value = {
|
|
||||||
labels,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: "Beneficiarios por género",
|
|
||||||
data: values,
|
|
||||||
backgroundColor: ["#3b82f6", "#fb7185"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildEdadChart = (porEdad) => {
|
|
||||||
// orden predecible para la X de la gráfica
|
|
||||||
const ORDER = [
|
|
||||||
"0-17",
|
|
||||||
"18-29",
|
|
||||||
"30-39",
|
|
||||||
"40-49",
|
|
||||||
"50-59",
|
|
||||||
"60-69",
|
|
||||||
"70+",
|
|
||||||
"sin_fecha",
|
|
||||||
];
|
|
||||||
const labels = ORDER.filter((k) =>
|
|
||||||
Object.prototype.hasOwnProperty.call(porEdad || {}, k)
|
|
||||||
);
|
|
||||||
// si no hay claves exactamente como ORDER, fallback a keys actuales
|
|
||||||
const finalLabels = labels.length ? labels : Object.keys(porEdad || {});
|
|
||||||
const values = finalLabels.map((k) => Number(porEdad[k] ?? 0));
|
|
||||||
const color = "#10B981"; // verde
|
|
||||||
edadChart.value = {
|
|
||||||
labels: finalLabels,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: "Beneficiarios por rango de edad",
|
|
||||||
data: values,
|
|
||||||
borderColor: color,
|
|
||||||
backgroundColor: "rgba(16,185,129,0.12)",
|
|
||||||
fill: true,
|
|
||||||
tension: 0.3,
|
|
||||||
pointRadius: 3,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
|
|
||||||
if (cancelTokenSource) {
|
|
||||||
try {
|
|
||||||
cancelTokenSource.cancel("cancel");
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
cancelTokenSource = axios.CancelToken.source();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await axios.get("/api/reporte-atencion", {
|
|
||||||
params: { start: dateRange.value.start, end: dateRange.value.end },
|
|
||||||
cancelToken: cancelTokenSource.token,
|
|
||||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = res.data || {};
|
|
||||||
const { porTipo, porGenero, porEdad, totalsShape } =
|
|
||||||
normalizeResponse(payload);
|
|
||||||
|
|
||||||
totals.value.day = totalsShape?.today ?? totalsShape?.dia ?? null;
|
|
||||||
totals.value.week = totalsShape?.week ?? totalsShape?.semana ?? null;
|
|
||||||
totals.value.month = totalsShape?.month ?? totalsShape?.mes ?? null;
|
|
||||||
|
|
||||||
buildTipoChart(porTipo);
|
|
||||||
buildGeneroChart(porGenero);
|
|
||||||
buildEdadChart(porEdad);
|
|
||||||
} catch (e) {
|
|
||||||
if (!(axios.isCancel && axios.isCancel(e))) {
|
|
||||||
console.error(e);
|
|
||||||
error.value =
|
|
||||||
e.response?.data?.error || e.message || "Error al cargar datos";
|
|
||||||
tipoChart.value = { labels: [], datasets: [] };
|
|
||||||
generoChart.value = { labels: [], datasets: [] };
|
|
||||||
edadChart.value = { labels: [], datasets: [] };
|
|
||||||
totals.value = { day: null, week: null, month: null };
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
dateRange,
|
|
||||||
() => {
|
|
||||||
clearTimeout(debounceId);
|
|
||||||
debounceId = setTimeout(load, 300);
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(load);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50">
|
|
||||||
<header class="relative">
|
|
||||||
<div class="bg-gradient-to-r from-gray-100 to-gray-50 py-3 shadow-sm">
|
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-bold text-gray-800 tracking-wide">COMALCALCO.GOB.MX</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style="background: linear-gradient(135deg, #621132 0%, #7d1a42 50%, #621132 100%)"
|
|
||||||
class="shadow-xl relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<!-- Patrón decorativo de fondo -->
|
|
||||||
<div class="absolute inset-0 opacity-10">
|
|
||||||
<div class="absolute top-0 left-0 w-full h-full"
|
|
||||||
style="background-image: repeating-linear-gradient(45deg, transparent, transparent 35px, rgba(255,255,255,.1) 35px, rgba(255,255,255,.1) 70px);"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 relative">
|
|
||||||
<div class="flex items-center justify-between h-32">
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<img
|
|
||||||
src="https://apoyos.comalcalco.gob.mx/images/logo_blanco.png"
|
|
||||||
alt="Logo Comalcalco"
|
|
||||||
class="h-20 w-auto object-contain filter drop-shadow-lg transition-transform hover:scale-105"
|
|
||||||
/>
|
|
||||||
<div class="hidden md:block">
|
|
||||||
<h1 class="text-2xl font-bold text-white">Atención Ciudadana y DIF</h1>
|
|
||||||
<p class="text-blue-100 text-sm">Sistema de seguimiento de apoyos</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Loading overlay mejorado -->
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="fixed inset-0 bg-white/95 backdrop-blur-sm flex items-center justify-center z-50 transition-all duration-300"
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="relative">
|
|
||||||
<div class="animate-spin rounded-full h-16 w-16 border-4 border-blue-200 border-t-blue-600 mx-auto"></div>
|
|
||||||
</div>
|
|
||||||
<p class="mt-6 text-gray-700 font-medium">Cargando datos...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<!-- Controles mejorados -->
|
|
||||||
<div class="mb-8 flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 mb-2">Filtros de fecha</h2>
|
|
||||||
<DateRange v-model="dateRange" :presets="true" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<div v-if="loading" class="flex items-center text-blue-600">
|
|
||||||
<div class="animate-spin h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full mr-2"></div>
|
|
||||||
<span class="text-sm font-medium">Cargando...</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="error" class="flex items-center text-red-600">
|
|
||||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm font-medium">{{ error }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cards mejoradas -->
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-8">
|
|
||||||
<div class="bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
|
||||||
<div class="p-6 text-white">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center space-x-2 mb-2">
|
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
<h3 class="text-sm font-semibold opacity-90">Apoyos del Mes</h3>
|
|
||||||
</div>
|
|
||||||
<p class="text-3xl font-bold">{{ totals.month ?? "--" }}</p>
|
|
||||||
<p class="text-xs text-blue-100 mt-1">Total mensual</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white/20 rounded-full p-3">
|
|
||||||
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
|
||||||
<div class="p-6 text-white">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center space-x-2 mb-2">
|
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
<h3 class="text-sm font-semibold opacity-90">Apoyos Semanales</h3>
|
|
||||||
</div>
|
|
||||||
<p class="text-3xl font-bold">{{ totals.week ?? "--" }}</p>
|
|
||||||
<p class="text-xs text-emerald-100 mt-1">Últimos 7 días</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white/20 rounded-full p-3">
|
|
||||||
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-gradient-to-br from-amber-500 to-amber-600 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
|
||||||
<div class="p-6 text-white">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center space-x-2 mb-2">
|
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 2a8 8 0 100 16 8 8 0 000-16zM8 12a1 1 0 102 0V9a1 1 0 10-2 0v3zm2-8a1 1 0 100 2 1 1 0 000-2z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
<h3 class="text-sm font-semibold opacity-90">Apoyos de Hoy</h3>
|
|
||||||
</div>
|
|
||||||
<p class="text-3xl font-bold">{{ totals.day ?? "--" }}</p>
|
|
||||||
<p class="text-xs text-amber-100 mt-1">En el día actual</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white/20 rounded-full p-3">
|
|
||||||
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 2a8 8 0 100 16 8 8 0 000-16zM8 12a1 1 0 102 0V9a1 1 0 10-2 0v3zm2-8a1 1 0 100 2 1 1 0 000-2z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contenedor de gráficas mejorado -->
|
|
||||||
<div class="max-w-7xl mx-auto">
|
|
||||||
<!-- Gráfica principal -->
|
|
||||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-100 p-8 mb-8 transition-all duration-300 hover:shadow-2xl">
|
|
||||||
<div class="text-center mb-8">
|
|
||||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-r from-blue-500 to-indigo-600 rounded-2xl mb-4 shadow-lg">
|
|
||||||
<svg class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">Distribución por Tipo de Apoyo</h2>
|
|
||||||
<p class="text-gray-600">Análisis detallado de los tipos de apoyos otorgados</p>
|
|
||||||
</div>
|
|
||||||
<div class="h-96">
|
|
||||||
<Bars :chartData="tipoChart" :chartOptions="chartOptions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Gráficas secundarias -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-100 p-6 transition-all duration-300 hover:shadow-2xl">
|
|
||||||
<div class="text-center mb-6">
|
|
||||||
<div class="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-pink-500 to-rose-600 rounded-xl mb-4 shadow-lg">
|
|
||||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-bold text-gray-900 mb-1">Distribución por Género</h3>
|
|
||||||
<p class="text-sm text-gray-600">Beneficiarios por género</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Pie
|
|
||||||
:chartData="generoChart"
|
|
||||||
:chartOptions="{ responsive: true, maintainAspectRatio: false }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-2xl shadow-xl border border-gray-100 p-6 transition-all duration-300 hover:shadow-2xl">
|
|
||||||
<div class="text-center mb-6">
|
|
||||||
<div class="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-emerald-500 to-teal-600 rounded-xl mb-4 shadow-lg">
|
|
||||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-bold text-gray-900 mb-1">Distribución por Edad</h3>
|
|
||||||
<p class="text-sm text-gray-600">Beneficiarios por rango etario</p>
|
|
||||||
</div>
|
|
||||||
<div class="h-64">
|
|
||||||
<Lines
|
|
||||||
:chartData="edadChart"
|
|
||||||
:chartOptions="{
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: 'bottom'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Footer mejorado -->
|
|
||||||
<footer class="bg-gradient-to-r from-gray-800 to-gray-900 text-white py-8 mt-16">
|
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex flex-col md:flex-row justify-between items-center">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,173 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { router } from "@inertiajs/vue3";
|
import Welcome from '@/Components/Dashboard/Welcome.vue';
|
||||||
|
import Layout from '@/Layouts/DashboardLayout.vue';
|
||||||
import GoogleIcon from "@/Components/Shared/GoogleIcon.vue";
|
|
||||||
import NotificationController from "@/Controllers/NotificationController";
|
|
||||||
|
|
||||||
const notificationCtl = NotificationController;
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
router.post(
|
|
||||||
route("logout"),
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onBefore: () => {
|
|
||||||
notificationCtl.stop();
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<Layout title="Dashboard">
|
||||||
class="min-h-screen font-sans bg-gradient-to-br from-[#621132] via-[#621132] to-[#621132] text-[#621132] antialiased"
|
<div class="py-4">
|
||||||
>
|
<Welcome />
|
||||||
<button
|
|
||||||
class="fixed top-4 right-4 z-50 inline-flex items-center bg-white/10 text-white rounded-lg hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 transition p-2"
|
|
||||||
type="button"
|
|
||||||
@click.prevent="logout"
|
|
||||||
>
|
|
||||||
{{ $t("auth.logout") }}
|
|
||||||
</button>
|
|
||||||
<main class="grid min-h-screen place-items-center p-4">
|
|
||||||
<!-- Tarjeta -->
|
|
||||||
<section
|
|
||||||
class="w-full max-w-2xl rounded-3xl border border-white/10 bg-white/5 p-6 sm:p-8 shadow-2xl backdrop-blur supports-[backdrop-filter]:bg-white/10"
|
|
||||||
>
|
|
||||||
<!-- Encabezado -->
|
|
||||||
<header class="mb-6 sm:mb-8">
|
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold tracking-tight text-white">
|
|
||||||
Accesos rápidos
|
|
||||||
</h1>
|
|
||||||
<p class="mt-2 text-sm sm:text-base text-white">
|
|
||||||
Selecciona un destino.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Grid de botones -->
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
|
||||||
<!-- Botón 1 -->
|
|
||||||
<a
|
|
||||||
:href="route('dashboard.api-tramite')"
|
|
||||||
target="_blank"
|
|
||||||
class="group relative overflow-hidden rounded-2xl border border-white/10 bg-white/10 p-4 sm:p-5 transition hover:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 active:scale-[0.99]"
|
|
||||||
aria-label="Ir al sitio 1"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<!-- Ícono externo simple -->
|
|
||||||
<span class="rounded-xl bg-white/10 p-2">
|
|
||||||
<GoogleIcon name="open_in_new" class="text-white" />
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h2 class="font-semibold text-white">
|
|
||||||
Reporte Especial de Trámites
|
|
||||||
</h2>
|
|
||||||
<p class="text-sm text-slate-300">
|
|
||||||
Resumen de trámites por unidad administrativa
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Efecto -->
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
|
||||||
style="
|
|
||||||
background: radial-gradient(
|
|
||||||
600px circle at var(--x, 50%) var(--y, 50%),
|
|
||||||
rgba(199, 12, 12, 0.12),
|
|
||||||
transparent 40%
|
|
||||||
);
|
|
||||||
"
|
|
||||||
></span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Botón 2 -->
|
|
||||||
<a
|
|
||||||
:href="route('dashboard.api-atencion')"
|
|
||||||
target="_blank"
|
|
||||||
class="group relative overflow-hidden rounded-2xl border border-white/10 bg-white/10 p-4 sm:p-5 transition hover:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 active:scale-[0.99]"
|
|
||||||
aria-label="Ir al sitio 2"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="rounded-xl bg-white/10 p-2">
|
|
||||||
<GoogleIcon name="open_in_new" class="text-white" />
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h2 class="font-semibold text-white">Apoyo a Beneficiarios y DIF</h2>
|
|
||||||
<p class="text-sm text-slate-300">
|
|
||||||
Gráficas de apoyo a Beneficiarios y DIF
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
|
||||||
style="
|
|
||||||
background: radial-gradient(
|
|
||||||
600px circle at var(--x, 50%) var(--y, 50%),
|
|
||||||
rgba(98, 17, 50, 0.12),
|
|
||||||
transparent 40%
|
|
||||||
);
|
|
||||||
"
|
|
||||||
></span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Botón 3 -->
|
|
||||||
<a
|
|
||||||
:href="route('dashboard.api-obra')"
|
|
||||||
target="_blank"
|
|
||||||
class="group relative overflow-hidden rounded-2xl border border-white/10 bg-white/10 p-4 sm:p-5 transition hover:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-fuchsia-400 active:scale-[0.99]"
|
|
||||||
aria-label="Ir al sitio 3"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="rounded-xl bg-white/10 p-2">
|
|
||||||
<GoogleIcon name="open_in_new" class="text-white" />
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h2 class="font-semibold text-white">Información de Obras</h2>
|
|
||||||
<p class="text-sm text-slate-300">Proyectos en seguimiento</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
|
||||||
style="
|
|
||||||
background: radial-gradient(
|
|
||||||
600px circle at var(--x, 50%) var(--y, 50%),
|
|
||||||
rgba(255, 255, 255, 0.12),
|
|
||||||
transparent 40%
|
|
||||||
);
|
|
||||||
"
|
|
||||||
></span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Botón 4 -->
|
|
||||||
<a
|
|
||||||
href="https://apoyos.comalcalco.gob.mx/beneficiaries/dashboard?department=&period=week&type=almacen"
|
|
||||||
target="_blank"
|
|
||||||
class="group relative overflow-hidden rounded-2xl border border-white/10 bg-white/10 p-4 sm:p-5 transition hover:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 active:scale-[0.99]"
|
|
||||||
aria-label="Ir al sitio 4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="rounded-xl bg-white/10 p-2">
|
|
||||||
<GoogleIcon name="open_in_new" class="text-white" />
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h2 class="font-semibold text-white">Información de Apoyos a Beneficiarios y DIF</h2>
|
|
||||||
<p class="text-sm text-slate-300"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
|
||||||
style="
|
|
||||||
background: radial-gradient(
|
|
||||||
600px circle at var(--x, 50%) var(--y, 50%),
|
|
||||||
rgba(255, 255, 255, 0.12),
|
|
||||||
transparent 40%
|
|
||||||
);
|
|
||||||
"
|
|
||||||
></span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Layout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,573 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed, watch, onMounted } from "vue";
|
|
||||||
|
|
||||||
import axios from "axios";
|
|
||||||
import Bars from "@/Components/Dashboard/Charts/Bars.vue";
|
|
||||||
import Pie from "@/Components/Dashboard/Charts/Pie.vue";
|
|
||||||
import DateRange from "@/Components/Dashboard/Form/DateRange.vue";
|
|
||||||
|
|
||||||
const data = ref(null);
|
|
||||||
const loading = ref(true);
|
|
||||||
const error = ref(null);
|
|
||||||
|
|
||||||
// Rango inicial: Hoy→Hoy (coincide con preset del DateRange)
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
const dateRange = ref({ start: today, end: today });
|
|
||||||
|
|
||||||
// toggles
|
|
||||||
const showBars = ref(false);
|
|
||||||
const showPie = ref(false);
|
|
||||||
|
|
||||||
// datasets
|
|
||||||
const barChartData = ref({ labels: [], datasets: [] });
|
|
||||||
const pieChartData = ref({ labels: [], datasets: [] });
|
|
||||||
|
|
||||||
// opciones reutilizables
|
|
||||||
const barChartOptions = {
|
|
||||||
responsive: true,
|
|
||||||
makeDataset: (label, values) => ({
|
|
||||||
label,
|
|
||||||
data: values,
|
|
||||||
backgroundColor: "rgba(75,192,192,0.2)",
|
|
||||||
borderColor: "rgba(75,192,192,1)",
|
|
||||||
borderWidth: 1,
|
|
||||||
}),
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
ticks: {
|
|
||||||
autoSkip: false,
|
|
||||||
maxRotation: 0,
|
|
||||||
minRotation: 0,
|
|
||||||
callback: (_value, index) => {
|
|
||||||
const lab = barChartData.value.labels[index] || "";
|
|
||||||
return lab.split(" ").map((w) => w.toUpperCase());
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const pieChartOptions = { responsive: true, maintainAspectRatio: false };
|
|
||||||
|
|
||||||
// helpers
|
|
||||||
const diffDays = (a, b) => {
|
|
||||||
if (!a || !b) return 0;
|
|
||||||
const A = new Date(a + "T00:00:00");
|
|
||||||
const B = new Date(b + "T00:00:00");
|
|
||||||
return Math.max(0, Math.round((B - A) / (1000 * 60 * 60 * 24)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const mode = computed(() => {
|
|
||||||
const d = diffDays(dateRange.value.start, dateRange.value.end);
|
|
||||||
if (d === 0) return "día";
|
|
||||||
if (d <= 7) return "semana";
|
|
||||||
return "rango";
|
|
||||||
});
|
|
||||||
|
|
||||||
let cancelTokenSource = null;
|
|
||||||
let debounceId = null;
|
|
||||||
|
|
||||||
const fetchReport = async ({ start, end }) => {
|
|
||||||
// cancelar petición previa
|
|
||||||
if (cancelTokenSource) {
|
|
||||||
try {
|
|
||||||
cancelTokenSource.cancel("cancel");
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
cancelTokenSource = axios.CancelToken.source();
|
|
||||||
|
|
||||||
const params = {};
|
|
||||||
if (start) params.start = start;
|
|
||||||
if (end) params.end = end;
|
|
||||||
|
|
||||||
const res = await axios.get("/api/reporte-especial", {
|
|
||||||
params,
|
|
||||||
cancelToken: cancelTokenSource.token,
|
|
||||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
|
||||||
});
|
|
||||||
|
|
||||||
// axios lanza en error si status >= 400, aquí devolvemos data directamente
|
|
||||||
return res.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
const payload = await fetchReport({
|
|
||||||
start: dateRange.value.start,
|
|
||||||
end: dateRange.value.end,
|
|
||||||
});
|
|
||||||
data.value = payload;
|
|
||||||
mapToCharts(payload, mode.value);
|
|
||||||
} catch (e) {
|
|
||||||
if (e.name !== "AbortError") {
|
|
||||||
console.error(e);
|
|
||||||
error.value = e.message || "Error desconocido";
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
dateRange,
|
|
||||||
() => {
|
|
||||||
clearTimeout(debounceId);
|
|
||||||
debounceId = setTimeout(load, 350);
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(load);
|
|
||||||
|
|
||||||
// --- mapping charts: usa SIEMPRE el rango ---
|
|
||||||
function mapToCharts(api, currentMode) {
|
|
||||||
const list = Array.isArray(api?.movements_by_procedure)
|
|
||||||
? api.movements_by_procedure
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const labels = list.map((it) => (it.name || "").toUpperCase());
|
|
||||||
|
|
||||||
// Pie: total (opened + closed). Si tu API devuelve valores por rango, puedes sustituir aquí.
|
|
||||||
const pieValues = list.map((it) => (it.opened || 0) + (it.closed || 0));
|
|
||||||
pieChartData.value = {
|
|
||||||
labels,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: "Recaudados",
|
|
||||||
data: pieValues,
|
|
||||||
backgroundColor: labels.map(
|
|
||||||
(_, i) => `hsl(${(i * 360) / Math.max(1, labels.length)}, 70%, 50%)`
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Bars: dataset según modo
|
|
||||||
let barLabel = "";
|
|
||||||
let barValues = [];
|
|
||||||
|
|
||||||
if (currentMode === "día") {
|
|
||||||
barLabel = "Solicitados Hoy";
|
|
||||||
barValues = list.map(
|
|
||||||
(it) => it.procedures_opened_today ?? it.opened_today ?? 0
|
|
||||||
);
|
|
||||||
} else if (currentMode === "semana") {
|
|
||||||
barLabel = "Solicitados Semana";
|
|
||||||
barValues = list.map((it) => it.total ?? 0);
|
|
||||||
} else {
|
|
||||||
barLabel = "Solicitados en Rango";
|
|
||||||
barValues = list.map((it) => {
|
|
||||||
if (typeof it.range_total === "number") return it.range_total;
|
|
||||||
return (it.opened || 0) + (it.closed || 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
barChartData.value = {
|
|
||||||
labels,
|
|
||||||
datasets: [barChartOptions.makeDataset(barLabel, barValues)],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI helpers
|
|
||||||
const toggleBars = () => (showBars.value = !showBars.value);
|
|
||||||
const togglePie = () => (showPie.value = !showPie.value);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<header class="relative">
|
|
||||||
<div class="bg-gradient-to-r from-gray-100 to-gray-50 py-3 shadow-sm">
|
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-bold text-gray-800 tracking-wide"
|
|
||||||
>COMALCALCO.GOB.MX</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
#621132 0%,
|
|
||||||
#7d1a42 50%,
|
|
||||||
#621132 100%
|
|
||||||
);
|
|
||||||
"
|
|
||||||
class="shadow-xl relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<!-- Patrón decorativo de fondo -->
|
|
||||||
<div class="absolute inset-0 opacity-10">
|
|
||||||
<div
|
|
||||||
class="absolute top-0 left-0 w-full h-full"
|
|
||||||
style="
|
|
||||||
background-image: repeating-linear-gradient(
|
|
||||||
45deg,
|
|
||||||
transparent,
|
|
||||||
transparent 35px,
|
|
||||||
rgba(255, 255, 255, 0.1) 35px,
|
|
||||||
rgba(255, 255, 255, 0.1) 70px
|
|
||||||
);
|
|
||||||
"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 relative">
|
|
||||||
<div class="flex items-center justify-between h-32">
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<img
|
|
||||||
src="https://apoyos.comalcalco.gob.mx/images/logo_blanco.png"
|
|
||||||
alt="Logo Comalcalco"
|
|
||||||
class="h-20 w-auto object-contain filter drop-shadow-lg transition-transform hover:scale-105"
|
|
||||||
/>
|
|
||||||
<div class="hidden md:block">
|
|
||||||
<h1 class="text-2xl font-bold text-white">
|
|
||||||
Reporte Especial de Trámites
|
|
||||||
</h1>
|
|
||||||
<p class="text-blue-100 text-sm">
|
|
||||||
Sistema de seguimiento de trámites
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Loading Overlay -->
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="fixed inset-0 bg-white/95 backdrop-blur-sm flex items-center justify-center z-50 transition-all duration-300"
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="relative">
|
|
||||||
<div
|
|
||||||
class="animate-spin rounded-full h-16 w-16 border-4 border-blue-200 border-t-blue-600 mx-auto"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<p class="mt-6 text-gray-700 font-medium">Cargando datos...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<div class="min-h-screen bg-gray-50 py-8">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="flex px-4 sm:px-6 lg:px-8 mb-4 items-center gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">
|
|
||||||
Reporte Especial de Trámites
|
|
||||||
</h1>
|
|
||||||
<p class="text-gray-600">Resumen por unidad administrativa</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- <div class="ml-auto flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
@click="toggleBars"
|
|
||||||
class="bg-primary text-white px-4 py-2 rounded"
|
|
||||||
>
|
|
||||||
Gráfica de Barras
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="togglePie"
|
|
||||||
class="bg-primary text-white px-4 py-2 rounded"
|
|
||||||
>
|
|
||||||
Más recaudados
|
|
||||||
</button>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Estadísticas del día -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
|
||||||
<div
|
|
||||||
class="bg-white rounded-lg shadow-sm border border-gray-200 p-6"
|
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-500">
|
|
||||||
Trámites Abiertos Hoy
|
|
||||||
</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(
|
|
||||||
data.procedures_opened_today
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="bg-white rounded-lg shadow-sm border border-gray-200 p-6"
|
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-500">
|
|
||||||
Con Movimientos Hoy
|
|
||||||
</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(
|
|
||||||
data.procedures_with_movements_today
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabla de trámites por administración -->
|
|
||||||
<div
|
|
||||||
class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden w-full"
|
|
||||||
>
|
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900">
|
|
||||||
Trámites por Unidad Administrativa
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto w-full">
|
|
||||||
<table class="w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Unidad Administrativa
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Abiertos
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Movimientos Hoy
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Cerrados
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Total
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
|
||||||
<tr
|
|
||||||
v-for="administration in data.procedures_by_administration ||
|
|
||||||
[]"
|
|
||||||
:key="administration.id"
|
|
||||||
class="hover:bg-gray-50 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm font-medium text-gray-900">
|
|
||||||
{{ administration.name }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(administration.opened)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(
|
|
||||||
administration.movement_today
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(administration.closed)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm font-semibold text-gray-900">
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(
|
|
||||||
administration.opened + administration.closed
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
|
||||||
v-if="
|
|
||||||
!(
|
|
||||||
data.procedures_by_administration &&
|
|
||||||
data.procedures_by_administration.length
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<td colspan="5" class="px-6 py-8 text-center">
|
|
||||||
<div class="text-gray-500">No hay datos disponibles</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabla de trámites por procedimiento -->
|
|
||||||
<div
|
|
||||||
class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden w-full mt-8"
|
|
||||||
>
|
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900">
|
|
||||||
Trámites por Procedimiento
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto w-full">
|
|
||||||
<table class="w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Procedimiento
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Abiertos
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Movimientos Hoy
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Cerrados
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Total
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
|
||||||
<tr
|
|
||||||
v-for="procedure in data.movements_by_procedure || []"
|
|
||||||
:key="procedure.id"
|
|
||||||
class="hover:bg-gray-50 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm font-medium text-gray-900">
|
|
||||||
{{ procedure.name }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
|
||||||
>
|
|
||||||
{{ new Intl.NumberFormat().format(procedure.opened) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(
|
|
||||||
procedure.movement_today
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
|
||||||
>
|
|
||||||
{{ new Intl.NumberFormat().format(procedure.closed) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm font-semibold text-gray-900">
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(
|
|
||||||
procedure.opened + procedure.closed
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
|
||||||
v-if="
|
|
||||||
!(
|
|
||||||
data.movements_by_procedure &&
|
|
||||||
data.movements_by_procedure.length
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<td colspan="5" class="px-6 py-8 text-center">
|
|
||||||
<div class="text-gray-500">No hay datos disponibles</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bars -->
|
|
||||||
<div
|
|
||||||
v-if="showBars"
|
|
||||||
class="max-w-7xl mx-auto mt-12 px-4 sm:px-6 lg:px-8 mb-8 bg-white rounded-lg shadow p-6"
|
|
||||||
>
|
|
||||||
<!-- DateRange mejorado -->
|
|
||||||
<DateRange v-model="dateRange" :presets="true" class="mb-12" />
|
|
||||||
<Bars :chartData="barChartData" :chartOptions="barChartOptions" />
|
|
||||||
<p class="text-sm text-gray-500 mt-2">Modo actual: {{ mode }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pie -->
|
|
||||||
<div
|
|
||||||
v-if="showPie"
|
|
||||||
class="max-w-7xl mx-auto mt-8 px-4 sm:px-6 lg:px-8 bg-white rounded-lg shadow p-6"
|
|
||||||
>
|
|
||||||
<Pie :chartData="pieChartData" :chartOptions="pieChartOptions" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 text-center">
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
Reporte generado el {{ new Date().toLocaleString("es-MX") }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,20 +1,35 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Admin\UserController;
|
use App\Http\Controllers\Admin\UserController;
|
||||||
|
use App\Http\Controllers\Dashboard\AtencionController;
|
||||||
use App\Http\Controllers\Dashboard\HistoryLogController;
|
use App\Http\Controllers\Dashboard\HistoryLogController;
|
||||||
use App\Http\Controllers\Dashboard\IndexController;
|
use App\Http\Controllers\Dashboard\IndexController;
|
||||||
use App\Http\Controllers\Dashboard\NotificationController;
|
use App\Http\Controllers\Dashboard\NotificationController;
|
||||||
use App\Http\Controllers\Developer\RoleController;
|
use App\Http\Controllers\Developer\RoleController;
|
||||||
use App\Http\Controllers\Example\IndexController as ExampleIndexController;
|
use App\Http\Controllers\Example\IndexController as ExampleIndexController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rutas generales/publicas
|
* Rutas generales/publicas
|
||||||
*
|
*
|
||||||
* Rutas accesibles por todos los usuarios y no usuarios
|
* Rutas accesibles por todos los usuarios y no usuarios
|
||||||
*/
|
*/
|
||||||
Route::redirect('/', '/login');
|
Route::redirect('/', '/tramites');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rutas públicas de reportes
|
||||||
|
*/
|
||||||
|
Route::prefix('/')->name('app.')->middleware([
|
||||||
|
'auth:sanctum',
|
||||||
|
'verified',
|
||||||
|
config('jetstream.auth_session')
|
||||||
|
])->group(function () {
|
||||||
|
Route::inertia('/tramites', 'App/Tramites')->name('tramites');
|
||||||
|
Route::inertia('/obras', 'App/Obras')->name('obras');
|
||||||
|
Route::inertia('/atencion', 'App/AtencionCiudadana')->name('atencion');
|
||||||
|
Route::get('/api/support-options', [AtencionController::class, 'getSupportOptions']);
|
||||||
|
Route::get('/api/export-excel', [AtencionController::class, 'exportExcel']);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rutas del Dashboard
|
* Rutas del Dashboard
|
||||||
@ -30,10 +45,6 @@
|
|||||||
Route::inertia('/changelogs', 'Dashboard/Changelogs')->name('changelogs');
|
Route::inertia('/changelogs', 'Dashboard/Changelogs')->name('changelogs');
|
||||||
Route::inertia('/help', 'Dashboard/Help')->name('help');
|
Route::inertia('/help', 'Dashboard/Help')->name('help');
|
||||||
|
|
||||||
Route::inertia('/api-tramite', 'Dashboard/Tramites')->name('api-tramite');
|
|
||||||
Route::inertia('/api-obra', 'Dashboard/Obras')->name('api-obra');
|
|
||||||
Route::inertia('/api-atencion', 'Dashboard/AtencionCiudadana')->name('api-atencion');
|
|
||||||
|
|
||||||
# Log de Acciones
|
# Log de Acciones
|
||||||
Route::resource('histories', HistoryLogController::class)->only([
|
Route::resource('histories', HistoryLogController::class)->only([
|
||||||
'index',
|
'index',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user