feat: index SAM con tableros por modulo (SamShell Academico + sidebar de funciones API)
This commit is contained in:
1 parent
6d30e1c274
commit
d30374729c
8 files changed
+719
-86
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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({
|
||||
|
||||
@@ -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<ApiResponse | null>(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 (
|
||||
<SamShell
|
||||
brand={title}
|
||||
subtitle="Tablero del módulo"
|
||||
menu={menu}
|
||||
activeHref={activeFn ? `#${activeFn.path}` : undefined}
|
||||
topUser="SAM"
|
||||
topSede={mod ? `${mod.subdomain}.devops.norteamericano.cl` : undefined}
|
||||
backHref="/"
|
||||
>
|
||||
{loading && (
|
||||
<div className="text-center py-5">
|
||||
<div className="spinner-border text-primary" role="status" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !mod && (
|
||||
<div className="alert alert-warning">
|
||||
<i className="bx bx-error me-2" />
|
||||
El módulo <strong>{name}</strong> no está integrado todavía.
|
||||
<Link href="/" className="btn btn-sm btn-outline-primary ms-3">
|
||||
Volver al índice
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && mod && activeFn && (
|
||||
<>
|
||||
<div className="alert alert-light section-header mb-3 py-2 px-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<span>
|
||||
<i className={`bx ${activeFn.icon} me-2`} />
|
||||
{activeFn.label}
|
||||
<small className="text-muted ms-2 d-block d-md-inline">
|
||||
{activeFn.desc}
|
||||
</small>
|
||||
</span>
|
||||
<span className="d-flex gap-2">
|
||||
<span className="badge bg-secondary font-monospace">
|
||||
GET {activeFn.path}
|
||||
</span>
|
||||
<a
|
||||
href={`${API_URL}/${activeFn.path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
>
|
||||
<i className="bx bx-link-external me-1" />
|
||||
API
|
||||
</a>
|
||||
<a
|
||||
href={moduleUrl(mod)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-sm btn-primary"
|
||||
>
|
||||
<i className="bx bx-window-open me-1" />
|
||||
Abrir aplicación
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="row row-cards">
|
||||
<div className="col-12">
|
||||
<div className="app-card p-4">
|
||||
<h6 className="fw-bold mb-3">
|
||||
<i className="bx bx-list-ul me-2" />
|
||||
Funciones disponibles de la API
|
||||
</h6>
|
||||
<div className="list-group">
|
||||
{functions.map((f) => (
|
||||
<button
|
||||
key={f.path}
|
||||
onClick={() => {
|
||||
window.location.hash = f.path;
|
||||
}}
|
||||
className={`list-group-item list-group-item-action d-flex justify-content-between align-items-center${
|
||||
f.path === activeFn.path ? " active" : ""
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
<i className={`bx ${f.icon} me-2`} />
|
||||
{f.label}
|
||||
<small className="text-muted d-block">{f.desc}</small>
|
||||
</span>
|
||||
<code className="small">{f.path}</code>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted small mt-3 mb-0">
|
||||
Las funciones se consumen desde {API_URL}. Usa «Abrir
|
||||
aplicación» para operar en {moduleUrl(mod)}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</SamShell>
|
||||
);
|
||||
}
|
||||
+67
-84
@@ -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<string, { icon: string; bg: string }> = {
|
||||
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<ApiResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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() {
|
||||
<div className="row text-light login-main-text">
|
||||
<div className="col-12 text-center">
|
||||
<div
|
||||
className="mx-auto d-flex align-items-center justify-content-center bg-white rounded-3"
|
||||
className="mx-auto d-flex align-items-center justify-content-center bg-white rounded-3 overflow-hidden"
|
||||
style={{ width: 80, height: 80 }}
|
||||
>
|
||||
<i className="bx bx-layer text-primary" style={{ fontSize: 48 }} />
|
||||
<img src="/logo-sam.png" alt="SAM" style={{ width: 64, height: 64, objectFit: "contain" }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 text-center mt-3">
|
||||
@@ -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 (
|
||||
<div className="col-md-6" key={m.name}>
|
||||
<div className="app-card p-2">
|
||||
@@ -183,14 +159,12 @@ export default function Home() {
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<Link
|
||||
href={`/m/${m.name}`}
|
||||
className="text-decoration-none text-dark d-block py-2 stretched-link"
|
||||
>
|
||||
{displayName}
|
||||
</a>
|
||||
{name}
|
||||
</Link>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<span className={`status-dot ${status.cls}`} />
|
||||
<small className="text-muted" style={{ fontSize: "0.75rem" }}>
|
||||
@@ -199,7 +173,16 @@ export default function Home() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-end pe-3" style={{ width: 40 }}>
|
||||
<i className="bx bx-chevron-right text-muted" />
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Abrir ${url}`}
|
||||
className="position-relative"
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<i className="bx bx-window-open text-muted" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -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 (
|
||||
<div className={`sam-shell${open ? " open" : ""}`}>
|
||||
<div className="sam-sidebar">
|
||||
<div className="logo-details">
|
||||
<span className="logo_name">
|
||||
<img src="/favicon.png" alt="SAM" width={28} height={28} />
|
||||
<span className="brand-text">SAM</span>
|
||||
</span>
|
||||
<i
|
||||
className={`bx ${open ? "bx-menu-alt-right" : "bx-menu"}`}
|
||||
id="btn"
|
||||
onClick={() => setOpen(!open)}
|
||||
role="button"
|
||||
aria-label="Alternar menú"
|
||||
/>
|
||||
</div>
|
||||
{backHref && (
|
||||
<div className="px-3 pt-2">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="btn btn-sm btn-outline-light w-100 d-flex align-items-center justify-content-center gap-1"
|
||||
>
|
||||
<i className="bx bx-grid-alt" />
|
||||
<span className="links_name">Módulos</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<ul className="nav-list">
|
||||
{menu.map((item) => (
|
||||
<li key={item.href + item.label}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={activeHref === item.href ? "active" : ""}
|
||||
>
|
||||
<i className={`bx ${item.icon}`} />
|
||||
<span className="links_name">{item.label}</span>
|
||||
</Link>
|
||||
<span className="tooltip">{item.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="profile">
|
||||
<div className="profile-details">
|
||||
<i className="bx bx-user-circle" />
|
||||
<div className="name_job">
|
||||
<div className="name">{topUser || brand}</div>
|
||||
<div className="job">{topSede || subtitle || "SAM"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="home-section">
|
||||
<div className="container-fluid">
|
||||
<div className="row sam-toolbar align-items-center py-2 mb-3">
|
||||
<div className="col-md-6">
|
||||
<h5 className="mb-0 fw-bold">
|
||||
<span style={{ color: "#283583" }}>{brand}</span>
|
||||
{subtitle && (
|
||||
<small className="text-muted ms-2">{subtitle}</small>
|
||||
)}
|
||||
</h5>
|
||||
</div>
|
||||
<div className="col-md-6 text-end">
|
||||
{topUser && (
|
||||
<span className="me-3">
|
||||
<i className="bx bx-user me-1" />
|
||||
{topUser}
|
||||
</span>
|
||||
)}
|
||||
{topSede && (
|
||||
<span>
|
||||
<i className="bx bx-home me-1" />
|
||||
{topSede}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+169
@@ -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<string, ApiFunction[]> = {
|
||||
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<string, string> = {
|
||||
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<string, { icon: string; bg: string }> = {
|
||||
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`;
|
||||
}
|
||||
Reference in new issue
Block a user