Files
whatsapp/modules/soporte/views/documentacion.php
T
Lizandro GuarnizoandClaude Opus 5 018fb13332 Módulo Soporte: visor de documentación con renderizado Markdown y buscador
Nuevo módulo `soporte` con la documentación del proyecto en cuatro secciones:
manual de usuario (visible para todos), y documentación técnica, arquitectura
y operación (solo administradores).

- Markdown.php: renderizador propio del subconjunto que usa la documentación
  (encabezados, listas anidadas, tablas, código, citas). Escapa todo el texto
  antes de aplicar formato, así que los .md no pueden inyectar HTML. Se
  prefirió un archivo auditable a incorporar una dependencia externa.
- DocIndex.php: descubre los .md, arma el árbol, resuelve acceso por sección
  y construye el índice del buscador.
- Generadores.php: expande marcadores {{modulos}}, {{endpoints}}, {{tablas}},
  {{roles}} y {{servicios}} leyendo el código y la base en cada carga, para
  que los inventarios no puedan quedar desactualizados.

Se registra en SYSTEM_MODULES y se concede a los 12 roles con permission=read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:07:06 -05:00

297 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* modules/soporte/views/documentacion.php
* Visor de la documentación del proyecto.
*
* ?s=<seccion>&d=<documento> documento puntual
* ?s=<seccion> portada de la sección
* (sin parámetros) portada general
*/
require_once APP_ROOT . '/config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
require_once __DIR__ . '/../Markdown.php';
require_once __DIR__ . '/../DocIndex.php';
require_once __DIR__ . '/../Generadores.php';
$arbol = DocIndex::arbol();
$seccion = preg_replace('/[^a-z0-9-]/', '', $_GET['s'] ?? '');
$docSlug = preg_replace('/[^a-z0-9-]/', '', $_GET['d'] ?? '');
$archivo = ($seccion && $docSlug) ? DocIndex::resolver($seccion, $docSlug) : null;
$titulo = 'Documentación';
$cuerpo = '';
$toc = [];
if ($archivo) {
$md = Generadores::expandir((string)file_get_contents($archivo));
$cuerpo = Markdown::render($md);
$toc = Markdown::indice($md);
foreach ($arbol[$seccion]['docs'] ?? [] as $d) {
if ($d['slug'] === $docSlug) { $titulo = $d['titulo']; break; }
}
} elseif ($seccion && isset($arbol[$seccion])) {
$titulo = $arbol[$seccion]['titulo'];
}
Layout::open('Soporte · Documentación', 'fas fa-life-ring');
?>
<style>
.doc-wrap { display:flex; gap:0; align-items:flex-start; min-height:calc(100vh - 60px); background:#fff; }
/* ── Índice lateral ── */
.doc-nav { width:280px; flex-shrink:0; border-right:1px solid #e2e8f0; background:#f8fafc;
align-self:stretch; padding:18px 0 60px; position:sticky; top:0; max-height:100vh; overflow-y:auto; }
.doc-nav .buscador { padding:0 16px 14px; }
.doc-nav .buscador input { width:100%; font-size:.84rem; padding:7px 11px; border:1px solid #cbd5e1;
border-radius:8px; background:#fff; }
.doc-nav .buscador input:focus { outline:none; border-color:#2563eb; box-shadow:0 0 0 3px rgba(37,99,235,.12); }
.doc-nav-sec { padding:10px 16px 4px; font-size:.68rem; font-weight:800; text-transform:uppercase;
letter-spacing:.08em; color:#64748b; display:flex; align-items:center; gap:6px; }
.doc-nav a { display:block; padding:5px 16px 5px 30px; font-size:.83rem; color:#334155;
text-decoration:none; border-left:2px solid transparent; }
.doc-nav a:hover { background:#eef2f7; color:#1e293b; }
.doc-nav a.activo { background:#e0edff; color:#1d4ed8; font-weight:600; border-left-color:#2563eb; }
/* ── Resultados de búsqueda ── */
#resultados { padding:0 16px; }
#resultados .r { display:block; padding:8px 10px; border-radius:8px; text-decoration:none;
margin-bottom:4px; background:#fff; border:1px solid #e2e8f0; }
#resultados .r:hover { border-color:#2563eb; }
#resultados .r-t { font-size:.83rem; font-weight:600; color:#1e293b; }
#resultados .r-c { font-size:.68rem; color:#64748b; text-transform:uppercase; letter-spacing:.05em; }
#resultados .r-x { font-size:.75rem; color:#475569; margin-top:2px; }
#resultados .vacio { font-size:.8rem; color:#94a3b8; padding:8px 4px; }
/* ── Contenido ── */
.doc-main { flex:1; min-width:0; display:flex; gap:0; }
.doc-body { flex:1; min-width:0; padding:26px 40px 80px; max-width:900px; }
.doc-ruta { font-size:.73rem; color:#94a3b8; margin-bottom:14px; }
.doc-ruta a { color:#64748b; text-decoration:none; }
.doc-ruta a:hover { text-decoration:underline; }
.doc-body h1 { font-size:1.65rem; font-weight:800; color:#0f172a; margin:0 0 18px;
padding-bottom:12px; border-bottom:1px solid #e2e8f0; }
.doc-body h2 { font-size:1.2rem; font-weight:700; color:#1e293b; margin:32px 0 12px;
padding-top:6px; scroll-margin-top:20px; }
.doc-body h3 { font-size:1rem; font-weight:700; color:#334155; margin:24px 0 9px; scroll-margin-top:20px; }
.doc-body h4 { font-size:.9rem; font-weight:700; color:#475569; margin:18px 0 7px; }
.doc-body p { font-size:.9rem; line-height:1.7; color:#334155; margin:0 0 13px; }
.doc-body ul, .doc-body ol { font-size:.9rem; line-height:1.7; color:#334155; margin:0 0 13px; padding-left:24px; }
.doc-body li { margin-bottom:4px; }
.doc-body li > ul, .doc-body li > ol { margin:4px 0 2px; }
.doc-body a { color:#2563eb; }
.doc-body strong { color:#0f172a; }
.doc-body code { background:#f1f5f9; color:#be185d; padding:1px 5px; border-radius:4px;
font-size:.83em; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
.doc-body pre { background:#0f172a; color:#e2e8f0; padding:14px 16px; border-radius:10px;
overflow-x:auto; margin:0 0 15px; }
.doc-body pre code { background:none; color:inherit; padding:0; font-size:.8rem; line-height:1.6; }
.doc-body blockquote { border-left:3px solid #f59e0b; background:#fffbeb; margin:0 0 15px;
padding:10px 16px; border-radius:0 8px 8px 0; }
.doc-body blockquote p { margin:0; color:#78350f; font-size:.86rem; }
.doc-body hr { border:none; border-top:1px solid #e2e8f0; margin:26px 0; }
.tabla-scroll { overflow-x:auto; margin:0 0 16px; }
.doc-body table { width:100%; border-collapse:collapse; font-size:.82rem; }
.doc-body thead th { background:#f8fafc; text-align:left; font-weight:700; color:#475569;
padding:8px 11px; border-bottom:2px solid #e2e8f0; white-space:nowrap; }
.doc-body tbody td { padding:7px 11px; border-bottom:1px solid #f1f5f9; color:#334155; vertical-align:top; }
.doc-body tbody tr:hover { background:#f8fafc; }
/* ── Tabla de contenidos ── */
.doc-toc { width:210px; flex-shrink:0; padding:32px 20px 60px; position:sticky; top:0;
max-height:100vh; overflow-y:auto; }
.doc-toc-t { font-size:.66rem; font-weight:800; text-transform:uppercase; letter-spacing:.08em;
color:#94a3b8; margin-bottom:9px; }
.doc-toc a { display:block; font-size:.76rem; color:#64748b; text-decoration:none;
padding:3px 0 3px 9px; border-left:2px solid #e2e8f0; line-height:1.4; }
.doc-toc a:hover { color:#2563eb; border-left-color:#93c5fd; }
.doc-toc a.n3 { padding-left:20px; font-size:.73rem; }
/* ── Portadas ── */
.doc-hero { margin-bottom:26px; }
.doc-hero h1 { font-size:1.7rem; font-weight:800; color:#0f172a; margin:0 0 8px; }
.doc-hero p { font-size:.92rem; color:#64748b; margin:0; }
.tarjetas { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:14px; }
.tarjeta { display:block; padding:18px; border:1px solid #e2e8f0; border-radius:12px;
text-decoration:none; background:#fff; transition:border-color .15s, transform .15s; }
.tarjeta:hover { border-color:#2563eb; transform:translateY(-2px); }
.tarjeta i { font-size:1.3rem; color:#2563eb; }
.tarjeta .t { font-size:1rem; font-weight:700; color:#1e293b; margin:9px 0 5px; }
.tarjeta .d { font-size:.82rem; color:#64748b; line-height:1.5; }
.tarjeta .n { font-size:.72rem; color:#94a3b8; margin-top:9px; }
.lista-docs { list-style:none; padding:0; margin:0; }
.lista-docs li { border-bottom:1px solid #f1f5f9; }
.lista-docs a { display:block; padding:12px 4px; text-decoration:none; color:#1e293b; font-size:.92rem; }
.lista-docs a:hover { color:#2563eb; }
@media (max-width:1100px) { .doc-toc { display:none; } }
@media (max-width:820px) {
.doc-nav { position:static; width:100%; max-height:none; border-right:none;
border-bottom:1px solid #e2e8f0; }
.doc-wrap { flex-direction:column; }
.doc-body { padding:20px 18px 60px; }
}
@media print {
.doc-nav, .doc-toc, .doc-ruta { display:none !important; }
.doc-body { max-width:none; padding:0; }
.doc-body pre { background:#f8fafc; color:#0f172a; border:1px solid #cbd5e1; }
}
</style>
<div class="doc-wrap">
<!-- ── Índice lateral ── -->
<nav class="doc-nav">
<div class="buscador">
<input type="search" id="q" placeholder="Buscar en la documentación…" autocomplete="off">
</div>
<div id="resultados" style="display:none"></div>
<div id="arbol">
<?php foreach ($arbol as $sec => $cfg): ?>
<div class="doc-nav-sec">
<i class="<?= htmlspecialchars($cfg['icono']) ?>"></i>
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($sec) ?>"
style="padding:0;border:none;color:inherit;font:inherit;letter-spacing:inherit">
<?= htmlspecialchars($cfg['titulo']) ?>
</a>
</div>
<?php foreach ($cfg['docs'] as $d): ?>
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($sec) ?>&d=<?= urlencode($d['slug']) ?>"
class="<?= ($sec === $seccion && $d['slug'] === $docSlug) ? 'activo' : '' ?>">
<?= htmlspecialchars($d['titulo']) ?>
</a>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
</nav>
<div class="doc-main">
<div class="doc-body">
<?php if ($archivo): ?>
<div class="doc-ruta">
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion">Documentación</a>
&nbsp;&nbsp;
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($seccion) ?>">
<?= htmlspecialchars($arbol[$seccion]['titulo'] ?? $seccion) ?>
</a>
&nbsp;&nbsp;<?= htmlspecialchars($titulo) ?>
</div>
<?= $cuerpo ?>
<?php elseif ($seccion && isset($arbol[$seccion])): ?>
<div class="doc-ruta">
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion">Documentación</a>
&nbsp;&nbsp;<?= htmlspecialchars($arbol[$seccion]['titulo']) ?>
</div>
<div class="doc-hero">
<h1><?= htmlspecialchars($arbol[$seccion]['titulo']) ?></h1>
<p><?= htmlspecialchars($arbol[$seccion]['resumen']) ?></p>
</div>
<?php if ($arbol[$seccion]['docs']): ?>
<ul class="lista-docs">
<?php foreach ($arbol[$seccion]['docs'] as $d): ?>
<li><a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($seccion) ?>&d=<?= urlencode($d['slug']) ?>">
<?= htmlspecialchars($d['titulo']) ?>
</a></li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="text-muted">Esta sección aún no tiene documentos.</p>
<?php endif; ?>
<?php else: ?>
<div class="doc-hero">
<h1>Documentación del sistema</h1>
<p>Todo sobre cómo funciona este ERP: cómo se usa, cómo está construido y qué hacer cuando algo falla.</p>
</div>
<div class="tarjetas">
<?php foreach ($arbol as $sec => $cfg): ?>
<a class="tarjeta" href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($sec) ?>">
<i class="<?= htmlspecialchars($cfg['icono']) ?>"></i>
<div class="t"><?= htmlspecialchars($cfg['titulo']) ?></div>
<div class="d"><?= htmlspecialchars($cfg['resumen']) ?></div>
<div class="n"><?= count($cfg['docs']) ?> documento<?= count($cfg['docs']) === 1 ? '' : 's' ?></div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php if ($toc): ?>
<aside class="doc-toc">
<div class="doc-toc-t">En esta página</div>
<?php foreach ($toc as $t): ?>
<a href="#<?= htmlspecialchars($t['slug']) ?>" class="<?= $t['nivel'] === 3 ? 'n3' : '' ?>">
<?= htmlspecialchars($t['texto']) ?>
</a>
<?php endforeach; ?>
</aside>
<?php endif; ?>
</div>
</div>
<script>
/* ── Buscador: índice completo servido con la página ── */
const DOCS = <?= json_encode(DocIndex::indiceBusqueda(), JSON_UNESCAPED_UNICODE) ?>;
const BASEQ = '<?= BASE_URL ?>erp.php?m=soporte&v=documentacion';
const norm = s => (s || '').toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '');
function buscar(termino) {
const q = norm(termino).trim();
const cajaR = document.getElementById('resultados');
const cajaA = document.getElementById('arbol');
if (q.length < 2) { cajaR.style.display = 'none'; cajaA.style.display = ''; return; }
const palabras = q.split(/\s+/);
const hits = [];
DOCS.forEach(d => {
const heno = norm(d.t + ' ' + d.c + ' ' + d.x);
if (!palabras.every(p => heno.includes(p))) return;
// El título pesa más que el cuerpo
const enTitulo = palabras.every(p => norm(d.t).includes(p));
const pos = heno.indexOf(palabras[0]);
hits.push({ d, score: (enTitulo ? 0 : 1000) + pos, pos });
});
hits.sort((a, b) => a.score - b.score);
cajaA.style.display = 'none';
cajaR.style.display = '';
if (!hits.length) {
cajaR.innerHTML = '<div class="vacio">Sin resultados para «' + esc(termino) + '»</div>';
return;
}
cajaR.innerHTML = hits.slice(0, 20).map(h => {
const ini = Math.max(0, h.pos - 40);
const frag = h.d.x.substr(ini, 120).trim();
return `<a class="r" href="${BASEQ}&s=${encodeURIComponent(h.d.s)}&d=${encodeURIComponent(h.d.u)}">
<div class="r-c">${esc(h.d.c)}</div>
<div class="r-t">${esc(h.d.t)}</div>
<div class="r-x">${ini > 0 ? '…' : ''}${esc(frag)}…</div>
</a>`;
}).join('');
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
const inputQ = document.getElementById('q');
let _t = null;
inputQ.addEventListener('input', () => {
clearTimeout(_t);
_t = setTimeout(() => buscar(inputQ.value), 120);
});
// Atajo: "/" enfoca el buscador
document.addEventListener('keydown', e => {
if (e.key === '/' && document.activeElement !== inputQ) { e.preventDefault(); inputQ.focus(); }
if (e.key === 'Escape' && document.activeElement === inputQ) { inputQ.value = ''; buscar(''); inputQ.blur(); }
});
</script>
<?php Layout::close(); ?>