feat: sistema de notas clínicas para enfermero (ficha + notas libres) y fix lab_reportes métricas
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
@@ -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">×</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">×</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">×</button>
|
||||
</div>
|
||||
<div id="nota-sheet-inner"></div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -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';
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user