Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2afe92991 | |||
| edc56138d5 | |||
| 97f2a8851d | |||
| 1bdde40409 | |||
| 286075da19 | |||
| 3dfc092e98 | |||
| c2e392f3d8 | |||
| 35245a9195 | |||
| c4f68aa1e0 |
@@ -0,0 +1,85 @@
|
|||||||
|
name: Despliegue Automatizado Universal
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
get-context:
|
||||||
|
runs-on: host
|
||||||
|
outputs:
|
||||||
|
node_version: ${{ steps.setup.outputs.version }}
|
||||||
|
target_app: ${{ steps.setup.outputs.app }}
|
||||||
|
steps:
|
||||||
|
- name: Resolver Aplicación desde apps.conf
|
||||||
|
id: setup
|
||||||
|
run: |
|
||||||
|
# 1. Obtener el nombre del repositorio actual en Gitea (ej: front-Censo)
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
|
||||||
|
# 2. Determinar si buscamos un entorno -dev (develop) o producción (master)
|
||||||
|
if [ "${{ github.ref_name }}" = "develop" ]; then
|
||||||
|
SUFFIX="-dev"
|
||||||
|
else
|
||||||
|
SUFFIX=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Buscando qué sección de apps.conf tiene REPO=$REPO_NAME y termina en '$SUFFIX'..."
|
||||||
|
|
||||||
|
# 3. Buscar en /etc/apps.conf el bloque que contenga REPO=nombre_del_repo
|
||||||
|
TARGET_APP=$(awk -v repo="REPO=$REPO_NAME" -v suf="$SUFFIX" '
|
||||||
|
/^\[/ { gsub(/[\[\]]/, "", $0); current_box=$0; next }
|
||||||
|
$0 == repo {
|
||||||
|
if ((suf == "-dev" && current_box ~ /-dev$/) || (suf == "" && current_box !~ /-dev$/)) {
|
||||||
|
print current_box;
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' /etc/apps.conf)
|
||||||
|
|
||||||
|
if [ -z "$TARGET_APP" ]; then
|
||||||
|
echo "ERROR: No se encontró ninguna sección en /etc/apps.conf para el repositorio $REPO_NAME con el sufijo correcto."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Leer la versión de Node del bloque encontrado
|
||||||
|
VERSION=$(awk -v app="$TARGET_APP" '
|
||||||
|
$0=="["app"]" { found=1; next }
|
||||||
|
/^\[/ && found { exit }
|
||||||
|
found && /^NODE=/ { split($0, a, "="); print a[2]; exit }
|
||||||
|
' /etc/apps.conf)
|
||||||
|
|
||||||
|
MAJOR_VERSION=$(echo "$VERSION" | tr -d '[:space:]' | grep -oE '[0-9]+' | head -n1)
|
||||||
|
|
||||||
|
echo "-> ID de Aplicación encontrado: $TARGET_APP"
|
||||||
|
echo "-> Versión Mayor de Node resuelta: $MAJOR_VERSION"
|
||||||
|
|
||||||
|
echo "app=${TARGET_APP}" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=${MAJOR_VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: get-context
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configurar Node.js dinámicamente
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ needs.get-context.outputs.node_version }}
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: [get-context, build]
|
||||||
|
runs-on: host
|
||||||
|
steps:
|
||||||
|
- name: Ejecutar Despliegue en Servidor Físico
|
||||||
|
run: |
|
||||||
|
TARGET_APP="${{ needs.get-context.outputs.target_app }}"
|
||||||
|
BRANCH_NAME="${{ github.ref_name }}"
|
||||||
|
|
||||||
|
sudo -u deploy /home/deploy/scripts/deploy-front.sh "$TARGET_APP" "$BRANCH_NAME"
|
||||||
@@ -48,6 +48,8 @@ export default function Inscripciones() {
|
|||||||
const [anio, setAnio] = useState<number | null>(null);
|
const [anio, setAnio] = useState<number | null>(null);
|
||||||
|
|
||||||
const [tablaData, setTablaData] = useState<TablaRow[]>([]);
|
const [tablaData, setTablaData] = useState<TablaRow[]>([]);
|
||||||
|
const [periodosUnicos, setPeriodosUnicos] = useState<string[]>([]);
|
||||||
|
const [tablaResumen, setTablaResumen] = useState<any[]>([]);
|
||||||
const [rawData, setRawData] = useState<ApiResponse[]>([]);
|
const [rawData, setRawData] = useState<ApiResponse[]>([]);
|
||||||
|
|
||||||
const token = Cookies.get("token");
|
const token = Cookies.get("token");
|
||||||
@@ -139,50 +141,80 @@ export default function Inscripciones() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setTablaData(Object.values(agrupado));
|
setTablaData(Object.values(agrupado));
|
||||||
|
|
||||||
|
const periodosUnicosList = Array.from(new Set(data.map((t) => t.semestre)));
|
||||||
|
setPeriodosUnicos(periodosUnicosList);
|
||||||
|
|
||||||
|
const carrerasUnicas = Array.from(new Set(data.map((t) => t.carrera)));
|
||||||
|
|
||||||
|
const resumen = carrerasUnicas.map((carrera) => {
|
||||||
|
const fila: any = { Carrera: carrera };
|
||||||
|
let totalFila = 0;
|
||||||
|
periodosUnicosList.forEach((periodo) => {
|
||||||
|
const items = data.filter((t) => t.carrera === carrera && t.semestre === periodo);
|
||||||
|
const totalPeriodo = items.reduce((acc, item) => acc + Number(item.total), 0);
|
||||||
|
fila[periodo] = totalPeriodo;
|
||||||
|
totalFila += totalPeriodo;
|
||||||
|
});
|
||||||
|
fila["Total"] = totalFila;
|
||||||
|
return fila;
|
||||||
|
});
|
||||||
|
|
||||||
|
setTablaResumen(resumen);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error al obtener inscritos", error);
|
console.error("Error al obtener inscritos", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportarExcel = () => {
|
const exportarExcel = () => {
|
||||||
if (!rawData.length) return;
|
if (!rawData.length || !tablaResumen.length) return;
|
||||||
|
|
||||||
const periodosUnicos = Array.from(
|
// HOJA 1: Inscritos (resumen)
|
||||||
new Set(rawData.map((t) => t.semestre))
|
const worksheet1 = XLSX.utils.json_to_sheet(tablaResumen);
|
||||||
);
|
|
||||||
|
|
||||||
const carrerasUnicas = Array.from(
|
const carrerasUnicas = Array.from(new Set(rawData.map((t) => t.carrera)));
|
||||||
new Set(rawData.map((t) => t.carrera))
|
|
||||||
);
|
|
||||||
|
|
||||||
const dataExcel = carrerasUnicas.map((carrera) => {
|
// HOJA 2: genero
|
||||||
|
const dataGenero = carrerasUnicas.map((carrera) => {
|
||||||
const fila: any = { Carrera: carrera };
|
const fila: any = { Carrera: carrera };
|
||||||
|
|
||||||
let totalFila = 0;
|
|
||||||
|
|
||||||
periodosUnicos.forEach((periodo) => {
|
periodosUnicos.forEach((periodo) => {
|
||||||
const items = rawData.filter(
|
const items = rawData.filter((t) => t.carrera === carrera && t.semestre === periodo);
|
||||||
(t) => t.carrera === carrera && t.semestre === periodo
|
const masc = items.reduce((acc, item) => acc + Number(item.masculino), 0);
|
||||||
);
|
const fem = items.reduce((acc, item) => acc + Number(item.femenino), 0);
|
||||||
|
fila[`Masculino - ${periodo}`] = masc;
|
||||||
const totalPeriodo = items.reduce(
|
fila[`Femenino - ${periodo}`] = fem;
|
||||||
(acc, item) => acc + Number(item.total),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
fila[periodo] = totalPeriodo;
|
|
||||||
totalFila += totalPeriodo;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
fila["Total"] = totalFila;
|
|
||||||
|
|
||||||
return fila;
|
return fila;
|
||||||
});
|
});
|
||||||
|
const worksheet2 = XLSX.utils.json_to_sheet(dataGenero);
|
||||||
|
|
||||||
|
// HOJA 3: areas
|
||||||
|
const dataAreas = carrerasUnicas.map((carrera) => {
|
||||||
|
const fila: any = { Carrera: carrera };
|
||||||
|
periodosUnicos.forEach((periodo) => {
|
||||||
|
const items = rawData.filter((t) => t.carrera === carrera && t.semestre === periodo);
|
||||||
|
let windows = 0, mac = 0, linux = 0, profes = 0;
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.profesor === "WINDOWS") windows += Number(item.total);
|
||||||
|
if (item.profesor === "MACINTOSH") mac += Number(item.total);
|
||||||
|
if (item.profesor === "LINUX") linux += Number(item.total);
|
||||||
|
if (item.profesor === "PROFESORES") profes += Number(item.total);
|
||||||
|
});
|
||||||
|
|
||||||
|
fila[`WINDOWS - ${periodo}`] = windows;
|
||||||
|
fila[`MACINTOSH - ${periodo}`] = mac;
|
||||||
|
fila[`LINUX - ${periodo}`] = linux;
|
||||||
|
fila[`PROFESORES - ${periodo}`] = profes;
|
||||||
|
});
|
||||||
|
return fila;
|
||||||
|
});
|
||||||
|
const worksheet3 = XLSX.utils.json_to_sheet(dataAreas);
|
||||||
|
|
||||||
const worksheet = XLSX.utils.json_to_sheet(dataExcel);
|
|
||||||
const workbook = XLSX.utils.book_new();
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet1, "Inscritos");
|
||||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Inscritos");
|
XLSX.utils.book_append_sheet(workbook, worksheet2, "genero");
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet3, "areas");
|
||||||
|
|
||||||
const excelBuffer = XLSX.write(workbook, {
|
const excelBuffer = XLSX.write(workbook, {
|
||||||
bookType: "xlsx",
|
bookType: "xlsx",
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { envConfig } from "@/app/lib/config";
|
|
||||||
import axios from "axios";
|
|
||||||
import Cookies from "js-cookie";
|
|
||||||
import * as XLSX from "xlsx";
|
|
||||||
import { saveAs } from "file-saver";
|
|
||||||
|
|
||||||
import {
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Line,
|
|
||||||
LineChart,
|
|
||||||
} from "recharts";
|
|
||||||
|
|
||||||
interface TotalServicioPeriodo {
|
|
||||||
nombre_servicio: string;
|
|
||||||
periodo: string;
|
|
||||||
monto_total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Servicio {
|
|
||||||
id_servicio: number;
|
|
||||||
servicio: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Periodo {
|
|
||||||
id_periodo: number;
|
|
||||||
semestre: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReporteTotalesPage() {
|
|
||||||
const [totales, setTotales] = useState<TotalServicioPeriodo[]>([]);
|
|
||||||
const [servicios, setServicios] = useState<Servicio[]>([]);
|
|
||||||
const [periodos, setPeriodos] = useState<Periodo[]>([]);
|
|
||||||
const [cargando, setCargando] = useState(true);
|
|
||||||
const [periodoInicio, setPeriodoInicio] = useState<string>("");
|
|
||||||
const [periodoFin, setPeriodoFin] = useState<string>("");
|
|
||||||
const [servicioId, setServicioId] = useState<string>("");
|
|
||||||
|
|
||||||
const token = Cookies.get("token");
|
|
||||||
const headers = { Authorization: `Bearer ${token}` };
|
|
||||||
|
|
||||||
// TOOLTIP ORDENADO
|
|
||||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
const sorted = [...payload]
|
|
||||||
.filter((item) => item.value > 0)
|
|
||||||
.sort((a, b) => b.value - a.value);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ background: "white", padding: "10px", border: "1px solid #ccc", borderRadius: "8px" }}>
|
|
||||||
<p style={{ fontWeight: "bold" }}>Periodo: {label}</p>
|
|
||||||
{sorted.map((entry: any, index: number) => (
|
|
||||||
<div key={index} style={{ color: entry.color }}>
|
|
||||||
{entry.name}: ${entry.value.toFixed(2)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// EXPORTAR EXCEL PIVOT
|
|
||||||
const exportarExcelPivot = () => {
|
|
||||||
if (!totales.length) return;
|
|
||||||
|
|
||||||
const periodosUnicos = Array.from(new Set(totales.map((t) => t.periodo))).sort();
|
|
||||||
const serviciosUnicos = Array.from(new Set(totales.map((t) => t.nombre_servicio)));
|
|
||||||
|
|
||||||
const dataExcel = serviciosUnicos.map((servicio) => {
|
|
||||||
const fila: any = { Servicio: servicio };
|
|
||||||
|
|
||||||
periodosUnicos.forEach((periodo) => {
|
|
||||||
const item = totales.find(
|
|
||||||
(t) => t.nombre_servicio === servicio && t.periodo === periodo
|
|
||||||
);
|
|
||||||
fila[periodo] = item ? item.monto_total : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// total por fila
|
|
||||||
fila["Total"] = periodosUnicos.reduce(
|
|
||||||
(acc, periodo) => acc + (fila[periodo] || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
return fila;
|
|
||||||
});
|
|
||||||
|
|
||||||
const worksheet = XLSX.utils.json_to_sheet(dataExcel);
|
|
||||||
|
|
||||||
worksheet["!cols"] = [
|
|
||||||
{ wch: 30 },
|
|
||||||
...periodosUnicos.map(() => ({ wch: 15 })),
|
|
||||||
{ wch: 15 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const workbook = XLSX.utils.book_new();
|
|
||||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Pivot");
|
|
||||||
|
|
||||||
const excelBuffer = XLSX.write(workbook, {
|
|
||||||
bookType: "xlsx",
|
|
||||||
type: "array",
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = new Blob([excelBuffer], {
|
|
||||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
});
|
|
||||||
|
|
||||||
saveAs(data, "reporte_periodo.xlsx");
|
|
||||||
};
|
|
||||||
|
|
||||||
// FETCH
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
const [serviciosRes, periodosRes] = await Promise.all([
|
|
||||||
axios.get(`${envConfig.apiUrl}/servicio`, { headers }),
|
|
||||||
axios.get(`${envConfig.apiUrl}/periodo`, { headers }),
|
|
||||||
]);
|
|
||||||
setServicios(serviciosRes.data);
|
|
||||||
setPeriodos(periodosRes.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error al cargar datos", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const obtenerTotales = async () => {
|
|
||||||
setCargando(true);
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (periodoInicio) params.append("periodoInicio", periodoInicio);
|
|
||||||
if (periodoFin) params.append("periodoFin", periodoFin);
|
|
||||||
if (servicioId) params.append("servicioId", servicioId);
|
|
||||||
|
|
||||||
const url = `${envConfig.apiUrl}/detalle-servicio/totales-por-periodo?${params.toString()}`;
|
|
||||||
const res = await axios.get(url, { headers });
|
|
||||||
setTotales(res.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error al obtener totales", error);
|
|
||||||
} finally {
|
|
||||||
setCargando(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const aplicarFiltros = () => {
|
|
||||||
obtenerTotales();
|
|
||||||
};
|
|
||||||
|
|
||||||
// DATA PARA GRAFICA
|
|
||||||
const transformDataForChart = () => {
|
|
||||||
const periodosUnicos = Array.from(new Set(totales.map((t) => t.periodo))).sort();
|
|
||||||
const serviciosUnicos = Array.from(new Set(totales.map((t) => t.nombre_servicio)));
|
|
||||||
|
|
||||||
const data = periodosUnicos.map((periodo) => {
|
|
||||||
const row: any = { periodo };
|
|
||||||
serviciosUnicos.forEach((servicio) => {
|
|
||||||
const item = totales.find(
|
|
||||||
(t) => t.periodo === periodo && t.nombre_servicio === servicio
|
|
||||||
);
|
|
||||||
row[servicio] = item ? item.monto_total : 0;
|
|
||||||
});
|
|
||||||
return row;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data, serviciosUnicos };
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data: chartData, serviciosUnicos } = transformDataForChart();
|
|
||||||
|
|
||||||
const colors = ["black", "red", "blue", "orange", "purple", "green"];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 style={{ color: "rgb(3, 1, 72)", marginBottom: "20px" }}>
|
|
||||||
REPORTE DE TOTALES POR SERVICIO Y PERIODO
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "20px", display: "flex", gap: "10px", flexWrap: "wrap" }}>
|
|
||||||
<select value={periodoInicio} onChange={(e) => setPeriodoInicio(e.target.value)}>
|
|
||||||
<option value="">Periodo inicio</option>
|
|
||||||
{periodos.map((p) => (
|
|
||||||
<option key={p.id_periodo} value={p.id_periodo}>
|
|
||||||
{p.semestre}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select value={periodoFin} onChange={(e) => setPeriodoFin(e.target.value)}>
|
|
||||||
<option value="">Periodo fin</option>
|
|
||||||
{periodos.map((p) => (
|
|
||||||
<option key={p.id_periodo} value={p.id_periodo}>
|
|
||||||
{p.semestre}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select value={servicioId} onChange={(e) => setServicioId(e.target.value)}>
|
|
||||||
<option value="">Todos los servicios</option>
|
|
||||||
{servicios.map((s) => (
|
|
||||||
<option key={s.id_servicio} value={s.id_servicio}>
|
|
||||||
{s.servicio}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<button onClick={aplicarFiltros}>Filtrar</button>
|
|
||||||
<button onClick={exportarExcelPivot}>Exportar Excel</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{cargando ? (
|
|
||||||
<p>Cargando...</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: "flex", gap: "50px", flexWrap: "wrap" }}>
|
|
||||||
|
|
||||||
{/* TABLA */}
|
|
||||||
<div style={{ flex: 1, minWidth: "500px", maxHeight: "500px", overflow: "auto" }}>
|
|
||||||
<table style={{ width: "100%" }}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Servicio</th>
|
|
||||||
<th>Periodo</th>
|
|
||||||
<th>Monto</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{totales.map((item, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
<td>{item.nombre_servicio}</td>
|
|
||||||
<td>{item.periodo}</td>
|
|
||||||
<td>${item.monto_total.toFixed(2)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GRAFICA */}
|
|
||||||
<div style={{ flex: 1, minWidth: "700px", background: "white" }}>
|
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
|
||||||
<LineChart data={chartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="periodo" />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Legend />
|
|
||||||
|
|
||||||
{serviciosUnicos.map((servicio, i) => (
|
|
||||||
<Line
|
|
||||||
key={servicio}
|
|
||||||
dataKey={servicio}
|
|
||||||
stroke={colors[i % colors.length]}
|
|
||||||
type="monotone"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
|
import {
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
} from "recharts";
|
||||||
|
import PeriodoPicker from "@/app/Components/PeriodoPicker/periodoPicker";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
|
||||||
|
interface Periodo {
|
||||||
|
id_periodo: number;
|
||||||
|
semestre: string;
|
||||||
|
fecha_inicio_servicio: string;
|
||||||
|
fecha_fin_servicio: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function PorServicio() {
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [periodos, setPeriodos] = useState<Periodo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [serviciosSeleccionados, setServiciosSeleccionados] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [periodo1, setPeriodo1] = useState<Periodo | null>(null);
|
||||||
|
const [periodo2, setPeriodo2] = useState<Periodo | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!periodo1 || !periodo2) return;
|
||||||
|
|
||||||
|
const fetchServicios = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = Cookies.get("token");
|
||||||
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
|
// 🔥 1. traer TODOS los periodos en rango
|
||||||
|
const resPeriodos = await axios.get(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/periodo/range?p1=${periodo1.id_periodo}&p2=${periodo2.id_periodo}`,
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
const periodosRango = resPeriodos.data;
|
||||||
|
setPeriodos(periodosRango);
|
||||||
|
|
||||||
|
// 🔥 2. hacer requests por cada periodo
|
||||||
|
const resultados = await Promise.all(
|
||||||
|
periodosRango.map((p: Periodo) =>
|
||||||
|
axios.post(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/detalle-servicio/rango`,
|
||||||
|
{
|
||||||
|
desde: p.fecha_inicio_servicio,
|
||||||
|
hasta: p.fecha_fin_servicio,
|
||||||
|
},
|
||||||
|
{ headers }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🔥 3. merge dinámico
|
||||||
|
const map = new Map();
|
||||||
|
|
||||||
|
periodosRango.forEach((p: Periodo, index: number) => {
|
||||||
|
resultados[index].data.forEach((item: any) => {
|
||||||
|
if (!map.has(item.servicio)) {
|
||||||
|
map.set(item.servicio, {
|
||||||
|
servicio: item.servicio,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
map.get(item.servicio)[p.semestre] = Number(item.total);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalData = Array.from(map.values());
|
||||||
|
setData(finalData);
|
||||||
|
setServiciosSeleccionados(finalData.map((item: any) => item.servicio));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error comparando servicios", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchServicios();
|
||||||
|
}, [periodo1, periodo2]);
|
||||||
|
|
||||||
|
// 🔥 TRANSFORMAR DATA PARA GRAFICA
|
||||||
|
const transformDataForChart = () => {
|
||||||
|
if (!data.length || !periodos.length) return [];
|
||||||
|
|
||||||
|
return periodos.map((p) => {
|
||||||
|
const row: any = { periodo: p.semestre };
|
||||||
|
|
||||||
|
data.forEach((servicio) => {
|
||||||
|
row[servicio.servicio] = servicio[p.semestre] || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// EXPORTAR A EXCEL
|
||||||
|
const exportToExcel = () => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const exportData = data.map((row) => {
|
||||||
|
const newRow: any = { Servicio: row.servicio };
|
||||||
|
periodos.forEach((p) => {
|
||||||
|
newRow[p.semestre] = row[p.semestre] || 0;
|
||||||
|
});
|
||||||
|
return newRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(exportData);
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, "Ingresos");
|
||||||
|
|
||||||
|
XLSX.writeFile(workbook, "Reporte_Ingresos_Servicio.xlsx");
|
||||||
|
};
|
||||||
|
|
||||||
|
// FORMATO DE NUMEROS
|
||||||
|
const formatNumber = (value: number) => {
|
||||||
|
return value.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// TOOLTIP ORDENADO
|
||||||
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const sorted = [...payload]
|
||||||
|
.filter((item) => item.value > 0)
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "white",
|
||||||
|
padding: "10px",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ fontWeight: "bold" }}>
|
||||||
|
Periodo: {label}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{sorted.map((entry: any, index: number) => (
|
||||||
|
<div key={index} style={{ color: entry.color }}>
|
||||||
|
{entry.name}: ${formatNumber(entry.value)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const chartData = transformDataForChart();
|
||||||
|
|
||||||
|
const colors = ["black", "red", "blue", "orange", "purple", "green"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "5px", overflow: "auto" }}>
|
||||||
|
|
||||||
|
<PeriodoPicker
|
||||||
|
onChange={(p1, p2) => {
|
||||||
|
setPeriodo1(p1);
|
||||||
|
setPeriodo2(p2);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!periodo1 || !periodo2 ? (
|
||||||
|
<p>Seleccione un rango de periodos</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* CONTROLES SUPERIORES */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "20px",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
padding: "10px 0",
|
||||||
|
position: "absolute",
|
||||||
|
top: "34px",
|
||||||
|
right: "0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={exportToExcel}
|
||||||
|
disabled={data.length === 0}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
backgroundColor:
|
||||||
|
data.length === 0 ? "#ccc" : "#10b981",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor:
|
||||||
|
data.length === 0
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Excel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{data.map((servicio) => (
|
||||||
|
<label
|
||||||
|
key={servicio.servicio}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "5px",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontSize:"9px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={serviciosSeleccionados.includes(
|
||||||
|
servicio.servicio
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setServiciosSeleccionados((prev) => [
|
||||||
|
...prev,
|
||||||
|
servicio.servicio,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
setServiciosSeleccionados((prev) =>
|
||||||
|
prev.filter(
|
||||||
|
(s) =>
|
||||||
|
s !==
|
||||||
|
servicio.servicio
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{servicio.servicio}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TABLA Y GRÁFICA */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "30px",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
overflow: "auto"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* COLUMNA IZQUIERDA: TABLA */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: "1 1 300px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
overflow: "auto",
|
||||||
|
maxHeight: "400px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading && <p>Cargando...</p>}
|
||||||
|
|
||||||
|
<table style={{ width: "100%" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Servicio</th>
|
||||||
|
|
||||||
|
{periodos.map((p) => (
|
||||||
|
<th key={p.id_periodo}>
|
||||||
|
{p.semestre}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{!loading && data.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={
|
||||||
|
periodos.length + 1
|
||||||
|
}
|
||||||
|
>
|
||||||
|
No hay datos en este rango
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.map((row) => (
|
||||||
|
<tr key={row.servicio}>
|
||||||
|
<td>{row.servicio}</td>
|
||||||
|
|
||||||
|
{periodos.map((p) => (
|
||||||
|
<td
|
||||||
|
key={p.id_periodo}
|
||||||
|
>
|
||||||
|
$
|
||||||
|
{formatNumber(
|
||||||
|
row[
|
||||||
|
p.semestre
|
||||||
|
] || 0
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* COLUMNA DERECHA: GRÁFICA */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: "2 1 500px",
|
||||||
|
minWidth: "500px",
|
||||||
|
background: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<ResponsiveContainer
|
||||||
|
width="100%"
|
||||||
|
height={400}
|
||||||
|
>
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
|
||||||
|
<XAxis dataKey="periodo" />
|
||||||
|
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(value) =>
|
||||||
|
value.toLocaleString()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
content={<CustomTooltip />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Legend />
|
||||||
|
|
||||||
|
{data
|
||||||
|
.filter((servicio) =>
|
||||||
|
serviciosSeleccionados.includes(
|
||||||
|
servicio.servicio
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map((servicio, i) => (
|
||||||
|
<Line
|
||||||
|
key={
|
||||||
|
servicio.servicio
|
||||||
|
}
|
||||||
|
dataKey={
|
||||||
|
servicio.servicio
|
||||||
|
}
|
||||||
|
stroke={
|
||||||
|
colors[
|
||||||
|
i %
|
||||||
|
colors.length
|
||||||
|
]
|
||||||
|
}
|
||||||
|
type="monotone"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,17 +6,24 @@ import SearchDateBetween from "@/app/Components/SearchDateBetween/SearchDateBetw
|
|||||||
import PorServicios from "@/app/Components/Reportes/porServicio";
|
import PorServicios from "@/app/Components/Reportes/porServicio";
|
||||||
import PorRecibos from "@/app/Components/Reportes/porRecibo";
|
import PorRecibos from "@/app/Components/Reportes/porRecibo";
|
||||||
import MonthYearPicker from "@/app/Components/MonthYearPicker/MonthYearPicker";
|
import MonthYearPicker from "@/app/Components/MonthYearPicker/MonthYearPicker";
|
||||||
|
import PeriodoPicker from "@/app/Components/PeriodoPicker/periodoPicker";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import PorServicio from "../Reportes-graficas/page";
|
||||||
|
|
||||||
export default function ReportesClient({
|
export default function ReportesClient({
|
||||||
defaultKey,
|
defaultKey,
|
||||||
}: {
|
}: {
|
||||||
defaultKey?: string;
|
defaultKey?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [desde, setDesde] = useState<string | null>(null);
|
const [desde, setDesde] = useState<string | null>(null);
|
||||||
const [hasta, setHasta] = useState<string | null>(null);
|
const [hasta, setHasta] = useState<string | null>(null);
|
||||||
|
const [periodo1, setPeriodo1] = useState<any>(null);
|
||||||
|
const [periodo2, setPeriodo2] = useState<any>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="containerSection">
|
<section className="containerSection" style={{ overflow: "visible", }}>
|
||||||
<h2 className="title">REPORTES</h2>
|
<h2 className="title">REPORTES</h2>
|
||||||
|
|
||||||
<Toggle
|
<Toggle
|
||||||
@@ -40,20 +47,13 @@ export default function ReportesClient({
|
|||||||
{
|
{
|
||||||
key: "Por Servicio",
|
key: "Por Servicio",
|
||||||
label: "Por Servicio",
|
label: "Por Servicio",
|
||||||
content: (
|
content: null,
|
||||||
<>
|
}
|
||||||
<MonthYearPicker
|
|
||||||
onChange={(d, h) => {
|
|
||||||
setDesde(d);
|
|
||||||
setHasta(h);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PorServicios desde={desde} hasta={hasta} />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
{defaultKey == "Por Servicio" &&
|
||||||
|
<PorServicio/>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface ToggleOption {
|
|||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
content: ReactNode | (() => ReactNode);
|
content: ReactNode | (() => ReactNode);
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToggleProps {
|
interface ToggleProps {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { envConfig } from "@/app/lib/config";
|
||||||
|
interface Periodo {
|
||||||
|
id_periodo: number;
|
||||||
|
semestre: string;
|
||||||
|
fecha_inicio_servicio: string;
|
||||||
|
fecha_fin_servicio: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PeriodoPicker({
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
onChange: (p1: Periodo | null, p2: Periodo | null) => void;
|
||||||
|
}) {
|
||||||
|
const [periodos, setPeriodos] = useState<Periodo[]>([]);
|
||||||
|
const [p1, setP1] = useState<number | null>(null);
|
||||||
|
const [p2, setP2] = useState<number | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
const token = Cookies.get("token");
|
||||||
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPeriodos = async () => {
|
||||||
|
const res = await axios.get(`${envConfig.apiUrl}/periodo`, { headers });
|
||||||
|
setPeriodos(res.data);
|
||||||
|
};
|
||||||
|
fetchPeriodos();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const periodo1 = periodos.find(p => p.id_periodo === p1) || null;
|
||||||
|
const periodo2 = periodos.find(p => p.id_periodo === p2) || null;
|
||||||
|
|
||||||
|
onChange(periodo1, periodo2);
|
||||||
|
}, [p1, p2]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Periodo 1 */}
|
||||||
|
<select onChange={(e) => setP1(Number(e.target.value))}>
|
||||||
|
<option value="">Periodo 1</option>
|
||||||
|
{periodos.map(p => (
|
||||||
|
<option key={p.id_periodo} value={p.id_periodo}>
|
||||||
|
{p.semestre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Periodo 2 */}
|
||||||
|
<select onChange={(e) => setP2(Number(e.target.value))}>
|
||||||
|
<option value="">Periodo 2</option>
|
||||||
|
{periodos.map(p => (
|
||||||
|
<option key={p.id_periodo} value={p.id_periodo}>
|
||||||
|
{p.semestre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,84 +4,269 @@ import { useEffect, useState } from "react";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
interface ServicioReporte {
|
import {
|
||||||
id_servicio: number;
|
XAxis,
|
||||||
servicio: string;
|
YAxis,
|
||||||
total: string;
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
interface Periodo {
|
||||||
|
id_periodo: number;
|
||||||
|
semestre: string;
|
||||||
|
fecha_inicio_servicio: string;
|
||||||
|
fecha_fin_servicio: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
desde: string | null;
|
periodo1: Periodo | null;
|
||||||
hasta: string | null;
|
periodo2: Periodo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PorServicio({ desde, hasta }: Props) {
|
export default function PorServicio({ periodo1, periodo2 }: Props) {
|
||||||
const [servicios, setServicios] = useState<ServicioReporte[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [periodos, setPeriodos] = useState<Periodo[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [serviciosSeleccionados, setServiciosSeleccionados] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!desde || !hasta) return;
|
if (!periodo1 || !periodo2) return;
|
||||||
|
|
||||||
const fetchServicios = async () => {
|
const fetchServicios = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = Cookies.get("token");
|
const token = Cookies.get("token");
|
||||||
const headers = { Authorization: `Bearer ${token}` };
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
const res = await axios.post(
|
// 🔥 1. traer TODOS los periodos en rango
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/detalle-servicio/rango`,
|
const resPeriodos = await axios.get(
|
||||||
{ desde, hasta },
|
`${process.env.NEXT_PUBLIC_API_URL}/periodo/range?p1=${periodo1.id_periodo}&p2=${periodo2.id_periodo}`,
|
||||||
{ headers },
|
{ headers }
|
||||||
);
|
);
|
||||||
|
|
||||||
setServicios(res.data);
|
const periodosRango = resPeriodos.data;
|
||||||
|
setPeriodos(periodosRango);
|
||||||
|
|
||||||
|
// 🔥 2. hacer requests por cada periodo
|
||||||
|
const resultados = await Promise.all(
|
||||||
|
periodosRango.map((p: Periodo) =>
|
||||||
|
axios.post(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/detalle-servicio/rango`,
|
||||||
|
{
|
||||||
|
desde: p.fecha_inicio_servicio,
|
||||||
|
hasta: p.fecha_fin_servicio,
|
||||||
|
},
|
||||||
|
{ headers }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🔥 3. merge dinámico
|
||||||
|
const map = new Map();
|
||||||
|
|
||||||
|
periodosRango.forEach((p: Periodo, index: number) => {
|
||||||
|
resultados[index].data.forEach((item: any) => {
|
||||||
|
if (!map.has(item.servicio)) {
|
||||||
|
map.set(item.servicio, {
|
||||||
|
servicio: item.servicio,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
map.get(item.servicio)[p.semestre] = Number(item.total);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalData = Array.from(map.values());
|
||||||
|
setData(finalData);
|
||||||
|
setServiciosSeleccionados(finalData.map((item: any) => item.servicio));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error al obtener reporte por servicio", error);
|
console.error("Error comparando servicios", error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchServicios();
|
fetchServicios();
|
||||||
}, [desde, hasta]);
|
}, [periodo1, periodo2]);
|
||||||
|
|
||||||
|
// 🔥 TRANSFORMAR DATA PARA GRAFICA
|
||||||
|
const transformDataForChart = () => {
|
||||||
|
if (!data.length || !periodos.length) return [];
|
||||||
|
|
||||||
|
return periodos.map((p) => {
|
||||||
|
const row: any = { periodo: p.semestre };
|
||||||
|
|
||||||
|
data.forEach((servicio) => {
|
||||||
|
row[servicio.servicio] = servicio[p.semestre] || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// FORMATO DE NUMEROS
|
||||||
|
const formatNumber = (value: number) => {
|
||||||
|
return value.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// TOOLTIP ORDENADO
|
||||||
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const sorted = [...payload]
|
||||||
|
.filter((item) => item.value > 0)
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "relative" }}>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
overflow: "auto",
|
background: "white",
|
||||||
height: "270px",
|
padding: "10px",
|
||||||
scrollbarColor: "#2563eb white",
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: "8px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<p style={{ fontWeight: "bold" }}>
|
||||||
|
Periodo: {label}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{sorted.map((entry: any, index: number) => (
|
||||||
|
<div key={index} style={{ color: entry.color }}>
|
||||||
|
{entry.name}: ${formatNumber(entry.value)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartData = transformDataForChart();
|
||||||
|
|
||||||
|
const colors = ["black", "red", "blue", "orange", "purple", "green"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "30px" }}>
|
||||||
|
|
||||||
|
{/* TABLA */}
|
||||||
|
<div style={{ overflow: "auto", maxHeight: "300px" }}>
|
||||||
{loading && <p>Cargando...</p>}
|
{loading && <p>Cargando...</p>}
|
||||||
|
|
||||||
<table>
|
<table style={{ width: "100%" }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Servicio</th>
|
<th>Servicio</th>
|
||||||
<th>Monto total</th>
|
|
||||||
|
{periodos.map((p) => (
|
||||||
|
<th key={p.id_periodo}>{p.semestre}</th>
|
||||||
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{!loading && servicios.length === 0 && (
|
{!loading && data.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={2}>No hay servicios en este rango</td>
|
<td colSpan={periodos.length + 1}>
|
||||||
|
No hay datos en este rango
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{servicios.map((servicio) => (
|
{data.map((row) => (
|
||||||
<tr key={servicio.id_servicio}>
|
<tr key={row.servicio}>
|
||||||
<td>{servicio.servicio}</td>
|
<td>{row.servicio}</td>
|
||||||
<td>${Number(servicio.total).toFixed(2)}</td>
|
|
||||||
|
{periodos.map((p) => (
|
||||||
|
<td key={p.id_periodo}>
|
||||||
|
${formatNumber(row[p.semestre] || 0)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "10px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.map((servicio) => (
|
||||||
|
<label
|
||||||
|
key={servicio.servicio}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "5px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={serviciosSeleccionados.includes(servicio.servicio)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setServiciosSeleccionados((prev) => [
|
||||||
|
...prev,
|
||||||
|
servicio.servicio,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
setServiciosSeleccionados((prev) =>
|
||||||
|
prev.filter((s) => s !== servicio.servicio)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{servicio.servicio}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* GRAFICA */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: "700px",
|
||||||
|
background: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="periodo" />
|
||||||
|
<YAxis tickFormatter={(value) => value.toLocaleString()} />
|
||||||
|
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
|
||||||
|
<Legend />
|
||||||
|
|
||||||
|
{data
|
||||||
|
.filter((servicio) =>
|
||||||
|
serviciosSeleccionados.includes(servicio.servicio)
|
||||||
|
)
|
||||||
|
.map((servicio, i) => (
|
||||||
|
<Line
|
||||||
|
key={servicio.servicio}
|
||||||
|
dataKey={servicio.servicio}
|
||||||
|
stroke={colors[i % colors.length]}
|
||||||
|
type="monotone"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
//IO
|
|
||||||
@@ -121,6 +121,7 @@ footer p {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.containerForm {
|
.containerForm {
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ export const metadata: Metadata = {
|
|||||||
authors: [{ name: "FES Acatlán" }],
|
authors: [{ name: "FES Acatlán" }],
|
||||||
creator: "Lino,Carlos,Axel",
|
creator: "Lino,Carlos,Axel",
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/icon.svg",
|
icon: [{ url: "/icon.ico" }, { url: "/icon.png" }, { url: "/icon.svg" }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Generated
+18
@@ -696,6 +696,7 @@
|
|||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||||
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
|
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -735,6 +736,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -755,6 +757,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -775,6 +778,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -795,6 +799,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -815,6 +820,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -835,6 +841,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -855,6 +862,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -875,6 +883,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -895,6 +904,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -915,6 +925,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -935,6 +946,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -955,6 +967,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -975,6 +988,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2635,6 +2649,7 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2677,6 +2692,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3190,6 +3206,7 @@
|
|||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
@@ -3290,6 +3307,7 @@
|
|||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -87,3 +87,4 @@ export const config = {
|
|||||||
"/ActivosMantenimiento",
|
"/ActivosMantenimiento",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
//IO
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Reference in New Issue
Block a user