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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bc318db129
commit
9b5e144386
@@ -47,6 +47,10 @@ Layout::open('Soporte · Documentación', 'fas fa-life-ring');
|
||||
.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 .btn-pdf { display:block; margin-top:8px; text-align:center; font-size:.78rem;
|
||||
padding:6px 10px; border:1px solid #cbd5e1; border-radius:8px;
|
||||
color:#475569; text-decoration:none; background:#fff; }
|
||||
.doc-nav .btn-pdf:hover { border-color:#2563eb; color:#1d4ed8; }
|
||||
.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;
|
||||
@@ -221,6 +225,9 @@ Layout::open('Soporte · Documentación', 'fas fa-life-ring');
|
||||
<nav class="doc-nav">
|
||||
<div class="buscador">
|
||||
<input type="search" id="q" placeholder="Buscar en la documentación…" autocomplete="off">
|
||||
<a class="btn-pdf" href="<?= BASE_URL ?>erp.php?m=soporte&v=imprimir" target="_blank">
|
||||
<i class="fas fa-file-pdf"></i> Exportar a PDF
|
||||
</a>
|
||||
</div>
|
||||
<div id="resultados" style="display:none"></div>
|
||||
<div id="arbol">
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
<?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>
|
||||
Reference in New Issue
Block a user