407 lines
16 KiB
Vue
407 lines
16 KiB
Vue
<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>
|