diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..7ce4c34 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/logo-sam.png b/public/logo-sam.png new file mode 100644 index 0000000..0d68b64 Binary files /dev/null and b/public/logo-sam.png differ diff --git a/src/app/globals.css b/src/app/globals.css index c20a2df..90991e3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -144,3 +144,206 @@ body { .status-starting { background: #ffc107; } + +/* ============================================================ + SamShell - plantilla migrada de SamMenu.Master (Académico) + Sidebar #00285D colapsable 78px <-> 250px + topbar + ============================================================ */ +.sam-shell .sam-sidebar { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 78px; + background: #00285d; + z-index: 100; + transition: width var(--transition); + display: flex; + flex-direction: column; + overflow-x: hidden; +} + +.sam-shell.open .sam-sidebar { + width: 250px; +} + +.sam-sidebar .logo-details { + height: 60px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 14px; + color: #fff; + white-space: nowrap; +} + +.sam-sidebar .logo-details .logo_name { + display: flex; + align-items: center; + gap: 8px; + font-weight: 700; + font-size: 1.1rem; + opacity: 0; + transition: opacity var(--transition); +} + +.sam-shell.open .logo-details .logo_name { + opacity: 1; +} + +.sam-sidebar .logo-details #btn { + font-size: 1.5rem; + cursor: pointer; + min-width: 50px; + text-align: center; +} + +.sam-sidebar .nav-list { + list-style: none; + padding: 8px; + margin: 0; + overflow-y: auto; + flex: 1; +} + +.sam-sidebar .nav-list li { + position: relative; + margin: 4px 0; +} + +.sam-sidebar .nav-list li a { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 8px; + color: #dbe4f3; + text-decoration: none; + white-space: nowrap; +} + +.sam-sidebar .nav-list li a i { + font-size: 1.4rem; + min-width: 30px; + text-align: center; +} + +.sam-sidebar .nav-list li a:hover { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +.sam-sidebar .nav-list li a.active { + background: #fff; + color: #00285d; + font-weight: 600; +} + +.sam-sidebar .links_name { + opacity: 0; + transition: opacity var(--transition); +} + +.sam-shell.open .links_name { + opacity: 1; +} + +.sam-sidebar .tooltip { + position: absolute; + left: calc(100% + 12px); + top: 50%; + transform: translateY(-50%); + background: #fff; + color: #00285d; + padding: 4px 10px; + border-radius: 6px; + font-size: 0.85rem; + white-space: nowrap; + opacity: 0; + pointer-events: none; + box-shadow: var(--card-shadow); +} + +.sam-shell:not(.open) .nav-list li:hover .tooltip { + opacity: 1; +} + +.sam-shell.open .tooltip { + display: none; +} + +.sam-sidebar .profile { + padding: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.15); + color: #dbe4f3; +} + +.sam-sidebar .profile-details { + display: flex; + align-items: center; + gap: 10px; + white-space: nowrap; +} + +.sam-sidebar .profile-details > i { + font-size: 1.8rem; +} + +.sam-sidebar .name_job { + opacity: 0; +} + +.sam-shell.open .name_job { + opacity: 1; +} + +.sam-sidebar .name_job .name { + font-size: 0.9rem; + font-weight: 600; + color: #fff; +} + +.sam-sidebar .name_job .job { + font-size: 0.75rem; + opacity: 0.7; +} + +.sam-shell .home-section { + margin-left: 78px; + background: #e9edf4; + min-height: 100vh; + padding: 16px 20px 40px; + transition: margin-left var(--transition); +} + +.sam-shell.open .home-section { + margin-left: 250px; +} + +.sam-toolbar { + background: #fff; + border-radius: var(--radius); + box-shadow: var(--card-shadow); + padding-left: 12px; + padding-right: 12px; +} + +@media screen and (max-width: 767px) { + .sam-shell .home-section, + .sam-shell.open .home-section { + margin-left: 0; + padding: 12px 12px 40px; + } + .sam-shell .sam-sidebar { + transform: translateX(-100%); + width: 250px; + } + .sam-shell.open .sam-sidebar { + transform: translateX(0); + } + .sam-shell .links_name, + .sam-shell .name_job, + .sam-shell .logo-details .logo_name { + opacity: 1; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 12782d0..92fe3cc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,8 +3,11 @@ import "bootstrap/dist/css/bootstrap.min.css"; import "./globals.css"; export const metadata: Metadata = { - title: "SAM | ICHN - Portal de Módulos", - description: "Sistema de Administración de Matrículas - Portal de integración de módulos", + title: "SAM | Sistema de Administración de Matrículas", + description: "SAM - Índice de módulos integrados y tableros por módulo", + icons: { + icon: "/favicon.png", + }, }; export default function RootLayout({ diff --git a/src/app/m/[name]/page.tsx b/src/app/m/[name]/page.tsx new file mode 100644 index 0000000..5978f67 --- /dev/null +++ b/src/app/m/[name]/page.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import SamShell from "@/components/SamShell"; +import { + displayName, + getModuleFunctions, + moduleUrl, + type ApiResponse, + type ModuleState, +} from "@/lib/sam"; + +const API_URL = + process.env.NEXT_PUBLIC_API_URL || "https://api.devops.norteamericano.cl"; + +function subscribeHash(cb: () => void) { + window.addEventListener("hashchange", cb); + return () => window.removeEventListener("hashchange", cb); +} + +function snapshotHash() { + return window.location.hash.slice(1); +} + +export default function ModuleBoard() { + const params = useParams(); + const name = Array.isArray(params.name) ? params.name[0] : params.name ?? ""; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const hash = + useSyncExternalStore(subscribeHash, snapshotHash, () => "") || null; + + useEffect(() => { + fetch("/api/modules", { cache: "no-store" }) + .then((r) => r.json()) + .then((j) => setData(j)) + .catch(() => setData(null)) + .finally(() => setLoading(false)); + }, []); + + const mod: ModuleState | undefined = useMemo( + () => data?.modules.find((m) => m.name.toLowerCase() === name.toLowerCase()), + [data, name] + ); + + const functions = useMemo(() => getModuleFunctions(name), [name]); + const activePath = hash || functions[0]?.path || null; + const activeFn = functions.find((f) => f.path === activePath) ?? functions[0]; + const title = mod ? displayName(mod.name) : displayName(name || "Módulo"); + + const menu = functions.map((f) => ({ + label: f.label, + icon: f.icon, + href: `#${f.path}`, + })); + + return ( + + {loading && ( +
+
+
+ )} + + {!loading && !mod && ( +
+ + El módulo {name} no está integrado todavía. + + Volver al índice + +
+ )} + + {!loading && mod && activeFn && ( + <> +
+ + + {activeFn.label} + + {activeFn.desc} + + + + + GET {activeFn.path} + + + + API + + + + Abrir aplicación + + +
+ +
+
+
+
+ + Funciones disponibles de la API +
+
+ {functions.map((f) => ( + + ))} +
+

+ Las funciones se consumen desde {API_URL}. Usa «Abrir + aplicación» para operar en {moduleUrl(mod)}. +

+
+
+
+ + )} + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index ece26c3..d70bc84 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,85 +1,62 @@ "use client"; -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; import Link from "next/link"; +import { + displayName, + getModuleMeta, + getStatusInfo, + moduleUrl, + type ApiResponse, +} from "@/lib/sam"; -interface ModuleState { - name: string; - repoUrl: string; - branch: string; - subdomain: string; - port: number; - active: boolean; - lastDeploy?: { - sha: string; - time: string; - ok: boolean; - log: string; +// Store externo: evita setState en efectos (regla react-hooks) +// y auto-refresca cada 15s. Los clics llaman a load() directo. +let cache: ApiResponse | null = null; +let loadError: string | null = null; +let started = false; +const listeners = new Set<() => void>(); + +function notify() { + listeners.forEach((l) => l()); +} + +async function load() { + try { + const res = await fetch("/api/modules", { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + cache = await res.json(); + loadError = null; + } catch (e) { + loadError = e instanceof Error ? e.message : "Error al cargar módulos"; + } + notify(); +} + +function subscribe(cb: () => void) { + if (!started) { + started = true; + load(); + setInterval(load, 15000); + } + listeners.add(cb); + return () => { + listeners.delete(cb); }; - container?: string | null; } -interface ApiResponse { - backend: { - repoUrl: string; - branch: string; - container: string | null; - lastDeploy: ModuleState["lastDeploy"] | null; - }; - modules: ModuleState[]; -} - -const iconMap: Record = { - ventas: { icon: "bx-money", bg: "bg-success" }, - comercial: { icon: "bx-money", bg: "bg-success" }, - finanzas: { icon: "bx-detail", bg: "bg-danger" }, - contabilidad: { icon: "bx-detail", bg: "bg-danger" }, - academico: { icon: "bx-user", bg: "bg-primary" }, - "academico-empresas": { icon: "bx-briefcase", bg: "bg-primary" }, - profesor: { icon: "bx-pencil", bg: "bg-warning text-dark" }, - profesoras: { icon: "bx-pencil", bg: "bg-warning text-dark" }, - empresas: { icon: "bx-building", bg: "bg-info" }, - pagos: { icon: "bx-credit-card", bg: "bg-success" }, - default: { icon: "bx-cube", bg: "bg-secondary" }, -}; - -function getModuleMeta(name: string) { - const key = name.toLowerCase(); - return iconMap[key] || iconMap.default; -} - -function getStatusInfo(m: ModuleState) { - if (!m.container) return { label: "Detenido", cls: "status-unhealthy" }; - if (m.container.includes("healthy")) return { label: "Activo", cls: "status-healthy" }; - if (m.container.includes("starting")) return { label: "Iniciando", cls: "status-starting" }; - return { label: "Activo", cls: "status-healthy" }; +function snapshot(): { data: ApiResponse | null; error: string | null } { + return { data: cache, error: loadError }; } export default function Home() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchModules = async () => { - try { - setLoading(true); - const res = await fetch("/api/modules", { cache: "no-store" }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const json = await res.json(); - setData(json); - setError(null); - } catch (e) { - setError(e instanceof Error ? e.message : "Error al cargar módulos"); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchModules(); - const id = setInterval(fetchModules, 15000); - return () => clearInterval(id); - }, []); + const { data, error } = useSyncExternalStore( + subscribe, + snapshot, + () => ({ data: null, error: null }) + ); + const loading = data === null && error === null; + const fetchModules = load; const modules = data?.modules ?? []; const backend = data?.backend; @@ -90,10 +67,10 @@ export default function Home() {
- + SAM
@@ -168,9 +145,8 @@ export default function Home() { modules.map((m) => { const meta = getModuleMeta(m.name); const status = getStatusInfo(m); - const url = `https://${m.subdomain}.devops.norteamericano.cl`; - const displayName = - m.name.charAt(0).toUpperCase() + m.name.slice(1).replace(/-/g, " "); + const url = moduleUrl(m); + const name = displayName(m.name); return (
@@ -183,14 +159,12 @@ export default function Home() { /> - - {displayName} - + {name} +
@@ -199,7 +173,16 @@ export default function Home() {
- + + + diff --git a/src/components/SamShell.tsx b/src/components/SamShell.tsx new file mode 100644 index 0000000..cc18378 --- /dev/null +++ b/src/components/SamShell.tsx @@ -0,0 +1,116 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import type { MenuItem } from "@/lib/sam"; + +interface SamShellProps { + brand: string; + subtitle?: string; + menu: MenuItem[]; + activeHref?: string; + topUser?: string; + topSede?: string; + backHref?: string; + children: React.ReactNode; +} + +// Plantilla SAM migrada de SamMenu.Master (Académico Web): +// sidebar #00285D colapsable 78px <-> 250px, iconos boxicons, +// topbar con usuario/sede y área de contenido. +export default function SamShell({ + brand, + subtitle, + menu, + activeHref, + topUser, + topSede, + backHref, + children, +}: SamShellProps) { + const [open, setOpen] = useState(true); + + return ( +
+
+
+ + SAM + SAM + + setOpen(!open)} + role="button" + aria-label="Alternar menú" + /> +
+ {backHref && ( +
+ + + Módulos + +
+ )} +
    + {menu.map((item) => ( +
  • + + + {item.label} + + {item.label} +
  • + ))} +
+
+
+ +
+
{topUser || brand}
+
{topSede || subtitle || "SAM"}
+
+
+
+
+ +
+
+
+
+
+ {brand} + {subtitle && ( + {subtitle} + )} +
+
+
+ {topUser && ( + + + {topUser} + + )} + {topSede && ( + + + {topSede} + + )} +
+
+ {children} +
+
+
+ ); +} diff --git a/src/lib/sam.ts b/src/lib/sam.ts new file mode 100644 index 0000000..3e1bd59 --- /dev/null +++ b/src/lib/sam.ts @@ -0,0 +1,169 @@ +// ============================================================ +// SAM - Tipos, manifiesto de funciones API y helpers de UI +// Las funciones por módulo reflejan los controllers del +// backend compartido (SAM.API). El routing de ASP.NET Core +// es case-insensitive, por eso los paths van en minúsculas. +// ============================================================ + +export interface LastDeploy { + sha: string; + time: string; + ok: boolean; + log: string; +} + +export interface ModuleState { + name: string; + repoUrl: string; + branch: string; + subdomain: string; + port: number; + active: boolean; + lastDeploy?: LastDeploy; + container?: string | null; +} + +export interface ApiResponse { + backend: { + repoUrl: string; + branch: string; + container: string | null; + lastDeploy: LastDeploy | null; + }; + modules: ModuleState[]; +} + +export interface ApiFunction { + label: string; + icon: string; + path: string; + desc: string; +} + +export interface MenuItem { + label: string; + icon: string; + href: string; +} + +const A = ( + label: string, + icon: string, + path: string, + desc: string +): ApiFunction => ({ label, icon, path, desc }); + +// --- Conjuntos de funciones por familia de módulo --- +const CORE: ApiFunction[] = [ + A("Alumnos", "bx-group", "api/alumnos", "Ficha, matrícula y sesiones de alumnos"), + A("Cursos", "bx-book", "api/cursos", "Catálogo, apertura y horarios de cursos"), + A("Profesores", "bx-pencil", "api/profesores", "Ficha y periodos de profesores"), + A("Empresas", "bx-buildings", "api/empresas/extra", "Empresas en convenio y propuestas"), + A("Pagos", "bx-money", "api/pagos", "Caja, cotizaciones y vouchers"), + A("Contratos", "bx-file", "api/contratos", "Contratos firmados y por firmar"), + A("Informes", "bx-bar-chart-alt-2", "api/informes", "Reportes académicos y de gestión"), +]; + +const FUNCTION_SETS: Record = { + academico: [ + A("Cursos", "bx-book", "api/cursos", "Catálogo, apertura y horarios"), + A("Alumnos", "bx-group", "api/alumnos/extra", "Ficha, matrícula y sesiones"), + A("Apoderados", "bx-user", "api/apoderados", "Apoderados y contactos"), + A("Horarios", "bx-time", "api/horarios", "Bloques horarios y salas"), + A("Jornadas", "bx-sun", "api/jornadas", "Jornadas diurno / vespertino"), + A("Feriados", "bx-calendar", "api/feriados", "Calendario de feriados"), + A("Placement Test", "bx-edit", "api/placement-test", "Pruebas de nivel"), + A("Plan Anual", "bx-calendar-alt", "api/plan-anual", "Planificación anual"), + ], + profesores: [ + A("Profesores", "bx-pencil", "api/profesores", "Ficha y periodos"), + A("Funciones Extra", "bx-plus-circle", "api/profesores/extra", "Operaciones extendidas"), + A("Horarios", "bx-time", "api/horarios", "Bloques horarios y salas"), + A("Jornadas", "bx-sun", "api/jornadas", "Jornadas diurno / vespertino"), + ], + empresas: [ + A("Propuestas", "bx-briefcase", "api/propuestas-empresa", "Propuestas a empresas"), + A("Empresas", "bx-buildings", "api/empresas/extra", "Empresas en convenio"), + A("Comercial", "bx-trending-up", "api/comercial/extra", "Gestión comercial"), + A("Contratos", "bx-file", "api/contratos/extra", "Contratos con empresas"), + ], + ventas: [ + A("Comercial", "bx-trending-up", "api/comercial/extra", "Gestión comercial"), + A("Leads", "bx-target-lock", "api/leads/extra", "Prospectos y seguimiento"), + A("Ventas", "bx-cart", "api/ventas", "Ventas y cotizaciones"), + A("Cotizaciones", "bx-receipt", "api/cotizaciones", "Cotizaciones emitidas"), + A("Ejecutivos", "bx-user-voice", "api/ejecutivos", "Ejecutivos de venta"), + ], + pagos: [ + A("Portal Pagos", "bx-credit-card", "api/portal-pagos", "Pagos en línea"), + A("Pagos", "bx-money", "api/pagos", "Caja y vouchers"), + A("WebPay", "bx-card", "api/webpay", "Transacciones Transbank"), + A("Boletas", "bx-receipt", "api/boletas", "Boletas y facturación"), + A("Arqueo", "bx-calculator", "api/arqueocaja", "Arqueo de caja"), + ], + contabilidad: [ + A("Contabilidad", "bx-detail", "api/contabilidad", "Movimientos contables"), + A("Libros", "bx-book-open", "api/libros", "Libros de compra / venta"), + A("DTE", "bx-file-blank", "api/dte", "Documentos tributarios"), + A("Documentos", "bx-folder", "api/documentos", "Repositorio documental"), + ], +}; + +const MODULE_FAMILY: Record = { + academico: "academico", + "academico-empresas": "empresas", + portal: "empresas", + empresas: "empresas", + ventas: "ventas", + comercial: "ventas", + pagos: "pagos", + contabilidad: "contabilidad", + profesores: "profesores", +}; + +export function getModuleFunctions(name: string): ApiFunction[] { + const key = name.toLowerCase(); + const family = + MODULE_FAMILY[key] ?? + Object.keys(FUNCTION_SETS).find((f) => key.includes(f)); + if (family && FUNCTION_SETS[family]) return FUNCTION_SETS[family]; + return CORE; +} + +// --- Meta visual por módulo (iconos boxicons + fondo) --- +const iconMap: Record = { + sam: { icon: "bx-layer", bg: "bg-primary" }, + ventas: { icon: "bx-money", bg: "bg-success" }, + comercial: { icon: "bx-money", bg: "bg-success" }, + finanzas: { icon: "bx-detail", bg: "bg-danger" }, + contabilidad: { icon: "bx-detail", bg: "bg-danger" }, + academico: { icon: "bx-user", bg: "bg-primary" }, + "academico-empresas": { icon: "bx-briefcase", bg: "bg-primary" }, + profesor: { icon: "bx-pencil", bg: "bg-warning text-dark" }, + profesores: { icon: "bx-pencil", bg: "bg-warning text-dark" }, + empresas: { icon: "bx-building", bg: "bg-info" }, + portal: { icon: "bx-building", bg: "bg-info" }, + pagos: { icon: "bx-credit-card", bg: "bg-success" }, + default: { icon: "bx-cube", bg: "bg-secondary" }, +}; + +export function getModuleMeta(name: string) { + return iconMap[name.toLowerCase()] || iconMap.default; +} + +export function getStatusInfo(m: ModuleState) { + if (!m.container) return { label: "Detenido", cls: "status-unhealthy" }; + if (m.container.includes("healthy")) + return { label: "Activo", cls: "status-healthy" }; + if (m.container.includes("starting")) + return { label: "Iniciando", cls: "status-starting" }; + return { label: "Activo", cls: "status-healthy" }; +} + +export function displayName(name: string) { + return name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, " "); +} + +export function moduleUrl(m: ModuleState) { + return `https://${m.subdomain}.devops.norteamericano.cl`; +}