992 lines
33 KiB
Vue
992 lines
33 KiB
Vue
<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" },
|
|
];
|
|
|
|
//Función para cargar datos de gráficas
|
|
const loadChartsData = async () => {
|
|
const cacheKey = getChartsCacheKey();
|
|
|
|
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: 20000, // Aumentar timeout
|
|
headers: { "X-Requested-With": "XMLHttpRequest" },
|
|
});
|
|
|
|
const payload = res.data || {};
|
|
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
|
|
}
|
|
};
|
|
|
|
// 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();
|
|
|
|
if (cache.has(cacheKey)) {
|
|
const cachedData = cache.get(cacheKey);
|
|
applyDataToComponents(cachedData, false); // Sin gráficas
|
|
return;
|
|
}
|
|
|
|
loading.value = true;
|
|
error.value = null;
|
|
|
|
if (cancelTokenSource) {
|
|
cancelTokenSource.cancel("cancel");
|
|
}
|
|
cancelTokenSource = axios.CancelToken.source();
|
|
|
|
try {
|
|
const params = {
|
|
period: selectedPeriod.value,
|
|
};
|
|
|
|
const res = await axios.get("/api/reporte-atencion", {
|
|
params,
|
|
timeout: 20000, // Aumentar timeout
|
|
cancelToken: cancelTokenSource.token,
|
|
headers: { "X-Requested-With": "XMLHttpRequest" },
|
|
});
|
|
|
|
const payload = res.data || {};
|
|
cache.set(cacheKey, payload);
|
|
applyDataToComponents(payload, false); // Sin gráficas para evitar conflicto
|
|
} 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;
|
|
};
|
|
|
|
const toggleGroup = (typeName) => {
|
|
if (expandedGroups.value.has(typeName)) {
|
|
expandedGroups.value.delete(typeName);
|
|
} else {
|
|
expandedGroups.value.add(typeName);
|
|
}
|
|
};
|
|
|
|
// Watchers para recargar datos cuando cambien los filtros
|
|
watch([selectedDepartment, selectedType, selectedPeriod], () => {
|
|
clearTimeout(debounceId);
|
|
debounceId = setTimeout(load, 100);
|
|
});
|
|
|
|
onMounted(() => {
|
|
// Cargar datos iniciales
|
|
load();
|
|
// Cargar gráficas iniciales
|
|
loadChartsData();
|
|
|
|
// Limpiar caches
|
|
setInterval(() => {
|
|
cache.clear();
|
|
chartsCache.clear();
|
|
}, 600000); // 10 minutos
|
|
});
|
|
</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>
|