Juan Felipe Zapata Moreno 8792c5b283 Se creo el archivo de obras
2025-08-14 16:32:20 -06:00

500 lines
17 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";
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 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' }
});
// axios lanza en error si status >= 400, aquí devolvemos data directamente
return res.data;
};
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>