582 lines
21 KiB
Vue
582 lines
21 KiB
Vue
<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 Footer from "@/Components/Dashboard/Footer.vue";
|
|
import AppLayout from "@/Layouts/AppLayout.vue";
|
|
|
|
const data = ref({
|
|
procedures_opened_today: 0,
|
|
procedures_with_movements_today: 0,
|
|
procedures_by_administration: [],
|
|
movements_by_procedure: [],
|
|
});
|
|
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 });
|
|
|
|
// 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" },
|
|
});
|
|
|
|
return res.data;
|
|
};
|
|
|
|
const load = async () => {
|
|
error.value = null;
|
|
try {
|
|
const payload = await fetchReport({
|
|
start: dateRange.value.start,
|
|
end: dateRange.value.end,
|
|
});
|
|
data.value = {
|
|
procedures_opened_today: payload?.procedures_opened_today || 0,
|
|
procedures_with_movements_today: payload?.procedures_with_movements_today || 0,
|
|
procedures_by_administration: Array.isArray(payload?.procedures_by_administration)
|
|
? payload.procedures_by_administration : [],
|
|
movements_by_procedure: Array.isArray(payload?.movements_by_procedure)
|
|
? payload.movements_by_procedure : [],
|
|
};
|
|
mapToCharts(data.value, mode.value);
|
|
} catch (e) {
|
|
if (!axios.isCancel(e)) {
|
|
console.error(e);
|
|
error.value = e.message || "Error desconocido";
|
|
mapToCharts(data.value, mode.value);
|
|
}
|
|
}
|
|
};
|
|
|
|
watch(
|
|
dateRange,
|
|
() => {
|
|
clearTimeout(debounceId);
|
|
debounceId = setTimeout(load, 350);
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
onMounted(() => {
|
|
mapToCharts(data.value, mode.value);
|
|
load();
|
|
});
|
|
|
|
// --- mapping charts: usa SIEMPRE el rango ---
|
|
function mapToCharts(api, currentMode) {
|
|
const proceduresList = Array.isArray(api?.movements_by_procedure)
|
|
? api.movements_by_procedure
|
|
: [];
|
|
|
|
// Filtrar procedimientos con datos significativos y ordenar por total descendente
|
|
const filteredProcedures = proceduresList
|
|
.filter((proc) => (proc.opened || 0) + (proc.closed || 0) > 0)
|
|
.sort((a, b) => {
|
|
const totalA = (a.opened || 0) + (a.closed || 0);
|
|
const totalB = (b.opened || 0) + (b.closed || 0);
|
|
return totalB - totalA;
|
|
});
|
|
|
|
const labels = filteredProcedures.map((proc) =>
|
|
(proc.name || "").toUpperCase().substring(0, 30) +
|
|
((proc.name || "").length > 30 ? "..." : "")
|
|
);
|
|
|
|
// Pie: Distribución de trámites por procedimiento
|
|
const pieValues = filteredProcedures.map((proc) => (proc.opened || 0) + (proc.closed || 0));
|
|
pieChartData.value = {
|
|
labels: labels.length > 0 ? labels : ["Sin datos"],
|
|
datasets: [
|
|
{
|
|
label: "Distribución de Trámites por Procedimiento",
|
|
data: pieValues.length > 0 ? pieValues : [0],
|
|
backgroundColor: labels.length > 0
|
|
? labels.map((_, i) => `hsl(${(i * 360) / Math.max(1, labels.length)}, 70%, 50%)`)
|
|
: ["#e5e7eb"],
|
|
},
|
|
],
|
|
};
|
|
|
|
// Bars: Trámites más solicitados según el modo
|
|
let barLabel = "";
|
|
let barValues = [];
|
|
|
|
if (currentMode === "día") {
|
|
barLabel = "Trámites Abiertos Hoy";
|
|
barValues = filteredProcedures.map((proc) => proc.opened_today || 0);
|
|
} else if (currentMode === "semana") {
|
|
barLabel = "Trámites de la Semana";
|
|
barValues = filteredProcedures.map((proc) => proc.week_total || (proc.opened || 0));
|
|
} else {
|
|
barLabel = "Trámites en el Rango Seleccionado";
|
|
barValues = filteredProcedures.map((proc) => {
|
|
// Priorizar datos específicos del rango si están disponibles
|
|
if (typeof proc.range_total === "number") return proc.range_total;
|
|
return (proc.opened || 0) + (proc.closed || 0);
|
|
});
|
|
}
|
|
|
|
barChartData.value = {
|
|
labels: labels.length > 0 ? labels : ["Sin datos"],
|
|
datasets: [
|
|
{
|
|
...barChartOptions.makeDataset(barLabel, barValues.length > 0 ? barValues : [0]),
|
|
backgroundColor: "rgba(99, 102, 241, 0.2)",
|
|
borderColor: "rgba(99, 102, 241, 1)",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
</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>
|
|
<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>
|
|
</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 uppercase">
|
|
<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
|
|
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
|
|
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>
|
|
<Footer />
|
|
</div>
|
|
</AppLayout>
|
|
</template>
|