2025-08-22 15:43:37 -06:00

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>