2 Commits
Author SHA1 Message Date
LizandroandClaude Sonnet 4.6 9749d726fa feat(turnero): cambio de estado y reapertura de turnos desde historial
Agrega botón de cambio de estado en cada fila del historial de turnos.
Admins (sin role_id) pueden reabrir turnos finalizados/ausentes/cancelados.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 01:43:29 +00:00
Lizandro GuarnizoandClaude Sonnet 4.6 339ca8a40d debug: habilitar display_errors temporalmente para diagnosticar 500
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 08:47:07 -05:00
137 changed files with 1288 additions and 13936 deletions
-48
View File
@@ -1,48 +0,0 @@
<?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>';
-58
View File
@@ -1,58 +0,0 @@
<?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>';
-39
View File
@@ -1,39 +0,0 @@
<?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>';
-42
View File
@@ -1,42 +0,0 @@
<?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.");
-64
View File
@@ -1,64 +0,0 @@
<?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.');
-67
View File
@@ -1,67 +0,0 @@
<?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);
-162
View File
@@ -1,162 +0,0 @@
<?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);
-64
View File
@@ -1,64 +0,0 @@
<?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.');
-157
View File
@@ -1,157 +0,0 @@
<?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,
]);
-16
View File
@@ -1,16 +0,0 @@
<?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]);
-2
View File
@@ -21,7 +21,6 @@ $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,
@@ -34,7 +33,6 @@ $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';
} }
+1 -7
View File
@@ -13,17 +13,11 @@ 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, $origen)); jsonOk($pac->listar($busqueda, $pagina, $por));
} catch (Exception $e) { } catch (Exception $e) {
jsonError($e->getMessage(), 500); jsonError($e->getMessage(), 500);
} }
-70
View File
@@ -1,70 +0,0 @@
<?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()]);
}
-235
View File
@@ -1,235 +0,0 @@
<?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()]);
}
-26
View File
@@ -1,26 +0,0 @@
<?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]);
-13
View File
@@ -91,19 +91,6 @@ 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
-28
View File
@@ -1,28 +0,0 @@
<?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.']);
-139
View File
@@ -1,139 +0,0 @@
<?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.");
}
-71
View File
@@ -1,71 +0,0 @@
<?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);
-18
View File
@@ -1,18 +0,0 @@
<?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);
+115 -110
View File
@@ -935,6 +935,32 @@ 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; }
@@ -1457,125 +1483,104 @@ 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 {
--dm-body: #0a1628; --light-bg: #071322;
--dm-surf: #071322; --text-primary: #e6eef1;
--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;
} }
}
[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) ── */ body {
@media (prefers-color-scheme: dark) { background-color: var(--light-bg);
body { background-color: var(--dm-body); color: var(--dm-txt); } color: var(--text-primary);
/* 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); /* Encabezados y títulos */
color: var(--dm-txt); .content-header h1,
border-color: #2d6a8a; .card-header h5,
box-shadow: 0 0 0 .2rem rgba(45,106,138,.25); .sidebar-header h4,
.status-text,
.status-indicator,
.conversation-empty {
color: var(--text-primary) !important;
} }
.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 */ /* Contenedores */
.sidebar { background: linear-gradient(135deg, #08131a, #0b2221); color: #e6eef1; } .content-header,
.card,
.chat-container {
background: #071322;
color: var(--text-primary);
border-color: var(--border-color);
box-shadow: none;
}
/* Chat */ .card-header {
.chat-container { background: var(--dm-surf); color: var(--dm-txt); border-color: var(--dm-bdr); } background: transparent;
.chat-header { background: linear-gradient(135deg, #1f5a3f, #0f513e); color: #fff; } border-bottom-color: rgba(255,255,255,0.04);
.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 */ /* Formularios y botones */
a:not(.btn):not(.badge), .nav-link { color: var(--dm-link); } .form-control {
background: #072033;
color: var(--text-primary);
border-color: #163240;
}
/* Placeholders */ .btn {
::placeholder { color: var(--dm-muted) !important; opacity: 1; } 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; }
} }
/* ── 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; }
-48
View File
@@ -1,48 +0,0 @@
/**
* 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();
}
};
+1 -1
View File
@@ -364,7 +364,7 @@ class Domicilio {
private function filtrarCampos(array $datos): array { private function filtrarCampos(array $datos): array {
$permitidos = [ $permitidos = [
'orden_id', 'paciente_id', 'numero_orden', 'direccion', 'ciudad', 'barrio', 'orden_id', 'paciente_id', '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',
+5 -7
View File
@@ -23,10 +23,8 @@ 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) + (SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id) AS total_envios,
(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')) AS total_completados
(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
@@ -216,13 +214,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(COALESCE(tc.firmado_at, tc.enviado_at, t.creado_at)) >= ?'; $paramsB[] = $filtros['fecha_desde']; } if (!empty($filtros['fecha_desde'])) { $whereB[] = 'DATE(tc.enviado_at) >= ?'; $paramsB[] = $filtros['fecha_desde']; }
if (!empty($filtros['fecha_hasta'])) { $whereB[] = 'DATE(COALESCE(tc.firmado_at, tc.enviado_at, t.creado_at)) <= ?'; $paramsB[] = $filtros['fecha_hasta']; } if (!empty($filtros['fecha_hasta'])) { $whereB[] = 'DATE(tc.enviado_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.firmado_at, tc.enviado_at, t.creado_at) AS fecha, $sqlB = "SELECT tc.id, tc.formulario_id, tc.estado, COALESCE(tc.enviado_at, tc.firmado_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,
+12 -30
View File
@@ -27,25 +27,20 @@ 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]
: [];
$conditions = []; $where = $busqueda
$params = []; ? "WHERE p.nombre_completo LIKE ?
OR p.numero_documento LIKE ?
if ($busqueda) { OR p.telefono LIKE ?
$conditions[] = "(p.nombre_completo LIKE ? OR p.numero_documento LIKE ? OR p.telefono LIKE ? OR p.email 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",
@@ -61,7 +56,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.created_at DESC, p.nombre_completo ASC ORDER BY p.nombre_completo ASC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
", array_merge($params, [$porPagina, $offset])); ", array_merge($params, [$porPagina, $offset]));
@@ -74,19 +69,6 @@ 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).
*/ */
@@ -256,7 +238,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', 'origen', 'ciudad', 'barrio', 'eps', 'notas_admin', 'is_active',
]; ];
$campos = array_intersect_key($datos, array_flip($permitidos)); $campos = array_intersect_key($datos, array_flip($permitidos));
if (!empty($campos['telefono'])) { if (!empty($campos['telefono'])) {
-9
View File
@@ -402,14 +402,6 @@ 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
@@ -552,7 +544,6 @@ 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,
]; ];
-5
View File
@@ -1,5 +0,0 @@
# Bloquear acceso web a la llave privada
<Files "private-key.pem">
Order deny,allow
Deny from all
</Files>
-24
View File
@@ -1,24 +0,0 @@
-----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-----
-28
View File
@@ -1,28 +0,0 @@
-----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-----
-85
View File
@@ -44,30 +44,6 @@ 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);
@@ -123,67 +99,6 @@ 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
-5
View File
@@ -67,11 +67,6 @@ 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';
+3 -6
View File
@@ -60,15 +60,12 @@ 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=15" rel="stylesheet"> <link href="<?= $base ?>/assets/css/styles.css?v=14" rel="stylesheet">
<style> <style>
:root { :root {
@@ -80,7 +77,7 @@ class Layout
.main-content { .main-content {
margin-left: var(--sidebar-width); margin-left: var(--sidebar-width);
min-height: 100vh; min-height: 100vh;
background: var(--bs-body-bg, #f8f9fa); background: #f8f9fa;
} }
@media (max-width: 991.98px) { @media (max-width: 991.98px) {
.main-content { margin-left: 0; } .main-content { margin-left: 0; }
@@ -111,7 +108,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-body-tertiary border-bottom px-4 py-3 d-flex align-items-center justify-content-between"> <div class="bg-white 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>
+2 -195
View File
@@ -90,29 +90,6 @@ 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>
@@ -227,15 +204,11 @@ 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 flex-wrap"> <div class="d-flex gap-2 mt-2">
<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>
@@ -321,36 +294,6 @@ 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>
@@ -705,15 +648,11 @@ 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 flex-wrap"> <div class="d-flex gap-2 mt-2">
<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>`;
@@ -1266,138 +1205,6 @@ 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
// ══════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════
-20
View File
@@ -1,20 +0,0 @@
; 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
}
-5
View File
@@ -1,5 +0,0 @@
@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
-8
View File
@@ -681,7 +681,6 @@ 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>'}
@@ -800,13 +799,6 @@ 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>
+26 -142
View File
@@ -56,28 +56,7 @@ $brandDark = sprintf('#%02x%02x%02x',
color: #64748b; color: #64748b;
} }
.hist-badge { font-size: .72rem; } .hist-badge { font-size: .72rem; }
/* Drawer de detalle (fixed overlay) */ #detail-panel { display: none; }
#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 {
@@ -146,10 +125,9 @@ $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; color: #fff; border-radius: .5rem .5rem 0 0;
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); }
@@ -177,37 +155,8 @@ require_once __DIR__ . '/shared/components/sidebar.php';
</header> </header>
<div class="container-fluid py-3"> <div class="container-fluid py-3">
<!-- Buscador -->
<!-- Stats --> <div class="row mb-3">
<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>
@@ -216,24 +165,11 @@ 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-12" id="lista-col"> <div class="col-lg-7" 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">
@@ -243,17 +179,13 @@ 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="10" class="text-center py-4 text-muted"> <tr><td colspan="6" 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>
@@ -263,22 +195,21 @@ 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">
@@ -436,54 +367,16 @@ 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 origen = document.getElementById('filtro-origen').value; const r = await fetch(`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(busq)}&page=${pag}&limit=25`);
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="10" class="text-center py-4 text-muted">Sin resultados</td></tr>'; tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-muted">Sin resultados</td></tr>';
document.getElementById('paginacion').innerHTML = ''; document.getElementById('paginacion').innerHTML = '';
return; return;
} }
@@ -492,15 +385,11 @@ 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.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''} ${p.phone_number ? `<small class="text-muted"><i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}</small>` : ''}
</td> </td>
<td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></td> <td class="small">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</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.telefono||'—')}</td>
<td class="small text-muted">${esc(p.email||'—')}</td> <td class="small">${esc(p.eps||'—')}</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"
@@ -528,8 +417,7 @@ 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.classList.add('open'); panel.style.display = 'block';
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}`);
@@ -548,8 +436,6 @@ 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>` : ''}
@@ -579,8 +465,7 @@ async function verDetalle(id) {
} }
function cerrarDetalle() { function cerrarDetalle() {
document.getElementById('detail-panel').classList.remove('open'); document.getElementById('detail-panel').style.display = 'none';
document.getElementById('detail-backdrop').style.display = 'none';
} }
// ── Formulario ───────────────────────────────────────────────────────────── // ── Formulario ─────────────────────────────────────────────────────────────
@@ -743,7 +628,6 @@ 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>
-191
View File
@@ -210,56 +210,6 @@ 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
══════════════════════════════════════════════════════════ --> ══════════════════════════════════════════════════════════ -->
@@ -510,11 +460,6 @@ 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>`;
@@ -651,142 +596,6 @@ 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>
-35
View File
@@ -41,25 +41,6 @@ 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'] ?? [];
@@ -70,22 +51,6 @@ 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)) {
-25
View File
@@ -1,25 +0,0 @@
<?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>";
@@ -1,7 +0,0 @@
-- 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);
@@ -1,32 +0,0 @@
-- =============================================================
-- 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';
-43
View File
@@ -1,43 +0,0 @@
-- =============================================================
-- 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';
-26
View File
@@ -1,26 +0,0 @@
-- =============================================================
-- 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';
@@ -1,50 +0,0 @@
-- =============================================================
-- 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);
-97
View File
@@ -1,97 +0,0 @@
-- =============================================================
-- 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';
-90
View File
@@ -1,90 +0,0 @@
-- =============================================================
-- 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';
@@ -1,28 +0,0 @@
-- =============================================================
-- 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';
-186
View File
@@ -1,186 +0,0 @@
-- =============================================================
-- 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;
@@ -1,17 +0,0 @@
-- =============================================================
-- 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);
@@ -1,16 +0,0 @@
-- =============================================================
-- 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';
@@ -1,25 +0,0 @@
-- =============================================================
-- 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;
@@ -1,7 +0,0 @@
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';
@@ -1,7 +0,0 @@
-- ============================================================
-- 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)';
@@ -1,107 +0,0 @@
-- ============================================================
-- 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;
@@ -1,5 +0,0 @@
-- 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;
@@ -1,28 +0,0 @@
-- ============================================================
-- 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);
@@ -1,6 +0,0 @@
-- 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';
@@ -1,5 +0,0 @@
-- 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);
@@ -1,13 +0,0 @@
-- 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`);
-25
View File
@@ -1,25 +0,0 @@
-- 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) != '';
@@ -1,2 +0,0 @@
ALTER TABLE turnero_solicitudes
ADD COLUMN IF NOT EXISTS numero_recibo VARCHAR(40) NULL DEFAULT NULL AFTER metodo_pago;
@@ -1,12 +0,0 @@
-- 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;
@@ -1,6 +0,0 @@
-- 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;
-2
View File
@@ -1,2 +0,0 @@
config.local.php
etl_*.log
-42
View File
@@ -1,42 +0,0 @@
<?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',
],
];
-90
View File
@@ -1,90 +0,0 @@
<?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();
}
-715
View File
@@ -1,715 +0,0 @@
#!/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";
@@ -1,37 +0,0 @@
<?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";
-3
View File
@@ -12,9 +12,6 @@ $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();
-12
View File
@@ -1,12 +0,0 @@
<?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']],
];
-149
View File
@@ -1,149 +0,0 @@
<?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>
-16
View File
@@ -1,16 +0,0 @@
<?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'],
],
];
-556
View File
@@ -1,556 +0,0 @@
<?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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escAttr(s) { return escHtml(s).replace(/'/g,'&#39;'); }
</script>
<?php Layout::close(); ?>
-212
View File
@@ -1,212 +0,0 @@
<?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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
<?php Layout::close(); ?>
-12
View File
@@ -1,12 +0,0 @@
<?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']],
];
-178
View File
@@ -1,178 +0,0 @@
<?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>
-55
View File
@@ -1,55 +0,0 @@
<?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;
}
-20
View File
@@ -1,20 +0,0 @@
<?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)]);
-23
View File
@@ -1,23 +0,0 @@
<?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)]);
-37
View File
@@ -1,37 +0,0 @@
<?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]);
-71
View File
@@ -1,71 +0,0 @@
<?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);
}
-44
View File
@@ -1,44 +0,0 @@
<?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');
}
-45
View File
@@ -1,45 +0,0 @@
<?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');
-15
View File
@@ -1,15 +0,0 @@
<?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'],
],
];
-507
View File
@@ -1,507 +0,0 @@
<?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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
</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(); ?>
-167
View File
@@ -1,167 +0,0 @@
<?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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
</script>
<?php Layout::close(); ?>
+14 -28
View File
@@ -6,35 +6,21 @@ 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 . '%';
$where = "WHERE nombres LIKE ? OR apellidos LIKE ? OR codigo LIKE ? OR docidmedico LIKE ?"; $stmt = $pdo->prepare(
$params = [$like, $like, $like, $like]; "SELECT id, codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico, activo
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"
);
} }
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM medicos $where"); echo json_encode(['ok' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
$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,
]);
+9 -71
View File
@@ -29,23 +29,6 @@ $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">
@@ -77,14 +60,10 @@ $API = BASE_URL . 'modules/medicos/api/';
</tr> </tr>
</thead> </thead>
<tbody id="tbl-body"> <tbody id="tbl-body">
<tr id="tbl-empty"><td colspan="7">Cargando…</td></tr> <tr id="tbl-empty"><td colspan="6">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>
@@ -171,7 +150,6 @@ $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'));
@@ -179,33 +157,24 @@ document.addEventListener('DOMContentLoaded', () => {
cargar(); cargar();
}); });
async function cargar(q = _q, page = _page) { async function cargar(q = '') {
_q = q; const url = API + 'list.php' + (q ? '?q=' + encodeURIComponent(q) : '');
_page = page; const res = await fetch(url);
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(), 1), 280); _buscarTimer = setTimeout(() => cargar(document.getElementById('inp-buscar').value.trim()), 280);
} }
function renderTabla(rows) { function renderTabla(rows) {
const body = document.getElementById('tbl-body'); const body = document.getElementById('tbl-body');
const desde = (_page - 1) * _limit + 1; document.getElementById('lbl-total').textContent = rows.length + ' médico(s)';
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="7">Sin resultados.</td></tr>'; body.innerHTML = '<tr id="tbl-empty"><td colspan="6">Sin resultados.</td></tr>';
return; return;
} }
body.innerHTML = rows.map(m => ` body.innerHTML = rows.map(m => `
@@ -229,37 +198,6 @@ 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');
@@ -309,7 +247,7 @@ async function guardar() {
return; return;
} }
_modal.hide(); _modal.hide();
cargar(_q, _page); cargar(document.getElementById('inp-buscar').value.trim());
} 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';
@@ -334,7 +272,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(_q, _page); cargar(document.getElementById('inp-buscar').value.trim());
} }
} finally { } finally {
btn.disabled = false; btn.disabled = false;
+7 -13
View File
@@ -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, ?int $sesionId = null): ?array function siguienteTurnoEnCola(string $estado, ?int $lugarId = null, ?array $lugarIds = null): ?array
{ {
$pdo = db(); $pdo = db();
@@ -207,8 +207,6 @@ 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 = "
@@ -216,29 +214,25 @@ 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
"; ";
$binds = $sesionId !== null ? [$estado, $sesionId] : [$estado]; $stmt = $pdo->prepare($sql);
$stmt = $pdo->prepare($sql); $stmt->execute([$estado]);
$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
"; ';
$binds = $sesionId !== null ? [$estado, $sesionId] : [$estado]; $stmt = $pdo->prepare($sql);
$stmt = $pdo->prepare($sql); $stmt->execute([$estado]);
$stmt->execute($binds);
} }
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
-22
View File
@@ -1,22 +0,0 @@
<?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)]);
+54 -9
View File
@@ -29,7 +29,6 @@ $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.');
@@ -65,10 +64,60 @@ try {
$estadoActual = $turno['estado']; $estadoActual = $turno['estado'];
// Estados finales no modificables // Estados finales — solo admin (sin role_id) puede reabrir
if (in_array($estadoActual, ['finalizado', 'ausente', 'cancelado'], true)) { if (in_array($estadoActual, ['finalizado', 'ausente', 'cancelado'], true)) {
$pdo->rollBack(); $roleId = $_SESSION['admin_user']['role_id'] ?? null;
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422); if (!empty($roleId)) {
$pdo->rollBack();
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422);
}
$permitidosAdmin = ['en_servicio', 'en_espera_lugar', 'en_recepcion', 'espera'];
if (!in_array($nuevoEstado, $permitidosAdmin, true)) {
$pdo->rollBack();
jsonError("Estado destino inválido para reapertura. Opciones: " . implode(', ', $permitidosAdmin), 422);
}
$sets = ['estado = ?'];
$binds = [$nuevoEstado];
if ($nuevoEstado === 'en_servicio') {
$sets[] = 'fin_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = COALESCE(inicio_lugar_at, NOW())';
$sets[] = 'atendido_lugar_por = COALESCE(atendido_lugar_por, ?)';
$binds[] = adminId();
} elseif ($nuevoEstado === 'en_espera_lugar') {
$sets[] = 'fin_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = NULL';
$sets[] = 'llamado_lugar_at = NULL';
} elseif ($nuevoEstado === 'espera') {
$sets[] = 'llamado_recepcion_at = NULL';
$sets[] = 'inicio_recepcion_at = NULL';
$sets[] = 'fin_recepcion_at = NULL';
$sets[] = 'llamado_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = NULL';
$sets[] = 'fin_lugar_at = NULL';
$sets[] = 'atendido_recepcion_por = NULL';
$sets[] = 'atendido_lugar_por = NULL';
$sets[] = 'recepcion_desk_id = NULL';
}
$sql = 'UPDATE turnero_turnos SET ' . implode(', ', $sets) . ' WHERE id = ?';
$binds[] = $turnoId;
$pdo->prepare($sql)->execute($binds);
$pdo->commit();
$stmt = $pdo->prepare(
'SELECT t.*, p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, p.color AS prioridad_color
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.id = ?'
);
$stmt->execute([$turnoId]);
$turnoActualizado = $stmt->fetch(PDO::FETCH_ASSOC);
notificarSSE((int) $turnoActualizado['sesion_id']);
jsonOk(['turno' => $turnoActualizado], "Turno reabierto: estado actualizado a '{$nuevoEstado}'");
} }
// Verificar que la transición sea válida // Verificar que la transición sea válida
@@ -108,14 +157,10 @@ try {
switch ($nuevoEstado) { switch ($nuevoEstado) {
case 'en_recepcion': case 'en_recepcion':
$sets[] = 'llamado_recepcion_at = NOW()'; $sets[] = 'llamado_recepcion_at = COALESCE(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':
@@ -1,33 +0,0 @@
<?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]);
@@ -1,29 +0,0 @@
<?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()]);
}
+5 -7
View File
@@ -75,15 +75,13 @@ 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;
$localFile = $m['local_file'] ?? null; $external = null;
$external = null; if ($mediaUrl) {
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 = '/api/version/media-url.php?url=' . urlencode($mediaUrl); $external = $mediaUrl;
} else { } else {
$external = '/api/version/media-url.php?id=' . urlencode($mediaUrl); $external = '../../../api/get_media.php?id=' . urlencode($mediaUrl);
} }
} }
return [ return [
@@ -1,30 +0,0 @@
<?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()]);
}
+1 -1
View File
@@ -34,7 +34,7 @@ try {
// Guardar reacción en el mensaje // Guardar reacción en el mensaje
$db->query( $db->query(
"UPDATE conversations SET reaction_emoji = ?, reaction_to_message_id = ? WHERE message_id = ?", "UPDATE messages SET reaction_emoji = ?, reaction_to_message_id = ? WHERE whatsapp_message_id = ?",
[$emoji, $messageId, $messageId] [$emoji, $messageId, $messageId]
); );
@@ -1,42 +0,0 @@
<?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()]);
}
+1 -4
View File
@@ -35,10 +35,7 @@ try {
$wa = new WhatsAppService('turnero'); $wa = new WhatsAppService('turnero');
if ($type === 'template') { if ($type === 'template') {
error_log("chat_send_message template: name={$template} lang={$lang} params=" . json_encode($params)); $response = $wa->sendTemplateMessage($user['phone_number'], $template, $lang, $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;
@@ -1,81 +0,0 @@
<?php
/**
* POST — Inicia una conversación nueva desde el chat turnero.
* Body: { phone, template_name, lang, params }
* Busca o crea el usuario en `users`, envía la plantilla y devuelve user_id.
*/
require_once __DIR__ . '/../../../config/config.php';
require_once __DIR__ . '/../../../services/WhatsAppService.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) ?: [];
$phone = trim($input['phone'] ?? '');
$templateName = trim($input['template_name'] ?? '');
$lang = trim($input['lang'] ?? 'es_CO');
$params = $input['params'] ?? [];
if (!$phone || !$templateName) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'phone y template_name son requeridos']); exit;
}
// Normalizar teléfono: solo dígitos, agregar 57 si es colombiano de 10 dígitos
$phoneClean = preg_replace('/\D/', '', $phone);
if (strlen($phoneClean) === 10 && $phoneClean[0] === '3') {
$phoneClean = '57' . $phoneClean;
}
if (strlen($phoneClean) < 10) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Número de teléfono inválido']); exit;
}
$db = Database::getInstance();
$pdo = $db->getConnection();
// Buscar usuario existente (puede estar guardado con o sin prefijo 57)
$user = $pdo->prepare(
"SELECT id, name FROM users WHERE phone_number = ? OR phone_number = ? LIMIT 1"
);
$user->execute([$phoneClean, ltrim($phoneClean, '57')]);
$userRow = $user->fetch(PDO::FETCH_ASSOC);
if (!$userRow) {
// Buscar nombre en lab_pacientes por teléfono
$pac = $pdo->prepare(
"SELECT nombre_completo FROM lab_pacientes WHERE telefono LIKE ? AND is_active=1 LIMIT 1"
);
$pac->execute(['%' . substr($phoneClean, -10) . '%']);
$pacRow = $pac->fetch(PDO::FETCH_ASSOC);
$nombre = $pacRow['nombre_completo'] ?? $phoneClean;
// Crear usuario mínimo
$ins = $pdo->prepare(
"INSERT INTO users (name, phone_number, created_at) VALUES (?, ?, NOW())"
);
$ins->execute([$nombre, $phoneClean]);
$userId = (int)$pdo->lastInsertId();
} else {
$userId = (int)$userRow['id'];
}
// Enviar plantilla
$operatorId = $_SESSION['admin_id'] ?? $_SESSION['user_id'] ?? null;
$meta = ['canal' => 'turnero'];
if ($operatorId) $meta['operator_id'] = $operatorId;
$wa = new WhatsAppService('turnero');
$wa->sendTemplateMessage($phoneClean, $templateName, $lang, $params, [], null, $meta);
echo json_encode(['ok' => true, 'user_id' => $userId]);
} catch (Throwable $e) {
error_log('chat_start_conversation: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}

Some files were not shown because too many files have changed in this diff Show More