495 lines
17 KiB
Vue
495 lines
17 KiB
Vue
<script setup>
|
|
import { ref, computed, watch, onMounted } from "vue";
|
|
|
|
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 abortController = null;
|
|
let debounceId = null;
|
|
|
|
const fetchReport = async ({ start, end }) => {
|
|
if (abortController) abortController.abort();
|
|
abortController = new AbortController();
|
|
const qs = new URLSearchParams();
|
|
if (start) qs.set("start", start);
|
|
if (end) qs.set("end", end);
|
|
|
|
const res = await fetch(
|
|
`/dashboard/api/proxy-reporte-especial?${qs.toString()}`,
|
|
{
|
|
signal: abortController.signal,
|
|
}
|
|
);
|
|
if (!res.ok) throw new Error("Error de red");
|
|
return res.json();
|
|
};
|
|
|
|
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>
|
|
<div v-if="loading">Cargando...</div>
|
|
<div v-else-if="error">Error: {{ error }}</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>
|