feat: sistema de notas clínicas para enfermero (ficha + notas libres) y fix lab_reportes métricas

This commit is contained in:
Lizandro Guarnizo
2026-03-25 13:14:25 -05:00
parent 4ba890a0d5
commit 5f695b5604
8 changed files with 1180 additions and 1 deletions
+44
View File
@@ -0,0 +1,44 @@
<?php
/**
* POST /api/lab/delete_nota_domicilio.php
* Elimina una nota libre de domicilio.
* Las notas tipo='clinica' NO se pueden eliminar (solo editar/vaciar).
*
* Body JSON: { id }
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
try {
$datos = inputJson();
$id = (int)($datos['id'] ?? 0);
if (!$id) jsonError('id requerido');
$db = Database::getInstance();
$nota = $db->fetch('SELECT * FROM lab_domicilio_notas WHERE id = ?', [$id]);
if (!$nota) jsonError('Nota no encontrada', 404);
if ($nota['tipo'] === 'clinica') {
jsonError('La ficha clínica no puede eliminarse, solo editarse');
}
// Solo el dueño o admin puede borrar
if (userRole() === 'enfermero' && (int)$nota['enfermera_id'] !== enfermeraId()) {
jsonError('Sin permiso para eliminar esta nota', 403);
}
// Eliminar imagen adjunta si existe
if (!empty($nota['imagen_path'])) {
$imgPath = realpath(__DIR__ . '/../../uploads/media/' . $nota['imagen_path']);
if ($imgPath && file_exists($imgPath)) {
@unlink($imgPath);
}
}
$db->delete('lab_domicilio_notas', 'id = ?', [$id]);
jsonOk(['id' => $id], 'Nota eliminada');
} catch (Exception $e) {
error_log('[delete_nota_domicilio] ' . $e->getMessage());
jsonError($e->getMessage(), 500);
}
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* GET /api/lab/get_notas_domicilio.php
* Devuelve todas las notas de un domicilio.
*
* Parámetros GET:
* domicilio_id (requerido)
*
* Respuesta: { success, data: [ nota, … ] }
* Orden: ficha clínica primero, luego libres por created_at ASC
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
try {
$domId = (int)($_GET['domicilio_id'] ?? 0);
if (!$domId) jsonError('domicilio_id requerido');
$db = Database::getInstance();
// Verificar acceso: enfermero solo puede ver si está asignado
if (userRole() === 'enfermero') {
$enfId = enfermeraId();
$asig = $db->fetch(
"SELECT id FROM lab_asignaciones
WHERE domicilio_id = ? AND enfermera_id = ?",
[$domId, $enfId]
);
if (!$asig) jsonError('Sin acceso a este domicilio', 403);
}
$notas = $db->fetchAll(
"SELECT n.*, e.nombre_completo AS enfermera_nombre
FROM lab_domicilio_notas n
LEFT JOIN lab_enfermeras e ON e.id = n.enfermera_id
WHERE n.domicilio_id = ?
ORDER BY FIELD(n.tipo,'clinica','libre'), n.created_at ASC",
[$domId]
);
jsonOk(['data' => $notas]);
} catch (Exception $e) {
error_log('[get_notas_domicilio] ' . $e->getMessage());
jsonError($e->getMessage(), 500);
}
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* POST /api/lab/save_nota_domicilio.php
* Crea o actualiza una nota de domicilio.
*
* Body JSON:
* { domicilio_id, tipo } — campos comunes (para crear)
* { id, tipo, … } — actualizar por id
*
* Campos tipo='clinica':
* antecedentes, medicamentos, acudiente_nombre, acudiente_documento
*
* Campos tipo='libre':
* titulo (requerido), cuerpo, imagen_path
*
* Reglas:
* · Solo puede existir UNA nota tipo='clinica' por domicilio.
* Si ya existe y no viene `id`, se hace UPSERT automático.
* · Las notas 'libre' son ilimitadas.
* · Solo el enfermero dueño o un admin puede editar/crear.
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
// Tags HTML permitidos en el cuerpo de notas libres (desde contenteditable)
const ALLOWED_HTML_TAGS = '<b><i><u><strong><em><s><strike><ul><ol><li><br><p><div>';
try {
$datos = inputJson();
$db = Database::getInstance();
$tipo = $datos['tipo'] ?? '';
if (!in_array($tipo, ['clinica', 'libre'], true)) {
jsonError('tipo debe ser "clinica" o "libre"');
}
// ── Actualizar nota existente ─────────────────────────────────────────
if (!empty($datos['id'])) {
$id = (int)$datos['id'];
$nota = $db->fetch('SELECT * FROM lab_domicilio_notas WHERE id = ?', [$id]);
if (!$nota) jsonError('Nota no encontrada', 404);
// Verificar propiedad
if (userRole() === 'enfermero' && (int)$nota['enfermera_id'] !== enfermeraId()) {
jsonError('No puede editar esta nota', 403);
}
$campos = _extraerCampos($datos, $tipo);
$db->update('lab_domicilio_notas', $campos, 'id = ?', [$id]);
jsonOk(['id' => $id], 'Nota actualizada');
}
// ── Crear nota nueva (o upsert clínica) ───────────────────────────────
$domId = (int)($datos['domicilio_id'] ?? 0);
if (!$domId) jsonError('domicilio_id requerido');
// Verificar que el domicilio existe
$dom = $db->fetch('SELECT id FROM lab_domicilios WHERE id = ?', [$domId]);
if (!$dom) jsonError('Domicilio no encontrado', 404);
// Verificar acceso
if (userRole() === 'enfermero') {
$enfId = enfermeraId();
$asig = $db->fetch(
"SELECT id FROM lab_asignaciones WHERE domicilio_id = ? AND enfermera_id = ?",
[$domId, $enfId]
);
if (!$asig) jsonError('Sin acceso a este domicilio', 403);
}
$enfId = (userRole() === 'enfermero') ? enfermeraId() : (int)($datos['enfermera_id'] ?? 0);
// Para nota clínica: solo puede haber una por domicilio → UPDATE si ya existe
if ($tipo === 'clinica') {
$existente = $db->fetch(
'SELECT id FROM lab_domicilio_notas WHERE domicilio_id = ? AND tipo = "clinica"',
[$domId]
);
$campos = _extraerCampos($datos, 'clinica');
if ($existente) {
$db->update('lab_domicilio_notas', $campos, 'id = ?', [(int)$existente['id']]);
jsonOk(['id' => (int)$existente['id']], 'Ficha clínica actualizada');
} else {
$campos['domicilio_id'] = $domId;
$campos['enfermera_id'] = $enfId;
$campos['tipo'] = 'clinica';
$id = $db->insert('lab_domicilio_notas', $campos);
jsonOk(['id' => $id], 'Ficha clínica creada');
}
}
// Nota libre
if (empty(trim($datos['titulo'] ?? ''))) jsonError('El título es obligatorio');
$campos = _extraerCampos($datos, 'libre');
$campos['domicilio_id'] = $domId;
$campos['enfermera_id'] = $enfId;
$campos['tipo'] = 'libre';
$id = $db->insert('lab_domicilio_notas', $campos);
jsonOk(['id' => $id], 'Nota creada');
} catch (Exception $e) {
error_log('[save_nota_domicilio] ' . $e->getMessage());
jsonError($e->getMessage(), 500);
}
// ── Helper interno ────────────────────────────────────────────────────────
function _extraerCampos(array $datos, string $tipo): array {
if ($tipo === 'clinica') {
return array_filter([
'antecedentes' => isset($datos['antecedentes']) ? trim($datos['antecedentes']) : null,
'medicamentos' => isset($datos['medicamentos']) ? trim($datos['medicamentos']) : null,
'acudiente_nombre' => isset($datos['acudiente_nombre']) ? trim($datos['acudiente_nombre']) : null,
'acudiente_documento' => isset($datos['acudiente_documento']) ? trim($datos['acudiente_documento']) : null,
], fn($v) => $v !== null);
}
// libre
$cuerpo = isset($datos['cuerpo'])
? strip_tags((string)$datos['cuerpo'], ALLOWED_HTML_TAGS)
: null;
return array_filter([
'titulo' => isset($datos['titulo']) ? mb_substr(trim($datos['titulo']), 0, 200) : null,
'cuerpo' => $cuerpo ?: null,
'imagen_path' => isset($datos['imagen_path']) ? preg_replace('/[^a-zA-Z0-9._\-]/', '', (string)$datos['imagen_path']) : null,
], fn($v) => $v !== null);
}
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* POST /api/lab/upload_nota_imagen.php
* Sube una imagen adjunta a una nota libre de domicilio.
*
* POST multipart/form-data con campo 'file'
* Devuelve: { success, imagen_path, file_size }
*/
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../classes/Database.php';
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
requireAuthentication();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Método no permitido']);
exit;
}
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
$code = $_FILES['file']['error'] ?? -1;
echo json_encode(['success' => false, 'error' => "No se recibió imagen (código $code)"]);
exit;
}
$file = $_FILES['file'];
// Máximo 5 MB para imágenes de notas
if ($file['size'] > 5 * 1024 * 1024) {
echo json_encode(['success' => false, 'error' => 'La imagen no puede superar 5 MB']);
exit;
}
// Validar MIME real (solo imágenes)
$allowedMimes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, $allowedMimes, true)) {
echo json_encode(['success' => false, 'error' => "Tipo de archivo no permitido ($mime). Solo imágenes."]);
exit;
}
// Extensión segura
$mimeToExt = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'];
$ext = $mimeToExt[$mime] ?? 'jpg';
$newName = 'nota_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$uploadDir = __DIR__ . '/../../uploads/media';
if (!is_dir($uploadDir)) {
@mkdir($uploadDir, 0755, true);
}
$destPath = realpath($uploadDir) . '/' . $newName;
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
echo json_encode(['success' => false, 'error' => 'Error al guardar el archivo en el servidor']);
exit;
}
echo json_encode([
'success' => true,
'imagen_path' => $newName,
'file_size' => $file['size'],
]);
+843
View File
@@ -117,6 +117,55 @@ $hoy = date('Y-m-d');
.pago-badge-exento { background:#e0e7ff; color:#3730a3; border:1px solid #818cf8; }
.cobro-box { background:#f0fdf4; border:1px solid #86efac; border-radius:8px; padding:.6rem .9rem; }
.cobro-box.sin-cobro { background:#f9fafb; border-color:#e5e7eb; color:#9ca3af; }
/* ── Notas clínicas ── */
.nota-toggle-btn { text-align:left; font-size:.78rem; font-weight:700;
background:#fef9c3; border:1px solid #fde68a; color:#92400e;
border-radius:8px; padding:7px 10px; width:100%; cursor:pointer;
display:flex; align-items:center; gap:6px; transition:.15s; }
.nota-toggle-btn:active { transform:scale(.98); }
.nota-chevron { margin-left:auto; transition:transform .2s; font-size:.7rem; }
.nota-chevron.open { transform:rotate(180deg); }
.notas-body { padding:0; margin-top:4px; }
.nota-libre-card { background:#fff; border:1px solid #e5e7eb; border-radius:8px;
padding:9px 11px; margin-bottom:6px; }
.nota-libre-card .nota-titulo { font-weight:700; font-size:.82rem; color:#374151; }
.nota-libre-card .nota-cuerpo { font-size:.78rem; color:#4b5563; margin-top:4px;
word-break:break-word; }
.nota-libre-card .nota-fecha { font-size:.69rem; color:#9ca3af; margin-top:4px; }
.nota-ficha-card { background:#fff7ed; border:1px solid #fdba74; border-radius:10px;
padding:9px 11px; margin-bottom:6px; }
.nota-ficha-card .ficha-header { display:flex; justify-content:space-between;
align-items:center; margin-bottom:6px; }
.nota-ficha-card .ficha-titulo { font-weight:700; font-size:.82rem; color:#9a3412; }
/* Bottom sheet */
#nota-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:1050; }
#nota-sheet { display:none; position:fixed; bottom:0; left:0; right:0; z-index:1055;
background:#fff; border-radius:20px 20px 0 0; max-height:88vh;
overflow-y:auto; box-shadow:0 -4px 24px rgba(0,0,0,.18);
padding-bottom:env(safe-area-inset-bottom, 16px); }
#nota-sheet .sheet-handle { display:flex; justify-content:center; padding:10px 0 2px; }
#nota-sheet .sheet-handle span { width:40px; height:4px; background:#e0e0e0; border-radius:2px; display:block; }
#nota-sheet-inner { padding:8px 16px 28px; }
/* Toolbar formato */
.fmt-toolbar { display:flex; gap:4px; overflow-x:auto; padding-bottom:2px; margin-bottom:6px; }
.fmt-btn { min-width:34px; border:1px solid #e5e7eb; background:#fff; border-radius:5px;
font-size:.78rem; padding:4px 8px; cursor:pointer; white-space:nowrap; }
.fmt-btn:active { background:#f0f4ff; }
/* Campos de nota */
.nota-input { width:100%; border:1px solid #d1d5db; border-radius:8px;
padding:8px 12px; font-size:.88rem; font-family:inherit; resize:vertical; }
.nota-input:focus { outline:2px solid #0d6efd; border-color:transparent; }
.nota-editor { min-height:90px; border:1px solid #d1d5db; border-radius:8px;
padding:8px 12px; font-size:.85rem; outline:none; background:#fff;
word-break:break-word; -webkit-user-select:text; cursor:text; }
.nota-editor:focus { outline:2px solid #0d6efd; border-color:transparent; }
.nota-label { font-size:.78rem; font-weight:700; color:#374151; display:block; margin-bottom:4px; }
.nota-section { margin-bottom:14px; }
.nota-acudiente-bloque { background:#fff0f0; border:1px solid #fca5a5;
border-radius:10px; padding:10px; }
.nota-btn-guardar { width:100%; border:none; border-radius:10px; padding:13px;
font-weight:800; font-size:.92rem; color:#fff; cursor:pointer; }
</style>
</head>
<body>
@@ -550,6 +599,21 @@ const portal = {
<!-- Servicios extra registrados -->
${seSuf ? `<div class="d-flex flex-wrap gap-1 mb-2">${seSuf}</div>` : ''}
<!-- ── Notas clínicas ── -->
<div class="mt-2 mb-2">
<button class="nota-toggle-btn" onclick="notasManager.toggle(${item.domicilio_id}, this)">
<i class="fas fa-notes-medical"></i>
<span>Notas clínicas</span>
<span class="badge rounded-pill ms-1" id="nota-count-${item.domicilio_id}"
style="background:#f59e0b;color:#fff;font-size:.67rem;display:none"></span>
<i class="fas fa-chevron-down nota-chevron"></i>
</button>
<div class="notas-body d-none" id="notas-body-${item.domicilio_id}"
data-dom-id="${item.domicilio_id}"
data-pac-nac="${esc(item.paciente_fecha_nacimiento||'')}">
</div>
</div>
<!-- Acciones -->
<div class="d-flex flex-wrap gap-2 mt-2">
${btnsAccion}
@@ -734,6 +798,426 @@ function renderOrdenesEnfermero(item) {
}).join('');
}
// ═══════════════════════════════════════════════════════════════
// notasManager — Sistema de notas clínicas por domicilio
// ═══════════════════════════════════════════════════════════════
const notasManager = {
_cache: {}, // domId → { clinica: nota|null, libres: [] }
_editDomId: null,
_editNota: null, // nota completa o null (nueva)
_editTipo: null, // 'clinica' | 'libre'
_editPacNac: '',
// ── Edad ─────────────────────────────────────────────────────────────
_esmenor(nacStr) {
if (!nacStr) return null; // desconocido
const dif = Date.now() - new Date(nacStr).getTime();
return Math.floor(dif / (365.25 * 24 * 3600 * 1000)) < 18;
},
// ── Toggle sección ────────────────────────────────────────────────────
async toggle(domId, btn) {
const body = document.getElementById(`notas-body-${domId}`);
const chevron = btn.querySelector('.nota-chevron');
if (!body) return;
const abrir = body.classList.contains('d-none');
body.classList.toggle('d-none', !abrir);
chevron?.classList.toggle('open', abrir);
if (abrir && !this._cache[domId]) {
await this.cargar(domId);
} else if (abrir) {
this._renderBody(domId);
}
},
// ── Cargar desde API ─────────────────────────────────────────────────
async cargar(domId) {
const body = document.getElementById(`notas-body-${domId}`);
if (!body) return;
body.innerHTML = '<div class="text-center py-2 text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</div>';
try {
const r = await fetch(`api/lab/get_notas_domicilio.php?domicilio_id=${domId}`);
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Error al cargar notas');
this._cache[domId] = {
clinica: d.data.find(n => n.tipo === 'clinica') || null,
libres: d.data.filter(n => n.tipo === 'libre'),
};
this._renderBody(domId);
// Badge contador
const total = d.data.length;
const badge = document.getElementById(`nota-count-${domId}`);
if (badge) { badge.textContent = total; badge.style.display = total ? '' : 'none'; }
} catch (e) {
if (body) body.innerHTML = `<div class="alert alert-danger small py-2 mt-2">${esc(e.message)}</div>`;
}
},
// ── Render cuerpo expandido ──────────────────────────────────────────
_renderBody(domId) {
const body = document.getElementById(`notas-body-${domId}`);
if (!body) return;
const pacNac = body.dataset.pacNac || '';
const menor = this._esmenor(pacNac);
const cache = this._cache[domId] || { clinica: null, libres: [] };
body.innerHTML = `
<div style="margin-top:6px">
${this._fichaHTML(cache.clinica, domId, menor)}
${cache.libres.map(n => this._libreHTML(n)).join('')}
<button style="width:100%;margin-top:6px;padding:8px;background:#f0f9ff;
border:1px dashed #7dd3fc;border-radius:8px;color:#0369a1;
font-size:.78rem;font-weight:700"
onclick="notasManager.abrirLibre(${domId})">
<i class="fas fa-plus me-1"></i>Agregar nota
</button>
</div>`;
},
_fichaHTML(nota, domId, menor) {
const vacio = !nota;
const tieneAcud = nota?.acudiente_nombre || nota?.acudiente_documento;
return `<div class="nota-ficha-card">
<div class="ficha-header">
<div class="ficha-titulo">
<i class="fas fa-file-medical-alt me-1"></i>Ficha clínica
${menor === true ? '<span style="color:#dc3545;font-size:.68rem"> · menor de edad</span>' : ''}
</div>
<button style="background:${vacio?'#f59e0b':'#ea580c'};color:#fff;border:none;
border-radius:999px;padding:3px 11px;font-size:.72rem;font-weight:700"
onclick="notasManager.abrirClinica(${domId})">
${vacio ? '<i class="fas fa-plus me-1"></i>Completar' : '<i class="fas fa-edit me-1"></i>Editar'}
</button>
</div>
${vacio
? `<div style="color:#a16207;font-size:.75rem;text-align:center;padding:4px 0">Sin información registrada</div>`
: `<dl style="margin:0;font-size:.78rem">
${nota.antecedentes ? `<dt style="color:#9a3412;margin-top:4px">Antecedentes</dt><dd style="margin:0 0 2px">${esc(nota.antecedentes).replace(/\n/g,'<br>')}</dd>` : ''}
${nota.medicamentos ? `<dt style="color:#9a3412;margin-top:4px">Medicamentos</dt><dd style="margin:0 0 2px">${esc(nota.medicamentos).replace(/\n/g,'<br>')}</dd>` : ''}
${tieneAcud ? `<dt style="color:#9a3412;margin-top:4px">Acudiente</dt>
<dd style="margin:0">${esc(nota.acudiente_nombre||'—')} · <span style="color:#6b7280">${esc(nota.acudiente_documento||'—')}</span></dd>` : ''}
</dl>`}
</div>`;
},
_libreHTML(nota) {
const domId = nota.domicilio_id;
return `<div class="nota-libre-card">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div class="nota-titulo">${esc(nota.titulo||'Nota')}</div>
<div style="display:flex;gap:2px;flex-shrink:0;margin-left:6px">
<button style="background:none;border:none;color:#6b7280;padding:2px 6px;font-size:.8rem"
onclick="notasManager.abrirEditarLibre(${domId},${nota.id})"><i class="fas fa-edit"></i></button>
<button style="background:none;border:none;color:#ef4444;padding:2px 6px;font-size:.8rem"
onclick="notasManager.eliminar(${nota.id},${domId})"><i class="fas fa-trash"></i></button>
</div>
</div>
${nota.cuerpo ? `<div class="nota-cuerpo">${nota.cuerpo}</div>` : ''}
${nota.imagen_path ? `<img src="uploads/media/${esc(nota.imagen_path)}"
style="max-width:100%;max-height:160px;border-radius:6px;margin-top:6px;
border:1px solid #e5e7eb;cursor:zoom-in"
onclick="window.open('uploads/media/${esc(nota.imagen_path)}','_blank')"
onerror="this.style.display='none'">` : ''}
<div class="nota-fecha">${_fmtFechaCorta(nota.created_at||'')}</div>
</div>`;
},
// ── Abrir bottom sheet: Ficha clínica ────────────────────────────────
abrirClinica(domId) {
this._editDomId = domId;
this._editTipo = 'clinica';
this._editNota = this._cache[domId]?.clinica || null;
const bodyEl = document.getElementById(`notas-body-${domId}`);
this._editPacNac = bodyEl?.dataset?.pacNac || '';
const menor = this._esmenor(this._editPacNac);
const nota = this._editNota;
const inner = document.getElementById('nota-sheet-inner');
inner.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div style="font-weight:800;font-size:1rem;color:#9a3412">
<i class="fas fa-file-medical-alt me-2"></i>Ficha clínica
</div>
<button onclick="notasManager.cerrarSheet()"
style="background:none;border:none;font-size:1.5rem;color:#9ca3af;line-height:1">&times;</button>
</div>
<div class="nota-section">
<label class="nota-label">Antecedentes</label>
<textarea id="nc-antecedentes" class="nota-input" rows="3"
placeholder="Enfermedades previas, alergias, cirugías, hospitalizaciones…">${esc(nota?.antecedentes||'')}</textarea>
</div>
<div class="nota-section">
<label class="nota-label">Medicamentos actuales</label>
<textarea id="nc-medicamentos" class="nota-input" rows="3"
placeholder="Nombre del medicamento, dosis y frecuencia…">${esc(nota?.medicamentos||'')}</textarea>
</div>
<div class="nota-section nota-acudiente-bloque">
<div style="font-size:.8rem;font-weight:700;color:#dc2626;margin-bottom:10px">
<i class="fas fa-user-shield me-1"></i>Datos del acudiente
${menor === true
? '<span style="font-weight:400"> — <strong>obligatorio</strong> (paciente menor de edad)</span>'
: '<span style="font-weight:400;color:#9ca3af"> (si aplica)</span>'}
</div>
<div style="margin-bottom:8px">
<label class="nota-label">Nombre completo ${menor===true?'<span style="color:#dc2626">*</span>':''}</label>
<input id="nc-acud-nombre" type="text" class="nota-input"
value="${esc(nota?.acudiente_nombre||'')}" placeholder="Nombre del acudiente o responsable">
</div>
<div>
<label class="nota-label">Documento de identidad ${menor===true?'<span style="color:#dc2626">*</span>':''}</label>
<input id="nc-acud-doc" type="text" class="nota-input" inputmode="numeric"
value="${esc(nota?.acudiente_documento||'')}" placeholder="Cédula, TI o Pasaporte">
</div>
</div>
<div style="margin-top:16px">
<button id="nc-guardar-btn" class="nota-btn-guardar" style="background:#ea580c"
onclick="notasManager.guardar()">
<i class="fas fa-save me-2"></i>Guardar ficha clínica
</button>
</div>`;
this._abrirSheet();
},
// ── Abrir bottom sheet: Nota libre nueva ────────────────────────────
abrirLibre(domId) {
const bodyEl = document.getElementById(`notas-body-${domId}`);
this._editDomId = domId;
this._editTipo = 'libre';
this._editNota = null;
this._editPacNac = bodyEl?.dataset?.pacNac || '';
this._renderFormLibre(null);
this._abrirSheet();
},
// ── Abrir bottom sheet: Editar nota libre ───────────────────────────
abrirEditarLibre(domId, notaId) {
const nota = (this._cache[domId]?.libres||[]).find(n => +n.id === +notaId);
const bodyEl = document.getElementById(`notas-body-${domId}`);
this._editDomId = domId;
this._editTipo = 'libre';
this._editNota = nota || null;
this._editPacNac = bodyEl?.dataset?.pacNac || '';
this._renderFormLibre(nota);
this._abrirSheet();
},
_renderFormLibre(nota) {
const inner = document.getElementById('nota-sheet-inner');
inner.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div style="font-weight:800;font-size:1rem">
<i class="fas fa-sticky-note me-2 text-primary"></i>${nota ? 'Editar nota' : 'Nueva nota'}
</div>
<button onclick="notasManager.cerrarSheet()"
style="background:none;border:none;font-size:1.5rem;color:#9ca3af;line-height:1">&times;</button>
</div>
<div class="nota-section">
<label class="nota-label">Título <span style="color:#dc2626">*</span></label>
<input id="nl-titulo" type="text" class="nota-input" maxlength="200"
value="${esc(nota?.titulo||'')}" placeholder="Ej: Observación inicial, Sangrado, Reacción…">
</div>
<div class="nota-section">
<label class="nota-label">Descripción</label>
<div class="fmt-toolbar">
<button class="fmt-btn" onclick="notasManager._fmt('bold')" title="Negrita"><b>B</b></button>
<button class="fmt-btn" style="font-style:italic" onclick="notasManager._fmt('italic')" title="Cursiva">I</button>
<button class="fmt-btn" style="text-decoration:underline" onclick="notasManager._fmt('underline')" title="Subrayado">U</button>
<button class="fmt-btn" onclick="notasManager._fmt('strikeThrough')" title="Tachado"><s>S</s></button>
<button class="fmt-btn" onclick="notasManager._insertLista()" title="Lista">• Lista</button>
<button class="fmt-btn" onclick="notasManager._limpiarFormato()" title="Sin formato">✕ Formato</button>
</div>
<div id="nl-cuerpo" class="nota-editor" contenteditable="true"
data-placeholder="Escribe los detalles de la nota…"></div>
</div>
<div class="nota-section">
<label class="nota-label"><i class="fas fa-camera me-1"></i>Imagen adjunta (opcional, máx. 5 MB)</label>
<div id="nl-preview" style="margin-bottom:6px">
${nota?.imagen_path ? `<div style="position:relative;display:inline-block">
<img src="uploads/media/${esc(nota.imagen_path)}"
style="max-height:100px;border-radius:6px;border:1px solid #e5e7eb">
<button onclick="notasManager._quitarImg()"
style="position:absolute;top:-6px;right:-6px;background:#ef4444;color:#fff;
border:none;border-radius:50%;width:20px;height:20px;font-size:.65rem;
display:flex;align-items:center;justify-content:center">✕</button>
</div>` : ''}
</div>
<input type="file" id="nl-img-input" accept="image/jpeg,image/png,image/webp"
capture="environment" style="width:100%;font-size:.82rem"
onchange="notasManager._previsualizarImg(this)">
<input type="hidden" id="nl-img-guardada" value="${esc(nota?.imagen_path||'')}">
</div>
<button id="nl-guardar-btn" class="nota-btn-guardar" style="background:#0d6efd"
onclick="notasManager.guardar()">
<i class="fas fa-save me-2"></i>Guardar nota
</button>`;
// Cargar HTML en contenteditable
if (nota?.cuerpo) {
requestAnimationFrame(() => {
const el = document.getElementById('nl-cuerpo');
if (el) el.innerHTML = nota.cuerpo;
});
}
},
_fmt(cmd) { document.getElementById('nl-cuerpo')?.focus(); document.execCommand(cmd, false, null); },
_insertLista() { document.getElementById('nl-cuerpo')?.focus(); document.execCommand('insertUnorderedList', false, null); },
_limpiarFormato() { document.getElementById('nl-cuerpo')?.focus(); document.execCommand('removeFormat', false, null); },
_previsualizarImg(input) {
if (!input.files?.[0]) return;
const reader = new FileReader();
reader.onload = e => {
const prev = document.getElementById('nl-preview');
if (prev) prev.innerHTML = `<div style="position:relative;display:inline-block">
<img src="${e.target.result}" style="max-height:100px;border-radius:6px;border:1px solid #e5e7eb">
<button onclick="notasManager._quitarImg()"
style="position:absolute;top:-6px;right:-6px;background:#ef4444;color:#fff;
border:none;border-radius:50%;width:20px;height:20px;font-size:.65rem;
display:flex;align-items:center;justify-content:center">✕</button>
</div>`;
};
reader.readAsDataURL(input.files[0]);
},
_quitarImg() {
document.getElementById('nl-preview').innerHTML = '';
const inp = document.getElementById('nl-img-input');
if (inp) inp.value = '';
const h = document.getElementById('nl-img-guardada');
if (h) h.value = '';
},
// ── Guardar ──────────────────────────────────────────────────────────
async guardar() {
const btnId = this._editTipo === 'clinica' ? 'nc-guardar-btn' : 'nl-guardar-btn';
const btn = document.getElementById(btnId);
const labs = { clinica: 'Guardar ficha clínica', libre: 'Guardar nota' };
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Guardando…'; }
try {
let imagenPath = null;
if (this._editTipo === 'libre') {
const imgInput = document.getElementById('nl-img-input');
if (imgInput?.files?.[0]) {
imagenPath = await this._subirImagen(imgInput.files[0]);
} else {
imagenPath = document.getElementById('nl-img-guardada')?.value || null;
}
}
let payload = { domicilio_id: this._editDomId, tipo: this._editTipo };
if (this._editNota?.id) payload.id = +this._editNota.id;
if (this._editTipo === 'clinica') {
payload.antecedentes = document.getElementById('nc-antecedentes')?.value.trim() || null;
payload.medicamentos = document.getElementById('nc-medicamentos')?.value.trim() || null;
const menor = this._esmenor(this._editPacNac);
const acNombre = document.getElementById('nc-acud-nombre')?.value.trim() || null;
const acDoc = document.getElementById('nc-acud-doc')?.value.trim() || null;
if (menor === true && (!acNombre || !acDoc)) {
alert('El nombre y documento del acudiente son obligatorios para pacientes menores de edad.');
if (btn) { btn.disabled = false; btn.innerHTML = `<i class="fas fa-save me-2"></i>${labs.clinica}`; }
return;
}
payload.acudiente_nombre = acNombre;
payload.acudiente_documento = acDoc;
} else {
const titulo = document.getElementById('nl-titulo')?.value.trim();
if (!titulo) {
alert('El título de la nota es obligatorio.');
if (btn) { btn.disabled = false; btn.innerHTML = `<i class="fas fa-save me-2"></i>${labs.libre}`; }
return;
}
payload.titulo = titulo;
payload.cuerpo = document.getElementById('nl-cuerpo')?.innerHTML.trim() || null;
payload.imagen_path = imagenPath;
}
const r = await fetch('api/lab/save_nota_domicilio.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Error al guardar');
delete this._cache[this._editDomId];
this.cerrarSheet();
await this.cargar(this._editDomId);
} catch (e) {
alert('Error: ' + e.message);
if (btn) { btn.disabled = false; btn.innerHTML = `<i class="fas fa-save me-2"></i>Reintentar`; }
}
},
async _subirImagen(file) {
const fd = new FormData();
fd.append('file', file);
const r = await fetch('api/lab/upload_nota_imagen.php', { method: 'POST', body: fd });
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Error al subir imagen');
return d.imagen_path;
},
async eliminar(notaId, domId) {
if (!confirm('¿Eliminar esta nota?')) return;
try {
const r = await fetch('api/lab/delete_nota_domicilio.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: notaId }),
});
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Error al eliminar');
delete this._cache[domId];
await this.cargar(domId);
} catch (e) {
alert('Error: ' + e.message);
}
},
_abrirSheet() {
const sheet = document.getElementById('nota-sheet');
const backdrop = document.getElementById('nota-backdrop');
if (!sheet || !backdrop) return;
sheet.style.transform = 'translateY(100%)';
sheet.style.transition = '';
sheet.style.display = 'block';
backdrop.style.display = 'block';
requestAnimationFrame(() => {
sheet.style.transition = 'transform .32s cubic-bezier(.4,0,.2,1)';
sheet.style.transform = 'translateY(0)';
});
},
cerrarSheet() {
const sheet = document.getElementById('nota-sheet');
const backdrop = document.getElementById('nota-backdrop');
if (!sheet) return;
sheet.style.transition = 'transform .28s cubic-bezier(.4,0,.2,1)';
sheet.style.transform = 'translateY(100%)';
setTimeout(() => {
sheet.style.display = 'none';
if (backdrop) backdrop.style.display = 'none';
}, 300);
},
};
function _fmtFechaCorta(s) {
if (!s) return '';
return new Date(s).toLocaleString('es-CO', { day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit' });
}
document.addEventListener('DOMContentLoaded', () => {
portal.init();
agendaNueva.init();
@@ -840,6 +1324,13 @@ const formVer = {
};
</script>
<!-- ══════════════ BOTTOM SHEET: Notas clínicas ══════════════ -->
<div id="nota-backdrop" onclick="notasManager.cerrarSheet()"></div>
<div id="nota-sheet">
<div class="sheet-handle"><span></span></div>
<div id="nota-sheet-inner"></div>
</div>
<!-- ══════════════ FAB: Nueva Agenda ══════════════ -->
<button class="fab-agenda" title="Agendar nuevo domicilio" onclick="agendaNueva.abrir()">
<i class="fas fa-calendar-plus"></i>
@@ -1479,6 +1970,358 @@ const pagoModal = {
}
},
};
// ══════════════════════════════════════════════════════════
// OBJETO: notasManager — Sistema de notas clínicas
// ══════════════════════════════════════════════════════════
const notasManager = {
_cache: {}, // domicilio_id → { clinica, libres[] }
_domId: null, // domicilio actualmente en el sheet
_notaId: null, // null = nueva nota libre, número = editar
_imagen: null, // File pendiente de subir
// ── Abrir/cerrar sección de notas en la tarjeta ────────────────────────
async toggle(domId, btn) {
const body = document.getElementById(`notas-body-${domId}`);
const chev = btn.querySelector('.nota-chevron');
if (!body) return;
if (!body.classList.contains('d-none')) {
body.classList.add('d-none');
chev.classList.remove('open');
return;
}
body.classList.remove('d-none');
chev.classList.add('open');
await this._renderSeccion(domId);
},
// ── Cargar notas desde API (con caché) ────────────────────────────────
async _cargar(domId) {
const r = await fetch(`api/lab/get_notas_domicilio.php?domicilio_id=${domId}`);
const d = await r.json();
this._cache[domId] = d.success
? { clinica: d.clinica || null, libres: d.libres || [] }
: { clinica: null, libres: [] };
// Actualizar badge
const total = (d.libres || []).length + (d.clinica ? 1 : 0);
const badge = document.getElementById(`nota-count-${domId}`);
if (badge) {
badge.textContent = total;
badge.style.display = total ? '' : 'none';
}
return this._cache[domId];
},
// ── Renderizar sección completa en la tarjeta ─────────────────────────
async _renderSeccion(domId) {
const body = document.getElementById(`notas-body-${domId}`);
if (!body) return;
const data = await this._cargar(domId);
const pac = body.dataset.pacNac || '';
const esMenor = pac ? this._esmenor(pac) : false;
let html = '';
// 1. Ficha clínica
const fc = data.clinica;
html += `<div class="nota-ficha-card">
<div class="ficha-header">
<span class="ficha-titulo"><i class="fas fa-file-medical me-1"></i>Ficha clínica</span>
<button class="btn btn-xs btn-outline-warning py-0 px-2" style="font-size:.72rem"
onclick="notasManager.abrirFicha(${domId})">
<i class="fas fa-pen"></i> ${fc ? 'Editar' : 'Completar'}
</button>
</div>`;
if (fc) {
if (fc.antecedentes) html += `<div class="small mb-1"><span class="fw-semibold">Antecedentes:</span> ${esc(fc.antecedentes)}</div>`;
if (fc.medicamentos) html += `<div class="small mb-1"><span class="fw-semibold">Medicamentos:</span> ${esc(fc.medicamentos)}</div>`;
if (fc.acudiente_nombre) html += `<div class="small mb-1"><span class="fw-semibold">Acudiente:</span> ${esc(fc.acudiente_nombre)} · ${esc(fc.acudiente_documento||'—')}</div>`;
} else {
html += `<div class="small text-muted">Sin ficha clínica registrada</div>`;
}
if (esMenor && (!fc || !fc.acudiente_nombre)) {
html += `<div class="small text-danger mt-1"><i class="fas fa-exclamation-triangle me-1"></i>Paciente menor de edad — acudiente requerido</div>`;
}
html += `</div>`;
// 2. Notas libres
data.libres.forEach(n => {
html += `<div class="nota-libre-card" id="nota-libre-${n.id}">
<div class="d-flex justify-content-between align-items-start">
<div class="nota-titulo">${esc(n.titulo||'Sin título')}</div>
<div class="d-flex gap-1">
<button class="btn btn-xs btn-outline-secondary py-0 px-1" style="font-size:.7rem"
onclick="notasManager.abrirLibre(${domId}, ${n.id})">
<i class="fas fa-pen"></i>
</button>
<button class="btn btn-xs btn-outline-danger py-0 px-1" style="font-size:.7rem"
onclick="notasManager.eliminar(${n.id}, ${domId})">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<div class="nota-cuerpo">${n.cuerpo ? n.cuerpo.replace(/\n/g,'<br>') : ''}</div>
${n.imagen_path ? `<img src="uploads/media/${esc(n.imagen_path)}"
class="img-fluid rounded mt-1" style="max-height:160px;cursor:pointer"
onclick="notasManager._verImg(this.src)">` : ''}
<div class="nota-fecha">${esc(n.created_at||'')}</div>
</div>`;
});
html += `<button class="btn btn-sm w-100 mt-1" style="background:#e0f2fe;color:#0369a1;border:1px dashed #7dd3fc;border-radius:8px;font-size:.78rem"
onclick="notasManager.abrirLibre(${domId}, null)">
<i class="fas fa-plus me-1"></i>Agregar nota
</button>`;
body.innerHTML = html;
},
// ── Bottom sheet: Ficha clínica ────────────────────────────────────────
abrirFicha(domId) {
this._domId = domId;
this._notaId = 'ficha';
this._imagen = null;
const body = document.getElementById(`notas-body-${domId}`);
const pac = body ? body.dataset.pacNac || '' : '';
const esMenor= pac ? this._esmenor(pac) : false;
const fc = (this._cache[domId] || {}).clinica || {};
document.getElementById('nota-sheet-title').textContent = 'Ficha clínica';
document.getElementById('nota-sheet-inner').innerHTML = `
<div class="nota-section">
<label class="nota-label"><i class="fas fa-history me-1 text-orange"></i>Antecedentes</label>
<textarea id="ns-antecedentes" class="nota-input" rows="3"
placeholder="Alergias, enfermedades previas, cirugías...">${esc(fc.antecedentes||'')}</textarea>
</div>
<div class="nota-section">
<label class="nota-label"><i class="fas fa-pills me-1 text-blue"></i>Medicamentos actuales</label>
<textarea id="ns-medicamentos" class="nota-input" rows="3"
placeholder="Nombre, dosis y frecuencia...">${esc(fc.medicamentos||'')}</textarea>
</div>
<div id="ns-acudiente-bloque" class="nota-acudiente-bloque mb-3 ${esMenor ? '' : 'd-none'}">
<div class="small fw-bold text-danger mb-2"><i class="fas fa-child me-1"></i>Datos del acudiente (paciente menor de edad)</div>
<label class="nota-label">Nombre completo acudiente <span class="text-danger">*</span></label>
<input type="text" id="ns-acud-nombre" class="nota-input mb-2"
placeholder="Nombre completo" value="${esc(fc.acudiente_nombre||'')}">
<label class="nota-label">Documento acudiente <span class="text-danger">*</span></label>
<input type="text" id="ns-acud-doc" class="nota-input"
placeholder="Número de documento" value="${esc(fc.acudiente_documento||'')}">
</div>
${!esMenor ? `<label class="d-flex align-items-center gap-2 small text-muted mb-3" style="cursor:pointer">
<input type="checkbox" id="ns-menor-toggle" onchange="notasManager._toggleAcudiente(this.checked)"
${fc.acudiente_nombre ? 'checked' : ''}>
<span>¿Paciente menor de edad?</span>
</label>` : ''}
<button class="nota-btn-guardar" style="background:#0d6efd"
onclick="notasManager._guardarFicha()">
<i class="fas fa-save me-2"></i>Guardar ficha clínica
</button>`;
if (!esMenor && fc.acudiente_nombre) {
document.getElementById('ns-acudiente-bloque').classList.remove('d-none');
}
this._abrirSheet();
},
// ── Bottom sheet: Nota libre ───────────────────────────────────────────
abrirLibre(domId, notaId) {
this._domId = domId;
this._notaId = notaId;
this._imagen = null;
let titulo = '', cuerpo = '';
if (notaId) {
const n = (this._cache[domId]?.libres || []).find(x => x.id == notaId);
if (n) { titulo = n.titulo || ''; cuerpo = n.cuerpo || ''; }
}
document.getElementById('nota-sheet-title').textContent = notaId ? 'Editar nota' : 'Nueva nota';
document.getElementById('nota-sheet-inner').innerHTML = `
<div class="nota-section">
<label class="nota-label">Título</label>
<input type="text" id="ns-titulo" class="nota-input"
placeholder="Ej: Observación inicial, Incidente..." value="${esc(titulo)}">
</div>
<div class="nota-section">
<label class="nota-label">Nota</label>
<div class="fmt-toolbar">
<button class="fmt-btn" onclick="notasManager._fmt('bold')"><b>N</b></button>
<button class="fmt-btn" onclick="notasManager._fmt('italic')"><i>K</i></button>
<button class="fmt-btn" onclick="notasManager._fmt('underline')"><u>U</u></button>
<button class="fmt-btn" onclick="notasManager._fmt('strikeThrough')"><s>S</s></button>
<button class="fmt-btn" onclick="notasManager._fmt('insertUnorderedList')">• Lista</button>
<button class="fmt-btn" onclick="notasManager._fmt('insertOrderedList')">1. Lista</button>
</div>
<div id="ns-cuerpo" class="nota-editor" contenteditable="true"
data-placeholder="Escribe aquí la observación...">${cuerpo}</div>
</div>
<div class="nota-section">
<label class="nota-label"><i class="fas fa-image me-1"></i>Imagen (opcional)</label>
<div class="d-flex gap-2">
<label class="btn btn-sm btn-outline-secondary" style="font-size:.76rem">
<i class="fas fa-camera me-1"></i>Cámara
<input type="file" accept="image/*" capture="environment" class="d-none"
onchange="notasManager._prevImagen(this)">
</label>
<label class="btn btn-sm btn-outline-secondary" style="font-size:.76rem">
<i class="fas fa-image me-1"></i>Galería
<input type="file" accept="image/*" class="d-none"
onchange="notasManager._prevImagen(this)">
</label>
</div>
<div id="ns-img-preview" class="mt-2"></div>
</div>
<button class="nota-btn-guardar" style="background:#059669"
onclick="notasManager._guardarLibre()">
<i class="fas fa-save me-2"></i>Guardar nota
</button>`;
this._abrirSheet();
},
// ── Guardar ficha clínica ─────────────────────────────────────────────
async _guardarFicha() {
const esMenor = !document.getElementById('ns-acudiente-bloque')?.classList.contains('d-none');
const antecedentes = document.getElementById('ns-antecedentes')?.value.trim() || '';
const medicamentos = document.getElementById('ns-medicamentos')?.value.trim() || '';
const acudNombre = document.getElementById('ns-acud-nombre')?.value.trim() || '';
const acudDoc = document.getElementById('ns-acud-doc')?.value.trim() || '';
if (esMenor && (!acudNombre || !acudDoc)) {
alert('El paciente es menor de edad — completa nombre y documento del acudiente.');
return;
}
try {
const r = await fetch('api/lab/save_nota_domicilio.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tipo: 'clinica',
domicilio_id: this._domId,
antecedentes, medicamentos,
acudiente_nombre: acudNombre || null,
acudiente_documento: acudDoc || null,
}),
});
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Error al guardar');
delete this._cache[this._domId];
this._cerrarSheet();
await this._renderSeccion(this._domId);
} catch(e) { alert('Error: ' + e.message); }
},
// ── Guardar nota libre ─────────────────────────────────────────────────
async _guardarLibre() {
const titulo = document.getElementById('ns-titulo')?.value.trim() || '';
const cuerpo = document.getElementById('ns-cuerpo')?.innerHTML || '';
if (!titulo) { alert('Escribe un título para la nota.'); return; }
let imagenPath = null;
if (this._imagen) {
const fd = new FormData();
fd.append('file', this._imagen);
const ru = await fetch('api/lab/upload_nota_imagen.php', { method:'POST', body: fd });
const du = await ru.json();
if (!du.success) { alert('No se pudo subir la imagen: ' + (du.error||'')); return; }
imagenPath = du.local_file;
}
try {
const payload = {
tipo: 'libre',
domicilio_id: this._domId,
titulo,
cuerpo,
imagen_path: imagenPath,
};
if (this._notaId) payload.id = this._notaId;
const r = await fetch('api/lab/save_nota_domicilio.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Error al guardar');
delete this._cache[this._domId];
this._cerrarSheet();
await this._renderSeccion(this._domId);
} catch(e) { alert('Error: ' + e.message); }
},
// ── Eliminar nota libre ────────────────────────────────────────────────
async eliminar(notaId, domId) {
if (!confirm('¿Eliminar esta nota?')) return;
try {
const r = await fetch('api/lab/delete_nota_domicilio.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: notaId }),
});
const d = await r.json();
if (!d.success) throw new Error(d.error);
delete this._cache[domId];
await this._renderSeccion(domId);
} catch(e) { alert('Error: ' + e.message); }
},
// ── Helpers de UI ──────────────────────────────────────────────────────
_abrirSheet() {
document.getElementById('nota-backdrop').style.display = 'block';
document.getElementById('nota-sheet').style.display = 'block';
document.body.style.overflow = 'hidden';
},
_cerrarSheet() {
document.getElementById('nota-backdrop').style.display = 'none';
document.getElementById('nota-sheet').style.display = 'none';
document.body.style.overflow = '';
this._domId = null; this._notaId = null; this._imagen = null;
},
_fmt(cmd) { document.execCommand(cmd, false, null); document.getElementById('ns-cuerpo')?.focus(); },
_toggleAcudiente(on) {
document.getElementById('ns-acudiente-bloque')?.classList.toggle('d-none', !on);
},
_prevImagen(input) {
const file = input.files?.[0];
if (!file) return;
this._imagen = file;
const reader = new FileReader();
reader.onload = e => {
const prev = document.getElementById('ns-img-preview');
if (prev) prev.innerHTML = `<img src="${e.target.result}"
class="img-fluid rounded" style="max-height:140px">
<div class="small text-muted mt-1">${esc(file.name)}</div>`;
};
reader.readAsDataURL(file);
},
_verImg(src) {
const w = window.open('', '_blank');
if (w) { w.document.write(`<img src="${src}" style="max-width:100%;height:auto">`); }
},
_esmenor(fechaNac) {
if (!fechaNac) return false;
const d = new Date(fechaNac);
const hoy = new Date();
const años = hoy.getFullYear() - d.getFullYear() -
(hoy < new Date(hoy.getFullYear(), d.getMonth(), d.getDate()) ? 1 : 0);
return años < 18;
},
};
</script>
<!-- ── Bottom sheet: editor de notas ──────────────────────────── -->
<div id="nota-backdrop" onclick="notasManager._cerrarSheet()"></div>
<div id="nota-sheet" role="dialog" aria-modal="true">
<div class="sheet-handle"><span></span></div>
<div style="padding:0 16px 4px;display:flex;align-items:center;justify-content:space-between">
<span id="nota-sheet-title" style="font-weight:800;font-size:.95rem;color:#111"></span>
<button onclick="notasManager._cerrarSheet()"
style="background:none;border:none;font-size:1.3rem;color:#9ca3af;line-height:1">&times;</button>
</div>
<div id="nota-sheet-inner"></div>
</div>
</body>
</html>
+1 -1
View File
@@ -433,7 +433,7 @@ async function cargarMetricas(desde, hasta) {
const r = await fetch(`api/lab/get_metricas.php?desde=${desde}&hasta=${hasta}`);
const d = await r.json();
if (!d.success) return;
const m = d.data;
const m = d; // jsonOk() hace array_merge → datos en nivel raíz
// ── KPIs ──
const kpi = m.kpi || {};
@@ -0,0 +1,28 @@
-- Migración: Sistema de notas clínicas por domicilio
-- Permite al enfermero registrar:
-- · Una ficha clínica (antecedentes, medicamentos, acudiente si es menor)
-- · N notas libres adicionales con título, texto enriquecido e imagen adjunta
CREATE TABLE IF NOT EXISTS `lab_domicilio_notas` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`domicilio_id` INT(11) NOT NULL COMMENT 'FK lab_domicilios.id',
`enfermera_id` INT(11) NOT NULL COMMENT 'FK lab_enfermeras.id — quien registró',
`tipo` ENUM('clinica','libre') NOT NULL DEFAULT 'libre',
-- Campos exclusivos de tipo='clinica'
`antecedentes` TEXT DEFAULT NULL COMMENT 'Antecedentes clínicos del paciente',
`medicamentos` TEXT DEFAULT NULL COMMENT 'Medicamentos actuales del paciente',
`acudiente_nombre` VARCHAR(200) DEFAULT NULL COMMENT 'Nombre del acudiente (obligatorio si es menor)',
`acudiente_documento` VARCHAR(50) DEFAULT NULL COMMENT 'Documento del acudiente',
-- Campos exclusivos de tipo='libre'
`titulo` VARCHAR(200) DEFAULT NULL COMMENT 'Título de la nota libre',
`cuerpo` TEXT DEFAULT NULL COMMENT 'Cuerpo de la nota (HTML básico desde contenteditable)',
`imagen_path` VARCHAR(300) DEFAULT NULL COMMENT 'Nombre del archivo de imagen en uploads/media/',
-- Timestamps
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_notas_domicilio` (`domicilio_id`),
KEY `idx_notas_enfermera` (`enfermera_id`),
KEY `idx_notas_tipo` (`domicilio_id`, `tipo`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Notas clínicas y libres registradas por enfermeros durante un servicio de domicilio';
+25
View File
@@ -0,0 +1,25 @@
<?php
/**
* Migración: crea tabla lab_domicilio_notas
* Ejecutar: php migrations/run_20260325_notas.php
*/
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../classes/Database.php';
$db = Database::getInstance()->getConnection();
$sql = file_get_contents(__DIR__ . '/20260325_lab_12_domicilio_notas.sql');
// Ejecutar la sentencia CREATE TABLE
try {
$db->exec($sql);
echo "✅ Tabla lab_domicilio_notas creada correctamente.\n";
} catch (PDOException $e) {
if (str_contains($e->getMessage(), 'already exists')) {
echo "⚠️ La tabla ya existe (omitido).\n";
} else {
echo "❌ ERROR: " . $e->getMessage() . "\n";
exit(1);
}
}
echo "Migración completada.\n";