refactor: validadores compartidos y componentes de UI extraídos
- src/lib/validators.ts centraliza regex y validaciones (module name, subdominio, slug, repoUrl, safeNext) usados por las rutas API y login, con tests en validators.test.ts - DeployStatus, HelpdeskUsage y PagosUsage se extraen de las páginas /upload y /m/[name] a componentes propios - README actualizado con la arquitectura y el contrato de /api/modules
This commit is contained in:
1 parent
ab6a5a148c
commit
1a891c3751
12 files changed
+753
-586
No files matched your search
@@ -18,9 +18,11 @@
|
||||
| `/eliminar` | sí | Baja de módulos (con/sin código) |
|
||||
| `/correo` | sí | Claves de correo por módulo (`POST /api/email/modules`, banco `mail.*`) |
|
||||
| `/correo/plantillas` | sí | Plantillas del banco `mail.templates` (listar, crear, editar, activar) |
|
||||
| `/api/modules`, `/api/stats` | no/sí | Proxy al deployer (token server-side) |
|
||||
| `/api/deployer/*` | sí | Proxy genérico al deployer |
|
||||
| `/api/auth`, `/api/me` | — | Sesión HMAC 12h en cookie httpOnly |
|
||||
| `/api/modules` | GET público sanitizado / POST sí | Proxy al deployer: GET filtra visibles sin repoUrl ni logs |
|
||||
| `/api/stats`, `/api/modules/:name/verify`, `/api/inventory` | sí | Proxy/lecturas del deployer (token server-side) |
|
||||
| `/api/deployer/*` | sí | Proxy genérico al deployer (segmentos validados) |
|
||||
| `/api/auth` (POST) | rate-limit 10/min por IP | Sesión HMAC 12h en cookie httpOnly |
|
||||
| `/api/me` | — | Sesión actual (email) |
|
||||
|
||||
## Auth IT
|
||||
|
||||
@@ -49,6 +51,17 @@ pnpm dev # http://localhost:3000
|
||||
pnpm lint && pnpm typecheck && pnpm test && pnpm build
|
||||
```
|
||||
|
||||
## Arquitectura
|
||||
|
||||
- `/` y `/tablero` son Server Components (caché 15s/30s en el servidor,
|
||||
isla cliente para revalidar). El resto de vistas IT son client components
|
||||
con stores `useSyncExternalStore` (`src/lib/store.ts`, `src/lib/correo.ts`).
|
||||
- El inventario SAM.API (`src/lib/api-inventory.ts`) solo se importa en
|
||||
`src/app/api/inventory/route.ts`: nunca viaja al navegador.
|
||||
- Secretos (`DEPLOYER_TOKEN`, `IT_SESSION_SECRET`, `HELPDESK_SERVICE_PASS`)
|
||||
solo se usan en route handlers y `*-server.ts` (verificado: nada
|
||||
server-side llega al bundle cliente).
|
||||
|
||||
El deploy a producción (.4) es vía push a `main` del repo + rebuild del
|
||||
contenedor (`portal-frontend`). El módulo está registrado en el deployer
|
||||
(categoría `sam`, subdominio `sam`); sus pushes ya no autodespliegan solos
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { assertIT } from "@/lib/correo-server";
|
||||
import { SLUG_SEG_RE } from "@/lib/validators";
|
||||
|
||||
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
|
||||
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
|
||||
|
||||
// Solo segmentos seguros: evita path traversal (.., /, %, etc.)
|
||||
const SEG_RE = /^[a-z0-9][a-z0-9_-]*$/i;
|
||||
|
||||
function badRequest(msg: string) {
|
||||
return NextResponse.json({ error: msg }, { status: 400 });
|
||||
@@ -27,7 +27,7 @@ async function proxy(req: NextRequest, path: string[]) {
|
||||
return badRequest("Ruta inválida");
|
||||
}
|
||||
for (const seg of path) {
|
||||
if (!seg || seg.length > 64 || seg === "." || seg === ".." || !SEG_RE.test(seg)) {
|
||||
if (!seg || seg.length > 64 || seg === "." || seg === ".." || !SLUG_SEG_RE.test(seg)) {
|
||||
return badRequest("Ruta inválida");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { assertIT } from "@/lib/correo-server";
|
||||
import { MODULE_NAME_RE } from "@/lib/validators";
|
||||
import type { VerifyCheck, VerifyResult } from "@/lib/verify";
|
||||
|
||||
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
|
||||
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
|
||||
|
||||
const NAME_RE = /^[a-z0-9][a-z0-9-]{1,62}$/i;
|
||||
|
||||
function deployerHeaders(): Record<string, string> {
|
||||
const h: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (DEPLOYER_TOKEN) h["X-API-Token"] = DEPLOYER_TOKEN;
|
||||
@@ -47,7 +46,7 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Requiere sesión IT" }, { status: 401 });
|
||||
}
|
||||
const rawName = (await params).name;
|
||||
if (!rawName || rawName.length > 64 || !NAME_RE.test(rawName)) {
|
||||
if (!rawName || rawName.length > 64 || !MODULE_NAME_RE.test(rawName)) {
|
||||
return NextResponse.json({ error: "Nombre de módulo inválido" }, { status: 400 });
|
||||
}
|
||||
const name = rawName.toLowerCase();
|
||||
@@ -159,7 +158,7 @@ export async function GET(
|
||||
mod?.subdomain ||
|
||||
(base.checks.find((c) => c.id === "caddy")?.detail.match(/https:\/\/([a-z0-9-]+)\./)?.[1] as string) ||
|
||||
name;
|
||||
const subdomain = NAME_RE.test(rawSubdomain) ? rawSubdomain.toLowerCase() : name;
|
||||
const subdomain = MODULE_NAME_RE.test(rawSubdomain) ? rawSubdomain.toLowerCase() : name;
|
||||
const publicUrl = `https://${subdomain}.devops.norteamericano.cl/`;
|
||||
let publicStatus: number | null = null;
|
||||
const caddyCheck = base.checks.find((c) => c.id === "caddy");
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { assertIT } from "@/lib/correo-server";
|
||||
import { sanitizeForPublic, type ModuleState } from "@/lib/sam";
|
||||
import { MODULE_NAME_RE, SUBDOMAIN_RE, validRepoUrl } from "@/lib/validators";
|
||||
|
||||
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
|
||||
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
|
||||
|
||||
const NAME_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
||||
const SUBDOMAIN_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
||||
|
||||
function headers() {
|
||||
const h: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (DEPLOYER_TOKEN) h["X-API-Token"] = DEPLOYER_TOKEN;
|
||||
@@ -21,30 +19,6 @@ function isIT(req: NextRequest): Promise<boolean> {
|
||||
);
|
||||
}
|
||||
|
||||
function validRepoUrl(v: unknown): boolean {
|
||||
if (typeof v !== "string" || v.length === 0 || v.length > 500) return false;
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(v);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (u.protocol !== "https:") return false;
|
||||
if (u.username || u.password) return false;
|
||||
const host = u.hostname.toLowerCase();
|
||||
if (
|
||||
host === "localhost" ||
|
||||
host.startsWith("127.") ||
|
||||
host.startsWith("10.") ||
|
||||
host.startsWith("192.168.") ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
||||
host.endsWith(".local") ||
|
||||
host.endsWith(".internal")
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const res = await fetch(`${DEPLOYER_URL}/api/modules`, {
|
||||
@@ -80,7 +54,7 @@ export async function POST(req: NextRequest) {
|
||||
const repoUrl = String(body?.repoUrl ?? "").trim();
|
||||
const subdomain =
|
||||
body?.subdomain === undefined ? undefined : String(body.subdomain).trim().toLowerCase();
|
||||
if (!NAME_RE.test(name)) {
|
||||
if (!MODULE_NAME_RE.test(name)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Nombre inválido ([a-z0-9-], 2-63, debe empezar con alfanumérico)" },
|
||||
{ status: 400 }
|
||||
|
||||
@@ -3,17 +3,11 @@
|
||||
import { useState, Suspense } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
function safeNext(v: string | null): string {
|
||||
if (!v || !v.startsWith("/") || v.startsWith("//") || v.startsWith("/\\")) {
|
||||
return "/tablero";
|
||||
}
|
||||
return v;
|
||||
}
|
||||
import { safeNextPath } from "@/lib/validators";
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const next = safeNext(useSearchParams().get("next"));
|
||||
const next = safeNextPath(useSearchParams().get("next"));
|
||||
const [rut, setRut] = useState("");
|
||||
const [clave, setClave] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -31,7 +25,7 @@ function LoginForm() {
|
||||
});
|
||||
const j = await res.json();
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
router.push(safeNext(next));
|
||||
router.push(safeNextPath(next));
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Error al ingresar");
|
||||
|
||||
+2
-396
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import SamShell from "@/components/SamShell";
|
||||
import { Bar } from "@/components/ProgressBar";
|
||||
import { HelpdeskUsage } from "@/components/HelpdeskUsage";
|
||||
import { PagosUsage } from "@/components/PagosUsage";
|
||||
import { useModules, useStats, useMe } from "@/lib/store"
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
@@ -24,401 +25,6 @@ import {
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_API_URL || "https://api.devops.norteamericano.cl";
|
||||
|
||||
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;
|
||||
esValido?: boolean;
|
||||
errorMotivo?: string | null;
|
||||
}
|
||||
|
||||
// Fecha real = distinta de nula/vacía y del MinValue de .NET
|
||||
// (0001-01-01: la función PG no informó fecha de caja).
|
||||
function fechaReal(f: string | null | undefined): boolean {
|
||||
return !!f && !f.startsWith("0001-01-01");
|
||||
}
|
||||
|
||||
function fechaCorta(f: string | null | undefined): string {
|
||||
return fechaReal(f) ? String(f).slice(0, 10) : "—";
|
||||
}
|
||||
|
||||
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}&limite=200`, {
|
||||
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 diferida a un callback: el setState ocurre fuera
|
||||
// del cuerpo del efecto (regla react-hooks/set-state-in-effect).
|
||||
// Se ejecuta una sola vez a propósito: los cambios de mes/año
|
||||
// posteriores van por el submit del formulario, no por re-render.
|
||||
const t = setTimeout(() => {
|
||||
load(mes, year);
|
||||
}, 0);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const valid = (rows ?? []).filter(
|
||||
(r) => r.esValido !== false && fechaReal(r.fecha)
|
||||
);
|
||||
const errors = (rows ?? []).filter(
|
||||
(r) => r.esValido === false || !fechaReal(r.fecha)
|
||||
);
|
||||
const total = valid.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">
|
||||
{valid.length} válidos
|
||||
{errors.length > 0 && ` · ${errors.length} observados`} · 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 && valid.length === 0 && (
|
||||
<p className="text-muted mb-0">Sin pagos válidos para {mes}/{year}.</p>
|
||||
)}
|
||||
|
||||
{!error && !loading && rows && valid.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>
|
||||
{valid.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">{fechaCorta(r.fecha)}</td>
|
||||
<td className="text-end">${Number(r.monto || 0).toLocaleString("es-CL")}</td>
|
||||
<td className="small">{r.estado || r.tipo || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{valid.length > 100 && (
|
||||
<small className="text-muted">Mostrando 100 de {valid.length}.</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!error && !loading && rows && errors.length > 0 && (
|
||||
<details className="mt-3">
|
||||
<summary className="small text-warning fw-semibold" style={{ cursor: "pointer" }}>
|
||||
<i className="bx bx-error me-1" />
|
||||
{errors.length} registro{errors.length !== 1 ? "s" : ""} observado{
|
||||
errors.length !== 1 ? "s" : ""
|
||||
} (no son ventas válidas)
|
||||
</summary>
|
||||
<div className="table-responsive mt-2">
|
||||
<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>Motivo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{errors.slice(0, 100).map((r, i) => (
|
||||
<tr key={`err-${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">{fechaCorta(r.fecha)}</td>
|
||||
<td className="text-end">${Number(r.monto || 0).toLocaleString("es-CL")}</td>
|
||||
<td className="small text-warning">{r.errorMotivo || "Sin fecha válida"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{errors.length > 100 && (
|
||||
<small className="text-muted">Mostrando 100 de {errors.length}.</small>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<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 +
|
||||
// uso real (equipo + estadísticas). Ya no muestra la lista
|
||||
// inventada "Funciones disponibles de la API": el uso de la API
|
||||
|
||||
+26
-144
@@ -1,38 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import SamShell from "@/components/SamShell";
|
||||
import { useModules, useMe } from "@/lib/store"
|
||||
import { itMenu, assignModuleColor } from "@/lib/sam";
|
||||
import type { VerifyCheck, VerifyResult } from "@/lib/verify";
|
||||
import { failedChecks, verifySummary } from "@/lib/verify";
|
||||
import { useVerifyPoll, VERIFY_TOTAL } from "@/lib/use-verify-poll";
|
||||
import type { VerifyResult } from "@/lib/verify";
|
||||
import { failedChecks } from "@/lib/verify";
|
||||
import { useVerifyPoll } from "@/lib/use-verify-poll";
|
||||
import {
|
||||
DeployAlerts,
|
||||
VerifyTimeline,
|
||||
type DeployInfo,
|
||||
type DeployPhase,
|
||||
} from "@/components/DeployStatus";
|
||||
|
||||
type Phase = "idle" | "deploying" | "verifying" | "done";
|
||||
|
||||
interface DeployInfo {
|
||||
httpStatus: number;
|
||||
deployLog: string;
|
||||
webhook: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function StepIcon({ state }: { state: "pending" | "run" | "ok" | "fail" }) {
|
||||
if (state === "run")
|
||||
return <span className="spinner-border spinner-border-sm text-primary" />;
|
||||
if (state === "ok") return <i className="bx bx-check-circle text-success fs-5" />;
|
||||
if (state === "fail") return <i className="bx bx-x-circle text-danger fs-5" />;
|
||||
return <i className="bx bx-circle text-muted fs-5" />;
|
||||
}
|
||||
|
||||
function checkState(
|
||||
check: VerifyCheck | undefined,
|
||||
phase: Phase
|
||||
): "pending" | "run" | "ok" | "fail" {
|
||||
if (!check) return phase === "verifying" ? "run" : "pending";
|
||||
return check.ok ? "ok" : "fail";
|
||||
}
|
||||
type Phase = DeployPhase;
|
||||
|
||||
export default function UploadPage() {
|
||||
const { data } = useModules();
|
||||
@@ -193,124 +175,24 @@ export default function UploadPage() {
|
||||
)}
|
||||
|
||||
{/* Progreso de verificación post-importación */}
|
||||
{(phase === "verifying" || (verify && phase === "done")) && (
|
||||
<div className="app-card p-3 mb-3 border">
|
||||
<h6 className="fw-semibold mb-1">
|
||||
<i className="bx bx-check-shield me-1" />
|
||||
Verificación de <code>{moduleName}</code>
|
||||
{phase === "verifying" && (
|
||||
<small className="text-muted fw-normal ms-2">
|
||||
comprobando contenedor y funcionamiento… (intento {Math.max(attempt, 1)}/{VERIFY_TOTAL})
|
||||
</small>
|
||||
)}
|
||||
</h6>
|
||||
{verify && (
|
||||
<small className="text-muted d-block mb-2">{verifySummary(verify)}</small>
|
||||
)}
|
||||
<ul className="list-group list-group-flush">
|
||||
<li className="list-group-item d-flex gap-2 align-items-start px-0">
|
||||
<StepIcon state={deployInfo ? "ok" : "run"} />
|
||||
<span>
|
||||
<strong>Registro y deploy</strong>
|
||||
<small className="text-muted d-block">
|
||||
{deployInfo
|
||||
? `registrado (HTTP ${deployInfo.httpStatus})${deployInfo.webhook ? " · webhook creado" : " · sin webhook"}`
|
||||
: "registrando módulo en el deployer…"}
|
||||
</small>
|
||||
</span>
|
||||
</li>
|
||||
{[
|
||||
{ id: "container", label: "Contenedor creado y en ejecución" },
|
||||
{ id: "logs", label: "Logs sin errores fatales" },
|
||||
{ id: "http-root", label: "Aplicación responde HTTP" },
|
||||
{ id: "http-extra", label: "Contenido válido (checks extra)" },
|
||||
{ id: "caddy", label: "Ruta pública (Caddy)" },
|
||||
{ id: "public-http", label: "URL pública accesible" },
|
||||
].map((s) => {
|
||||
const check = verify?.checks.find((c) => c.id === s.id);
|
||||
return (
|
||||
<li key={s.id} className="list-group-item d-flex gap-2 align-items-start px-0">
|
||||
<StepIcon state={checkState(check, phase)} />
|
||||
<span>
|
||||
<strong>{s.label}</strong>
|
||||
<small className="text-muted d-block">
|
||||
{check ? check.detail : "pendiente…"}
|
||||
</small>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{phase === "verifying" && (
|
||||
<p className="text-muted small mb-0 mt-2">
|
||||
Puedes seguir en esta página: si algún paso falla se te avisará aquí mismo
|
||||
con el detalle. El estado también queda en <Link href="/tablero">Tablero</Link> y{" "}
|
||||
<Link href="/actualizar">Actualizar</Link>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<VerifyTimeline
|
||||
moduleName={moduleName}
|
||||
phase={phase}
|
||||
verify={verify}
|
||||
deployInfo={deployInfo}
|
||||
attempt={attempt}
|
||||
/>
|
||||
|
||||
{/* Aviso directo a quien sube: verificación fallida */}
|
||||
{verifyFailed && (
|
||||
<div className="alert alert-danger">
|
||||
<i className="bx bx-error-circle me-2" />
|
||||
<strong>
|
||||
{me?.email ? `${me.email}, la importación de "${moduleName}" falló` : `La importación de "${moduleName}" falló`}
|
||||
</strong>{" "}
|
||||
en: {failed.map((c) => c.label).join(", ")}.
|
||||
<ul className="mb-2 mt-2">
|
||||
{failed.map((c) => (
|
||||
<li key={c.id}>
|
||||
<strong>{c.label}:</strong> {c.detail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{deployInfo && deployInfo.warnings.length > 0 && (
|
||||
<div className="small mb-2">
|
||||
<strong>Advertencias de config de datos:</strong>
|
||||
<ul className="mb-0">
|
||||
{deployInfo.warnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{(verify.logsTail || deployInfo?.deployLog) && (
|
||||
<details className="small mb-2">
|
||||
<summary style={{ cursor: "pointer" }}>Ver logs del deploy</summary>
|
||||
<pre className="bg-dark text-light p-2 mt-1 mb-0 small" style={{ whiteSpace: "pre-wrap", maxHeight: 240, overflow: "auto" }}>
|
||||
{(verify.logsTail || deployInfo?.deployLog || "").slice(-3000)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="small mb-0">
|
||||
El módulo quedó registrado pero <strong>no está funcionando</strong>. Revisa el
|
||||
Dockerfile/puerto (<code>NEXT_PUBLIC_API_URL</code>, puerto interno 3000),
|
||||
corrige el repo y reintenta desde <Link href="/actualizar">Actualizar</Link> o
|
||||
elimínalo en <Link href="/eliminar">Eliminar</Link>. Su estado también es visible
|
||||
en el <Link href={`/m/${moduleName}`}>tablero del módulo</Link>.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Éxito verificado */}
|
||||
{phase === "done" && verify?.ok && (
|
||||
<div className="alert alert-success">
|
||||
<i className="bx bx-check-circle me-2" />
|
||||
Módulo <strong>{moduleName}</strong> importado y <strong>verificado</strong>:
|
||||
contenedor {verify.containerStatus || "running"} + HTTP {verify.httpStatus}
|
||||
{verify.publicUrl ? <> + <code>{verify.publicUrl}</code></> : null}.
|
||||
<span className="ms-2">
|
||||
<a href={verify.publicUrl || `https://${moduleName}.devops.norteamericano.cl/`} target="_blank" rel="noopener noreferrer" className="btn btn-sm btn-success ms-1">
|
||||
<i className="bx bx-window-open me-1" /> Abrir aplicación
|
||||
</a>
|
||||
<Link href={`/m/${moduleName}`} className="btn btn-sm btn-outline-success ms-1">
|
||||
Ver tablero
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<DeployAlerts
|
||||
moduleName={moduleName}
|
||||
phase={phase}
|
||||
verify={verify}
|
||||
deployInfo={deployInfo}
|
||||
failed={failed}
|
||||
verifyFailed={!!verifyFailed}
|
||||
userEmail={me?.email || undefined}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="row g-3">
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import Link from "next/link";
|
||||
import type { VerifyCheck, VerifyResult } from "@/lib/verify";
|
||||
import { verifySummary } from "@/lib/verify";
|
||||
import { VERIFY_TOTAL } from "@/lib/use-verify-poll";
|
||||
|
||||
export type DeployPhase = "idle" | "deploying" | "verifying" | "done";
|
||||
|
||||
export interface DeployInfo {
|
||||
httpStatus: number;
|
||||
deployLog: string;
|
||||
webhook: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function StepIcon({ state }: { state: "pending" | "run" | "ok" | "fail" }) {
|
||||
if (state === "run")
|
||||
return <span className="spinner-border spinner-border-sm text-primary" />;
|
||||
if (state === "ok") return <i className="bx bx-check-circle text-success fs-5" />;
|
||||
if (state === "fail") return <i className="bx bx-x-circle text-danger fs-5" />;
|
||||
return <i className="bx bx-circle text-muted fs-5" />;
|
||||
}
|
||||
|
||||
function checkState(
|
||||
check: VerifyCheck | undefined,
|
||||
phase: DeployPhase
|
||||
): "pending" | "run" | "ok" | "fail" {
|
||||
if (!check) return phase === "verifying" ? "run" : "pending";
|
||||
return check.ok ? "ok" : "fail";
|
||||
}
|
||||
|
||||
// Línea de tiempo de la verificación post-importación.
|
||||
export function VerifyTimeline({
|
||||
moduleName,
|
||||
phase,
|
||||
verify,
|
||||
deployInfo,
|
||||
attempt,
|
||||
}: {
|
||||
moduleName: string;
|
||||
phase: DeployPhase;
|
||||
verify: VerifyResult | null;
|
||||
deployInfo: DeployInfo | null;
|
||||
attempt: number;
|
||||
}) {
|
||||
if (!(phase === "verifying" || (verify && phase === "done"))) return null;
|
||||
return (
|
||||
<div className="app-card p-3 mb-3 border">
|
||||
<h6 className="fw-semibold mb-1">
|
||||
<i className="bx bx-check-shield me-1" />
|
||||
Verificación de <code>{moduleName}</code>
|
||||
{phase === "verifying" && (
|
||||
<small className="text-muted fw-normal ms-2">
|
||||
comprobando contenedor y funcionamiento… (intento {Math.max(attempt, 1)}/{VERIFY_TOTAL})
|
||||
</small>
|
||||
)}
|
||||
</h6>
|
||||
{verify && (
|
||||
<small className="text-muted d-block mb-2">{verifySummary(verify)}</small>
|
||||
)}
|
||||
<ul className="list-group list-group-flush">
|
||||
<li className="list-group-item d-flex gap-2 align-items-start px-0">
|
||||
<StepIcon state={deployInfo ? "ok" : "run"} />
|
||||
<span>
|
||||
<strong>Registro y deploy</strong>
|
||||
<small className="text-muted d-block">
|
||||
{deployInfo
|
||||
? `registrado (HTTP ${deployInfo.httpStatus})${deployInfo.webhook ? " · webhook creado" : " · sin webhook"}`
|
||||
: "registrando módulo en el deployer…"}
|
||||
</small>
|
||||
</span>
|
||||
</li>
|
||||
{[
|
||||
{ id: "container", label: "Contenedor creado y en ejecución" },
|
||||
{ id: "logs", label: "Logs sin errores fatales" },
|
||||
{ id: "http-root", label: "Aplicación responde HTTP" },
|
||||
{ id: "http-extra", label: "Contenido válido (checks extra)" },
|
||||
{ id: "caddy", label: "Ruta pública (Caddy)" },
|
||||
{ id: "public-http", label: "URL pública accesible" },
|
||||
].map((s) => {
|
||||
const check = verify?.checks.find((c) => c.id === s.id);
|
||||
return (
|
||||
<li key={s.id} className="list-group-item d-flex gap-2 align-items-start px-0">
|
||||
<StepIcon state={checkState(check, phase)} />
|
||||
<span>
|
||||
<strong>{s.label}</strong>
|
||||
<small className="text-muted d-block">
|
||||
{check ? check.detail : "pendiente…"}
|
||||
</small>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{phase === "verifying" && (
|
||||
<p className="text-muted small mb-0 mt-2">
|
||||
Puedes seguir en esta página: si algún paso falla se te avisará aquí mismo
|
||||
con el detalle. El estado también queda en <Link href="/tablero">Tablero</Link> y{" "}
|
||||
<Link href="/actualizar">Actualizar</Link>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Avisos de resultado: verificación fallida y éxito verificado.
|
||||
export function DeployAlerts({
|
||||
moduleName,
|
||||
phase,
|
||||
verify,
|
||||
deployInfo,
|
||||
failed,
|
||||
verifyFailed,
|
||||
userEmail,
|
||||
}: {
|
||||
moduleName: string;
|
||||
phase: DeployPhase;
|
||||
verify: VerifyResult | null;
|
||||
deployInfo: DeployInfo | null;
|
||||
failed: VerifyCheck[];
|
||||
verifyFailed: boolean;
|
||||
userEmail?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{verifyFailed && verify && (
|
||||
<div className="alert alert-danger">
|
||||
<i className="bx bx-error-circle me-2" />
|
||||
<strong>
|
||||
{userEmail ? `${userEmail}, la importación de "${moduleName}" falló` : `La importación de "${moduleName}" falló`}
|
||||
</strong>{" "}
|
||||
en: {failed.map((c) => c.label).join(", ")}.
|
||||
<ul className="mb-2 mt-2">
|
||||
{failed.map((c) => (
|
||||
<li key={c.id}>
|
||||
<strong>{c.label}:</strong> {c.detail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{deployInfo && deployInfo.warnings.length > 0 && (
|
||||
<div className="small mb-2">
|
||||
<strong>Advertencias de config de datos:</strong>
|
||||
<ul className="mb-0">
|
||||
{deployInfo.warnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{(verify.logsTail || deployInfo?.deployLog) && (
|
||||
<details className="small mb-2">
|
||||
<summary style={{ cursor: "pointer" }}>Ver logs del deploy</summary>
|
||||
<pre className="bg-dark text-light p-2 mt-1 mb-0 small" style={{ whiteSpace: "pre-wrap", maxHeight: 240, overflow: "auto" }}>
|
||||
{(verify.logsTail || deployInfo?.deployLog || "").slice(-3000)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="small mb-0">
|
||||
El módulo quedó registrado pero <strong>no está funcionando</strong>. Revisa el
|
||||
Dockerfile/puerto (<code>NEXT_PUBLIC_API_URL</code>, puerto interno 3000),
|
||||
corrige el repo y reintenta desde <Link href="/actualizar">Actualizar</Link> o
|
||||
elimínalo en <Link href="/eliminar">Eliminar</Link>. Su estado también es visible
|
||||
en el <Link href={`/m/${moduleName}`}>tablero del módulo</Link>.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "done" && verify?.ok && (
|
||||
<div className="alert alert-success">
|
||||
<i className="bx bx-check-circle me-2" />
|
||||
Módulo <strong>{moduleName}</strong> importado y <strong>verificado</strong>:
|
||||
contenedor {verify.containerStatus || "running"} + HTTP {verify.httpStatus}
|
||||
{verify.publicUrl ? <> + <code>{verify.publicUrl}</code></> : null}.
|
||||
<span className="ms-2">
|
||||
<a href={verify.publicUrl || `https://${moduleName}.devops.norteamericano.cl/`} target="_blank" rel="noopener noreferrer" className="btn btn-sm btn-success ms-1">
|
||||
<i className="bx bx-window-open me-1" /> Abrir aplicación
|
||||
</a>
|
||||
<Link href={`/m/${moduleName}`} className="btn btn-sm btn-outline-success ms-1">
|
||||
Ver tablero
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
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 }[];
|
||||
}
|
||||
|
||||
export 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Boleta {
|
||||
folio: number;
|
||||
alumnoRut?: string | null;
|
||||
alumnoNombre?: string | null;
|
||||
fecha: string;
|
||||
monto: number;
|
||||
tipo?: string;
|
||||
estado?: string;
|
||||
esValido?: boolean;
|
||||
errorMotivo?: string | null;
|
||||
}
|
||||
|
||||
// Fecha real = distinta de nula/vacía y del MinValue de .NET
|
||||
// (0001-01-01: la función PG no informó fecha de caja).
|
||||
function fechaReal(f: string | null | undefined): boolean {
|
||||
return !!f && !f.startsWith("0001-01-01");
|
||||
}
|
||||
|
||||
function fechaCorta(f: string | null | undefined): string {
|
||||
return fechaReal(f) ? String(f).slice(0, 10) : "—";
|
||||
}
|
||||
|
||||
export 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}&limite=200`, {
|
||||
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 diferida a un callback: el setState ocurre fuera
|
||||
// del cuerpo del efecto (regla react-hooks/set-state-in-effect).
|
||||
// Se ejecuta una sola vez a propósito: los cambios de mes/año
|
||||
// posteriores van por el submit del formulario, no por re-render.
|
||||
const t = setTimeout(() => {
|
||||
load(mes, year);
|
||||
}, 0);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const valid = (rows ?? []).filter(
|
||||
(r) => r.esValido !== false && fechaReal(r.fecha)
|
||||
);
|
||||
const errors = (rows ?? []).filter(
|
||||
(r) => r.esValido === false || !fechaReal(r.fecha)
|
||||
);
|
||||
const total = valid.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">
|
||||
{valid.length} válidos
|
||||
{errors.length > 0 && ` · ${errors.length} observados`} · 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 && valid.length === 0 && (
|
||||
<p className="text-muted mb-0">Sin pagos válidos para {mes}/{year}.</p>
|
||||
)}
|
||||
|
||||
{!error && !loading && rows && valid.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>
|
||||
{valid.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">{fechaCorta(r.fecha)}</td>
|
||||
<td className="text-end">${Number(r.monto || 0).toLocaleString("es-CL")}</td>
|
||||
<td className="small">{r.estado || r.tipo || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{valid.length > 100 && (
|
||||
<small className="text-muted">Mostrando 100 de {valid.length}.</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!error && !loading && rows && errors.length > 0 && (
|
||||
<details className="mt-3">
|
||||
<summary className="small text-warning fw-semibold" style={{ cursor: "pointer" }}>
|
||||
<i className="bx bx-error me-1" />
|
||||
{errors.length} registro{errors.length !== 1 ? "s" : ""} observado{
|
||||
errors.length !== 1 ? "s" : ""
|
||||
} (no son ventas válidas)
|
||||
</summary>
|
||||
<div className="table-responsive mt-2">
|
||||
<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>Motivo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{errors.slice(0, 100).map((r, i) => (
|
||||
<tr key={`err-${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">{fechaCorta(r.fecha)}</td>
|
||||
<td className="text-end">${Number(r.monto || 0).toLocaleString("es-CL")}</td>
|
||||
<td className="small text-warning">{r.errorMotivo || "Sin fecha válida"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{errors.length > 100 && (
|
||||
<small className="text-muted">Mostrando 100 de {errors.length}.</small>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<small className="text-muted d-block mt-2">
|
||||
Fuente: <code>GET /api/Boletas/payments?mes=&year=</code> (SAM.API).
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MODULE_NAME_RE,
|
||||
SLUG_SEG_RE,
|
||||
SUBDOMAIN_RE,
|
||||
safeNextPath,
|
||||
validRepoUrl,
|
||||
} from "./validators";
|
||||
|
||||
describe("MODULE_NAME_RE / SUBDOMAIN_RE", () => {
|
||||
it("acepta nombres válidos", () => {
|
||||
expect(MODULE_NAME_RE.test("ventas")).toBe(true);
|
||||
expect(MODULE_NAME_RE.test("sam-api")).toBe(true);
|
||||
expect(SUBDOMAIN_RE.test("academico-empresas")).toBe(true);
|
||||
});
|
||||
it("rechaza traversal y caracteres raros", () => {
|
||||
for (const bad of ["..", ".", "../x", "a/b", "a b", "", "a".repeat(64), "-x"]) {
|
||||
expect(MODULE_NAME_RE.test(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SLUG_SEG_RE", () => {
|
||||
it("acepta segmentos de ruta del proxy", () => {
|
||||
expect(SLUG_SEG_RE.test("modules")).toBe(true);
|
||||
expect(SLUG_SEG_RE.test("mi-modulo_1")).toBe(true);
|
||||
});
|
||||
it("rechaza .., vacío y %", () => {
|
||||
for (const bad of ["..", ".", "", "a/b", "a%b", "a b"]) {
|
||||
expect(SLUG_SEG_RE.test(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validRepoUrl", () => {
|
||||
it("acepta https pública", () => {
|
||||
expect(validRepoUrl("https://git.norteamericano.cl/Nurfog/sam-portal.git")).toBe(true);
|
||||
expect(validRepoUrl("https://github.com/org/repo")).toBe(true);
|
||||
});
|
||||
it("rechaza http, otras schemes y basura", () => {
|
||||
expect(validRepoUrl("http://git.norteamericano.cl/x")).toBe(false);
|
||||
expect(validRepoUrl("ssh://git@git.norteamericano.cl/x")).toBe(false);
|
||||
expect(validRepoUrl("not a url")).toBe(false);
|
||||
expect(validRepoUrl("")).toBe(false);
|
||||
expect(validRepoUrl(null)).toBe(false);
|
||||
});
|
||||
it("rechaza hosts internos y credenciales", () => {
|
||||
expect(validRepoUrl("https://localhost:3000/x")).toBe(false);
|
||||
expect(validRepoUrl("https://127.0.0.1/x")).toBe(false);
|
||||
expect(validRepoUrl("https://10.0.0.5/x")).toBe(false);
|
||||
expect(validRepoUrl("https://192.168.1.10/x")).toBe(false);
|
||||
expect(validRepoUrl("https://172.17.0.2/x")).toBe(false);
|
||||
expect(validRepoUrl("https://svc.internal/x")).toBe(false);
|
||||
expect(validRepoUrl("https://user:pass@git.norteamericano.cl/x")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeNextPath", () => {
|
||||
it("acepta rutas internas", () => {
|
||||
expect(safeNextPath("/tablero")).toBe("/tablero");
|
||||
expect(safeNextPath("/m/ventas")).toBe("/m/ventas");
|
||||
});
|
||||
it("bloquea open redirect y vacíos", () => {
|
||||
expect(safeNextPath("//evil.com")).toBe("/tablero");
|
||||
expect(safeNextPath("//evil.com/x")).toBe("/tablero");
|
||||
expect(safeNextPath("/\\evil.com")).toBe("/tablero");
|
||||
expect(safeNextPath("https://evil.com")).toBe("/tablero");
|
||||
expect(safeNextPath("")).toBe("/tablero");
|
||||
expect(safeNextPath(null)).toBe("/tablero");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// Validadores de seguridad compartidos por las rutas API y el login.
|
||||
// Centralizados aquí para cubrirlos con tests (son la frontera que
|
||||
// separa input de usuario del deployer y de redirects externos).
|
||||
|
||||
export const MODULE_NAME_RE = /^[a-z0-9][a-z0-9-]{1,62}$/i;
|
||||
export const SUBDOMAIN_RE = /^[a-z0-9][a-z0-9-]{1,62}$/i;
|
||||
export const SLUG_SEG_RE = /^[a-z0-9][a-z0-9_-]*$/i;
|
||||
|
||||
// repoUrl de un módulo: https pública, sin credenciales ni hosts
|
||||
// internos (localhost, RFC1918, .local, .internal).
|
||||
export function validRepoUrl(v: unknown): boolean {
|
||||
if (typeof v !== "string" || v.length === 0 || v.length > 500) return false;
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(v);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (u.protocol !== "https:") return false;
|
||||
if (u.username || u.password) return false;
|
||||
const host = u.hostname.toLowerCase();
|
||||
if (
|
||||
host === "localhost" ||
|
||||
host.startsWith("127.") ||
|
||||
host.startsWith("10.") ||
|
||||
host.startsWith("192.168.") ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
||||
host.endsWith(".local") ||
|
||||
host.endsWith(".internal")
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Destino post-login seguro: ruta interna con una sola barra inicial.
|
||||
// Bloquea `//evil.com` y `/\evil.com` (resuelven a origen externo).
|
||||
export function safeNextPath(v: string | null | undefined): string {
|
||||
if (!v || !v.startsWith("/") || v.startsWith("//") || v.startsWith("/\\")) {
|
||||
return "/tablero";
|
||||
}
|
||||
return v;
|
||||
}
|
||||
Reference in new issue
Block a user