Files
Lizandro GuarnizoandClaude Opus 5 9b5e144386 Exportación de la documentación a PDF mediante vista de impresión
Nueva vista que reúne los 32 documentos en un solo entregable maquetado para
imprimir: portada con los datos del laboratorio, índice numerado, y cada
capítulo y documento empezando en página nueva.

Se resolvió como vista y no como archivo generado para que el entregable
refleje siempre el estado actual, incluidos los inventarios técnicos que se
generan del código y de la base en cada consulta. No hay paso de regeneración
que alguien pueda olvidar.

El CSS de impresión evita cortes a mitad de tabla, bloque de código o cita,
repite el encabezado en tablas que abarcan varias páginas y oculta la barra de
acciones. Permite exportar todo o una sección puntual, y respeta la
visibilidad por rol: cada usuario exporta lo que puede leer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:31:22 -05:00

271 lines
12 KiB
PHP

<?php
/**
* modules/soporte/views/imprimir.php
* Documentación completa en un solo documento, maquetado para imprimir a PDF.
*
* ?s=<seccion> limita la exportación a una sección
* (sin parámetro) exporta todo lo que el usuario puede ver
*
* Se sirve sin el layout del ERP: la idea es que la página sea el entregable.
*/
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();
// Filtro opcional por sección
$soloSeccion = preg_replace('/[^a-z0-9-]/', '', $_GET['s'] ?? '');
if ($soloSeccion && isset($arbol[$soloSeccion])) {
$arbol = [$soloSeccion => $arbol[$soloSeccion]];
}
// Datos del laboratorio para la portada
$emp = ['empresa_nombre' => 'Laboratorio Clínico', 'empresa_subtitulo' => '', 'doc_logo_base64' => ''];
try {
$rows = Database::getInstance()->getConnection()->query(
"SELECT clave, valor FROM lab_config
WHERE clave IN ('empresa_nombre','empresa_subtitulo','doc_logo_base64')"
)->fetchAll(PDO::FETCH_KEY_PAIR);
$emp = array_merge($emp, $rows ?: []);
} catch (\Throwable $_) {}
// Numeración: 1. Sección → 1.1 Documento
$capitulos = [];
$nSec = 0;
foreach ($arbol as $sec => $cfg) {
$nSec++;
$docs = [];
$nDoc = 0;
foreach ($cfg['docs'] as $d) {
$nDoc++;
[, $cuerpo] = DocIndex::leer($d['archivo']);
$md = Generadores::expandir($cuerpo);
// El H1 del documento se reemplaza por el encabezado numerado
$md = preg_replace('/^#\s+.*$/m', '', $md, 1);
$docs[] = [
'num' => "{$nSec}.{$nDoc}",
'titulo' => $d['titulo'],
'id' => 'doc-' . $sec . '-' . $d['slug'],
'html' => Markdown::render($md),
];
}
$capitulos[] = ['num' => (string)$nSec, 'titulo' => $cfg['titulo'],
'resumen' => $cfg['resumen'], 'id' => 'cap-' . $sec, 'docs' => $docs];
}
$totalDocs = array_sum(array_map(fn($c) => count($c['docs']), $capitulos));
$esc = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES);
?><!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Documentación del Sistema — <?= $esc($emp['empresa_nombre']) ?></title>
<style>
@page {
size: A4;
margin: 18mm 16mm 16mm 16mm;
}
@page :first { margin: 0; }
* { box-sizing: border-box; }
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body {
font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 10.5pt; line-height: 1.55; color: #1e293b;
margin: 0; padding: 0; background: #fff;
}
/* ── Barra de acciones (no se imprime) ── */
.barra {
position: sticky; top: 0; z-index: 10;
background: #0f172a; color: #fff; padding: 11px 20px;
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
}
.barra .t { font-weight: 700; flex: 1; font-size: .95rem; }
.barra button, .barra a {
background: #2563eb; color: #fff; border: none; border-radius: 7px;
padding: 7px 15px; font-size: .84rem; cursor: pointer; text-decoration: none;
}
.barra a.sec { background: rgba(255,255,255,.14); }
.barra button:hover { background: #1d4ed8; }
.hoja { max-width: 195mm; margin: 0 auto; padding: 14mm 12mm 20mm; }
/* ── Portada ── */
.portada {
display: flex; flex-direction: column; justify-content: center;
min-height: 247mm; padding: 0 22mm; text-align: center;
page-break-after: always; break-after: page;
}
.portada img { max-width: 170px; margin: 0 auto 26px; }
.portada .lab { font-size: 13pt; color: #64748b; letter-spacing: .06em;
text-transform: uppercase; margin-bottom: 6px; }
.portada .sub { font-size: 10.5pt; color: #94a3b8; margin-bottom: 46px; }
.portada h1 { font-size: 30pt; font-weight: 800; color: #0d47a1;
margin: 0 0 10px; line-height: 1.15; letter-spacing: -.5px; }
.portada .lin { width: 76px; height: 3px; background: #0d47a1;
margin: 22px auto 24px; border-radius: 2px; }
.portada .desc { font-size: 11pt; color: #475569; max-width: 118mm;
margin: 0 auto 50px; line-height: 1.65; }
.portada .meta { font-size: 9.5pt; color: #64748b; line-height: 1.9; }
.portada .meta b { color: #334155; }
/* ── Índice ── */
.indice { page-break-after: always; break-after: page; }
.indice h2 { font-size: 17pt; color: #0d47a1; margin: 0 0 22px;
padding-bottom: 10px; border-bottom: 2px solid #e2e8f0; }
.idx-cap { margin-bottom: 16px; }
.idx-cap > a { display: block; font-size: 11.5pt; font-weight: 700; color: #0f172a;
text-decoration: none; margin-bottom: 3px; }
.idx-cap .res { font-size: 9pt; color: #94a3b8; margin: 0 0 7px 20px; }
.idx-doc { display: block; font-size: 10pt; color: #334155; text-decoration: none;
margin: 0 0 3px 20px; }
.idx-doc .n { color: #94a3b8; display: inline-block; min-width: 30px; }
/* ── Capítulo ── */
.cap { page-break-before: always; break-before: page; padding-top: 26mm; text-align: center; }
.cap .n { font-size: 46pt; font-weight: 800; color: #dbeafe; line-height: 1; }
.cap h2 { font-size: 22pt; color: #0d47a1; margin: 6px 0 12px; font-weight: 800; }
.cap p { color: #64748b; font-size: 10.5pt; max-width: 115mm; margin: 0 auto; }
/* ── Documento ── */
.doc { page-break-before: always; break-before: page; }
.doc-h {
border-bottom: 2px solid #0d47a1; padding-bottom: 8px; margin-bottom: 16px;
page-break-after: avoid; break-after: avoid;
}
.doc-h .n { font-size: 9pt; color: #2563eb; font-weight: 700; letter-spacing: .08em; }
.doc-h h3 { font-size: 16pt; color: #0f172a; margin: 2px 0 0; font-weight: 700; }
h2, h3, h4 { page-break-after: avoid; break-after: avoid; }
.doc h2 { font-size: 12.5pt; color: #1565c0; margin: 17px 0 7px; font-weight: 700; }
.doc h3 { font-size: 11pt; color: #334155; margin: 13px 0 5px; font-weight: 700; }
.doc h4 { font-size: 10pt; color: #475569; margin: 11px 0 4px; font-weight: 700; }
.doc p { margin: 0 0 8px; text-align: justify; }
.doc ul, .doc ol { margin: 0 0 9px; padding-left: 20px; }
.doc li { margin-bottom: 2px; }
.doc a { color: #1d4ed8; text-decoration: none; }
.doc strong { color: #0f172a; }
code { background: #f1f5f9; color: #be185d; padding: 1px 4px; border-radius: 3px;
font-family: "Consolas", "SF Mono", Menlo, monospace; font-size: .86em; }
pre { background: #f8fafc; border: 1px solid #cbd5e1; border-left: 3px solid #1565c0;
padding: 9px 12px; border-radius: 4px; margin: 0 0 10px; overflow-x: auto;
page-break-inside: avoid; break-inside: avoid; }
pre code { background: none; color: #0f172a; padding: 0; font-size: 8.2pt; line-height: 1.45; }
blockquote { border-left: 3px solid #f59e0b; background: #fffbeb; margin: 0 0 10px;
padding: 8px 13px; page-break-inside: avoid; break-inside: avoid; }
blockquote p { margin: 0; color: #78350f; font-size: 9.8pt; text-align: left; }
.tabla-scroll { margin: 0 0 11px; page-break-inside: avoid; break-inside: avoid; }
table { width: 100%; border-collapse: collapse; font-size: 9pt; }
thead { display: table-header-group; }
th { background: #1565c0; color: #fff; text-align: left; font-weight: 600;
padding: 5px 8px; border: 1px solid #1565c0; }
td { padding: 4px 8px; border: 1px solid #e2e8f0; vertical-align: top; }
tbody tr:nth-child(even) { background: #f8fafc; }
hr { border: none; border-top: 1px solid #e2e8f0; margin: 16px 0; }
/* Tablas muy largas (inventarios generados) pueden partirse entre páginas */
.doc table { page-break-inside: auto; }
tr { page-break-inside: avoid; break-inside: avoid; }
@media print {
.barra { display: none !important; }
.hoja { max-width: none; margin: 0; padding: 0; }
a { color: #1e293b !important; text-decoration: none; }
.portada { min-height: 250mm; }
}
</style>
</head>
<body>
<div class="barra">
<span class="t">
<?= $totalDocs ?> documento<?= $totalDocs === 1 ? '' : 's' ?>
<?= $soloSeccion ? '· ' . $esc($arbol[$soloSeccion]['titulo']) : '· documentación completa' ?>
</span>
<?php if ($soloSeccion): ?>
<a class="sec" href="<?= BASE_URL ?>erp.php?m=soporte&v=imprimir">Exportar todo</a>
<?php else: ?>
<?php foreach (DocIndex::seccionesVisibles() as $s => $c): ?>
<a class="sec" href="<?= BASE_URL ?>erp.php?m=soporte&v=imprimir&s=<?= urlencode($s) ?>">Solo <?= $esc($c['titulo']) ?></a>
<?php endforeach; ?>
<?php endif; ?>
<a class="sec" href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion">Volver</a>
<button onclick="window.print()">Imprimir / Guardar como PDF</button>
</div>
<div class="hoja">
<!-- ── Portada ── -->
<div class="portada">
<?php if (!empty($emp['doc_logo_base64'])): ?>
<img src="<?= $esc($emp['doc_logo_base64']) ?>" alt="">
<?php endif; ?>
<div class="lab"><?= $esc($emp['empresa_nombre']) ?></div>
<?php if (!empty($emp['empresa_subtitulo'])): ?>
<div class="sub"><?= $esc($emp['empresa_subtitulo']) ?></div>
<?php endif; ?>
<h1>Documentación<br>del Sistema</h1>
<div class="lin"></div>
<p class="desc">
<?php if ($soloSeccion): ?>
<?= $esc($arbol[$soloSeccion]['resumen']) ?>
<?php else: ?>
Manual de usuario, documentación técnica, arquitectura y guía de operación
del sistema de gestión del laboratorio.
<?php endif; ?>
</p>
<div class="meta">
<?php if ($soloSeccion): ?><b><?= $esc($arbol[$soloSeccion]['titulo']) ?></b><br><?php endif; ?>
<b><?= $totalDocs ?></b> documentos<br>
Generado el <b><?= date('d/m/Y') ?></b><br>
<span style="font-size:8.5pt">Los inventarios técnicos reflejan el estado del sistema a esta fecha</span>
</div>
</div>
<!-- ── Índice ── -->
<div class="indice">
<h2>Contenido</h2>
<?php foreach ($capitulos as $cap): ?>
<div class="idx-cap">
<a href="#<?= $esc($cap['id']) ?>"><?= $esc($cap['num']) ?>. <?= $esc($cap['titulo']) ?></a>
<p class="res"><?= $esc($cap['resumen']) ?></p>
<?php foreach ($cap['docs'] as $d): ?>
<a class="idx-doc" href="#<?= $esc($d['id']) ?>">
<span class="n"><?= $esc($d['num']) ?></span><?= $esc($d['titulo']) ?>
</a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<!-- ── Contenido ── -->
<?php foreach ($capitulos as $cap): ?>
<div class="cap" id="<?= $esc($cap['id']) ?>">
<div class="n"><?= $esc($cap['num']) ?></div>
<h2><?= $esc($cap['titulo']) ?></h2>
<p><?= $esc($cap['resumen']) ?></p>
</div>
<?php foreach ($cap['docs'] as $d): ?>
<div class="doc" id="<?= $esc($d['id']) ?>">
<div class="doc-h">
<div class="n">CAPÍTULO <?= $esc($d['num']) ?></div>
<h3><?= $esc($d['titulo']) ?></h3>
</div>
<?= $d['html'] ?>
</div>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
</body>
</html>