Compare commits
237
Commits
master
...
c6d2caef55
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Migración: simplificar formulario de Tomas Prolongadas (id=15)
|
||||||
|
* - Campo _c8j2g16 pasa de radio/checkbox a texto libre ("Tipo de examen")
|
||||||
|
* - Se eliminan las condiciones de todos los separadores (siempre visibles)
|
||||||
|
* Auto-elimina al ejecutar. Acceder como admin.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/config/config.php';
|
||||||
|
if (!isUserLoggedIn() || !in_array($_SESSION['admin_user']['role'] ?? '', ['admin','superadmin'], true)) {
|
||||||
|
http_response_code(403); die('Acceso denegado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
$row = $pdo->query("SELECT esquema FROM lab_formularios WHERE id = 15 LIMIT 1")->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$row) { die('Formulario id=15 no encontrado.'); }
|
||||||
|
|
||||||
|
$esquema = json_decode($row['esquema'], true);
|
||||||
|
if (!is_array($esquema)) { die('Esquema inválido.'); }
|
||||||
|
|
||||||
|
$cambios = 0;
|
||||||
|
foreach ($esquema as &$campo) {
|
||||||
|
$id = $campo['id'] ?? '';
|
||||||
|
$tipo = $campo['tipo'] ?? '';
|
||||||
|
|
||||||
|
// Separadores con condición sobre _c8j2g16 → quitar condición
|
||||||
|
if ($tipo === 'separador' && isset($campo['condicion'])) {
|
||||||
|
$condCampo = $campo['condicion']['campo_id'] ?? '';
|
||||||
|
if ($condCampo === '_c8j2g16') {
|
||||||
|
unset($campo['condicion']);
|
||||||
|
$cambios++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($campo);
|
||||||
|
|
||||||
|
$pdo->prepare("UPDATE lab_formularios SET esquema = ? WHERE id = 15")
|
||||||
|
->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
|
||||||
|
|
||||||
|
@unlink(__FILE__);
|
||||||
|
|
||||||
|
echo '<p style="font-family:sans-serif;padding:2rem">
|
||||||
|
<span style="color:green;font-size:1.2rem">✅ Migración completada</span><br><br>
|
||||||
|
<strong>' . $cambios . ' campos modificados</strong>:<br>
|
||||||
|
• Condiciones eliminadas de separadores de tomas<br>
|
||||||
|
• Campo _c8j2g16 conservado como selector (+ botón "Añadir tipo")<br><br>
|
||||||
|
<small style="color:#666">Este archivo fue eliminado automáticamente.</small><br><br>
|
||||||
|
<a href="lab_formularios.php" style="color:#0d6efd">← Volver a Formularios</a>
|
||||||
|
</p>';
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Restaura las condiciones eliminadas por _migrate_tomas_simplify.php
|
||||||
|
* Para cada separador cuyo label empiece con un tipo de examen conocido
|
||||||
|
* (ej. "Curva de Glicemia · Minuto 0") → agrega condicion sobre _c8j2g16.
|
||||||
|
* Auto-elimina al ejecutar. Acceder como admin.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/config/config.php';
|
||||||
|
if (!isUserLoggedIn() || !in_array($_SESSION['admin_user']['role'] ?? '', ['admin','superadmin'], true)) {
|
||||||
|
http_response_code(403); die('Acceso denegado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
$row = $pdo->query("SELECT esquema FROM lab_formularios WHERE id = 15 LIMIT 1")->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$row) { die('Formulario id=15 no encontrado.'); }
|
||||||
|
|
||||||
|
$esquema = json_decode($row['esquema'], true);
|
||||||
|
if (!is_array($esquema)) { die('Esquema inválido.'); }
|
||||||
|
|
||||||
|
// Obtener opciones del selector de examen
|
||||||
|
$examOptions = [];
|
||||||
|
foreach ($esquema as $c) {
|
||||||
|
if (($c['id'] ?? '') === '_c8j2g16') { $examOptions = $c['options'] ?? []; break; }
|
||||||
|
}
|
||||||
|
if (empty($examOptions)) { die('No se encontraron tipos de examen en _c8j2g16.'); }
|
||||||
|
|
||||||
|
$restaurados = 0;
|
||||||
|
foreach ($esquema as &$campo) {
|
||||||
|
if (($campo['tipo'] ?? '') !== 'separador') continue;
|
||||||
|
if (isset($campo['condicion'])) continue; // ya tiene condicion
|
||||||
|
|
||||||
|
$label = $campo['label'] ?? '';
|
||||||
|
$prefix = trim(explode('·', $label)[0] ?? '');
|
||||||
|
if (!$prefix) continue;
|
||||||
|
|
||||||
|
// Buscar si el prefijo corresponde exactamente a un tipo de examen
|
||||||
|
if (in_array($prefix, $examOptions, true)) {
|
||||||
|
$campo['condicion'] = [
|
||||||
|
'campo_id' => '_c8j2g16',
|
||||||
|
'valores' => [$prefix],
|
||||||
|
];
|
||||||
|
$restaurados++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($campo);
|
||||||
|
|
||||||
|
$pdo->prepare("UPDATE lab_formularios SET esquema = ? WHERE id = 15")
|
||||||
|
->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
|
||||||
|
|
||||||
|
@unlink(__FILE__);
|
||||||
|
|
||||||
|
echo '<p style="font-family:sans-serif;padding:2rem">
|
||||||
|
<span style="color:green;font-size:1.2rem">✅ Condiciones restauradas</span><br><br>
|
||||||
|
<strong>' . $restaurados . ' separadores</strong> actualizados con condicion sobre _c8j2g16.<br>
|
||||||
|
<small style="color:#666">Tipos de examen encontrados: ' . implode(', ', array_map('htmlspecialchars', $examOptions)) . '</small><br><br>
|
||||||
|
<small style="color:#999">Este archivo fue eliminado automáticamente.</small><br><br>
|
||||||
|
<a href="lab_formularios.php" style="color:#0d6efd">← Volver a Formularios</a>
|
||||||
|
</p>';
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Script de migración de un solo uso — ELIMINAR DESPUÉS DE EJECUTAR
|
||||||
|
* Acceder como admin para registrar lab_tomas_config en system_modules.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/config/config.php';
|
||||||
|
if (!isUserLoggedIn() || !in_array($_SESSION['admin_user']['role'] ?? '', ['admin','superadmin'], true)) {
|
||||||
|
http_response_code(403); die('Acceso denegado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
|
||||||
|
$row = $pdo->query("SELECT MAX(sort_order) AS mx FROM system_modules WHERE category = 'clinico'")->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$nextOrder = (int)($row['mx'] ?? 50) + 10;
|
||||||
|
|
||||||
|
$pdo->prepare("
|
||||||
|
INSERT INTO system_modules (slug, name, icon, category, route, is_active, sort_order, description)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name=VALUES(name), icon=VALUES(icon), category=VALUES(category),
|
||||||
|
route=VALUES(route), is_active=1, sort_order=VALUES(sort_order), description=VALUES(description)
|
||||||
|
")->execute([
|
||||||
|
'lab_tomas_config',
|
||||||
|
'Tipos de Examen (Tomas)',
|
||||||
|
'fas fa-vials',
|
||||||
|
'clinico',
|
||||||
|
'/lab_tomas_config.php',
|
||||||
|
$nextOrder,
|
||||||
|
'Configurar tipos de examen y ciclos de tomas prolongadas (F-LAB-28)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Auto-eliminar el script
|
||||||
|
@unlink(__FILE__);
|
||||||
|
|
||||||
|
echo '<p style="font-family:sans-serif;color:green;padding:2rem">
|
||||||
|
✅ Módulo <strong>lab_tomas_config</strong> registrado en system_modules (sort_order='.$nextOrder.').<br>
|
||||||
|
<small>Este archivo fue eliminado automáticamente.</small><br><br>
|
||||||
|
<a href="lab_tomas_config.php">Ir a Tipos de Examen →</a>
|
||||||
|
</p>';
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/lab/add_exam_option.php
|
||||||
|
* Añade una opción al campo selector de tipo de examen (_c8j2g16) del formulario id=15.
|
||||||
|
* Body JSON: { exam_name: string }
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireMethod('POST');
|
||||||
|
requireAdmin();
|
||||||
|
|
||||||
|
$body = inputJson();
|
||||||
|
$examName = trim($body['exam_name'] ?? '');
|
||||||
|
|
||||||
|
if (!$examName) jsonError('exam_name requerido.');
|
||||||
|
if (strlen($examName) > 80) jsonError('Nombre demasiado largo (máx 80 chars).');
|
||||||
|
if (!preg_match('/\S/', $examName)) jsonError('Nombre inválido.');
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$row = $db->fetch("SELECT esquema FROM lab_formularios WHERE id = 15 LIMIT 1");
|
||||||
|
if (!$row) jsonError('Formulario no encontrado.', 404);
|
||||||
|
|
||||||
|
$esquema = json_decode($row['esquema'], true);
|
||||||
|
if (!is_array($esquema)) jsonError('Esquema inválido.', 500);
|
||||||
|
|
||||||
|
$updated = false;
|
||||||
|
foreach ($esquema as &$campo) {
|
||||||
|
if (($campo['id'] ?? '') !== '_c8j2g16') continue;
|
||||||
|
$opts = $campo['options'] ?? [];
|
||||||
|
if (in_array($examName, $opts, true)) jsonError("El tipo \"$examName\" ya existe.");
|
||||||
|
$opts[] = $examName;
|
||||||
|
$campo['options'] = $opts;
|
||||||
|
$updated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
unset($campo);
|
||||||
|
|
||||||
|
if (!$updated) jsonError('Campo selector de examen no encontrado en el formulario.', 500);
|
||||||
|
|
||||||
|
$db->getConnection()->prepare("UPDATE lab_formularios SET esquema = ? WHERE id = 15")
|
||||||
|
->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
|
||||||
|
|
||||||
|
jsonOk(['exam_name' => $examName], "Tipo \"$examName\" añadido.");
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* /api/lab/ciudades.php
|
||||||
|
* GET ?action=list [solo_activas=1] → lista ciudades
|
||||||
|
* POST {action:save, id?, nombre} → crear / renombrar
|
||||||
|
* POST {action:toggle, id} → activar / desactivar
|
||||||
|
* POST {action:delete, id} → eliminar (solo si no hay pacientes)
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||||
|
$soloActivas = isset($_GET['solo_activas']) && $_GET['solo_activas'] == '1';
|
||||||
|
$sql = 'SELECT id, nombre, activa, orden FROM lab_ciudades' . ($soloActivas ? ' WHERE activa = 1' : '') . ' ORDER BY nombre';
|
||||||
|
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
jsonOk(['ciudades' => $rows]);
|
||||||
|
}
|
||||||
|
|
||||||
|
requireMethod('POST');
|
||||||
|
requireAdmin();
|
||||||
|
|
||||||
|
$body = inputJson();
|
||||||
|
$action = trim($body['action'] ?? '');
|
||||||
|
|
||||||
|
if ($action === 'save') {
|
||||||
|
$nombre = trim($body['nombre'] ?? '');
|
||||||
|
$id = (int)($body['id'] ?? 0);
|
||||||
|
if (!$nombre) jsonError('nombre requerido.');
|
||||||
|
if (strlen($nombre) > 100) jsonError('nombre demasiado largo (máx 100).');
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
$pdo->prepare('UPDATE lab_ciudades SET nombre = ? WHERE id = ?')->execute([$nombre, $id]);
|
||||||
|
jsonOk(['id' => $id], 'Ciudad actualizada.');
|
||||||
|
} else {
|
||||||
|
$st = $pdo->prepare('INSERT INTO lab_ciudades (nombre) VALUES (?)');
|
||||||
|
try {
|
||||||
|
$st->execute([$nombre]);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
if ($e->getCode() == 23000) jsonError('Ya existe una ciudad con ese nombre.');
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
jsonOk(['id' => (int)$pdo->lastInsertId()], 'Ciudad creada.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'toggle') {
|
||||||
|
$id = (int)($body['id'] ?? 0);
|
||||||
|
if (!$id) jsonError('id requerido.');
|
||||||
|
$pdo->prepare('UPDATE lab_ciudades SET activa = NOT activa WHERE id = ?')->execute([$id]);
|
||||||
|
jsonOk([], 'Estado actualizado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete') {
|
||||||
|
$id = (int)($body['id'] ?? 0);
|
||||||
|
if (!$id) jsonError('id requerido.');
|
||||||
|
$uso = $pdo->prepare('SELECT COUNT(*) FROM lab_pacientes WHERE ciudad = (SELECT nombre FROM lab_ciudades WHERE id = ?)');
|
||||||
|
$uso->execute([$id]);
|
||||||
|
if ((int)$uso->fetchColumn() > 0) jsonError('No se puede eliminar: hay pacientes con esta ciudad. Desactívela en su lugar.');
|
||||||
|
$pdo->prepare('DELETE FROM lab_ciudades WHERE id = ?')->execute([$id]);
|
||||||
|
jsonOk([], 'Ciudad eliminada.');
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonError('action inválida.');
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* /api/lab/empresa_subgrupos.php
|
||||||
|
*
|
||||||
|
* GET ?nit_empresa= → subgrupos de la empresa
|
||||||
|
* POST {action:save, ...} → upsert subgrupo
|
||||||
|
* POST {action:delete, id} → elimina subgrupo
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$nit = trim($_GET['nit_empresa'] ?? '');
|
||||||
|
if ($nit === '') jsonError('nit_empresa requerido');
|
||||||
|
$pdo = db();
|
||||||
|
$s = $pdo->prepare(
|
||||||
|
"SELECT es.*, ti.nombre AS tarifa_nombre
|
||||||
|
FROM lab_empresa_subgrupos es
|
||||||
|
LEFT JOIN lab_tarifas_id ti ON ti.id = es.tarifa_id
|
||||||
|
WHERE es.nit_empresa = ?
|
||||||
|
ORDER BY es.subgrupo ASC"
|
||||||
|
);
|
||||||
|
$s->execute([$nit]);
|
||||||
|
jsonOk(['subgrupos' => $s->fetchAll(PDO::FETCH_ASSOC)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
requireMethod('POST');
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
$action = $data['action'] ?? '';
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
if ($action === 'delete') {
|
||||||
|
$id = (int)($data['id'] ?? 0);
|
||||||
|
if ($id <= 0) jsonError('id requerido');
|
||||||
|
$pdo->prepare("DELETE FROM lab_empresa_subgrupos WHERE id = ?")->execute([$id]);
|
||||||
|
jsonOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'save') {
|
||||||
|
$id = (int)($data['id'] ?? 0);
|
||||||
|
$nitEmpresa = trim($data['nit_empresa'] ?? '');
|
||||||
|
$subgrupo = trim($data['subgrupo'] ?? '');
|
||||||
|
if ($nitEmpresa === '') jsonError('nit_empresa requerido');
|
||||||
|
if ($subgrupo === '') jsonError('subgrupo requerido');
|
||||||
|
|
||||||
|
$tarifaId = isset($data['tarifa_id']) && $data['tarifa_id'] !== '' ? (int)$data['tarifa_id'] : null;
|
||||||
|
$refSub = trim($data['ref_subgrupo'] ?? '') ?: null;
|
||||||
|
$codCon = trim($data['cod_contrato'] ?? '') ?: null;
|
||||||
|
|
||||||
|
if ($id > 0) {
|
||||||
|
$pdo->prepare(
|
||||||
|
"UPDATE lab_empresa_subgrupos
|
||||||
|
SET subgrupo=?, tarifa_id=?, ref_subgrupo=?, cod_contrato=?
|
||||||
|
WHERE id=? AND nit_empresa=?"
|
||||||
|
)->execute([$subgrupo, $tarifaId, $refSub, $codCon, $id, $nitEmpresa]);
|
||||||
|
} else {
|
||||||
|
$pdo->prepare(
|
||||||
|
"INSERT INTO lab_empresa_subgrupos (nit_empresa, subgrupo, tarifa_id, ref_subgrupo, cod_contrato)
|
||||||
|
VALUES (?, ?, ?, ?, ?)"
|
||||||
|
)->execute([$nitEmpresa, $subgrupo, $tarifaId, $refSub, $codCon]);
|
||||||
|
$id = (int)$pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
jsonOk(['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonError('Acción no reconocida', 400);
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* /api/lab/empresas.php
|
||||||
|
*
|
||||||
|
* GET ?action=list [search, activa, page, limit] → lista paginada
|
||||||
|
* GET ?action=get &nit= → empresa + subgrupos
|
||||||
|
* POST {action:save, nit, nombre, ...} → upsert empresa
|
||||||
|
* POST {action:toggle, nit} → activa/inactiva
|
||||||
|
* POST {action:delete, nit} → elimina (si sin recepciones)
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
// ─── GET ─────────────────────────────────────────────────────────────────────
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$action = $_GET['action'] ?? 'list';
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
if ($action === 'get') {
|
||||||
|
$nit = trim($_GET['nit'] ?? '');
|
||||||
|
if ($nit === '') jsonError('nit requerido');
|
||||||
|
|
||||||
|
$e = $pdo->prepare(
|
||||||
|
"SELECT e.*, ti.nombre AS tarifa_nombre
|
||||||
|
FROM lab_empresas e
|
||||||
|
LEFT JOIN lab_tarifas_id ti ON ti.id = e.tarifa_id
|
||||||
|
WHERE e.nit = ?"
|
||||||
|
);
|
||||||
|
$e->execute([$nit]);
|
||||||
|
$empresa = $e->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$empresa) jsonError('Empresa no encontrada', 404);
|
||||||
|
|
||||||
|
$s = $pdo->prepare(
|
||||||
|
"SELECT es.*, ti.nombre AS tarifa_nombre
|
||||||
|
FROM lab_empresa_subgrupos es
|
||||||
|
LEFT JOIN lab_tarifas_id ti ON ti.id = es.tarifa_id
|
||||||
|
WHERE es.nit_empresa = ?
|
||||||
|
ORDER BY es.subgrupo ASC"
|
||||||
|
);
|
||||||
|
$s->execute([$nit]);
|
||||||
|
$empresa['subgrupos'] = $s->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
jsonOk(['empresa' => $empresa]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// list
|
||||||
|
$search = trim($_GET['search'] ?? '');
|
||||||
|
$activa = isset($_GET['activa']) && $_GET['activa'] !== '' ? (int)$_GET['activa'] : null;
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$limit = max(1, min(100, (int)($_GET['limit'] ?? 30)));
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
if ($search !== '') {
|
||||||
|
$where[] = '(e.nombre LIKE ? OR e.nit LIKE ? OR e.razon_social LIKE ?)';
|
||||||
|
$like = "%{$search}%";
|
||||||
|
$params[] = $like; $params[] = $like; $params[] = $like;
|
||||||
|
}
|
||||||
|
if ($activa !== null) {
|
||||||
|
$where[] = 'e.activa = ?';
|
||||||
|
$params[] = $activa;
|
||||||
|
}
|
||||||
|
|
||||||
|
$wSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||||
|
|
||||||
|
$total = $pdo->prepare("SELECT COUNT(*) FROM lab_empresas e $wSql");
|
||||||
|
$total->execute($params);
|
||||||
|
$totalRows = (int)$total->fetchColumn();
|
||||||
|
|
||||||
|
$rows = $pdo->prepare(
|
||||||
|
"SELECT e.nit, e.nombre, e.razon_social, e.tarifa_id, ti.nombre AS tarifa_nombre,
|
||||||
|
e.descuento_pct, e.codigo_eps, e.tipo_usuario, e.req_autoriza, e.activa,
|
||||||
|
(SELECT COUNT(*) FROM lab_empresa_subgrupos es WHERE es.nit_empresa = e.nit) AS total_subgrupos
|
||||||
|
FROM lab_empresas e
|
||||||
|
LEFT JOIN lab_tarifas_id ti ON ti.id = e.tarifa_id
|
||||||
|
$wSql
|
||||||
|
ORDER BY e.nombre ASC
|
||||||
|
LIMIT $limit OFFSET $offset"
|
||||||
|
);
|
||||||
|
$rows->execute($params);
|
||||||
|
|
||||||
|
jsonOk([
|
||||||
|
'empresas' => $rows->fetchAll(PDO::FETCH_ASSOC),
|
||||||
|
'total' => $totalRows,
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
'pages' => (int)ceil($totalRows / $limit),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST ────────────────────────────────────────────────────────────────────
|
||||||
|
requireMethod('POST');
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
$action = $data['action'] ?? '';
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
if ($action === 'toggle') {
|
||||||
|
$nit = trim($data['nit'] ?? '');
|
||||||
|
if ($nit === '') jsonError('nit requerido');
|
||||||
|
$pdo->prepare("UPDATE lab_empresas SET activa = 1 - activa WHERE nit = ?")->execute([$nit]);
|
||||||
|
$activa = (int)$pdo->prepare("SELECT activa FROM lab_empresas WHERE nit=?")->execute([$nit]);
|
||||||
|
$row = $pdo->prepare("SELECT activa FROM lab_empresas WHERE nit=?");
|
||||||
|
$row->execute([$nit]);
|
||||||
|
jsonOk(['activa' => (bool)(int)$row->fetchColumn()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete') {
|
||||||
|
$nit = trim($data['nit'] ?? '');
|
||||||
|
if ($nit === '') jsonError('nit requerido');
|
||||||
|
// Block deletion if empresa has recepciones (historical)
|
||||||
|
$chk = $pdo->prepare("SELECT COUNT(*) FROM lab_recepciones WHERE nit_empresa = ? LIMIT 1");
|
||||||
|
$chk->execute([$nit]);
|
||||||
|
if ((int)$chk->fetchColumn() > 0) {
|
||||||
|
jsonError('No se puede eliminar: la empresa tiene recepciones históricas registradas.', 409);
|
||||||
|
}
|
||||||
|
$pdo->prepare("DELETE FROM lab_empresas WHERE nit = ?")->execute([$nit]);
|
||||||
|
jsonOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'save') {
|
||||||
|
$nit = trim($data['nit'] ?? '');
|
||||||
|
$nombre = trim($data['nombre'] ?? '');
|
||||||
|
if ($nit === '') jsonError('El NIT es obligatorio');
|
||||||
|
if ($nombre === '') jsonError('El nombre es obligatorio');
|
||||||
|
|
||||||
|
$fields = [
|
||||||
|
'nombre' => $nombre,
|
||||||
|
'razon_social' => trim($data['razon_social'] ?? '') ?: null,
|
||||||
|
'tarifa_id' => isset($data['tarifa_id']) && $data['tarifa_id'] !== '' ? (int)$data['tarifa_id'] : null,
|
||||||
|
'descuento_pct' => isset($data['descuento_pct']) ? (float)$data['descuento_pct'] : 0,
|
||||||
|
'codigo_eps' => trim($data['codigo_eps'] ?? '') ?: null,
|
||||||
|
'tipo_usuario' => trim($data['tipo_usuario'] ?? '') ?: null,
|
||||||
|
'tipo_usuario_sispro'=> trim($data['tipo_usuario_sispro']?? '') ?: null,
|
||||||
|
'cod_contrato' => trim($data['cod_contrato'] ?? '') ?: null,
|
||||||
|
'cod_tercero' => trim($data['cod_tercero'] ?? '') ?: null,
|
||||||
|
'centro_costo' => trim($data['centro_costo'] ?? '') ?: null,
|
||||||
|
'req_autoriza' => isset($data['req_autoriza']) ? (int)(bool)$data['req_autoriza'] : 0,
|
||||||
|
'activa' => isset($data['activa']) ? (int)(bool)$data['activa'] : 1,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check if exists
|
||||||
|
$exists = $pdo->prepare("SELECT nit FROM lab_empresas WHERE nit = ?");
|
||||||
|
$exists->execute([$nit]);
|
||||||
|
$isNew = !$exists->fetch();
|
||||||
|
|
||||||
|
if ($isNew) {
|
||||||
|
$cols = implode(', ', array_map(fn($k) => "`$k`", array_keys($fields)));
|
||||||
|
$ph = implode(', ', array_fill(0, count($fields), '?'));
|
||||||
|
$pdo->prepare("INSERT INTO lab_empresas (nit, $cols) VALUES (?, $ph)")
|
||||||
|
->execute(array_merge([$nit], array_values($fields)));
|
||||||
|
} else {
|
||||||
|
$sets = implode(', ', array_map(fn($k) => "`$k` = ?", array_keys($fields)));
|
||||||
|
$pdo->prepare("UPDATE lab_empresas SET $sets WHERE nit = ?")
|
||||||
|
->execute(array_merge(array_values($fields), [$nit]));
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonOk(['nit' => $nit, 'created' => $isNew]);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonError('Acción no reconocida', 400);
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* /api/lab/eps.php
|
||||||
|
* GET ?action=list [solo_activas=1] → lista EPS
|
||||||
|
* POST {action:save, id?, nombre} → crear / renombrar
|
||||||
|
* POST {action:toggle, id} → activar / desactivar
|
||||||
|
* POST {action:delete, id} → eliminar (solo si no hay pacientes)
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||||
|
$soloActivas = isset($_GET['solo_activas']) && $_GET['solo_activas'] == '1';
|
||||||
|
$sql = 'SELECT id, nombre, activa, orden FROM lab_eps' . ($soloActivas ? ' WHERE activa = 1' : '') . ' ORDER BY nombre';
|
||||||
|
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
jsonOk(['eps' => $rows]);
|
||||||
|
}
|
||||||
|
|
||||||
|
requireMethod('POST');
|
||||||
|
requireAdmin();
|
||||||
|
|
||||||
|
$body = inputJson();
|
||||||
|
$action = trim($body['action'] ?? '');
|
||||||
|
|
||||||
|
if ($action === 'save') {
|
||||||
|
$nombre = trim($body['nombre'] ?? '');
|
||||||
|
$id = (int)($body['id'] ?? 0);
|
||||||
|
if (!$nombre) jsonError('nombre requerido.');
|
||||||
|
if (strlen($nombre) > 120) jsonError('nombre demasiado largo (máx 120).');
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
$pdo->prepare('UPDATE lab_eps SET nombre = ? WHERE id = ?')->execute([$nombre, $id]);
|
||||||
|
jsonOk(['id' => $id], 'EPS actualizada.');
|
||||||
|
} else {
|
||||||
|
$st = $pdo->prepare('INSERT INTO lab_eps (nombre) VALUES (?)');
|
||||||
|
try {
|
||||||
|
$st->execute([$nombre]);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
if ($e->getCode() == 23000) jsonError('Ya existe una EPS con ese nombre.');
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
jsonOk(['id' => (int)$pdo->lastInsertId()], 'EPS creada.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'toggle') {
|
||||||
|
$id = (int)($body['id'] ?? 0);
|
||||||
|
if (!$id) jsonError('id requerido.');
|
||||||
|
$pdo->prepare('UPDATE lab_eps SET activa = NOT activa WHERE id = ?')->execute([$id]);
|
||||||
|
jsonOk([], 'Estado actualizado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete') {
|
||||||
|
$id = (int)($body['id'] ?? 0);
|
||||||
|
if (!$id) jsonError('id requerido.');
|
||||||
|
$uso = $pdo->prepare('SELECT COUNT(*) FROM lab_pacientes WHERE eps = (SELECT nombre FROM lab_eps WHERE id = ?)');
|
||||||
|
$uso->execute([$id]);
|
||||||
|
if ((int)$uso->fetchColumn() > 0) jsonError('No se puede eliminar: hay pacientes con esta EPS. Desactívela en su lugar.');
|
||||||
|
$pdo->prepare('DELETE FROM lab_eps WHERE id = ?')->execute([$id]);
|
||||||
|
jsonOk([], 'EPS eliminada.');
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonError('action inválida.');
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET /api/lab/get_examenes_rips.php?cedula=X
|
||||||
|
* Consulta al RIPS Manager los exámenes registrados en los últimos 5 minutos
|
||||||
|
* para la cédula indicada, y los mapea a exam_tipos locales por codigo_legacy.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireMethod('GET');
|
||||||
|
|
||||||
|
$cedula = trim($_GET['cedula'] ?? '');
|
||||||
|
if (!$cedula) jsonError('cedula requerida', 400);
|
||||||
|
|
||||||
|
// ── 1. Buscar en cache local (exámenes enviados por el scheduler de RIPS) ─────
|
||||||
|
$examenes = [];
|
||||||
|
$fuenteCache = false;
|
||||||
|
|
||||||
|
$cacheRow = db()->prepare(
|
||||||
|
"SELECT datos, recepcion_id, hora_recepcion
|
||||||
|
FROM rips_examenes_pendientes
|
||||||
|
WHERE numero_documento = ?
|
||||||
|
AND DATE(created_at) = CURDATE()
|
||||||
|
AND created_at >= NOW() - INTERVAL 30 MINUTE
|
||||||
|
AND turno_id IS NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$cacheRow->execute([$cedula]);
|
||||||
|
$cache = $cacheRow->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($cache) {
|
||||||
|
$examenes = json_decode($cache['datos'], true) ?: [];
|
||||||
|
$fuenteCache = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Si no hay cache, consultar RIPS Manager ────────────────────────────────
|
||||||
|
if (!$examenes) {
|
||||||
|
$ripsUrl = defined('RIPS_MANAGER_URL') ? rtrim(RIPS_MANAGER_URL, '/') : '';
|
||||||
|
if (!$ripsUrl) jsonError('RIPS_MANAGER_URL no configurado en el servidor', 503);
|
||||||
|
|
||||||
|
$url = $ripsUrl . '/pacientes/examenes?cedula=' . urlencode($cedula);
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'GET',
|
||||||
|
'header' => 'X-Lab-Key: ' . LAB_SYNC_KEY . "\r\n",
|
||||||
|
'timeout' => 8,
|
||||||
|
'ignore_errors' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resp = @file_get_contents($url, false, $ctx);
|
||||||
|
if ($resp === false) jsonError('No se pudo conectar con RIPS Manager', 503);
|
||||||
|
|
||||||
|
$data = json_decode($resp, true);
|
||||||
|
if (!($data['ok'] ?? false)) {
|
||||||
|
jsonError($data['error'] ?? 'Error en RIPS Manager', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
$examenes = $data['examenes'] ?? [];
|
||||||
|
}
|
||||||
|
if (!$examenes) {
|
||||||
|
jsonOk(['encontrados' => [], 'no_mapeados' => [], 'total_rips' => 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapear COD_EXAMEN → exam_tipos por codigo_legacy
|
||||||
|
$codigos = array_values(array_unique(array_filter(
|
||||||
|
array_map(fn($e) => trim($e['cod_examen'] ?? ''), $examenes)
|
||||||
|
)));
|
||||||
|
|
||||||
|
$encontrados = [];
|
||||||
|
$no_mapeados = [];
|
||||||
|
|
||||||
|
if ($codigos) {
|
||||||
|
$ph = implode(',', array_fill(0, count($codigos), '?'));
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
"SELECT id AS exam_tipo_id, codigo, nombre,
|
||||||
|
COALESCE(codigo_legacy, codigo) AS match_key
|
||||||
|
FROM exam_tipos
|
||||||
|
WHERE (codigo_legacy IN ($ph) OR (codigo_legacy IS NULL AND codigo IN ($ph)))
|
||||||
|
AND activo = 1"
|
||||||
|
);
|
||||||
|
$stmt->execute(array_merge($codigos, $codigos));
|
||||||
|
$mapa = [];
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||||
|
$mapa[trim($r['match_key'])] = $r;
|
||||||
|
}
|
||||||
|
foreach ($codigos as $cod) {
|
||||||
|
if (isset($mapa[$cod])) {
|
||||||
|
$encontrados[] = [
|
||||||
|
'exam_tipo_id' => (int)$mapa[$cod]['exam_tipo_id'],
|
||||||
|
'codigo' => $mapa[$cod]['codigo'],
|
||||||
|
'nombre' => $mapa[$cod]['nombre'],
|
||||||
|
'cod_rips' => $cod,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$no_mapeados[] = $cod;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = $examenes[0] ?? [];
|
||||||
|
|
||||||
|
// ── Médico ordenante ──────────────────────────────────────────────────────────
|
||||||
|
$medicoObj = null;
|
||||||
|
$docidmedico = trim($meta['medico_docidmedico'] ?? '');
|
||||||
|
if ($docidmedico) {
|
||||||
|
$stm = db()->prepare(
|
||||||
|
"SELECT id, codigo, CONCAT(nombres, ' ', apellidos) AS nombre_completo, cod_especialidad
|
||||||
|
FROM medicos WHERE docidmedico = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$stm->execute([$docidmedico]);
|
||||||
|
$row = $stm->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if ($row) {
|
||||||
|
$medicoObj = [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'codigo' => $row['codigo'],
|
||||||
|
'nombre' => $row['nombre_completo'],
|
||||||
|
'especialidad' => $row['cod_especialidad'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empresa / EPS ─────────────────────────────────────────────────────────────
|
||||||
|
$empresaObj = null;
|
||||||
|
$nitEmpresa = trim($meta['nit_empresa'] ?? '');
|
||||||
|
if ($nitEmpresa) {
|
||||||
|
$stm = db()->prepare(
|
||||||
|
"SELECT e.nit, e.nombre, e.razon_social, e.tarifa_id,
|
||||||
|
ti.nombre AS tarifa_nombre, e.descuento_pct,
|
||||||
|
e.tipo_usuario, e.req_autoriza, e.activa
|
||||||
|
FROM lab_empresas e
|
||||||
|
LEFT JOIN lab_tarifas_id ti ON ti.id = e.tarifa_id
|
||||||
|
WHERE e.nit = ? AND e.activa = 1
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$stm->execute([$nitEmpresa]);
|
||||||
|
$row = $stm->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if ($row) {
|
||||||
|
$empresaObj = $row;
|
||||||
|
$empresaObj['activa'] = (bool)$empresaObj['activa'];
|
||||||
|
$empresaObj['req_autoriza'] = (bool)$empresaObj['req_autoriza'];
|
||||||
|
$empresaObj['descuento_pct'] = (float)$empresaObj['descuento_pct'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonOk([
|
||||||
|
'encontrados' => $encontrados,
|
||||||
|
'no_mapeados' => $no_mapeados,
|
||||||
|
'total_rips' => count($examenes),
|
||||||
|
'recepcion_id' => $meta['recepcion_id'] ?? null,
|
||||||
|
'hora' => $meta['hora'] ?? null,
|
||||||
|
'fuente' => $fuenteCache ? 'cache' : 'rips',
|
||||||
|
'diagnostico_cod' => $meta['diagnostico_cod'] ?? null,
|
||||||
|
'diagnostico_nombre' => $meta['diagnostico_nombre'] ?? null,
|
||||||
|
'medico' => $medicoObj,
|
||||||
|
'empresa' => $empresaObj,
|
||||||
|
'valor_total' => isset($meta['valor_total']) ? (float)$meta['valor_total'] : null,
|
||||||
|
]);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET ?user_id=X — Devuelve la firma_svg de un usuario (solo admins).
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireAdmin();
|
||||||
|
|
||||||
|
$userId = (int)($_GET['user_id'] ?? 0);
|
||||||
|
if ($userId <= 0) jsonError('user_id requerido.');
|
||||||
|
|
||||||
|
$db = Database::getInstance()->getConnection();
|
||||||
|
$row = $db->prepare("SELECT firma_svg FROM admin_users WHERE id = ? LIMIT 1");
|
||||||
|
$row->execute([$userId]);
|
||||||
|
$data = $row->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
jsonOk(['firma_svg' => $data['firma_svg'] ?? null]);
|
||||||
@@ -21,6 +21,7 @@ $users = $db->fetchAll("
|
|||||||
u.enfermera_id,
|
u.enfermera_id,
|
||||||
u.last_login,
|
u.last_login,
|
||||||
u.created_at,
|
u.created_at,
|
||||||
|
(u.firma_svg IS NOT NULL) AS tiene_firma,
|
||||||
r.name AS role_name,
|
r.name AS role_name,
|
||||||
r.color AS role_color,
|
r.color AS role_color,
|
||||||
r.slug AS role_slug,
|
r.slug AS role_slug,
|
||||||
@@ -33,6 +34,7 @@ $users = $db->fetchAll("
|
|||||||
|
|
||||||
foreach ($users as &$u) {
|
foreach ($users as &$u) {
|
||||||
$u['is_active'] = (bool)$u['is_active'];
|
$u['is_active'] = (bool)$u['is_active'];
|
||||||
|
$u['tiene_firma'] = (bool)$u['tiene_firma'];
|
||||||
$u['role_name'] = $u['role_name'] ?? ucfirst($u['role'] ?? 'admin');
|
$u['role_name'] = $u['role_name'] ?? ucfirst($u['role'] ?? 'admin');
|
||||||
$u['role_color'] = $u['role_color'] ?? '#0d6efd';
|
$u['role_color'] = $u['role_color'] ?? '#0d6efd';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,17 @@ try {
|
|||||||
jsonOk(['data' => [$row]]);
|
jsonOk(['data' => [$row]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isset($_GET['stats'])) {
|
||||||
|
jsonOk(['stats' => $pac->stats()]);
|
||||||
|
}
|
||||||
|
|
||||||
$busqueda = trim($_GET['busqueda'] ?? $_GET['search'] ?? '');
|
$busqueda = trim($_GET['busqueda'] ?? $_GET['search'] ?? '');
|
||||||
$pagina = max(1, (int)($_GET['page'] ?? $_GET['pagina'] ?? 1));
|
$pagina = max(1, (int)($_GET['page'] ?? $_GET['pagina'] ?? 1));
|
||||||
$por = max(1, min(100, (int)($_GET['limit'] ?? $_GET['por_pagina'] ?? 30)));
|
$por = max(1, min(100, (int)($_GET['limit'] ?? $_GET['por_pagina'] ?? 30)));
|
||||||
|
$origen = in_array($_GET['origen'] ?? '', ['manual', 'lab', 'whatsapp'], true)
|
||||||
|
? $_GET['origen'] : '';
|
||||||
|
|
||||||
jsonOk($pac->listar($busqueda, $pagina, $por));
|
jsonOk($pac->listar($busqueda, $pagina, $por, $origen));
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
jsonError($e->getMessage(), 500);
|
jsonError($e->getMessage(), 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/lab/ingest_diagnosticos.php
|
||||||
|
* Recibe un lote de diagnósticos CIE-10 desde RIPS Manager y los upserta en cie10_diagnosticos.
|
||||||
|
* Body: { "rows": [{"cod": "A000", "concepto": "..."}, ...] }
|
||||||
|
*/
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
|
|
||||||
|
error_reporting(E_ERROR | E_PARSE);
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
ob_clean();
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Método no permitido']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyHeader = $_SERVER['HTTP_X_LAB_KEY'] ?? $_SERVER['HTTP_X_LAB_SYNC_KEY'] ?? '';
|
||||||
|
if (!$keyHeader || !hash_equals(LAB_SYNC_KEY, $keyHeader)) {
|
||||||
|
http_response_code(401);
|
||||||
|
ob_clean();
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'API key inválida']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function db(): \PDO {
|
||||||
|
return Database::getInstance()->getConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($data) || empty($data['rows']) || !is_array($data['rows'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
ob_clean();
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Body inválido: se espera {rows: [...]}']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"INSERT INTO cie10_diagnosticos (cod_diag, concepto)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE concepto = VALUES(concepto)"
|
||||||
|
);
|
||||||
|
if (!$stmt) {
|
||||||
|
$info = $pdo->errorInfo();
|
||||||
|
throw new \RuntimeException("Prepare falló [{$info[0]}]: {$info[2]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$insertados = 0;
|
||||||
|
foreach ($data['rows'] as $row) {
|
||||||
|
$cod = trim($row['cod'] ?? '');
|
||||||
|
$concepto = trim($row['concepto'] ?? '');
|
||||||
|
if (!$cod || !$concepto) continue;
|
||||||
|
$stmt->execute([$cod, $concepto]);
|
||||||
|
$insertados++;
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_clean();
|
||||||
|
echo json_encode(['ok' => true, 'insertados' => $insertados]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
ob_clean();
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/lab/ingest_paciente.php
|
||||||
|
* Endpoint server-to-server para ingesta de pacientes desde RIPS Manager.
|
||||||
|
* Autenticación: header X-Lab-Key: <LAB_SYNC_KEY> (sin sesión de usuario).
|
||||||
|
*
|
||||||
|
* Body JSON (todos opcionales excepto nombre_completo en creación):
|
||||||
|
* { numero_documento, tipo_documento, nombre_completo, telefono, email,
|
||||||
|
* fecha_nacimiento, genero, direccion, ciudad, eps, origen }
|
||||||
|
*
|
||||||
|
* Respuesta:
|
||||||
|
* { ok, action: "created"|"updated"|"skipped", id, message }
|
||||||
|
*/
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
|
require_once __DIR__ . '/../../classes/lab/Paciente.php';
|
||||||
|
|
||||||
|
error_reporting(E_ERROR | E_PARSE);
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
ini_set('html_errors', '0');
|
||||||
|
|
||||||
|
register_shutdown_function(function () {
|
||||||
|
$err = error_get_last();
|
||||||
|
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||||
|
ob_clean();
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Error interno: ' . $err['message']]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, X-Lab-Key, X-Lab-Sync-Key');
|
||||||
|
header('Access-Control-Max-Age: 86400');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Método no permitido']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Autenticación por API key ────────────────────────────────────────────────
|
||||||
|
$keyHeader = $_SERVER['HTTP_X_LAB_KEY']
|
||||||
|
?? $_SERVER['HTTP_X_LAB_SYNC_KEY']
|
||||||
|
?? '';
|
||||||
|
|
||||||
|
if (!$keyHeader || !hash_equals(LAB_SYNC_KEY, $keyHeader)) {
|
||||||
|
http_response_code(401);
|
||||||
|
ob_clean();
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'API key inválida']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Leer body ────────────────────────────────────────────────────────────────
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
|
||||||
|
if (!is_array($data)) {
|
||||||
|
http_response_code(400);
|
||||||
|
ob_clean();
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Body JSON inválido']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Normalizar campos ────────────────────────────────────────────────────────
|
||||||
|
$campos = [];
|
||||||
|
|
||||||
|
if (!empty($data['nombre_completo'])) {
|
||||||
|
$campos['nombre_completo'] = mb_strtoupper(trim($data['nombre_completo']));
|
||||||
|
}
|
||||||
|
if (!empty($data['numero_documento'])) {
|
||||||
|
$campos['numero_documento'] = trim($data['numero_documento']);
|
||||||
|
}
|
||||||
|
if (!empty($data['tipo_documento'])) {
|
||||||
|
$tiposValidos = ['CC', 'CE', 'TI', 'PA', 'NIT', 'RC', 'MS'];
|
||||||
|
$t = strtoupper(trim($data['tipo_documento']));
|
||||||
|
$campos['tipo_documento'] = in_array($t, $tiposValidos) ? $t : 'CC';
|
||||||
|
}
|
||||||
|
if (!empty($data['telefono'])) {
|
||||||
|
$tel = preg_replace('/[^0-9+]/', '', $data['telefono']);
|
||||||
|
if (strlen($tel) >= 6) {
|
||||||
|
$campos['telefono'] = $tel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($data['email']) && filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$e = strtolower(trim($data['email']));
|
||||||
|
// Ignorar emails de relleno generados por RIPS
|
||||||
|
if (!str_contains($e, '@sinregistro.co') && !str_contains($e, 'sinregistro')) {
|
||||||
|
$campos['email'] = $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($data['fecha_nacimiento'])) {
|
||||||
|
$fn = trim($data['fecha_nacimiento']);
|
||||||
|
// Acepta dd/mm/yyyy o yyyy-mm-dd
|
||||||
|
if (preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $fn, $m)) {
|
||||||
|
$campos['fecha_nacimiento'] = "{$m[3]}-{$m[2]}-{$m[1]}";
|
||||||
|
} elseif (preg_match('/^\d{4}-\d{2}-\d{2}$/', $fn)) {
|
||||||
|
$campos['fecha_nacimiento'] = $fn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($data['genero'])) {
|
||||||
|
$g = strtoupper(trim($data['genero']));
|
||||||
|
if (in_array($g, ['M', 'F', 'O'])) {
|
||||||
|
$campos['genero'] = $g;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($data['direccion'])) {
|
||||||
|
$campos['direccion'] = trim($data['direccion']);
|
||||||
|
}
|
||||||
|
if (!empty($data['ciudad'])) {
|
||||||
|
$campos['ciudad'] = trim($data['ciudad']);
|
||||||
|
}
|
||||||
|
if (!empty($data['eps'])) {
|
||||||
|
$campos['eps'] = trim($data['eps']);
|
||||||
|
}
|
||||||
|
// origen solo se aplica en creación, nunca en update
|
||||||
|
$origenValidos = ['manual', 'lab', 'whatsapp'];
|
||||||
|
$campos['origen'] = in_array($data['origen'] ?? '', $origenValidos, true)
|
||||||
|
? $data['origen'] : 'lab';
|
||||||
|
|
||||||
|
// ── Modo: "insertar" (solo nuevos) | "upsert" (crea o actualiza) ─────────────
|
||||||
|
$modo = trim($data['modo'] ?? 'upsert');
|
||||||
|
if (!in_array($modo, ['insertar', 'upsert'], true)) {
|
||||||
|
$modo = 'upsert';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Exámenes opcionales (vienen del scheduler de RIPS) ───────────────────────
|
||||||
|
$examenesRaw = $data['examenes'] ?? null;
|
||||||
|
$examenesGuardados = 0;
|
||||||
|
|
||||||
|
function db(): \PDO {
|
||||||
|
return Database::getInstance()->getConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function guardarExamenesPendientes(string $doc, array $examenes): int {
|
||||||
|
if (!$examenes || !$doc) return 0;
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$stDel = $pdo->prepare(
|
||||||
|
"DELETE FROM rips_examenes_pendientes
|
||||||
|
WHERE numero_documento = ? AND DATE(created_at) = CURDATE()"
|
||||||
|
);
|
||||||
|
if (!$stDel) {
|
||||||
|
$info = $pdo->errorInfo();
|
||||||
|
throw new \RuntimeException("rips_examenes_pendientes no existe o error: {$info[2]}");
|
||||||
|
}
|
||||||
|
$stDel->execute([$doc]);
|
||||||
|
|
||||||
|
$meta = $examenes[0] ?? [];
|
||||||
|
$stIns = $pdo->prepare(
|
||||||
|
"INSERT INTO rips_examenes_pendientes
|
||||||
|
(numero_documento, datos, recepcion_id, hora_recepcion)
|
||||||
|
VALUES (?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
if (!$stIns) {
|
||||||
|
$info = $pdo->errorInfo();
|
||||||
|
throw new \RuntimeException("INSERT rips_examenes_pendientes falló: {$info[2]}");
|
||||||
|
}
|
||||||
|
$stIns->execute([
|
||||||
|
$doc,
|
||||||
|
json_encode($examenes, JSON_UNESCAPED_UNICODE),
|
||||||
|
$meta['recepcion_id'] ?? null,
|
||||||
|
$meta['hora'] ?? null,
|
||||||
|
]);
|
||||||
|
return count($examenes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UPSERT / INSERT-ONLY ─────────────────────────────────────────────────────
|
||||||
|
try {
|
||||||
|
$pac = new Paciente();
|
||||||
|
|
||||||
|
$existente = null;
|
||||||
|
if (!empty($campos['numero_documento'])) {
|
||||||
|
$existente = $pac->porDocumento($campos['numero_documento']);
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_clean();
|
||||||
|
|
||||||
|
if ($existente) {
|
||||||
|
if (!empty($examenesRaw) && is_array($examenesRaw)) {
|
||||||
|
$examenesGuardados = guardarExamenesPendientes($campos['numero_documento'], $examenesRaw);
|
||||||
|
}
|
||||||
|
if ($modo === 'insertar') {
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => true,
|
||||||
|
'action' => 'skipped',
|
||||||
|
'id' => $existente['id'],
|
||||||
|
'message' => 'Paciente ya existe',
|
||||||
|
'examenes_guardados' => $examenesGuardados,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// Nunca pisar origen en update — conservar el que ya tiene en BD
|
||||||
|
unset($campos['origen']);
|
||||||
|
$pac->actualizar($existente['id'], $campos, null);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => true,
|
||||||
|
'action' => 'updated',
|
||||||
|
'id' => $existente['id'],
|
||||||
|
'message' => 'Paciente actualizado',
|
||||||
|
'examenes_guardados' => $examenesGuardados,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} elseif (!empty($campos['nombre_completo'])) {
|
||||||
|
$id = $pac->crear($campos, null);
|
||||||
|
if (!empty($examenesRaw) && is_array($examenesRaw)) {
|
||||||
|
$examenesGuardados = guardarExamenesPendientes($campos['numero_documento'], $examenesRaw);
|
||||||
|
}
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => true,
|
||||||
|
'action' => 'created',
|
||||||
|
'id' => $id,
|
||||||
|
'message' => 'Paciente creado',
|
||||||
|
'examenes_guardados' => $examenesGuardados,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
http_response_code(422);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'action' => 'skipped',
|
||||||
|
'error' => 'Sin número de documento ni nombre: registro omitido',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
ob_clean();
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/lab/marcar_rips_usado.php
|
||||||
|
* Marca el registro RIPS de una cédula como consumido por un turno.
|
||||||
|
* Body: { cedula, turno_id }
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireMethod('POST');
|
||||||
|
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
$cedula = trim($body['cedula'] ?? '');
|
||||||
|
$turnoId = (int)($body['turno_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$cedula || !$turnoId) jsonError('cedula y turno_id requeridos', 400);
|
||||||
|
|
||||||
|
db()->prepare(
|
||||||
|
"UPDATE rips_examenes_pendientes
|
||||||
|
SET turno_id = ?
|
||||||
|
WHERE numero_documento = ?
|
||||||
|
AND DATE(created_at) = CURDATE()
|
||||||
|
AND turno_id IS NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1"
|
||||||
|
)->execute([$turnoId, $cedula]);
|
||||||
|
|
||||||
|
jsonOk(['marcado' => true]);
|
||||||
@@ -91,6 +91,19 @@ try {
|
|||||||
$eid = enfermeraId();
|
$eid = enfermeraId();
|
||||||
if ($eid) $datos['enfermera_id'] = $eid;
|
if ($eid) $datos['enfermera_id'] = $eid;
|
||||||
}
|
}
|
||||||
|
// Asignar número de orden D-YYYYMMDD-NNN si no viene uno
|
||||||
|
if (empty($datos['numero_orden'])) {
|
||||||
|
$pdo = db();
|
||||||
|
$prefix = 'D-' . date('Ymd') . '-';
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
"SELECT MAX(CAST(SUBSTRING_INDEX(numero_orden, '-', -1) AS UNSIGNED)) AS ultimo
|
||||||
|
FROM lab_domicilios WHERE numero_orden LIKE ?"
|
||||||
|
);
|
||||||
|
$st->execute([$prefix . '%']);
|
||||||
|
$ultimo = (int)($st->fetch(\PDO::FETCH_ASSOC)['ultimo'] ?? 0);
|
||||||
|
$datos['numero_orden'] = $prefix . str_pad($ultimo + 1, 3, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
$id = $dom->crear($datos, $admin);
|
$id = $dom->crear($datos, $admin);
|
||||||
|
|
||||||
// Crear asignación automática si se indicó enfermera_id
|
// Crear asignación automática si se indicó enfermera_id
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST — Admin guarda/borra la firma pre-configurada de cualquier usuario.
|
||||||
|
* Body JSON: { user_id: 5, firma_svg: "data:image/png;base64,..." }
|
||||||
|
* { user_id: 5, _borrar: true }
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireAdmin();
|
||||||
|
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
$userId = (int)($body['user_id'] ?? 0);
|
||||||
|
if ($userId <= 0) jsonError('user_id requerido.');
|
||||||
|
|
||||||
|
$db = Database::getInstance()->getConnection();
|
||||||
|
|
||||||
|
if (!empty($body['_borrar'])) {
|
||||||
|
$db->prepare("UPDATE admin_users SET firma_svg = NULL WHERE id = ?")->execute([$userId]);
|
||||||
|
jsonOk(['mensaje' => 'Firma eliminada.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$svg = $body['firma_svg'] ?? '';
|
||||||
|
if (strlen($svg) < 100) jsonError('Firma requerida.');
|
||||||
|
if (!preg_match('/^data:image\/(svg\+xml|png|jpeg|webp);base64,/i', $svg)) {
|
||||||
|
jsonError('Formato de firma no válido.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->prepare("UPDATE admin_users SET firma_svg = ? WHERE id = ?")->execute([$svg, $userId]);
|
||||||
|
jsonOk(['mensaje' => 'Firma guardada correctamente.']);
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/lab/save_tomas_config.php
|
||||||
|
* Gestiona los tipos de examen del formulario de Tomas Prolongadas (lab_formularios.id=15).
|
||||||
|
*
|
||||||
|
* Body JSON:
|
||||||
|
* action string 'add_exam' | 'delete_exam'
|
||||||
|
* exam_name string Nombre del nuevo tipo de examen
|
||||||
|
* tomas array [{label, tipo:'minutos'|'hora_fija', valor}] (solo add_exam)
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireAdmin();
|
||||||
|
requireMethod('POST');
|
||||||
|
|
||||||
|
$body = inputJson();
|
||||||
|
$action = trim($body['action'] ?? '');
|
||||||
|
|
||||||
|
if (!in_array($action, ['add_exam', 'delete_exam'], true)) jsonError('action inválida.');
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$row = $db->fetch("SELECT id, esquema FROM lab_formularios WHERE id = 15 LIMIT 1");
|
||||||
|
if (!$row) jsonError('Formulario de tomas no encontrado.', 404);
|
||||||
|
|
||||||
|
$esquema = json_decode($row['esquema'], true);
|
||||||
|
if (!is_array($esquema)) jsonError('Esquema del formulario no válido.', 500);
|
||||||
|
|
||||||
|
// ── Localizar el campo selector de tipo de examen ────────────
|
||||||
|
$idxSelector = null;
|
||||||
|
foreach ($esquema as $i => $c) {
|
||||||
|
if (($c['id'] ?? '') === '_c8j2g16') { $idxSelector = $i; break; }
|
||||||
|
}
|
||||||
|
if ($idxSelector === null) jsonError('Campo selector de examen (_c8j2g16) no encontrado.', 500);
|
||||||
|
|
||||||
|
// ── ADD EXAM ─────────────────────────────────────────────────
|
||||||
|
if ($action === 'add_exam') {
|
||||||
|
$examName = trim($body['exam_name'] ?? '');
|
||||||
|
$tomas = $body['tomas'] ?? [];
|
||||||
|
|
||||||
|
if (!$examName) jsonError('exam_name requerido.');
|
||||||
|
if (strlen($examName) > 80) jsonError('exam_name demasiado largo (máx 80 chars).');
|
||||||
|
if (!is_array($tomas) || empty($tomas)) jsonError('tomas requeridas.');
|
||||||
|
if (count($tomas) > 20) jsonError('Máximo 20 tomas por examen.');
|
||||||
|
|
||||||
|
// Verificar que el examen no exista ya
|
||||||
|
$currentOptions = $esquema[$idxSelector]['options'] ?? [];
|
||||||
|
if (in_array($examName, $currentOptions, true)) {
|
||||||
|
jsonError("El tipo de examen '$examName' ya existe.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar tomas
|
||||||
|
foreach ($tomas as $i => $t) {
|
||||||
|
$tipo = $t['tipo'] ?? '';
|
||||||
|
$valor = $t['valor'] ?? '';
|
||||||
|
$label = trim($t['label'] ?? '');
|
||||||
|
if (!in_array($tipo, ['minutos', 'hora_fija'], true)) jsonError("Toma $i: tipo inválido.");
|
||||||
|
if (!$label) jsonError("Toma $i: label requerido.");
|
||||||
|
if ($tipo === 'minutos' && (!is_numeric($valor) || (int)$valor < 0))
|
||||||
|
jsonError("Toma $i: valor de minutos inválido.");
|
||||||
|
if ($tipo === 'hora_fija' && !preg_match('/^\d{1,2}:\d{2}$/', $valor))
|
||||||
|
jsonError("Toma $i: hora_fija debe ser HH:MM.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefijo corto para IDs (basado en nombre del examen, sanitizado)
|
||||||
|
$prefix = '_' . substr(preg_replace('/[^a-z0-9]/i', '', strtolower($examName)), 0, 8) . '_';
|
||||||
|
$uid = substr(md5($examName . microtime()), 0, 4);
|
||||||
|
|
||||||
|
// 1. Agregar opción al campo selector
|
||||||
|
$esquema[$idxSelector]['options'][] = $examName;
|
||||||
|
|
||||||
|
// 2. Agregar campos al final del esquema
|
||||||
|
$condCampoId = '_c8j2g16';
|
||||||
|
foreach ($tomas as $idx => $t) {
|
||||||
|
$label = trim($t['label']);
|
||||||
|
$tipo = $t['tipo'];
|
||||||
|
$valor = $t['valor'];
|
||||||
|
|
||||||
|
// Construir label del separador
|
||||||
|
if ($tipo === 'minutos') {
|
||||||
|
$sepLabel = "$examName · Minuto $valor";
|
||||||
|
} else {
|
||||||
|
// hora_fija: convertir HH:MM a "H:MM a.m./p.m."
|
||||||
|
[$hh, $mm] = explode(':', $valor);
|
||||||
|
$h = (int)$hh; $ampm = $h >= 12 ? 'p.m.' : 'a.m.';
|
||||||
|
$h12 = $h > 12 ? $h - 12 : ($h === 0 ? 12 : $h);
|
||||||
|
$sepLabel = "$examName · {$h12}:{$mm} {$ampm}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$sepId = $prefix . 's' . $idx . $uid;
|
||||||
|
$horaId = $prefix . 'h' . $idx . $uid;
|
||||||
|
$obsId = $prefix . 'o' . $idx . $uid;
|
||||||
|
$firmaId = $prefix . 'f' . $idx . $uid;
|
||||||
|
|
||||||
|
$esquema[] = [
|
||||||
|
'id' => $sepId,
|
||||||
|
'tipo' => 'separador',
|
||||||
|
'label' => $sepLabel,
|
||||||
|
'condicion'=> ['campo_id' => $condCampoId, 'valores' => [$examName]],
|
||||||
|
];
|
||||||
|
$esquema[] = ['id' => $horaId, 'tipo' => 'hora', 'label' => 'Hora de toma'];
|
||||||
|
$esquema[] = ['id' => $obsId, 'tipo' => 'texto', 'label' => 'Observaciones', 'placeholder' => '', 'required' => false];
|
||||||
|
$esquema[] = ['id' => $firmaId, 'tipo' => 'firma_profesional', 'label' => 'Firma del profesional'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->getConnection()->prepare(
|
||||||
|
"UPDATE lab_formularios SET esquema = ? WHERE id = 15"
|
||||||
|
)->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
|
||||||
|
|
||||||
|
jsonOk([
|
||||||
|
'exam_name' => $examName,
|
||||||
|
'tomas_count' => count($tomas),
|
||||||
|
'options_count'=> count($esquema[$idxSelector]['options']),
|
||||||
|
], "Examen '$examName' agregado con " . count($tomas) . " tomas.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE EXAM ──────────────────────────────────────────────
|
||||||
|
if ($action === 'delete_exam') {
|
||||||
|
$examName = trim($body['exam_name'] ?? '');
|
||||||
|
if (!$examName) jsonError('exam_name requerido.');
|
||||||
|
|
||||||
|
$options = $esquema[$idxSelector]['options'] ?? [];
|
||||||
|
if (!in_array($examName, $options, true)) jsonError("El examen '$examName' no existe.");
|
||||||
|
|
||||||
|
// Eliminar opción del selector
|
||||||
|
$esquema[$idxSelector]['options'] = array_values(array_filter($options, fn($o) => $o !== $examName));
|
||||||
|
|
||||||
|
// Eliminar separadores condicionados únicamente a este examen
|
||||||
|
$esquema = array_values(array_filter($esquema, function($c) use ($examName) {
|
||||||
|
$cond = $c['condicion'] ?? null;
|
||||||
|
if (!$cond) return true;
|
||||||
|
$vals = $cond['valores'] ?? [];
|
||||||
|
return !(count($vals) === 1 && $vals[0] === $examName);
|
||||||
|
}));
|
||||||
|
|
||||||
|
$db->getConnection()->prepare(
|
||||||
|
"UPDATE lab_formularios SET esquema = ? WHERE id = 15"
|
||||||
|
)->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
|
||||||
|
|
||||||
|
jsonOk(['exam_name' => $examName], "Examen '$examName' eliminado.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* /api/lab/tarifas_id.php
|
||||||
|
*
|
||||||
|
* GET → lista todas las tarifas (para selects en formularios)
|
||||||
|
* POST {action:save, id?, nombre, porcentaje, tarifa_origen?} → upsert
|
||||||
|
* POST {action:delete, id} → elimina si sin precios asociados
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$rows = $pdo->query(
|
||||||
|
"SELECT t.id, t.nombre, t.porcentaje, t.tarifa_origen,
|
||||||
|
tb.nombre AS tarifa_origen_nombre
|
||||||
|
FROM lab_tarifas_id t
|
||||||
|
LEFT JOIN lab_tarifas_id tb ON tb.id = t.tarifa_origen
|
||||||
|
ORDER BY t.id ASC"
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
jsonOk(['tarifas' => $rows]);
|
||||||
|
}
|
||||||
|
|
||||||
|
requireMethod('POST');
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
$action = $data['action'] ?? '';
|
||||||
|
|
||||||
|
if ($action === 'delete') {
|
||||||
|
$id = (int)($data['id'] ?? 0);
|
||||||
|
if ($id <= 0) jsonError('id requerido');
|
||||||
|
$chk = $pdo->prepare("SELECT COUNT(*) FROM lab_tarifas WHERE tarifa_id = ? LIMIT 1");
|
||||||
|
$chk->execute([$id]);
|
||||||
|
if ((int)$chk->fetchColumn() > 0) {
|
||||||
|
jsonError('No se puede eliminar: la tarifa tiene precios de exámenes asociados.', 409);
|
||||||
|
}
|
||||||
|
$pdo->prepare("DELETE FROM lab_tarifas_id WHERE id = ?")->execute([$id]);
|
||||||
|
jsonOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'save') {
|
||||||
|
$id = isset($data['id']) && $data['id'] !== '' ? (int)$data['id'] : null;
|
||||||
|
$nombre = trim($data['nombre'] ?? '');
|
||||||
|
$porcentaje = isset($data['porcentaje']) ? (float)$data['porcentaje'] : 0;
|
||||||
|
$origen = isset($data['tarifa_origen']) && $data['tarifa_origen'] !== '' ? (int)$data['tarifa_origen'] : null;
|
||||||
|
|
||||||
|
if ($nombre === '') jsonError('nombre requerido');
|
||||||
|
|
||||||
|
if ($id !== null) {
|
||||||
|
$chk = $pdo->prepare("SELECT id FROM lab_tarifas_id WHERE id = ?");
|
||||||
|
$chk->execute([$id]);
|
||||||
|
if ($chk->fetch()) {
|
||||||
|
$pdo->prepare(
|
||||||
|
"UPDATE lab_tarifas_id SET nombre=?, porcentaje=?, tarifa_origen=? WHERE id=?"
|
||||||
|
)->execute([$nombre, $porcentaje, $origen, $id]);
|
||||||
|
} else {
|
||||||
|
$pdo->prepare(
|
||||||
|
"INSERT INTO lab_tarifas_id (id, nombre, porcentaje, tarifa_origen) VALUES (?,?,?,?)"
|
||||||
|
)->execute([$id, $nombre, $porcentaje, $origen]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$maxId = (int)$pdo->query("SELECT COALESCE(MAX(id),0)+1 FROM lab_tarifas_id")->fetchColumn();
|
||||||
|
$pdo->prepare(
|
||||||
|
"INSERT INTO lab_tarifas_id (id, nombre, porcentaje, tarifa_origen) VALUES (?,?,?,?)"
|
||||||
|
)->execute([$maxId, $nombre, $porcentaje, $origen]);
|
||||||
|
$id = $maxId;
|
||||||
|
}
|
||||||
|
jsonOk(['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonError('Acción no reconocida', 400);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /api/qz_sign.php
|
||||||
|
* Firma el request de QZ Tray con la llave privada RSA del servidor.
|
||||||
|
* QZ Tray verifica la firma usando el digital-certificate.txt instalado en el cliente.
|
||||||
|
*/
|
||||||
|
header('Content-Type: text/plain');
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
|
||||||
|
$request = file_get_contents('php://input');
|
||||||
|
if (!$request) { http_response_code(400); exit('missing request'); }
|
||||||
|
|
||||||
|
$keyPath = __DIR__ . '/../config/qz/private-key.pem';
|
||||||
|
$key = openssl_pkey_get_private('file://' . $keyPath);
|
||||||
|
if (!$key) { http_response_code(500); exit('key error'); }
|
||||||
|
|
||||||
|
openssl_sign($request, $signature, $key, 'SHA512');
|
||||||
|
echo base64_encode($signature);
|
||||||
+117
-122
@@ -935,32 +935,6 @@ body {
|
|||||||
#sidebar-toggle { display: none !important; }
|
#sidebar-toggle { display: none !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark Mode Support */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--light-bg: #1f2937;
|
|
||||||
--text-primary: #f9fafb;
|
|
||||||
--text-secondary: #d1d5db;
|
|
||||||
--border-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--dark-bg);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
background: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
background: #4b5563;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utility Classes */
|
/* Utility Classes */
|
||||||
.text-center { text-align: center; }
|
.text-center { text-align: center; }
|
||||||
@@ -1483,104 +1457,125 @@ body {
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Ajustes para modo nocturno (dark mode) ===== */
|
/* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
MODO OSCURO
|
||||||
|
Activación dual:
|
||||||
|
1) @media (prefers-color-scheme: dark) — OS sin JS
|
||||||
|
2) [data-bs-theme="dark"] en <html> — Bootstrap 5.3 via script inline
|
||||||
|
|
||||||
|
Bootstrap 5.3 maneja automáticamente (cuando data-bs-theme está activo):
|
||||||
|
card, modal, table, form-control, form-select, dropdown, badge, alert,
|
||||||
|
list-group, nav, pagination, offcanvas, toast, popover, etc.
|
||||||
|
Aquí solo cubrimos variables custom y elementos no-Bootstrap.
|
||||||
|
───────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* ── Variables custom para elementos no-Bootstrap ── */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--light-bg: #071322;
|
--dm-body: #0a1628;
|
||||||
--text-primary: #e6eef1;
|
--dm-surf: #071322;
|
||||||
|
--dm-surf2: #0b1c32;
|
||||||
|
--dm-bdr: #163240;
|
||||||
|
--dm-txt: #e6eef1;
|
||||||
|
--dm-muted: #9ca3af;
|
||||||
|
--dm-link: #a6f3c9;
|
||||||
|
/* Alias compatibilidad con código que usa estas vars */
|
||||||
|
--light-bg: #071322;
|
||||||
|
--text-primary: #e6eef1;
|
||||||
--text-secondary: #9ca3af;
|
--text-secondary: #9ca3af;
|
||||||
--border-color: #163240;
|
--border-color: #163240;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--light-bg);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Encabezados y títulos */
|
|
||||||
.content-header h1,
|
|
||||||
.card-header h5,
|
|
||||||
.sidebar-header h4,
|
|
||||||
.status-text,
|
|
||||||
.status-indicator,
|
|
||||||
.conversation-empty {
|
|
||||||
color: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Contenedores */
|
|
||||||
.content-header,
|
|
||||||
.card,
|
|
||||||
.chat-container {
|
|
||||||
background: #071322;
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: var(--border-color);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
background: transparent;
|
|
||||||
border-bottom-color: rgba(255,255,255,0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Formularios y botones */
|
|
||||||
.form-control {
|
|
||||||
background: #072033;
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: #163240;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar */
|
|
||||||
.sidebar {
|
|
||||||
background: linear-gradient(135deg,#08131a,#0b2221);
|
|
||||||
color: #e6eef1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.conversation-actions .btn {
|
|
||||||
background: #0b1220;
|
|
||||||
border-color: #1f2937;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chat específico */
|
|
||||||
.chat-header {
|
|
||||||
background: linear-gradient(135deg, #1f5a3f 0%, #0f513e 100%);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-bubble {
|
|
||||||
color: #e6eef1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-incoming {
|
|
||||||
background: #0b1220;
|
|
||||||
color: #e6eef1;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-outgoing {
|
|
||||||
background: #0f513e;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-document,
|
|
||||||
.media-preview {
|
|
||||||
background: rgba(255,255,255,0.03);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.conversation-search {
|
|
||||||
background: rgba(255,255,255,0.04);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
a, .nav-link {
|
|
||||||
color: #a6f3c9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Placeholders visibles */
|
|
||||||
::placeholder { color: #9ca3af !important; opacity: 1; }
|
|
||||||
}
|
}
|
||||||
|
[data-bs-theme="dark"] {
|
||||||
|
--dm-body: #0a1628;
|
||||||
|
--dm-surf: #071322;
|
||||||
|
--dm-surf2: #0b1c32;
|
||||||
|
--dm-bdr: #163240;
|
||||||
|
--dm-txt: #e6eef1;
|
||||||
|
--dm-muted: #9ca3af;
|
||||||
|
--dm-link: #a6f3c9;
|
||||||
|
--light-bg: #071322;
|
||||||
|
--text-primary: #e6eef1;
|
||||||
|
--text-secondary: #9ca3af;
|
||||||
|
--border-color: #163240;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reglas para modo OS sin JS (fallback) ── */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body { background-color: var(--dm-body); color: var(--dm-txt); }
|
||||||
|
|
||||||
|
/* Fondo principal */
|
||||||
|
.main-content { background-color: var(--dm-body) !important; }
|
||||||
|
|
||||||
|
/* Bootstrap no cambia bg-white — forzamos adaptación */
|
||||||
|
.bg-white { background-color: var(--dm-surf) !important; color: var(--dm-txt) !important; }
|
||||||
|
|
||||||
|
/* Encabezados y texto */
|
||||||
|
.content-header { background: var(--dm-surf); border-color: var(--dm-bdr) !important; color: var(--dm-txt); }
|
||||||
|
.content-header h1, .card-header h5, .sidebar-header h4,
|
||||||
|
.status-text, .status-indicator, .conversation-empty { color: var(--dm-txt) !important; }
|
||||||
|
|
||||||
|
/* Componentes Bootstrap (fallback sin data-bs-theme) */
|
||||||
|
.card { background: var(--dm-surf); color: var(--dm-txt); border-color: var(--dm-bdr); box-shadow: none; }
|
||||||
|
.card-header { background: transparent; border-color: rgba(255,255,255,.06); }
|
||||||
|
.form-control, .form-select {
|
||||||
|
background: var(--dm-surf2);
|
||||||
|
color: var(--dm-txt);
|
||||||
|
border-color: var(--dm-bdr);
|
||||||
|
}
|
||||||
|
.form-control:focus, .form-select:focus {
|
||||||
|
background: var(--dm-surf2);
|
||||||
|
color: var(--dm-txt);
|
||||||
|
border-color: #2d6a8a;
|
||||||
|
box-shadow: 0 0 0 .2rem rgba(45,106,138,.25);
|
||||||
|
}
|
||||||
|
.table { color: var(--dm-txt); border-color: var(--dm-bdr); }
|
||||||
|
.table th { background: var(--dm-surf2); color: var(--dm-txt); }
|
||||||
|
.modal-content { background: var(--dm-surf); color: var(--dm-txt); }
|
||||||
|
.modal-header, .modal-footer { border-color: var(--dm-bdr); }
|
||||||
|
.dropdown-menu { background: var(--dm-surf); border-color: var(--dm-bdr); }
|
||||||
|
.dropdown-item { color: var(--dm-txt); }
|
||||||
|
.dropdown-item:hover { background: var(--dm-surf2); color: var(--dm-txt); }
|
||||||
|
.list-group-item { background: var(--dm-surf); color: var(--dm-txt); border-color: var(--dm-bdr); }
|
||||||
|
.input-group-text { background: var(--dm-surf2); color: var(--dm-txt); border-color: var(--dm-bdr); }
|
||||||
|
.nav-tabs .nav-link { color: var(--dm-muted); }
|
||||||
|
.nav-tabs .nav-link.active { background: var(--dm-surf); color: var(--dm-txt); border-color: var(--dm-bdr); }
|
||||||
|
|
||||||
|
/* Sidebar custom */
|
||||||
|
.sidebar { background: linear-gradient(135deg, #08131a, #0b2221); color: #e6eef1; }
|
||||||
|
|
||||||
|
/* Chat */
|
||||||
|
.chat-container { background: var(--dm-surf); color: var(--dm-txt); border-color: var(--dm-bdr); }
|
||||||
|
.chat-header { background: linear-gradient(135deg, #1f5a3f, #0f513e); color: #fff; }
|
||||||
|
.message-bubble { color: var(--dm-txt); }
|
||||||
|
.message-incoming { background: var(--dm-surf2); color: var(--dm-txt); box-shadow: none; }
|
||||||
|
.message-outgoing { background: #0f513e; color: #fff; }
|
||||||
|
.message-document, .media-preview { background: rgba(255,255,255,.03); color: var(--dm-txt); }
|
||||||
|
.conversation-search { background: rgba(255,255,255,.04); color: var(--dm-txt); }
|
||||||
|
.conversation-actions .btn { background: #0b1220; border-color: #1f2937; color: var(--dm-muted); }
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a:not(.btn):not(.badge), .nav-link { color: var(--dm-link); }
|
||||||
|
|
||||||
|
/* Placeholders */
|
||||||
|
::placeholder { color: var(--dm-muted) !important; opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reglas para Bootstrap dark mode activo (data-bs-theme="dark") ── */
|
||||||
|
/* Bootstrap ya maneja card, modal, table, form, dropdown, badge, alert, etc. */
|
||||||
|
/* Solo corregimos lo que Bootstrap no toca: elementos custom y clases forzadas */
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .main-content { background-color: var(--dm-body, #0a1628) !important; }
|
||||||
|
[data-bs-theme="dark"] .bg-white { background-color: var(--bs-body-bg) !important; color: var(--bs-body-color) !important; }
|
||||||
|
[data-bs-theme="dark"] .sidebar { background: linear-gradient(135deg, #08131a, #0b2221) !important; color: #e6eef1; }
|
||||||
|
[data-bs-theme="dark"] .chat-container { background: var(--dm-surf, #071322); border-color: var(--dm-bdr, #163240); }
|
||||||
|
[data-bs-theme="dark"] .chat-header { background: linear-gradient(135deg, #1f5a3f, #0f513e); color: #fff; }
|
||||||
|
[data-bs-theme="dark"] .message-bubble { color: var(--dm-txt, #e6eef1); }
|
||||||
|
[data-bs-theme="dark"] .message-incoming { background: var(--dm-surf2, #0b1c32); color: var(--dm-txt, #e6eef1); box-shadow: none; }
|
||||||
|
[data-bs-theme="dark"] .message-outgoing { background: #0f513e; color: #fff; }
|
||||||
|
[data-bs-theme="dark"] .message-document,
|
||||||
|
[data-bs-theme="dark"] .media-preview { background: rgba(255,255,255,.03); color: var(--dm-txt, #e6eef1); }
|
||||||
|
[data-bs-theme="dark"] .conversation-search { background: rgba(255,255,255,.04); color: var(--dm-txt, #e6eef1); }
|
||||||
|
[data-bs-theme="dark"] .conversation-actions .btn { background: #0b1220; border-color: #1f2937; color: var(--dm-muted, #9ca3af); }
|
||||||
|
[data-bs-theme="dark"] a:not(.btn):not(.badge) { color: var(--dm-link, #a6f3c9); }
|
||||||
|
[data-bs-theme="dark"] ::placeholder { color: var(--dm-muted, #9ca3af) !important; opacity: 1; }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* qz-print.js — Impresión silenciosa vía QZ Tray
|
||||||
|
* Requiere qz-tray.js cargado antes de este archivo.
|
||||||
|
* Uso: qzPrint({ url, printer })
|
||||||
|
*/
|
||||||
|
window.qzPrint = async function({ printer } = {}) {
|
||||||
|
try {
|
||||||
|
// 1. Conectar
|
||||||
|
if (!qz.websocket.isActive()) {
|
||||||
|
await qz.websocket.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Firma con el servidor
|
||||||
|
qz.security.setSignatureAlgorithm('SHA512');
|
||||||
|
qz.security.setSignaturePromise(function(toSign) {
|
||||||
|
return function(resolve, reject) {
|
||||||
|
fetch('/api/qz_sign.php', { method: 'POST', body: toSign })
|
||||||
|
.then(r => r.text()).then(resolve).catch(reject);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Certificado público
|
||||||
|
qz.security.setCertificatePromise(function(resolve) {
|
||||||
|
fetch('/config/qz/digital-certificate.txt')
|
||||||
|
.then(r => r.text()).then(resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Seleccionar impresora
|
||||||
|
var p = printer || await qz.printers.getDefault();
|
||||||
|
console.log('[QZ] Impresora:', p);
|
||||||
|
|
||||||
|
// 5. Imprimir la página actual como HTML
|
||||||
|
var cfg = qz.configs.create(p);
|
||||||
|
var data = [{
|
||||||
|
type : 'pixel',
|
||||||
|
format: 'html',
|
||||||
|
flavor: 'plain',
|
||||||
|
data : document.documentElement.outerHTML,
|
||||||
|
}];
|
||||||
|
|
||||||
|
await qz.print(cfg, data);
|
||||||
|
console.log('[QZ] Trabajo enviado correctamente');
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('[QZ] Error:', e.message || e);
|
||||||
|
// Fallback al diálogo del navegador si QZ no está disponible
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -364,7 +364,7 @@ class Domicilio {
|
|||||||
|
|
||||||
private function filtrarCampos(array $datos): array {
|
private function filtrarCampos(array $datos): array {
|
||||||
$permitidos = [
|
$permitidos = [
|
||||||
'orden_id', 'paciente_id', 'direccion', 'ciudad', 'barrio',
|
'orden_id', 'paciente_id', 'numero_orden', 'direccion', 'ciudad', 'barrio',
|
||||||
'indicaciones_dir', 'fecha_programada', 'hora_programada',
|
'indicaciones_dir', 'fecha_programada', 'hora_programada',
|
||||||
'tipo_servicio', 'tipo_cliente', 'examenes_solicitados',
|
'tipo_servicio', 'tipo_cliente', 'examenes_solicitados',
|
||||||
'estado', 'motivo_cancelacion',
|
'estado', 'motivo_cancelacion',
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ class Formulario {
|
|||||||
$w = $soloActivos ? 'WHERE f.is_active = 1' : '';
|
$w = $soloActivos ? 'WHERE f.is_active = 1' : '';
|
||||||
return $this->db->fetchAll("
|
return $this->db->fetchAll("
|
||||||
SELECT f.*, u.full_name AS creado_por_nombre,
|
SELECT f.*, u.full_name AS creado_por_nombre,
|
||||||
(SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id) AS total_envios,
|
(SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id) +
|
||||||
(SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id AND e.estado IN ('completado','firmado')) AS total_completados
|
(SELECT COUNT(*) FROM turnero_consentimientos tc WHERE tc.formulario_id = f.id) AS total_envios,
|
||||||
|
(SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id AND e.estado IN ('completado','firmado')) +
|
||||||
|
(SELECT COUNT(*) FROM turnero_consentimientos tc WHERE tc.formulario_id = f.id AND tc.estado = 'firmado') AS total_completados
|
||||||
FROM lab_formularios f
|
FROM lab_formularios f
|
||||||
LEFT JOIN admin_users u ON u.id = f.creado_por
|
LEFT JOIN admin_users u ON u.id = f.creado_por
|
||||||
$w
|
$w
|
||||||
@@ -214,13 +216,13 @@ class Formulario {
|
|||||||
$whereB[] = '1=0'; // estados como 'completado'/'expirado' no aplican al turnero
|
$whereB[] = '1=0'; // estados como 'completado'/'expirado' no aplican al turnero
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!empty($filtros['fecha_desde'])) { $whereB[] = 'DATE(tc.enviado_at) >= ?'; $paramsB[] = $filtros['fecha_desde']; }
|
if (!empty($filtros['fecha_desde'])) { $whereB[] = 'DATE(COALESCE(tc.firmado_at, tc.enviado_at, t.creado_at)) >= ?'; $paramsB[] = $filtros['fecha_desde']; }
|
||||||
if (!empty($filtros['fecha_hasta'])) { $whereB[] = 'DATE(tc.enviado_at) <= ?'; $paramsB[] = $filtros['fecha_hasta']; }
|
if (!empty($filtros['fecha_hasta'])) { $whereB[] = 'DATE(COALESCE(tc.firmado_at, tc.enviado_at, t.creado_at)) <= ?'; $paramsB[] = $filtros['fecha_hasta']; }
|
||||||
if (!empty($filtros['paciente'])) { $whereB[] = 'p.nombre_completo LIKE ?'; $paramsB[] = '%' . $filtros['paciente'] . '%'; }
|
if (!empty($filtros['paciente'])) { $whereB[] = 'p.nombre_completo LIKE ?'; $paramsB[] = '%' . $filtros['paciente'] . '%'; }
|
||||||
if (!empty($filtros['enviado_por'])) { $whereB[] = '1=0'; } // turnero no tiene enviado_por
|
if (!empty($filtros['enviado_por'])) { $whereB[] = '1=0'; } // turnero no tiene enviado_por
|
||||||
$wB = implode(' AND ', $whereB);
|
$wB = implode(' AND ', $whereB);
|
||||||
|
|
||||||
$sqlB = "SELECT tc.id, tc.formulario_id, tc.estado, COALESCE(tc.enviado_at, tc.firmado_at) AS fecha,
|
$sqlB = "SELECT tc.id, tc.formulario_id, tc.estado, COALESCE(tc.firmado_at, tc.enviado_at, t.creado_at) AS fecha,
|
||||||
f.nombre AS form_nombre, f.categoria,
|
f.nombre AS form_nombre, f.categoria,
|
||||||
p.nombre_completo AS paciente_nombre,
|
p.nombre_completo AS paciente_nombre,
|
||||||
'Turnero' AS enviado_por_nombre,
|
'Turnero' AS enviado_por_nombre,
|
||||||
|
|||||||
+30
-12
@@ -27,20 +27,25 @@ class Paciente {
|
|||||||
public function listar(
|
public function listar(
|
||||||
string $busqueda = '',
|
string $busqueda = '',
|
||||||
int $pagina = 1,
|
int $pagina = 1,
|
||||||
int $porPagina = 30
|
int $porPagina = 30,
|
||||||
|
string $origen = ''
|
||||||
): array {
|
): array {
|
||||||
$offset = ($pagina - 1) * $porPagina;
|
$offset = ($pagina - 1) * $porPagina;
|
||||||
$like = "%$busqueda%";
|
$like = "%$busqueda%";
|
||||||
$params = $busqueda
|
|
||||||
? [$like, $like, $like, $like]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
$where = $busqueda
|
$conditions = [];
|
||||||
? "WHERE p.nombre_completo LIKE ?
|
$params = [];
|
||||||
OR p.numero_documento LIKE ?
|
|
||||||
OR p.telefono LIKE ?
|
if ($busqueda) {
|
||||||
OR p.email LIKE ?"
|
$conditions[] = "(p.nombre_completo LIKE ? OR p.numero_documento LIKE ? OR p.telefono LIKE ? OR p.email LIKE ?)";
|
||||||
: '';
|
$params = [$like, $like, $like, $like];
|
||||||
|
}
|
||||||
|
if ($origen) {
|
||||||
|
$conditions[] = "p.origen = ?";
|
||||||
|
$params[] = $origen;
|
||||||
|
}
|
||||||
|
|
||||||
|
$where = $conditions ? 'WHERE ' . implode(' AND ', $conditions) : '';
|
||||||
|
|
||||||
$total = $this->db->fetch(
|
$total = $this->db->fetch(
|
||||||
"SELECT COUNT(*) AS n FROM lab_pacientes p $where",
|
"SELECT COUNT(*) AS n FROM lab_pacientes p $where",
|
||||||
@@ -56,7 +61,7 @@ class Paciente {
|
|||||||
FROM lab_pacientes p
|
FROM lab_pacientes p
|
||||||
LEFT JOIN users u ON u.id = p.user_id
|
LEFT JOIN users u ON u.id = p.user_id
|
||||||
$where
|
$where
|
||||||
ORDER BY p.nombre_completo ASC
|
ORDER BY p.created_at DESC, p.nombre_completo ASC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
", array_merge($params, [$porPagina, $offset]));
|
", array_merge($params, [$porPagina, $offset]));
|
||||||
|
|
||||||
@@ -69,6 +74,19 @@ class Paciente {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function stats(): array {
|
||||||
|
return $this->db->fetch("
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(origen = 'lab') AS importados,
|
||||||
|
SUM(origen = 'manual') AS manuales,
|
||||||
|
SUM(origen = 'whatsapp') AS whatsapp,
|
||||||
|
SUM(user_id IS NOT NULL) AS con_wa
|
||||||
|
FROM lab_pacientes
|
||||||
|
WHERE is_active = 1
|
||||||
|
") ?: ['total' => 0, 'importados' => 0, 'manuales' => 0, 'whatsapp' => 0, 'con_wa' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Un paciente por ID (con datos del usuario WhatsApp).
|
* Un paciente por ID (con datos del usuario WhatsApp).
|
||||||
*/
|
*/
|
||||||
@@ -238,7 +256,7 @@ class Paciente {
|
|||||||
'user_id', 'numero_documento', 'tipo_documento',
|
'user_id', 'numero_documento', 'tipo_documento',
|
||||||
'nombre_completo', 'telefono', 'email',
|
'nombre_completo', 'telefono', 'email',
|
||||||
'fecha_nacimiento', 'genero', 'direccion',
|
'fecha_nacimiento', 'genero', 'direccion',
|
||||||
'ciudad', 'barrio', 'eps', 'notas_admin', 'is_active',
|
'ciudad', 'barrio', 'eps', 'notas_admin', 'is_active', 'origen',
|
||||||
];
|
];
|
||||||
$campos = array_intersect_key($datos, array_flip($permitidos));
|
$campos = array_intersect_key($datos, array_flip($permitidos));
|
||||||
if (!empty($campos['telefono'])) {
|
if (!empty($campos['telefono'])) {
|
||||||
|
|||||||
@@ -402,6 +402,14 @@ if (!defined('BASE_URL')) {
|
|||||||
if (!defined('MIGRATION_TOKEN')) {
|
if (!defined('MIGRATION_TOKEN')) {
|
||||||
define('MIGRATION_TOKEN', 'lab2026migrate');
|
define('MIGRATION_TOKEN', 'lab2026migrate');
|
||||||
}
|
}
|
||||||
|
// API key server-to-server para ingesta de pacientes desde RIPS Manager
|
||||||
|
if (!defined('LAB_SYNC_KEY')) {
|
||||||
|
define('LAB_SYNC_KEY', getenv('LAB_SYNC_KEY') ?: 'rips-lab-sync-2026');
|
||||||
|
}
|
||||||
|
// URL base del RIPS Manager (para consultas server-to-server)
|
||||||
|
if (!defined('RIPS_MANAGER_URL')) {
|
||||||
|
define('RIPS_MANAGER_URL', getenv('RIPS_MANAGER_URL') ?: '');
|
||||||
|
}
|
||||||
define('TIMEZONE', 'America/Bogota');
|
define('TIMEZONE', 'America/Bogota');
|
||||||
|
|
||||||
// Información del desarrollador
|
// Información del desarrollador
|
||||||
@@ -544,6 +552,7 @@ function authenticateUser($username, $password) {
|
|||||||
'role_id' => $admin['role_id'] ?? null,
|
'role_id' => $admin['role_id'] ?? null,
|
||||||
'home_page' => $homePage,
|
'home_page' => $homePage,
|
||||||
'enfermera_id' => $admin['enfermera_id'] ?? null,
|
'enfermera_id' => $admin['enfermera_id'] ?? null,
|
||||||
|
'turnero_lugar_id' => $admin['turnero_lugar_id'] ?? null,
|
||||||
'modules' => $modules,
|
'modules' => $modules,
|
||||||
'module_permissions' => $modulePermissions,
|
'module_permissions' => $modulePermissions,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Bloquear acceso web a la llave privada
|
||||||
|
<Files "private-key.pem">
|
||||||
|
Order deny,allow
|
||||||
|
Deny from all
|
||||||
|
</Files>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIID+TCCAuGgAwIBAgIULkHXGPyy0w0ieUoN/YOZOO5MPvkwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwgYsxCzAJBgNVBAYTAkNPMRswGQYDVQQIDBJOb3J0ZSBkZSBTYW50YW5kZXIx
|
||||||
|
DzANBgNVBAcMBkN1Y3V0YTEjMCEGA1UECgwaTGFib3JhdG9yaW8gWGltZW5hIENh
|
||||||
|
aWNlZG8xKTAnBgNVBAMMIGVycC5sYWJvcmF0b3Jpb3hpbWVuYWNhaWNlZG8uY29t
|
||||||
|
MB4XDTI2MDcyMTE5NTEzNloXDTM2MDcxODE5NTEzNlowgYsxCzAJBgNVBAYTAkNP
|
||||||
|
MRswGQYDVQQIDBJOb3J0ZSBkZSBTYW50YW5kZXIxDzANBgNVBAcMBkN1Y3V0YTEj
|
||||||
|
MCEGA1UECgwaTGFib3JhdG9yaW8gWGltZW5hIENhaWNlZG8xKTAnBgNVBAMMIGVy
|
||||||
|
cC5sYWJvcmF0b3Jpb3hpbWVuYWNhaWNlZG8uY29tMIIBIjANBgkqhkiG9w0BAQEF
|
||||||
|
AAOCAQ8AMIIBCgKCAQEAjbzIjSUEj6qjwHvg+qNAEk0rAjfezbpyGPGkbH1AFnmI
|
||||||
|
8rz2TZKAN5PWJSvjYS91YEtnfFttEDa0wQpvHOIPhn2l8gAsYNFoDS+i1ilXHBEA
|
||||||
|
OtAB7uVL2TvrZBTQtcab4upI1ehUjdqA9ae51KBEfqOD3k7dc5X8IeskBDMLfI5G
|
||||||
|
cuJejGpx6S8aFur/PfZfu3oApHW02b4GXqSguUHwBJZtGauFQWRrN/6VG3YVKXON
|
||||||
|
g8bDEyFgPCZ2Slp4pHoLK5BIjBkF1t3PUcSq5rRZCxjYzoOcDIfoVV5f4Y+QSspM
|
||||||
|
u3tI+cMAnqwrKma/TBmSSikTMuK9nb+qjVvlQ/kijwIDAQABo1MwUTAdBgNVHQ4E
|
||||||
|
FgQUVnCTxhnONKLUYtxlT4SsIPH5rEgwHwYDVR0jBBgwFoAUVnCTxhnONKLUYtxl
|
||||||
|
T4SsIPH5rEgwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAEL/L
|
||||||
|
PP8b2UQavVLh3r0D3Is0V7JrVTRsT/iB2FwJLhudHAojVBinh7+tvZboEFkJ5trR
|
||||||
|
bNG3XxXDprw76awc/BsR19YXWrjZfraROrVNYEVTW1+Mit2HYRuTiEnVxOX1EENR
|
||||||
|
dAwZrwtFmpy3I2sZNblxQm0cZbDq6g4x/LBTwUXmXQK259Nrv5SEjs8wixeDMDNC
|
||||||
|
wuA9vAamsw+38nqHCouNtE+yPwM7srinFkfilMnIB2d874eqaVBvEKWmUtBCsWaO
|
||||||
|
f5OviyodKH9BtF+ACpgU2E/at03VratR5REIjje52qsWOMY1Wnn8FIYsCWJIPQYu
|
||||||
|
m56yKz4rQLPX/kyeCQ==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCNvMiNJQSPqqPA
|
||||||
|
e+D6o0ASTSsCN97NunIY8aRsfUAWeYjyvPZNkoA3k9YlK+NhL3VgS2d8W20QNrTB
|
||||||
|
Cm8c4g+GfaXyACxg0WgNL6LWKVccEQA60AHu5UvZO+tkFNC1xpvi6kjV6FSN2oD1
|
||||||
|
p7nUoER+o4PeTt1zlfwh6yQEMwt8jkZy4l6ManHpLxoW6v899l+7egCkdbTZvgZe
|
||||||
|
pKC5QfAElm0Zq4VBZGs3/pUbdhUpc42DxsMTIWA8JnZKWnikegsrkEiMGQXW3c9R
|
||||||
|
xKrmtFkLGNjOg5wMh+hVXl/hj5BKyky7e0j5wwCerCsqZr9MGZJKKRMy4r2dv6qN
|
||||||
|
W+VD+SKPAgMBAAECggEAPqedtfsPrZx+f6ejN9hzicOQCA5/kMzjBBDJoOWrL2Qx
|
||||||
|
PDB45qikwiy5ZLwmav8qMVOT3v6hUyIDvDPrE0cBGvAvK6+U7oWTLAULRAWJStBf
|
||||||
|
HCB4Qk0dPt3Ee/zRmBFANspfQSPPQNe+2xj2Rj5EmQCaWerd7Or3xlymEq8n3Dp2
|
||||||
|
E9pFn4wiEDdjXG69/fqu/LoFpz31E2Mp0q+V4ZdWvMXA4U6akH2/oGWCnjDdhm/O
|
||||||
|
Wy1shQL5oVxdw/yhHhaC/3GZv/MKVbaW7JeBtnt3S5Szktr6A1O2obSJS+KOw9d+
|
||||||
|
Y2rqIMAWXv+kHKzAB2LtfczpfkEDkqQ7jCbu+sjX4QKBgQC+3uwfwM9mlxoCt93E
|
||||||
|
cerMyfrbssTjZjZSsxnHsGz+qmDr8TIyOrwcZnnYs6MNkuTJW6MvgN8hM8oO2arh
|
||||||
|
E3s0oWrbILD42rzVQUeLr7/N/JjUwK7yyEnfZX/hnQdilRsT+rxpXBIA0HfUheih
|
||||||
|
6KkddNoOyvs+wu1eOralO117LwKBgQC+Ge4ch1hicRchwX/Bu+h3cdkLVVgdI08+
|
||||||
|
O7vnSCuhj8Ead7z3C5XjEqJOs75xY+LF4UxPsrqTuCxpWIOupCSnRe+RGMcJdzbR
|
||||||
|
0i/leyL9fWhvHzP13R4T6sqAHqIXKgCfR7Hx99TW3Whc4N2GoqAnIrjqow+H+ic8
|
||||||
|
8Hrr+Ul2oQKBgQC1AyRbWLdYS6RXP5gJXR+X51UIVZlzLtQFyeSBBEfZnCselzdL
|
||||||
|
e3g6VtTnNjVEAjMG4uj3e/gfvMW7H6J2ocsONqbn+TDcUFUUyTvYtWvpJcyqt7Ey
|
||||||
|
fc/RFKkahZkjXNS5NejI4pAQRaPe4L+mDMeVL+Q8czOiaapC2tusB4i38QKBgCYh
|
||||||
|
G1Jbj03HcyVRI2ffYcQ7cJZGWvMVNvq7jnfYUPAJ3miJpbxDdZ/jB+0TPlqN91lL
|
||||||
|
VDwUFDo20ambmGX6BGQMsf1/Y8SxRayWJQc5SI5hjgXj0084N6U1DcLe4hIVWaSZ
|
||||||
|
A8cNt4IVTK58Z9JuYgMXgtGFPUM/2Ijvjygvix2hAoGBALSgjUzho5Jn/vAQza/M
|
||||||
|
k4q24RZ0kBJ1Go+/b3iSOieCCVKgow9224r/frcrFvXWzbNjhKs/HAV1bB9uCrxD
|
||||||
|
/V/1byH0XLYmYIKvhTqpTtBLCYXrv+2m+ox8pHrgPze4KuJy3VdN/V/Dj3CpY0VL
|
||||||
|
G5Uf9TfOiMk4EiZw/pBR1YT9
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
@@ -44,6 +44,30 @@ class App
|
|||||||
header('Location: ' . APP_ROOT . '/../enfermero_portal.php');
|
header('Location: ' . APP_ROOT . '/../enfermero_portal.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
// Recepcionistas con IP registrada: solo pueden acceder a su desk
|
||||||
|
if (Auth::role() === 'recepcionista') {
|
||||||
|
$mod = $router->getModule();
|
||||||
|
$view = $router->getView();
|
||||||
|
if ($mod === 'turnero' && $view === 'recepcion') {
|
||||||
|
$deskId = (int)($_GET['desk_id'] ?? 0);
|
||||||
|
$assignedDesk = self::recepIpDesk();
|
||||||
|
if ($assignedDesk && $deskId !== $assignedDesk) {
|
||||||
|
header('Location: /erp.php?m=turnero&v=recepcion&desk_id=' . $assignedDesk);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bacteriólogos: sin dashboard ni historial; redirige según IP
|
||||||
|
if (Auth::isBacteriologo()) {
|
||||||
|
$mod = $router->getModule();
|
||||||
|
$view = $router->getView();
|
||||||
|
$blocked = ($mod === 'dashboard')
|
||||||
|
|| ($mod === 'turnero' && in_array($view, ['dashboard', 'historial'], true));
|
||||||
|
if ($blocked) {
|
||||||
|
header('Location: ' . self::bacteDefaultUrl());
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self::dispatch($router);
|
self::dispatch($router);
|
||||||
@@ -99,6 +123,67 @@ class App
|
|||||||
include $viewFile;
|
include $viewFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Helpers de rol ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** IP real del cliente, soporta proxy con X-Forwarded-For. */
|
||||||
|
public static function clientIp(): string
|
||||||
|
{
|
||||||
|
$raw = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
|
||||||
|
return trim(explode(',', $raw)[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve el lugar_id de recepción asignado a la IP del cliente, o null si no está registrada.
|
||||||
|
*/
|
||||||
|
private static function recepIpDesk(): ?int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
"SELECT d.lugar_id FROM turnero_dispositivos d
|
||||||
|
JOIN turnero_lugares l ON l.id = d.lugar_id
|
||||||
|
WHERE d.ip = ? AND d.activo = 1 AND l.tipo = 'recepcion' LIMIT 1"
|
||||||
|
);
|
||||||
|
$st->execute([self::clientIp()]);
|
||||||
|
$id = $st->fetchColumn();
|
||||||
|
return $id ? (int)$id : null;
|
||||||
|
} catch (\Throwable $_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL de destino para bacteriólogo según IP del cliente.
|
||||||
|
* IP registrada en turnero_dispositivos → ese lugar.
|
||||||
|
* IP no registrada → primer lugar de tipo muestras.
|
||||||
|
*/
|
||||||
|
private static function bacteDefaultUrl(): string
|
||||||
|
{
|
||||||
|
$base = '/erp.php?m=turnero&v=lugar&lugar_id=';
|
||||||
|
try {
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
$ip = self::clientIp();
|
||||||
|
// ¿IP registrada?
|
||||||
|
$dev = $pdo->prepare(
|
||||||
|
"SELECT lugar_id FROM turnero_dispositivos WHERE ip = ? AND activo = 1 LIMIT 1"
|
||||||
|
);
|
||||||
|
$dev->execute([$ip]);
|
||||||
|
$row = $dev->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if ($row) {
|
||||||
|
return $base . (int)$row['lugar_id'];
|
||||||
|
}
|
||||||
|
// Primer lugar de toma de muestras
|
||||||
|
$first = $pdo->query(
|
||||||
|
"SELECT id FROM turnero_lugares WHERE activo=1 AND tipo='muestras' ORDER BY sort_order LIMIT 1"
|
||||||
|
)->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if ($first) {
|
||||||
|
return $base . (int)$first['id'];
|
||||||
|
}
|
||||||
|
} catch (\Throwable $_) {}
|
||||||
|
// Fallback: turnero sin vista específica
|
||||||
|
return '/erp.php?m=turnero';
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Páginas de error ────────────────────────────────────────────────────
|
// ─── Páginas de error ────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static function render404(string $module, string $view): void
|
private static function render404(string $module, string $view): void
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ class Auth
|
|||||||
return self::role() === 'enfermero';
|
return self::role() === 'enfermero';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function isBacteriologo(): bool
|
||||||
|
{
|
||||||
|
return self::role() === 'bacteriologo';
|
||||||
|
}
|
||||||
|
|
||||||
public static function isAdmin(): bool
|
public static function isAdmin(): bool
|
||||||
{
|
{
|
||||||
return self::role() === 'admin';
|
return self::role() === 'admin';
|
||||||
|
|||||||
+6
-3
@@ -60,12 +60,15 @@ class Layout
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= $safeTitle ?> — <?= htmlspecialchars($appName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></title>
|
<title><?= $safeTitle ?> — <?= htmlspecialchars($appName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></title>
|
||||||
|
|
||||||
|
<!-- Dark mode: aplica data-bs-theme antes del primer paint para evitar flash -->
|
||||||
|
<script>(function(){try{var m=window.matchMedia('(prefers-color-scheme: dark)');function a(d){document.documentElement.setAttribute('data-bs-theme',d?'dark':'light');}a(m.matches);m.addEventListener('change',function(e){a(e.matches);});}catch(e){}})();</script>
|
||||||
|
|
||||||
<!-- Bootstrap 5 -->
|
<!-- Bootstrap 5 -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<!-- Font Awesome 6 -->
|
<!-- Font Awesome 6 -->
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
<!-- Estilos del sistema -->
|
<!-- Estilos del sistema -->
|
||||||
<link href="<?= $base ?>/assets/css/styles.css?v=14" rel="stylesheet">
|
<link href="<?= $base ?>/assets/css/styles.css?v=15" rel="stylesheet">
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
@@ -77,7 +80,7 @@ class Layout
|
|||||||
.main-content {
|
.main-content {
|
||||||
margin-left: var(--sidebar-width);
|
margin-left: var(--sidebar-width);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f8f9fa;
|
background: var(--bs-body-bg, #f8f9fa);
|
||||||
}
|
}
|
||||||
@media (max-width: 991.98px) {
|
@media (max-width: 991.98px) {
|
||||||
.main-content { margin-left: 0; }
|
.main-content { margin-left: 0; }
|
||||||
@@ -108,7 +111,7 @@ class Layout
|
|||||||
<!-- Contenido principal -->
|
<!-- Contenido principal -->
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<!-- Barra de título de módulo -->
|
<!-- Barra de título de módulo -->
|
||||||
<div class="bg-white border-bottom px-4 py-3 d-flex align-items-center justify-content-between">
|
<div class="bg-body-tertiary border-bottom px-4 py-3 d-flex align-items-center justify-content-between">
|
||||||
<h5 class="mb-0 fw-semibold">
|
<h5 class="mb-0 fw-semibold">
|
||||||
<i class="<?= $safeIcon ?> me-2 text-primary"></i><?= $safeTitle ?>
|
<i class="<?= $safeIcon ?> me-2 text-primary"></i><?= $safeTitle ?>
|
||||||
</h5>
|
</h5>
|
||||||
|
|||||||
+195
-2
@@ -90,6 +90,29 @@ if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|||||||
/* ── Loading screen ──────────────────────────────── */
|
/* ── Loading screen ──────────────────────────────── */
|
||||||
#loading-screen { min-height:50vh; display:flex; flex-direction:column;
|
#loading-screen { min-height:50vh; display:flex; flex-direction:column;
|
||||||
align-items:center; justify-content:center; gap:12px; }
|
align-items:center; justify-content:center; gap:12px; }
|
||||||
|
|
||||||
|
/* ── Topaz SigWeb overlay ─────────────────────────── */
|
||||||
|
#topaz-overlay { display:none; position:fixed; inset:0; z-index:9999;
|
||||||
|
background:rgba(0,0,0,.55); align-items:center;
|
||||||
|
justify-content:center; padding:16px; }
|
||||||
|
.topaz-modal { background:#fff; border-radius:16px; width:100%;
|
||||||
|
max-width:400px; box-shadow:0 12px 40px rgba(0,0,0,.3); overflow:hidden; }
|
||||||
|
.topaz-modal-hdr { background:linear-gradient(135deg,#1565c0,#0288d1);
|
||||||
|
color:#fff; padding:14px 18px; font-weight:700; font-size:.95rem;
|
||||||
|
display:flex; align-items:center; gap:8px; }
|
||||||
|
.topaz-modal-body { padding:20px 18px; }
|
||||||
|
.topaz-pad-area { border:2px dashed #adb5bd; border-radius:10px; padding:24px 16px;
|
||||||
|
text-align:center; background:#f8fafc; min-height:100px;
|
||||||
|
display:flex; flex-direction:column; align-items:center;
|
||||||
|
justify-content:center; gap:6px; transition:border-color .2s; }
|
||||||
|
.topaz-pad-area.has-sig { border-color:#198754; border-style:solid; background:#f0fff4; }
|
||||||
|
.topaz-pts-badge { font-size:.8rem; color:#64748b; }
|
||||||
|
.topaz-modal-footer { display:flex; gap:8px; justify-content:flex-end;
|
||||||
|
padding:12px 18px; border-top:1px solid #f1f5f9; flex-wrap:wrap; }
|
||||||
|
.btn-topaz { font-size:.83rem; padding:6px 13px; border-radius:8px; border:1.5px solid #0288d1;
|
||||||
|
background:#fff; color:#0288d1; cursor:pointer; font-weight:600;
|
||||||
|
display:inline-flex; align-items:center; gap:5px; transition:all .15s; }
|
||||||
|
.btn-topaz:hover { background:#e0f2fe; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -204,11 +227,15 @@ if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|||||||
<div id="firma-canvas-area">
|
<div id="firma-canvas-area">
|
||||||
<p class="text-muted small mb-2">✍️ Dibuja tu firma con el dedo o el mouse.</p>
|
<p class="text-muted small mb-2">✍️ Dibuja tu firma con el dedo o el mouse.</p>
|
||||||
<canvas id="firma-canvas" class="empty"></canvas>
|
<canvas id="firma-canvas" class="empty"></canvas>
|
||||||
<div class="d-flex gap-2 mt-2">
|
<div class="d-flex gap-2 mt-2 flex-wrap">
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
onclick="firma.limpiarCanvas()">
|
onclick="firma.limpiarCanvas()">
|
||||||
<i class="fas fa-eraser me-1"></i>Limpiar firma
|
<i class="fas fa-eraser me-1"></i>Limpiar firma
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-topaz" onclick="topaz.activar('__global')"
|
||||||
|
title="Usar pad biométrico Topaz">
|
||||||
|
<i class="fas fa-tablet-alt"></i>Tableta
|
||||||
|
</button>
|
||||||
<span id="firma-status" class="small text-muted align-self-center">Sin firma</span>
|
<span id="firma-status" class="small text-muted align-self-center">Sin firma</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -294,6 +321,36 @@ if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Topaz SigWeb overlay ───────────────────────────────────────── -->
|
||||||
|
<div id="topaz-overlay">
|
||||||
|
<div class="topaz-modal">
|
||||||
|
<div class="topaz-modal-hdr">
|
||||||
|
<i class="fas fa-tablet-alt"></i> Pad biométrico Topaz
|
||||||
|
</div>
|
||||||
|
<div class="topaz-modal-body">
|
||||||
|
<div class="topaz-pad-area" id="topaz-pad-area">
|
||||||
|
<i class="fas fa-signature fa-2x" style="color:#adb5bd" id="topaz-pad-icon"></i>
|
||||||
|
<div id="topaz-status-msg" style="font-size:.9rem;color:#64748b;font-weight:600">
|
||||||
|
Firme en el pad biométrico
|
||||||
|
</div>
|
||||||
|
<div class="topaz-pts-badge">Trazos: <span id="topaz-pts">0</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="topaz-modal-footer">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="topaz.cancelar()">
|
||||||
|
<i class="fas fa-times me-1"></i>Cancelar
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="topaz.limpiarPad()">
|
||||||
|
<i class="fas fa-eraser me-1"></i>Limpiar
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-success btn-sm fw-semibold" id="topaz-btn-aceptar"
|
||||||
|
onclick="topaz.aceptar()" disabled>
|
||||||
|
<i class="fas fa-check me-1"></i>Aceptar firma
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -648,11 +705,15 @@ function renderFirmaInline(fid, modos, label, required, isPro) {
|
|||||||
<p class="text-muted small mb-2">✍️ Dibuja la firma con el dedo o el mouse.</p>
|
<p class="text-muted small mb-2">✍️ Dibuja la firma con el dedo o el mouse.</p>
|
||||||
<canvas id="fw-${fid}-canvas" class="fw-canvas empty"
|
<canvas id="fw-${fid}-canvas" class="fw-canvas empty"
|
||||||
style="border:2px dashed ${borderC};background:${bgC};"></canvas>
|
style="border:2px dashed ${borderC};background:${bgC};"></canvas>
|
||||||
<div class="d-flex gap-2 mt-2">
|
<div class="d-flex gap-2 mt-2 flex-wrap">
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
onclick="firmaWidgetLimpiar('${fid}')">
|
onclick="firmaWidgetLimpiar('${fid}')">
|
||||||
<i class="fas fa-eraser me-1"></i>Limpiar
|
<i class="fas fa-eraser me-1"></i>Limpiar
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-topaz" onclick="topaz.activar('${fid}')"
|
||||||
|
title="Usar pad biométrico Topaz">
|
||||||
|
<i class="fas fa-tablet-alt"></i>Tableta
|
||||||
|
</button>
|
||||||
<span id="fw-${fid}-status" class="small text-muted align-self-center">Sin firma</span>
|
<span id="fw-${fid}-status" class="small text-muted align-self-center">Sin firma</span>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -1205,6 +1266,138 @@ function iniciarCondiciones(esquema) {
|
|||||||
evaluar();
|
evaluar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// TOPAZ SIGWEB
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
const topaz = (() => {
|
||||||
|
const SIGWEB_URL = 'http://localhost:47289/SigWeb/SigWebTablet.js';
|
||||||
|
let _loaded = false, _ctx = null, _fid = null, _poll = null;
|
||||||
|
|
||||||
|
function _call(fn, ...args) {
|
||||||
|
// SigWeb v1: global functions with ctx as last param
|
||||||
|
// SigWeb v2: methods on ctx object
|
||||||
|
if (_ctx && typeof _ctx[fn] === 'function') return _ctx[fn](...args);
|
||||||
|
const g = window[fn];
|
||||||
|
if (typeof g === 'function') return _ctx ? g(...args, _ctx) : g(...args);
|
||||||
|
throw new Error('SigWeb: ' + fn + ' no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadScript() {
|
||||||
|
if (_loaded) return Promise.resolve(true);
|
||||||
|
return new Promise(res => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = SIGWEB_URL;
|
||||||
|
s.onload = () => { _loaded = true; res(true); };
|
||||||
|
s.onerror = () => res(false);
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setStatus(msg, ok) {
|
||||||
|
const el = $('topaz-status-msg');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.color = ok ? '#198754' : '#64748b';
|
||||||
|
const area = $('topaz-pad-area');
|
||||||
|
if (area) area.classList.toggle('has-sig', !!ok);
|
||||||
|
const icon = $('topaz-pad-icon');
|
||||||
|
if (icon) icon.style.color = ok ? '#198754' : '#adb5bd';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activar(fid) {
|
||||||
|
const ok = await _loadScript();
|
||||||
|
if (!ok) {
|
||||||
|
alert('No se detectó SigWeb.\nInstala el servicio Topaz SigWeb y vuelve a intentarlo.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_fid = fid;
|
||||||
|
try {
|
||||||
|
_ctx = typeof SigWebTablet !== 'undefined' ? new SigWebTablet() : null;
|
||||||
|
_call('SetImageXSize', 500);
|
||||||
|
_call('SetImageYSize', 150);
|
||||||
|
_call('SetImagePenWidth', 3);
|
||||||
|
_call('SetTabletState', 1);
|
||||||
|
_call('ClearTablet');
|
||||||
|
} catch(e) {
|
||||||
|
alert('Error al activar el pad: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('topaz-overlay').style.display = 'flex';
|
||||||
|
$('topaz-btn-aceptar').disabled = true;
|
||||||
|
$('topaz-pts').textContent = '0';
|
||||||
|
_setStatus('Firme en el pad biométrico', false);
|
||||||
|
|
||||||
|
_poll = setInterval(() => {
|
||||||
|
try {
|
||||||
|
const pts = _call('GetSigTotalPoints');
|
||||||
|
$('topaz-pts').textContent = pts;
|
||||||
|
const hasSig = pts > 0;
|
||||||
|
$('topaz-btn-aceptar').disabled = !hasSig;
|
||||||
|
if (hasSig) _setStatus('✅ Firma detectada — presione Aceptar', true);
|
||||||
|
} catch(e) { _stopPoll(); }
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopPoll() { if (_poll) { clearInterval(_poll); _poll = null; } }
|
||||||
|
|
||||||
|
function _cerrarOverlay() {
|
||||||
|
_stopPoll();
|
||||||
|
try { if (_ctx) _call('SetTabletState', 0); } catch(e) {}
|
||||||
|
_ctx = null; _fid = null;
|
||||||
|
$('topaz-overlay').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelar() { _cerrarOverlay(); }
|
||||||
|
|
||||||
|
function limpiarPad() {
|
||||||
|
try { _call('ClearTablet'); } catch(e) {}
|
||||||
|
$('topaz-pts').textContent = '0';
|
||||||
|
$('topaz-btn-aceptar').disabled = true;
|
||||||
|
_setStatus('Firme en el pad biométrico', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aceptar() {
|
||||||
|
let b64;
|
||||||
|
try { b64 = _call('GetSigImageB64'); } catch(e) {
|
||||||
|
alert('Error al capturar la firma: ' + e.message);
|
||||||
|
_cerrarOverlay(); return;
|
||||||
|
}
|
||||||
|
if (!b64) { alert('No se capturó ninguna firma.'); return; }
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const fid = _fid;
|
||||||
|
_cerrarOverlay();
|
||||||
|
|
||||||
|
if (fid === '__global') {
|
||||||
|
const c = $('firma-canvas');
|
||||||
|
const cx = c.getContext('2d');
|
||||||
|
const r = window.devicePixelRatio || 1;
|
||||||
|
cx.clearRect(0, 0, c.width / r, c.height / r);
|
||||||
|
cx.drawImage(img, 0, 0, c.offsetWidth, c.offsetHeight);
|
||||||
|
c.classList.remove('empty');
|
||||||
|
_firmaDibujada = true;
|
||||||
|
const st = $('firma-status');
|
||||||
|
if (st) { st.textContent = '✅ Firma lista (tableta)'; st.className = 'small text-success align-self-center fw-semibold'; }
|
||||||
|
} else {
|
||||||
|
const s = _fw[fid];
|
||||||
|
if (!s?.canvas) return;
|
||||||
|
const r = window.devicePixelRatio || 1;
|
||||||
|
s.ctx.clearRect(0, 0, s.canvas.width / r, s.canvas.height / r);
|
||||||
|
s.ctx.drawImage(img, 0, 0, s.canvas.offsetWidth, s.canvas.offsetHeight);
|
||||||
|
s.hasFirma = true;
|
||||||
|
s.canvas.classList.remove('empty');
|
||||||
|
s.canvas.style.borderStyle = 'solid';
|
||||||
|
const st = document.getElementById('fw-' + fid + '-status');
|
||||||
|
if (st) { st.textContent = '✅ Firmado (tableta)'; st.className = 'small text-success align-self-center fw-semibold'; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.src = 'data:image/png;base64,' + b64;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { activar, cancelar, limpiarPad, aceptar };
|
||||||
|
})();
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
// INIT
|
// INIT
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
; kiosko_autoprint.ahk
|
||||||
|
; Detecta el diálogo de impresión y lo confirma automáticamente.
|
||||||
|
; Requiere AutoHotkey v2: https://www.autohotkey.com/
|
||||||
|
|
||||||
|
#Persistent
|
||||||
|
SetTitleMatchMode 2
|
||||||
|
|
||||||
|
Loop {
|
||||||
|
; Esperar cualquier ventana de diálogo de impresión (Chrome, Edge, Windows)
|
||||||
|
if WinExist("Imprimir") or WinExist("Print") {
|
||||||
|
WinActivate
|
||||||
|
Sleep 400
|
||||||
|
; Intentar presionar el botón Imprimir / OK / Enter
|
||||||
|
ControlClick "Button1"
|
||||||
|
Sleep 200
|
||||||
|
Send "{Enter}"
|
||||||
|
Sleep 1000
|
||||||
|
}
|
||||||
|
Sleep 500
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
taskkill /F /IM msedge.exe >nul 2>&1
|
||||||
|
timeout /t 2 /nobreak >nul
|
||||||
|
|
||||||
|
start "" "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --kiosk "https://erp.laboratorioximenacaicedo.com/erp.php?m=turnero&v=kiosko&autoprint=1" --edge-kiosk-type=fullscreen --kiosk-printing
|
||||||
@@ -681,6 +681,7 @@ async function cargarLista(pag = 1) {
|
|||||||
<td>
|
<td>
|
||||||
<div class="fw-semibold">${esc(dom.paciente_nombre)}</div>
|
<div class="fw-semibold">${esc(dom.paciente_nombre)}</div>
|
||||||
<small class="text-muted">${esc(dom.barrio||'')}</small>
|
<small class="text-muted">${esc(dom.barrio||'')}</small>
|
||||||
|
${dom.numero_orden ? `<div><span style="font-size:.68rem;background:#f0fdf4;color:#15803d;border:1px solid #86efac;border-radius:20px;padding:0 7px;font-weight:700">#${esc(dom.numero_orden)}</span></div>` : ''}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
${dom.enfermera_nombre ? `<small>${esc(dom.enfermera_nombre)}</small>` : '<span class="text-danger small"><i class="fas fa-exclamation-triangle me-1"></i>Sin asignar</span>'}
|
${dom.enfermera_nombre ? `<small>${esc(dom.enfermera_nombre)}</small>` : '<span class="text-danger small"><i class="fas fa-exclamation-triangle me-1"></i>Sin asignar</span>'}
|
||||||
@@ -799,6 +800,13 @@ async function verDomicilio(id) {
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
|
${dom.numero_orden ? `<div class="mb-3">
|
||||||
|
<label class="form-label small text-muted text-uppercase">Número de orden</label><br>
|
||||||
|
<span style="font-size:.82rem;background:#f0fdf4;color:#15803d;border:1px solid #86efac;
|
||||||
|
border-radius:20px;padding:2px 12px;font-weight:700">
|
||||||
|
<i class="fas fa-hashtag me-1" style="font-size:.7rem"></i>${esc(dom.numero_orden)}
|
||||||
|
</span>
|
||||||
|
</div>` : ''}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label small text-muted text-uppercase">Estado actual</label><br>
|
<label class="form-label small text-muted text-uppercase">Estado actual</label><br>
|
||||||
<span class="badge bg-${COLOR_DOM[dom.estado]||'secondary'} fs-6">${esc(dom.estado)}</span>
|
<span class="badge bg-${COLOR_DOM[dom.estado]||'secondary'} fs-6">${esc(dom.estado)}</span>
|
||||||
|
|||||||
+142
-26
@@ -56,7 +56,28 @@ $brandDark = sprintf('#%02x%02x%02x',
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
.hist-badge { font-size: .72rem; }
|
.hist-badge { font-size: .72rem; }
|
||||||
#detail-panel { display: none; }
|
/* Drawer de detalle (fixed overlay) */
|
||||||
|
#detail-backdrop {
|
||||||
|
display: none;
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,.25);
|
||||||
|
z-index: 1049;
|
||||||
|
}
|
||||||
|
#detail-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; right: 0;
|
||||||
|
height: 100vh;
|
||||||
|
width: 420px;
|
||||||
|
z-index: 1050;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: -4px 0 24px rgba(0,0,0,.18);
|
||||||
|
transform: translateX(110%);
|
||||||
|
transition: transform .25s cubic-bezier(.4,0,.2,1);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
#detail-panel.open { transform: translateX(0); }
|
||||||
|
@media (max-width: 480px) { #detail-panel { width: 100vw; } }
|
||||||
|
#detail-body { flex: 1; overflow-y: auto; padding: 1rem; }
|
||||||
|
|
||||||
/* ── Modal header ──────────────────────────────── */
|
/* ── Modal header ──────────────────────────────── */
|
||||||
.modal-header-brand {
|
.modal-header-brand {
|
||||||
@@ -125,9 +146,10 @@ $brandDark = sprintf('#%02x%02x%02x',
|
|||||||
/* ── Panel detalle ─────────────────────────────── */
|
/* ── Panel detalle ─────────────────────────────── */
|
||||||
.detail-header-band {
|
.detail-header-band {
|
||||||
background: linear-gradient(135deg, var(--brand-dark) 0%, var(--brand) 100%);
|
background: linear-gradient(135deg, var(--brand-dark) 0%, var(--brand) 100%);
|
||||||
color: #fff; border-radius: .5rem .5rem 0 0;
|
color: #fff;
|
||||||
padding: .75rem 1rem;
|
padding: .75rem 1rem;
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.detail-header-band .btn-close { filter: invert(1) brightness(2); }
|
.detail-header-band .btn-close { filter: invert(1) brightness(2); }
|
||||||
|
|
||||||
@@ -155,8 +177,37 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="container-fluid py-3">
|
<div class="container-fluid py-3">
|
||||||
<!-- Buscador -->
|
|
||||||
<div class="row mb-3">
|
<!-- Stats -->
|
||||||
|
<div class="row g-2 mb-3" id="stats-row">
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm rounded-3 text-center py-2">
|
||||||
|
<div class="fw-bold fs-4" id="stat-total">—</div>
|
||||||
|
<div class="text-muted small">Total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm rounded-3 text-center py-2" style="cursor:pointer" onclick="filtrarOrigen('lab')">
|
||||||
|
<div class="fw-bold fs-4 text-primary" id="stat-rips">—</div>
|
||||||
|
<div class="text-muted small"><i class="fas fa-flask me-1 text-primary"></i>Importados Lab</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm rounded-3 text-center py-2" style="cursor:pointer" onclick="filtrarOrigen('whatsapp')">
|
||||||
|
<div class="fw-bold fs-4 text-success" id="stat-wa">—</div>
|
||||||
|
<div class="text-muted small"><i class="fab fa-whatsapp me-1 text-success"></i>Vinculados WA</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm rounded-3 text-center py-2" style="cursor:pointer" onclick="filtrarOrigen('manual')">
|
||||||
|
<div class="fw-bold fs-4 text-secondary" id="stat-manual">—</div>
|
||||||
|
<div class="text-muted small"><i class="fas fa-pen me-1"></i>Manuales</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="row mb-3 g-2">
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text bg-white border-end-0"><i class="fas fa-search text-muted"></i></span>
|
<span class="input-group-text bg-white border-end-0"><i class="fas fa-search text-muted"></i></span>
|
||||||
@@ -165,11 +216,24 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
|||||||
oninput="debounce(cargarLista, 380)()">
|
oninput="debounce(cargarLista, 380)()">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<select id="filtro-origen" class="form-select" onchange="cargarLista(1)">
|
||||||
|
<option value="">Todos los orígenes</option>
|
||||||
|
<option value="lab">Resultados Lab</option>
|
||||||
|
<option value="manual">Manuales</option>
|
||||||
|
<option value="whatsapp">Desde WhatsApp</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-auto ms-auto">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="cargarStats()" title="Actualizar stats">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<!-- Lista -->
|
<!-- Lista -->
|
||||||
<div class="col-lg-7" id="lista-col">
|
<div class="col-12" id="lista-col">
|
||||||
<div class="card border-0 shadow-sm rounded-3">
|
<div class="card border-0 shadow-sm rounded-3">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@@ -179,13 +243,17 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
|||||||
<th class="ps-3">Paciente</th>
|
<th class="ps-3">Paciente</th>
|
||||||
<th>Documento</th>
|
<th>Documento</th>
|
||||||
<th>Teléfono</th>
|
<th>Teléfono</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Ciudad</th>
|
||||||
<th>EPS</th>
|
<th>EPS</th>
|
||||||
|
<th>Origen</th>
|
||||||
|
<th>Registro</th>
|
||||||
<th>Órd.</th>
|
<th>Órd.</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="tabla-body">
|
<tbody id="tabla-body">
|
||||||
<tr><td colspan="6" class="text-center py-4 text-muted">
|
<tr><td colspan="10" class="text-center py-4 text-muted">
|
||||||
<i class="fas fa-spinner fa-spin me-2"></i>Cargando...
|
<i class="fas fa-spinner fa-spin me-2"></i>Cargando...
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -195,21 +263,22 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Panel de detalle -->
|
|
||||||
<div class="col-lg-5" id="detail-panel">
|
|
||||||
<div class="card border-0 shadow-sm rounded-3 overflow-hidden">
|
|
||||||
<div class="detail-header-band">
|
|
||||||
<span class="fw-semibold" id="detail-nombre">Paciente</span>
|
|
||||||
<button class="btn-close" onclick="cerrarDetalle()"></button>
|
|
||||||
</div>
|
|
||||||
<div class="card-body" id="detail-body"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Backdrop para el drawer de detalle -->
|
||||||
|
<div id="detail-backdrop" onclick="cerrarDetalle()"></div>
|
||||||
|
|
||||||
|
<!-- Drawer de detalle (fixed overlay) -->
|
||||||
|
<div id="detail-panel">
|
||||||
|
<div class="detail-header-band">
|
||||||
|
<span class="fw-semibold" id="detail-nombre">Paciente</span>
|
||||||
|
<button class="btn-close" onclick="cerrarDetalle()"></button>
|
||||||
|
</div>
|
||||||
|
<div id="detail-body"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Modal Formulario Paciente -->
|
<!-- Modal Formulario Paciente -->
|
||||||
<div class="modal fade" id="modalPaciente" tabindex="-1">
|
<div class="modal fade" id="modalPaciente" tabindex="-1">
|
||||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
@@ -367,16 +436,54 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
|||||||
let paginaActual = 1;
|
let paginaActual = 1;
|
||||||
const modal = new bootstrap.Modal('#modalPaciente');
|
const modal = new bootstrap.Modal('#modalPaciente');
|
||||||
|
|
||||||
|
// ── Stats ──────────────────────────────────────────────────────────────────
|
||||||
|
async function cargarStats() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('api/lab/get_pacientes.php?stats=1');
|
||||||
|
const d = await r.json();
|
||||||
|
const s = d.stats || {};
|
||||||
|
document.getElementById('stat-total').textContent = s.total ?? '—';
|
||||||
|
document.getElementById('stat-rips').textContent = s.importados ?? '—';
|
||||||
|
document.getElementById('stat-wa').textContent = s.con_wa ?? '—';
|
||||||
|
document.getElementById('stat-manual').textContent = s.manuales ?? '—';
|
||||||
|
} catch(_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtrarOrigen(origen) {
|
||||||
|
const sel = document.getElementById('filtro-origen');
|
||||||
|
sel.value = sel.value === origen ? '' : origen;
|
||||||
|
cargarLista(1);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Lista ──────────────────────────────────────────────────────────────────
|
// ── Lista ──────────────────────────────────────────────────────────────────
|
||||||
|
const _origenBadge = {
|
||||||
|
lab: '<span class="badge" style="background:#e3f2fd;color:#1565c0;font-size:.7rem"><i class="fas fa-flask me-1"></i>Resultados Lab</span>',
|
||||||
|
whatsapp: '<span class="badge" style="background:#e8f5e9;color:#2e7d32;font-size:.7rem"><i class="fab fa-whatsapp me-1"></i>WhatsApp</span>',
|
||||||
|
manual: '<span class="badge bg-secondary bg-opacity-10 text-secondary" style="font-size:.7rem"><i class="fas fa-pen me-1"></i>Manual</span>',
|
||||||
|
rips: '<span class="badge" style="background:#e3f2fd;color:#1565c0;font-size:.7rem"><i class="fas fa-flask me-1"></i>Resultados Lab</span>',
|
||||||
|
};
|
||||||
|
|
||||||
|
function origenBadge(origen) {
|
||||||
|
return _origenBadge[origen] || _origenBadge.manual;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtFecha(str) {
|
||||||
|
if (!str) return '—';
|
||||||
|
return str.slice(0, 10).split('-').reverse().join('/');
|
||||||
|
}
|
||||||
|
|
||||||
async function cargarLista(pag = 1) {
|
async function cargarLista(pag = 1) {
|
||||||
paginaActual = pag;
|
paginaActual = pag;
|
||||||
const busq = document.getElementById('buscador').value.trim();
|
const busq = document.getElementById('buscador').value.trim();
|
||||||
const r = await fetch(`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(busq)}&page=${pag}&limit=25`);
|
const origen = document.getElementById('filtro-origen').value;
|
||||||
|
const r = await fetch(
|
||||||
|
`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(busq)}&page=${pag}&limit=25&origen=${encodeURIComponent(origen)}`
|
||||||
|
);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
|
|
||||||
const tbody = document.getElementById('tabla-body');
|
const tbody = document.getElementById('tabla-body');
|
||||||
if (!d.data?.length) {
|
if (!d.data?.length) {
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-muted">Sin resultados</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="10" class="text-center py-4 text-muted">Sin resultados</td></tr>';
|
||||||
document.getElementById('paginacion').innerHTML = '';
|
document.getElementById('paginacion').innerHTML = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -385,11 +492,15 @@ async function cargarLista(pag = 1) {
|
|||||||
<tr style="cursor:pointer" onclick="verDetalle(${p.id})">
|
<tr style="cursor:pointer" onclick="verDetalle(${p.id})">
|
||||||
<td class="ps-3">
|
<td class="ps-3">
|
||||||
<div class="fw-semibold">${esc(p.nombre_completo)}</div>
|
<div class="fw-semibold">${esc(p.nombre_completo)}</div>
|
||||||
${p.phone_number ? `<small class="text-muted"><i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}</small>` : ''}
|
${p.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''}
|
||||||
</td>
|
</td>
|
||||||
<td class="small">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</td>
|
<td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></td>
|
||||||
<td class="small">${esc(p.telefono||'—')}</td>
|
<td class="small">${esc(p.telefono||'—')}${p.phone_number ? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>` : ''}</td>
|
||||||
<td class="small">${esc(p.eps||'—')}</td>
|
<td class="small text-muted">${esc(p.email||'—')}</td>
|
||||||
|
<td class="small text-muted">${esc(p.ciudad||'—')}</td>
|
||||||
|
<td class="small text-muted">${esc(p.eps||'—')}</td>
|
||||||
|
<td>${origenBadge(p.origen)}</td>
|
||||||
|
<td class="small text-muted">${fmtFecha(p.created_at)}</td>
|
||||||
<td><span class="badge bg-primary hist-badge">${p.total_ordenes||0}</span></td>
|
<td><span class="badge bg-primary hist-badge">${p.total_ordenes||0}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm btn-outline-secondary py-1 px-2"
|
<button class="btn btn-sm btn-outline-secondary py-1 px-2"
|
||||||
@@ -417,7 +528,8 @@ async function cargarLista(pag = 1) {
|
|||||||
// ── Detalle ────────────────────────────────────────────────────────────────
|
// ── Detalle ────────────────────────────────────────────────────────────────
|
||||||
async function verDetalle(id) {
|
async function verDetalle(id) {
|
||||||
const panel = document.getElementById('detail-panel');
|
const panel = document.getElementById('detail-panel');
|
||||||
panel.style.display = 'block';
|
panel.classList.add('open');
|
||||||
|
document.getElementById('detail-backdrop').style.display = 'block';
|
||||||
document.getElementById('detail-body').innerHTML = '<div class="text-center py-4"><i class="fas fa-spinner fa-spin fa-lg text-muted"></i></div>';
|
document.getElementById('detail-body').innerHTML = '<div class="text-center py-4"><i class="fas fa-spinner fa-spin fa-lg text-muted"></i></div>';
|
||||||
|
|
||||||
const r = await fetch(`api/lab/get_pacientes.php?id=${id}`);
|
const r = await fetch(`api/lab/get_pacientes.php?id=${id}`);
|
||||||
@@ -436,6 +548,8 @@ async function verDetalle(id) {
|
|||||||
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
|
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
|
||||||
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd>
|
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd>
|
||||||
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd>
|
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd>
|
||||||
|
<dt class="col-5 text-muted">Origen</dt><dd class="col-7">${origenBadge(p.origen)}</dd>
|
||||||
|
<dt class="col-5 text-muted">Registro</dt><dd class="col-7 text-muted small">${fmtFecha(p.created_at)}</dd>
|
||||||
${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''}
|
${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''}
|
||||||
${p.fecha_nacimiento ? `<dt class="col-5 text-muted">Nacimiento</dt><dd class="col-7">${esc(p.fecha_nacimiento.slice(0,10))}</dd>` : ''}
|
${p.fecha_nacimiento ? `<dt class="col-5 text-muted">Nacimiento</dt><dd class="col-7">${esc(p.fecha_nacimiento.slice(0,10))}</dd>` : ''}
|
||||||
${p.genero ? `<dt class="col-5 text-muted">Género</dt><dd class="col-7">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</dd>` : ''}
|
${p.genero ? `<dt class="col-5 text-muted">Género</dt><dd class="col-7">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</dd>` : ''}
|
||||||
@@ -465,7 +579,8 @@ async function verDetalle(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cerrarDetalle() {
|
function cerrarDetalle() {
|
||||||
document.getElementById('detail-panel').style.display = 'none';
|
document.getElementById('detail-panel').classList.remove('open');
|
||||||
|
document.getElementById('detail-backdrop').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Formulario ─────────────────────────────────────────────────────────────
|
// ── Formulario ─────────────────────────────────────────────────────────────
|
||||||
@@ -628,6 +743,7 @@ function mostrarToast(msg, tipo = 'success') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cargarLista();
|
cargarLista();
|
||||||
|
cargarStats();
|
||||||
</script>
|
</script>
|
||||||
<script src="assets/js/lab-sidebar.js"></script>
|
<script src="assets/js/lab-sidebar.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -210,6 +210,56 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
MODAL — FIRMA DE USUARIO
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="modal fade" id="modalFirmaUsuario" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="fas fa-signature me-2"></i>Firma de <span id="firma-u-nombre"></span></h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="firma-u-id">
|
||||||
|
|
||||||
|
<!-- Firma actual -->
|
||||||
|
<div id="firma-u-actual" class="mb-3 d-none">
|
||||||
|
<label class="form-label small fw-semibold text-muted">Firma guardada</label>
|
||||||
|
<div class="border rounded p-2 text-center bg-light">
|
||||||
|
<img id="firma-u-img" src="" alt="Firma" style="max-height:80px;max-width:100%">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-danger btn-sm mt-2 w-100" onclick="firmaUsuario.borrar()">
|
||||||
|
<i class="fas fa-trash me-1"></i>Eliminar firma guardada
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Canvas para dibujar -->
|
||||||
|
<label class="form-label small fw-semibold">Dibujar nueva firma</label>
|
||||||
|
<div style="border:1px solid #dee2e6;border-radius:8px;background:#fff;touch-action:none">
|
||||||
|
<canvas id="firma-u-canvas" width="460" height="140" style="width:100%;height:140px;border-radius:8px;cursor:crosshair"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 mt-2">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="firmaUsuario.limpiarCanvas()">
|
||||||
|
<i class="fas fa-eraser me-1"></i>Limpiar
|
||||||
|
</button>
|
||||||
|
<div class="ms-auto">
|
||||||
|
<label class="form-label small fw-semibold mb-0 me-2">O subir imagen:</label>
|
||||||
|
<input type="file" id="firma-u-file" accept="image/*" class="form-control form-control-sm d-inline-block" style="width:auto" onchange="firmaUsuario.cargarImagen(this)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger mt-2 d-none" id="firma-u-error"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button class="btn btn-primary" onclick="firmaUsuario.guardar()">
|
||||||
|
<i class="fas fa-save me-1"></i>Guardar firma
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════════════════════════
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
MODAL — ROL
|
MODAL — ROL
|
||||||
══════════════════════════════════════════════════════════ -->
|
══════════════════════════════════════════════════════════ -->
|
||||||
@@ -460,6 +510,11 @@ const usuarios = {
|
|||||||
<td class="text-muted small">${lastLogin}</td>
|
<td class="text-muted small">${lastLogin}</td>
|
||||||
<td class="text-end table-actions">
|
<td class="text-end table-actions">
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="usuarios.editar(${u.id})"><i class="fas fa-edit"></i></button>
|
<button class="btn btn-sm btn-outline-primary" onclick="usuarios.editar(${u.id})"><i class="fas fa-edit"></i></button>
|
||||||
|
<button class="btn btn-sm ${u.tiene_firma ? 'btn-success' : 'btn-outline-secondary'} ms-1"
|
||||||
|
onclick="firmaUsuario.abrir(${u.id}, '${esc(u.full_name ?? u.username)}')"
|
||||||
|
title="${u.tiene_firma ? 'Ver / cambiar firma' : 'Subir firma'}">
|
||||||
|
<i class="fas fa-signature"></i>
|
||||||
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-danger ms-1" onclick="usuarios.eliminar(${u.id},'${esc(u.username)}')"><i class="fas fa-trash-alt"></i></button>
|
<button class="btn btn-sm btn-outline-danger ms-1" onclick="usuarios.eliminar(${u.id},'${esc(u.username)}')"><i class="fas fa-trash-alt"></i></button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
@@ -596,6 +651,142 @@ document.getElementById('r_slug').addEventListener('input', function() {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Gestión de firma por usuario ────────────────────────────
|
||||||
|
const firmaUsuario = (() => {
|
||||||
|
let _canvas, _ctx, _drawing = false;
|
||||||
|
|
||||||
|
function initCanvas() {
|
||||||
|
_canvas = document.getElementById('firma-u-canvas');
|
||||||
|
_ctx = _canvas.getContext('2d');
|
||||||
|
_ctx.strokeStyle = '#1e293b';
|
||||||
|
_ctx.lineWidth = 2.2;
|
||||||
|
_ctx.lineCap = 'round';
|
||||||
|
|
||||||
|
const pos = e => {
|
||||||
|
const r = _canvas.getBoundingClientRect();
|
||||||
|
const t = e.touches?.[0] ?? e;
|
||||||
|
return [(t.clientX - r.left) * (_canvas.width / r.width),
|
||||||
|
(t.clientY - r.top) * (_canvas.height / r.height)];
|
||||||
|
};
|
||||||
|
const start = e => { e.preventDefault(); _drawing = true; _ctx.beginPath(); _ctx.moveTo(...pos(e)); };
|
||||||
|
const move = e => { e.preventDefault(); if (!_drawing) return; _ctx.lineTo(...pos(e)); _ctx.stroke(); };
|
||||||
|
const stop = () => { _drawing = false; };
|
||||||
|
|
||||||
|
_canvas.addEventListener('mousedown', start);
|
||||||
|
_canvas.addEventListener('mousemove', move);
|
||||||
|
_canvas.addEventListener('mouseup', stop);
|
||||||
|
_canvas.addEventListener('mouseleave', stop);
|
||||||
|
_canvas.addEventListener('touchstart', start, { passive: false });
|
||||||
|
_canvas.addEventListener('touchmove', move, { passive: false });
|
||||||
|
_canvas.addEventListener('touchend', stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
abrir(userId, nombre) {
|
||||||
|
document.getElementById('firma-u-id').value = userId;
|
||||||
|
document.getElementById('firma-u-nombre').textContent = nombre;
|
||||||
|
document.getElementById('firma-u-error').classList.add('d-none');
|
||||||
|
document.getElementById('firma-u-file').value = '';
|
||||||
|
|
||||||
|
// Mostrar firma actual si existe
|
||||||
|
const u = (typeof allUsers !== 'undefined' ? allUsers : []).find(x => x.id === userId);
|
||||||
|
const actualEl = document.getElementById('firma-u-actual');
|
||||||
|
if (u?.tiene_firma) {
|
||||||
|
// Cargar imagen desde servidor
|
||||||
|
fetch(`api/lab/get_firma_usuario.php?user_id=${userId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(j => {
|
||||||
|
if (j.firma_svg) {
|
||||||
|
document.getElementById('firma-u-img').src = j.firma_svg;
|
||||||
|
actualEl.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
} else {
|
||||||
|
actualEl.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_canvas) initCanvas();
|
||||||
|
this.limpiarCanvas();
|
||||||
|
new bootstrap.Modal('#modalFirmaUsuario').show();
|
||||||
|
},
|
||||||
|
|
||||||
|
limpiarCanvas() {
|
||||||
|
if (_ctx) _ctx.clearRect(0, 0, _canvas.width, _canvas.height);
|
||||||
|
},
|
||||||
|
|
||||||
|
cargarImagen(input) {
|
||||||
|
const file = input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
if (!_canvas) initCanvas();
|
||||||
|
this.limpiarCanvas();
|
||||||
|
const scale = Math.min(_canvas.width / img.width, _canvas.height / img.height);
|
||||||
|
const w = img.width * scale, h = img.height * scale;
|
||||||
|
_ctx.drawImage(img, (_canvas.width - w) / 2, (_canvas.height - h) / 2, w, h);
|
||||||
|
};
|
||||||
|
img.src = e.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
},
|
||||||
|
|
||||||
|
async guardar() {
|
||||||
|
const userId = parseInt(document.getElementById('firma-u-id').value);
|
||||||
|
const errEl = document.getElementById('firma-u-error');
|
||||||
|
errEl.classList.add('d-none');
|
||||||
|
|
||||||
|
// Verificar que el canvas tenga algo dibujado
|
||||||
|
const blank = document.createElement('canvas');
|
||||||
|
blank.width = _canvas.width; blank.height = _canvas.height;
|
||||||
|
if (_canvas.toDataURL() === blank.toDataURL()) {
|
||||||
|
errEl.textContent = 'Dibuja o sube una firma primero.';
|
||||||
|
errEl.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const png = _canvas.toDataURL('image/png');
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/lab/save_firma_usuario.php', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user_id: userId, firma_svg: png })
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.ok) { errEl.textContent = json.error || 'Error'; errEl.classList.remove('d-none'); return; }
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('modalFirmaUsuario'))?.hide();
|
||||||
|
// Marcar tiene_firma en memoria local para actualizar el botón
|
||||||
|
if (typeof allUsers !== 'undefined') {
|
||||||
|
const u = allUsers.find(x => x.id === userId);
|
||||||
|
if (u) u.tiene_firma = true;
|
||||||
|
if (typeof usuarios !== 'undefined') usuarios.render();
|
||||||
|
}
|
||||||
|
} catch (e) { errEl.textContent = 'Error de conexión.'; errEl.classList.remove('d-none'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
async borrar() {
|
||||||
|
const userId = parseInt(document.getElementById('firma-u-id').value);
|
||||||
|
if (!confirm('¿Eliminar la firma guardada de este usuario?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/lab/save_firma_usuario.php', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user_id: userId, _borrar: true })
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.ok) return;
|
||||||
|
document.getElementById('firma-u-actual').classList.add('d-none');
|
||||||
|
if (typeof allUsers !== 'undefined') {
|
||||||
|
const u = allUsers.find(x => x.id === userId);
|
||||||
|
if (u) u.tiene_firma = false;
|
||||||
|
if (typeof usuarios !== 'undefined') usuarios.render();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<script src="assets/js/lab-sidebar.js"></script>
|
<script src="assets/js/lab-sidebar.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -41,6 +41,25 @@ if ($_POST && !$loginBlocked) {
|
|||||||
$_SESSION['login_ip'] = $clientIp;
|
$_SESSION['login_ip'] = $clientIp;
|
||||||
$_SESSION['login_time'] = time();
|
$_SESSION['login_time'] = time();
|
||||||
|
|
||||||
|
// Tablet fija: redirigir al lugar asignado por IP y bloquear el resto
|
||||||
|
try {
|
||||||
|
$_dispStmt = Database::getInstance()->getConnection()->prepare(
|
||||||
|
"SELECT td.lugar_id, td.nombre, tl.tipo
|
||||||
|
FROM turnero_dispositivos td
|
||||||
|
JOIN turnero_lugares tl ON tl.id = td.lugar_id
|
||||||
|
WHERE td.ip = ? AND td.activo = 1 LIMIT 1"
|
||||||
|
);
|
||||||
|
$_dispStmt->execute([$clientIp]);
|
||||||
|
$_disp = $_dispStmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if ($_disp) {
|
||||||
|
$_SESSION['turnero_dispositivo'] = $_disp;
|
||||||
|
$url = $_disp['tipo'] === 'recepcion'
|
||||||
|
? BASE_URL . 'erp.php?m=turnero&v=recepcion&desk_id=' . $_disp['lugar_id']
|
||||||
|
: BASE_URL . 'erp.php?m=turnero&v=lugar&lugar_id=' . $_disp['lugar_id'];
|
||||||
|
header('Location: ' . $url); exit;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $_) {}
|
||||||
|
|
||||||
$roleSlug = $adminUser['role'] ?? 'admin';
|
$roleSlug = $adminUser['role'] ?? 'admin';
|
||||||
$modules = $adminUser['modules'] ?? [];
|
$modules = $adminUser['modules'] ?? [];
|
||||||
|
|
||||||
@@ -51,6 +70,22 @@ if ($_POST && !$loginBlocked) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Usuario con lugar fijo asignado (sin IP registrada)
|
||||||
|
$_userLugarId = (int)($adminUser['turnero_lugar_id'] ?? 0);
|
||||||
|
if ($_userLugarId && in_array('turnero', $modules, true)) {
|
||||||
|
// Determinar si ese lugar es recepción o toma de muestras
|
||||||
|
try {
|
||||||
|
$_lugarTipo = Database::getInstance()->getConnection()
|
||||||
|
->prepare("SELECT tipo FROM turnero_lugares WHERE id = ? LIMIT 1");
|
||||||
|
$_lugarTipo->execute([$_userLugarId]);
|
||||||
|
$_tipo = $_lugarTipo->fetchColumn() ?: 'muestras';
|
||||||
|
} catch (\Throwable $_) { $_tipo = 'muestras'; }
|
||||||
|
$url = $_tipo === 'recepcion'
|
||||||
|
? BASE_URL . 'erp.php?m=turnero&v=recepcion&desk_id=' . $_userLugarId
|
||||||
|
: BASE_URL . 'erp.php?m=turnero&v=lugar&lugar_id=' . $_userLugarId;
|
||||||
|
header('Location: ' . $url); exit;
|
||||||
|
}
|
||||||
|
|
||||||
if ($roleSlug === 'enfermero') {
|
if ($roleSlug === 'enfermero') {
|
||||||
header('Location: enfermero_portal.php');
|
header('Location: enfermero_portal.php');
|
||||||
} elseif (in_array('turnero', $modules, true)) {
|
} elseif (in_array('turnero', $modules, true)) {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/config/config.php';
|
||||||
|
if (!defined('MIGRATION_TOKEN') || ($_GET['token'] ?? '') !== MIGRATION_TOKEN) {
|
||||||
|
http_response_code(403); die('Acceso denegado');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
|
||||||
|
$steps = [
|
||||||
|
"ALTER TABLE lab_pacientes ADD COLUMN IF NOT EXISTS origen VARCHAR(20) NOT NULL DEFAULT 'manual' COMMENT 'Origen: manual | lab | whatsapp' AFTER notas_admin",
|
||||||
|
"UPDATE lab_pacientes SET origen = 'whatsapp' WHERE user_id IS NOT NULL AND origen = 'manual'",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_pac_origen ON lab_pacientes (origen)",
|
||||||
|
];
|
||||||
|
|
||||||
|
echo '<pre>';
|
||||||
|
foreach ($steps as $sql) {
|
||||||
|
echo htmlspecialchars(substr($sql, 0, 80)) . "...\n";
|
||||||
|
try {
|
||||||
|
$pdo->exec($sql);
|
||||||
|
echo " ✓ OK\n\n";
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo " ✗ ERROR: " . htmlspecialchars($e->getMessage()) . "\n\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "Listo.\n</pre>";
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Agrega número de orden visible a domicilios (formato D-YYYYMMDD-NNN)
|
||||||
|
ALTER TABLE lab_domicilios
|
||||||
|
ADD COLUMN numero_orden VARCHAR(20) NULL DEFAULT NULL
|
||||||
|
COMMENT 'Número de orden del domicilio. Formato D-YYYYMMDD-NNN'
|
||||||
|
AFTER paciente_id;
|
||||||
|
|
||||||
|
CREATE INDEX idx_lab_domicilios_numero_orden ON lab_domicilios (numero_orden);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- LIS 01 — Catálogos base
|
||||||
|
-- Crea: lab_secciones, lab_especialidades, lab_tipos_muestra
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_secciones (
|
||||||
|
codigo VARCHAR(10) NOT NULL,
|
||||||
|
nombre VARCHAR(100) NOT NULL,
|
||||||
|
PRIMARY KEY (codigo)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Secciones del laboratorio (Hematología, Química, etc.)';
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_especialidades (
|
||||||
|
codigo VARCHAR(10) NOT NULL,
|
||||||
|
nombre VARCHAR(100) NOT NULL,
|
||||||
|
PRIMARY KEY (codigo)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Especialidades médicas — replica ESPECIALIDAD Firebird';
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_tipos_muestra (
|
||||||
|
codigo VARCHAR(20) NOT NULL,
|
||||||
|
nombre VARCHAR(100) NOT NULL,
|
||||||
|
color_hex VARCHAR(7) DEFAULT NULL COMMENT 'Color del tubo para UI (#RRGGBB)',
|
||||||
|
requiere_ayuno TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
activo TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (codigo)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Tipos de muestra derivados de EXAMEN.TIPOMUESTRA Firebird';
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- LIS 02 — Protocolos e ítems de resultado (catálogos)
|
||||||
|
-- Crea: lab_protocolos, lab_items_resultado
|
||||||
|
-- Depende de: lab_secciones (LIS 01)
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_protocolos (
|
||||||
|
codigo VARCHAR(20) NOT NULL,
|
||||||
|
nombre VARCHAR(150) NOT NULL,
|
||||||
|
cod_seccion VARCHAR(10) DEFAULT NULL,
|
||||||
|
id_planilla VARCHAR(30) DEFAULT NULL COMMENT 'Identificador de plantilla de impresión',
|
||||||
|
only_show_items TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (codigo),
|
||||||
|
KEY idx_seccion (cod_seccion),
|
||||||
|
CONSTRAINT fk_proto_seccion
|
||||||
|
FOREIGN KEY (cod_seccion) REFERENCES lab_secciones (codigo)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Plantillas de resultado — replica PROTOCOLO Firebird';
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_items_resultado (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
cod_protocolo VARCHAR(20) NOT NULL,
|
||||||
|
nombre VARCHAR(150) NOT NULL,
|
||||||
|
tipo_sexo ENUM('M','F') DEFAULT NULL COMMENT 'NULL = aplica a ambos sexos',
|
||||||
|
tipo ENUM('N','T') DEFAULT NULL COMMENT 'N=numérico T=texto',
|
||||||
|
medida VARCHAR(30) DEFAULT NULL,
|
||||||
|
abreviatura VARCHAR(30) DEFAULT NULL,
|
||||||
|
vmin_ref DECIMAL(12,4) DEFAULT NULL,
|
||||||
|
vmax_ref DECIMAL(12,4) DEFAULT NULL,
|
||||||
|
orden SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
formula VARCHAR(500) DEFAULT NULL COMMENT 'Fórmula de cálculo automático',
|
||||||
|
cups_detalle VARCHAR(20) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_protocolo (cod_protocolo),
|
||||||
|
KEY idx_orden (cod_protocolo, orden),
|
||||||
|
CONSTRAINT fk_item_proto
|
||||||
|
FOREIGN KEY (cod_protocolo) REFERENCES lab_protocolos (codigo)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Analitos por protocolo — replica ITEM Firebird';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- LIS 03 — Perfiles de examen (paquetes)
|
||||||
|
-- Crea: lab_perfiles, lab_perfil_examenes
|
||||||
|
-- Depende de: exam_tipos (migración 003)
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_perfiles (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
nombre VARCHAR(150) NOT NULL,
|
||||||
|
activo TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Paquetes de exámenes — replica PERFIL Firebird';
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_perfil_examenes (
|
||||||
|
perfil_id INT UNSIGNED NOT NULL,
|
||||||
|
exam_tipo_id INT UNSIGNED NOT NULL,
|
||||||
|
PRIMARY KEY (perfil_id, exam_tipo_id),
|
||||||
|
CONSTRAINT fk_pe_perfil FOREIGN KEY (perfil_id)
|
||||||
|
REFERENCES lab_perfiles (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_pe_exam FOREIGN KEY (exam_tipo_id)
|
||||||
|
REFERENCES exam_tipos (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Exámenes por perfil — replica PERFIL_EXA Firebird';
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- LIS 04 — Ampliar exam_tipos y lab_pacientes con campos legacy
|
||||||
|
-- Depende de: lab_tipos_muestra (LIS 01), lab_protocolos (LIS 02)
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- exam_tipos: agregar campos del EXAMEN Firebird
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
ALTER TABLE exam_tipos
|
||||||
|
ADD COLUMN IF NOT EXISTS codigo_legacy VARCHAR(20) DEFAULT NULL
|
||||||
|
COMMENT 'Código original Firebird (CODIGO). Usado para ETL y mapeos.',
|
||||||
|
ADD COLUMN IF NOT EXISTS cups VARCHAR(20) DEFAULT NULL
|
||||||
|
COMMENT 'Código CUPS colombiano',
|
||||||
|
ADD COLUMN IF NOT EXISTS cod_protocolo VARCHAR(20) DEFAULT NULL
|
||||||
|
COMMENT 'FK lab_protocolos.codigo',
|
||||||
|
ADD COLUMN IF NOT EXISTS tipo_muestra VARCHAR(20) DEFAULT NULL
|
||||||
|
COMMENT 'FK lab_tipos_muestra.codigo',
|
||||||
|
ADD COLUMN IF NOT EXISTS nivel TINYINT DEFAULT NULL
|
||||||
|
COMMENT 'Nivel de complejidad (1/2/3)',
|
||||||
|
ADD COLUMN IF NOT EXISTS abreviatura VARCHAR(30) DEFAULT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS seremite TINYINT(1) NOT NULL DEFAULT 0
|
||||||
|
COMMENT '1 = se remite a laboratorio externo',
|
||||||
|
ADD COLUMN IF NOT EXISTS serecibe VARCHAR(50) DEFAULT NULL
|
||||||
|
COMMENT 'Nombre del laboratorio donde se recibe si seremite=1';
|
||||||
|
|
||||||
|
-- Índice único para buscar por código legacy durante el ETL
|
||||||
|
ALTER TABLE exam_tipos
|
||||||
|
ADD UNIQUE KEY IF NOT EXISTS uq_codigo_legacy (codigo_legacy);
|
||||||
|
|
||||||
|
-- FK suave (no FK real para facilitar carga masiva del ETL)
|
||||||
|
ALTER TABLE exam_tipos
|
||||||
|
ADD KEY IF NOT EXISTS idx_cod_protocolo (cod_protocolo),
|
||||||
|
ADD KEY IF NOT EXISTS idx_tipo_muestra (tipo_muestra);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- lab_pacientes: agregar campos del PACIENTE Firebird
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
ALTER TABLE lab_pacientes
|
||||||
|
ADD COLUMN IF NOT EXISTS codigo_legacy VARCHAR(20) DEFAULT NULL
|
||||||
|
COMMENT 'CODPAC Firebird',
|
||||||
|
ADD COLUMN IF NOT EXISTS codetnia VARCHAR(10) DEFAULT NULL
|
||||||
|
COMMENT 'Código de etnia (RIPS/SISPRO)',
|
||||||
|
ADD COLUMN IF NOT EXISTS tipores VARCHAR(10) DEFAULT NULL
|
||||||
|
COMMENT 'Tipo de residencia (RIPS/SISPRO)',
|
||||||
|
ADD COLUMN IF NOT EXISTS ocupacion VARCHAR(100) DEFAULT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS es_historico TINYINT(1) NOT NULL DEFAULT 0
|
||||||
|
COMMENT '1 = migrado solo de Firebird, sin cuenta en nuevo sistema';
|
||||||
|
|
||||||
|
ALTER TABLE lab_pacientes
|
||||||
|
ADD KEY IF NOT EXISTS idx_codigo_legacy (codigo_legacy);
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- LIS 05 — Motor de precios y convenios
|
||||||
|
-- Crea: lab_tarifas_id, lab_tarifas, lab_empresas,
|
||||||
|
-- lab_empresa_subgrupos, lab_examenes_empresa
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- Catálogo de tarifas
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_tarifas_id (
|
||||||
|
id INT NOT NULL,
|
||||||
|
nombre VARCHAR(150) NOT NULL,
|
||||||
|
tarifa_origen INT DEFAULT NULL
|
||||||
|
COMMENT 'Si != NULL, esta tarifa = tarifa_origen * (1 + porcentaje/100)',
|
||||||
|
porcentaje DECIMAL(8,4) NOT NULL DEFAULT 0
|
||||||
|
COMMENT '0 = precios fijos, >0 = derivada porcentualmente',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_origen (tarifa_origen)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Catálogo de tarifas — replica TARIFAID Firebird';
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- Precios por examen y tarifa
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_tarifas (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
cod_examen_legacy VARCHAR(20) NOT NULL COMMENT 'CODIGO original Firebird para trazabilidad',
|
||||||
|
exam_tipo_id INT UNSIGNED DEFAULT NULL,
|
||||||
|
tarifa_id INT NOT NULL,
|
||||||
|
valor DECIMAL(12,2) NOT NULL,
|
||||||
|
recargo_urg DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
recargo_fes DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
recargo_esp DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_examen_tarifa (exam_tipo_id, tarifa_id),
|
||||||
|
KEY idx_legacy (cod_examen_legacy),
|
||||||
|
KEY idx_tarifa (tarifa_id),
|
||||||
|
CONSTRAINT fk_tar_tarifa FOREIGN KEY (tarifa_id)
|
||||||
|
REFERENCES lab_tarifas_id (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Precios por examen y tarifa — replica TARIFA Firebird';
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- Empresas / convenios
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_empresas (
|
||||||
|
nit VARCHAR(20) NOT NULL,
|
||||||
|
nombre VARCHAR(200) NOT NULL,
|
||||||
|
razon_social VARCHAR(200) DEFAULT NULL,
|
||||||
|
tarifa_id INT DEFAULT NULL,
|
||||||
|
descuento_pct DECIMAL(8,4) NOT NULL DEFAULT 0,
|
||||||
|
codigo_eps VARCHAR(20) DEFAULT NULL,
|
||||||
|
tipo_usuario VARCHAR(10) DEFAULT NULL COMMENT 'Tipo usuario para facturación',
|
||||||
|
tipo_usuario_sispro VARCHAR(10) DEFAULT NULL,
|
||||||
|
cod_contrato VARCHAR(50) DEFAULT NULL,
|
||||||
|
cod_tercero VARCHAR(50) DEFAULT NULL,
|
||||||
|
centro_costo VARCHAR(50) DEFAULT NULL,
|
||||||
|
req_autoriza TINYINT(1) NOT NULL DEFAULT 0
|
||||||
|
COMMENT '1 = exige número de autorización en recepción',
|
||||||
|
activa TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (nit),
|
||||||
|
KEY idx_tarifa (tarifa_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Empresas y convenios — replica EMPRESA Firebird';
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- Subgrupos de empresa
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_empresa_subgrupos (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
nit_empresa VARCHAR(20) NOT NULL,
|
||||||
|
subgrupo VARCHAR(100) NOT NULL,
|
||||||
|
tarifa_id INT DEFAULT NULL,
|
||||||
|
ref_subgrupo VARCHAR(50) DEFAULT NULL,
|
||||||
|
cod_contrato VARCHAR(50) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_empresa_sub (nit_empresa, subgrupo),
|
||||||
|
KEY idx_tarifa (tarifa_id),
|
||||||
|
CONSTRAINT fk_esub_empresa FOREIGN KEY (nit_empresa)
|
||||||
|
REFERENCES lab_empresas (nit) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Subgrupos por empresa — replica EMPRESA_SUB Firebird';
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- Códigos de examen alternos por empresa (para interfaces)
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_examenes_empresa (
|
||||||
|
nit_empresa VARCHAR(20) NOT NULL,
|
||||||
|
cod_examen_legacy VARCHAR(20) NOT NULL,
|
||||||
|
exam_tipo_id INT UNSIGNED DEFAULT NULL,
|
||||||
|
codigo_empresa VARCHAR(50) NOT NULL,
|
||||||
|
PRIMARY KEY (nit_empresa, cod_examen_legacy),
|
||||||
|
KEY idx_exam (exam_tipo_id),
|
||||||
|
CONSTRAINT fk_ee_empresa FOREIGN KEY (nit_empresa)
|
||||||
|
REFERENCES lab_empresas (nit) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Códigos alternos por empresa — replica EXAMEN_EMP Firebird';
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- LIS 06 — Histórico transaccional (solo lectura post-migración)
|
||||||
|
-- Crea: lab_recepciones, lab_relaciones, lab_pagos, lab_pagos_det
|
||||||
|
-- Depende de: lab_pacientes, medicos, lab_empresas
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- Recepciones (equivalente a facturas del legacy)
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_recepciones (
|
||||||
|
id INT NOT NULL COMMENT 'IDRECEPCION original Firebird',
|
||||||
|
cod_paciente_legacy VARCHAR(20) DEFAULT NULL,
|
||||||
|
paciente_id INT DEFAULT NULL,
|
||||||
|
cod_medico_legacy VARCHAR(20) DEFAULT NULL,
|
||||||
|
medico_id INT UNSIGNED DEFAULT NULL,
|
||||||
|
nit_empresa VARCHAR(20) DEFAULT NULL,
|
||||||
|
subgrupo VARCHAR(100) DEFAULT NULL,
|
||||||
|
fecha_recepcion DATE NOT NULL,
|
||||||
|
hora_inicio TIME DEFAULT NULL,
|
||||||
|
prefijo VARCHAR(5) DEFAULT NULL,
|
||||||
|
num_factura INT DEFAULT NULL,
|
||||||
|
valor_total DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
valor_desc DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
diag_ppal VARCHAR(10) DEFAULT NULL COMMENT 'Código diagnóstico CIE-10',
|
||||||
|
tipo_usuario VARCHAR(10) DEFAULT NULL,
|
||||||
|
autorizacion VARCHAR(50) DEFAULT NULL,
|
||||||
|
usuario VARCHAR(50) DEFAULT NULL COMMENT 'Login del operador en Firebird',
|
||||||
|
es_historico TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
migrado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_paciente (paciente_id),
|
||||||
|
KEY idx_fecha (fecha_recepcion),
|
||||||
|
KEY idx_empresa (nit_empresa),
|
||||||
|
KEY idx_factura (prefijo, num_factura)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Recepciones históricas — solo lectura, migrado de Firebird';
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- Exámenes por recepción
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_relaciones (
|
||||||
|
recepcion_id INT NOT NULL,
|
||||||
|
cod_examen_legacy VARCHAR(20) NOT NULL,
|
||||||
|
exam_tipo_id INT UNSIGNED DEFAULT NULL,
|
||||||
|
precio DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
fecha_reportado DATE DEFAULT NULL,
|
||||||
|
reportado TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
reportado_por VARCHAR(50) DEFAULT NULL,
|
||||||
|
validado TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
usuario_valida VARCHAR(50) DEFAULT NULL,
|
||||||
|
fecha_valida DATE DEFAULT NULL,
|
||||||
|
PRIMARY KEY (recepcion_id, cod_examen_legacy),
|
||||||
|
KEY idx_exam_tipo (exam_tipo_id),
|
||||||
|
CONSTRAINT fk_rel_recepcion FOREIGN KEY (recepcion_id)
|
||||||
|
REFERENCES lab_recepciones (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Exámenes por recepción histórica — replica RELACION Firebird';
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- Pagos
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_pagos (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
numcaja_legacy INT NOT NULL UNIQUE COMMENT 'NUMCAJA original Firebird',
|
||||||
|
recepcion_id INT NOT NULL,
|
||||||
|
valor DECIMAL(12,2) NOT NULL,
|
||||||
|
fecha DATE NOT NULL,
|
||||||
|
usuario VARCHAR(50) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_recepcion (recepcion_id),
|
||||||
|
CONSTRAINT fk_pago_recep FOREIGN KEY (recepcion_id)
|
||||||
|
REFERENCES lab_recepciones (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Pagos históricos — replica PAGOS Firebird';
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- Detalle de formas de pago por transacción
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_pagos_det (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
pago_id INT UNSIGNED NOT NULL,
|
||||||
|
tipo_pago VARCHAR(30) NOT NULL COMMENT 'efectivo, cheque, tarjeta, etc.',
|
||||||
|
valor DECIMAL(12,2) NOT NULL,
|
||||||
|
num_doc VARCHAR(50) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_pago (pago_id),
|
||||||
|
CONSTRAINT fk_pagdet_pago FOREIGN KEY (pago_id)
|
||||||
|
REFERENCES lab_pagos (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Detalle de formas de pago — replica PAGOS_DET Firebird';
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- LIS 07 — Tracking de muestras por turnero (solicitud)
|
||||||
|
-- Crea: turnero_muestras
|
||||||
|
-- Depende de: turnero_solicitudes (003), lab_tipos_muestra (LIS 01)
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS turnero_muestras (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
solicitud_id INT UNSIGNED NOT NULL
|
||||||
|
COMMENT 'FK turnero_solicitudes.id',
|
||||||
|
tipo_muestra VARCHAR(20) NOT NULL
|
||||||
|
COMMENT 'FK lab_tipos_muestra.codigo (ej: SANGRE_VENOSA, ORINA)',
|
||||||
|
estado ENUM('pendiente','recibida','rechazada')
|
||||||
|
NOT NULL DEFAULT 'pendiente',
|
||||||
|
motivo_rechazo VARCHAR(200) DEFAULT NULL
|
||||||
|
COMMENT 'Razón de rechazo (hemólisis, coagulado, volumen insuficiente…)',
|
||||||
|
recibida_por INT DEFAULT NULL
|
||||||
|
COMMENT 'FK admin_users.id — quién marcó recibida',
|
||||||
|
recibida_at DATETIME DEFAULT NULL,
|
||||||
|
creado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_solicitud_tipo (solicitud_id, tipo_muestra),
|
||||||
|
KEY idx_estado (estado),
|
||||||
|
KEY idx_solicitud (solicitud_id),
|
||||||
|
CONSTRAINT fk_tm_solicitud FOREIGN KEY (solicitud_id)
|
||||||
|
REFERENCES turnero_solicitudes (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Estado de muestras físicas por turno — solo lugar toma de muestras';
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- LIS 08 — Vistas del sistema
|
||||||
|
-- Todas usan CREATE OR REPLACE para poder re-ejecutar sin error.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- v_muestras_hoy
|
||||||
|
-- Muestras del día en el turnero (estado en tiempo real).
|
||||||
|
-- Usada por: dashboard, widget lugar.php
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE OR REPLACE VIEW v_muestras_hoy AS
|
||||||
|
SELECT
|
||||||
|
tm.id,
|
||||||
|
tm.solicitud_id,
|
||||||
|
tm.tipo_muestra,
|
||||||
|
COALESCE(lt.nombre, tm.tipo_muestra) AS tipo_muestra_label,
|
||||||
|
lt.color_hex AS tipo_muestra_color,
|
||||||
|
tm.estado,
|
||||||
|
tm.motivo_rechazo,
|
||||||
|
tm.recibida_at,
|
||||||
|
ts.turno_id,
|
||||||
|
tt.codigo AS turno_codigo,
|
||||||
|
ts.lugar_id,
|
||||||
|
tl.nombre AS lugar_nombre,
|
||||||
|
ts.paciente_id,
|
||||||
|
lp.nombre_completo AS paciente_nombre,
|
||||||
|
lp.numero_documento AS paciente_documento,
|
||||||
|
DATE(tt.creado_at) AS fecha
|
||||||
|
FROM turnero_muestras tm
|
||||||
|
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
|
||||||
|
JOIN turnero_turnos tt ON tt.id = ts.turno_id
|
||||||
|
JOIN turnero_lugares tl ON tl.id = ts.lugar_id
|
||||||
|
LEFT JOIN lab_pacientes lp ON lp.id = ts.paciente_id
|
||||||
|
LEFT JOIN lab_tipos_muestra lt ON lt.codigo = tm.tipo_muestra;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- v_muestras_pendientes_hoy
|
||||||
|
-- Solo las que faltan recibir hoy.
|
||||||
|
-- Usada por: contador en dashboard, alerta visual
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE OR REPLACE VIEW v_muestras_pendientes_hoy AS
|
||||||
|
SELECT *
|
||||||
|
FROM v_muestras_hoy
|
||||||
|
WHERE estado = 'pendiente'
|
||||||
|
AND fecha = CURDATE();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- v_recepcion_completa
|
||||||
|
-- Histórico Firebird con todas las FK resueltas.
|
||||||
|
-- Usada por: módulo de consulta histórica (solo lectura)
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE OR REPLACE VIEW v_recepcion_completa AS
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.fecha_recepcion,
|
||||||
|
r.hora_inicio,
|
||||||
|
r.prefijo,
|
||||||
|
r.num_factura,
|
||||||
|
CONCAT(COALESCE(r.prefijo,''), '-',
|
||||||
|
LPAD(COALESCE(r.num_factura, 0), 6, '0')) AS factura,
|
||||||
|
r.valor_total,
|
||||||
|
r.valor_desc,
|
||||||
|
(r.valor_total - r.valor_desc) AS valor_neto,
|
||||||
|
r.paciente_id,
|
||||||
|
r.cod_paciente_legacy,
|
||||||
|
COALESCE(lp.nombre_completo, r.cod_paciente_legacy) AS paciente_nombre,
|
||||||
|
lp.numero_documento AS paciente_documento,
|
||||||
|
lp.tipo_documento AS paciente_tipo_doc,
|
||||||
|
r.medico_id,
|
||||||
|
r.cod_medico_legacy,
|
||||||
|
CONCAT(COALESCE(m.nombres,''), ' ', COALESCE(m.apellidos,'')) AS medico_nombre,
|
||||||
|
m.cod_especialidad AS medico_especialidad,
|
||||||
|
r.nit_empresa,
|
||||||
|
COALESCE(e.nombre, r.nit_empresa) AS empresa_nombre,
|
||||||
|
r.subgrupo,
|
||||||
|
r.diag_ppal,
|
||||||
|
r.tipo_usuario,
|
||||||
|
r.autorizacion,
|
||||||
|
r.usuario
|
||||||
|
FROM lab_recepciones r
|
||||||
|
LEFT JOIN lab_pacientes lp ON lp.id = r.paciente_id
|
||||||
|
LEFT JOIN medicos m ON m.id = r.medico_id
|
||||||
|
LEFT JOIN lab_empresas e ON e.nit = r.nit_empresa;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- v_relacion_completa
|
||||||
|
-- Exámenes por recepción con nombre resuelto.
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE OR REPLACE VIEW v_relacion_completa AS
|
||||||
|
SELECT
|
||||||
|
lr.recepcion_id,
|
||||||
|
r.fecha_recepcion,
|
||||||
|
r.prefijo,
|
||||||
|
r.num_factura,
|
||||||
|
lr.cod_examen_legacy,
|
||||||
|
lr.exam_tipo_id,
|
||||||
|
et.nombre AS examen_nombre,
|
||||||
|
et.categoria AS examen_categoria,
|
||||||
|
et.tipo_muestra AS tipo_muestra,
|
||||||
|
lr.precio,
|
||||||
|
lr.reportado,
|
||||||
|
lr.fecha_reportado,
|
||||||
|
lr.reportado_por,
|
||||||
|
lr.validado,
|
||||||
|
lr.usuario_valida,
|
||||||
|
lr.fecha_valida
|
||||||
|
FROM lab_relaciones lr
|
||||||
|
JOIN lab_recepciones r ON r.id = lr.recepcion_id
|
||||||
|
LEFT JOIN exam_tipos et ON et.id = lr.exam_tipo_id;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- v_examen_precio
|
||||||
|
-- Precio efectivo de cada examen en cada tarifa.
|
||||||
|
-- Resuelve tarifas derivadas por porcentaje.
|
||||||
|
-- Usada por: motor de precios en nueva recepción
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE OR REPLACE VIEW v_examen_precio AS
|
||||||
|
SELECT
|
||||||
|
et.id AS exam_tipo_id,
|
||||||
|
et.codigo,
|
||||||
|
et.codigo_legacy,
|
||||||
|
et.nombre AS examen_nombre,
|
||||||
|
et.categoria,
|
||||||
|
et.tipo_muestra,
|
||||||
|
ti.id AS tarifa_id,
|
||||||
|
ti.nombre AS tarifa_nombre,
|
||||||
|
ti.porcentaje,
|
||||||
|
ti.tarifa_origen,
|
||||||
|
lt.valor AS valor_almacenado,
|
||||||
|
CASE
|
||||||
|
WHEN ti.porcentaje > 0 AND ti.tarifa_origen IS NOT NULL
|
||||||
|
AND lt_base.valor IS NOT NULL
|
||||||
|
THEN ROUND(lt_base.valor * (1 + ti.porcentaje / 100), 0)
|
||||||
|
ELSE lt.valor
|
||||||
|
END AS valor_efectivo,
|
||||||
|
lt.recargo_urg,
|
||||||
|
lt.recargo_fes,
|
||||||
|
lt.recargo_esp
|
||||||
|
FROM exam_tipos et
|
||||||
|
JOIN lab_tarifas lt ON lt.exam_tipo_id = et.id
|
||||||
|
JOIN lab_tarifas_id ti ON ti.id = lt.tarifa_id
|
||||||
|
LEFT JOIN lab_tarifas lt_base ON lt_base.exam_tipo_id = et.id
|
||||||
|
AND lt_base.tarifa_id = ti.tarifa_origen;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- v_paciente_resumen
|
||||||
|
-- Vista unificada: pacientes del nuevo sistema + migrados.
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE OR REPLACE VIEW v_paciente_resumen AS
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
nombre_completo,
|
||||||
|
tipo_documento,
|
||||||
|
numero_documento,
|
||||||
|
telefono,
|
||||||
|
email,
|
||||||
|
fecha_nacimiento,
|
||||||
|
genero,
|
||||||
|
ciudad,
|
||||||
|
eps,
|
||||||
|
es_historico,
|
||||||
|
codigo_legacy,
|
||||||
|
created_at
|
||||||
|
FROM lab_pacientes
|
||||||
|
WHERE is_active = 1;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
-- v_turno_muestras_estado
|
||||||
|
-- Estado agregado de muestras por turno (para cola y dashboard).
|
||||||
|
-- -----------------------------------------------------------
|
||||||
|
CREATE OR REPLACE VIEW v_turno_muestras_estado AS
|
||||||
|
SELECT
|
||||||
|
ts.turno_id,
|
||||||
|
COUNT(*) AS total_muestras,
|
||||||
|
SUM(tm.estado = 'pendiente') AS pendientes,
|
||||||
|
SUM(tm.estado = 'recibida') AS recibidas,
|
||||||
|
SUM(tm.estado = 'rechazada') AS rechazadas,
|
||||||
|
CASE
|
||||||
|
WHEN SUM(tm.estado = 'pendiente') = 0 THEN 'completo'
|
||||||
|
WHEN SUM(tm.estado = 'recibida') = 0 AND SUM(tm.estado = 'rechazada') = 0
|
||||||
|
THEN 'sin_recibir'
|
||||||
|
ELSE 'parcial'
|
||||||
|
END AS estado_global
|
||||||
|
FROM turnero_muestras tm
|
||||||
|
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
|
||||||
|
GROUP BY ts.turno_id;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- LIS 09 — Registra módulo lab_empresas en system_modules
|
||||||
|
-- y agrega permiso por defecto para el rol admin.
|
||||||
|
-- Seguro para re-ejecutar (INSERT IGNORE).
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
INSERT IGNORE INTO system_modules
|
||||||
|
(slug, name, icon, category, route, is_active, sort_order, oleada, description)
|
||||||
|
VALUES
|
||||||
|
('lab_empresas', 'Empresas y Convenios', 'fas fa-building', 'clinico',
|
||||||
|
'/erp.php?m=lab_empresas&v=index', 1, 65, 2,
|
||||||
|
'Gestión de empresas, EPS, convenios, subgrupos y catálogo de tarifas');
|
||||||
|
|
||||||
|
-- Permiso automático para rol admin (role_id = 1)
|
||||||
|
INSERT IGNORE INTO role_modules (role_id, module_slug)
|
||||||
|
SELECT 1, 'lab_empresas'
|
||||||
|
WHERE EXISTS (SELECT 1 FROM roles WHERE id = 1);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- LIS 10 — Extiende turnero_solicitudes con campos de empresa/convenio
|
||||||
|
-- Todos DEFAULT NULL para no romper datos existentes.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
ALTER TABLE turnero_solicitudes
|
||||||
|
ADD COLUMN IF NOT EXISTS nit_empresa VARCHAR(20) DEFAULT NULL
|
||||||
|
COMMENT 'FK lab_empresas.nit — empresa/EPS del paciente',
|
||||||
|
ADD COLUMN IF NOT EXISTS subgrupo_id INT UNSIGNED DEFAULT NULL
|
||||||
|
COMMENT 'FK lab_empresa_subgrupos.id',
|
||||||
|
ADD COLUMN IF NOT EXISTS autorizacion VARCHAR(100) DEFAULT NULL
|
||||||
|
COMMENT 'Número de autorización EPS',
|
||||||
|
ADD COLUMN IF NOT EXISTS diag_ppal VARCHAR(20) DEFAULT NULL
|
||||||
|
COMMENT 'Diagnóstico principal CIE-10',
|
||||||
|
ADD COLUMN IF NOT EXISTS items_precio JSON DEFAULT NULL
|
||||||
|
COMMENT 'Snapshot de precios al momento de la recepción';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- LIS 11 — Habilita lab_recepciones para registros nuevos (no históricos)
|
||||||
|
--
|
||||||
|
-- El esquema original fue diseñado SOLO para histórico Firebird:
|
||||||
|
-- · id INT NOT NULL (no AUTO_INCREMENT) — el ID venía de Firebird
|
||||||
|
-- · lab_pagos.numcaja_legacy NOT NULL UNIQUE — también era de Firebird
|
||||||
|
--
|
||||||
|
-- Aquí los ajustamos para aceptar registros del nuevo sistema
|
||||||
|
-- sin tocar los datos históricos migrados.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- 1. Hacer id AUTO_INCREMENT (MySQL usará MAX(id)+1 como siguiente valor,
|
||||||
|
-- así los registros nuevos nunca colisionan con los históricos de Firebird).
|
||||||
|
ALTER TABLE lab_recepciones
|
||||||
|
MODIFY COLUMN id INT NOT NULL AUTO_INCREMENT;
|
||||||
|
|
||||||
|
-- 2. Enlace de vuelta a la solicitud del turnero
|
||||||
|
ALTER TABLE lab_recepciones
|
||||||
|
ADD COLUMN IF NOT EXISTS solicitud_id INT UNSIGNED DEFAULT NULL
|
||||||
|
COMMENT 'FK turnero_solicitudes.id — NULL para registros históricos Firebird';
|
||||||
|
|
||||||
|
-- 3. numcaja_legacy era NOT NULL UNIQUE (campo obligatorio en Firebird).
|
||||||
|
-- Los registros nuevos no tienen NUMCAJA → lo hacemos nullable.
|
||||||
|
ALTER TABLE lab_pagos
|
||||||
|
MODIFY COLUMN numcaja_legacy INT DEFAULT NULL;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE lab_domicilios
|
||||||
|
ADD COLUMN IF NOT EXISTS numero_orden VARCHAR(30) DEFAULT NULL
|
||||||
|
COMMENT 'Número de orden de domicilio (ej: D-2026-001)';
|
||||||
|
|
||||||
|
ALTER TABLE turnero_solicitudes
|
||||||
|
ADD COLUMN IF NOT EXISTS numero_orden VARCHAR(30) DEFAULT NULL
|
||||||
|
COMMENT 'Número de orden de la solicitud del turnero';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Migration: 20260707_admin_users_firma
|
||||||
|
-- Firma pre-guardada del profesional para firma en 1 clic
|
||||||
|
-- ============================================================
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
ADD COLUMN IF NOT EXISTS firma_svg MEDIUMTEXT DEFAULT NULL
|
||||||
|
COMMENT 'Firma pre-guardada del profesional (data URL PNG)';
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Migration: 20260707_formulario_vih_turnero
|
||||||
|
-- Clon de F-LAB-05 VIH para uso en turnero.
|
||||||
|
--
|
||||||
|
-- Problema del original (id=1):
|
||||||
|
-- · Esquema sin campos tipo "firma" ni "firma_profesional"
|
||||||
|
-- · Paciente firmaba por fallback global (canvas genérico al pie)
|
||||||
|
-- · Profesional NUNCA podía firmar (requiere $_soloFirmaPro=true
|
||||||
|
-- o campo firma_profesional en esquema — ninguno se cumplía)
|
||||||
|
-- · doc_color vacío, requiere_firma=0
|
||||||
|
--
|
||||||
|
-- Esta versión corrige el esquema agregando:
|
||||||
|
-- · Separador + campo texto para responsable (menores/incapaces)
|
||||||
|
-- · campo tipo="firma" → firma inline del paciente
|
||||||
|
-- · campo tipo="firma_profesional" → 1-clic para el profesional
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET @esquema_vih = '[
|
||||||
|
{
|
||||||
|
"id": "_8h4v4jg",
|
||||||
|
"tipo": "parrafo",
|
||||||
|
"contenido": "Autorización voluntaria para realizar la prueba presuntiva para VIH (Decreto 1543/97 del Ministerio de Protección Social por el cual se reglamentan los mecanismos de prevención, diagnóstico, manejo y reporte epidemiológico de la infección por VIH)",
|
||||||
|
"flujoLibre": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "_z7pv612",
|
||||||
|
"tipo": "fecha_hoy",
|
||||||
|
"label": "Fecha",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "_afsycwa",
|
||||||
|
"tipo": "parrafo",
|
||||||
|
"contenido": "Qué es el síndrome de inmunodeficiencia adquirida (SIDA)? \nEs una enfermedad producida por un virus conocido como el virus de inmunodeficiencia Humana (VIH), el cual infecta y destruye las células del sistema inmune, originando una falla progresiva y grave en los sistemas de defensa del organismo el cual queda expuesto a la infección y ciertos tipos de tumores. \n\n¿Cómo se adquiere la enfermedad? \nLa enfermedad se adquiere principalmente por contacto sexual con personas infectadas con el VIH. Por exposición a la sangre y a ciertos productos derivados de la misma contaminados con el virus. Además, durante el embarazo, las madres infectadas con el virus de inmunodeficiencia humana pueden transmitir la infección al feto a través de la placenta. \n\n¿Cómo se hace el diagnóstico de la infección? El diagnóstico se hace mediante una prueba de sangre que busca anticuerpos producidos por el organismo contra el virus. Existen dos clases de pruebas de laboratorio. Presuntivas, que pueden indicar una posible infección, y las pruebas confirmatorias, las cuales se hacen únicamente en caso de que la prueba presuntiva de positiva. \n\n¿Cuál es el procedimiento que el laboratorio debe realizar en el análisis de la prueba? El laboratorio procesa la muestra y en caso de un resultado presuntamente positivo o inconcluyente, realiza un segundo examen con otra muestra. En caso de que la segunda muestra también arroje un resultado presuntamente positivo, el resultado de la prueba se reporta como reactivo. En caso de que la segunda muestra no confirme los resultados de la primera muestra, las dos muestras se envían a un tercer laboratorio, con el fin de que sea procesada en él. Los resultados de este tercer laboratorio se toman como definitivos para decidir el reporte como Reactivo o Negativo. En todo caso, los costos por estas pruebas son asumidos por cuenta del laboratorio. \n\n¿Cómo se debe interpretar el resultado de la prueba? La prueba inicial, como ya se anotó, es apenas una prueba presuntiva, y por lo tanto, el hecho de salir reactiva no implica que usted tenga SIDA, o esté infectado por el virus. Este resultado debe ser confirmado mediante una prueba llamada Western Blot, con el fin de eliminar posibles falsos positivos de la prueba presuntiva. Para este segundo examen es importante tomar una nueva muestra y procesarla asumiendo usted los costos. Aún si esta segunda prueba confirma la presencia de anticuerpos para el VIH, esto no significa que usted tiene SIDA, pues existe un período de la enfermedad, controlable, en el cual los pacientes tienen anticuerpos contra el VIH, pero no tienen síntomas de la enfermedad, y pueden, inclusive, no desarrollar jamás la enfermedad. Lo que es muy urgente, es consultar con un médico para que se determine el estado de su enfermedad, y se inicie el tratamiento apropiado. \n\nEl resultado será entregado personalmente, previa identificación. ",
|
||||||
|
"flujoLibre": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "_wsqqoiu",
|
||||||
|
"tipo": "parrafo_inline",
|
||||||
|
"contenido": "Yo, {nombre_completo}, con N.º de identificación {numero_documento}, declaro que fui informado (a) sobre el examen de anticuerpos contra el VIH que me será practicado el día de hoy, he recibido asesoría, me han explicado en que consiste; el procedimiento y sus implicaciones en mi vida, y la confidencialidad con la que se manejara las información que he dado y que se obtendrá. YO COMPRENDO Y AUTORIZO LA REALIZACIÓN DE LA PRUEBA DE FORMA LIBRE Y ESPONTÁNEA, en el Laboratorio Clínico Ximena Caicedo Empresa Unipersonal."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "_vihsep1",
|
||||||
|
"tipo": "separador",
|
||||||
|
"label": "AUTORIZACIÓN Y FIRMA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "_vihresp",
|
||||||
|
"tipo": "texto",
|
||||||
|
"label": "En caso de menor o incapacitado: nombre y parentesco del responsable",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "_vihfpac",
|
||||||
|
"tipo": "firma",
|
||||||
|
"label": "Firma del paciente / responsable",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "_vihsep2",
|
||||||
|
"tipo": "separador",
|
||||||
|
"label": "USO EXCLUSIVO DEL PROFESIONAL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "_vihfpro",
|
||||||
|
"tipo": "firma_profesional",
|
||||||
|
"label": "Firma del profesional de salud"
|
||||||
|
}
|
||||||
|
]';
|
||||||
|
|
||||||
|
INSERT INTO lab_formularios (
|
||||||
|
nombre,
|
||||||
|
descripcion,
|
||||||
|
categoria,
|
||||||
|
esquema,
|
||||||
|
permite_firma,
|
||||||
|
requiere_firma,
|
||||||
|
doc_encabezado,
|
||||||
|
doc_subtitulo,
|
||||||
|
doc_color,
|
||||||
|
tipo,
|
||||||
|
is_principal,
|
||||||
|
is_active,
|
||||||
|
version,
|
||||||
|
creado_por
|
||||||
|
) VALUES (
|
||||||
|
'CONSENTIMIENTO INFORMADO VIH F-LAB-05 V.5',
|
||||||
|
'Autorización voluntaria para prueba presuntiva de VIH — uso turnero',
|
||||||
|
'consentimiento',
|
||||||
|
@esquema_vih,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
'XIMENA CAICEDO G. E.U',
|
||||||
|
'Laboratorio Hematológico',
|
||||||
|
'#a0a59c',
|
||||||
|
'consentimiento',
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
5,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Asignar a todos los puestos de toma de muestras activos
|
||||||
|
INSERT INTO turnero_lugar_consentimientos (lugar_id, formulario_id)
|
||||||
|
SELECT id, LAST_INSERT_ID()
|
||||||
|
FROM turnero_lugares
|
||||||
|
WHERE tipo = 'muestras' AND activo = 1;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Separar consentimientos por examen vs por lugar destino
|
||||||
|
-- Los creados desde turnero_lugar_consentimientos llevan origen_lugar_id = lugar_id de la estación
|
||||||
|
-- Los creados desde exam_tipo_consentimientos quedan con origen_lugar_id = NULL
|
||||||
|
ALTER TABLE turnero_consentimientos
|
||||||
|
ADD COLUMN IF NOT EXISTS origen_lugar_id INT NULL DEFAULT NULL;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Migration: 20260707_turnero_dispositivos
|
||||||
|
-- Mapeo IP fija → lugar para tablets del turnero.
|
||||||
|
-- Cada tablet solo puede acceder a su lugar asignado.
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS turnero_dispositivos (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ip VARCHAR(45) NOT NULL UNIQUE,
|
||||||
|
lugar_id INT NOT NULL,
|
||||||
|
nombre VARCHAR(100) NOT NULL,
|
||||||
|
activo TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
INDEX idx_ip (ip),
|
||||||
|
INDEX idx_lugar (lugar_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT INTO turnero_dispositivos (ip, lugar_id, nombre) VALUES
|
||||||
|
('192.168.21.61', 1, 'Toma de Muestras 1'),
|
||||||
|
('192.168.21.62', 2, 'Toma de Muestras 2'),
|
||||||
|
('192.168.21.63', 13, 'Toma de Muestras 3'),
|
||||||
|
('192.168.21.64', 14, 'Toma de Muestras 4'),
|
||||||
|
('192.168.21.65', 15, 'Toma de Muestras 5'),
|
||||||
|
('192.168.21.66', 16, 'Toma de Muestras 6'),
|
||||||
|
('192.168.21.67', 17, 'Toma de Muestras 7'),
|
||||||
|
('192.168.21.68', 18, 'Toma de Muestras 8'),
|
||||||
|
('192.168.0.221', 10, 'Recepción 1 - Vianny Ortega'),
|
||||||
|
('192.168.0.29', 11, 'Recepción 2 - Angel Wilches'),
|
||||||
|
('192.168.0.225', 19, 'Recepción 3 - Gina Gomez')
|
||||||
|
ON DUPLICATE KEY UPDATE lugar_id = VALUES(lugar_id), nombre = VALUES(nombre);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Restricción de usuario a un lugar específico del turnero.
|
||||||
|
-- NULL = acceso libre (admin, recepcionista, etc.)
|
||||||
|
-- Valor = solo puede operar en ese lugar (ej. bacteriólogo asignado a Toma 1)
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
ADD COLUMN IF NOT EXISTS turnero_lugar_id INT UNSIGNED DEFAULT NULL
|
||||||
|
COMMENT 'FK turnero_lugares.id — si != NULL el usuario solo opera en ese lugar';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Token único por navegador/dispositivo para restricción sin depender de IP pública.
|
||||||
|
-- NULL = dispositivo registrado solo por IP (compatibilidad hacia atrás).
|
||||||
|
ALTER TABLE turnero_dispositivos
|
||||||
|
ADD COLUMN IF NOT EXISTS token VARCHAR(64) DEFAULT NULL,
|
||||||
|
ADD UNIQUE KEY IF NOT EXISTS uq_dispositivo_token (token);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Migration: 20260709_lab_pacientes_origen
|
||||||
|
-- Agrega columna 'origen' para identificar pacientes importados desde RIPS
|
||||||
|
-- vs creados manualmente o desde WhatsApp
|
||||||
|
|
||||||
|
ALTER TABLE `lab_pacientes`
|
||||||
|
ADD COLUMN `origen` VARCHAR(20) NOT NULL DEFAULT 'manual'
|
||||||
|
COMMENT 'Origen del registro: manual | lab | whatsapp'
|
||||||
|
AFTER `notas_admin`;
|
||||||
|
|
||||||
|
-- Los pacientes con user_id ya vinculado se marcan como whatsapp
|
||||||
|
UPDATE `lab_pacientes` SET `origen` = 'whatsapp' WHERE `user_id` IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX `idx_origen` ON `lab_pacientes` (`origen`);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- lab_eps: catálogo de EPS / aseguradoras
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_eps (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
nombre VARCHAR(120) NOT NULL UNIQUE,
|
||||||
|
activa TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
orden SMALLINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO lab_eps (nombre)
|
||||||
|
SELECT DISTINCT TRIM(eps)
|
||||||
|
FROM lab_pacientes
|
||||||
|
WHERE eps IS NOT NULL AND TRIM(eps) != '';
|
||||||
|
|
||||||
|
-- lab_ciudades: catálogo de ciudades de pacientes
|
||||||
|
CREATE TABLE IF NOT EXISTS lab_ciudades (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
nombre VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
activa TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
orden SMALLINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO lab_ciudades (nombre)
|
||||||
|
SELECT DISTINCT TRIM(ciudad)
|
||||||
|
FROM lab_pacientes
|
||||||
|
WHERE ciudad IS NOT NULL AND TRIM(ciudad) != '';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE turnero_solicitudes
|
||||||
|
ADD COLUMN IF NOT EXISTS numero_recibo VARCHAR(40) NULL DEFAULT NULL AFTER metodo_pago;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Cache de exámenes enviados desde RIPS Manager junto con la ingesta de pacientes.
|
||||||
|
-- Se usa en get_examenes_rips.php para evitar el round-trip a RIPS cuando ya vienen precargados.
|
||||||
|
CREATE TABLE IF NOT EXISTS rips_examenes_pendientes (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
numero_documento VARCHAR(30) NOT NULL,
|
||||||
|
datos JSON NOT NULL,
|
||||||
|
recepcion_id INT DEFAULT NULL,
|
||||||
|
hora_recepcion VARCHAR(20) DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_doc (numero_documento),
|
||||||
|
INDEX idx_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Migración: tabla CIE-10 diagnósticos desde Firebird
|
||||||
|
CREATE TABLE IF NOT EXISTS cie10_diagnosticos (
|
||||||
|
cod_diag VARCHAR(10) NOT NULL,
|
||||||
|
concepto VARCHAR(500) NOT NULL,
|
||||||
|
PRIMARY KEY (cod_diag)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
config.local.php
|
||||||
|
etl_*.log
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Configuración de conexiones para el ETL Firebird → MySQL
|
||||||
|
*
|
||||||
|
* Antes de ejecutar:
|
||||||
|
* 1. Copiar este archivo como config.local.php (está en .gitignore)
|
||||||
|
* 2. Llenar los valores reales
|
||||||
|
* 3. Ejecutar: php run_etl.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
// ── Firebird (origen) ────────────────────────────────────────────────
|
||||||
|
'firebird' => [
|
||||||
|
// DSN para PDO: "firebird:dbname=HOST:RUTA_AL_FDB;charset=WIN1252"
|
||||||
|
// Si el FDB está en la misma máquina: "firebird:dbname=localhost:/opt/firebird/data/DBLAB.FDB"
|
||||||
|
// Si es una ruta Windows remota: "firebird:dbname=192.168.1.10:C:/datos/DBLAB_XIMENA_FB25.FDB"
|
||||||
|
'dsn' => 'firebird:dbname=localhost:/ruta/DBLAB_XIMENA_FB25.FDB;charset=WIN1252',
|
||||||
|
'user' => 'SYSDBA',
|
||||||
|
'password' => 'masterkey',
|
||||||
|
// Encoding declarado en el FDB (para convertir a UTF-8 durante la extracción)
|
||||||
|
'charset' => 'WIN1252',
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── MySQL (destino) ──────────────────────────────────────────────────
|
||||||
|
'mysql' => [
|
||||||
|
'host' => '127.0.0.1',
|
||||||
|
'port' => 3306,
|
||||||
|
'dbname' => 'whatsapp', // nombre de la BD del nuevo sistema
|
||||||
|
'user' => 'root',
|
||||||
|
'password' => '',
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Opciones de migración ────────────────────────────────────────────
|
||||||
|
'options' => [
|
||||||
|
'batch_size' => 500, // registros por INSERT batch
|
||||||
|
'dry_run' => false, // true = solo leer, no insertar
|
||||||
|
'skip_historico' => false, // true = saltar RECEPCION/RELACION/PAGOS
|
||||||
|
'log_file' => __DIR__ . '/etl_' . date('Ymd_His') . '.log',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Funciones auxiliares compartidas por el ETL
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte un string de WIN1252 a UTF-8.
|
||||||
|
* Si el valor ya es UTF-8 válido, lo devuelve sin cambios.
|
||||||
|
*/
|
||||||
|
function toUtf8(?string $val, string $srcEncoding = 'WIN1252'): ?string {
|
||||||
|
if ($val === null) return null;
|
||||||
|
if (mb_check_encoding($val, 'UTF-8')) return $val;
|
||||||
|
return iconv($srcEncoding, 'UTF-8//TRANSLIT//IGNORE', $val);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte un array completo de strings (resultado de Firebird) a UTF-8.
|
||||||
|
*/
|
||||||
|
function rowToUtf8(array $row, string $srcEncoding = 'WIN1252'): array {
|
||||||
|
foreach ($row as $k => $v) {
|
||||||
|
if (is_string($v)) {
|
||||||
|
$row[$k] = toUtf8($v, $srcEncoding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia un VARCHAR de Firebird: recorta espacios y convierte encoding.
|
||||||
|
*/
|
||||||
|
function cleanStr(?string $val, string $srcEncoding = 'WIN1252'): ?string {
|
||||||
|
if ($val === null) return null;
|
||||||
|
$val = trim(toUtf8($val, $srcEncoding) ?? '');
|
||||||
|
return $val === '' ? null : $val;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte fecha Firebird (puede ser DATE o string 'YYYY-MM-DD') a MySQL DATE.
|
||||||
|
* Devuelve null si la fecha es inválida o vacía.
|
||||||
|
*/
|
||||||
|
function fbDate($val): ?string {
|
||||||
|
if ($val === null) return null;
|
||||||
|
if ($val instanceof DateTime) return $val->format('Y-m-d');
|
||||||
|
$str = trim((string)$val);
|
||||||
|
if ($str === '' || $str === '0000-00-00') return null;
|
||||||
|
try {
|
||||||
|
return (new DateTime($str))->format('Y-m-d');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INSERT en batch.
|
||||||
|
* $pdo → conexión MySQL
|
||||||
|
* $table → nombre de la tabla
|
||||||
|
* $rows → array de arrays asociativos con los mismos keys
|
||||||
|
* $ignore → usa INSERT IGNORE para saltar duplicados
|
||||||
|
*/
|
||||||
|
function batchInsert(PDO $pdo, string $table, array $rows, bool $ignore = false): int {
|
||||||
|
if (empty($rows)) return 0;
|
||||||
|
|
||||||
|
$cols = array_keys($rows[0]);
|
||||||
|
$colList = implode(', ', array_map(fn($c) => "`$c`", $cols));
|
||||||
|
$phRow = '(' . implode(', ', array_fill(0, count($cols), '?')) . ')';
|
||||||
|
|
||||||
|
$inserted = 0;
|
||||||
|
foreach (array_chunk($rows, 500) as $chunk) {
|
||||||
|
$placeholders = implode(', ', array_fill(0, count($chunk), $phRow));
|
||||||
|
$keyword = $ignore ? 'INSERT IGNORE' : 'INSERT';
|
||||||
|
$sql = "$keyword INTO `$table` ($colList) VALUES $placeholders";
|
||||||
|
$flat = array_merge(...array_map('array_values', $chunk));
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($flat);
|
||||||
|
$inserted += $stmt->rowCount();
|
||||||
|
}
|
||||||
|
return $inserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escribe al log y a stdout simultáneamente. */
|
||||||
|
function etlLog(string $msg, $logFp = null): void {
|
||||||
|
$line = '[' . date('H:i:s') . '] ' . $msg . PHP_EOL;
|
||||||
|
echo $line;
|
||||||
|
if ($logFp) fwrite($logFp, $line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Devuelve conteo de filas de una tabla Firebird. */
|
||||||
|
function fbCount(PDO $fb, string $table): int {
|
||||||
|
return (int) $fb->query("SELECT COUNT(*) FROM $table")->fetchColumn();
|
||||||
|
}
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* ETL Firebird 2.5 → MySQL (utf8mb4)
|
||||||
|
* Sistema: DBLAB_XIMENA_FB25 → nuevo sistema WhatsApp-Lab
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* php run_etl.php [--dry-run] [--skip-historico] [--only=PASO]
|
||||||
|
*
|
||||||
|
* Pasos disponibles (--only):
|
||||||
|
* secciones | especialidades | tipos_muestra | protocolos | items |
|
||||||
|
* perfiles | exam_tipos | tarifas | empresas |
|
||||||
|
* medicos | pacientes | recepciones | relaciones | pagos
|
||||||
|
*
|
||||||
|
* Requerimientos:
|
||||||
|
* - PHP 8.0+ con extensión PDO_Firebird (php-firebird) instalada
|
||||||
|
* - Archivo config.local.php con credenciales reales
|
||||||
|
* - Las migraciones LIS 01-07 ya ejecutadas en MySQL
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
set_time_limit(0);
|
||||||
|
ini_set('memory_limit', '512M');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/helpers.php';
|
||||||
|
|
||||||
|
// ── Configuración ────────────────────────────────────────────────────────────
|
||||||
|
$cfgFile = file_exists(__DIR__ . '/config.local.php')
|
||||||
|
? __DIR__ . '/config.local.php'
|
||||||
|
: __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
$cfg = require $cfgFile;
|
||||||
|
|
||||||
|
// Argumentos CLI
|
||||||
|
$args = array_slice($argv ?? [], 1);
|
||||||
|
$dryRun = in_array('--dry-run', $args, true) || $cfg['options']['dry_run'];
|
||||||
|
$skipHist = in_array('--skip-historico', $args, true) || $cfg['options']['skip_historico'];
|
||||||
|
$onlyPaso = null;
|
||||||
|
foreach ($args as $arg) {
|
||||||
|
if (str_starts_with($arg, '--only=')) {
|
||||||
|
$onlyPaso = strtolower(substr($arg, 7));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$logFp = fopen($cfg['options']['log_file'], 'w');
|
||||||
|
|
||||||
|
etlLog('=== ETL Firebird → MySQL ===', $logFp);
|
||||||
|
etlLog("DRY_RUN: " . ($dryRun ? 'SÍ' : 'NO'), $logFp);
|
||||||
|
if ($onlyPaso) etlLog("Solo paso: $onlyPaso", $logFp);
|
||||||
|
|
||||||
|
// ── Conexiones ───────────────────────────────────────────────────────────────
|
||||||
|
try {
|
||||||
|
$fb = new PDO(
|
||||||
|
$cfg['firebird']['dsn'],
|
||||||
|
$cfg['firebird']['user'],
|
||||||
|
$cfg['firebird']['password'],
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||||
|
);
|
||||||
|
etlLog('Conexión Firebird OK', $logFp);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
etlLog('ERROR conectando Firebird: ' . $e->getMessage(), $logFp);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$my = new PDO(
|
||||||
|
"mysql:host={$cfg['mysql']['host']};port={$cfg['mysql']['port']};dbname={$cfg['mysql']['dbname']};charset=utf8mb4",
|
||||||
|
$cfg['mysql']['user'],
|
||||||
|
$cfg['mysql']['password'],
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => true]
|
||||||
|
);
|
||||||
|
$my->exec("SET NAMES utf8mb4");
|
||||||
|
$my->exec("SET foreign_key_checks = 0");
|
||||||
|
etlLog('Conexión MySQL OK', $logFp);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
etlLog('ERROR conectando MySQL: ' . $e->getMessage(), $logFp);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$charset = $cfg['firebird']['charset'];
|
||||||
|
|
||||||
|
// ── Función auxiliar: ¿ejecutar este paso? ──────────────────────────────────
|
||||||
|
function shouldRun(string $paso, ?string $only): bool {
|
||||||
|
return $only === null || $only === $paso;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PASO 1 — SECCION → lab_secciones
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
if (shouldRun('secciones', $onlyPaso)) {
|
||||||
|
etlLog('--- SECCION → lab_secciones ---', $logFp);
|
||||||
|
$total = fbCount($fb, 'SECCION');
|
||||||
|
etlLog(" Origen: $total registros", $logFp);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($fb->query('SELECT CODSECCION, NOMBSECCION FROM SECCION') as $r) {
|
||||||
|
$rows[] = [
|
||||||
|
'codigo' => cleanStr($r['CODSECCION'], $charset),
|
||||||
|
'nombre' => cleanStr($r['NOMBSECCION'], $charset),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!$dryRun) {
|
||||||
|
$n = batchInsert($my, 'lab_secciones', $rows, true);
|
||||||
|
etlLog(" Insertados: $n", $logFp);
|
||||||
|
} else {
|
||||||
|
etlLog(" [DRY] Se insertarían " . count($rows), $logFp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PASO 2 — ESPECIALIDAD → lab_especialidades
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
if (shouldRun('especialidades', $onlyPaso)) {
|
||||||
|
etlLog('--- ESPECIALIDAD → lab_especialidades ---', $logFp);
|
||||||
|
$rows = [];
|
||||||
|
foreach ($fb->query('SELECT CODESPECIA, NOMBESPECIA FROM ESPECIALIDAD') as $r) {
|
||||||
|
$rows[] = [
|
||||||
|
'codigo' => cleanStr($r['CODESPECIA'], $charset),
|
||||||
|
'nombre' => cleanStr($r['NOMBESPECIA'], $charset),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!$dryRun) {
|
||||||
|
$n = batchInsert($my, 'lab_especialidades', $rows, true);
|
||||||
|
etlLog(" Insertados: $n / " . count($rows), $logFp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PASO 3 — EXAMEN.TIPOMUESTRA → lab_tipos_muestra
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
if (shouldRun('tipos_muestra', $onlyPaso)) {
|
||||||
|
etlLog('--- EXAMEN.TIPOMUESTRA → lab_tipos_muestra ---', $logFp);
|
||||||
|
$rows = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($fb->query('SELECT DISTINCT TIPOMUESTRA FROM EXAMEN WHERE TIPOMUESTRA IS NOT NULL') as $r) {
|
||||||
|
$cod = cleanStr($r['TIPOMUESTRA'], $charset);
|
||||||
|
if (!$cod || isset($seen[$cod])) continue;
|
||||||
|
$seen[$cod] = true;
|
||||||
|
$rows[] = [
|
||||||
|
'codigo' => $cod,
|
||||||
|
'nombre' => ucwords(strtolower($cod)), // nombre provisional, editar luego
|
||||||
|
];
|
||||||
|
}
|
||||||
|
etlLog(" Tipos únicos encontrados: " . count($rows), $logFp);
|
||||||
|
if (!$dryRun) {
|
||||||
|
$n = batchInsert($my, 'lab_tipos_muestra', $rows, true);
|
||||||
|
etlLog(" Insertados: $n", $logFp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PASO 4 — TARIFAID → lab_tarifas_id
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
if (shouldRun('tarifas', $onlyPaso)) {
|
||||||
|
etlLog('--- TARIFAID → lab_tarifas_id ---', $logFp);
|
||||||
|
$rows = [];
|
||||||
|
foreach ($fb->query('SELECT IDTARIFA, NOMTARIFA, IDTARIFABASE, PORCENTAJE FROM TARIFAID') as $r) {
|
||||||
|
$rows[] = [
|
||||||
|
'id' => (int)$r['IDTARIFA'],
|
||||||
|
'nombre' => cleanStr($r['NOMTARIFA'], $charset),
|
||||||
|
'tarifa_origen' => $r['IDTARIFABASE'] ? (int)$r['IDTARIFABASE'] : null,
|
||||||
|
'porcentaje' => (float)($r['PORCENTAJE'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!$dryRun) {
|
||||||
|
$n = batchInsert($my, 'lab_tarifas_id', $rows, true);
|
||||||
|
etlLog(" Insertados: $n / " . count($rows), $logFp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TARIFA → lab_tarifas (104k registros, se hace en batches) ────────
|
||||||
|
etlLog('--- TARIFA → lab_tarifas ---', $logFp);
|
||||||
|
$total = fbCount($fb, 'TARIFA');
|
||||||
|
etlLog(" Origen: $total registros", $logFp);
|
||||||
|
|
||||||
|
$stmt = $fb->query('SELECT CODIGO, IDTARIFA, VALOR, RECARGO_URG, RECARGO_FES, RECARGO_ESP FROM TARIFA');
|
||||||
|
$batch = [];
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$batch[] = [
|
||||||
|
'cod_examen_legacy' => cleanStr($r['CODIGO'], $charset),
|
||||||
|
'exam_tipo_id' => null, // se resuelve en paso 6 (exam_tipos)
|
||||||
|
'tarifa_id' => (int)$r['IDTARIFA'],
|
||||||
|
'valor' => (float)($r['VALOR'] ?? 0),
|
||||||
|
'recargo_urg' => (float)($r['RECARGO_URG'] ?? 0),
|
||||||
|
'recargo_fes' => (float)($r['RECARGO_FES'] ?? 0),
|
||||||
|
'recargo_esp' => (float)($r['RECARGO_ESP'] ?? 0),
|
||||||
|
];
|
||||||
|
if (count($batch) >= 500) {
|
||||||
|
if (!$dryRun) $count += batchInsert($my, 'lab_tarifas', $batch, true);
|
||||||
|
$batch = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_tarifas', $batch, true);
|
||||||
|
etlLog(" Insertados: $count", $logFp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PASO 5 — PROTOCOLO + ITEM → lab_protocolos + lab_items_resultado
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
if (shouldRun('protocolos', $onlyPaso)) {
|
||||||
|
etlLog('--- PROTOCOLO → lab_protocolos ---', $logFp);
|
||||||
|
$rows = [];
|
||||||
|
foreach ($fb->query('SELECT CODPROTOCOLO, NOMPROTOCOLO, CODSECCION, IDPLANILLA, ONLYITEMS FROM PROTOCOLO') as $r) {
|
||||||
|
$rows[] = [
|
||||||
|
'codigo' => cleanStr($r['CODPROTOCOLO'], $charset),
|
||||||
|
'nombre' => cleanStr($r['NOMPROTOCOLO'], $charset),
|
||||||
|
'cod_seccion' => cleanStr($r['CODSECCION'], $charset),
|
||||||
|
'id_planilla' => cleanStr($r['IDPLANILLA'], $charset),
|
||||||
|
'only_show_items' => $r['ONLYITEMS'] ? 1 : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!$dryRun) {
|
||||||
|
$n = batchInsert($my, 'lab_protocolos', $rows, true);
|
||||||
|
etlLog(" Protocolos insertados: $n / " . count($rows), $logFp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRun('items', $onlyPaso)) {
|
||||||
|
etlLog('--- ITEM → lab_items_resultado ---', $logFp);
|
||||||
|
$total = fbCount($fb, 'ITEM');
|
||||||
|
etlLog(" Origen: $total registros", $logFp);
|
||||||
|
|
||||||
|
$stmt = $fb->query('SELECT CODPROTOCOLO,NOMITEM,TIPOSEXO,TIPO,MEDIDA,ABREVITEM,VMINREF,VMAXREF,ORDEN,FORMULA,CUPS FROM ITEM ORDER BY CODPROTOCOLO, ORDEN');
|
||||||
|
$batch = [];
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$batch[] = [
|
||||||
|
'cod_protocolo' => cleanStr($r['CODPROTOCOLO'], $charset),
|
||||||
|
'nombre' => cleanStr($r['NOMITEM'], $charset),
|
||||||
|
'tipo_sexo' => cleanStr($r['TIPOSEXO'], $charset),
|
||||||
|
'tipo' => cleanStr($r['TIPO'], $charset),
|
||||||
|
'medida' => cleanStr($r['MEDIDA'], $charset),
|
||||||
|
'abreviatura' => cleanStr($r['ABREVITEM'], $charset),
|
||||||
|
'vmin_ref' => is_numeric($r['VMINREF']) ? (float)$r['VMINREF'] : null,
|
||||||
|
'vmax_ref' => is_numeric($r['VMAXREF']) ? (float)$r['VMAXREF'] : null,
|
||||||
|
'orden' => (int)($r['ORDEN'] ?? 0),
|
||||||
|
'formula' => cleanStr($r['FORMULA'], $charset),
|
||||||
|
'cups_detalle' => cleanStr($r['CUPS'], $charset),
|
||||||
|
];
|
||||||
|
if (count($batch) >= 500) {
|
||||||
|
if (!$dryRun) $count += batchInsert($my, 'lab_items_resultado', $batch, true);
|
||||||
|
$batch = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_items_resultado', $batch, true);
|
||||||
|
etlLog(" Insertados: $count", $logFp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PASO 6 — PERFIL + PERFIL_EXA → lab_perfiles + lab_perfil_examenes
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
if (shouldRun('perfiles', $onlyPaso)) {
|
||||||
|
etlLog('--- PERFIL → lab_perfiles ---', $logFp);
|
||||||
|
$rows = [];
|
||||||
|
foreach ($fb->query('SELECT CODPERFIL, NOMPERFIL FROM PERFIL') as $r) {
|
||||||
|
$rows[] = [
|
||||||
|
'nombre' => cleanStr($r['NOMPERFIL'], $charset),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// Firebird usa VARCHAR código; MySQL usa INT AUTO_INCREMENT
|
||||||
|
// Guardamos el código viejo → id nuevo en memoria para PERFIL_EXA
|
||||||
|
if (!$dryRun) {
|
||||||
|
// Insertar uno a uno para mapear código → id nuevo
|
||||||
|
$mapaPerfiles = [];
|
||||||
|
foreach ($fb->query('SELECT CODPERFIL, NOMPERFIL FROM PERFIL') as $r) {
|
||||||
|
$stmt2 = $my->prepare('INSERT IGNORE INTO lab_perfiles (nombre) VALUES (?)');
|
||||||
|
$stmt2->execute([cleanStr($r['NOMPERFIL'], $charset)]);
|
||||||
|
$newId = (int)$my->lastInsertId();
|
||||||
|
if ($newId) $mapaPerfiles[cleanStr($r['CODPERFIL'], $charset)] = $newId;
|
||||||
|
}
|
||||||
|
etlLog(" Perfiles insertados: " . count($mapaPerfiles), $logFp);
|
||||||
|
|
||||||
|
// PERFIL_EXA — mapear cod_examen → exam_tipo_id
|
||||||
|
etlLog('--- PERFIL_EXA → lab_perfil_examenes ---', $logFp);
|
||||||
|
$examMap = [];
|
||||||
|
foreach ($my->query('SELECT id, codigo_legacy FROM exam_tipos WHERE codigo_legacy IS NOT NULL') as $r) {
|
||||||
|
$examMap[$r['codigo_legacy']] = (int)$r['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$peBatch = [];
|
||||||
|
foreach ($fb->query('SELECT CODPERFIL, CODEXAMEN FROM PERFIL_EXA') as $r) {
|
||||||
|
$pId = $mapaPerfiles[cleanStr($r['CODPERFIL'], $charset)] ?? null;
|
||||||
|
$eId = $examMap[cleanStr($r['CODEXAMEN'], $charset)] ?? null;
|
||||||
|
if ($pId && $eId) {
|
||||||
|
$peBatch[] = ['perfil_id' => $pId, 'exam_tipo_id' => $eId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$n = batchInsert($my, 'lab_perfil_examenes', $peBatch, true);
|
||||||
|
etlLog(" Relaciones perfil-examen: $n", $logFp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PASO 7 — EXAMEN → exam_tipos (ampliar con campos legacy)
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
if (shouldRun('exam_tipos', $onlyPaso)) {
|
||||||
|
etlLog('--- EXAMEN → exam_tipos ---', $logFp);
|
||||||
|
$total = fbCount($fb, 'EXAMEN');
|
||||||
|
etlLog(" Origen: $total registros", $logFp);
|
||||||
|
|
||||||
|
$insertados = 0;
|
||||||
|
$actualizados = 0;
|
||||||
|
|
||||||
|
$stmt = $fb->query(
|
||||||
|
'SELECT CODIGO, NOMEXAMEN, CODPROT, TIPOMUESTRA, NIVEL, CUPS,
|
||||||
|
ABREVEXAMEN, SEREMITE, SERECIBE, CODSECCION
|
||||||
|
FROM EXAMEN'
|
||||||
|
);
|
||||||
|
|
||||||
|
$checkStmt = $my->prepare('SELECT id FROM exam_tipos WHERE codigo_legacy = ?');
|
||||||
|
$updStmt = $my->prepare(
|
||||||
|
'UPDATE exam_tipos SET
|
||||||
|
cod_protocolo = ?, tipo_muestra = ?, nivel = ?, cups = ?,
|
||||||
|
abreviatura = ?, seremite = ?, serecibe = ?
|
||||||
|
WHERE codigo_legacy = ?'
|
||||||
|
);
|
||||||
|
$insStmt = $my->prepare(
|
||||||
|
'INSERT IGNORE INTO exam_tipos
|
||||||
|
(codigo, nombre, categoria, codigo_legacy, cod_protocolo,
|
||||||
|
tipo_muestra, nivel, cups, abreviatura, seremite, serecibe)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
|
||||||
|
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$codLeg = cleanStr($r['CODIGO'], $charset);
|
||||||
|
$nombre = cleanStr($r['NOMEXAMEN'], $charset);
|
||||||
|
$proto = cleanStr($r['CODPROT'], $charset);
|
||||||
|
$tMuest = cleanStr($r['TIPOMUESTRA'], $charset);
|
||||||
|
$nivel = $r['NIVEL'] ? (int)$r['NIVEL'] : null;
|
||||||
|
$cups = cleanStr($r['CUPS'], $charset);
|
||||||
|
$abrev = cleanStr($r['ABREVEXAMEN'], $charset);
|
||||||
|
$serem = $r['SEREMITE'] ? 1 : 0;
|
||||||
|
$serec = cleanStr($r['SERECIBE'], $charset);
|
||||||
|
$seccion = cleanStr($r['CODSECCION'], $charset);
|
||||||
|
|
||||||
|
if (!$codLeg || !$nombre) continue;
|
||||||
|
|
||||||
|
$checkStmt->execute([$codLeg]);
|
||||||
|
$existing = $checkStmt->fetchColumn();
|
||||||
|
|
||||||
|
if ($dryRun) continue;
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$updStmt->execute([$proto, $tMuest, $nivel, $cups, $abrev, $serem, $serec, $codLeg]);
|
||||||
|
$actualizados++;
|
||||||
|
} else {
|
||||||
|
// Usar el código Firebird como código del nuevo sistema (si no hay conflicto)
|
||||||
|
$insStmt->execute([$codLeg, $nombre, $seccion, $codLeg, $proto, $tMuest, $nivel, $cups, $abrev, $serem, $serec]);
|
||||||
|
$insertados++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
etlLog(" Nuevos: $insertados | Actualizados: $actualizados", $logFp);
|
||||||
|
|
||||||
|
// Resolver exam_tipo_id en lab_tarifas (ahora que los exámenes ya están)
|
||||||
|
if (!$dryRun) {
|
||||||
|
etlLog(' Resolviendo exam_tipo_id en lab_tarifas...', $logFp);
|
||||||
|
$updated = $my->exec(
|
||||||
|
'UPDATE lab_tarifas t
|
||||||
|
JOIN exam_tipos e ON e.codigo_legacy = t.cod_examen_legacy
|
||||||
|
SET t.exam_tipo_id = e.id
|
||||||
|
WHERE t.exam_tipo_id IS NULL'
|
||||||
|
);
|
||||||
|
etlLog(" lab_tarifas actualizadas: $updated filas", $logFp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PASO 8 — EMPRESA + EMPRESA_SUB + EXAMEN_EMP
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
if (shouldRun('empresas', $onlyPaso)) {
|
||||||
|
etlLog('--- EMPRESA → lab_empresas ---', $logFp);
|
||||||
|
$rows = [];
|
||||||
|
foreach ($fb->query(
|
||||||
|
'SELECT NIT,NOMBRE,RAZSOCIAL,IDTARIFA,DESCUENTO,CODEEPS,
|
||||||
|
TIPOUSUARIO,TIPOUSUARIOSISPRO,CODCONTRATO,CODTERCERO,
|
||||||
|
CENTROCOSTO,REQAUTORIZA,ACTIVA
|
||||||
|
FROM EMPRESA'
|
||||||
|
) as $r) {
|
||||||
|
$rows[] = [
|
||||||
|
'nit' => cleanStr($r['NIT'], $charset),
|
||||||
|
'nombre' => cleanStr($r['NOMBRE'], $charset),
|
||||||
|
'razon_social' => cleanStr($r['RAZSOCIAL'], $charset),
|
||||||
|
'tarifa_id' => $r['IDTARIFA'] ? (int)$r['IDTARIFA'] : null,
|
||||||
|
'descuento_pct' => (float)($r['DESCUENTO'] ?? 0),
|
||||||
|
'codigo_eps' => cleanStr($r['CODEEPS'], $charset),
|
||||||
|
'tipo_usuario' => cleanStr($r['TIPOUSUARIO'], $charset),
|
||||||
|
'tipo_usuario_sispro' => cleanStr($r['TIPOUSUARIOSISPRO'],$charset),
|
||||||
|
'cod_contrato' => cleanStr($r['CODCONTRATO'], $charset),
|
||||||
|
'cod_tercero' => cleanStr($r['CODTERCERO'], $charset),
|
||||||
|
'centro_costo' => cleanStr($r['CENTROCOSTO'], $charset),
|
||||||
|
'req_autoriza' => $r['REQAUTORIZA'] ? 1 : 0,
|
||||||
|
'activa' => $r['ACTIVA'] ? 1 : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!$dryRun) {
|
||||||
|
$n = batchInsert($my, 'lab_empresas', $rows, true);
|
||||||
|
etlLog(" Empresas: $n / " . count($rows), $logFp);
|
||||||
|
}
|
||||||
|
|
||||||
|
etlLog('--- EMPRESA_SUB → lab_empresa_subgrupos ---', $logFp);
|
||||||
|
$rows = [];
|
||||||
|
foreach ($fb->query('SELECT NIT,SUBGRUPO,IDTARIFA,REF_SUBGRUPO,CODCONTRATO FROM EMPRESA_SUB') as $r) {
|
||||||
|
$rows[] = [
|
||||||
|
'nit_empresa' => cleanStr($r['NIT'], $charset),
|
||||||
|
'subgrupo' => cleanStr($r['SUBGRUPO'], $charset),
|
||||||
|
'tarifa_id' => $r['IDTARIFA'] ? (int)$r['IDTARIFA'] : null,
|
||||||
|
'ref_subgrupo' => cleanStr($r['REF_SUBGRUPO'],$charset),
|
||||||
|
'cod_contrato' => cleanStr($r['CODCONTRATO'], $charset),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!$dryRun) {
|
||||||
|
$n = batchInsert($my, 'lab_empresa_subgrupos', $rows, true);
|
||||||
|
etlLog(" Subgrupos: $n", $logFp);
|
||||||
|
}
|
||||||
|
|
||||||
|
etlLog('--- EXAMEN_EMP → lab_examenes_empresa ---', $logFp);
|
||||||
|
$rows = [];
|
||||||
|
foreach ($fb->query('SELECT NIT,CODIGO,CODIGOEMP FROM EXAMEN_EMP') as $r) {
|
||||||
|
$rows[] = [
|
||||||
|
'nit_empresa' => cleanStr($r['NIT'], $charset),
|
||||||
|
'cod_examen_legacy' => cleanStr($r['CODIGO'], $charset),
|
||||||
|
'exam_tipo_id' => null,
|
||||||
|
'codigo_empresa' => cleanStr($r['CODIGOEMP'],$charset),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!$dryRun) {
|
||||||
|
$n = batchInsert($my, 'lab_examenes_empresa', $rows, true);
|
||||||
|
// Resolver exam_tipo_id
|
||||||
|
$my->exec(
|
||||||
|
'UPDATE lab_examenes_empresa ee
|
||||||
|
JOIN exam_tipos e ON e.codigo_legacy = ee.cod_examen_legacy
|
||||||
|
SET ee.exam_tipo_id = e.id
|
||||||
|
WHERE ee.exam_tipo_id IS NULL'
|
||||||
|
);
|
||||||
|
etlLog(" Examenes-empresa: $n", $logFp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PASO 9 — MEDICO → medicos
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
if (shouldRun('medicos', $onlyPaso)) {
|
||||||
|
etlLog('--- MEDICO → medicos ---', $logFp);
|
||||||
|
$rows = [];
|
||||||
|
foreach ($fb->query(
|
||||||
|
'SELECT CODMEDICO,NOMBRES,APELLIDOS,CODESPECIA,TELEFONO1,EMAIL,DOCIDMEDICO,ACTIVO
|
||||||
|
FROM MEDICO'
|
||||||
|
) as $r) {
|
||||||
|
$rows[] = [
|
||||||
|
'codigo' => cleanStr($r['CODMEDICO'], $charset),
|
||||||
|
'nombres' => cleanStr($r['NOMBRES'], $charset),
|
||||||
|
'apellidos' => cleanStr($r['APELLIDOS'], $charset),
|
||||||
|
'cod_especialidad'=> cleanStr($r['CODESPECIA'], $charset),
|
||||||
|
'telefonos' => cleanStr($r['TELEFONO1'], $charset),
|
||||||
|
'email' => cleanStr($r['EMAIL'], $charset),
|
||||||
|
'docidmedico' => cleanStr($r['DOCIDMEDICO'], $charset),
|
||||||
|
'activo' => $r['ACTIVO'] ? 1 : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!$dryRun) {
|
||||||
|
$n = batchInsert($my, 'medicos', $rows, true);
|
||||||
|
etlLog(" Médicos: $n / " . count($rows), $logFp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PASO 10 — PACIENTE → lab_pacientes
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
if (shouldRun('pacientes', $onlyPaso)) {
|
||||||
|
etlLog('--- PACIENTE → lab_pacientes ---', $logFp);
|
||||||
|
$total = fbCount($fb, 'PACIENTE');
|
||||||
|
etlLog(" Origen: $total registros", $logFp);
|
||||||
|
|
||||||
|
$stmt = $fb->query(
|
||||||
|
'SELECT CODPAC,DOCIDENT,TIPOIDENT,NOMBRES,APELLIDOS,
|
||||||
|
TELEFONO1,EMAIL,FECHANAC,SEXO,CIUDAD,
|
||||||
|
OCUPACION,CODETNIA,TIPORES
|
||||||
|
FROM PACIENTE
|
||||||
|
ORDER BY CODPAC'
|
||||||
|
);
|
||||||
|
|
||||||
|
$checkStmt = $my->prepare('SELECT id FROM lab_pacientes WHERE numero_documento = ?');
|
||||||
|
$insStmt = $my->prepare(
|
||||||
|
'INSERT IGNORE INTO lab_pacientes
|
||||||
|
(numero_documento, tipo_documento, nombre_completo, telefono,
|
||||||
|
email, fecha_nacimiento, genero, ciudad,
|
||||||
|
ocupacion, codetnia, tipores, codigo_legacy, es_historico)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,1)'
|
||||||
|
);
|
||||||
|
$updLegacy = $my->prepare(
|
||||||
|
'UPDATE lab_pacientes SET codigo_legacy = ? WHERE numero_documento = ? AND codigo_legacy IS NULL'
|
||||||
|
);
|
||||||
|
|
||||||
|
$nuevos = $coincidentes = 0;
|
||||||
|
|
||||||
|
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$doc = cleanStr($r['DOCIDENT'], $charset);
|
||||||
|
if (!$doc) continue;
|
||||||
|
|
||||||
|
$nombres = cleanStr($r['NOMBRES'], $charset);
|
||||||
|
$apellidos = cleanStr($r['APELLIDOS'], $charset);
|
||||||
|
$nombre = trim("$nombres $apellidos");
|
||||||
|
|
||||||
|
if ($dryRun) continue;
|
||||||
|
|
||||||
|
$checkStmt->execute([$doc]);
|
||||||
|
$existeId = $checkStmt->fetchColumn();
|
||||||
|
|
||||||
|
if ($existeId) {
|
||||||
|
// Ya existe → solo actualizar codigo_legacy si falta
|
||||||
|
$updLegacy->execute([cleanStr($r['CODPAC'], $charset), $doc]);
|
||||||
|
$coincidentes++;
|
||||||
|
} else {
|
||||||
|
$tipoDoc = match(strtoupper(cleanStr($r['TIPOIDENT'], $charset) ?? '')) {
|
||||||
|
'CC' => 'CC',
|
||||||
|
'CE' => 'CE',
|
||||||
|
'TI' => 'TI',
|
||||||
|
'PA' => 'PA',
|
||||||
|
'NIT' => 'NIT',
|
||||||
|
'RC' => 'RC',
|
||||||
|
'MS' => 'MS',
|
||||||
|
default => 'CC',
|
||||||
|
};
|
||||||
|
$genero = match(strtoupper(cleanStr($r['SEXO'], $charset) ?? '')) {
|
||||||
|
'M' => 'M', 'F' => 'F', default => null
|
||||||
|
};
|
||||||
|
$insStmt->execute([
|
||||||
|
$doc,
|
||||||
|
$tipoDoc,
|
||||||
|
$nombre ?: 'Sin nombre',
|
||||||
|
cleanStr($r['TELEFONO1'], $charset),
|
||||||
|
cleanStr($r['EMAIL'], $charset),
|
||||||
|
fbDate($r['FECHANAC']),
|
||||||
|
$genero,
|
||||||
|
cleanStr($r['CIUDAD'], $charset),
|
||||||
|
cleanStr($r['OCUPACION'], $charset),
|
||||||
|
cleanStr($r['CODETNIA'], $charset),
|
||||||
|
cleanStr($r['TIPORES'], $charset),
|
||||||
|
cleanStr($r['CODPAC'], $charset),
|
||||||
|
]);
|
||||||
|
$nuevos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
etlLog(" Nuevos: $nuevos | Coincidentes (codigo_legacy actualizado): $coincidentes", $logFp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// PASO 11-13 — Histórico transaccional (RECEPCION / RELACION / PAGOS)
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
if (!$skipHist) {
|
||||||
|
|
||||||
|
// ── 11. RECEPCION → lab_recepciones ──────────────────────────────────
|
||||||
|
if (shouldRun('recepciones', $onlyPaso)) {
|
||||||
|
etlLog('--- RECEPCION → lab_recepciones ---', $logFp);
|
||||||
|
$total = fbCount($fb, 'RECEPCION');
|
||||||
|
etlLog(" Origen: $total registros", $logFp);
|
||||||
|
|
||||||
|
// Mapa paciente legacy → id nuevo
|
||||||
|
$pacMap = [];
|
||||||
|
foreach ($my->query('SELECT id, codigo_legacy FROM lab_pacientes WHERE codigo_legacy IS NOT NULL') as $r) {
|
||||||
|
$pacMap[$r['codigo_legacy']] = (int)$r['id'];
|
||||||
|
}
|
||||||
|
// Mapa médico legacy → id nuevo
|
||||||
|
$medMap = [];
|
||||||
|
foreach ($my->query('SELECT id, codigo FROM medicos WHERE codigo IS NOT NULL') as $r) {
|
||||||
|
$medMap[$r['codigo']] = (int)$r['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $fb->query(
|
||||||
|
'SELECT IDRECEPCION,CODPAC,CODMEDICO,NIT,SUBGRUPO,
|
||||||
|
FECHA,HORAINICIO,PREFIJO,NUMFACTURA,
|
||||||
|
VALORTOTAL,VALORDESC,DIAGPPAL,TIPOUSUARIO,AUTORIZACION,USUARIO
|
||||||
|
FROM RECEPCION
|
||||||
|
ORDER BY IDRECEPCION'
|
||||||
|
);
|
||||||
|
$batch = [];
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$codPac = cleanStr($r['CODPAC'], $charset);
|
||||||
|
$codMed = cleanStr($r['CODMEDICO'],$charset);
|
||||||
|
|
||||||
|
$batch[] = [
|
||||||
|
'id' => (int)$r['IDRECEPCION'],
|
||||||
|
'cod_paciente_legacy' => $codPac,
|
||||||
|
'paciente_id' => $pacMap[$codPac] ?? null,
|
||||||
|
'cod_medico_legacy' => $codMed,
|
||||||
|
'medico_id' => $medMap[$codMed] ?? null,
|
||||||
|
'nit_empresa' => cleanStr($r['NIT'], $charset),
|
||||||
|
'subgrupo' => cleanStr($r['SUBGRUPO'], $charset),
|
||||||
|
'fecha_recepcion' => fbDate($r['FECHA']),
|
||||||
|
'hora_inicio' => cleanStr($r['HORAINICIO'], $charset),
|
||||||
|
'prefijo' => cleanStr($r['PREFIJO'], $charset),
|
||||||
|
'num_factura' => $r['NUMFACTURA'] ? (int)$r['NUMFACTURA'] : null,
|
||||||
|
'valor_total' => (float)($r['VALORTOTAL'] ?? 0),
|
||||||
|
'valor_desc' => (float)($r['VALORDESC'] ?? 0),
|
||||||
|
'diag_ppal' => cleanStr($r['DIAGPPAL'], $charset),
|
||||||
|
'tipo_usuario' => cleanStr($r['TIPOUSUARIO'], $charset),
|
||||||
|
'autorizacion' => cleanStr($r['AUTORIZACION'], $charset),
|
||||||
|
'usuario' => cleanStr($r['USUARIO'], $charset),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (count($batch) >= 500) {
|
||||||
|
if (!$dryRun) $count += batchInsert($my, 'lab_recepciones', $batch, true);
|
||||||
|
$batch = [];
|
||||||
|
if ($count % 5000 === 0) etlLog(" ... $count procesadas", $logFp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_recepciones', $batch, true);
|
||||||
|
etlLog(" Insertadas: $count", $logFp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 12. RELACION → lab_relaciones ─────────────────────────────────────
|
||||||
|
if (shouldRun('relaciones', $onlyPaso)) {
|
||||||
|
etlLog('--- RELACION → lab_relaciones ---', $logFp);
|
||||||
|
$total = fbCount($fb, 'RELACION');
|
||||||
|
etlLog(" Origen: $total registros", $logFp);
|
||||||
|
|
||||||
|
// Mapa cod_examen_legacy → exam_tipo_id
|
||||||
|
$examMap = [];
|
||||||
|
foreach ($my->query('SELECT id, codigo_legacy FROM exam_tipos WHERE codigo_legacy IS NOT NULL') as $r) {
|
||||||
|
$examMap[$r['codigo_legacy']] = (int)$r['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $fb->query(
|
||||||
|
'SELECT IDRECEPCION,CODIGO,PRECIO,FECHAREPORT,REPORTADO,REPORPOR,VALIDADO,USRVALIDA,FECHAVALIDA
|
||||||
|
FROM RELACION'
|
||||||
|
);
|
||||||
|
$batch = [];
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$codExam = cleanStr($r['CODIGO'], $charset);
|
||||||
|
$batch[] = [
|
||||||
|
'recepcion_id' => (int)$r['IDRECEPCION'],
|
||||||
|
'cod_examen_legacy' => $codExam,
|
||||||
|
'exam_tipo_id' => $examMap[$codExam] ?? null,
|
||||||
|
'precio' => (float)($r['PRECIO'] ?? 0),
|
||||||
|
'fecha_reportado' => fbDate($r['FECHAREPORT']),
|
||||||
|
'reportado' => $r['REPORTADO'] ? 1 : 0,
|
||||||
|
'reportado_por' => cleanStr($r['REPORPOR'], $charset),
|
||||||
|
'validado' => $r['VALIDADO'] ? 1 : 0,
|
||||||
|
'usuario_valida' => cleanStr($r['USRVALIDA'], $charset),
|
||||||
|
'fecha_valida' => fbDate($r['FECHAVALIDA']),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (count($batch) >= 500) {
|
||||||
|
if (!$dryRun) $count += batchInsert($my, 'lab_relaciones', $batch, true);
|
||||||
|
$batch = [];
|
||||||
|
if ($count % 10000 === 0) etlLog(" ... $count", $logFp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_relaciones', $batch, true);
|
||||||
|
etlLog(" Insertadas: $count", $logFp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 13. PAGOS + PAGOS_DET ─────────────────────────────────────────────
|
||||||
|
if (shouldRun('pagos', $onlyPaso)) {
|
||||||
|
etlLog('--- PAGOS → lab_pagos ---', $logFp);
|
||||||
|
$rows = [];
|
||||||
|
foreach ($fb->query('SELECT NUMCAJA,IDRECEPCION,VALOR,FECHAPAGO,USUARIO FROM PAGOS') as $r) {
|
||||||
|
$rows[] = [
|
||||||
|
'numcaja_legacy' => (int)$r['NUMCAJA'],
|
||||||
|
'recepcion_id' => (int)$r['IDRECEPCION'],
|
||||||
|
'valor' => (float)($r['VALOR'] ?? 0),
|
||||||
|
'fecha' => fbDate($r['FECHAPAGO']),
|
||||||
|
'usuario' => cleanStr($r['USUARIO'], $charset),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!$dryRun) {
|
||||||
|
$n = batchInsert($my, 'lab_pagos', $rows, true);
|
||||||
|
etlLog(" Pagos: $n / " . count($rows), $logFp);
|
||||||
|
}
|
||||||
|
|
||||||
|
etlLog('--- PAGOS_DET → lab_pagos_det ---', $logFp);
|
||||||
|
// Mapa numcaja → id nuevo
|
||||||
|
$pagoMap = [];
|
||||||
|
if (!$dryRun) {
|
||||||
|
foreach ($my->query('SELECT id, numcaja_legacy FROM lab_pagos') as $r) {
|
||||||
|
$pagoMap[$r['numcaja_legacy']] = (int)$r['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rows = [];
|
||||||
|
foreach ($fb->query('SELECT NUMCAJA,TIPOPAGO,VALOR,NUMDOC FROM PAGOS_DET') as $r) {
|
||||||
|
$pagoId = $pagoMap[(int)$r['NUMCAJA']] ?? null;
|
||||||
|
if (!$pagoId) continue;
|
||||||
|
$rows[] = [
|
||||||
|
'pago_id' => $pagoId,
|
||||||
|
'tipo_pago' => cleanStr($r['TIPOPAGO'], $charset),
|
||||||
|
'valor' => (float)($r['VALOR'] ?? 0),
|
||||||
|
'num_doc' => cleanStr($r['NUMDOC'], $charset),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!$dryRun) {
|
||||||
|
$n = batchInsert($my, 'lab_pagos_det', $rows, true);
|
||||||
|
etlLog(" Detalles de pago: $n", $logFp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
etlLog('--- Histórico omitido (--skip-historico) ---', $logFp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Restaurar FK checks ──────────────────────────────────────────────────────
|
||||||
|
if (!$dryRun) {
|
||||||
|
$my->exec("SET foreign_key_checks = 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
etlLog('=== ETL COMPLETADO ===', $logFp);
|
||||||
|
fclose($logFp);
|
||||||
|
echo "Log guardado en: {$cfg['options']['log_file']}\n";
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Migración: registra lab_tomas_config en system_modules
|
||||||
|
* Ejecutar: php migrations/run_20260710_lab_tomas_config_module.php
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
|
||||||
|
// Buscar el sort_order máximo en la categoría 'clinico' para poner al final
|
||||||
|
$row = $pdo->query("SELECT MAX(sort_order) AS mx FROM system_modules WHERE category = 'clinico'")->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$nextOrder = (int)($row['mx'] ?? 50) + 10;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO system_modules (slug, name, icon, category, route, is_active, sort_order, description)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
icon = VALUES(icon),
|
||||||
|
category = VALUES(category),
|
||||||
|
route = VALUES(route),
|
||||||
|
is_active = 1,
|
||||||
|
sort_order = VALUES(sort_order),
|
||||||
|
description = VALUES(description)
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'lab_tomas_config',
|
||||||
|
'Tipos de Examen (Tomas)',
|
||||||
|
'fas fa-vials',
|
||||||
|
'clinico',
|
||||||
|
'/lab_tomas_config.php',
|
||||||
|
$nextOrder,
|
||||||
|
'Configurar tipos de examen y ciclos de tomas prolongadas (F-LAB-28)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo "✅ Módulo lab_tomas_config registrado en system_modules (sort_order=$nextOrder).\n";
|
||||||
@@ -12,6 +12,9 @@ $pendientes = [
|
|||||||
'20260703_medicos.sql',
|
'20260703_medicos.sql',
|
||||||
'20260703_medicos_seed.sql',
|
'20260703_medicos_seed.sql',
|
||||||
'20260703_solicitud_medico.sql',
|
'20260703_solicitud_medico.sql',
|
||||||
|
'20260707_turnero_consent_origen_lugar.sql',
|
||||||
|
'20260716_lab_eps_ciudades.sql',
|
||||||
|
'20260716_numero_recibo_tarjeta.sql',
|
||||||
];
|
];
|
||||||
|
|
||||||
$pdo = Database::getInstance()->getConnection();
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php return [
|
||||||
|
'slug' => 'lab_ciudades',
|
||||||
|
'name' => 'Ciudades',
|
||||||
|
'icon' => 'fas fa-map-marker-alt',
|
||||||
|
'category' => 'lab',
|
||||||
|
'route' => '/lab_ciudades.php',
|
||||||
|
'is_active' => true,
|
||||||
|
'sort_order' => 23,
|
||||||
|
'oleada' => 0,
|
||||||
|
'description' => 'Gestión de ciudades de pacientes',
|
||||||
|
'links' => [['name' => 'Ciudades', 'icon' => 'fas fa-map-marker-alt', 'route' => '/lab_ciudades.php']],
|
||||||
|
];
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||||
|
requireRole('admin');
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Ciudades — <?= htmlspecialchars($_cfg['empresa_nombre'] ?? 'ERP') ?></title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link href="<?= BASE_URL ?>assets/css/styles.css?v=15" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.ciu-table { width:100%; border-collapse:collapse; font-size:.9rem; }
|
||||||
|
.ciu-table th { background:#f8fafc; font-weight:700; font-size:.75rem; text-transform:uppercase;
|
||||||
|
letter-spacing:.06em; color:#64748b; padding:.6rem 1rem; border-bottom:2px solid #e2e8f0; }
|
||||||
|
.ciu-table td { padding:.65rem 1rem; border-bottom:1px solid #f1f5f9; vertical-align:middle; }
|
||||||
|
.ciu-table tr:hover td { background:#f8fafc; }
|
||||||
|
.badge-activa { background:#dcfce7; color:#166534; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:99px; }
|
||||||
|
.badge-inactiva { background:#f1f5f9; color:#94a3b8; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:99px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<?php include APP_ROOT . '/partials/navbar.php'; ?>
|
||||||
|
|
||||||
|
<div class="container-fluid py-4" style="max-width:720px">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-map-marker-alt me-2 text-primary"></i>Ciudades</h5>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="abrirModal()">
|
||||||
|
<i class="fas fa-plus me-1"></i>Nueva ciudad
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="ciu-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th style="width:90px">Estado</th>
|
||||||
|
<th style="width:110px">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbody-ciu">
|
||||||
|
<tr><td colspan="3" class="text-muted text-center py-3">Cargando…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div class="modal fade" id="modal-ciu" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-sm">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h6 class="modal-title" id="modal-ciu-titulo">Nueva ciudad</h6>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="ciu-id">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small fw-semibold">Nombre <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" id="ciu-nombre" class="form-control" maxlength="100" placeholder="Ej: Cúcuta, Bogotá…">
|
||||||
|
</div>
|
||||||
|
<div id="ciu-error" class="text-danger small d-none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="guardar()">
|
||||||
|
<i class="fas fa-save me-1"></i>Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '<?= BASE_URL ?>api/lab/ciudades.php';
|
||||||
|
let _modal;
|
||||||
|
|
||||||
|
async function cargar() {
|
||||||
|
const r = await fetch(API + '?action=list');
|
||||||
|
const j = await r.json();
|
||||||
|
const tbody = document.getElementById('tbody-ciu');
|
||||||
|
if (!j.ok || !j.ciudades.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="3" class="text-muted text-center py-3">Sin registros</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = j.ciudades.map(c => `
|
||||||
|
<tr>
|
||||||
|
<td>${escHtml(c.nombre)}</td>
|
||||||
|
<td><span class="badge-${c.activa == 1 ? 'activa' : 'inactiva'}">${c.activa == 1 ? 'Activa' : 'Inactiva'}</span></td>
|
||||||
|
<td class="d-flex gap-1">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="abrirModal(${c.id},'${escHtml(c.nombre).replace(/'/g,"\\'")}')"><i class="fas fa-pen"></i></button>
|
||||||
|
<button class="btn btn-outline-${c.activa == 1 ? 'warning' : 'success'} btn-sm" onclick="toggle(${c.id})"><i class="fas fa-${c.activa == 1 ? 'ban' : 'check'}"></i></button>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" onclick="eliminar(${c.id},'${escHtml(c.nombre).replace(/'/g,"\\'")}')"><i class="fas fa-trash"></i></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirModal(id = 0, nombre = '') {
|
||||||
|
document.getElementById('ciu-id').value = id;
|
||||||
|
document.getElementById('ciu-nombre').value = nombre;
|
||||||
|
document.getElementById('ciu-error').classList.add('d-none');
|
||||||
|
document.getElementById('modal-ciu-titulo').textContent = id ? 'Editar ciudad' : 'Nueva ciudad';
|
||||||
|
_modal = _modal || new bootstrap.Modal(document.getElementById('modal-ciu'));
|
||||||
|
_modal.show();
|
||||||
|
setTimeout(() => document.getElementById('ciu-nombre').focus(), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardar() {
|
||||||
|
const nombre = document.getElementById('ciu-nombre').value.trim();
|
||||||
|
const id = parseInt(document.getElementById('ciu-id').value) || 0;
|
||||||
|
const errEl = document.getElementById('ciu-error');
|
||||||
|
errEl.classList.add('d-none');
|
||||||
|
if (!nombre) { errEl.textContent = 'El nombre es requerido.'; errEl.classList.remove('d-none'); return; }
|
||||||
|
const r = await fetch(API, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({action:'save', id, nombre}) });
|
||||||
|
const j = await r.json();
|
||||||
|
if (!j.ok) { errEl.textContent = j.error || 'Error'; errEl.classList.remove('d-none'); return; }
|
||||||
|
_modal.hide();
|
||||||
|
cargar();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(id) {
|
||||||
|
await fetch(API, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({action:'toggle', id}) });
|
||||||
|
cargar();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eliminar(id, nombre) {
|
||||||
|
if (!confirm(`¿Eliminar "${nombre}"?`)) return;
|
||||||
|
const r = await fetch(API, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({action:'delete', id}) });
|
||||||
|
const j = await r.json();
|
||||||
|
if (!j.ok) { alert(j.error); return; }
|
||||||
|
cargar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.appendChild(document.createTextNode(String(str)));
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('ciu-nombre').addEventListener('keydown', e => { if (e.key === 'Enter') guardar(); });
|
||||||
|
cargar();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
return [
|
||||||
|
'slug' => 'lab_empresas',
|
||||||
|
'name' => 'Empresas y Convenios',
|
||||||
|
'icon' => 'fas fa-building',
|
||||||
|
'category' => 'clinico',
|
||||||
|
'route' => '/erp.php?m=lab_empresas&v=index',
|
||||||
|
'is_active' => true,
|
||||||
|
'sort_order' => 65,
|
||||||
|
'oleada' => 2,
|
||||||
|
'description' => 'Gestión de empresas, EPS, convenios, subgrupos y tarifas.',
|
||||||
|
'links' => [
|
||||||
|
['name' => 'Empresas', 'icon' => 'fas fa-building', 'route' => '/erp.php?m=lab_empresas&v=index'],
|
||||||
|
['name' => 'Tarifas', 'icon' => 'fas fa-tags', 'route' => '/erp.php?m=lab_empresas&v=tarifas'],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* modules/lab_empresas/views/index.php
|
||||||
|
* CRUD de empresas/convenios y sus subgrupos.
|
||||||
|
*/
|
||||||
|
require_once APP_ROOT . '/config/config.php';
|
||||||
|
Layout::open('Empresas y Convenios', 'fas fa-building');
|
||||||
|
?>
|
||||||
|
<div class="container-fluid py-3">
|
||||||
|
|
||||||
|
<!-- Encabezado -->
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0"><i class="fas fa-building me-2 text-primary"></i>Empresas y Convenios</h4>
|
||||||
|
<small class="text-muted">EPS, IPS, convenios y particulares con tarifa especial</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="<?= BASE_URL ?>/erp.php?m=lab_empresas&v=tarifas"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="fas fa-tags me-1"></i>Tarifas
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="abrirModal()">
|
||||||
|
<i class="fas fa-plus me-1"></i>Nueva empresa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-sm-5 col-md-4">
|
||||||
|
<input type="search" id="filtroSearch" class="form-control form-control-sm"
|
||||||
|
placeholder="Buscar por nombre, NIT…" oninput="debounceCargar()">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<select id="filtroActiva" class="form-select form-select-sm" onchange="cargarEmpresas()">
|
||||||
|
<option value="">Todas</option>
|
||||||
|
<option value="1" selected>Activas</option>
|
||||||
|
<option value="0">Inactivas</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto ms-auto">
|
||||||
|
<span class="text-muted small" id="lblTotal"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover table-sm mb-0" id="tablaEmpresas">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>NIT</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Tarifa</th>
|
||||||
|
<th class="text-end">Dcto%</th>
|
||||||
|
<th class="text-center">Subgrupos</th>
|
||||||
|
<th class="text-center">Autoriza</th>
|
||||||
|
<th class="text-center">Estado</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbodyEmpresas">
|
||||||
|
<tr><td colspan="8" class="text-center py-4 text-muted">Cargando…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Paginación -->
|
||||||
|
<div class="card-footer d-flex justify-content-between align-items-center py-1">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" id="btnPrev" onclick="cambiarPagina(-1)">
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
<span class="small text-muted" id="lblPagina"></span>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" id="btnNext" onclick="cambiarPagina(1)">
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════
|
||||||
|
Modal empresa (crear / editar)
|
||||||
|
═══════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="modal fade" id="modalEmpresa" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="modalEmpresaTitulo">Nueva empresa</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="formEmpresa" novalidate>
|
||||||
|
<input type="hidden" id="fNitOrig" value="">
|
||||||
|
|
||||||
|
<!-- Fila 1: NIT + Nombre -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<label class="form-label fw-semibold small">NIT <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" id="fNit" class="form-control form-control-sm"
|
||||||
|
placeholder="900123456-7" maxlength="20" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<label class="form-label fw-semibold small">Nombre <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" id="fNombre" class="form-control form-control-sm"
|
||||||
|
placeholder="Nombre de la empresa" maxlength="200" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fila 2: Razón social -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold small">Razón social</label>
|
||||||
|
<input type="text" id="fRazonSocial" class="form-control form-control-sm"
|
||||||
|
placeholder="Razón social completa" maxlength="200">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fila 3: Tarifa + Descuento -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<label class="form-label fw-semibold small">Tarifa</label>
|
||||||
|
<select id="fTarifaId" class="form-select form-select-sm">
|
||||||
|
<option value="">— Sin tarifa especial —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-3">
|
||||||
|
<label class="form-label fw-semibold small">Descuento %</label>
|
||||||
|
<input type="number" id="fDescuentoPct" class="form-control form-control-sm"
|
||||||
|
value="0" min="0" max="100" step="0.01">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-3">
|
||||||
|
<label class="form-label fw-semibold small">Cód. EPS</label>
|
||||||
|
<input type="text" id="fCodigoEps" class="form-control form-control-sm"
|
||||||
|
placeholder="EPS001" maxlength="20">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fila 4: Tipo usuario -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<label class="form-label fw-semibold small">Tipo usuario</label>
|
||||||
|
<select id="fTipoUsuario" class="form-select form-select-sm">
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="01">01 - Contributivo</option>
|
||||||
|
<option value="02">02 - Subsidiado</option>
|
||||||
|
<option value="03">03 - Vinculado</option>
|
||||||
|
<option value="04">04 - Particular</option>
|
||||||
|
<option value="05">05 - ARP</option>
|
||||||
|
<option value="06">06 - Póliza</option>
|
||||||
|
<option value="07">07 - Estudiante</option>
|
||||||
|
<option value="08">08 - Empleado</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<label class="form-label fw-semibold small">Tipo usuario SISPRO</label>
|
||||||
|
<input type="text" id="fTipoUsuarioSispro" class="form-control form-control-sm"
|
||||||
|
placeholder="Código SISPRO" maxlength="10">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<label class="form-label fw-semibold small">Cod. tercero</label>
|
||||||
|
<input type="text" id="fCodTercero" class="form-control form-control-sm"
|
||||||
|
placeholder="Cód. tercero" maxlength="50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fila 5: Contrato / Centro costo -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<label class="form-label fw-semibold small">Cód. contrato</label>
|
||||||
|
<input type="text" id="fCodContrato" class="form-control form-control-sm"
|
||||||
|
placeholder="Número de contrato" maxlength="50">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<label class="form-label fw-semibold small">Centro de costo</label>
|
||||||
|
<input type="text" id="fCentroCosto" class="form-control form-control-sm"
|
||||||
|
placeholder="Centro de costo" maxlength="50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fila 6: Checks -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="form-check form-switch mt-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="fReqAutoriza">
|
||||||
|
<label class="form-check-label small" for="fReqAutoriza">
|
||||||
|
Exige número de autorización
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="form-check form-switch mt-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="fActiva" checked>
|
||||||
|
<label class="form-check-label small" for="fActiva">Activa</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mensaje de error -->
|
||||||
|
<div id="formError" class="alert alert-danger d-none py-2 small"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="btnGuardarEmpresa" onclick="guardarEmpresa()">
|
||||||
|
<i class="fas fa-save me-1"></i>Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════
|
||||||
|
Modal subgrupos
|
||||||
|
═══════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="modal fade" id="modalSubgrupos" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="fas fa-layer-group me-2 text-secondary"></i>
|
||||||
|
Subgrupos — <span id="subNitNombre"></span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="listaSubgrupos" class="mb-3"></div>
|
||||||
|
<hr>
|
||||||
|
<h6 class="small fw-semibold text-muted text-uppercase mb-2">Agregar / editar subgrupo</h6>
|
||||||
|
<input type="hidden" id="subId" value="">
|
||||||
|
<input type="hidden" id="subNitEmpresa" value="">
|
||||||
|
<div class="row g-2 mb-2">
|
||||||
|
<div class="col-12">
|
||||||
|
<input type="text" id="subNombre" class="form-control form-control-sm"
|
||||||
|
placeholder="Nombre del subgrupo" maxlength="100">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<select id="subTarifaId" class="form-select form-select-sm">
|
||||||
|
<option value="">— Tarifa de la empresa —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<input type="text" id="subRefSubgrupo" class="form-control form-control-sm"
|
||||||
|
placeholder="Ref. subgrupo" maxlength="50">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<input type="text" id="subCodContrato" class="form-control form-control-sm"
|
||||||
|
placeholder="Cód. contrato subgrupo" maxlength="50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="subError" class="alert alert-danger d-none py-2 small mb-2"></div>
|
||||||
|
<button class="btn btn-sm btn-primary w-100" onclick="guardarSubgrupo()">
|
||||||
|
<i class="fas fa-save me-1"></i>Guardar subgrupo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.badge-tarifa { font-size:.72rem; background:#e0f2fe; color:#0369a1; border-radius:4px; padding:2px 7px; }
|
||||||
|
.badge-activa { font-size:.72rem; }
|
||||||
|
.sub-row { background:#f8fafc; border-radius:6px; padding:8px 12px; margin-bottom:6px;
|
||||||
|
border:1px solid #e2e8f0; display:flex; align-items:center; gap:8px; }
|
||||||
|
.sub-row .sub-info { flex:1 }
|
||||||
|
.sub-row .sub-info .sub-nombre { font-weight:600; font-size:.9rem; }
|
||||||
|
.sub-row .sub-info .sub-meta { font-size:.78rem; color:#64748b; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const BASE = '<?= BASE_URL ?>';
|
||||||
|
let _pagina = 1;
|
||||||
|
let _pages = 1;
|
||||||
|
let _tarifas = [];
|
||||||
|
let _modalEmpresa, _modalSubgrupos;
|
||||||
|
|
||||||
|
// ─── Init ─────────────────────────────────────────────────────
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
_modalEmpresa = new bootstrap.Modal(document.getElementById('modalEmpresa'));
|
||||||
|
_modalSubgrupos = new bootstrap.Modal(document.getElementById('modalSubgrupos'));
|
||||||
|
cargarTarifas().then(() => cargarEmpresas());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Tarifas ──────────────────────────────────────────────────
|
||||||
|
async function cargarTarifas() {
|
||||||
|
const res = await fetch(`${BASE}/api/lab/tarifas_id.php`);
|
||||||
|
const j = await res.json();
|
||||||
|
_tarifas = j.tarifas || [];
|
||||||
|
const opts = _tarifas.map(t =>
|
||||||
|
`<option value="${t.id}">${escHtml(t.nombre)}${t.porcentaje > 0 ? ` (+${t.porcentaje}%)` : ''}</option>`
|
||||||
|
).join('');
|
||||||
|
document.getElementById('fTarifaId').innerHTML = '<option value="">— Sin tarifa especial —</option>' + opts;
|
||||||
|
document.getElementById('subTarifaId').innerHTML = '<option value="">— Tarifa de la empresa —</option>' + opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lista empresas ───────────────────────────────────────────
|
||||||
|
let _debTimer;
|
||||||
|
function debounceCargar() {
|
||||||
|
clearTimeout(_debTimer);
|
||||||
|
_debTimer = setTimeout(() => { _pagina = 1; cargarEmpresas(); }, 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cargarEmpresas() {
|
||||||
|
const search = document.getElementById('filtroSearch').value.trim();
|
||||||
|
const activa = document.getElementById('filtroActiva').value;
|
||||||
|
const params = new URLSearchParams({ action:'list', page:_pagina, limit:30 });
|
||||||
|
if (search) params.append('search', search);
|
||||||
|
if (activa !== '') params.append('activa', activa);
|
||||||
|
|
||||||
|
const tbody = document.getElementById('tbodyEmpresas');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-3 text-muted"><i class="fas fa-spinner fa-spin me-2"></i>Cargando…</td></tr>';
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE}/api/lab/empresas.php?${params}`);
|
||||||
|
const j = await res.json();
|
||||||
|
|
||||||
|
_pages = j.pages || 1;
|
||||||
|
document.getElementById('lblTotal').textContent = `${j.total} empresa(s)`;
|
||||||
|
document.getElementById('lblPagina').textContent = `Página ${_pagina} de ${_pages}`;
|
||||||
|
document.getElementById('btnPrev').disabled = _pagina <= 1;
|
||||||
|
document.getElementById('btnNext').disabled = _pagina >= _pages;
|
||||||
|
|
||||||
|
if (!j.empresas || !j.empresas.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-4 text-muted">Sin resultados</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = j.empresas.map(e => `
|
||||||
|
<tr>
|
||||||
|
<td class="text-monospace small fw-semibold">${escHtml(e.nit)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">${escHtml(e.nombre)}</div>
|
||||||
|
${e.razon_social ? `<div class="text-muted small">${escHtml(e.razon_social)}</div>` : ''}
|
||||||
|
</td>
|
||||||
|
<td>${e.tarifa_nombre ? `<span class="badge-tarifa">${escHtml(e.tarifa_nombre)}</span>` : '<span class="text-muted small">—</span>'}</td>
|
||||||
|
<td class="text-end small">${parseFloat(e.descuento_pct) > 0 ? escHtml(e.descuento_pct)+'%' : '—'}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
${parseInt(e.total_subgrupos) > 0
|
||||||
|
? `<button class="btn btn-link btn-sm p-0 text-primary" onclick="abrirSubgrupos('${escAttr(e.nit)}','${escAttr(e.nombre)}')">${e.total_subgrupos} <i class="fas fa-layer-group ms-1"></i></button>`
|
||||||
|
: `<button class="btn btn-link btn-sm p-0 text-muted" onclick="abrirSubgrupos('${escAttr(e.nit)}','${escAttr(e.nombre)}')">+ subgrupo</button>`
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">${parseInt(e.req_autoriza) ? '<i class="fas fa-check-circle text-warning" title="Exige autorización"></i>' : '—'}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge ${parseInt(e.activa) ? 'bg-success' : 'bg-secondary'} badge-activa">
|
||||||
|
${parseInt(e.activa) ? 'Activa' : 'Inactiva'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-2">
|
||||||
|
<button class="btn btn-outline-primary btn-sm py-0 px-2 me-1" onclick="abrirModal('${escAttr(e.nit)}')" title="Editar">
|
||||||
|
<i class="fas fa-pen"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-${parseInt(e.activa) ? 'warning' : 'success'} btn-sm py-0 px-2" onclick="toggleActiva('${escAttr(e.nit)}')" title="${parseInt(e.activa) ? 'Desactivar' : 'Activar'}">
|
||||||
|
<i class="fas fa-${parseInt(e.activa) ? 'ban' : 'check'}"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cambiarPagina(delta) {
|
||||||
|
_pagina = Math.max(1, Math.min(_pages, _pagina + delta));
|
||||||
|
cargarEmpresas();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modal empresa ────────────────────────────────────────────
|
||||||
|
async function abrirModal(nit = null) {
|
||||||
|
resetFormError();
|
||||||
|
document.getElementById('fNitOrig').value = nit || '';
|
||||||
|
document.getElementById('modalEmpresaTitulo').textContent = nit ? 'Editar empresa' : 'Nueva empresa';
|
||||||
|
document.getElementById('fNit').readOnly = !!nit;
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
['fNit','fNombre','fRazonSocial','fCodigoEps','fTipoUsuarioSispro',
|
||||||
|
'fCodTercero','fCodContrato','fCentroCosto'].forEach(id => document.getElementById(id).value = '');
|
||||||
|
document.getElementById('fTarifaId').value = '';
|
||||||
|
document.getElementById('fTipoUsuario').value = '';
|
||||||
|
document.getElementById('fDescuentoPct').value= '0';
|
||||||
|
document.getElementById('fReqAutoriza').checked = false;
|
||||||
|
document.getElementById('fActiva').checked = true;
|
||||||
|
|
||||||
|
if (nit) {
|
||||||
|
const res = await fetch(`${BASE}/api/lab/empresas.php?action=get&nit=${encodeURIComponent(nit)}`);
|
||||||
|
const j = await res.json();
|
||||||
|
const e = j.empresa;
|
||||||
|
document.getElementById('fNit').value = e.nit;
|
||||||
|
document.getElementById('fNombre').value = e.nombre;
|
||||||
|
document.getElementById('fRazonSocial').value = e.razon_social || '';
|
||||||
|
document.getElementById('fTarifaId').value = e.tarifa_id || '';
|
||||||
|
document.getElementById('fDescuentoPct').value = e.descuento_pct || 0;
|
||||||
|
document.getElementById('fCodigoEps').value = e.codigo_eps || '';
|
||||||
|
document.getElementById('fTipoUsuario').value = e.tipo_usuario || '';
|
||||||
|
document.getElementById('fTipoUsuarioSispro').value = e.tipo_usuario_sispro || '';
|
||||||
|
document.getElementById('fCodTercero').value = e.cod_tercero || '';
|
||||||
|
document.getElementById('fCodContrato').value = e.cod_contrato || '';
|
||||||
|
document.getElementById('fCentroCosto').value = e.centro_costo || '';
|
||||||
|
document.getElementById('fReqAutoriza').checked = !!parseInt(e.req_autoriza);
|
||||||
|
document.getElementById('fActiva').checked = !!parseInt(e.activa);
|
||||||
|
}
|
||||||
|
_modalEmpresa.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardarEmpresa() {
|
||||||
|
resetFormError();
|
||||||
|
const nit = document.getElementById('fNit').value.trim();
|
||||||
|
if (!nit) return showFormError('El NIT es obligatorio');
|
||||||
|
if (!document.getElementById('fNombre').value.trim()) return showFormError('El nombre es obligatorio');
|
||||||
|
|
||||||
|
const btn = document.getElementById('btnGuardarEmpresa');
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
action: 'save',
|
||||||
|
nit,
|
||||||
|
nombre: document.getElementById('fNombre').value.trim(),
|
||||||
|
razon_social: document.getElementById('fRazonSocial').value.trim(),
|
||||||
|
tarifa_id: document.getElementById('fTarifaId').value,
|
||||||
|
descuento_pct: document.getElementById('fDescuentoPct').value,
|
||||||
|
codigo_eps: document.getElementById('fCodigoEps').value.trim(),
|
||||||
|
tipo_usuario: document.getElementById('fTipoUsuario').value,
|
||||||
|
tipo_usuario_sispro: document.getElementById('fTipoUsuarioSispro').value.trim(),
|
||||||
|
cod_tercero: document.getElementById('fCodTercero').value.trim(),
|
||||||
|
cod_contrato: document.getElementById('fCodContrato').value.trim(),
|
||||||
|
centro_costo: document.getElementById('fCentroCosto').value.trim(),
|
||||||
|
req_autoriza: document.getElementById('fReqAutoriza').checked,
|
||||||
|
activa: document.getElementById('fActiva').checked,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/api/lab/empresas.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (!j.ok) return showFormError(j.error || 'Error al guardar');
|
||||||
|
_modalEmpresa.hide();
|
||||||
|
cargarEmpresas();
|
||||||
|
} catch(e) {
|
||||||
|
showFormError('Error de red: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActiva(nit) {
|
||||||
|
await fetch(`${BASE}/api/lab/empresas.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({action:'toggle', nit})
|
||||||
|
});
|
||||||
|
cargarEmpresas();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFormError(msg) { const el = document.getElementById('formError'); el.textContent = msg; el.classList.remove('d-none'); }
|
||||||
|
function resetFormError() { document.getElementById('formError').classList.add('d-none'); }
|
||||||
|
|
||||||
|
// ─── Modal subgrupos ─────────────────────────────────────────
|
||||||
|
async function abrirSubgrupos(nit, nombre) {
|
||||||
|
document.getElementById('subNitNombre').textContent = nombre;
|
||||||
|
document.getElementById('subNitEmpresa').value = nit;
|
||||||
|
document.getElementById('subId').value = '';
|
||||||
|
['subNombre','subRefSubgrupo','subCodContrato'].forEach(id => document.getElementById(id).value = '');
|
||||||
|
document.getElementById('subTarifaId').value = '';
|
||||||
|
document.getElementById('subError').classList.add('d-none');
|
||||||
|
await recargarSubgrupos(nit);
|
||||||
|
_modalSubgrupos.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recargarSubgrupos(nit) {
|
||||||
|
if (!nit) nit = document.getElementById('subNitEmpresa').value;
|
||||||
|
const res = await fetch(`${BASE}/api/lab/empresa_subgrupos.php?nit_empresa=${encodeURIComponent(nit)}`);
|
||||||
|
const j = await res.json();
|
||||||
|
const lista = document.getElementById('listaSubgrupos');
|
||||||
|
if (!j.subgrupos || !j.subgrupos.length) {
|
||||||
|
lista.innerHTML = '<p class="text-muted small text-center">Sin subgrupos</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lista.innerHTML = j.subgrupos.map(s => `
|
||||||
|
<div class="sub-row">
|
||||||
|
<div class="sub-info">
|
||||||
|
<div class="sub-nombre">${escHtml(s.subgrupo)}</div>
|
||||||
|
<div class="sub-meta">
|
||||||
|
${s.tarifa_nombre ? `<span class="badge-tarifa me-2">${escHtml(s.tarifa_nombre)}</span>` : ''}
|
||||||
|
${s.ref_subgrupo ? `Ref: ${escHtml(s.ref_subgrupo)}` : ''}
|
||||||
|
${s.cod_contrato ? ` · Cto: ${escHtml(s.cod_contrato)}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-primary btn-sm py-0 px-2" onclick="editarSubgrupo(${s.id},'${escAttr(s.subgrupo)}','${escAttr(s.tarifa_id||'')}','${escAttr(s.ref_subgrupo||'')}','${escAttr(s.cod_contrato||'')}')" title="Editar">
|
||||||
|
<i class="fas fa-pen"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger btn-sm py-0 px-2" onclick="eliminarSubgrupo(${s.id})" title="Eliminar">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editarSubgrupo(id, nombre, tarifaId, ref, codCon) {
|
||||||
|
document.getElementById('subId').value = id;
|
||||||
|
document.getElementById('subNombre').value = nombre;
|
||||||
|
document.getElementById('subTarifaId').value = tarifaId;
|
||||||
|
document.getElementById('subRefSubgrupo').value = ref;
|
||||||
|
document.getElementById('subCodContrato').value = codCon;
|
||||||
|
document.getElementById('subNombre').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardarSubgrupo() {
|
||||||
|
const errEl = document.getElementById('subError');
|
||||||
|
errEl.classList.add('d-none');
|
||||||
|
const nit = document.getElementById('subNitEmpresa').value;
|
||||||
|
const nombre = document.getElementById('subNombre').value.trim();
|
||||||
|
if (!nombre) { errEl.textContent='El nombre es obligatorio'; errEl.classList.remove('d-none'); return; }
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
action: 'save',
|
||||||
|
id: document.getElementById('subId').value || null,
|
||||||
|
nit_empresa: nit,
|
||||||
|
subgrupo: nombre,
|
||||||
|
tarifa_id: document.getElementById('subTarifaId').value,
|
||||||
|
ref_subgrupo: document.getElementById('subRefSubgrupo').value.trim(),
|
||||||
|
cod_contrato: document.getElementById('subCodContrato').value.trim(),
|
||||||
|
};
|
||||||
|
const res = await fetch(`${BASE}/api/lab/empresa_subgrupos.php`, {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (!j.ok) { errEl.textContent = j.error || 'Error'; errEl.classList.remove('d-none'); return; }
|
||||||
|
// Reset form
|
||||||
|
document.getElementById('subId').value = '';
|
||||||
|
['subNombre','subRefSubgrupo','subCodContrato'].forEach(id => document.getElementById(id).value='');
|
||||||
|
document.getElementById('subTarifaId').value = '';
|
||||||
|
recargarSubgrupos(nit);
|
||||||
|
cargarEmpresas();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eliminarSubgrupo(id) {
|
||||||
|
if (!confirm('¿Eliminar este subgrupo?')) return;
|
||||||
|
const nit = document.getElementById('subNitEmpresa').value;
|
||||||
|
await fetch(`${BASE}/api/lab/empresa_subgrupos.php`, {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({action:'delete', id})
|
||||||
|
});
|
||||||
|
recargarSubgrupos(nit);
|
||||||
|
cargarEmpresas();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────
|
||||||
|
function escHtml(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
function escAttr(s) { return escHtml(s).replace(/'/g,'''); }
|
||||||
|
</script>
|
||||||
|
<?php Layout::close(); ?>
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* modules/lab_empresas/views/tarifas.php
|
||||||
|
* CRUD del catálogo de tarifas (lab_tarifas_id).
|
||||||
|
*/
|
||||||
|
require_once APP_ROOT . '/config/config.php';
|
||||||
|
Layout::open('Catálogo de Tarifas', 'fas fa-tags');
|
||||||
|
?>
|
||||||
|
<div class="container-fluid py-3">
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0"><i class="fas fa-tags me-2 text-primary"></i>Catálogo de Tarifas</h4>
|
||||||
|
<small class="text-muted">IDs de tarifa usados en el motor de precios</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="<?= BASE_URL ?>/erp.php?m=lab_empresas&v=index"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="fas fa-building me-1"></i>Empresas
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="abrirForm()">
|
||||||
|
<i class="fas fa-plus me-1"></i>Nueva tarifa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Tabla tarifas -->
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-hover table-sm mb-0" id="tablaTarifas">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width:60px">ID</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th class="text-end">%</th>
|
||||||
|
<th>Origen</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbodyTarifas">
|
||||||
|
<tr><td colspan="5" class="text-center py-4 text-muted">Cargando…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mt-2">
|
||||||
|
<i class="fas fa-info-circle me-1"></i>
|
||||||
|
Tarifas con <strong>% > 0</strong> y <strong>Origen</strong> se calculan automáticamente
|
||||||
|
como: precio_origen × (1 + %/100).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulario -->
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header py-2">
|
||||||
|
<h6 class="mb-0 small fw-semibold" id="formTitulo">Nueva tarifa</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold small">ID numérico</label>
|
||||||
|
<input type="number" id="tId" class="form-control form-control-sm"
|
||||||
|
placeholder="Dejar vacío para auto-asignar" min="1">
|
||||||
|
<div class="form-text">El ID debe coincidir con el ID Firebird si se va a migrar.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold small">Nombre <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" id="tNombre" class="form-control form-control-sm"
|
||||||
|
placeholder="Ej: PARTICULAR, EPS SURA" maxlength="150">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold small">Porcentaje sobre tarifa origen</label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="number" id="tPorcentaje" class="form-control"
|
||||||
|
value="0" min="0" step="0.01" max="999">
|
||||||
|
<span class="input-group-text">%</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">0 = precios fijos. >0 = derivada de otra tarifa.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold small">Tarifa origen</label>
|
||||||
|
<select id="tOrigen" class="form-select form-select-sm">
|
||||||
|
<option value="">— Precios directos —</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Solo si esta tarifa deriva de otra por porcentaje.</div>
|
||||||
|
</div>
|
||||||
|
<div id="tError" class="alert alert-danger d-none py-2 small mb-2"></div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-primary btn-sm flex-fill" onclick="guardarTarifa()">
|
||||||
|
<i class="fas fa-save me-1"></i>Guardar
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="resetForm()">Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const BASE = '<?= BASE_URL ?>';
|
||||||
|
let _tarifas = [];
|
||||||
|
let _editId = null;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => cargarTarifas());
|
||||||
|
|
||||||
|
async function cargarTarifas() {
|
||||||
|
const res = await fetch(`${BASE}/api/lab/tarifas_id.php`);
|
||||||
|
const j = await res.json();
|
||||||
|
_tarifas = j.tarifas || [];
|
||||||
|
|
||||||
|
// Poblar select origen (excluyendo la propia tarifa en edición)
|
||||||
|
const optsOrigen = _tarifas
|
||||||
|
.filter(t => _editId === null || t.id != _editId)
|
||||||
|
.map(t => `<option value="${t.id}">${escHtml(t.nombre)} (ID ${t.id})</option>`)
|
||||||
|
.join('');
|
||||||
|
document.getElementById('tOrigen').innerHTML = '<option value="">— Precios directos —</option>' + optsOrigen;
|
||||||
|
|
||||||
|
const tbody = document.getElementById('tbodyTarifas');
|
||||||
|
if (!_tarifas.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="text-center py-4 text-muted">Sin tarifas registradas</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = _tarifas.map(t => `
|
||||||
|
<tr>
|
||||||
|
<td class="text-monospace fw-semibold text-primary">${t.id}</td>
|
||||||
|
<td>${escHtml(t.nombre)}</td>
|
||||||
|
<td class="text-end">${parseFloat(t.porcentaje) > 0 ? `<span class="badge bg-info text-dark">+${t.porcentaje}%</span>` : '—'}</td>
|
||||||
|
<td>${t.tarifa_origen_nombre ? `<span class="small text-muted">${escHtml(t.tarifa_origen_nombre)}</span>` : '—'}</td>
|
||||||
|
<td class="text-end pe-2">
|
||||||
|
<button class="btn btn-outline-primary btn-sm py-0 px-2 me-1"
|
||||||
|
onclick="editarTarifa(${t.id})" title="Editar">
|
||||||
|
<i class="fas fa-pen"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger btn-sm py-0 px-2"
|
||||||
|
onclick="eliminarTarifa(${t.id})" title="Eliminar">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editarTarifa(id) {
|
||||||
|
const t = _tarifas.find(x => x.id == id);
|
||||||
|
if (!t) return;
|
||||||
|
_editId = id;
|
||||||
|
document.getElementById('formTitulo').textContent = 'Editar tarifa';
|
||||||
|
document.getElementById('tId').value = t.id;
|
||||||
|
document.getElementById('tId').readOnly = true;
|
||||||
|
document.getElementById('tNombre').value = t.nombre;
|
||||||
|
document.getElementById('tPorcentaje').value = t.porcentaje;
|
||||||
|
document.getElementById('tOrigen').value = t.tarifa_origen || '';
|
||||||
|
document.getElementById('tError').classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
_editId = null;
|
||||||
|
document.getElementById('formTitulo').textContent = 'Nueva tarifa';
|
||||||
|
document.getElementById('tId').value = '';
|
||||||
|
document.getElementById('tId').readOnly = false;
|
||||||
|
document.getElementById('tNombre').value = '';
|
||||||
|
document.getElementById('tPorcentaje').value = '0';
|
||||||
|
document.getElementById('tOrigen').value = '';
|
||||||
|
document.getElementById('tError').classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirForm() { resetForm(); document.getElementById('tNombre').focus(); }
|
||||||
|
|
||||||
|
async function guardarTarifa() {
|
||||||
|
const errEl = document.getElementById('tError');
|
||||||
|
errEl.classList.add('d-none');
|
||||||
|
const nombre = document.getElementById('tNombre').value.trim();
|
||||||
|
if (!nombre) { errEl.textContent='El nombre es obligatorio'; errEl.classList.remove('d-none'); return; }
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
action: 'save',
|
||||||
|
nombre,
|
||||||
|
porcentaje: document.getElementById('tPorcentaje').value,
|
||||||
|
tarifa_origen:document.getElementById('tOrigen').value,
|
||||||
|
};
|
||||||
|
const idVal = document.getElementById('tId').value.trim();
|
||||||
|
if (idVal) payload.id = parseInt(idVal);
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE}/api/lab/tarifas_id.php`, {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (!j.ok) { errEl.textContent = j.error || 'Error'; errEl.classList.remove('d-none'); return; }
|
||||||
|
resetForm();
|
||||||
|
cargarTarifas();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eliminarTarifa(id) {
|
||||||
|
if (!confirm('¿Eliminar esta tarifa? Solo es posible si no tiene precios de exámenes asociados.')) return;
|
||||||
|
const res = await fetch(`${BASE}/api/lab/tarifas_id.php`, {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({action:'delete', id})
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (!j.ok) { alert(j.error || 'Error al eliminar'); return; }
|
||||||
|
cargarTarifas();
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<?php Layout::close(); ?>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php return [
|
||||||
|
'slug' => 'lab_eps',
|
||||||
|
'name' => 'EPS',
|
||||||
|
'icon' => 'fas fa-hospital',
|
||||||
|
'category' => 'lab',
|
||||||
|
'route' => '/lab_eps.php',
|
||||||
|
'is_active' => true,
|
||||||
|
'sort_order' => 22,
|
||||||
|
'oleada' => 0,
|
||||||
|
'description' => 'Gestión de EPS y aseguradoras',
|
||||||
|
'links' => [['name' => 'EPS', 'icon' => 'fas fa-hospital', 'route' => '/lab_eps.php']],
|
||||||
|
];
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||||
|
requireRole('admin');
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>EPS — <?= htmlspecialchars($_cfg['empresa_nombre'] ?? 'ERP') ?></title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link href="<?= BASE_URL ?>assets/css/styles.css?v=15" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.eps-table { width:100%; border-collapse:collapse; font-size:.9rem; }
|
||||||
|
.eps-table th { background:#f8fafc; font-weight:700; font-size:.75rem; text-transform:uppercase;
|
||||||
|
letter-spacing:.06em; color:#64748b; padding:.6rem 1rem; border-bottom:2px solid #e2e8f0; }
|
||||||
|
.eps-table td { padding:.65rem 1rem; border-bottom:1px solid #f1f5f9; vertical-align:middle; }
|
||||||
|
.eps-table tr:hover td { background:#f8fafc; }
|
||||||
|
.badge-activa { background:#dcfce7; color:#166534; font-size:.7rem; font-weight:700;
|
||||||
|
padding:2px 8px; border-radius:99px; }
|
||||||
|
.badge-inactiva { background:#f1f5f9; color:#94a3b8; font-size:.7rem; font-weight:700;
|
||||||
|
padding:2px 8px; border-radius:99px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<?php include APP_ROOT . '/partials/navbar.php'; ?>
|
||||||
|
|
||||||
|
<div class="container-fluid py-4" style="max-width:720px">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-hospital me-2 text-primary"></i>EPS / Aseguradoras</h5>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="abrirModal()">
|
||||||
|
<i class="fas fa-plus me-1"></i>Nueva EPS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="eps-table" id="tbl-eps">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th style="width:90px">Estado</th>
|
||||||
|
<th style="width:110px">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbody-eps">
|
||||||
|
<tr><td colspan="3" class="text-muted text-center py-3">Cargando…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div class="modal fade" id="modal-eps" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-sm">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h6 class="modal-title" id="modal-eps-titulo">Nueva EPS</h6>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="eps-id">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small fw-semibold">Nombre <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" id="eps-nombre" class="form-control" maxlength="120"
|
||||||
|
placeholder="Ej: Nueva EPS, Sanitas, Sura…">
|
||||||
|
</div>
|
||||||
|
<div id="eps-error" class="text-danger small d-none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="guardarEps()">
|
||||||
|
<i class="fas fa-save me-1"></i>Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_EPS = '<?= BASE_URL ?>api/lab/eps.php';
|
||||||
|
let _modal;
|
||||||
|
|
||||||
|
async function cargarEps() {
|
||||||
|
const r = await fetch(API_EPS + '?action=list');
|
||||||
|
const j = await r.json();
|
||||||
|
const tbody = document.getElementById('tbody-eps');
|
||||||
|
if (!j.ok || !j.eps.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="3" class="text-muted text-center py-3">Sin registros</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = j.eps.map(e => `
|
||||||
|
<tr id="row-${e.id}">
|
||||||
|
<td>${escHtml(e.nombre)}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge-${e.activa == 1 ? 'activa' : 'inactiva'}">
|
||||||
|
${e.activa == 1 ? 'Activa' : 'Inactiva'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="d-flex gap-1">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" title="Editar" onclick="abrirModal(${e.id},'${escHtml(e.nombre).replace(/'/g,"\\'")}')">
|
||||||
|
<i class="fas fa-pen"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-${e.activa == 1 ? 'warning' : 'success'} btn-sm" title="${e.activa == 1 ? 'Desactivar' : 'Activar'}" onclick="toggleEps(${e.id})">
|
||||||
|
<i class="fas fa-${e.activa == 1 ? 'ban' : 'check'}"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" title="Eliminar" onclick="eliminarEps(${e.id},'${escHtml(e.nombre).replace(/'/g,"\\'")}')">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirModal(id = 0, nombre = '') {
|
||||||
|
document.getElementById('eps-id').value = id;
|
||||||
|
document.getElementById('eps-nombre').value = nombre;
|
||||||
|
document.getElementById('eps-error').classList.add('d-none');
|
||||||
|
document.getElementById('modal-eps-titulo').textContent = id ? 'Editar EPS' : 'Nueva EPS';
|
||||||
|
_modal = _modal || new bootstrap.Modal(document.getElementById('modal-eps'));
|
||||||
|
_modal.show();
|
||||||
|
setTimeout(() => document.getElementById('eps-nombre').focus(), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardarEps() {
|
||||||
|
const nombre = document.getElementById('eps-nombre').value.trim();
|
||||||
|
const id = parseInt(document.getElementById('eps-id').value) || 0;
|
||||||
|
const errEl = document.getElementById('eps-error');
|
||||||
|
errEl.classList.add('d-none');
|
||||||
|
if (!nombre) { errEl.textContent = 'El nombre es requerido.'; errEl.classList.remove('d-none'); return; }
|
||||||
|
|
||||||
|
const r = await fetch(API_EPS, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'save', id, nombre }),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!j.ok) { errEl.textContent = j.error || 'Error'; errEl.classList.remove('d-none'); return; }
|
||||||
|
_modal.hide();
|
||||||
|
cargarEps();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEps(id) {
|
||||||
|
await fetch(API_EPS, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'toggle', id }),
|
||||||
|
});
|
||||||
|
cargarEps();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eliminarEps(id, nombre) {
|
||||||
|
if (!confirm(`¿Eliminar "${nombre}"?`)) return;
|
||||||
|
const r = await fetch(API_EPS, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'delete', id }),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!j.ok) { alert(j.error); return; }
|
||||||
|
cargarEps();
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.appendChild(document.createTextNode(String(str)));
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('eps-nombre').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') guardarEps();
|
||||||
|
});
|
||||||
|
|
||||||
|
cargarEps();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
ob_start();
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
error_reporting(E_ERROR | E_PARSE);
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
ini_set('html_errors', '0');
|
||||||
|
|
||||||
|
register_shutdown_function(function () {
|
||||||
|
$err = error_get_last();
|
||||||
|
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||||
|
ob_clean();
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Error interno: ' . $err['message']]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
|
||||||
|
|
||||||
|
function jsonOk(array $data = [], string $msg = ''): never {
|
||||||
|
ob_clean();
|
||||||
|
$r = ['ok' => true];
|
||||||
|
if ($msg) $r['message'] = $msg;
|
||||||
|
if ($data) $r = array_merge($r, $data);
|
||||||
|
echo json_encode($r);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonError(string $msg, int $code = 400): never {
|
||||||
|
ob_clean();
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode(['ok' => false, 'error' => $msg]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAdmin(): void {
|
||||||
|
if (!isUserLoggedIn()) jsonError('No autorizado', 401);
|
||||||
|
$role = $_SESSION['admin_user']['role'] ?? '';
|
||||||
|
if (!in_array($role, ['superadmin', 'admin', 'supervisor'], true)) jsonError('Sin permiso', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireLogin(): void {
|
||||||
|
if (!isUserLoggedIn()) jsonError('No autorizado', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputJson(): array {
|
||||||
|
return json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function db(): PDO {
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
$pdo->exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireLogin();
|
||||||
|
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if (!$id) jsonError('ID requerido');
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$s = $pdo->prepare('SELECT * FROM exam_tipos WHERE id = ?');
|
||||||
|
$s->execute([$id]);
|
||||||
|
$exam = $s->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$exam) jsonError('Examen no encontrado', 404);
|
||||||
|
|
||||||
|
$items = $pdo->prepare(
|
||||||
|
'SELECT * FROM lab_items_resultado WHERE cod_protocolo = ? ORDER BY orden, id'
|
||||||
|
);
|
||||||
|
$items->execute([$exam['cod_protocolo'] ?? $exam['codigo']]);
|
||||||
|
|
||||||
|
jsonOk(['data' => $exam, 'items' => $items->fetchAll(PDO::FETCH_ASSOC)]);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireLogin();
|
||||||
|
|
||||||
|
$examId = (int)($_GET['exam_id'] ?? 0);
|
||||||
|
if (!$examId) jsonError('exam_id requerido');
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$s = $pdo->prepare('SELECT 1 FROM exam_tipos WHERE id = ?');
|
||||||
|
$s->execute([$examId]);
|
||||||
|
if (!$s->fetchColumn()) jsonError('Examen no encontrado', 404);
|
||||||
|
|
||||||
|
$s = $pdo->prepare(
|
||||||
|
"SELECT ti.id AS tarifa_id, ti.nombre AS tarifa_nombre,
|
||||||
|
lt.id AS precio_id, lt.valor, lt.recargo_urg, lt.recargo_fes, lt.recargo_esp
|
||||||
|
FROM lab_tarifas_id ti
|
||||||
|
LEFT JOIN lab_tarifas lt ON lt.tarifa_id = ti.id AND lt.exam_tipo_id = ?
|
||||||
|
ORDER BY ti.id"
|
||||||
|
);
|
||||||
|
$s->execute([$examId]);
|
||||||
|
|
||||||
|
jsonOk(['data' => $s->fetchAll(PDO::FETCH_ASSOC)]);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireLogin();
|
||||||
|
|
||||||
|
$q = trim($_GET['q'] ?? '');
|
||||||
|
$cat = trim($_GET['categoria'] ?? '');
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$limit = max(1, min(200, (int)($_GET['limit'] ?? 50)));
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($q !== '') {
|
||||||
|
$like = '%' . $q . '%';
|
||||||
|
$where[] = '(nombre LIKE ? OR codigo LIKE ? OR cups LIKE ?)';
|
||||||
|
$params[] = $like; $params[] = $like; $params[] = $like;
|
||||||
|
}
|
||||||
|
if ($cat !== '') {
|
||||||
|
$where[] = 'categoria = ?';
|
||||||
|
$params[] = $cat;
|
||||||
|
}
|
||||||
|
$wSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$s = $pdo->prepare("SELECT COUNT(*) FROM exam_tipos $wSql");
|
||||||
|
$s->execute($params);
|
||||||
|
$total = (int)$s->fetchColumn();
|
||||||
|
|
||||||
|
$s = $pdo->prepare(
|
||||||
|
"SELECT id, codigo, nombre, categoria, activo, cups, seremite, cod_protocolo, tipo_muestra
|
||||||
|
FROM exam_tipos $wSql ORDER BY categoria, nombre LIMIT $limit OFFSET $offset"
|
||||||
|
);
|
||||||
|
$s->execute($params);
|
||||||
|
|
||||||
|
jsonOk(['data' => $s->fetchAll(PDO::FETCH_ASSOC), 'total' => $total, 'page' => $page, 'limit' => $limit]);
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireAdmin();
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
||||||
|
|
||||||
|
$d = inputJson();
|
||||||
|
$id = (int)($d['id'] ?? 0);
|
||||||
|
|
||||||
|
if (!empty($d['_delete'])) {
|
||||||
|
if (!$id) jsonError('ID requerido');
|
||||||
|
$pdo = db();
|
||||||
|
$s = $pdo->prepare('SELECT COUNT(*) FROM turnero_examen_items WHERE exam_tipo_id = ?');
|
||||||
|
$s->execute([$id]);
|
||||||
|
if ($s->fetchColumn() > 0) jsonError('Hay solicitudes con este examen. Desactívelo en su lugar.');
|
||||||
|
$pdo->prepare('DELETE FROM exam_tipo_consentimientos WHERE exam_tipo_id = ?')->execute([$id]);
|
||||||
|
$pdo->prepare('DELETE FROM exam_tipos WHERE id = ?')->execute([$id]);
|
||||||
|
jsonOk([], 'Examen eliminado');
|
||||||
|
}
|
||||||
|
|
||||||
|
$codigo = strtoupper(trim($d['codigo'] ?? ''));
|
||||||
|
$nombre = trim($d['nombre'] ?? '');
|
||||||
|
$categoria = trim($d['categoria'] ?? '') ?: null;
|
||||||
|
$cups = trim($d['cups'] ?? '') ?: null;
|
||||||
|
$cod_prot = trim($d['cod_protocolo'] ?? '') ?: null;
|
||||||
|
$tipo_m = trim($d['tipo_muestra'] ?? '') ?: null;
|
||||||
|
$nivel = isset($d['nivel']) && $d['nivel'] !== '' ? (int)$d['nivel'] : null;
|
||||||
|
$abrev = trim($d['abreviatura'] ?? '') ?: null;
|
||||||
|
$seremite = (int)($d['seremite'] ?? 0);
|
||||||
|
$serecibe = trim($d['serecibe'] ?? '') ?: null;
|
||||||
|
$activo = (int)($d['activo'] ?? 1);
|
||||||
|
$req_ayuno = (int)($d['requiere_ayuno'] ?? 0);
|
||||||
|
$horas_ayuno = isset($d['horas_ayuno']) && $d['horas_ayuno'] !== '' ? (int)$d['horas_ayuno'] : null;
|
||||||
|
$instruc = trim($d['instrucciones'] ?? '') ?: null;
|
||||||
|
$formIds = array_values(array_unique(array_filter(array_map('intval', (array)($d['form_ids'] ?? [])))));
|
||||||
|
|
||||||
|
if ($codigo === '') jsonError('El código es requerido');
|
||||||
|
if ($nombre === '') jsonError('El nombre es requerido');
|
||||||
|
if (strlen($codigo) > 20) jsonError('Código máximo 20 caracteres');
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
if ($id) {
|
||||||
|
$pdo->prepare(
|
||||||
|
'UPDATE exam_tipos SET codigo=?,nombre=?,categoria=?,cups=?,cod_protocolo=?,tipo_muestra=?,
|
||||||
|
nivel=?,abreviatura=?,seremite=?,serecibe=?,activo=?,requiere_ayuno=?,horas_ayuno=?,instrucciones=?
|
||||||
|
WHERE id=?'
|
||||||
|
)->execute([$codigo,$nombre,$categoria,$cups,$cod_prot,$tipo_m,
|
||||||
|
$nivel,$abrev,$seremite,$serecibe,$activo,$req_ayuno,$horas_ayuno,$instruc,$id]);
|
||||||
|
$examId = $id;
|
||||||
|
} else {
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO exam_tipos (codigo,nombre,categoria,cups,cod_protocolo,tipo_muestra,
|
||||||
|
nivel,abreviatura,seremite,serecibe,activo,codigo_legacy,requiere_ayuno,horas_ayuno,instrucciones)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
|
||||||
|
)->execute([$codigo,$nombre,$categoria,$cups,$cod_prot,$tipo_m,
|
||||||
|
$nivel,$abrev,$seremite,$serecibe,$activo,$codigo,$req_ayuno,$horas_ayuno,$instruc]);
|
||||||
|
$examId = (int)$pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->prepare('DELETE FROM exam_tipo_consentimientos WHERE exam_tipo_id = ?')->execute([$examId]);
|
||||||
|
$ins = $pdo->prepare('INSERT INTO exam_tipo_consentimientos (exam_tipo_id, formulario_id) VALUES (?,?)');
|
||||||
|
foreach ($formIds as $fid) { $ins->execute([$examId, $fid]); }
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
jsonOk(['id' => $examId], $id ? 'Examen actualizado' : 'Examen creado');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
if ($e->getCode() === '23000') jsonError('Ya existe un examen con ese código');
|
||||||
|
jsonError('Error: ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireAdmin();
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
||||||
|
|
||||||
|
$d = inputJson();
|
||||||
|
$id = (int)($d['id'] ?? 0);
|
||||||
|
|
||||||
|
if (!empty($d['_delete'])) {
|
||||||
|
if (!$id) jsonError('ID requerido');
|
||||||
|
db()->prepare('DELETE FROM lab_items_resultado WHERE id = ?')->execute([$id]);
|
||||||
|
jsonOk([], 'Item eliminado');
|
||||||
|
}
|
||||||
|
|
||||||
|
$codProt = trim($d['cod_protocolo'] ?? '');
|
||||||
|
$nombre = trim($d['nombre'] ?? '');
|
||||||
|
if (!$codProt) jsonError('cod_protocolo requerido');
|
||||||
|
if (!$nombre) jsonError('Nombre requerido');
|
||||||
|
|
||||||
|
$tipoSexo = in_array($d['tipo_sexo'] ?? '', ['M','F']) ? $d['tipo_sexo'] : null;
|
||||||
|
$tipo = in_array($d['tipo'] ?? '', ['N','T']) ? $d['tipo'] : 'T';
|
||||||
|
$medida = trim($d['medida'] ?? '') ?: null;
|
||||||
|
$abrev = trim($d['abreviatura'] ?? '') ?: null;
|
||||||
|
$vmin = $d['vmin_ref'] !== '' && $d['vmin_ref'] !== null ? (float)$d['vmin_ref'] : null;
|
||||||
|
$vmax = $d['vmax_ref'] !== '' && $d['vmax_ref'] !== null ? (float)$d['vmax_ref'] : null;
|
||||||
|
$orden = (int)($d['orden'] ?? 0);
|
||||||
|
$formula = trim($d['formula'] ?? '') ?: null;
|
||||||
|
$cups_d = trim($d['cups_detalle'] ?? '') ?: null;
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
if ($id) {
|
||||||
|
$pdo->prepare(
|
||||||
|
'UPDATE lab_items_resultado SET cod_protocolo=?,nombre=?,tipo_sexo=?,tipo=?,medida=?,
|
||||||
|
abreviatura=?,vmin_ref=?,vmax_ref=?,orden=?,formula=?,cups_detalle=? WHERE id=?'
|
||||||
|
)->execute([$codProt,$nombre,$tipoSexo,$tipo,$medida,$abrev,$vmin,$vmax,$orden,$formula,$cups_d,$id]);
|
||||||
|
jsonOk(['id' => $id], 'Item actualizado');
|
||||||
|
} else {
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO lab_items_resultado
|
||||||
|
(cod_protocolo,nombre,tipo_sexo,tipo,medida,abreviatura,vmin_ref,vmax_ref,orden,formula,cups_detalle)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)'
|
||||||
|
)->execute([$codProt,$nombre,$tipoSexo,$tipo,$medida,$abrev,$vmin,$vmax,$orden,$formula,$cups_d]);
|
||||||
|
jsonOk(['id' => (int)$pdo->lastInsertId()], 'Item creado');
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireAdmin();
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
||||||
|
|
||||||
|
$d = inputJson();
|
||||||
|
$examId = (int)($d['exam_tipo_id'] ?? 0);
|
||||||
|
$tarifaId = (int)($d['tarifa_id'] ?? 0);
|
||||||
|
if (!$examId || !$tarifaId) jsonError('exam_tipo_id y tarifa_id son requeridos');
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
if (!empty($d['_delete'])) {
|
||||||
|
$pdo->prepare('DELETE FROM lab_tarifas WHERE exam_tipo_id = ? AND tarifa_id = ?')
|
||||||
|
->execute([$examId, $tarifaId]);
|
||||||
|
jsonOk([], 'Precio eliminado');
|
||||||
|
}
|
||||||
|
|
||||||
|
$valor = (float)($d['valor'] ?? 0);
|
||||||
|
$rec_urg = (float)($d['recargo_urg'] ?? 0);
|
||||||
|
$rec_fes = (float)($d['recargo_fes'] ?? 0);
|
||||||
|
$rec_esp = (float)($d['recargo_esp'] ?? 0);
|
||||||
|
|
||||||
|
$s = $pdo->prepare('SELECT COALESCE(codigo_legacy, codigo) FROM exam_tipos WHERE id = ?');
|
||||||
|
$s->execute([$examId]);
|
||||||
|
$leg = $s->fetchColumn();
|
||||||
|
if (!$leg) jsonError('Examen no encontrado', 404);
|
||||||
|
|
||||||
|
// Check if price row exists (no unique key in schema, so do it manually)
|
||||||
|
$s = $pdo->prepare('SELECT id FROM lab_tarifas WHERE exam_tipo_id = ? AND tarifa_id = ?');
|
||||||
|
$s->execute([$examId, $tarifaId]);
|
||||||
|
$precioId = $s->fetchColumn();
|
||||||
|
|
||||||
|
if ($precioId) {
|
||||||
|
$pdo->prepare(
|
||||||
|
'UPDATE lab_tarifas SET valor=?,recargo_urg=?,recargo_fes=?,recargo_esp=?,cod_examen_legacy=? WHERE id=?'
|
||||||
|
)->execute([$valor, $rec_urg, $rec_fes, $rec_esp, $leg, $precioId]);
|
||||||
|
} else {
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO lab_tarifas (cod_examen_legacy,exam_tipo_id,tarifa_id,valor,recargo_urg,recargo_fes,recargo_esp)
|
||||||
|
VALUES (?,?,?,?,?,?,?)'
|
||||||
|
)->execute([$leg, $examId, $tarifaId, $valor, $rec_urg, $rec_fes, $rec_esp]);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonOk([], 'Precio guardado');
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
return [
|
||||||
|
'slug' => 'lab_examenes',
|
||||||
|
'name' => 'Catálogo de Exámenes',
|
||||||
|
'icon' => 'fas fa-flask',
|
||||||
|
'category' => 'clinico',
|
||||||
|
'route' => '/erp.php?m=lab_examenes&v=index',
|
||||||
|
'is_active' => true,
|
||||||
|
'sort_order' => 60,
|
||||||
|
'oleada' => 2,
|
||||||
|
'description' => 'Gestión del catálogo de exámenes, valores de referencia y tarifas.',
|
||||||
|
'links' => [
|
||||||
|
['name' => 'Exámenes', 'icon' => 'fas fa-flask', 'route' => '/erp.php?m=lab_examenes&v=index'],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
<?php
|
||||||
|
require_once APP_ROOT . '/config/config.php';
|
||||||
|
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||||
|
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
|
||||||
|
$exam = null;
|
||||||
|
$items = [];
|
||||||
|
if ($id) {
|
||||||
|
$s = $pdo->prepare('SELECT * FROM exam_tipos WHERE id=?');
|
||||||
|
$s->execute([$id]);
|
||||||
|
$exam = $s->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$exam) { header('Location: ' . BASE_URL . 'erp.php?m=lab_examenes&v=index'); exit; }
|
||||||
|
|
||||||
|
$s = $pdo->prepare('SELECT * FROM lab_items_resultado WHERE cod_protocolo=? ORDER BY orden,id');
|
||||||
|
$s->execute([$exam['cod_protocolo'] ?? $exam['codigo']]);
|
||||||
|
$items = $s->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
$protocolos = $pdo->query('SELECT codigo, nombre FROM lab_protocolos ORDER BY nombre')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$muestras = $pdo->query('SELECT codigo, nombre FROM lab_tipos_muestra ORDER BY nombre')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$formularios = $pdo->query('SELECT id, nombre FROM lab_formularios ORDER BY nombre')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$formsActuales = [];
|
||||||
|
if ($id) {
|
||||||
|
$s = $pdo->prepare('SELECT formulario_id FROM exam_tipo_consentimientos WHERE exam_tipo_id=?');
|
||||||
|
$s->execute([$id]);
|
||||||
|
$formsActuales = $s->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$titulo = $exam ? 'Editar: ' . htmlspecialchars($exam['nombre']) : 'Nuevo Examen';
|
||||||
|
Layout::open($titulo, 'fas fa-flask');
|
||||||
|
$API = BASE_URL . 'modules/lab_examenes/api/';
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
body { background:#f1f5f9 }
|
||||||
|
.page-header { background:#fff; border-bottom:1px solid #e2e8f0; padding:12px 24px;
|
||||||
|
display:flex; align-items:center; gap:10px; flex-wrap:wrap }
|
||||||
|
.breadcrumb-lab { font-size:.82rem; color:#64748b }
|
||||||
|
.breadcrumb-lab a { color:#2563eb; text-decoration:none }
|
||||||
|
.content-wrap { max-width:1100px; margin:20px auto; padding:0 16px 60px }
|
||||||
|
.panel { background:#fff; border:1px solid #e2e8f0; border-radius:12px; margin-bottom:20px }
|
||||||
|
.panel-hdr { padding:14px 20px; border-bottom:1px solid #e2e8f0; display:flex; align-items:center; gap:8px }
|
||||||
|
.panel-hdr h2 { font-size:1rem; font-weight:700; color:#1e293b; margin:0; flex:1 }
|
||||||
|
.panel-body { padding:20px }
|
||||||
|
.form-label { font-size:.8rem; font-weight:600; color:#374151 }
|
||||||
|
/* items table */
|
||||||
|
.items-table { width:100%; border-collapse:collapse; font-size:.83rem }
|
||||||
|
.items-table th { background:#f8fafc; padding:7px 10px; font-size:.7rem; text-transform:uppercase;
|
||||||
|
letter-spacing:.06em; color:#64748b; border-bottom:1px solid #e2e8f0; white-space:nowrap }
|
||||||
|
.items-table td { padding:6px 8px; border-bottom:1px solid #f1f5f9; vertical-align:middle }
|
||||||
|
.items-table tr:last-child td { border-bottom:none }
|
||||||
|
.items-table input, .items-table select { font-size:.8rem; padding:3px 6px; border:1px solid #d1d5db;
|
||||||
|
border-radius:5px; width:100% }
|
||||||
|
.items-table input:focus, .items-table select:focus { outline:none; border-color:#6366f1 }
|
||||||
|
/* tarifas table */
|
||||||
|
.tar-table { width:100%; border-collapse:collapse; font-size:.83rem }
|
||||||
|
.tar-table th { background:#f8fafc; padding:7px 14px; font-size:.7rem; text-transform:uppercase;
|
||||||
|
letter-spacing:.06em; color:#64748b; border-bottom:1px solid #e2e8f0 }
|
||||||
|
.tar-table td { padding:7px 14px; border-bottom:1px solid #f1f5f9; vertical-align:middle }
|
||||||
|
.tar-table tr:last-child td { border-bottom:none }
|
||||||
|
.tar-table tr:hover td { background:#f8fafc }
|
||||||
|
.tar-val { font-family:monospace; font-size:.88rem }
|
||||||
|
.edit-inline { display:none; gap:4px; align-items:center }
|
||||||
|
.tr-editing .edit-inline { display:flex }
|
||||||
|
.tr-editing .val-display { display:none }
|
||||||
|
.inp-val { width:90px; padding:3px 6px; font-size:.83rem; border:1px solid #d1d5db; border-radius:5px }
|
||||||
|
.inp-val:focus { border-color:#6366f1; outline:none }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="breadcrumb-lab">
|
||||||
|
<a href="<?= BASE_URL ?>erp.php?m=lab_examenes&v=index"><i class="fas fa-flask me-1"></i>Exámenes</a>
|
||||||
|
<i class="fas fa-chevron-right mx-1" style="font-size:.7rem"></i>
|
||||||
|
<?= $exam ? htmlspecialchars($exam['nombre']) : 'Nuevo examen' ?>
|
||||||
|
</div>
|
||||||
|
<?php if ($exam): ?>
|
||||||
|
<button class="btn btn-outline-danger btn-sm ms-auto" onclick="eliminar(<?=$exam['id']?>, '<?= addslashes(htmlspecialchars($exam['nombre'])) ?>')">
|
||||||
|
<i class="fas fa-trash me-1"></i>Eliminar
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-wrap">
|
||||||
|
<div id="alerta-global" class="alert d-none mb-3 py-2"></div>
|
||||||
|
|
||||||
|
<!-- ── Datos generales ── -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-hdr">
|
||||||
|
<i class="fas fa-info-circle text-primary"></i>
|
||||||
|
<h2>Datos generales</h2>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="guardarExamen()">
|
||||||
|
<i class="fas fa-save me-1"></i>Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<input type="hidden" id="exam-id" value="<?= $exam['id'] ?? '' ?>">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-sm-3">
|
||||||
|
<label class="form-label">Código <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" id="f-codigo" class="form-control form-control-sm"
|
||||||
|
value="<?= htmlspecialchars($exam['codigo'] ?? '') ?>" maxlength="20" style="text-transform:uppercase">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-3">
|
||||||
|
<label class="form-label">Código CUPS</label>
|
||||||
|
<input type="text" id="f-cups" class="form-control form-control-sm"
|
||||||
|
value="<?= htmlspecialchars($exam['cups'] ?? '') ?>" maxlength="20">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-3">
|
||||||
|
<label class="form-label">Abreviatura</label>
|
||||||
|
<input type="text" id="f-abreviatura" class="form-control form-control-sm"
|
||||||
|
value="<?= htmlspecialchars($exam['abreviatura'] ?? '') ?>" maxlength="30">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-3">
|
||||||
|
<label class="form-label">Nivel</label>
|
||||||
|
<input type="number" id="f-nivel" class="form-control form-control-sm"
|
||||||
|
value="<?= $exam['nivel'] ?? '' ?>" min="0" max="9">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Nombre <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" id="f-nombre" class="form-control form-control-sm"
|
||||||
|
value="<?= htmlspecialchars($exam['nombre'] ?? '') ?>" maxlength="150">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<label class="form-label">Protocolo</label>
|
||||||
|
<select id="f-protocolo" class="form-select form-select-sm">
|
||||||
|
<option value="">— ninguno —</option>
|
||||||
|
<?php foreach ($protocolos as $p): ?>
|
||||||
|
<option value="<?= htmlspecialchars($p['codigo']) ?>"
|
||||||
|
<?= ($exam['cod_protocolo'] ?? '') === $p['codigo'] ? 'selected' : '' ?>>
|
||||||
|
<?= htmlspecialchars($p['codigo'] . ' – ' . $p['nombre']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<label class="form-label">Tipo de muestra</label>
|
||||||
|
<select id="f-muestra" class="form-select form-select-sm">
|
||||||
|
<option value="">— ninguna —</option>
|
||||||
|
<?php foreach ($muestras as $m): ?>
|
||||||
|
<option value="<?= htmlspecialchars($m['codigo']) ?>"
|
||||||
|
<?= ($exam['tipo_muestra'] ?? '') === $m['codigo'] ? 'selected' : '' ?>>
|
||||||
|
<?= htmlspecialchars($m['nombre']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<label class="form-label">Formularios consentimiento</label>
|
||||||
|
<div id="f-formularios-wrap" class="border rounded p-2" style="max-height:120px;overflow-y:auto;background:#fffbeb">
|
||||||
|
<?php foreach ($formularios as $f): ?>
|
||||||
|
<div class="form-check mb-1">
|
||||||
|
<input class="form-check-input f-formulario-chk" type="checkbox"
|
||||||
|
value="<?= $f['id'] ?>" id="f-fc-<?= $f['id'] ?>"
|
||||||
|
<?= in_array((int)$f['id'], array_map('intval', $formsActuales)) ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label small" for="f-fc-<?= $f['id'] ?>">
|
||||||
|
<?= htmlspecialchars($f['nombre']) ?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<label class="form-label">Se recibe de (lab externo)</label>
|
||||||
|
<input type="text" id="f-serecibe" class="form-control form-control-sm"
|
||||||
|
value="<?= htmlspecialchars($exam['serecibe'] ?? '') ?>" maxlength="50">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<label class="form-label">Instrucciones para el paciente</label>
|
||||||
|
<input type="text" id="f-instrucciones" class="form-control form-control-sm"
|
||||||
|
value="<?= htmlspecialchars($exam['instrucciones'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex gap-4 flex-wrap">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="f-seremite"
|
||||||
|
<?= ($exam['seremite'] ?? 0) ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label small" for="f-seremite">Se remite a laboratorio externo</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="f-ayuno"
|
||||||
|
<?= ($exam['requiere_ayuno'] ?? 0) ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label small" for="f-ayuno">Requiere ayuno</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="f-activo"
|
||||||
|
<?= ($exam['activo'] ?? 1) ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label small" for="f-activo">Activo</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-3" id="blq-ayuno" style="display:<?= ($exam['requiere_ayuno'] ?? 0) ? 'block' : 'none' ?>">
|
||||||
|
<label class="form-label">Horas de ayuno</label>
|
||||||
|
<input type="number" id="f-horas-ayuno" class="form-control form-control-sm"
|
||||||
|
value="<?= $exam['horas_ayuno'] ?? '' ?>" min="1" max="72">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($exam): ?>
|
||||||
|
<!-- ── Items de resultado ── -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-hdr">
|
||||||
|
<i class="fas fa-list text-success"></i>
|
||||||
|
<h2>Items / Valores de referencia</h2>
|
||||||
|
<button class="btn btn-outline-success btn-sm" onclick="addItem()">
|
||||||
|
<i class="fas fa-plus me-1"></i>Agregar item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table class="items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:30px">#</th>
|
||||||
|
<th>Nombre item</th>
|
||||||
|
<th style="width:70px">Sexo</th>
|
||||||
|
<th style="width:70px">Tipo</th>
|
||||||
|
<th style="width:90px">Medida</th>
|
||||||
|
<th style="width:80px">V.Min</th>
|
||||||
|
<th style="width:80px">V.Max</th>
|
||||||
|
<th style="width:50px">Ord.</th>
|
||||||
|
<th>Fórmula</th>
|
||||||
|
<th style="width:60px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="items-body">
|
||||||
|
<?php foreach ($items as $it): ?>
|
||||||
|
<tr data-id="<?=$it['id']?>" data-cod="<?=htmlspecialchars($exam['cod_protocolo']??$exam['codigo'])?>">
|
||||||
|
<?= _itemRow($it) ?>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Tarifas ── -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-hdr">
|
||||||
|
<i class="fas fa-tags text-warning"></i>
|
||||||
|
<h2>Precios por tarifa</h2>
|
||||||
|
<small class="text-muted">Clic en el valor para editar</small>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table class="tar-table" id="tar-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:30px">#</th>
|
||||||
|
<th>Tarifa</th>
|
||||||
|
<th style="width:120px">Valor</th>
|
||||||
|
<th style="width:100px">R. Urgencia</th>
|
||||||
|
<th style="width:100px">R. Festivo</th>
|
||||||
|
<th style="width:100px">R. Especial</th>
|
||||||
|
<th style="width:80px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tar-body">
|
||||||
|
<tr><td colspan="7" class="text-center text-muted py-3">Cargando tarifas…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
function _itemRow(array $it): string {
|
||||||
|
$sexos = [''=>'Ambos','M'=>'M','F'=>'F'];
|
||||||
|
$tipos = ['T'=>'Texto','N'=>'Numérico'];
|
||||||
|
$sOpts = ''; foreach($sexos as $v=>$l) $sOpts .= "<option value='$v'".($it['tipo_sexo']===$v?' selected':'').">$l</option>";
|
||||||
|
$tOpts = ''; foreach($tipos as $v=>$l) $tOpts .= "<option value='$v'".($it['tipo']===$v?' selected':'').">$l</option>";
|
||||||
|
return "
|
||||||
|
<td><input type='number' name='orden' value='{$it['orden']}' style='width:45px'></td>
|
||||||
|
<td><input type='text' name='nombre' value='".htmlspecialchars($it['nombre'])."' style='min-width:160px'></td>
|
||||||
|
<td><select name='tipo_sexo'>$sOpts</select></td>
|
||||||
|
<td><select name='tipo'>$tOpts</select></td>
|
||||||
|
<td><input type='text' name='medida' value='".htmlspecialchars($it['medida']??'')."'></td>
|
||||||
|
<td><input type='number' name='vmin_ref' value='".($it['vmin_ref']??'')."' step='any'></td>
|
||||||
|
<td><input type='number' name='vmax_ref' value='".($it['vmax_ref']??'')."' step='any'></td>
|
||||||
|
<td><input type='number' name='orden2' value='{$it['orden']}' style='width:45px' disabled></td>
|
||||||
|
<td><input type='text' name='formula' value='".htmlspecialchars($it['formula']??'')."'></td>
|
||||||
|
<td><button type='button' class='btn btn-sm btn-outline-success py-0 px-1 me-1' onclick='saveItem(this)' title='Guardar'><i class='fas fa-check'></i></button>
|
||||||
|
<button type='button' class='btn btn-sm btn-outline-danger py-0 px-1' onclick='delItem(this)' title='Eliminar'><i class='fas fa-trash'></i></button></td>
|
||||||
|
";
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '<?= $API ?>';
|
||||||
|
const EXAM_ID = <?= $id ?: 'null' ?>;
|
||||||
|
const COD_PROT = '<?= addslashes($exam['cod_protocolo'] ?? ($exam['codigo'] ?? '')) ?>';
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.getElementById('f-ayuno')?.addEventListener('change', e => {
|
||||||
|
document.getElementById('blq-ayuno').style.display = e.target.checked ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
if (EXAM_ID) cargarTarifas();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Guardar examen ─────────────────────────────────
|
||||||
|
async function guardarExamen() {
|
||||||
|
const payload = {
|
||||||
|
id: document.getElementById('exam-id').value || null,
|
||||||
|
codigo: document.getElementById('f-codigo').value.trim().toUpperCase(),
|
||||||
|
nombre: document.getElementById('f-nombre').value.trim(),
|
||||||
|
cups: document.getElementById('f-cups').value.trim(),
|
||||||
|
abreviatura: document.getElementById('f-abreviatura').value.trim(),
|
||||||
|
nivel: document.getElementById('f-nivel').value,
|
||||||
|
cod_protocolo: document.getElementById('f-protocolo').value,
|
||||||
|
tipo_muestra: document.getElementById('f-muestra').value,
|
||||||
|
seremite: document.getElementById('f-seremite').checked ? 1 : 0,
|
||||||
|
requiere_ayuno: document.getElementById('f-ayuno').checked ? 1 : 0,
|
||||||
|
horas_ayuno: document.getElementById('f-horas-ayuno')?.value || null,
|
||||||
|
instrucciones: document.getElementById('f-instrucciones').value.trim(),
|
||||||
|
serecibe: document.getElementById('f-serecibe').value.trim(),
|
||||||
|
activo: document.getElementById('f-activo').checked ? 1 : 0,
|
||||||
|
form_ids: [...document.querySelectorAll('.f-formulario-chk:checked')].map(c => parseInt(c.value)),
|
||||||
|
};
|
||||||
|
const r = await fetch(API+'save.php', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.json());
|
||||||
|
if (r.ok) {
|
||||||
|
mostrarAlerta('success', r.message || 'Guardado');
|
||||||
|
if (!payload.id && r.id) setTimeout(() => location.href = location.href.split('?')[0] + '?m=lab_examenes&v=examen&id='+r.id, 800);
|
||||||
|
} else {
|
||||||
|
mostrarAlerta('danger', r.error || 'Error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eliminar(id, nombre) {
|
||||||
|
if (!confirm(`¿Eliminar "${nombre}"? No se puede deshacer.`)) return;
|
||||||
|
const r = await fetch(API+'save.php', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,_delete:true})}).then(r=>r.json());
|
||||||
|
if (r.ok) location.href = '<?= BASE_URL ?>erp.php?m=lab_examenes&v=index';
|
||||||
|
else mostrarAlerta('danger', r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Items ─────────────────────────────────────────
|
||||||
|
function addItem() {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.dataset.id = '0';
|
||||||
|
tr.dataset.cod = COD_PROT;
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><input type='number' name='orden' value='0' style='width:45px'></td>
|
||||||
|
<td><input type='text' name='nombre' placeholder='Nombre del item' style='min-width:160px'></td>
|
||||||
|
<td><select name='tipo_sexo'><option value=''>Ambos</option><option value='M'>M</option><option value='F'>F</option></select></td>
|
||||||
|
<td><select name='tipo'><option value='T'>Texto</option><option value='N'>Numérico</option></select></td>
|
||||||
|
<td><input type='text' name='medida'></td>
|
||||||
|
<td><input type='number' name='vmin_ref' step='any'></td>
|
||||||
|
<td><input type='number' name='vmax_ref' step='any'></td>
|
||||||
|
<td><input type='number' name='orden2' style='width:45px' disabled></td>
|
||||||
|
<td><input type='text' name='formula'></td>
|
||||||
|
<td><button type='button' class='btn btn-sm btn-outline-success py-0 px-1 me-1' onclick='saveItem(this)'><i class='fas fa-check'></i></button>
|
||||||
|
<button type='button' class='btn btn-sm btn-outline-danger py-0 px-1' onclick='this.closest("tr").remove()'><i class='fas fa-times'></i></button></td>`;
|
||||||
|
document.getElementById('items-body').appendChild(tr);
|
||||||
|
tr.querySelector('[name=nombre]').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveItem(btn) {
|
||||||
|
const tr = btn.closest('tr');
|
||||||
|
const g = n => tr.querySelector(`[name=${n}]`)?.value ?? '';
|
||||||
|
const payload = {
|
||||||
|
id: +tr.dataset.id || null,
|
||||||
|
cod_protocolo: tr.dataset.cod || COD_PROT,
|
||||||
|
nombre: g('nombre').trim(),
|
||||||
|
tipo_sexo: g('tipo_sexo') || null,
|
||||||
|
tipo: g('tipo') || 'T',
|
||||||
|
medida: g('medida'),
|
||||||
|
vmin_ref: g('vmin_ref'),
|
||||||
|
vmax_ref: g('vmax_ref'),
|
||||||
|
orden: +g('orden') || 0,
|
||||||
|
formula: g('formula'),
|
||||||
|
};
|
||||||
|
if (!payload.nombre) { alert('Nombre requerido'); return; }
|
||||||
|
const r = await fetch(API+'save_item.php',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.json());
|
||||||
|
if (r.ok) { tr.dataset.id = r.id || tr.dataset.id; mostrarAlerta('success','Item guardado'); }
|
||||||
|
else mostrarAlerta('danger', r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delItem(btn) {
|
||||||
|
const tr = btn.closest('tr');
|
||||||
|
const id = +tr.dataset.id;
|
||||||
|
if (id && !confirm('¿Eliminar este item?')) return;
|
||||||
|
if (id) {
|
||||||
|
const r = await fetch(API+'save_item.php',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,_delete:true})}).then(r=>r.json());
|
||||||
|
if (!r.ok) { mostrarAlerta('danger', r.error); return; }
|
||||||
|
}
|
||||||
|
tr.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tarifas ──────────────────────────────────────
|
||||||
|
let _tarifas = [];
|
||||||
|
async function cargarTarifas() {
|
||||||
|
const r = await fetch(API+'get_tarifas.php?exam_id='+EXAM_ID).then(r=>r.json());
|
||||||
|
if (!r.ok) return;
|
||||||
|
_tarifas = r.data;
|
||||||
|
renderTarifas();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTarifas() {
|
||||||
|
const body = document.getElementById('tar-body');
|
||||||
|
if (!_tarifas.length) { body.innerHTML='<tr><td colspan="7" class="text-center text-muted py-3">Sin tarifas.</td></tr>'; return; }
|
||||||
|
body.innerHTML = _tarifas.map((t,i) => {
|
||||||
|
const v = t.valor !== null ? parseFloat(t.valor).toLocaleString('es-CO') : '—';
|
||||||
|
return `<tr data-tidx="${i}">
|
||||||
|
<td class="text-muted" style="font-size:.75rem">${t.tarifa_id}</td>
|
||||||
|
<td>${esc(t.tarifa_nombre)}</td>
|
||||||
|
<td class="tar-val val-display" onclick="editTarifa(this)">${v}</td>
|
||||||
|
<td class="text-muted tar-val" style="font-size:.8rem">${t.recargo_urg>0?parseFloat(t.recargo_urg).toLocaleString('es-CO'):'—'}</td>
|
||||||
|
<td class="text-muted tar-val" style="font-size:.8rem">${t.recargo_fes>0?parseFloat(t.recargo_fes).toLocaleString('es-CO'):'—'}</td>
|
||||||
|
<td class="text-muted tar-val" style="font-size:.8rem">${t.recargo_esp>0?parseFloat(t.recargo_esp).toLocaleString('es-CO'):'—'}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-outline-primary btn-sm py-0 px-2" onclick="abrirEditorTarifa(${i})" title="Editar precios">
|
||||||
|
<i class="fas fa-pencil-alt"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirEditorTarifa(idx) {
|
||||||
|
const t = _tarifas[idx];
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('modalTarifa'));
|
||||||
|
document.getElementById('mt-nombre').textContent = t.tarifa_nombre;
|
||||||
|
document.getElementById('mt-idx').value = idx;
|
||||||
|
document.getElementById('mt-valor').value = t.valor ?? '';
|
||||||
|
document.getElementById('mt-urg').value = t.recargo_urg ?? 0;
|
||||||
|
document.getElementById('mt-fes').value = t.recargo_fes ?? 0;
|
||||||
|
document.getElementById('mt-esp').value = t.recargo_esp ?? 0;
|
||||||
|
modal.show();
|
||||||
|
document.getElementById('mt-valor').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardarTarifa() {
|
||||||
|
const idx = +document.getElementById('mt-idx').value;
|
||||||
|
const t = _tarifas[idx];
|
||||||
|
const payload = {
|
||||||
|
exam_tipo_id: EXAM_ID,
|
||||||
|
tarifa_id: t.tarifa_id,
|
||||||
|
valor: parseFloat(document.getElementById('mt-valor').value) || 0,
|
||||||
|
recargo_urg: parseFloat(document.getElementById('mt-urg').value) || 0,
|
||||||
|
recargo_fes: parseFloat(document.getElementById('mt-fes').value) || 0,
|
||||||
|
recargo_esp: parseFloat(document.getElementById('mt-esp').value) || 0,
|
||||||
|
};
|
||||||
|
const btn = document.getElementById('btn-guardar-tarifa');
|
||||||
|
btn.disabled = true;
|
||||||
|
const r = await fetch(API+'save_tarifa.php',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.json());
|
||||||
|
btn.disabled = false;
|
||||||
|
if (r.ok) {
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('modalTarifa')).hide();
|
||||||
|
cargarTarifas();
|
||||||
|
mostrarAlerta('success','Precio guardado');
|
||||||
|
} else {
|
||||||
|
mostrarAlerta('danger', r.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mostrarAlerta(tipo, msg) {
|
||||||
|
const el = document.getElementById('alerta-global');
|
||||||
|
el.className = `alert alert-${tipo} py-2`;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.classList.remove('d-none');
|
||||||
|
setTimeout(()=>el.classList.add('d-none'), 3500);
|
||||||
|
el.scrollIntoView({behavior:'smooth',block:'nearest'});
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s){if(!s)return'';return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Modal editar tarifa -->
|
||||||
|
<div class="modal fade" id="modalTarifa" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-sm">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header py-2">
|
||||||
|
<h6 class="modal-title"><i class="fas fa-tag me-1"></i><span id="mt-nombre"></span></h6>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="mt-idx">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" style="font-size:.78rem;font-weight:600">Valor base</label>
|
||||||
|
<input type="number" id="mt-valor" class="form-control form-control-sm" step="1" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" style="font-size:.78rem;font-weight:600">R. Urgencia</label>
|
||||||
|
<input type="number" id="mt-urg" class="form-control form-control-sm" step="1" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" style="font-size:.78rem;font-weight:600">R. Festivo</label>
|
||||||
|
<input type="number" id="mt-fes" class="form-control form-control-sm" step="1" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label" style="font-size:.78rem;font-weight:600">R. Especial</label>
|
||||||
|
<input type="number" id="mt-esp" class="form-control form-control-sm" step="1" min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer py-2">
|
||||||
|
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="btn-guardar-tarifa" onclick="guardarTarifa()">
|
||||||
|
<i class="fas fa-save me-1"></i>Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php Layout::close(); ?>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
require_once APP_ROOT . '/config/config.php';
|
||||||
|
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||||
|
Layout::open('Catálogo de Exámenes', 'fas fa-flask');
|
||||||
|
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
$categorias = $pdo->query(
|
||||||
|
"SELECT DISTINCT categoria FROM exam_tipos WHERE categoria IS NOT NULL ORDER BY categoria"
|
||||||
|
)->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
$API = BASE_URL . 'modules/lab_examenes/api/';
|
||||||
|
$DETAIL = BASE_URL . 'erp.php?m=lab_examenes&v=examen&id=';
|
||||||
|
?>
|
||||||
|
<style>
|
||||||
|
body { background:#f1f5f9 }
|
||||||
|
.page-header { background:#fff; border-bottom:1px solid #e2e8f0; padding:14px 24px;
|
||||||
|
display:flex; align-items:center; gap:12px; flex-wrap:wrap }
|
||||||
|
.page-header h1 { font-size:1.18rem; font-weight:700; color:#1e293b; margin:0; flex:1 }
|
||||||
|
.content-wrap { max-width:1200px; margin:24px auto; padding:0 16px 48px }
|
||||||
|
.card-box { background:#fff; border:1px solid #e2e8f0; border-radius:12px; overflow:hidden }
|
||||||
|
.toolbar { padding:12px 16px; display:flex; gap:10px; align-items:center;
|
||||||
|
border-bottom:1px solid #e2e8f0; flex-wrap:wrap }
|
||||||
|
.toolbar input, .toolbar select { font-size:.85rem }
|
||||||
|
table { width:100%; border-collapse:collapse; font-size:.88rem }
|
||||||
|
thead th { background:#f8fafc; padding:9px 14px; font-size:.7rem; text-transform:uppercase;
|
||||||
|
letter-spacing:.07em; color:#64748b; border-bottom:1px solid #e2e8f0; white-space:nowrap }
|
||||||
|
tbody td { padding:9px 14px; border-bottom:1px solid #f1f5f9; color:#1e293b; vertical-align:middle }
|
||||||
|
tbody tr:last-child td { border-bottom:none }
|
||||||
|
tbody tr:hover td { background:#f8fafc }
|
||||||
|
.badge-cat { background:#eff6ff; color:#1d4ed8; border-radius:20px; padding:2px 9px;
|
||||||
|
font-size:.72rem; font-weight:600; white-space:nowrap }
|
||||||
|
.badge-remite { background:#dcfce7; color:#166534; border-radius:20px; padding:2px 9px; font-size:.72rem }
|
||||||
|
.badge-off { background:#f1f5f9; color:#94a3b8; border-radius:20px; padding:2px 9px; font-size:.72rem }
|
||||||
|
.pag-bar { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.5rem;
|
||||||
|
padding:10px 16px; border-top:1px solid #e2e8f0; background:#f8fafc }
|
||||||
|
.pag-info { font-size:.8rem; color:#64748b }
|
||||||
|
.pag-btn { min-width:32px; height:32px; padding:0 8px; border:1px solid #e2e8f0; border-radius:7px;
|
||||||
|
background:#fff; font-size:.82rem; color:#374151; cursor:pointer;
|
||||||
|
display:inline-flex; align-items:center; justify-content:center }
|
||||||
|
.pag-btn:hover:not(:disabled) { background:#eff6ff; border-color:#bfdbfe; color:#1d4ed8 }
|
||||||
|
.pag-btn.active { background:#2563eb; color:#fff; border-color:#2563eb }
|
||||||
|
.pag-btn:disabled { opacity:.4; cursor:not-allowed }
|
||||||
|
.toggle-activo { cursor:pointer; font-size:1.05rem }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<i class="fas fa-flask" style="font-size:1.3rem;color:#6366f1"></i>
|
||||||
|
<h1>Catálogo de Exámenes</h1>
|
||||||
|
<a href="<?= BASE_URL ?>erp.php?m=lab_examenes&v=examen" class="btn btn-primary btn-sm ms-auto">
|
||||||
|
<i class="fas fa-plus me-1"></i>Nuevo examen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-wrap">
|
||||||
|
<div class="card-box">
|
||||||
|
<div class="toolbar">
|
||||||
|
<input type="search" id="inp-q" class="form-control form-control-sm"
|
||||||
|
placeholder="Buscar por nombre, código o CUPS…" style="max-width:280px" oninput="buscar()">
|
||||||
|
<select id="sel-cat" class="form-select form-select-sm" style="max-width:220px" onchange="cargar(1)">
|
||||||
|
<option value="">Todas las categorías</option>
|
||||||
|
<?php foreach ($categorias as $c): ?>
|
||||||
|
<option value="<?= htmlspecialchars($c) ?>"><?= htmlspecialchars($c) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<span class="text-muted small ms-auto" id="lbl-total"></span>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Código</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Categoría</th>
|
||||||
|
<th>CUPS</th>
|
||||||
|
<th>Protocolo</th>
|
||||||
|
<th>Se remite</th>
|
||||||
|
<th>Activo</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbl-body">
|
||||||
|
<tr><td colspan="8" class="text-center text-muted py-4">Cargando…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="pag-bar" id="pag-bar" style="display:none">
|
||||||
|
<span class="pag-info" id="pag-info"></span>
|
||||||
|
<div id="pag-ctrl" style="display:flex;gap:4px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '<?= $API ?>';
|
||||||
|
const DETAIL = '<?= $DETAIL ?>';
|
||||||
|
let _page=1, _limit=50, _total=0, _q='', _timer;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => cargar(1));
|
||||||
|
|
||||||
|
function buscar() {
|
||||||
|
clearTimeout(_timer);
|
||||||
|
_timer = setTimeout(() => cargar(1), 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cargar(page=_page) {
|
||||||
|
_page = page;
|
||||||
|
_q = document.getElementById('inp-q').value.trim();
|
||||||
|
const cat = document.getElementById('sel-cat').value;
|
||||||
|
const p = new URLSearchParams({page, limit:_limit});
|
||||||
|
if (_q) p.set('q', _q);
|
||||||
|
if (cat) p.set('categoria', cat);
|
||||||
|
const r = await fetch(API+'list.php?'+p).then(r=>r.json());
|
||||||
|
if (!r.ok) return;
|
||||||
|
_total = r.total;
|
||||||
|
renderTabla(r.data);
|
||||||
|
renderPag(Math.ceil(_total/_limit));
|
||||||
|
const d=(_page-1)*_limit+1, h=Math.min(_page*_limit,_total);
|
||||||
|
document.getElementById('lbl-total').textContent = _total ? `${d}–${h} de ${_total}` : '0 exámenes';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTabla(rows) {
|
||||||
|
const b = document.getElementById('tbl-body');
|
||||||
|
if (!rows.length) { b.innerHTML='<tr><td colspan="8" class="text-center text-muted py-4">Sin resultados.</td></tr>'; return; }
|
||||||
|
b.innerHTML = rows.map(r => `
|
||||||
|
<tr>
|
||||||
|
<td><code class="text-primary">${esc(r.codigo)}</code></td>
|
||||||
|
<td><a href="${DETAIL+r.id}" class="text-decoration-none fw-semibold text-dark">${esc(r.nombre)}</a></td>
|
||||||
|
<td>${r.categoria ? `<span class="badge-cat">${esc(r.categoria)}</span>` : '<span class="text-muted">—</span>'}</td>
|
||||||
|
<td><small class="text-muted">${esc(r.cups||'—')}</small></td>
|
||||||
|
<td><small class="text-muted">${esc(r.cod_protocolo||'—')}</small></td>
|
||||||
|
<td>${+r.seremite ? '<span class="badge-remite">Sí</span>' : '<span class="badge-off">No</span>'}</td>
|
||||||
|
<td><span class="toggle-activo" title="Clic para cambiar" onclick="toggleActivo(${r.id},${r.activo})">${+r.activo?'✅':'⬜'}</span></td>
|
||||||
|
<td><a href="${DETAIL+r.id}" class="btn btn-outline-primary btn-sm py-0 px-2"><i class="fas fa-pencil-alt"></i></a></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActivo(id, actual) {
|
||||||
|
const r = await fetch(API+'save.php', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({id, activo: actual=='1'||actual===1 ? 0 : 1})
|
||||||
|
}).then(r=>r.json());
|
||||||
|
if (r.ok) cargar(_page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPag(total) {
|
||||||
|
const bar = document.getElementById('pag-bar');
|
||||||
|
const ctrl = document.getElementById('pag-ctrl');
|
||||||
|
if (total<=1) { bar.style.display='none'; return; }
|
||||||
|
bar.style.display='flex';
|
||||||
|
document.getElementById('pag-info').textContent = `Página ${_page} de ${total}`;
|
||||||
|
const pages = new Set([1,total,_page]);
|
||||||
|
for(let i=_page-2;i<=_page+2;i++) if(i>0&&i<=total) pages.add(i);
|
||||||
|
const sorted=[...pages].sort((a,b)=>a-b);
|
||||||
|
let h=`<button class="pag-btn" onclick="cargar(${_page-1})" ${_page===1?'disabled':''}><i class="fas fa-chevron-left"></i></button>`;
|
||||||
|
let prev=0;
|
||||||
|
for(const p of sorted){
|
||||||
|
if(prev&&p-prev>1) h+=`<span style="padding:0 4px;line-height:32px;font-size:.82rem;color:#94a3b8">…</span>`;
|
||||||
|
h+=`<button class="pag-btn ${p===_page?'active':''}" onclick="cargar(${p})">${p}</button>`;
|
||||||
|
prev=p;
|
||||||
|
}
|
||||||
|
h+=`<button class="pag-btn" onclick="cargar(${_page+1})" ${_page===total?'disabled':''}><i class="fas fa-chevron-right"></i></button>`;
|
||||||
|
ctrl.innerHTML=h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s){if(!s)return'';return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||||
|
</script>
|
||||||
|
<?php Layout::close(); ?>
|
||||||
@@ -6,21 +6,35 @@ header('Content-Type: application/json');
|
|||||||
|
|
||||||
$pdo = Database::getInstance()->getConnection();
|
$pdo = Database::getInstance()->getConnection();
|
||||||
$buscar = trim($_GET['q'] ?? '');
|
$buscar = trim($_GET['q'] ?? '');
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$limit = max(1, min(200, (int)($_GET['limit'] ?? 25)));
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
$where = '';
|
||||||
|
$params = [];
|
||||||
|
|
||||||
if ($buscar !== '') {
|
if ($buscar !== '') {
|
||||||
$like = '%' . $buscar . '%';
|
$like = '%' . $buscar . '%';
|
||||||
$stmt = $pdo->prepare(
|
$where = "WHERE nombres LIKE ? OR apellidos LIKE ? OR codigo LIKE ? OR docidmedico LIKE ?";
|
||||||
"SELECT id, codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico, activo
|
$params = [$like, $like, $like, $like];
|
||||||
FROM medicos
|
|
||||||
WHERE nombres LIKE ? OR apellidos LIKE ? OR codigo LIKE ? OR docidmedico LIKE ?
|
|
||||||
ORDER BY apellidos, nombres"
|
|
||||||
);
|
|
||||||
$stmt->execute([$like, $like, $like, $like]);
|
|
||||||
} else {
|
|
||||||
$stmt = $pdo->query(
|
|
||||||
"SELECT id, codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico, activo
|
|
||||||
FROM medicos ORDER BY apellidos, nombres"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode(['ok' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
|
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM medicos $where");
|
||||||
|
$countStmt->execute($params);
|
||||||
|
$total = (int)$countStmt->fetchColumn();
|
||||||
|
|
||||||
|
$dataStmt = $pdo->prepare(
|
||||||
|
"SELECT id, codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico, activo
|
||||||
|
FROM medicos $where
|
||||||
|
ORDER BY apellidos, nombres
|
||||||
|
LIMIT $limit OFFSET $offset"
|
||||||
|
);
|
||||||
|
$dataStmt->execute($params);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $dataStmt->fetchAll(PDO::FETCH_ASSOC),
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
]);
|
||||||
|
|||||||
@@ -29,6 +29,23 @@ $API = BASE_URL . 'modules/medicos/api/';
|
|||||||
font-size: .75rem; font-weight: 600; }
|
font-size: .75rem; font-weight: 600; }
|
||||||
.acciones { display: flex; gap: 6px; }
|
.acciones { display: flex; gap: 6px; }
|
||||||
#tbl-empty { text-align: center; padding: 3rem; color: #94a3b8; }
|
#tbl-empty { text-align: center; padding: 3rem; color: #94a3b8; }
|
||||||
|
.pag-bar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .5rem;
|
||||||
|
padding: 10px 16px; border-top: 1px solid #e2e8f0; background: #f8fafc;
|
||||||
|
}
|
||||||
|
.pag-bar .pag-info { font-size: .8rem; color: #64748b; }
|
||||||
|
.pag-controls { display: flex; gap: 4px; align-items: center; }
|
||||||
|
.pag-btn {
|
||||||
|
min-width: 32px; height: 32px; padding: 0 8px;
|
||||||
|
border: 1px solid #e2e8f0; border-radius: 7px; background: #fff;
|
||||||
|
font-size: .82rem; color: #374151; cursor: pointer; font-weight: 500;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: background .12s, border-color .12s;
|
||||||
|
}
|
||||||
|
.pag-btn:hover:not(:disabled) { background: #eff6ff; border-color: #bfdbfe; color: #1d4ed8; }
|
||||||
|
.pag-btn.active { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||||
|
.pag-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
.pag-ellipsis { font-size: .82rem; color: #94a3b8; padding: 0 4px; line-height: 32px; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
@@ -60,10 +77,14 @@ $API = BASE_URL . 'modules/medicos/api/';
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="tbl-body">
|
<tbody id="tbl-body">
|
||||||
<tr id="tbl-empty"><td colspan="6">Cargando…</td></tr>
|
<tr id="tbl-empty"><td colspan="7">Cargando…</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pag-bar" id="pag-bar" style="display:none">
|
||||||
|
<span class="pag-info" id="pag-info"></span>
|
||||||
|
<div class="pag-controls" id="pag-controls"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -150,6 +171,7 @@ $API = BASE_URL . 'modules/medicos/api/';
|
|||||||
<script>
|
<script>
|
||||||
const API = '<?= $API ?>';
|
const API = '<?= $API ?>';
|
||||||
let _modal, _modalDel, _pendingDelId, _buscarTimer;
|
let _modal, _modalDel, _pendingDelId, _buscarTimer;
|
||||||
|
let _page = 1, _limit = 25, _total = 0, _q = '';
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
_modal = new bootstrap.Modal(document.getElementById('modalMedico'));
|
_modal = new bootstrap.Modal(document.getElementById('modalMedico'));
|
||||||
@@ -157,24 +179,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
cargar();
|
cargar();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function cargar(q = '') {
|
async function cargar(q = _q, page = _page) {
|
||||||
const url = API + 'list.php' + (q ? '?q=' + encodeURIComponent(q) : '');
|
_q = q;
|
||||||
const res = await fetch(url);
|
_page = page;
|
||||||
|
const params = new URLSearchParams({ page, limit: _limit });
|
||||||
|
if (q) params.set('q', q);
|
||||||
|
const res = await fetch(API + 'list.php?' + params);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (!json.ok) return;
|
if (!json.ok) return;
|
||||||
|
_total = json.total;
|
||||||
|
_page = json.page;
|
||||||
renderTabla(json.data);
|
renderTabla(json.data);
|
||||||
|
renderPaginacion();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buscar() {
|
function buscar() {
|
||||||
clearTimeout(_buscarTimer);
|
clearTimeout(_buscarTimer);
|
||||||
_buscarTimer = setTimeout(() => cargar(document.getElementById('inp-buscar').value.trim()), 280);
|
_buscarTimer = setTimeout(() => cargar(document.getElementById('inp-buscar').value.trim(), 1), 280);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTabla(rows) {
|
function renderTabla(rows) {
|
||||||
const body = document.getElementById('tbl-body');
|
const body = document.getElementById('tbl-body');
|
||||||
document.getElementById('lbl-total').textContent = rows.length + ' médico(s)';
|
const desde = (_page - 1) * _limit + 1;
|
||||||
|
const hasta = Math.min(_page * _limit, _total);
|
||||||
|
document.getElementById('lbl-total').textContent =
|
||||||
|
_total ? `${desde}–${hasta} de ${_total} médico(s)` : '0 médicos';
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
body.innerHTML = '<tr id="tbl-empty"><td colspan="6">Sin resultados.</td></tr>';
|
body.innerHTML = '<tr id="tbl-empty"><td colspan="7">Sin resultados.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
body.innerHTML = rows.map(m => `
|
body.innerHTML = rows.map(m => `
|
||||||
@@ -198,6 +229,37 @@ function renderTabla(rows) {
|
|||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPaginacion() {
|
||||||
|
const totalPages = Math.ceil(_total / _limit);
|
||||||
|
const bar = document.getElementById('pag-bar');
|
||||||
|
const info = document.getElementById('pag-info');
|
||||||
|
const ctrl = document.getElementById('pag-controls');
|
||||||
|
|
||||||
|
if (totalPages <= 1) { bar.style.display = 'none'; return; }
|
||||||
|
bar.style.display = 'flex';
|
||||||
|
info.textContent = `Página ${_page} de ${totalPages}`;
|
||||||
|
|
||||||
|
// Compute page window: always show first, last, current ±2
|
||||||
|
const pages = new Set([1, totalPages, _page]);
|
||||||
|
for (let i = _page - 2; i <= _page + 2; i++) if (i > 0 && i <= totalPages) pages.add(i);
|
||||||
|
const sorted = [...pages].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
html += `<button class="pag-btn" onclick="cargar(_q,${_page-1})" ${_page===1?'disabled':''}>
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</button>`;
|
||||||
|
let prev = 0;
|
||||||
|
for (const p of sorted) {
|
||||||
|
if (prev && p - prev > 1) html += `<span class="pag-ellipsis">…</span>`;
|
||||||
|
html += `<button class="pag-btn ${p===_page?'active':''}" onclick="cargar(_q,${p})">${p}</button>`;
|
||||||
|
prev = p;
|
||||||
|
}
|
||||||
|
html += `<button class="pag-btn" onclick="cargar(_q,${_page+1})" ${_page===totalPages?'disabled':''}>
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</button>`;
|
||||||
|
ctrl.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
function abrirModal(m = null) {
|
function abrirModal(m = null) {
|
||||||
document.getElementById('modal-titulo').textContent = m ? 'Editar médico' : 'Nuevo médico';
|
document.getElementById('modal-titulo').textContent = m ? 'Editar médico' : 'Nuevo médico';
|
||||||
document.getElementById('modal-alert').classList.add('d-none');
|
document.getElementById('modal-alert').classList.add('d-none');
|
||||||
@@ -247,7 +309,7 @@ async function guardar() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_modal.hide();
|
_modal.hide();
|
||||||
cargar(document.getElementById('inp-buscar').value.trim());
|
cargar(_q, _page);
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar';
|
btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar';
|
||||||
@@ -272,7 +334,7 @@ async function confirmarEliminar() {
|
|||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (json.ok) {
|
if (json.ok) {
|
||||||
_modalDel.hide();
|
_modalDel.hide();
|
||||||
cargar(document.getElementById('inp-buscar').value.trim());
|
cargar(_q, _page);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ function siguienteNumeroPorPrioridad(int $sesionId, int $prioridadId): int
|
|||||||
* @param array|null $lugarIds Lista explícita de lugar_ids (grupo completo)
|
* @param array|null $lugarIds Lista explícita de lugar_ids (grupo completo)
|
||||||
* @return array|null Fila de turnero_turnos o null si cola vacía
|
* @return array|null Fila de turnero_turnos o null si cola vacía
|
||||||
*/
|
*/
|
||||||
function siguienteTurnoEnCola(string $estado, ?int $lugarId = null, ?array $lugarIds = null): ?array
|
function siguienteTurnoEnCola(string $estado, ?int $lugarId = null, ?array $lugarIds = null, ?int $sesionId = null): ?array
|
||||||
{
|
{
|
||||||
$pdo = db();
|
$pdo = db();
|
||||||
|
|
||||||
@@ -207,6 +207,8 @@ function siguienteTurnoEnCola(string $estado, ?int $lugarId = null, ?array $luga
|
|||||||
$lugarIds = lugarIdsDeGrupo($lugarId);
|
$lugarIds = lugarIdsDeGrupo($lugarId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$sesionCond = $sesionId !== null ? 'AND t.sesion_id = ?' : '';
|
||||||
|
|
||||||
if (!empty($lugarIds)) {
|
if (!empty($lugarIds)) {
|
||||||
$in = implode(',', array_map('intval', $lugarIds));
|
$in = implode(',', array_map('intval', $lugarIds));
|
||||||
$sql = "
|
$sql = "
|
||||||
@@ -214,25 +216,29 @@ function siguienteTurnoEnCola(string $estado, ?int $lugarId = null, ?array $luga
|
|||||||
FROM turnero_turnos t
|
FROM turnero_turnos t
|
||||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||||
WHERE t.estado = ?
|
WHERE t.estado = ?
|
||||||
|
$sesionCond
|
||||||
AND t.lugar_destino_id IN ($in)
|
AND t.lugar_destino_id IN ($in)
|
||||||
ORDER BY p.orden_peso ASC, t.creado_at ASC
|
ORDER BY p.orden_peso ASC, t.creado_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
";
|
";
|
||||||
$stmt = $pdo->prepare($sql);
|
$binds = $sesionId !== null ? [$estado, $sesionId] : [$estado];
|
||||||
$stmt->execute([$estado]);
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($binds);
|
||||||
} else {
|
} else {
|
||||||
$sql = '
|
$sql = "
|
||||||
SELECT t.*, p.orden_peso, p.codigo AS prioridad_codigo, p.color AS prioridad_color
|
SELECT t.*, p.orden_peso, p.codigo AS prioridad_codigo, p.color AS prioridad_color
|
||||||
FROM turnero_turnos t
|
FROM turnero_turnos t
|
||||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||||
WHERE t.estado = ?
|
WHERE t.estado = ?
|
||||||
|
$sesionCond
|
||||||
ORDER BY p.orden_peso ASC, t.creado_at ASC
|
ORDER BY p.orden_peso ASC, t.creado_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
';
|
";
|
||||||
$stmt = $pdo->prepare($sql);
|
$binds = $sesionId !== null ? [$estado, $sesionId] : [$estado];
|
||||||
$stmt->execute([$estado]);
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($binds);
|
||||||
}
|
}
|
||||||
|
|
||||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireMethod('GET');
|
||||||
|
requireTurnero();
|
||||||
|
|
||||||
|
$q = trim($_GET['q'] ?? '');
|
||||||
|
if (strlen($q) < 2) { jsonOk(['items' => []]); }
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
$like = '%' . $q . '%';
|
||||||
|
|
||||||
|
// Código exacto primero, luego coincidencias por descripción
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT cod_diag, concepto
|
||||||
|
FROM cie10_diagnosticos
|
||||||
|
WHERE cod_diag LIKE ? OR concepto LIKE ?
|
||||||
|
ORDER BY CASE WHEN cod_diag LIKE ? THEN 0 ELSE 1 END, cod_diag
|
||||||
|
LIMIT 12"
|
||||||
|
);
|
||||||
|
$stmt->execute([$like, $like, $like]);
|
||||||
|
|
||||||
|
jsonOk(['items' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
|
||||||
@@ -29,6 +29,7 @@ $datos = inputJson();
|
|||||||
$turnoId = isset($datos['turno_id']) ? (int) $datos['turno_id'] : 0;
|
$turnoId = isset($datos['turno_id']) ? (int) $datos['turno_id'] : 0;
|
||||||
$nuevoEstado = isset($datos['nuevo_estado']) ? trim((string) $datos['nuevo_estado']) : '';
|
$nuevoEstado = isset($datos['nuevo_estado']) ? trim((string) $datos['nuevo_estado']) : '';
|
||||||
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : null;
|
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : null;
|
||||||
|
$deskId = isset($datos['desk_id']) ? (int) $datos['desk_id'] : null;
|
||||||
|
|
||||||
// ── Validación básica ─────────────────────────────────────────
|
// ── Validación básica ─────────────────────────────────────────
|
||||||
if ($turnoId <= 0) jsonError('turno_id inválido.');
|
if ($turnoId <= 0) jsonError('turno_id inválido.');
|
||||||
@@ -107,10 +108,14 @@ try {
|
|||||||
|
|
||||||
switch ($nuevoEstado) {
|
switch ($nuevoEstado) {
|
||||||
case 'en_recepcion':
|
case 'en_recepcion':
|
||||||
$sets[] = 'llamado_recepcion_at = COALESCE(llamado_recepcion_at, NOW())';
|
$sets[] = 'llamado_recepcion_at = NOW()';
|
||||||
$sets[] = 'inicio_recepcion_at = COALESCE(inicio_recepcion_at, NOW())';
|
$sets[] = 'inicio_recepcion_at = COALESCE(inicio_recepcion_at, NOW())';
|
||||||
$sets[] = 'atendido_recepcion_por = COALESCE(atendido_recepcion_por, ?)';
|
$sets[] = 'atendido_recepcion_por = COALESCE(atendido_recepcion_por, ?)';
|
||||||
$binds[] = adminId();
|
$binds[] = adminId();
|
||||||
|
if ($deskId) {
|
||||||
|
$sets[] = 'recepcion_desk_id = ?';
|
||||||
|
$binds[] = $deskId;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'en_espera_lugar':
|
case 'en_espera_lugar':
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /modules/turnero/api/cancelar_toma_pendiente.php
|
||||||
|
* Marca una toma progresiva en_progreso como rechazada (paciente no quiere continuar).
|
||||||
|
* Body: { consentimiento_id }
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireMethod('POST');
|
||||||
|
requireTurnero();
|
||||||
|
|
||||||
|
$body = inputJson();
|
||||||
|
$consentId = (int)($body['consentimiento_id'] ?? 0);
|
||||||
|
$turnoId = (int)($body['turno_id'] ?? 0);
|
||||||
|
if (!$consentId) jsonError('consentimiento_id requerido.');
|
||||||
|
if (!$turnoId) jsonError('turno_id requerido.');
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
// Verificar que el consentimiento pertenece al mismo paciente que el turno activo.
|
||||||
|
// Previene que un turnero cancele tomas de pacientes que no tiene abiertos (IDOR).
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"UPDATE turnero_consentimientos tc
|
||||||
|
JOIN turnero_turnos t_old ON t_old.id = tc.turno_id
|
||||||
|
JOIN turnero_turnos t_cur ON t_cur.id = ? AND t_cur.paciente_id IS NOT NULL
|
||||||
|
AND t_cur.paciente_id = t_old.paciente_id
|
||||||
|
SET tc.estado = 'rechazado'
|
||||||
|
WHERE tc.id = ? AND tc.estado = 'en_progreso'"
|
||||||
|
);
|
||||||
|
$stmt->execute([$turnoId, $consentId]);
|
||||||
|
|
||||||
|
if ($stmt->rowCount() === 0) jsonError('Toma no encontrada o el paciente no coincide.', 404);
|
||||||
|
|
||||||
|
jsonOk(['cancelado' => true]);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET ?q= — Busca pacientes en lab_pacientes por nombre o documento.
|
||||||
|
* Usado por el modal "Nuevo mensaje" del chat turnero.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
requireAuthentication();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$q = trim($_GET['q'] ?? '');
|
||||||
|
if (strlen($q) < 2) { echo json_encode(['ok' => true, 'data' => []]); exit; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
$like = '%' . $q . '%';
|
||||||
|
$rows = $pdo->prepare(
|
||||||
|
"SELECT id, nombre_completo, telefono, numero_documento
|
||||||
|
FROM lab_pacientes
|
||||||
|
WHERE is_active = 1
|
||||||
|
AND (nombre_completo LIKE ? OR numero_documento LIKE ? OR telefono LIKE ?)
|
||||||
|
ORDER BY nombre_completo ASC
|
||||||
|
LIMIT 20"
|
||||||
|
);
|
||||||
|
$rows->execute([$like, $like, $like]);
|
||||||
|
echo json_encode(['ok' => true, 'data' => $rows->fetchAll(PDO::FETCH_ASSOC)]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -75,13 +75,15 @@ try {
|
|||||||
$earliestId = $rows[0]['id'] ?? null;
|
$earliestId = $rows[0]['id'] ?? null;
|
||||||
|
|
||||||
$data = array_map(function($m) {
|
$data = array_map(function($m) {
|
||||||
$mediaUrl = $m['media_url'] ?? null;
|
$mediaUrl = $m['media_url'] ?? null;
|
||||||
$external = null;
|
$localFile = $m['local_file'] ?? null;
|
||||||
if ($mediaUrl) {
|
$external = null;
|
||||||
|
if ($mediaUrl && !$localFile) {
|
||||||
|
// WhatsApp CDN URLs require Bearer token — always proxy through media-url.php
|
||||||
if (preg_match('#^https?://#i', $mediaUrl)) {
|
if (preg_match('#^https?://#i', $mediaUrl)) {
|
||||||
$external = $mediaUrl;
|
$external = '/api/version/media-url.php?url=' . urlencode($mediaUrl);
|
||||||
} else {
|
} else {
|
||||||
$external = '../../../api/get_media.php?id=' . urlencode($mediaUrl);
|
$external = '/api/version/media-url.php?id=' . urlencode($mediaUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET — Plantillas habilitadas para el chat turnero.
|
||||||
|
* Crea la tabla si no existe aún.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
requireAuthentication();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS turnero_chat_plantillas (
|
||||||
|
template_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (template_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||||
|
|
||||||
|
$rows = $pdo->query(
|
||||||
|
"SELECT t.id, t.name, t.template_name, t.language_code, t.body_text,
|
||||||
|
t.header_text, t.header_type, t.footer_text, t.example_parameters, t.status
|
||||||
|
FROM turnero_chat_plantillas p
|
||||||
|
JOIN message_templates t ON t.id = p.template_id
|
||||||
|
ORDER BY t.name ASC"
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo json_encode(['ok' => true, 'data' => $rows]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ try {
|
|||||||
|
|
||||||
// Guardar reacción en el mensaje
|
// Guardar reacción en el mensaje
|
||||||
$db->query(
|
$db->query(
|
||||||
"UPDATE messages SET reaction_emoji = ?, reaction_to_message_id = ? WHERE whatsapp_message_id = ?",
|
"UPDATE conversations SET reaction_emoji = ?, reaction_to_message_id = ? WHERE message_id = ?",
|
||||||
[$emoji, $messageId, $messageId]
|
[$emoji, $messageId, $messageId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST — Guarda la lista de plantillas habilitadas para el chat turnero.
|
||||||
|
* Body: { ids: [1, 2, 3] }
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
requireAuthentication();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Método no permitido']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$ids = array_map('intval', $input['ids'] ?? []);
|
||||||
|
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS turnero_chat_plantillas (
|
||||||
|
template_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (template_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
$pdo->exec("DELETE FROM turnero_chat_plantillas");
|
||||||
|
|
||||||
|
if (!empty($ids)) {
|
||||||
|
$ph = implode(',', array_fill(0, count($ids), '(?)'));
|
||||||
|
$stmt = $pdo->prepare("INSERT IGNORE INTO turnero_chat_plantillas (template_id) VALUES $ph");
|
||||||
|
$stmt->execute($ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
@@ -35,7 +35,10 @@ try {
|
|||||||
$wa = new WhatsAppService('turnero');
|
$wa = new WhatsAppService('turnero');
|
||||||
|
|
||||||
if ($type === 'template') {
|
if ($type === 'template') {
|
||||||
$response = $wa->sendTemplateMessage($user['phone_number'], $template, $lang, $params);
|
error_log("chat_send_message template: name={$template} lang={$lang} params=" . json_encode($params));
|
||||||
|
$meta = ['canal' => 'turnero'];
|
||||||
|
if ($operatorId) $meta['operator_id'] = $operatorId;
|
||||||
|
$response = $wa->sendTemplateMessage($user['phone_number'], $template, $lang, $params, [], null, $meta);
|
||||||
} else {
|
} else {
|
||||||
$extra = ['canal' => 'turnero'];
|
$extra = ['canal' => 'turnero'];
|
||||||
if ($operatorId) $extra['operator_id'] = $operatorId;
|
if ($operatorId) $extra['operator_id'] = $operatorId;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user