9 Commits

Author SHA1 Message Date
stern f2afe92991 Add deploy.yaml
Despliegue Automatizado Universal / get-context (push) Successful in 1s
Despliegue Automatizado Universal / build (push) Successful in 1m54s
Despliegue Automatizado Universal / deploy (push) Successful in 36s
2026-07-02 03:08:12 -06:00
IO edc56138d5 added new icons 2026-06-05 15:38:48 -05:00
IO 97f2a8851d added new icons 2026-06-05 15:33:00 -05:00
IO 1bdde40409 fixed Axel 2026-06-04 17:40:21 -05:00
IO 286075da19 edded reporte and pull axel 2026-06-03 19:55:29 -05:00
Tyrannuss 3dfc092e98 creacion de graficas y archivos EXCEL 2026-06-03 16:50:01 -06:00
IO c2e392f3d8 Merge branch 'Axel' of repositorio.acatlan.unam.mx:IO/front-AT 2026-05-21 13:12:14 -05:00
Tyrannuss 35245a9195 se agregaron las graficas por servicio ademas de poner un filtro para escoger que servicios se van a graficar 2026-05-20 15:51:46 -06:00
Tyrannuss c4f68aa1e0 se modifico la manera en que se traen los datos del back-end para reportes y se creo un componente llamado PeriodoPicker, para poder traer la informacion de los periodos por rango 2026-04-14 14:17:08 -06:00
15 changed files with 866 additions and 350 deletions
+85
View File
@@ -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"
+60 -28
View File
@@ -48,6 +48,8 @@ export default function Inscripciones() {
const [anio, setAnio] = useState<number | null>(null);
const [tablaData, setTablaData] = useState<TablaRow[]>([]);
const [periodosUnicos, setPeriodosUnicos] = useState<string[]>([]);
const [tablaResumen, setTablaResumen] = useState<any[]>([]);
const [rawData, setRawData] = useState<ApiResponse[]>([]);
const token = Cookies.get("token");
@@ -139,50 +141,80 @@ export default function Inscripciones() {
});
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) {
console.error("Error al obtener inscritos", error);
}
};
const exportarExcel = () => {
if (!rawData.length) return;
if (!rawData.length || !tablaResumen.length) return;
const periodosUnicos = Array.from(
new Set(rawData.map((t) => t.semestre))
);
// HOJA 1: Inscritos (resumen)
const worksheet1 = XLSX.utils.json_to_sheet(tablaResumen);
const carrerasUnicas = Array.from(
new Set(rawData.map((t) => t.carrera))
);
const carrerasUnicas = Array.from(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 };
let totalFila = 0;
periodosUnicos.forEach((periodo) => {
const items = rawData.filter(
(t) => t.carrera === carrera && t.semestre === periodo
);
const totalPeriodo = items.reduce(
(acc, item) => acc + Number(item.total),
0
);
fila[periodo] = totalPeriodo;
totalFila += totalPeriodo;
const items = rawData.filter((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;
fila[`Femenino - ${periodo}`] = fem;
});
fila["Total"] = totalFila;
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();
XLSX.utils.book_append_sheet(workbook, worksheet, "Inscritos");
XLSX.utils.book_append_sheet(workbook, worksheet1, "Inscritos");
XLSX.utils.book_append_sheet(workbook, worksheet2, "genero");
XLSX.utils.book_append_sheet(workbook, worksheet3, "areas");
const excelBuffer = XLSX.write(workbook, {
bookType: "xlsx",
-272
View File
@@ -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>
);
}
+400
View File
@@ -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>
);
}
+13 -13
View File
@@ -6,17 +6,24 @@ import SearchDateBetween from "@/app/Components/SearchDateBetween/SearchDateBetw
import PorServicios from "@/app/Components/Reportes/porServicio";
import PorRecibos from "@/app/Components/Reportes/porRecibo";
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({
defaultKey,
}: {
defaultKey?: string;
}) {
const router = useRouter();
const [desde, setDesde] = useState<string | null>(null);
const [hasta, setHasta] = useState<string | null>(null);
const [periodo1, setPeriodo1] = useState<any>(null);
const [periodo2, setPeriodo2] = useState<any>(null);
return (
<section className="containerSection">
<section className="containerSection" style={{ overflow: "visible", }}>
<h2 className="title">REPORTES</h2>
<Toggle
@@ -40,20 +47,13 @@ export default function ReportesClient({
{
key: "Por Servicio",
label: "Por Servicio",
content: (
<>
<MonthYearPicker
onChange={(d, h) => {
setDesde(d);
setHasta(h);
}}
/>
<PorServicios desde={desde} hasta={hasta} />
</>
),
},
content: null,
}
]}
/>
{defaultKey == "Por Servicio" &&
<PorServicio/>
}
</section>
);
}
+1
View File
@@ -8,6 +8,7 @@ interface ToggleOption {
key: string;
label: string;
content: ReactNode | (() => ReactNode);
onClick?: () => void;
}
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>
);
}
+218 -33
View File
@@ -4,84 +4,269 @@ import { useEffect, useState } from "react";
import axios from "axios";
import Cookies from "js-cookie";
interface ServicioReporte {
id_servicio: number;
servicio: string;
total: string;
import {
XAxis,
YAxis,
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 {
desde: string | null;
hasta: string | null;
periodo1: Periodo | null;
periodo2: Periodo | null;
}
export default function PorServicio({ desde, hasta }: Props) {
const [servicios, setServicios] = useState<ServicioReporte[]>([]);
export default function PorServicio({ periodo1, periodo2 }: Props) {
const [data, setData] = useState<any[]>([]);
const [periodos, setPeriodos] = useState<Periodo[]>([]);
const [loading, setLoading] = useState(false);
const [serviciosSeleccionados, setServiciosSeleccionados] = useState<string[]>([]);
useEffect(() => {
if (!desde || !hasta) return;
if (!periodo1 || !periodo2) return;
const fetchServicios = async () => {
setLoading(true);
try {
const token = Cookies.get("token");
const headers = { Authorization: `Bearer ${token}` };
const res = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/detalle-servicio/rango`,
{ desde, hasta },
{ headers },
// 🔥 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 }
);
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) {
console.error("Error al obtener reporte por servicio", error);
console.error("Error comparando servicios", error);
} finally {
setLoading(false);
}
};
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 (
<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={{ position: "relative" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "30px" }}>
<div
style={{
overflow: "auto",
height: "270px",
scrollbarColor: "#2563eb white",
}}
>
{/* TABLA */}
<div style={{ overflow: "auto", maxHeight: "300px" }}>
{loading && <p>Cargando...</p>}
<table>
<table style={{ width: "100%" }}>
<thead>
<tr>
<th>Servicio</th>
<th>Monto total</th>
{periodos.map((p) => (
<th key={p.id_periodo}>{p.semestre}</th>
))}
</tr>
</thead>
<tbody>
{!loading && servicios.length === 0 && (
{!loading && data.length === 0 && (
<tr>
<td colSpan={2}>No hay servicios en este rango</td>
<td colSpan={periodos.length + 1}>
No hay datos en este rango
</td>
</tr>
)}
{servicios.map((servicio) => (
<tr key={servicio.id_servicio}>
<td>{servicio.servicio}</td>
<td>${Number(servicio.total).toFixed(2)}</td>
{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
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>
);
}
//IO
+1
View File
@@ -121,6 +121,7 @@ footer p {
height: 100%;
display: flex;
flex-direction: column;
overflow: visible;
}
.containerForm {
+1 -1
View File
@@ -17,7 +17,7 @@ export const metadata: Metadata = {
authors: [{ name: "FES Acatlán" }],
creator: "Lino,Carlos,Axel",
icons: {
icon: "/icon.svg",
icon: [{ url: "/icon.ico" }, { url: "/icon.png" }, { url: "/icon.svg" }],
},
};
+18
View File
@@ -696,6 +696,7 @@
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -735,6 +736,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -755,6 +757,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -775,6 +778,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -795,6 +799,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -815,6 +820,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -835,6 +841,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -855,6 +862,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -875,6 +883,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -895,6 +904,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -915,6 +925,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -935,6 +946,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -955,6 +967,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -975,6 +988,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2635,6 +2649,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
@@ -2677,6 +2692,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -3190,6 +3206,7 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT",
"optional": true
},
@@ -3290,6 +3307,7 @@
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
+1
View File
@@ -87,3 +87,4 @@ export const config = {
"/ActivosMantenimiento",
],
};
//IO
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB