feat: tablero de modulo con uso real (equipo helpdesk, pagos validados, inventario API en dashboard)
This commit is contained in:
1 parent
b6ca254a82
commit
c64e990dfa
7 files changed
+2969
-116
No files matched your search
@@ -0,0 +1,13 @@
|
|||||||
|
// Estadísticas reales de uso de Helpdesk: proxy server-side a
|
||||||
|
// GET /api/helpdesk/stats con la cuenta de servicio.
|
||||||
|
// Requiere sesión IT.
|
||||||
|
import { assertIT, helpdeskFetch, asResponse, errResponse } from "@/lib/correo-server";
|
||||||
|
|
||||||
|
export async function GET(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
await assertIT(req);
|
||||||
|
return await asResponse(await helpdeskFetch("/api/helpdesk/stats"));
|
||||||
|
} catch (e) {
|
||||||
|
return errResponse(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Equipo real que usa Helpdesk: proxy server-side a
|
||||||
|
// GET /api/helpdesk/users con la cuenta de servicio
|
||||||
|
// (HELPDESK_SERVICE_USER/PASS). Requiere sesión IT.
|
||||||
|
import { assertIT, helpdeskFetch, asResponse, errResponse } from "@/lib/correo-server";
|
||||||
|
|
||||||
|
export async function GET(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
await assertIT(req);
|
||||||
|
return await asResponse(await helpdeskFetch("/api/helpdesk/users"));
|
||||||
|
} catch (e) {
|
||||||
|
return errResponse(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { assertIT } from "@/lib/correo-server";
|
||||||
|
|
||||||
|
// Pagos validados reales: proxy server-side a
|
||||||
|
// GET /api/Boletas/payments?mes=&year= (endpoint público de SAM.API,
|
||||||
|
// no requiere Bearer). Requiere sesión IT para verlo en el portal.
|
||||||
|
const SAM_API_URL =
|
||||||
|
process.env.SAM_API_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ??
|
||||||
|
"https://api.devops.norteamericano.cl";
|
||||||
|
|
||||||
|
export async function GET(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
await assertIT(req);
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const now = new Date();
|
||||||
|
const mes = url.searchParams.get("mes") ?? String(now.getMonth() + 1);
|
||||||
|
const year = url.searchParams.get("year") ?? String(now.getFullYear());
|
||||||
|
const m = Number(mes);
|
||||||
|
const y = Number(year);
|
||||||
|
if (!Number.isInteger(m) || m < 1 || m > 12 || !Number.isInteger(y) || y < 2000 || y > 2100) {
|
||||||
|
return NextResponse.json({ error: "mes (1-12) y year válidos requeridos" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const res = await fetch(
|
||||||
|
`${SAM_API_URL}/api/Boletas/payments?mes=${m}&year=${y}`,
|
||||||
|
{ cache: "no-store" }
|
||||||
|
);
|
||||||
|
const text = await res.text();
|
||||||
|
try {
|
||||||
|
return NextResponse.json(JSON.parse(text), { status: res.status });
|
||||||
|
} catch {
|
||||||
|
return new NextResponse(text, { status: res.status });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const status =
|
||||||
|
typeof e === "object" && e !== null && "status" in e
|
||||||
|
? Number((e as { status: unknown }).status) || 500
|
||||||
|
: 500;
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e instanceof Error ? e.message : "Error" },
|
||||||
|
{ status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+345
-28
@@ -1,14 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import SamShell from "@/components/SamShell";
|
import SamShell from "@/components/SamShell";
|
||||||
import { useModules, useStats, useMe } from "@/lib/store"
|
import { useModules, useStats, useMe } from "@/lib/store"
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
displayName,
|
displayName,
|
||||||
envOf,
|
envOf,
|
||||||
getModuleFunctions,
|
|
||||||
getStatusInfo,
|
getStatusInfo,
|
||||||
|
isPagosModule,
|
||||||
|
isHelpdeskModule,
|
||||||
moduleUrl,
|
moduleUrl,
|
||||||
} from "@/lib/sam";
|
} from "@/lib/sam";
|
||||||
import {
|
import {
|
||||||
@@ -36,8 +38,340 @@ function Bar({ value, color }: { value: number; color: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HelpdeskMember {
|
||||||
|
idUsuario: string;
|
||||||
|
nombres?: string;
|
||||||
|
apPaterno?: string;
|
||||||
|
email?: string;
|
||||||
|
role?: string;
|
||||||
|
departmentName?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HelpdeskStats {
|
||||||
|
totalTickets: number;
|
||||||
|
openTickets: number;
|
||||||
|
inProgressTickets: number;
|
||||||
|
resolvedTickets: number;
|
||||||
|
closedTickets: number;
|
||||||
|
unassignedTickets: number;
|
||||||
|
criticalTickets: number;
|
||||||
|
dailyCounts: { date: string; count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Boleta {
|
||||||
|
folio: number;
|
||||||
|
alumnoRut?: string | null;
|
||||||
|
alumnoNombre?: string | null;
|
||||||
|
fecha: string;
|
||||||
|
monto: number;
|
||||||
|
tipo?: string;
|
||||||
|
estado?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HelpdeskUsage() {
|
||||||
|
const [team, setTeam] = useState<HelpdeskMember[] | null>(null);
|
||||||
|
const [stats, setStats] = useState<HelpdeskStats | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [tRes, sRes] = await Promise.all([
|
||||||
|
fetch("/api/portal/helpdesk-team", { cache: "no-store" }),
|
||||||
|
fetch("/api/portal/helpdesk-stats", { cache: "no-store" }),
|
||||||
|
]);
|
||||||
|
if (!tRes.ok || !sRes.ok) {
|
||||||
|
const t = await tRes.json().catch(() => ({}));
|
||||||
|
const s = await sRes.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
(t as { error?: string }).error ||
|
||||||
|
(s as { error?: string }).error ||
|
||||||
|
`Helpdesk respondió ${tRes.status}/${sRes.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const t = (await tRes.json()) as HelpdeskMember[];
|
||||||
|
const s = (await sRes.json()) as HelpdeskStats;
|
||||||
|
if (!cancelled) {
|
||||||
|
setTeam(Array.isArray(t) ? t : []);
|
||||||
|
setStats(s);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : "Error al cargar uso");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="alert alert-warning">
|
||||||
|
<i className="bx bx-error me-2" />
|
||||||
|
No se pudo cargar el uso real de Helpdesk: {error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!team || !stats) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div className="spinner-border text-primary" role="status" />
|
||||||
|
<p className="mt-2 text-muted small">Cargando equipo y estadísticas reales…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = team.filter((m) => m.isActive !== false);
|
||||||
|
const maxDaily = Math.max(...stats.dailyCounts.map((d) => d.count), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="alert alert-light section-header mt-4 mb-3 py-2 px-3">
|
||||||
|
<i className="bx bx-group me-1" /> Equipo que lo usa ({active.length})
|
||||||
|
</div>
|
||||||
|
<div className="row row-cards">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="app-card p-3">
|
||||||
|
{active.length === 0 && (
|
||||||
|
<p className="text-muted mb-0">Sin integrantes registrados en el equipo.</p>
|
||||||
|
)}
|
||||||
|
<div className="list-group">
|
||||||
|
{active.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.idUsuario}
|
||||||
|
className="list-group-item d-flex justify-content-between align-items-center flex-wrap gap-2"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<i className="bx bx-user me-2" />
|
||||||
|
{[m.nombres, m.apPaterno].filter(Boolean).join(" ") || m.idUsuario}
|
||||||
|
<small className="text-muted d-block">
|
||||||
|
{m.idUsuario}
|
||||||
|
{m.email ? ` · ${m.email}` : ""}
|
||||||
|
{m.departmentName ? ` · ${m.departmentName}` : ""}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<span className="badge bg-primary rounded-pill">{m.role ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<small className="text-muted d-block mt-2">
|
||||||
|
Fuente: <code>GET /api/helpdesk/users</code> (SAM.API, cuenta de servicio).
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="alert alert-light section-header mt-4 mb-3 py-2 px-3">
|
||||||
|
<i className="bx bx-bar-chart-alt-2 me-1" /> Estadísticas de uso
|
||||||
|
</div>
|
||||||
|
<div className="row g-3 mb-3">
|
||||||
|
{[
|
||||||
|
{ label: "Total tickets", value: stats.totalTickets },
|
||||||
|
{ label: "Abiertos", value: stats.openTickets },
|
||||||
|
{ label: "En progreso", value: stats.inProgressTickets },
|
||||||
|
{ label: "Resueltos", value: stats.resolvedTickets },
|
||||||
|
{ label: "Críticos", value: stats.criticalTickets },
|
||||||
|
{ label: "Sin asignar", value: stats.unassignedTickets },
|
||||||
|
].map((c) => (
|
||||||
|
<div className="col-6 col-lg-4 col-xl-2" key={c.label}>
|
||||||
|
<div className="app-card p-3 text-center h-100">
|
||||||
|
<div className="fw-bold fs-4">{c.value}</div>
|
||||||
|
<small className="text-muted">{c.label}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{stats.dailyCounts.length > 0 && (
|
||||||
|
<div className="card mb-4">
|
||||||
|
<div className="card-header d-flex align-items-center gap-2">
|
||||||
|
<i className="bx bx-graph-up" style={{ color: "var(--primary)" }}></i>
|
||||||
|
Tickets por día (últimos 14 días)
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-end gap-1" style={{ height: "120px" }}>
|
||||||
|
{stats.dailyCounts.map((d) => (
|
||||||
|
<div
|
||||||
|
key={d.date}
|
||||||
|
className="flex-fill rounded"
|
||||||
|
style={{
|
||||||
|
height: `${(d.count / maxDaily) * 100}%`,
|
||||||
|
backgroundColor: "var(--primary)",
|
||||||
|
opacity: 0.7,
|
||||||
|
minHeight: "4px",
|
||||||
|
}}
|
||||||
|
title={`${d.date}: ${d.count} tickets`}
|
||||||
|
></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<small className="text-muted">
|
||||||
|
Fuente: <code>GET /api/helpdesk/stats</code> (SAM.API).
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PagosUsage() {
|
||||||
|
const now = new Date();
|
||||||
|
const [mes, setMes] = useState(String(now.getMonth() + 1));
|
||||||
|
const [year, setYear] = useState(String(now.getFullYear()));
|
||||||
|
const [rows, setRows] = useState<Boleta[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function load(m: string, y: string) {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/portal/pagos-validados?mes=${m}&year=${y}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
(data as { error?: string } | null)?.error || `Pagos respondió ${res.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setRows(Array.isArray(data) ? data : []);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Error al cargar pagos");
|
||||||
|
setRows(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Carga inicial: fetch async (setState ocurre en callbacks, no sincronico).
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
load(mes, year);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const total = (rows ?? []).reduce((acc, r) => acc + (Number(r.monto) || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="alert alert-light section-header mt-4 mb-3 py-2 px-3">
|
||||||
|
<i className="bx bx-receipt me-1" /> Pagos validados
|
||||||
|
</div>
|
||||||
|
<div className="row row-cards">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="app-card p-3">
|
||||||
|
<form
|
||||||
|
className="d-flex gap-2 flex-wrap align-items-end mb-3"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
load(mes, year);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-muted mb-1">Mes</label>
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
value={mes}
|
||||||
|
onChange={(e) => setMes(e.target.value)}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 12 }, (_, i) => (
|
||||||
|
<option key={i + 1} value={String(i + 1)}>
|
||||||
|
{i + 1}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label small text-muted mb-1">Año</label>
|
||||||
|
<input
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-sm btn-primary" disabled={loading}>
|
||||||
|
{loading ? "Cargando…" : "Consultar"}
|
||||||
|
</button>
|
||||||
|
{rows && (
|
||||||
|
<span className="ms-auto small text-muted align-self-center">
|
||||||
|
{rows.length} pagos · Total ${total.toLocaleString("es-CL")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-warning mb-0">
|
||||||
|
<i className="bx bx-error me-2" />
|
||||||
|
No se pudieron cargar los pagos validados: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && loading && (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div className="spinner-border text-primary" role="status" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && !loading && rows && rows.length === 0 && (
|
||||||
|
<p className="text-muted mb-0">Sin pagos validados para {mes}/{year}.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && !loading && rows && rows.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Folio</th>
|
||||||
|
<th>Alumno</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th className="text-end">Monto</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.slice(0, 100).map((r, i) => (
|
||||||
|
<tr key={`${r.folio}-${i}`}>
|
||||||
|
<td>{r.folio}</td>
|
||||||
|
<td className="small">
|
||||||
|
{r.alumnoNombre ?? "—"}
|
||||||
|
{r.alumnoRut ? (
|
||||||
|
<span className="text-muted d-block">{r.alumnoRut}</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="small">
|
||||||
|
{r.fecha ? String(r.fecha).slice(0, 10) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">${Number(r.monto || 0).toLocaleString("es-CL")}</td>
|
||||||
|
<td className="small">{r.estado || r.tipo || "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{rows.length > 100 && (
|
||||||
|
<small className="text-muted">Mostrando 100 de {rows.length}.</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<small className="text-muted d-block mt-2">
|
||||||
|
Fuente: <code>GET /api/Boletas/payments?mes=&year=</code> (SAM.API).
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Tablero funcional del módulo: estado + carga de trabajo +
|
// Tablero funcional del módulo: estado + carga de trabajo +
|
||||||
// funciones API en el sidebar (plantilla SamShell).
|
// uso real (equipo + estadísticas). Ya no muestra la lista
|
||||||
|
// inventada "Funciones disponibles de la API": el uso de la API
|
||||||
|
// vive solo en el dashboard principal (/tablero).
|
||||||
export default function ModuleBoard() {
|
export default function ModuleBoard() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const name = Array.isArray(params.name) ? params.name[0] : params.name ?? "";
|
const name = Array.isArray(params.name) ? params.name[0] : params.name ?? "";
|
||||||
@@ -49,7 +383,6 @@ export default function ModuleBoard() {
|
|||||||
const mod = data?.modules.find(
|
const mod = data?.modules.find(
|
||||||
(m) => m.name.toLowerCase() === name.toLowerCase()
|
(m) => m.name.toLowerCase() === name.toLowerCase()
|
||||||
);
|
);
|
||||||
const functions = getModuleFunctions(name);
|
|
||||||
const stat = mod ? statForModule(stats, mod.name) : undefined;
|
const stat = mod ? statForModule(stats, mod.name) : undefined;
|
||||||
const status = mod ? getStatusInfo(mod) : null;
|
const status = mod ? getStatusInfo(mod) : null;
|
||||||
const env = mod ? envOf(mod.branch) : null;
|
const env = mod ? envOf(mod.branch) : null;
|
||||||
@@ -58,6 +391,8 @@ export default function ModuleBoard() {
|
|||||||
const title = mod ? displayName(mod.name) : displayName(name || "Módulo");
|
const title = mod ? displayName(mod.name) : displayName(name || "Módulo");
|
||||||
|
|
||||||
const menu = [{ label: "Aplicaciones", icon: "bx-layer", href: "/" }];
|
const menu = [{ label: "Aplicaciones", icon: "bx-layer", href: "/" }];
|
||||||
|
const showHelpdesk = isHelpdeskModule(name);
|
||||||
|
const showPagos = !showHelpdesk && isPagosModule(name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SamShell
|
<SamShell
|
||||||
@@ -160,33 +495,15 @@ export default function ModuleBoard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showHelpdesk && <HelpdeskUsage />}
|
||||||
|
{showPagos && <PagosUsage />}
|
||||||
|
{!showHelpdesk && !showPagos && (
|
||||||
<div className="alert alert-light section-header mt-4 mb-3 py-2 px-3">
|
<div className="alert alert-light section-header mt-4 mb-3 py-2 px-3">
|
||||||
<i className="bx bx-list-ul me-1" /> Funciones disponibles de la API
|
<i className="bx bx-info-circle me-1" /> Este módulo aún no expone
|
||||||
</div>
|
estadísticas de uso en el portal. El uso de la API se muestra solo
|
||||||
<div className="row row-cards">
|
en el <Link href="/tablero">dashboard principal</Link>.
|
||||||
<div className="col-12">
|
|
||||||
<div className="app-card p-3">
|
|
||||||
<div className="list-group">
|
|
||||||
{functions.map((f) => (
|
|
||||||
<a
|
|
||||||
key={f.path}
|
|
||||||
href={`${API_URL}/${f.path}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<i className={`bx ${f.icon} me-2`} />
|
|
||||||
{f.label}
|
|
||||||
<small className="text-muted d-block">{f.desc}</small>
|
|
||||||
</span>
|
|
||||||
<code className="small">GET {f.path}</code>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SamShell>
|
</SamShell>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo, type CSSProperties } from "react";
|
import { useMemo, useState, type CSSProperties } from "react";
|
||||||
import { useModules, useStats } from "@/lib/store";
|
import { useModules, useStats } from "@/lib/store";
|
||||||
import {
|
import {
|
||||||
assignColorsForModules,
|
assignColorsForModules,
|
||||||
@@ -24,6 +24,11 @@ import {
|
|||||||
type ContainerStat,
|
type ContainerStat,
|
||||||
} from "@/lib/stats";
|
} from "@/lib/stats";
|
||||||
import type { ModuleState } from "@/lib/sam";
|
import type { ModuleState } from "@/lib/sam";
|
||||||
|
import {
|
||||||
|
API_INVENTORY,
|
||||||
|
API_TOTAL_CONTROLLERS,
|
||||||
|
API_TOTAL_ENDPOINTS,
|
||||||
|
} from "@/lib/api-inventory";
|
||||||
|
|
||||||
function Donut({ ok, starting, down }: { ok: number; starting: number; down: number }) {
|
function Donut({ ok, starting, down }: { ok: number; starting: number; down: number }) {
|
||||||
const total = ok + starting + down;
|
const total = ok + starting + down;
|
||||||
@@ -180,9 +185,120 @@ function AppCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SAM_API_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_URL || "https://api.devops.norteamericano.cl";
|
||||||
|
|
||||||
|
function ApiUsage() {
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const needle = q.trim().toLowerCase();
|
||||||
|
if (!needle) return API_INVENTORY;
|
||||||
|
return API_INVENTORY.filter(
|
||||||
|
(c) =>
|
||||||
|
c.controller.toLowerCase().includes(needle) ||
|
||||||
|
c.base.toLowerCase().includes(needle) ||
|
||||||
|
c.endpoints.some((e) => e.path.toLowerCase().includes(needle))
|
||||||
|
);
|
||||||
|
}, [q]);
|
||||||
|
const shown = expanded ? filtered : filtered.slice(0, 12);
|
||||||
|
const getCount = API_INVENTORY.reduce(
|
||||||
|
(acc, c) => acc + c.endpoints.filter((e) => e.method === "GET").length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="alert alert-light section-header mt-4 mb-3 py-2 px-3">
|
||||||
|
<i className="bx bx-link-external me-1" /> Uso de la API (SAM.API real)
|
||||||
|
</div>
|
||||||
|
<div className="row row-cards">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="app-card p-4">
|
||||||
|
<div className="d-flex gap-4 flex-wrap align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<small className="text-muted d-block">Endpoints reales</small>
|
||||||
|
<span className="fw-bold fs-4">{API_TOTAL_ENDPOINTS}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small className="text-muted d-block">Controladores</small>
|
||||||
|
<span className="fw-bold fs-4">{API_TOTAL_CONTROLLERS}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small className="text-muted d-block">Lecturas (GET)</small>
|
||||||
|
<span className="fw-bold fs-4">{getCount}</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`${SAM_API_URL}/swagger`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn btn-sm btn-outline-primary ms-auto"
|
||||||
|
>
|
||||||
|
<i className="bx bx-link-external me-1" />
|
||||||
|
Abrir Swagger
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3" style={{ maxWidth: 420 }}>
|
||||||
|
<input
|
||||||
|
className="form-control form-control-sm"
|
||||||
|
placeholder="Filtrar por controlador o ruta (ej: webpay, boletas, helpdesk)…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="table-responsive" style={{ maxHeight: 420, overflowY: "auto" }}>
|
||||||
|
<table className="table table-sm table-hover mb-0">
|
||||||
|
<thead className="table-light" style={{ position: "sticky", top: 0 }}>
|
||||||
|
<tr>
|
||||||
|
<th>Controlador</th>
|
||||||
|
<th>Ruta base</th>
|
||||||
|
<th className="text-center">Endpoints</th>
|
||||||
|
<th>Métodos</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shown.map((c) => {
|
||||||
|
const methods = Array.from(new Set(c.endpoints.map((e) => e.method))).join(" · ");
|
||||||
|
return (
|
||||||
|
<tr key={c.controller}>
|
||||||
|
<td className="fw-semibold">{c.controller}</td>
|
||||||
|
<td>
|
||||||
|
<code className="small">{c.base}</code>
|
||||||
|
</td>
|
||||||
|
<td className="text-center">{c.count}</td>
|
||||||
|
<td className="small text-muted">{methods}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="text-muted small mb-0 mt-2">Sin coincidencias para “{q}”.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{filtered.length > 12 && (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-secondary mt-3"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{expanded ? "Ver menos" : `Ver todos (${filtered.length})`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<small className="text-muted d-block mt-3">
|
||||||
|
Inventario generado desde <code>backend/SAM.API/Controllers/*.cs</code> — no
|
||||||
|
son funciones inventadas por el portal. El detalle por aplicación vive en su
|
||||||
|
tablero: Helpdesk muestra equipo + tickets, WebPay/Pagos muestra pagos
|
||||||
|
validados.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
// Vista del tablero (contenido): tarjeta grande de estado global +
|
// Vista del tablero (contenido): tarjeta grande de estado global +
|
||||||
// estado y carga por aplicación. Se usa en /tablero y como
|
// estado y carga por aplicación + uso real de la API.
|
||||||
// landing de it.sam.devops.norteamericano.cl
|
// Se usa en /tablero y como landing de it.sam.devops.norteamericano.cl
|
||||||
export default function TableroView() {
|
export default function TableroView() {
|
||||||
const { data } = useModules();
|
const { data } = useModules();
|
||||||
const stats = useStats();
|
const stats = useStats();
|
||||||
@@ -292,6 +408,8 @@ export default function TableroView() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ApiUsage />
|
||||||
|
|
||||||
<p className="text-muted small mt-4 mb-4 text-center">
|
<p className="text-muted small mt-4 mb-4 text-center">
|
||||||
Auto-actualizado cada 30s · Carga de trabajo en tiempo real (docker stats)
|
Auto-actualizado cada 30s · Carga de trabajo en tiempo real (docker stats)
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+25
-85
@@ -50,13 +50,6 @@ export interface ApiResponse {
|
|||||||
modules: ModuleState[];
|
modules: ModuleState[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiFunction {
|
|
||||||
label: string;
|
|
||||||
icon: string;
|
|
||||||
path: string;
|
|
||||||
desc: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -204,88 +197,35 @@ export function onModuleColor(hex: string): string {
|
|||||||
return lum > 0.62 ? "#212529" : "#ffffff";
|
return lum > 0.62 ? "#212529" : "#ffffff";
|
||||||
}
|
}
|
||||||
|
|
||||||
const A = (
|
// ============================================================
|
||||||
label: string,
|
// Uso real por módulo (sin funciones inventadas).
|
||||||
icon: string,
|
// El bloque anterior "Funciones disponibles de la API" (CORE /
|
||||||
path: string,
|
// FUNCTION_SETS) se eliminó: listaba rutas que no corresponden
|
||||||
desc: string
|
// a la API real. Ahora:
|
||||||
): ApiFunction => ({ label, icon, path, desc });
|
// - El dashboard principal muestra el uso de la API con el
|
||||||
|
// inventario real (src/lib/api-inventory.ts, generado desde
|
||||||
|
// backend/SAM.API/Controllers).
|
||||||
|
// - /m/helpdesk muestra equipo (GET /api/helpdesk/users) +
|
||||||
|
// estadísticas de uso (GET /api/helpdesk/stats).
|
||||||
|
// - /m/webpay y /m/pagos muestran pagos validados
|
||||||
|
// (GET /api/Boletas/payments?mes&year).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
// --- Conjuntos de funciones por familia de módulo ---
|
export type ModuleUsageKind = "helpdesk" | "pagos" | "generic";
|
||||||
const CORE: ApiFunction[] = [
|
|
||||||
A("Alumnos", "bx-group", "api/alumnos", "Ficha, matrícula y sesiones de alumnos"),
|
|
||||||
A("Cursos", "bx-book", "api/cursos", "Catálogo, apertura y horarios de cursos"),
|
|
||||||
A("Profesores", "bx-pencil", "api/profesores", "Ficha y periodos de profesores"),
|
|
||||||
A("Empresas", "bx-buildings", "api/empresas/extra", "Empresas en convenio y propuestas"),
|
|
||||||
A("Pagos", "bx-money", "api/pagos", "Caja, cotizaciones y vouchers"),
|
|
||||||
A("Contratos", "bx-file", "api/contratos", "Contratos firmados y por firmar"),
|
|
||||||
A("Informes", "bx-bar-chart-alt-2", "api/informes", "Reportes académicos y de gestión"),
|
|
||||||
];
|
|
||||||
|
|
||||||
const FUNCTION_SETS: Record<string, ApiFunction[]> = {
|
export function moduleUsageKind(name: string): ModuleUsageKind {
|
||||||
academico: [
|
const key = (name || "").toLowerCase();
|
||||||
A("Cursos", "bx-book", "api/cursos", "Catálogo, apertura y horarios"),
|
if (key === "helpdesk" || key.includes("helpdesk")) return "helpdesk";
|
||||||
A("Alumnos", "bx-group", "api/alumnos/extra", "Ficha, matrícula y sesiones"),
|
if (key === "webpay" || key === "pagos" || key.includes("pago")) return "pagos";
|
||||||
A("Apoderados", "bx-user", "api/apoderados", "Apoderados y contactos"),
|
return "generic";
|
||||||
A("Horarios", "bx-time", "api/horarios", "Bloques horarios y salas"),
|
}
|
||||||
A("Jornadas", "bx-sun", "api/jornadas", "Jornadas diurno / vespertino"),
|
|
||||||
A("Feriados", "bx-calendar", "api/feriados", "Calendario de feriados"),
|
|
||||||
A("Placement Test", "bx-edit", "api/placement-test", "Pruebas de nivel"),
|
|
||||||
A("Plan Anual", "bx-calendar-alt", "api/plan-anual", "Planificación anual"),
|
|
||||||
],
|
|
||||||
profesores: [
|
|
||||||
A("Profesores", "bx-pencil", "api/profesores", "Ficha y periodos"),
|
|
||||||
A("Funciones Extra", "bx-plus-circle", "api/profesores/extra", "Operaciones extendidas"),
|
|
||||||
A("Horarios", "bx-time", "api/horarios", "Bloques horarios y salas"),
|
|
||||||
A("Jornadas", "bx-sun", "api/jornadas", "Jornadas diurno / vespertino"),
|
|
||||||
],
|
|
||||||
empresas: [
|
|
||||||
A("Propuestas", "bx-briefcase", "api/propuestas-empresa", "Propuestas a empresas"),
|
|
||||||
A("Empresas", "bx-buildings", "api/empresas/extra", "Empresas en convenio"),
|
|
||||||
A("Comercial", "bx-trending-up", "api/comercial/extra", "Gestión comercial"),
|
|
||||||
A("Contratos", "bx-file", "api/contratos/extra", "Contratos con empresas"),
|
|
||||||
],
|
|
||||||
ventas: [
|
|
||||||
A("Comercial", "bx-trending-up", "api/comercial/extra", "Gestión comercial"),
|
|
||||||
A("Leads", "bx-target-lock", "api/leads/extra", "Prospectos y seguimiento"),
|
|
||||||
A("Ventas", "bx-cart", "api/ventas", "Ventas y cotizaciones"),
|
|
||||||
A("Cotizaciones", "bx-receipt", "api/cotizaciones", "Cotizaciones emitidas"),
|
|
||||||
A("Ejecutivos", "bx-user-voice", "api/ejecutivos", "Ejecutivos de venta"),
|
|
||||||
],
|
|
||||||
pagos: [
|
|
||||||
A("Portal Pagos", "bx-credit-card", "api/portal-pagos", "Pagos en línea"),
|
|
||||||
A("Pagos", "bx-money", "api/pagos", "Caja y vouchers"),
|
|
||||||
A("WebPay", "bx-card", "api/webpay", "Transacciones Transbank"),
|
|
||||||
A("Boletas", "bx-receipt", "api/boletas", "Boletas y facturación"),
|
|
||||||
A("Arqueo", "bx-calculator", "api/arqueocaja", "Arqueo de caja"),
|
|
||||||
],
|
|
||||||
contabilidad: [
|
|
||||||
A("Contabilidad", "bx-detail", "api/contabilidad", "Movimientos contables"),
|
|
||||||
A("Libros", "bx-book-open", "api/libros", "Libros de compra / venta"),
|
|
||||||
A("DTE", "bx-file-blank", "api/dte", "Documentos tributarios"),
|
|
||||||
A("Documentos", "bx-folder", "api/documentos", "Repositorio documental"),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const MODULE_FAMILY: Record<string, string> = {
|
export function isHelpdeskModule(name: string): boolean {
|
||||||
academico: "academico",
|
return moduleUsageKind(name) === "helpdesk";
|
||||||
"academico-empresas": "empresas",
|
}
|
||||||
portal: "empresas",
|
|
||||||
empresas: "empresas",
|
|
||||||
ventas: "ventas",
|
|
||||||
comercial: "ventas",
|
|
||||||
pagos: "pagos",
|
|
||||||
contabilidad: "contabilidad",
|
|
||||||
profesores: "profesores",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getModuleFunctions(name: string): ApiFunction[] {
|
export function isPagosModule(name: string): boolean {
|
||||||
const key = name.toLowerCase();
|
return moduleUsageKind(name) === "pagos";
|
||||||
const family =
|
|
||||||
MODULE_FAMILY[key] ??
|
|
||||||
Object.keys(FUNCTION_SETS).find((f) => key.includes(f));
|
|
||||||
if (family && FUNCTION_SETS[family]) return FUNCTION_SETS[family];
|
|
||||||
return CORE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in new issue
Block a user