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) |
|
| `/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` | 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) |
|
| `/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/modules` | GET público sanitizado / POST sí | Proxy al deployer: GET filtra visibles sin repoUrl ni logs |
|
||||||
| `/api/deployer/*` | sí | Proxy genérico al deployer |
|
| `/api/stats`, `/api/modules/:name/verify`, `/api/inventory` | sí | Proxy/lecturas del deployer (token server-side) |
|
||||||
| `/api/auth`, `/api/me` | — | Sesión HMAC 12h en cookie httpOnly |
|
| `/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
|
## Auth IT
|
||||||
|
|
||||||
@@ -49,6 +51,17 @@ pnpm dev # http://localhost:3000
|
|||||||
pnpm lint && pnpm typecheck && pnpm test && pnpm build
|
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
|
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
|
contenedor (`portal-frontend`). El módulo está registrado en el deployer
|
||||||
(categoría `sam`, subdominio `sam`); sus pushes ya no autodespliegan solos
|
(categoría `sam`, subdominio `sam`); sus pushes ya no autodespliegan solos
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { assertIT } from "@/lib/correo-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_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
|
||||||
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
|
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
|
||||||
|
|
||||||
// Solo segmentos seguros: evita path traversal (.., /, %, etc.)
|
// Solo segmentos seguros: evita path traversal (.., /, %, etc.)
|
||||||
const SEG_RE = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
||||||
|
|
||||||
function badRequest(msg: string) {
|
function badRequest(msg: string) {
|
||||||
return NextResponse.json({ error: msg }, { status: 400 });
|
return NextResponse.json({ error: msg }, { status: 400 });
|
||||||
@@ -27,7 +27,7 @@ async function proxy(req: NextRequest, path: string[]) {
|
|||||||
return badRequest("Ruta inválida");
|
return badRequest("Ruta inválida");
|
||||||
}
|
}
|
||||||
for (const seg of path) {
|
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");
|
return badRequest("Ruta inválida");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { assertIT } from "@/lib/correo-server";
|
import { assertIT } from "@/lib/correo-server";
|
||||||
|
import { MODULE_NAME_RE } from "@/lib/validators";
|
||||||
import type { VerifyCheck, VerifyResult } from "@/lib/verify";
|
import type { VerifyCheck, VerifyResult } from "@/lib/verify";
|
||||||
|
|
||||||
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
|
const DEPLOYER_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
|
||||||
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
|
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
|
||||||
|
|
||||||
const NAME_RE = /^[a-z0-9][a-z0-9-]{1,62}$/i;
|
|
||||||
|
|
||||||
function deployerHeaders(): Record<string, string> {
|
function deployerHeaders(): Record<string, string> {
|
||||||
const h: Record<string, string> = { "Content-Type": "application/json" };
|
const h: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
if (DEPLOYER_TOKEN) h["X-API-Token"] = DEPLOYER_TOKEN;
|
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 });
|
return NextResponse.json({ error: "Requiere sesión IT" }, { status: 401 });
|
||||||
}
|
}
|
||||||
const rawName = (await params).name;
|
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 });
|
return NextResponse.json({ error: "Nombre de módulo inválido" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const name = rawName.toLowerCase();
|
const name = rawName.toLowerCase();
|
||||||
@@ -159,7 +158,7 @@ export async function GET(
|
|||||||
mod?.subdomain ||
|
mod?.subdomain ||
|
||||||
(base.checks.find((c) => c.id === "caddy")?.detail.match(/https:\/\/([a-z0-9-]+)\./)?.[1] as string) ||
|
(base.checks.find((c) => c.id === "caddy")?.detail.match(/https:\/\/([a-z0-9-]+)\./)?.[1] as string) ||
|
||||||
name;
|
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/`;
|
const publicUrl = `https://${subdomain}.devops.norteamericano.cl/`;
|
||||||
let publicStatus: number | null = null;
|
let publicStatus: number | null = null;
|
||||||
const caddyCheck = base.checks.find((c) => c.id === "caddy");
|
const caddyCheck = base.checks.find((c) => c.id === "caddy");
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { assertIT } from "@/lib/correo-server";
|
import { assertIT } from "@/lib/correo-server";
|
||||||
import { sanitizeForPublic, type ModuleState } from "@/lib/sam";
|
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_URL = process.env.DEPLOYER_URL || "http://127.0.0.1:3010";
|
||||||
const DEPLOYER_TOKEN = process.env.DEPLOYER_TOKEN || "";
|
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() {
|
function headers() {
|
||||||
const h: Record<string, string> = { "Content-Type": "application/json" };
|
const h: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
if (DEPLOYER_TOKEN) h["X-API-Token"] = DEPLOYER_TOKEN;
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${DEPLOYER_URL}/api/modules`, {
|
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 repoUrl = String(body?.repoUrl ?? "").trim();
|
||||||
const subdomain =
|
const subdomain =
|
||||||
body?.subdomain === undefined ? undefined : String(body.subdomain).trim().toLowerCase();
|
body?.subdomain === undefined ? undefined : String(body.subdomain).trim().toLowerCase();
|
||||||
if (!NAME_RE.test(name)) {
|
if (!MODULE_NAME_RE.test(name)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Nombre inválido ([a-z0-9-], 2-63, debe empezar con alfanumérico)" },
|
{ error: "Nombre inválido ([a-z0-9-], 2-63, debe empezar con alfanumérico)" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
|
|||||||
@@ -3,17 +3,11 @@
|
|||||||
import { useState, Suspense } from "react";
|
import { useState, Suspense } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { safeNextPath } from "@/lib/validators";
|
||||||
function safeNext(v: string | null): string {
|
|
||||||
if (!v || !v.startsWith("/") || v.startsWith("//") || v.startsWith("/\\")) {
|
|
||||||
return "/tablero";
|
|
||||||
}
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoginForm() {
|
function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const next = safeNext(useSearchParams().get("next"));
|
const next = safeNextPath(useSearchParams().get("next"));
|
||||||
const [rut, setRut] = useState("");
|
const [rut, setRut] = useState("");
|
||||||
const [clave, setClave] = useState("");
|
const [clave, setClave] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -31,7 +25,7 @@ function LoginForm() {
|
|||||||
});
|
});
|
||||||
const j = await res.json();
|
const j = await res.json();
|
||||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
router.push(safeNext(next));
|
router.push(safeNextPath(next));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Error al ingresar");
|
setError(err instanceof Error ? err.message : "Error al ingresar");
|
||||||
|
|||||||
+2
-396
@@ -1,9 +1,10 @@
|
|||||||
"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 { Bar } from "@/components/ProgressBar";
|
import { Bar } from "@/components/ProgressBar";
|
||||||
|
import { HelpdeskUsage } from "@/components/HelpdeskUsage";
|
||||||
|
import { PagosUsage } from "@/components/PagosUsage";
|
||||||
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 {
|
||||||
@@ -24,401 +25,6 @@ import {
|
|||||||
const API_URL =
|
const API_URL =
|
||||||
process.env.NEXT_PUBLIC_API_URL || "https://api.devops.norteamericano.cl";
|
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 +
|
// Tablero funcional del módulo: estado + carga de trabajo +
|
||||||
// uso real (equipo + estadísticas). Ya no muestra la lista
|
// uso real (equipo + estadísticas). Ya no muestra la lista
|
||||||
// inventada "Funciones disponibles de la API": el uso de la API
|
// inventada "Funciones disponibles de la API": el uso de la API
|
||||||
|
|||||||
+26
-144
@@ -1,38 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import SamShell from "@/components/SamShell";
|
import SamShell from "@/components/SamShell";
|
||||||
import { useModules, useMe } from "@/lib/store"
|
import { useModules, useMe } from "@/lib/store"
|
||||||
import { itMenu, assignModuleColor } from "@/lib/sam";
|
import { itMenu, assignModuleColor } from "@/lib/sam";
|
||||||
import type { VerifyCheck, VerifyResult } from "@/lib/verify";
|
import type { VerifyResult } from "@/lib/verify";
|
||||||
import { failedChecks, verifySummary } from "@/lib/verify";
|
import { failedChecks } from "@/lib/verify";
|
||||||
import { useVerifyPoll, VERIFY_TOTAL } from "@/lib/use-verify-poll";
|
import { useVerifyPoll } from "@/lib/use-verify-poll";
|
||||||
|
import {
|
||||||
|
DeployAlerts,
|
||||||
|
VerifyTimeline,
|
||||||
|
type DeployInfo,
|
||||||
|
type DeployPhase,
|
||||||
|
} from "@/components/DeployStatus";
|
||||||
|
|
||||||
type Phase = "idle" | "deploying" | "verifying" | "done";
|
type Phase = DeployPhase;
|
||||||
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UploadPage() {
|
export default function UploadPage() {
|
||||||
const { data } = useModules();
|
const { data } = useModules();
|
||||||
@@ -193,124 +175,24 @@ export default function UploadPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progreso de verificación post-importación */}
|
{/* Progreso de verificación post-importación */}
|
||||||
{(phase === "verifying" || (verify && phase === "done")) && (
|
<VerifyTimeline
|
||||||
<div className="app-card p-3 mb-3 border">
|
moduleName={moduleName}
|
||||||
<h6 className="fw-semibold mb-1">
|
phase={phase}
|
||||||
<i className="bx bx-check-shield me-1" />
|
verify={verify}
|
||||||
Verificación de <code>{moduleName}</code>
|
deployInfo={deployInfo}
|
||||||
{phase === "verifying" && (
|
attempt={attempt}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Aviso directo a quien sube: verificación fallida */}
|
{/* Aviso directo a quien sube: verificación fallida */}
|
||||||
{verifyFailed && (
|
<DeployAlerts
|
||||||
<div className="alert alert-danger">
|
moduleName={moduleName}
|
||||||
<i className="bx bx-error-circle me-2" />
|
phase={phase}
|
||||||
<strong>
|
verify={verify}
|
||||||
{me?.email ? `${me.email}, la importación de "${moduleName}" falló` : `La importación de "${moduleName}" falló`}
|
deployInfo={deployInfo}
|
||||||
</strong>{" "}
|
failed={failed}
|
||||||
en: {failed.map((c) => c.label).join(", ")}.
|
verifyFailed={!!verifyFailed}
|
||||||
<ul className="mb-2 mt-2">
|
userEmail={me?.email || undefined}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="row g-3">
|
<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