Files
whatsapp/modules/soporte/views/documentacion.php
T
Lizandro GuarnizoandClaude Opus 5 dc27184051 Documentación: visibilidad por rol en cada documento y una sola entrada de menú
Un documento puede declarar qué roles lo ven mediante una cabecera al inicio
del archivo; sin ella hereda el permiso de su sección. Los administradores ven
todo. Se aplica tanto al índice como al acceso directo por URL, y una sección
que queda sin documentos visibles deja de mostrarse.

Con esto un recepcionista ve solo el manual de recepción, un bacteriólogo el de
toma de muestras y un enfermero el suyo, en vez del manual completo.

El módulo declaraba cinco enlaces que apuntaban todos a la misma vista, así que
al abrir una sección quedaban dos entradas marcadas como activas a la vez. Queda
una sola, "Documentación": la navegación por secciones ya vive dentro de la
página.

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

298 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) {
[, $crudo] = DocIndex::leer($archivo); // descarta la cabecera de permisos
$md = Generadores::expandir($crudo);
$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(); ?>