This commit is contained in:
Lizandro Guarnizo
2026-04-18 23:41:01 -05:00
parent 876e935a66
commit 318a1742cc
37 changed files with 8838 additions and 89 deletions
+159
View File
@@ -0,0 +1,159 @@
<?php
/**
* Helpers compartidos para los endpoints del módulo Registro de Exámenes.
* Incluido al inicio de cada archivo en modules/registro_exams/api/.
*/
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');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
// ── Respuestas JSON ──────────────────────────────────────────
function jsonOk(array $payload = [], string $mensaje = ''): void
{
ob_clean();
$resp = ['ok' => true, 'success' => true];
if ($mensaje) {
$resp['message'] = $mensaje;
}
echo json_encode(array_merge($resp, $payload), JSON_UNESCAPED_UNICODE);
exit;
}
function jsonError(string $mensaje, int $code = 400): void
{
ob_clean();
http_response_code($code);
echo json_encode(['ok' => false, 'success' => false, 'error' => $mensaje], JSON_UNESCAPED_UNICODE);
exit;
}
function requireMethod(string $method): void
{
if ($_SERVER['REQUEST_METHOD'] !== strtoupper($method)) {
jsonError('Método no permitido', 405);
}
}
function inputJson(): array
{
static $parsed = null;
if ($parsed === null) {
$raw = file_get_contents('php://input');
$parsed = json_decode($raw, true) ?? [];
}
return $parsed;
}
// ── Autenticación ────────────────────────────────────────────
function requireAuth(): void
{
if (empty($_SESSION['admin_user']['id'])) {
jsonError('No autenticado', 401);
}
}
/**
* Exige sesión + acceso al módulo registro_exams.
*/
function requireExams(): void
{
requireAuth();
$modules = $_SESSION['admin_user']['modules'] ?? [];
$roleId = $_SESSION['admin_user']['role_id'] ?? null;
// Superadmin / admin global = acceso total
if (empty($roleId)) {
return;
}
if (!in_array('registro_exams', (array) $modules, true)) {
jsonError('Sin acceso al módulo Registro Exámenes', 403);
}
}
function adminId(): ?int
{
return isset($_SESSION['admin_user']['id'])
? (int) $_SESSION['admin_user']['id']
: null;
}
// ── Helper BD ────────────────────────────────────────────────
function db(): PDO
{
return Database::getInstance()->getConnection();
}
// ── Código de orden ──────────────────────────────────────────
/**
* Genera el siguiente código de orden legible: EX-YYYYMMDD-NNNN
* Usa SELECT FOR UPDATE para evitar duplicados en concurrencia.
*/
function generarCodigoOrden(): string
{
$pdo = db();
$hoy = date('Ymd');
$prefijo = "EX-{$hoy}-";
$stmt = $pdo->prepare(
"SELECT COALESCE(MAX(CAST(SUBSTRING(codigo, -4) AS UNSIGNED)), 0) + 1 AS siguiente
FROM exam_ordenes
WHERE codigo LIKE ?
FOR UPDATE"
);
$stmt->execute(["{$prefijo}%"]);
$n = (int) $stmt->fetchColumn();
return $prefijo . str_pad($n, 4, '0', STR_PAD_LEFT);
}
// ── Etiqueta de prioridad ────────────────────────────────────
function badgePrioridad(string $p): string
{
return match ($p) {
'urgente' => '<span class="badge bg-warning text-dark">Urgente</span>',
'stat' => '<span class="badge bg-danger">STAT</span>',
default => '<span class="badge bg-secondary">Normal</span>',
};
}
function badgeEstadoOrden(string $e): string
{
return match ($e) {
'en_proceso' => '<span class="badge bg-primary">En proceso</span>',
'completa' => '<span class="badge bg-success">Completa</span>',
'entregada' => '<span class="badge bg-info text-dark">Entregada</span>',
'cancelada' => '<span class="badge bg-dark">Cancelada</span>',
default => '<span class="badge bg-light text-dark border">Pendiente</span>',
};
}
@@ -0,0 +1,143 @@
<?php
/**
* modules/registro_exams/api/cambiar_estado_item.php
* POST — cambia el estado de un ítem, muestra o de la orden entera.
*
* Body JSON:
* {
* "tipo": "item" | "muestra" | "orden",
* "id": int,
* "estado": string, // valor según ENUM de la tabla
* "notas": string // opcional, solo para ítem
* }
*
* Adicional para tipo="muestra" (crear muestra si id=null):
* {
* "tipo": "muestra",
* "id": null, // null = crear nueva
* "orden_id": int,
* "tipo_muestra": "sangre_venosa" | ...,
* "codigo_barras": string,
* "estado": "tomada"
* }
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireExams();
$body = inputJson();
$tipo = $body['tipo'] ?? '';
$estadosItem = ['pendiente','muestra_tomada','en_proceso','resultado_listo','entregado'];
$estadosMuestra = ['pendiente','tomada','procesando','procesada','rechazada'];
$estadosOrden = ['pendiente','en_proceso','completa','entregada','cancelada'];
$pdo = db();
switch ($tipo) {
// ── Cambiar estado de un ítem individual ──────────────────────
case 'item': {
$id = isset($body['id']) ? (int) $body['id'] : 0;
$estado = $body['estado'] ?? '';
if ($id <= 0 || !in_array($estado, $estadosItem, true)) {
jsonError('Parámetros inválidos para ítem');
}
$notas = isset($body['notas']) && $body['notas'] !== '' ? $body['notas'] : null;
// Obtener orden_id para actualizar padre si aplica
$stmtG = $pdo->prepare('SELECT orden_id FROM exam_items WHERE id = ?');
$stmtG->execute([$id]);
$item = $stmtG->fetch(PDO::FETCH_ASSOC);
if (!$item) {
jsonError('Ítem no encontrado', 404);
}
$pdo->prepare(
'UPDATE exam_items SET estado = ?, notas_tecnico = ?, creado_at = creado_at WHERE id = ?'
)->execute([$estado, $notas, $id]);
// Si todos los ítems de la orden están listos → marcar orden como completa
if ($estado === 'resultado_listo' || $estado === 'entregado') {
$stmtC = $pdo->prepare(
"SELECT COUNT(*) FROM exam_items
WHERE orden_id = ?
AND estado NOT IN ('resultado_listo','entregado')"
);
$stmtC->execute([$item['orden_id']]);
if ((int) $stmtC->fetchColumn() === 0) {
$pdo->prepare(
"UPDATE exam_ordenes SET estado = 'completa', updated_at = NOW() WHERE id = ?"
)->execute([$item['orden_id']]);
}
}
jsonOk([], 'Estado actualizado');
}
// ── Crear o actualizar una muestra ──────────────────────────
case 'muestra': {
$id = isset($body['id']) && $body['id'] !== null ? (int) $body['id'] : null;
$estado = $body['estado'] ?? 'tomada';
$ordenId = isset($body['orden_id']) ? (int) $body['orden_id'] : 0;
$tipoMuest = $body['tipo_muestra'] ?? 'sangre_venosa';
$barcode = trim($body['codigo_barras'] ?? '');
if (!in_array($estado, $estadosMuestra, true)) {
jsonError('Estado de muestra inválido');
}
if ($id === null) {
// Crear nueva muestra
if ($ordenId <= 0) {
jsonError('orden_id requerido para crear muestra');
}
$pdo->prepare('
INSERT INTO exam_muestras
(orden_id, tipo_muestra, codigo_barras, tomada_por, tomada_at, estado)
VALUES (?, ?, ?, ?, NOW(), ?)
')->execute([$ordenId, $tipoMuest, $barcode ?: null, adminId(), $estado]);
$newId = (int) $pdo->lastInsertId();
// Marcar ítems como muestra_tomada si están pendientes
$pdo->prepare(
"UPDATE exam_items SET estado = 'muestra_tomada'
WHERE orden_id = ? AND estado = 'pendiente'"
)->execute([$ordenId]);
// Actualizar orden a en_proceso si aún está pendiente
$pdo->prepare(
"UPDATE exam_ordenes SET estado = 'en_proceso', updated_at = NOW()
WHERE id = ? AND estado = 'pendiente'"
)->execute([$ordenId]);
jsonOk(['muestra_id' => $newId], 'Muestra registrada');
} else {
// Actualizar muestra existente
$pdo->prepare(
'UPDATE exam_muestras SET estado = ?, codigo_barras = ? WHERE id = ?'
)->execute([$estado, $barcode ?: null, $id]);
jsonOk([], 'Muestra actualizada');
}
}
// ── Cambiar estado de la orden completa ─────────────────────
case 'orden': {
$id = isset($body['id']) ? (int) $body['id'] : 0;
$estado = $body['estado'] ?? '';
if ($id <= 0 || !in_array($estado, $estadosOrden, true)) {
jsonError('Parámetros inválidos para orden');
}
$pdo->prepare(
'UPDATE exam_ordenes SET estado = ?, updated_at = NOW() WHERE id = ?'
)->execute([$estado, $id]);
jsonOk([], 'Estado de orden actualizado');
}
default:
jsonError('Tipo de operación desconocido: ' . $tipo);
}
+111
View File
@@ -0,0 +1,111 @@
<?php
/**
* modules/registro_exams/api/get_orden.php
* GET ?id=<orden_id>
* Devuelve la orden completa: datos, paciente, ítems, muestras, resultados.
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
requireExams();
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($id <= 0) {
jsonError('ID de orden inválido');
}
$pdo = db();
// Orden + paciente
$stmt = $pdo->prepare('
SELECT
o.id, o.codigo, o.prioridad, o.estado,
o.requiere_ayuno, o.horas_ayuno,
o.medico_nombre, o.medico_registro,
o.diagnostico, o.notas,
o.solicitud_id, o.orden_medica_id,
o.creado_por, o.creado_at, o.updated_at,
p.id AS paciente_id,
p.nombre_completo,
p.numero_documento,
p.tipo_documento,
p.telefono,
p.email,
p.fecha_nacimiento,
p.genero,
p.eps
FROM exam_ordenes o
JOIN lab_pacientes p ON p.id = o.paciente_id
WHERE o.id = ?
');
$stmt->execute([$id]);
$orden = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$orden) {
jsonError('Orden no encontrada', 404);
}
// Calcular edad
if ($orden['fecha_nacimiento']) {
$diff = (new DateTime($orden['fecha_nacimiento']))->diff(new DateTime());
$orden['edad'] = $diff->y;
} else {
$orden['edad'] = null;
}
// Ítems
$stmt = $pdo->prepare('
SELECT
ei.id, ei.orden_id, ei.exam_tipo_id, ei.estado, ei.notas_tecnico, ei.creado_at,
et.codigo AS tipo_codigo, et.nombre AS tipo_nombre,
et.categoria, et.requiere_ayuno AS tipo_requiere_ayuno,
et.instrucciones AS tipo_instrucciones
FROM exam_items ei
JOIN exam_tipos et ON et.id = ei.exam_tipo_id
WHERE ei.orden_id = ?
ORDER BY et.categoria, et.nombre
');
$stmt->execute([$id]);
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Resultados por ítem
$resultadosPorItem = [];
if ($items) {
$itemIds = array_column($items, 'id');
$inPh = implode(',', array_fill(0, count($itemIds), '?'));
$stmt = $pdo->prepare("
SELECT er.*, au.nombre AS ingresado_nombre
FROM exam_resultados er
LEFT JOIN admin_users au ON au.id = er.ingresado_por
WHERE er.item_id IN ({$inPh})
ORDER BY er.item_id, er.id
");
$stmt->execute($itemIds);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $res) {
$resultadosPorItem[(int) $res['item_id']][] = $res;
}
}
// Agregar resultados a cada ítem
foreach ($items as &$item) {
$item['resultados'] = $resultadosPorItem[(int) $item['id']] ?? [];
}
unset($item);
// Muestras
$stmt = $pdo->prepare('
SELECT em.*, au.nombre AS tomada_nombre
FROM exam_muestras em
LEFT JOIN admin_users au ON au.id = em.tomada_por
WHERE em.orden_id = ?
ORDER BY em.id
');
$stmt->execute([$id]);
$muestras = $stmt->fetchAll(PDO::FETCH_ASSOC);
jsonOk([
'data' => array_merge($orden, [
'items' => $items,
'muestras' => $muestras,
]),
]);
+102
View File
@@ -0,0 +1,102 @@
<?php
/**
* modules/registro_exams/api/get_ordenes.php
* GET ?fecha=YYYY-MM-DD &estado=pendiente &busqueda=texto &page=1 &limit=25
* Devuelve lista de órdenes con datos del paciente e ítems (count).
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
requireExams();
$pdo = db();
$fecha = trim($_GET['fecha'] ?? date('Y-m-d'));
$estado = trim($_GET['estado'] ?? '');
$busqueda = trim($_GET['busqueda'] ?? '');
$page = max(1, (int) ($_GET['page'] ?? 1));
$limit = min(100, max(1, (int) ($_GET['limit'] ?? 25)));
$offset = ($page - 1) * $limit;
// Validar fecha
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fecha)) {
$fecha = date('Y-m-d');
}
$where = ['DATE(o.creado_at) = ?'];
$params = [$fecha];
if ($estado !== '') {
$allowed = ['pendiente', 'en_proceso', 'completa', 'entregada', 'cancelada'];
if (in_array($estado, $allowed, true)) {
$where[] = 'o.estado = ?';
$params[] = $estado;
}
}
if ($busqueda !== '') {
$like = '%' . $busqueda . '%';
$where[] = '(p.nombre_completo LIKE ? OR p.numero_documento LIKE ? OR o.codigo LIKE ?)';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
$whereSQL = 'WHERE ' . implode(' AND ', $where);
// Total para paginación
$countStmt = $pdo->prepare(
"SELECT COUNT(*) FROM exam_ordenes o
JOIN lab_pacientes p ON p.id = o.paciente_id
{$whereSQL}"
);
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
// Datos
$sql = "
SELECT
o.id, o.codigo, o.prioridad, o.estado, o.requiere_ayuno, o.horas_ayuno,
o.medico_nombre, o.diagnostico, o.notas,
o.creado_at, o.updated_at,
p.id AS paciente_id,
p.nombre_completo,
p.numero_documento,
p.tipo_documento,
p.telefono,
p.fecha_nacimiento,
(SELECT COUNT(*) FROM exam_items ei WHERE ei.orden_id = o.id) AS total_items,
(SELECT COUNT(*) FROM exam_items ei WHERE ei.orden_id = o.id AND ei.estado = 'resultado_listo') AS items_listos,
(SELECT COUNT(*) FROM exam_muestras em WHERE em.orden_id = o.id AND em.estado = 'tomada') AS muestras_tomadas
FROM exam_ordenes o
JOIN lab_pacientes p ON p.id = o.paciente_id
{$whereSQL}
ORDER BY
FIELD(o.prioridad, 'stat', 'urgente', 'normal'),
o.creado_at ASC
LIMIT {$limit} OFFSET {$offset}
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$ordenes = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Calcular edad en años para cada paciente
foreach ($ordenes as &$row) {
if ($row['fecha_nacimiento']) {
$diff = (new DateTime($row['fecha_nacimiento']))->diff(new DateTime());
$row['edad'] = $diff->y;
} else {
$row['edad'] = null;
}
}
unset($row);
jsonOk([
'data' => $ordenes,
'total' => $total,
'page' => $page,
'limit' => $limit,
'pages' => (int) ceil($total / $limit),
'fecha' => $fecha,
]);
+148
View File
@@ -0,0 +1,148 @@
<?php
/**
* modules/registro_exams/api/save_orden.php
* POST — crea o actualiza una orden de examen.
*
* Body JSON:
* {
* "id": null | int, // null = nueva / int = editar
* "paciente_id": int, // requerido
* "solicitud_id": null | int,
* "orden_medica_id": null | int,
* "medico_nombre": string,
* "medico_registro": string,
* "diagnostico": string,
* "prioridad": "normal"|"urgente"|"stat",
* "notas": string,
* "items": [int, int, ...] // exam_tipo_id[]
* }
*
* Respuesta exitosa:
* { ok: true, id: <orden_id>, codigo: "EX-YYYYMMDD-NNNN" }
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireExams();
$body = inputJson();
// ── Validaciones ──────────────────────────────────────────────
$pacienteId = isset($body['paciente_id']) ? (int) $body['paciente_id'] : 0;
if ($pacienteId <= 0) {
jsonError('Debe indicar el paciente (paciente_id)');
}
$items = isset($body['items']) && is_array($body['items'])
? array_map('intval', $body['items'])
: [];
// Filtrar zeros
$items = array_values(array_filter($items, fn($v) => $v > 0));
if (count($items) === 0) {
jsonError('Debe agregar al menos un examen');
}
// Normalizar prioridad
$prioridadesPermitidas = ['normal', 'urgente', 'stat'];
$prioridad = in_array($body['prioridad'] ?? '', $prioridadesPermitidas, true)
? $body['prioridad'] : 'normal';
$ordenId = isset($body['id']) && $body['id'] ? (int) $body['id'] : null;
$solicitudId = isset($body['solicitud_id']) ? ((int) $body['solicitud_id'] ?: null) : null;
$ordenMedId = isset($body['orden_medica_id']) ? ((int) $body['orden_medica_id'] ?: null) : null;
$medicoNombre = trim($body['medico_nombre'] ?? '');
$medicoReg = trim($body['medico_registro'] ?? '');
$diagnostico = trim($body['diagnostico'] ?? '');
$notas = trim($body['notas'] ?? '');
$pdo = db();
try {
$pdo->beginTransaction();
if ($ordenId === null) {
// ── CREAR nueva orden ──────────────────────────────────
$codigo = generarCodigoOrden();
$stmt = $pdo->prepare('
INSERT INTO exam_ordenes
(paciente_id, solicitud_id, orden_medica_id, codigo,
medico_nombre, medico_registro, diagnostico,
prioridad, notas, creado_por, creado_at)
VALUES
(?, ?, ?, ?,
?, ?, ?,
?, ?, ?, NOW())
');
$stmt->execute([
$pacienteId, $solicitudId, $ordenMedId, $codigo,
$medicoNombre ?: null, $medicoReg ?: null, $diagnostico ?: null,
$prioridad, $notas ?: null, adminId(),
]);
$ordenId = (int) $pdo->lastInsertId();
} else {
// ── ACTUALIZAR orden existente ─────────────────────────
$stmt = $pdo->prepare('
UPDATE exam_ordenes SET
medico_nombre = ?,
medico_registro = ?,
diagnostico = ?,
prioridad = ?,
notas = ?,
updated_at = NOW()
WHERE id = ?
');
$stmt->execute([
$medicoNombre ?: null, $medicoReg ?: null, $diagnostico ?: null,
$prioridad, $notas ?: null, $ordenId,
]);
// Leer código actual para devolverlo
$stmt2 = $pdo->prepare('SELECT codigo FROM exam_ordenes WHERE id = ?');
$stmt2->execute([$ordenId]);
$codigo = $stmt2->fetchColumn() ?: '';
}
// ── Sincronizar ítems ──────────────────────────────────────
// Obtener tipos ya existentes en la orden
$stmtEx = $pdo->prepare(
'SELECT exam_tipo_id FROM exam_items WHERE orden_id = ?'
);
$stmtEx->execute([$ordenId]);
$existentes = array_column($stmtEx->fetchAll(PDO::FETCH_ASSOC), 'exam_tipo_id');
$existentes = array_map('intval', $existentes);
// Insertar los que no existen aún
$nuevos = array_diff($items, $existentes);
if ($nuevos) {
$stmtIns = $pdo->prepare(
'INSERT INTO exam_items (orden_id, exam_tipo_id) VALUES (?, ?)'
);
foreach ($nuevos as $tipoId) {
$stmtIns->execute([$ordenId, $tipoId]);
}
}
// Eliminar los que ya no están en la lista (solo si aún están pendientes)
$eliminar = array_diff($existentes, $items);
if ($eliminar) {
$inPlaceholders = implode(',', array_fill(0, count($eliminar), '?'));
$stmtDel = $pdo->prepare(
"DELETE FROM exam_items
WHERE orden_id = ?
AND exam_tipo_id IN ({$inPlaceholders})
AND estado = 'pendiente'"
);
$stmtDel->execute(array_merge([$ordenId], array_values($eliminar)));
}
$pdo->commit();
jsonOk(['id' => $ordenId, 'codigo' => $codigo], 'Orden guardada correctamente');
} catch (\Throwable $e) {
$pdo->rollBack();
jsonError('Error al guardar la orden: ' . $e->getMessage(), 500);
}
@@ -0,0 +1,121 @@
<?php
/**
* modules/registro_exams/api/save_resultado.php
* POST — agrega o reemplaza resultados de un ítem de examen.
*
* Body JSON:
* {
* "item_id": int, // requerido
* "resultados": [
* {
* "campo_nombre": "Hemoglobina",
* "valor_texto": "14.5",
* "valor_numerico": 14.5, // null si no aplica
* "unidad": "g/dL",
* "referencia_min": 12.0, // null si no aplica
* "referencia_max": 16.0,
* "referencia_texto":"12.0 16.0 g/dL",
* "es_anormal": false
* },
* ...
* ]
* }
*
* Respuesta: { ok: true, inserted: N }
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireExams();
$body = inputJson();
$itemId = isset($body['item_id']) ? (int) $body['item_id'] : 0;
if ($itemId <= 0) {
jsonError('item_id inválido');
}
$resultados = $body['resultados'] ?? [];
if (!is_array($resultados) || count($resultados) === 0) {
jsonError('Debe enviar al menos un resultado');
}
$pdo = db();
// Verificar que el ítem existe
$stmt = $pdo->prepare('SELECT id, orden_id FROM exam_items WHERE id = ?');
$stmt->execute([$itemId]);
$item = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$item) {
jsonError('Ítem no encontrado', 404);
}
try {
$pdo->beginTransaction();
// Borrar resultados anteriores del ítem (reemplazo completo)
$pdo->prepare('DELETE FROM exam_resultados WHERE item_id = ?')->execute([$itemId]);
$ins = $pdo->prepare('
INSERT INTO exam_resultados
(item_id, campo_nombre, valor_texto, valor_numerico,
unidad, referencia_min, referencia_max, referencia_texto,
es_anormal, ingresado_por)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
');
$inserted = 0;
foreach ($resultados as $r) {
$campo = trim($r['campo_nombre'] ?? '');
if ($campo === '') {
continue;
}
$ins->execute([
$itemId,
$campo,
isset($r['valor_texto']) && $r['valor_texto'] !== '' ? $r['valor_texto'] : null,
isset($r['valor_numerico']) && $r['valor_numerico'] !== null ? (float) $r['valor_numerico'] : null,
isset($r['unidad']) && $r['unidad'] !== '' ? $r['unidad'] : null,
isset($r['referencia_min']) && $r['referencia_min'] !== null ? (float) $r['referencia_min'] : null,
isset($r['referencia_max']) && $r['referencia_max'] !== null ? (float) $r['referencia_max'] : null,
isset($r['referencia_texto']) && $r['referencia_texto'] !== '' ? $r['referencia_texto'] : null,
empty($r['es_anormal']) ? 0 : 1,
adminId(),
]);
$inserted++;
}
if ($inserted === 0) {
$pdo->rollBack();
jsonError('No se guardó ningún resultado (verifique los campos)');
}
// Actualizar estado del ítem a resultado_listo
$pdo->prepare(
"UPDATE exam_items SET estado = 'resultado_listo' WHERE id = ?"
)->execute([$itemId]);
// Verificar si todos los ítems de la orden están listos para cerrarla
$stmtCheck = $pdo->prepare(
"SELECT COUNT(*) FROM exam_items
WHERE orden_id = ?
AND estado NOT IN ('resultado_listo','entregado')"
);
$stmtCheck->execute([$item['orden_id']]);
$pendientes = (int) $stmtCheck->fetchColumn();
if ($pendientes === 0) {
$pdo->prepare(
"UPDATE exam_ordenes SET estado = 'completa', updated_at = NOW() WHERE id = ?"
)->execute([$item['orden_id']]);
}
$pdo->commit();
jsonOk(['inserted' => $inserted], 'Resultados guardados');
} catch (\Throwable $e) {
$pdo->rollBack();
jsonError('Error al guardar resultados: ' . $e->getMessage(), 500);
}
+21
View File
@@ -0,0 +1,21 @@
<?php
/**
* modules/registro_exams/module.php
* Descriptor del módulo Registro de Exámenes para el sistema ERP.
*/
return [
'slug' => 'registro_exams',
'name' => 'Registro Exámenes',
'icon' => 'fas fa-vials',
'category' => 'clinico',
'route' => '/erp.php?m=registro_exams&v=lista',
'is_active' => true,
'sort_order' => 60,
'oleada' => 2,
'description' => 'Registro de exámenes de laboratorio en sede. '
. 'Creación de órdenes, toma de muestras e ingreso de resultados.',
'links' => [
['label' => 'Órdenes del día', 'icon' => 'fas fa-list-check', 'url' => '/erp.php?m=registro_exams&v=lista'],
['label' => 'Nueva orden', 'icon' => 'fas fa-plus-circle', 'url' => '/erp.php?m=registro_exams&v=nueva_orden'],
],
];
+325
View File
@@ -0,0 +1,325 @@
<?php
/**
* modules/registro_exams/views/etiqueta.php
* Vista de impresión A6 (148 × 105 mm) para etiqueta de muestra.
* URL: /erp.php?m=registro_exams&v=etiqueta&id=N
*
* - Se abre en nueva pestaña y lanza window.print() automáticamente.
* - Sin sidebar; página mínima optimizada para impresión.
*/
require_once APP_ROOT . '/config/config.php';
$ordenId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($ordenId <= 0) {
http_response_code(400);
die('Orden no especificada.');
}
// Cargar datos de la orden directamente (sin AJAX, para que el HTML sea self-contained)
try {
$pdo = Database::getInstance()->getConnection();
$stmt = $pdo->prepare('
SELECT
o.id, o.codigo, o.prioridad, o.estado,
o.requiere_ayuno, o.horas_ayuno,
o.medico_nombre, o.diagnostico, o.creado_at,
p.nombre_completo, p.numero_documento, p.tipo_documento,
p.fecha_nacimiento, p.eps
FROM exam_ordenes o
JOIN lab_pacientes p ON p.id = o.paciente_id
WHERE o.id = ?
');
$stmt->execute([$ordenId]);
$orden = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$orden) {
http_response_code(404);
die('Orden no encontrada.');
}
// Ítems
$stmt = $pdo->prepare('
SELECT ei.estado, et.nombre AS tipo_nombre, et.codigo AS tipo_codigo,
et.requiere_ayuno, et.horas_ayuno
FROM exam_items ei
JOIN exam_tipos et ON et.id = ei.exam_tipo_id
WHERE ei.orden_id = ?
ORDER BY et.nombre
');
$stmt->execute([$ordenId]);
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Edad
$edad = null;
if ($orden['fecha_nacimiento']) {
$edad = (new DateTime($orden['fecha_nacimiento']))->diff(new DateTime())->y;
}
// Ayuno requerido (por la orden o por algún ítem)
$ayunoReq = (bool) $orden['requiere_ayuno'];
foreach ($items as $it) {
if ($it['requiere_ayuno']) { $ayunoReq = true; break; }
}
$horasValues = array_filter(array_column($items, 'horas_ayuno'));
$horasAyuno = $orden['horas_ayuno'] ?? ($horasValues ? max($horasValues) : 0);
} catch (\Throwable $e) {
http_response_code(500);
die('Error al cargar datos: ' . htmlspecialchars($e->getMessage()));
}
$fecha = $orden['creado_at']
? (new DateTime($orden['creado_at']))->format('d/m/Y H:i')
: date('d/m/Y');
// Generar código de barras como CSS barcode (sin librería externa)
// Usaremos simplemente el código textual + formato visual
$codigoBarras = $orden['codigo'];
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Etiqueta <?= htmlspecialchars($codigoBarras) ?></title>
<style>
/* ── Reset e impresión A6 ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
@page {
size: A6 landscape; /* 148mm × 105mm en horizontal */
margin: 4mm;
}
body {
font-family: Arial, Helvetica, sans-serif;
font-size: 9pt;
color: #000;
background: #fff;
width: 140mm;
}
/* Pantalla previa */
@media screen {
body {
width: 148mm;
min-height: 105mm;
margin: 10mm auto;
border: 1px dashed #ccc;
padding: 4mm;
background: #fff;
}
.no-print { display: block; }
}
@media print {
.no-print { display: none !important; }
}
/* ── Layout etiqueta ── */
.etiqueta {
width: 100%;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1.5pt solid #000;
padding-bottom: 2mm;
margin-bottom: 2mm;
}
.lab-nombre {
font-size: 10pt;
font-weight: bold;
text-transform: uppercase;
}
.codigo {
font-size: 13pt;
font-weight: bold;
letter-spacing: 1px;
text-align: right;
}
.barcode {
font-family: 'Libre Barcode 128', monospace;
font-size: 26pt;
letter-spacing: 0;
line-height: 1;
text-align: right;
}
.paciente-nombre {
font-size: 11pt;
font-weight: bold;
line-height: 1.2;
}
.meta-row {
display: flex;
gap: 6mm;
margin-top: 1mm;
font-size: 8.5pt;
}
.meta-item { white-space: nowrap; }
.meta-label { color: #555; font-size: 7.5pt; display: block; }
.examenes-titulo {
font-weight: bold;
font-size: 8pt;
text-transform: uppercase;
color: #444;
margin-top: 2.5mm;
border-top: .5pt solid #999;
padding-top: 1.5mm;
}
.examenes-lista {
font-size: 8pt;
line-height: 1.5;
}
.badge-prio {
display: inline-block;
font-size: 7pt;
font-weight: bold;
padding: 0.5mm 1.5mm;
border-radius: 2pt;
border: 1pt solid #000;
text-transform: uppercase;
}
.prio-stat { background: #000; color: #fff; }
.prio-urgente { background: #ff0; color: #000; }
.prio-normal { border-color: #aaa; color: #555; }
.aviso-ayuno {
margin-top: 2mm;
font-size: 8pt;
font-weight: bold;
border: 1pt solid #f90;
padding: 1mm 2mm;
border-radius: 2pt;
color: #a60;
}
.footer {
margin-top: 2mm;
font-size: 7pt;
color: #666;
border-top: .5pt solid #ccc;
padding-top: 1mm;
display: flex;
justify-content: space-between;
}
/* ── Botón de impresión (solo pantalla) ── */
.btn-imprimir {
display: block;
margin: 8mm auto;
padding: 6px 20px;
font-size: 12px;
cursor: pointer;
background: #0d6efd;
color: #fff;
border: none;
border-radius: 4px;
}
</style>
<?php
// Intentar cargar Libre Barcode si hay CDN (solo para pantalla; en impresión el texto es suficiente)
?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Libre+Barcode+128+Text&display=swap" rel="stylesheet">
</head>
<body>
<!-- Botón solo en pantalla -->
<div class="no-print" style="text-align:center;margin-bottom:4mm">
<button class="btn-imprimir" onclick="window.print()">🖨 Imprimir etiqueta</button>
<a href="<?= BASE_URL ?>/erp.php?m=registro_exams&v=orden&id=<?= $ordenId ?>"
style="display:inline-block;margin-left:8px;font-size:12px;color:#666">← Volver a orden</a>
</div>
<div class="etiqueta">
<!-- Encabezado: nombre lab + código -->
<div class="header">
<div>
<div class="lab-nombre"><?= defined('APP_NAME') ? htmlspecialchars(APP_NAME) : 'Laboratorio Clínico' ?></div>
<div style="font-size:7.5pt;color:#555">Exámenes de laboratorio en sede</div>
</div>
<div style="text-align:right">
<div class="codigo"><?= htmlspecialchars($codigoBarras) ?></div>
<div class="barcode"><?= htmlspecialchars($codigoBarras) ?></div>
</div>
</div>
<!-- Paciente -->
<div class="paciente-nombre"><?= htmlspecialchars($orden['nombre_completo']) ?></div>
<div class="meta-row">
<div class="meta-item">
<span class="meta-label">Documento</span>
<?= htmlspecialchars($orden['tipo_documento'] . ' ' . $orden['numero_documento']) ?>
</div>
<?php if ($edad !== null): ?>
<div class="meta-item">
<span class="meta-label">Edad</span>
<?= $edad ?> años
</div>
<?php endif; ?>
<?php if ($orden['eps']): ?>
<div class="meta-item">
<span class="meta-label">EPS</span>
<?= htmlspecialchars($orden['eps']) ?>
</div>
<?php endif; ?>
<div class="meta-item" style="margin-left:auto">
<span class="meta-label">Prioridad</span>
<span class="badge-prio prio-<?= $orden['prioridad'] ?>">
<?= strtoupper($orden['prioridad']) ?>
</span>
</div>
</div>
<!-- Exámenes -->
<div class="examenes-titulo">
<i>Exámenes solicitados (<?= count($items) ?>)</i>
</div>
<div class="examenes-lista">
<?php foreach ($items as $i => $item): ?>
<?= ($i + 1) ?>. <?= htmlspecialchars($item['tipo_nombre']) ?>
<span style="color:#888;font-size:7.5pt">(<?= htmlspecialchars($item['tipo_codigo']) ?>)</span><?php
if (!$item['requiere_ayuno']): ?><?php endif; ?>
<?php if ($i < count($items) - 1): ?> ·
<?php endif; ?>
<?php endforeach; ?>
</div>
<!-- Aviso ayuno -->
<?php if ($ayunoReq): ?>
<div class="aviso-ayuno">
⚠ REQUIERE AYUNO<?= $horasAyuno ? " DE {$horasAyuno} HORAS" : '' ?>
</div>
<?php endif; ?>
<!-- Pie: médico + fecha -->
<div class="footer">
<span>Médico: <?= htmlspecialchars($orden['medico_nombre'] ?: '—') ?></span>
<span><?= $fecha ?></span>
</div>
</div><!-- /etiqueta -->
<script>
// Auto-print al abrir
window.addEventListener('load', function () {
// Pequeño delay para cargar la fuente barcode
setTimeout(function () { window.print(); }, 800);
});
</script>
</body>
</html>
+257
View File
@@ -0,0 +1,257 @@
<?php
/**
* modules/registro_exams/views/lista.php
* Vista principal: listado de órdenes del día con filtros y acciones.
* Accesible en /erp.php?m=registro_exams&v=lista
*/
require_once APP_ROOT . '/config/config.php';
Layout::open('Órdenes de Exámenes', 'fas fa-vials');
?>
<div class="container-fluid py-3">
<!-- Encabezado + botón nueva orden -->
<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-vials me-2 text-primary"></i>Órdenes de Exámenes</h4>
<small class="text-muted">Registro y seguimiento de exámenes de laboratorio</small>
</div>
<a href="<?= BASE_URL ?>/erp.php?m=registro_exams&v=nueva_orden" class="btn btn-primary">
<i class="fas fa-plus me-1"></i> Nueva orden
</a>
</div>
<!-- Filtros -->
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<form id="filtroForm" class="row g-2 align-items-end">
<div class="col-sm-3 col-md-2">
<label class="form-label mb-1 small fw-semibold">Fecha</label>
<input type="date" id="filtroFecha" name="fecha" class="form-control form-control-sm"
value="<?= date('Y-m-d') ?>">
</div>
<div class="col-sm-3 col-md-2">
<label class="form-label mb-1 small fw-semibold">Estado</label>
<select id="filtroEstado" name="estado" class="form-select form-select-sm">
<option value="">Todos</option>
<option value="pendiente">Pendiente</option>
<option value="en_proceso">En proceso</option>
<option value="completa">Completa</option>
<option value="entregada">Entregada</option>
<option value="cancelada">Cancelada</option>
</select>
</div>
<div class="col-sm-4 col-md-3">
<label class="form-label mb-1 small fw-semibold">Buscar</label>
<input type="search" id="filtroBusqueda" name="busqueda" class="form-control form-control-sm"
placeholder="Nombre, documento o código…">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-outline-primary">
<i class="fas fa-search me-1"></i>Filtrar
</button>
<button type="button" id="btnHoy" class="btn btn-sm btn-outline-secondary ms-1">
Hoy
</button>
</div>
</form>
</div>
</div>
<!-- KPIs rápidos del día -->
<div class="row g-2 mb-3" id="kpis">
<div class="col-6 col-md-3">
<div class="card border-0 bg-light text-center py-2">
<div class="fs-4 fw-bold text-primary" id="kpiTotal">—</div>
<div class="small text-muted">Total día</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 bg-light text-center py-2">
<div class="fs-4 fw-bold text-warning" id="kpiPendientes">—</div>
<div class="small text-muted">Pendientes</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 bg-light text-center py-2">
<div class="fs-4 fw-bold text-info" id="kpiEnProceso">—</div>
<div class="small text-muted">En proceso</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 bg-light text-center py-2">
<div class="fs-4 fw-bold text-success" id="kpiCompletas">—</div>
<div class="small text-muted">Completas</div>
</div>
</div>
</div>
<!-- Tabla de órdenes -->
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0" id="tablaOrdenes">
<thead class="table-light">
<tr>
<th style="width:110px">Código</th>
<th>Paciente</th>
<th style="width:80px" class="text-center">Edad</th>
<th style="width:90px">Prioridad</th>
<th style="width:110px">Estado</th>
<th style="width:90px" class="text-center">Ítems</th>
<th style="width:80px" class="text-center">Muestras</th>
<th style="width:110px">Hora</th>
<th style="width:90px" class="text-end">Acciones</th>
</tr>
</thead>
<tbody id="tbodyOrdenes">
<tr><td colspan="9" class="text-center text-muted py-4">
<i class="fas fa-spinner fa-spin me-2"></i>Cargando…
</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center py-2" id="paginacion">
<span class="small text-muted" id="paginaInfo"></span>
<div id="paginaBtns" class="btn-group btn-group-sm"></div>
</div>
</div>
</div>
<script>
(function () {
const BASE = '<?= BASE_URL ?>';
const API = BASE + '/modules/registro_exams/api/get_ordenes.php';
let currentPage = 1;
// ── KPI contadores del día (sin filtro estado) ──
async function cargarKpis(fecha) {
try {
const r = await fetch(`${API}?fecha=${fecha}&limit=200`);
const j = await r.json();
if (!j.ok) return;
const rows = j.data;
document.getElementById('kpiTotal').textContent = j.total;
document.getElementById('kpiPendientes').textContent = rows.filter(x => x.estado === 'pendiente').length;
document.getElementById('kpiEnProceso').textContent = rows.filter(x => x.estado === 'en_proceso').length;
document.getElementById('kpiCompletas').textContent = rows.filter(x => x.estado === 'completa').length;
} catch {}
}
// ── Construir fila de tabla ──
function buildRow(o) {
const hora = o.creado_at ? o.creado_at.substr(11, 5) : '—';
const edad = o.edad !== null ? o.edad + ' a.' : '—';
const badgePrio = {
stat: '<span class="badge bg-danger">STAT</span>',
urgente: '<span class="badge bg-warning text-dark">Urgente</span>',
normal: '<span class="badge bg-secondary">Normal</span>',
}[o.prioridad] || '';
const badgeEst = {
pendiente: '<span class="badge bg-light text-dark border">Pendiente</span>',
en_proceso: '<span class="badge bg-primary">En proceso</span>',
completa: '<span class="badge bg-success">Completa</span>',
entregada: '<span class="badge bg-info text-dark">Entregada</span>',
cancelada: '<span class="badge bg-dark">Cancelada</span>',
}[o.estado] || o.estado;
const itemsInfo = `${o.items_listos}/${o.total_items}`;
const muestInfo = o.muestras_tomadas > 0
? `<span class="text-success fw-semibold">${o.muestras_tomadas}</span>`
: `<span class="text-muted">0</span>`;
return `<tr>
<td><a href="${BASE}/erp.php?m=registro_exams&v=orden&id=${o.id}" class="fw-semibold text-decoration-none">${escHtml(o.codigo)}</a></td>
<td>
<div class="fw-semibold lh-sm">${escHtml(o.nombre_completo)}</div>
<div class="small text-muted">${escHtml(o.tipo_documento)} ${escHtml(o.numero_documento)}</div>
</td>
<td class="text-center">${edad}</td>
<td>${badgePrio}</td>
<td>${badgeEst}</td>
<td class="text-center">${itemsInfo}</td>
<td class="text-center">${muestInfo}</td>
<td>${hora}</td>
<td class="text-end">
<a href="${BASE}/erp.php?m=registro_exams&v=orden&id=${o.id}"
class="btn btn-sm btn-outline-primary" title="Ver orden">
<i class="fas fa-eye"></i>
</a>
<a href="${BASE}/erp.php?m=registro_exams&v=etiqueta&id=${o.id}"
target="_blank" class="btn btn-sm btn-outline-secondary ms-1" title="Etiqueta">
<i class="fas fa-print"></i>
</a>
</td>
</tr>`;
}
function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── Cargar tabla ──
async function cargar(page = 1) {
currentPage = page;
const fecha = document.getElementById('filtroFecha').value;
const estado = document.getElementById('filtroEstado').value;
const busqueda = document.getElementById('filtroBusqueda').value;
const url = `${API}?fecha=${encodeURIComponent(fecha)}&estado=${encodeURIComponent(estado)}&busqueda=${encodeURIComponent(busqueda)}&page=${page}&limit=25`;
document.getElementById('tbodyOrdenes').innerHTML =
'<tr><td colspan="9" class="text-center text-muted py-4"><i class="fas fa-spinner fa-spin me-2"></i>Cargando…</td></tr>';
try {
const r = await fetch(url);
const j = await r.json();
if (!j.ok) throw new Error(j.error);
const tbody = document.getElementById('tbodyOrdenes');
if (!j.data.length) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-4"><i class="fas fa-inbox me-2"></i>Sin órdenes para los filtros seleccionados.</td></tr>';
} else {
tbody.innerHTML = j.data.map(buildRow).join('');
}
// Paginación
document.getElementById('paginaInfo').textContent =
`${j.total} orden(es) — Página ${j.page} de ${j.pages}`;
const btns = document.getElementById('paginaBtns');
btns.innerHTML = '';
for (let p = 1; p <= j.pages; p++) {
const btn = document.createElement('button');
btn.className = 'btn btn-outline-secondary' + (p === j.page ? ' active' : '');
btn.textContent = p;
btn.addEventListener('click', () => cargar(p));
btns.appendChild(btn);
}
// KPIs (solo recargar cuando cambia fecha)
cargarKpis(fecha);
} catch (e) {
document.getElementById('tbodyOrdenes').innerHTML =
`<tr><td colspan="9" class="text-center text-danger py-4"><i class="fas fa-exclamation-circle me-2"></i>${e.message}</td></tr>`;
}
}
// ── Eventos ──
document.getElementById('filtroForm').addEventListener('submit', e => { e.preventDefault(); cargar(1); });
document.getElementById('btnHoy').addEventListener('click', () => {
document.getElementById('filtroFecha').value = new Date().toISOString().slice(0, 10);
cargar(1);
});
// Autorefresh cada 45 s
setInterval(() => cargar(currentPage), 45000);
// Carga inicial
cargar(1);
})();
</script>
<?php Layout::close(); ?>
@@ -0,0 +1,419 @@
<?php
/**
* modules/registro_exams/views/nueva_orden.php
* Formulario para crear una nueva orden de exámenes.
* URL: /erp.php?m=registro_exams&v=nueva_orden[&solicitud_id=N]
*/
require_once APP_ROOT . '/config/config.php';
// Pre-carga de solicitud del turnero (opcional)
$solicitudId = isset($_GET['solicitud_id']) ? (int) $_GET['solicitud_id'] : null;
$pacientePreId = null;
$pacientePreNom = '';
$pacientePreDoc = '';
if ($solicitudId) {
try {
$pdo = Database::getInstance()->getConnection();
$stmt = $pdo->prepare('
SELECT s.id AS sol_id,
p.id AS pac_id, p.nombre_completo, p.numero_documento
FROM turnero_solicitudes s
JOIN lab_pacientes p ON p.id = s.paciente_id
WHERE s.id = ?
');
$stmt->execute([$solicitudId]);
$pre = $stmt->fetch(PDO::FETCH_ASSOC);
if ($pre) {
$pacientePreId = (int) $pre['pac_id'];
$pacientePreNom = $pre['nombre_completo'];
$pacientePreDoc = $pre['numero_documento'];
}
} catch (\Throwable $e) {
// continuar sin pre-cargar
}
}
// Cargar tipos de exámenes activos agrupados por categoría
$examTipos = [];
try {
$pdo = Database::getInstance()->getConnection();
$stmt = $pdo->query('
SELECT id, codigo, nombre, categoria, requiere_ayuno, horas_ayuno,
precio_base, instrucciones
FROM exam_tipos
WHERE activo = 1
ORDER BY categoria, nombre
');
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$cat = $row['categoria'] ?: 'General';
$examTipos[$cat][] = $row;
}
} catch (\Throwable $e) {}
Layout::open('Nueva Orden de Exámenes', 'fas fa-plus-circle');
?>
<div class="container-fluid py-3" style="max-width: 960px;">
<div class="d-flex align-items-center gap-2 mb-3">
<a href="<?= BASE_URL ?>/erp.php?m=registro_exams&v=lista" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Volver
</a>
<h4 class="mb-0"><i class="fas fa-plus-circle me-2 text-primary"></i>Nueva Orden de Exámenes</h4>
</div>
<form id="formOrden" novalidate>
<input type="hidden" id="ordenId" value="">
<input type="hidden" id="solicitudIdHidden" value="<?= (int) $solicitudId ?>">
<!-- ── SECCIÓN 1: Paciente ─────────────────────────── -->
<div class="card shadow-sm mb-3">
<div class="card-header bg-primary text-white py-2">
<i class="fas fa-user me-2"></i><strong>1. Datos del paciente</strong>
</div>
<div class="card-body">
<input type="hidden" id="pacienteId" value="<?= (int) $pacientePreId ?>">
<?php if ($pacientePreId): ?>
<!-- Paciente pre-cargado desde turnero -->
<div id="pacienteSeleccionado" class="alert alert-success d-flex align-items-center gap-3 mb-2">
<i class="fas fa-user-check fs-4"></i>
<div>
<div class="fw-bold" id="pacNombre"><?= htmlspecialchars($pacientePreNom) ?></div>
<div class="small" id="pacDoc"><?= htmlspecialchars($pacientePreDoc) ?></div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger ms-auto" id="btnCambiarPaciente">
<i class="fas fa-times me-1"></i>Cambiar
</button>
</div>
<div id="buscadorPaciente" class="d-none">
<?php else: ?>
<div id="pacienteSeleccionado" class="alert alert-light border-dashed d-none">
<div class="fw-bold" id="pacNombre"></div>
<div class="small text-muted" id="pacDoc"></div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" id="btnCambiarPaciente">Cambiar</button>
</div>
<div id="buscadorPaciente">
<?php endif; ?>
<label class="form-label fw-semibold">Buscar paciente</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="search" id="inputBuscarPac" class="form-control"
placeholder="Nombre o número de documento…" autocomplete="off">
</div>
<div id="resultadosPac" class="list-group mt-1 shadow-sm"></div>
</div><!-- /buscadorPaciente -->
</div><!-- /card-body -->
</div>
<!-- ── SECCIÓN 2: Datos médicos ───────────────────── -->
<div class="card shadow-sm mb-3">
<div class="card-header py-2">
<i class="fas fa-stethoscope me-2 text-secondary"></i><strong>2. Datos médicos</strong>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">Médico solicitante</label>
<input type="text" id="medicoNombre" class="form-control form-control-sm"
placeholder="Nombre del médico">
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold">Registro médico</label>
<input type="text" id="medicoRegistro" class="form-control form-control-sm"
placeholder="No. registro">
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Prioridad</label>
<select id="prioridad" class="form-select form-select-sm">
<option value="normal" selected>Normal</option>
<option value="urgente">Urgente</option>
<option value="stat">STAT (inmediato)</option>
</select>
</div>
<div class="col-12">
<label class="form-label small fw-semibold">Diagnóstico / indicación</label>
<textarea id="diagnostico" class="form-control form-control-sm" rows="2"
placeholder="CIE-10 o descripción clínica"></textarea>
</div>
<div class="col-12">
<label class="form-label small fw-semibold">Notas internas</label>
<textarea id="notas" class="form-control form-control-sm" rows="2"
placeholder="Observaciones para el bacteriólogo…"></textarea>
</div>
</div>
</div>
</div>
<!-- ── SECCIÓN 3: Exámenes solicitados ───────────── -->
<div class="card shadow-sm mb-3">
<div class="card-header py-2">
<i class="fas fa-vials me-2 text-secondary"></i>
<strong>3. Exámenes solicitados</strong>
</div>
<div class="card-body">
<!-- Buscador de exámenes -->
<div class="input-group mb-3">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="search" id="inputBuscarExam" class="form-control"
placeholder="Filtrar exámenes por nombre o código…">
</div>
<?php if (empty($examTipos)): ?>
<div class="alert alert-warning">
No hay tipos de examen configurados. Configure los exámenes en
<a href="<?= BASE_URL ?>/erp.php?m=turnero&v=configuracion">Configuración → Exámenes</a>.
</div>
<?php else: ?>
<div id="catalogoExams">
<?php foreach ($examTipos as $categoria => $tipos): ?>
<div class="exam-categoria mb-2">
<div class="fw-semibold text-uppercase text-muted small border-bottom pb-1 mb-2 categoria-header">
<?= htmlspecialchars($categoria) ?>
</div>
<div class="row g-2">
<?php foreach ($tipos as $t): ?>
<div class="col-sm-6 col-md-4 exam-item"
data-nombre="<?= htmlspecialchars(strtolower($t['nombre'])) ?>"
data-codigo="<?= htmlspecialchars(strtolower($t['codigo'])) ?>">
<label class="card h-100 border-0 bg-light p-2 cursor-pointer exam-card"
for="et<?= $t['id'] ?>">
<div class="d-flex align-items-start gap-2">
<input class="form-check-input mt-1 exam-checkbox flex-shrink-0"
type="checkbox"
id="et<?= $t['id'] ?>"
value="<?= $t['id'] ?>"
data-nombre="<?= htmlspecialchars($t['nombre']) ?>"
data-ayuno="<?= $t['requiere_ayuno'] ? '1' : '0' ?>"
data-horas-ayuno="<?= (int) $t['horas_ayuno'] ?>"
data-precio="<?= $t['precio_base'] !== null ? number_format((float)$t['precio_base'], 0, ',', '.') : '' ?>">
<div class="lh-sm">
<div class="fw-semibold small"><?= htmlspecialchars($t['nombre']) ?></div>
<div class="text-muted" style="font-size:.75rem"><?= htmlspecialchars($t['codigo']) ?></div>
<?php if ($t['requiere_ayuno']): ?>
<span class="badge bg-warning text-dark" style="font-size:.65rem">
<i class="fas fa-clock me-1"></i>Ayuno <?= $t['horas_ayuno'] ? $t['horas_ayuno'].'h' : '' ?>
</span>
<?php endif; ?>
<?php if ($t['precio_base']): ?>
<span class="badge bg-light text-secondary border" style="font-size:.65rem">
$<?= number_format((float)$t['precio_base'], 0, ',', '.') ?>
</span>
<?php endif; ?>
</div>
</div>
</label>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Resumen seleccionados -->
<div id="resumenExams" class="mt-3 d-none">
<div class="alert alert-primary py-2 mb-0">
<strong><i class="fas fa-check-circle me-1"></i>Seleccionados:</strong>
<span id="resumenLista"></span>
<span id="ayunoAviso" class="ms-2 badge bg-warning text-dark d-none">
<i class="fas fa-clock me-1"></i> Ayuno requerido
</span>
</div>
</div>
</div>
</div>
<!-- ── Botones ───────────────────────────────────── -->
<div class="d-flex gap-2 justify-content-end mb-4">
<a href="<?= BASE_URL ?>/erp.php?m=registro_exams&v=lista"
class="btn btn-outline-secondary">
Cancelar
</a>
<button type="submit" class="btn btn-primary px-4" id="btnGuardar">
<i class="fas fa-save me-1"></i> Crear orden
</button>
</div>
</form>
</div>
<style>
.exam-card { cursor: pointer; transition: border-color .15s; }
.exam-card:hover { border-color: #0d6efd !important; background: #e9f0ff !important; }
.exam-card:has(.exam-checkbox:checked) { background: #dbeafe !important; border: 1px solid #0d6efd !important; }
.cursor-pointer { cursor: pointer; }
</style>
<script>
(function () {
const BASE = '<?= BASE_URL ?>';
const API_PAC = BASE + '/api/lab/get_pacientes.php';
const API_SV = BASE + '/modules/registro_exams/api/save_orden.php';
let debounceTimer = null;
let examDebounce = null;
// ── Buscador de pacientes ──────────────────────────────────
const inputBuscarPac = document.getElementById('inputBuscarPac');
const resultadosPac = document.getElementById('resultadosPac');
const buscadorDiv = document.getElementById('buscadorPaciente');
const seleccionadoDiv = document.getElementById('pacienteSeleccionado');
const pacNombreEl = document.getElementById('pacNombre');
const pacDocEl = document.getElementById('pacDoc');
const pacienteIdEl = document.getElementById('pacienteId');
if (inputBuscarPac) {
inputBuscarPac.addEventListener('input', () => {
clearTimeout(debounceTimer);
const q = inputBuscarPac.value.trim();
if (q.length < 2) { resultadosPac.innerHTML = ''; return; }
debounceTimer = setTimeout(() => buscarPaciente(q), 300);
});
}
async function buscarPaciente(q) {
try {
const r = await fetch(`${API_PAC}?busqueda=${encodeURIComponent(q)}&limit=8`);
const j = await r.json();
if (!j.ok || !j.data?.length) {
resultadosPac.innerHTML = '<div class="list-group-item text-muted small">Sin resultados</div>';
return;
}
resultadosPac.innerHTML = j.data.map(p =>
`<button type="button" class="list-group-item list-group-item-action py-2"
data-id="${p.id}" data-nombre="${escAtt(p.nombre_completo)}" data-doc="${escAtt(p.numero_documento)} ${escAtt(p.tipo_documento||'')}">
<div class="fw-semibold lh-sm">${escHtml(p.nombre_completo)}</div>
<div class="small text-muted">${escHtml(p.tipo_documento||'')} ${escHtml(p.numero_documento)}</div>
</button>`
).join('');
resultadosPac.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => seleccionarPaciente(
parseInt(btn.dataset.id), btn.dataset.nombre, btn.dataset.doc
));
});
} catch { resultadosPac.innerHTML = ''; }
}
function seleccionarPaciente(id, nombre, doc) {
pacienteIdEl.value = id;
pacNombreEl.textContent = nombre;
pacDocEl.textContent = doc;
resultadosPac.innerHTML = '';
buscadorDiv.classList.add('d-none');
seleccionadoDiv.classList.remove('d-none');
seleccionadoDiv.classList.remove('alert-light');
seleccionadoDiv.classList.add('alert-success');
}
const btnCambiar = document.getElementById('btnCambiarPaciente');
if (btnCambiar) {
btnCambiar.addEventListener('click', () => {
pacienteIdEl.value = '';
buscadorDiv.classList.remove('d-none');
seleccionadoDiv.classList.add('d-none');
if (inputBuscarPac) { inputBuscarPac.value = ''; inputBuscarPac.focus(); }
});
}
// ── Buscador de exámenes ───────────────────────────────────
const inputBuscarExam = document.getElementById('inputBuscarExam');
if (inputBuscarExam) {
inputBuscarExam.addEventListener('input', () => {
clearTimeout(examDebounce);
examDebounce = setTimeout(() => filtrarExams(inputBuscarExam.value.trim().toLowerCase()), 200);
});
}
function filtrarExams(q) {
const items = document.querySelectorAll('.exam-item');
const cats = document.querySelectorAll('.exam-categoria');
items.forEach(el => {
const visible = !q || el.dataset.nombre.includes(q) || el.dataset.codigo.includes(q);
el.style.display = visible ? '' : 'none';
});
// Ocultar categorías vacías
cats.forEach(cat => {
const visibles = [...cat.querySelectorAll('.exam-item')].some(i => i.style.display !== 'none');
cat.style.display = visibles ? '' : 'none';
});
}
// ── Resumen de exámenes seleccionados ──────────────────────
document.querySelectorAll('.exam-checkbox').forEach(cb => {
cb.addEventListener('change', actualizarResumen);
});
function actualizarResumen() {
const sel = [...document.querySelectorAll('.exam-checkbox:checked')];
const resumenDiv = document.getElementById('resumenExams');
const resumenLista = document.getElementById('resumenLista');
const ayunoAviso = document.getElementById('ayunoAviso');
if (!sel.length) {
resumenDiv.classList.add('d-none');
return;
}
resumenDiv.classList.remove('d-none');
resumenLista.textContent = sel.map(c => c.dataset.nombre).join(' · ');
const ayuno = sel.some(c => c.dataset.ayuno === '1');
ayunoAviso.classList.toggle('d-none', !ayuno);
}
// ── Enviar formulario ──────────────────────────────────────
document.getElementById('formOrden').addEventListener('submit', async (e) => {
e.preventDefault();
const pacId = parseInt(document.getElementById('pacienteId').value) || 0;
if (!pacId) {
alert('Seleccione un paciente antes de continuar.');
return;
}
const items = [...document.querySelectorAll('.exam-checkbox:checked')]
.map(c => parseInt(c.value));
if (!items.length) {
alert('Seleccione al menos un examen.');
return;
}
const btn = document.getElementById('btnGuardar');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando…';
const body = {
id: null,
paciente_id: pacId,
solicitud_id: parseInt(document.getElementById('solicitudIdHidden').value) || null,
medico_nombre: document.getElementById('medicoNombre').value.trim(),
medico_registro: document.getElementById('medicoRegistro').value.trim(),
diagnostico: document.getElementById('diagnostico').value.trim(),
prioridad: document.getElementById('prioridad').value,
notas: document.getElementById('notas').value.trim(),
items: items,
};
try {
const r = await fetch(API_SV, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
// Redirigir a la orden creada
window.location.href = `${BASE}/erp.php?m=registro_exams&v=orden&id=${j.id}`;
} catch (err) {
alert('Error al guardar: ' + err.message);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save me-1"></i> Crear orden';
}
});
function escHtml(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function escAtt(s) { return String(s||'').replace(/"/g,'&quot;'); }
})();
</script>
<?php Layout::close(); ?>
+536
View File
@@ -0,0 +1,536 @@
<?php
/**
* modules/registro_exams/views/orden.php
* Detalle de una orden: datos paciente, ítems, muestras y resultados.
* URL: /erp.php?m=registro_exams&v=orden&id=N
*/
require_once APP_ROOT . '/config/config.php';
$ordenId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($ordenId <= 0) {
header('Location: ' . BASE_URL . '/erp.php?m=registro_exams&v=lista');
exit;
}
Layout::open('Orden de Exámenes', 'fas fa-file-medical');
?>
<div class="container-fluid py-3" style="max-width: 1100px;">
<!-- Encabezado -->
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
<a href="<?= BASE_URL ?>/erp.php?m=registro_exams&v=lista" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Volver
</a>
<h4 class="mb-0" id="tituloOrden">
<i class="fas fa-file-medical me-2 text-primary"></i>
<span id="codigoOrden">Cargando…</span>
</h4>
<div class="ms-auto d-flex gap-2 flex-wrap">
<a href="<?= BASE_URL ?>/erp.php?m=registro_exams&v=etiqueta&id=<?= $ordenId ?>"
target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-print me-1"></i>Etiqueta
</a>
<button class="btn btn-sm btn-outline-success" id="btnMarcarEntregada">
<i class="fas fa-hand-holding me-1"></i>Marcar entregada
</button>
</div>
</div>
<!-- Spinner de carga -->
<div id="spinnerOrden" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<div class="mt-2 text-muted">Cargando orden…</div>
</div>
<!-- Contenido principal (oculto hasta carga) -->
<div id="contenidoOrden" class="d-none">
<!-- Fila 1: Paciente + Datos médicos -->
<div class="row g-3 mb-3">
<!-- Card Paciente -->
<div class="col-md-5">
<div class="card shadow-sm h-100">
<div class="card-header bg-primary text-white py-2">
<i class="fas fa-user me-2"></i><strong>Paciente</strong>
</div>
<div class="card-body py-2">
<div class="fs-5 fw-bold" id="detPacNombre"></div>
<div class="text-muted small mb-1" id="detPacDoc"></div>
<div class="row g-1 small">
<div class="col-6"><span class="text-muted">Edad:</span> <span id="detPacEdad"></span></div>
<div class="col-6"><span class="text-muted">Género:</span> <span id="detPacGenero"></span></div>
<div class="col-12"><span class="text-muted">EPS:</span> <span id="detPacEps"></span></div>
<div class="col-12"><span class="text-muted">Tel:</span> <span id="detPacTel"></span></div>
</div>
</div>
</div>
</div>
<!-- Card Orden -->
<div class="col-md-7">
<div class="card shadow-sm h-100">
<div class="card-header py-2">
<i class="fas fa-file-medical-alt me-2 text-secondary"></i>
<strong>Datos de la orden</strong>
<span class="ms-2" id="detBadgePrio"></span>
<span class="ms-1" id="detBadgeEstado"></span>
</div>
<div class="card-body py-2">
<div class="row g-1 small">
<div class="col-sm-6"><span class="text-muted">Médico:</span> <span id="detMedico"></span></div>
<div class="col-sm-6"><span class="text-muted">Registro:</span> <span id="detMedicoReg"></span></div>
<div class="col-12"><span class="text-muted">Diagnóstico:</span> <span id="detDiagnostico"></span></div>
<div class="col-12"><span class="text-muted">Notas:</span> <span id="detNotas"></span></div>
<div class="col-12"><span class="text-muted">Creado:</span> <span id="detFecha"></span></div>
</div>
</div>
</div>
</div>
</div>
<!-- Fila 2: Muestras -->
<div class="card shadow-sm mb-3">
<div class="card-header d-flex align-items-center justify-content-between py-2">
<span><i class="fas fa-tint me-2 text-danger"></i><strong>Muestras</strong></span>
<button class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#modalMuestra">
<i class="fas fa-plus me-1"></i>Registrar muestra
</button>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0" id="tablaMuestras">
<thead class="table-light">
<tr>
<th>Tipo</th>
<th>Código barras</th>
<th>Estado</th>
<th>Tomada por</th>
<th>Hora</th>
</tr>
</thead>
<tbody id="tbodyMuestras">
<tr><td colspan="5" class="text-muted text-center py-3">Sin muestras registradas</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Fila 3: Ítems de examen con sección de resultados expandible -->
<div class="card shadow-sm mb-3">
<div class="card-header py-2">
<i class="fas fa-vials me-2 text-secondary"></i>
<strong>Exámenes solicitados</strong>
<span class="badge bg-secondary ms-2" id="contadorItems"></span>
</div>
<div class="card-body p-0" id="listaItems">
<div class="text-center text-muted py-3">Cargando ítems…</div>
</div>
</div>
</div><!-- /contenidoOrden -->
</div>
<!-- ── Modal: Registrar muestra ──────────────────────────── -->
<div class="modal fade" id="modalMuestra" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white py-2">
<h6 class="modal-title"><i class="fas fa-tint me-2"></i>Registrar muestra</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small fw-semibold">Tipo de muestra</label>
<select id="mTipoMuestra" class="form-select form-select-sm">
<option value="sangre_venosa">Sangre venosa</option>
<option value="sangre_capilar">Sangre capilar</option>
<option value="orina_24h">Orina 24 h</option>
<option value="orina_espontanea">Orina espontánea</option>
<option value="heces">Heces</option>
<option value="esputo">Esputo</option>
<option value="hisopado">Hisopado</option>
<option value="otro">Otro</option>
</select>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Código de barras (opcional)</label>
<input type="text" id="mCodigoBarras" class="form-control form-control-sm"
placeholder="Escanear o ingresar código">
</div>
<div class="mb-0">
<label class="form-label small fw-semibold">Estado</label>
<select id="mEstadoMuestra" class="form-select form-select-sm">
<option value="tomada" selected>Tomada</option>
<option value="procesando">Procesando</option>
<option value="procesada">Procesada</option>
<option value="rechazada">Rechazada</option>
</select>
</div>
</div>
<div class="modal-footer py-2">
<button class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-sm btn-danger" id="btnGuardarMuestra">
<i class="fas fa-save me-1"></i>Guardar muestra
</button>
</div>
</div>
</div>
</div>
<!-- ── Modal: Ingresar resultados ────────────────────────── -->
<div class="modal fade modal-xl" id="modalResultados" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header bg-primary text-white py-2">
<h6 class="modal-title" id="modalResTitle"><i class="fas fa-flask me-2"></i>Ingresar resultados</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="resItemId">
<div id="tablaResultados">
<!-- Filas generadas dinámicamente -->
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" id="btnAgregarFila">
<i class="fas fa-plus me-1"></i>Agregar campo
</button>
</div>
<div class="modal-footer py-2">
<button class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-sm btn-primary" id="btnGuardarResultados">
<i class="fas fa-save me-1"></i>Guardar resultados
</button>
</div>
</div>
</div>
</div>
<script>
(function () {
const ORDEN_ID = <?= $ordenId ?>;
const BASE = '<?= BASE_URL ?>';
const API_GET = BASE + '/modules/registro_exams/api/get_orden.php';
const API_CAM = BASE + '/modules/registro_exams/api/cambiar_estado_item.php';
const API_RES = BASE + '/modules/registro_exams/api/save_resultado.php';
let ordenData = null;
// ── Carga inicial ─────────────────────────────────────────
async function cargarOrden() {
try {
const r = await fetch(`${API_GET}?id=${ORDEN_ID}`);
const j = await r.json();
if (!j.ok) throw new Error(j.error);
ordenData = j.data;
renderOrden(j.data);
} catch (e) {
document.getElementById('spinnerOrden').innerHTML =
`<div class="alert alert-danger m-4">Error al cargar: ${escHtml(e.message)}</div>`;
}
}
function renderOrden(o) {
document.getElementById('spinnerOrden').classList.add('d-none');
document.getElementById('contenidoOrden').classList.remove('d-none');
// Encabezado
document.getElementById('codigoOrden').textContent = o.codigo;
// Paciente
document.getElementById('detPacNombre').textContent = o.nombre_completo;
document.getElementById('detPacDoc').textContent = `${o.tipo_documento} ${o.numero_documento}`;
document.getElementById('detPacEdad').textContent = o.edad !== null ? `${o.edad} años` : '—';
document.getElementById('detPacGenero').textContent = o.genero || '—';
document.getElementById('detPacEps').textContent = o.eps || '—';
document.getElementById('detPacTel').textContent = o.telefono || '—';
// Orden
document.getElementById('detBadgePrio').innerHTML = badgePrio(o.prioridad);
document.getElementById('detBadgeEstado').innerHTML = badgeEstado(o.estado);
document.getElementById('detMedico').textContent = o.medico_nombre || '—';
document.getElementById('detMedicoReg').textContent = o.medico_registro || '—';
document.getElementById('detDiagnostico').textContent = o.diagnostico || '—';
document.getElementById('detNotas').textContent = o.notas || '—';
document.getElementById('detFecha').textContent = o.creado_at ? o.creado_at.substr(0, 16) : '—';
// Muestras
renderMuestras(o.muestras);
// Ítems
renderItems(o.items);
// Contador
document.getElementById('contadorItems').textContent = o.items.length;
// Botón marcar entregada (solo si está completa)
const btnEntregar = document.getElementById('btnMarcarEntregada');
btnEntregar.disabled = !['completa'].includes(o.estado);
}
// ── Muestras ──────────────────────────────────────────────
function renderMuestras(muestras) {
const tbody = document.getElementById('tbodyMuestras');
if (!muestras.length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-muted text-center py-3">Sin muestras registradas</td></tr>';
return;
}
const labelTipo = {
sangre_venosa: 'Sangre venosa', sangre_capilar: 'Sangre capilar',
orina_24h: 'Orina 24h', orina_espontanea: 'Orina espontánea',
heces: 'Heces', esputo: 'Esputo', hisopado: 'Hisopado', otro: 'Otro'
};
const badgeEst = {
pendiente:'<span class="badge bg-light text-dark border">Pendiente</span>',
tomada: '<span class="badge bg-success">Tomada</span>',
procesando:'<span class="badge bg-info text-dark">Procesando</span>',
procesada: '<span class="badge bg-primary">Procesada</span>',
rechazada: '<span class="badge bg-danger">Rechazada</span>',
};
tbody.innerHTML = muestras.map(m =>
`<tr>
<td>${labelTipo[m.tipo_muestra] || m.tipo_muestra}</td>
<td>${escHtml(m.codigo_barras || '—')}</td>
<td>${badgeEst[m.estado] || m.estado}</td>
<td>${escHtml(m.tomada_nombre || '—')}</td>
<td>${m.tomada_at ? m.tomada_at.substr(11,5) : '—'}</td>
</tr>`
).join('');
}
// ── Ítems ─────────────────────────────────────────────────
function renderItems(items) {
const cont = document.getElementById('listaItems');
if (!items.length) {
cont.innerHTML = '<div class="text-muted text-center py-3">Sin ítems</div>';
return;
}
const estadoColor = {
pendiente: 'bg-light text-dark border',
muestra_tomada: 'bg-warning text-dark',
en_proceso: 'bg-info text-dark',
resultado_listo: 'bg-success',
entregado: 'bg-secondary',
};
cont.innerHTML = items.map(item => {
const badge = `<span class="badge ${estadoColor[item.estado] || 'bg-light'}">${labelEstItem(item.estado)}</span>`;
const nResultados = item.resultados?.length ?? 0;
const resHTML = nResultados > 0 ? renderTablaResultados(item.resultados) : '';
return `
<div class="border-bottom p-3" id="item-${item.id}">
<div class="d-flex flex-wrap align-items-start gap-2">
<div class="flex-grow-1">
<div class="fw-semibold">${escHtml(item.tipo_nombre)}
<small class="text-muted ms-1">${escHtml(item.tipo_codigo)}</small>
</div>
<div class="small text-muted">${escHtml(item.categoria || '')}</div>
${item.tipo_instrucciones
? `<div class="small text-info mt-1"><i class="fas fa-info-circle me-1"></i>${escHtml(item.tipo_instrucciones)}</div>`
: ''}
</div>
<div class="d-flex align-items-center gap-1 flex-wrap">
${badge}
<button class="btn btn-sm btn-outline-primary"
onclick="abrirResultados(${item.id}, '${escAtt(item.tipo_nombre)}', ${JSON.stringify(item.resultados || []).replace(/"/g,'&quot;')})">
<i class="fas fa-flask me-1"></i>${nResultados > 0 ? 'Editar resultados' : 'Ingresar resultados'}
</button>
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown">
Estado
</button>
<ul class="dropdown-menu dropdown-menu-end">
${['pendiente','muestra_tomada','en_proceso','resultado_listo','entregado'].map(est =>
`<li><a class="dropdown-item small" href="#"
onclick="cambiarEstado('item',${item.id},'${est}');return false;">
${labelEstItem(est)}</a></li>`
).join('')}
</ul>
</div>
</div>
</div>
${resHTML ? `<div class="mt-2">${resHTML}</div>` : ''}
</div>`;
}).join('');
}
function renderTablaResultados(resultados) {
if (!resultados.length) return '';
const rows = resultados.map(r =>
`<tr class="${r.es_anormal ? 'table-danger' : ''}">
<td>${escHtml(r.campo_nombre)}</td>
<td class="fw-semibold">${escHtml(r.valor_texto || (r.valor_numerico !== null ? r.valor_numerico : '—'))}</td>
<td>${escHtml(r.unidad || '—')}</td>
<td>${escHtml(r.referencia_texto || (r.referencia_min !== null ? `${r.referencia_min} ${r.referencia_max}` : '—'))}</td>
<td>${r.es_anormal ? '<span class="badge bg-danger">ANORMAL</span>' : ''}</td>
</tr>`
).join('');
return `<table class="table table-sm table-bordered mb-0" style="font-size:.85rem">
<thead class="table-light">
<tr><th>Campo</th><th>Valor</th><th>Unidad</th><th>Referencia</th><th>Obs.</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
}
// ── Cambiar estado ────────────────────────────────────────
window.cambiarEstado = async function(tipo, id, estado) {
try {
const r = await fetch(API_CAM, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tipo, id, estado }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
await cargarOrden();
} catch (e) {
alert('Error: ' + e.message);
}
};
document.getElementById('btnMarcarEntregada').addEventListener('click', async () => {
if (!confirm('¿Marcar esta orden como entregada?')) return;
await cambiarEstado('orden', ORDEN_ID, 'entregada');
});
// ── Registrar muestra ─────────────────────────────────────
document.getElementById('btnGuardarMuestra').addEventListener('click', async () => {
const btn = document.getElementById('btnGuardarMuestra');
btn.disabled = true;
try {
const r = await fetch(API_CAM, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tipo: 'muestra',
id: null,
orden_id: ORDEN_ID,
tipo_muestra: document.getElementById('mTipoMuestra').value,
codigo_barras: document.getElementById('mCodigoBarras').value.trim(),
estado: document.getElementById('mEstadoMuestra').value,
}),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
bootstrap.Modal.getInstance(document.getElementById('modalMuestra')).hide();
document.getElementById('mCodigoBarras').value = '';
await cargarOrden();
} catch (e) {
alert('Error: ' + e.message);
} finally {
btn.disabled = false;
}
});
// ── Modal Resultados ──────────────────────────────────────
window.abrirResultados = function(itemId, nombre, resultados) {
document.getElementById('resItemId').value = itemId;
document.getElementById('modalResTitle').innerHTML =
`<i class="fas fa-flask me-2"></i>Resultados — ${escHtml(nombre)}`;
renderFilasResultados(resultados);
bootstrap.Modal.getOrCreateInstance(document.getElementById('modalResultados')).show();
};
function renderFilasResultados(resultados) {
const cont = document.getElementById('tablaResultados');
const filas = resultados.length > 0 ? resultados : [{}];
cont.innerHTML = `
<table class="table table-sm table-bordered" id="tResultados">
<thead class="table-light">
<tr>
<th>Campo</th>
<th>Valor</th>
<th>Unidad</th>
<th>Referencia</th>
<th class="text-center">Anormal</th>
<th></th>
</tr>
</thead>
<tbody id="tbodyRes">${filas.map(r => buildFilaRes(r)).join('')}</tbody>
</table>`;
}
function buildFilaRes(r = {}) {
return `<tr>
<td><input class="form-control form-control-sm r-campo" value="${escAtt(r.campo_nombre||'')}" placeholder="Hemoglobina"></td>
<td><input class="form-control form-control-sm r-valor" value="${escAtt(r.valor_texto||r.valor_numerico||'')}" placeholder="14.5"></td>
<td><input class="form-control form-control-sm r-unidad" value="${escAtt(r.unidad||'')}" placeholder="g/dL" style="width:80px"></td>
<td><input class="form-control form-control-sm r-ref" value="${escAtt(r.referencia_texto || (r.referencia_min!=null ? r.referencia_min+' '+r.referencia_max : ''))}" placeholder="12.0 16.0"></td>
<td class="text-center"><input type="checkbox" class="form-check-input r-anormal" ${r.es_anormal ? 'checked' : ''}></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('tr').remove()"><i class="fas fa-times"></i></button></td>
</tr>`;
}
document.getElementById('btnAgregarFila').addEventListener('click', () => {
document.getElementById('tbodyRes').insertAdjacentHTML('beforeend', buildFilaRes());
});
document.getElementById('btnGuardarResultados').addEventListener('click', async () => {
const itemId = parseInt(document.getElementById('resItemId').value);
const filas = [...document.querySelectorAll('#tbodyRes tr')];
const resultados = filas.map(tr => ({
campo_nombre: tr.querySelector('.r-campo').value.trim(),
valor_texto: tr.querySelector('.r-valor').value.trim(),
valor_numerico: parseFloat(tr.querySelector('.r-valor').value) || null,
unidad: tr.querySelector('.r-unidad').value.trim(),
referencia_texto: tr.querySelector('.r-ref').value.trim(),
es_anormal: tr.querySelector('.r-anormal').checked,
})).filter(r => r.campo_nombre !== '');
if (!resultados.length) { alert('Ingrese al menos un campo de resultado.'); return; }
const btn = document.getElementById('btnGuardarResultados');
btn.disabled = true;
try {
const r = await fetch(API_RES, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_id: itemId, resultados }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
bootstrap.Modal.getInstance(document.getElementById('modalResultados')).hide();
await cargarOrden();
} catch (e) {
alert('Error: ' + e.message);
} finally {
btn.disabled = false;
}
});
// ── Helpers ───────────────────────────────────────────────
function labelEstItem(e) {
return {
pendiente: 'Pendiente',
muestra_tomada: 'Muestra tomada',
en_proceso: 'En proceso',
resultado_listo: 'Resultado listo',
entregado: 'Entregado',
}[e] || e;
}
function badgePrio(p) {
return {stat:'<span class="badge bg-danger">STAT</span>',urgente:'<span class="badge bg-warning text-dark">Urgente</span>',normal:'<span class="badge bg-secondary">Normal</span>'}[p]||'';
}
function badgeEstado(e) {
return {pendiente:'<span class="badge bg-light text-dark border">Pendiente</span>',en_proceso:'<span class="badge bg-primary">En proceso</span>',completa:'<span class="badge bg-success">Completa</span>',entregada:'<span class="badge bg-info text-dark">Entregada</span>',cancelada:'<span class="badge bg-dark">Cancelada</span>'}[e]||e;
}
function escHtml(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function escAtt(s) { return String(s||'').replace(/"/g,'&quot;'); }
// ── Inicio ───────────────────────────────────────────────
cargarOrden();
})();
</script>
<?php Layout::close(); ?>
+219
View File
@@ -0,0 +1,219 @@
<?php
/**
* Helpers compartidos para los endpoints del módulo Turnero.
* Incluido por cada endpoint de modules/turnero/api/.
*/
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');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
// ── Funciones de respuesta ───────────────────────────────────
function jsonOk(array $payload = [], string $mensaje = ''): void
{
ob_clean();
$resp = ['ok' => true, 'success' => true];
if ($mensaje) {
$resp['message'] = $mensaje;
}
echo json_encode(array_merge($resp, $payload), JSON_UNESCAPED_UNICODE);
exit;
}
function jsonError(string $mensaje, int $code = 400): void
{
ob_clean();
http_response_code($code);
echo json_encode(['ok' => false, 'success' => false, 'error' => $mensaje], JSON_UNESCAPED_UNICODE);
exit;
}
function requireMethod(string $method): void
{
if ($_SERVER['REQUEST_METHOD'] !== strtoupper($method)) {
jsonError('Método no permitido', 405);
}
}
function inputJson(): array
{
static $parsed = null;
if ($parsed === null) {
$raw = file_get_contents('php://input');
$parsed = json_decode($raw, true) ?? [];
}
return $parsed;
}
// ── Autenticación (kiosko y display son públicos) ────────────
/**
* Exige sesión activa. Para endpoints de login requerido.
*/
function requireAuth(): void
{
if (empty($_SESSION['admin_user']['id'])) {
jsonError('No autenticado', 401);
}
}
/**
* Exige sesión + módulo turnero.
*/
function requireTurnero(): void
{
requireAuth();
$modules = $_SESSION['admin_user']['modules'] ?? [];
$roleId = $_SESSION['admin_user']['role_id'] ?? null;
// Superadmin / admin sin role_id = acceso total
if (empty($roleId)) {
return;
}
if (!in_array('turnero', (array) $modules, true)) {
jsonError('Sin acceso al módulo Turnero', 403);
}
}
function adminId(): ?int
{
return isset($_SESSION['admin_user']['id'])
? (int) $_SESSION['admin_user']['id']
: null;
}
// ── Helper BD ────────────────────────────────────────────────
function db(): PDO
{
return Database::getInstance()->getConnection();
}
// ── Motor de cola ────────────────────────────────────────────
/**
* Obtiene o crea la sesión del día actual.
* Devuelve el id de la sesión.
*/
function obtenerOCrearSesionHoy(): int
{
$pdo = db();
$hoy = date('Y-m-d');
$stmt = $pdo->prepare('SELECT id FROM turnero_sesiones WHERE fecha = ? LIMIT 1');
$stmt->execute([$hoy]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
return (int) $row['id'];
}
// Crear sesión del día
$stmt = $pdo->prepare(
'INSERT INTO turnero_sesiones (fecha, abierto_por, inicio_at) VALUES (?, ?, NOW())'
);
$stmt->execute([$hoy, adminId()]);
return (int) $pdo->lastInsertId();
}
/**
* Genera el siguiente número correlativo de turno para una sesión.
* Usa bloqueo a nivel de fila para evitar duplicados en concurrencia.
*/
function siguienteNumero(int $sesionId): int
{
$pdo = db();
$stmt = $pdo->prepare(
'SELECT COALESCE(MAX(numero), 0) + 1 AS siguiente
FROM turnero_turnos
WHERE sesion_id = ?
FOR UPDATE'
);
$stmt->execute([$sesionId]);
return (int) $stmt->fetchColumn();
}
/**
* Selecciona el siguiente turno a llamar según el motor de prioridades.
*
* @param string $estado Estado que deben tener los turnos en cola
* @param int|null $lugarId Si no es null, filtra por lugar_destino_id
* @return array|null Fila de turnero_turnos o null si cola vacía
*/
function siguienteTurnoEnCola(string $estado, ?int $lugarId = null): ?array
{
$pdo = db();
if ($lugarId !== null) {
$sql = '
SELECT t.*, p.orden_peso, p.codigo AS prioridad_codigo, p.color AS prioridad_color
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.estado = ?
AND t.lugar_destino_id = ?
ORDER BY p.orden_peso ASC, t.creado_at ASC
LIMIT 1
';
$stmt = $pdo->prepare($sql);
$stmt->execute([$estado, $lugarId]);
} else {
$sql = '
SELECT t.*, p.orden_peso, p.codigo AS prioridad_codigo, p.color AS prioridad_color
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.estado = ?
ORDER BY p.orden_peso ASC, t.creado_at ASC
LIMIT 1
';
$stmt = $pdo->prepare($sql);
$stmt->execute([$estado]);
}
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
/**
* Emite un evento SSE a todos los clientes suscritos escribiendo
* un flag en la tabla turnero_sesiones (campo sse_ping_at).
* Los clientes hacen polling de ese campo o escuchan el SSE directamente.
*
* Implementación ligera: actualiza timestamp en sesión para que
* sse_turno.php lo detecte y reenvíe el estado.
*/
function notificarSSE(int $sesionId): void
{
try {
db()->prepare(
'UPDATE turnero_sesiones SET sse_ping_at = NOW() WHERE id = ?'
)->execute([$sesionId]);
} catch (\Throwable $e) {
// No es crítico si falla; el SSE continuará con polling normal
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
/**
* POST /modules/turnero/api/cambiar_estado.php
* Avanza o cancela el estado de un turno.
* Requiere login + módulo turnero.
*
* Body JSON:
* turno_id int requerido
* nuevo_estado string requerido (ver transiciones válidas abajo)
* lugar_id int requerido cuando nuevo_estado = "en_espera_lugar"
*
* Transiciones permettidas:
* espera → en_recepcion
* en_recepcion → en_espera_lugar, ausente, cancelado
* en_espera_lugar → en_servicio, ausente, cancelado
* en_servicio → finalizado, ausente, cancelado
* (cualquier) → ausente, cancelado
*
* Bloqueo especial:
* en_espera_lugar → en_servicio requiere que todos los consentimientos
* del turno estén en estado 'firmado' o 'rechazado'.
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$datos = inputJson();
$turnoId = isset($datos['turno_id']) ? (int) $datos['turno_id'] : 0;
$nuevoEstado = isset($datos['nuevo_estado']) ? trim((string) $datos['nuevo_estado']) : '';
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : null;
// ── Validación básica ─────────────────────────────────────────
if ($turnoId <= 0) jsonError('turno_id inválido.');
if ($nuevoEstado === '') jsonError('nuevo_estado es requerido.');
// Mapa de transiciones válidas [estado_actual => [estados_destino_permitidos]]
const TRANSICIONES = [
'espera' => ['en_recepcion', 'ausente', 'cancelado'],
'en_recepcion' => ['en_espera_lugar', 'ausente', 'cancelado'],
'en_espera_lugar' => ['en_servicio', 'ausente', 'cancelado'],
'en_servicio' => ['finalizado', 'ausente', 'cancelado'],
];
$pdo = db();
$pdo->beginTransaction();
try {
// Leer turno actual con lock
$stmt = $pdo->prepare(
'SELECT t.*, p.codigo AS prioridad_codigo
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.id = ?
FOR UPDATE'
);
$stmt->execute([$turnoId]);
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$turno) {
$pdo->rollBack();
jsonError('Turno no encontrado.', 404);
}
$estadoActual = $turno['estado'];
// Estados finales no modificables
if (in_array($estadoActual, ['finalizado', 'ausente', 'cancelado'], true)) {
$pdo->rollBack();
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422);
}
// Verificar que la transición sea válida
$permitidos = TRANSICIONES[$estadoActual] ?? [];
if (!in_array($nuevoEstado, $permitidos, true)) {
$pdo->rollBack();
jsonError(
"Transición '{$estadoActual}' → '{$nuevoEstado}' no está permitida. "
. "Transiciones válidas: " . implode(', ', $permitidos),
422
);
}
// ── Regla especial: en_espera_lugar → en_servicio ─────────
if ($nuevoEstado === 'en_servicio') {
$cStmt = $pdo->prepare(
"SELECT COUNT(*) FROM turnero_consentimientos
WHERE turno_id = ?
AND estado NOT IN ('firmado', 'rechazado')"
);
$cStmt->execute([$turnoId]);
$pendientes = (int) $cStmt->fetchColumn();
if ($pendientes > 0) {
$pdo->rollBack();
jsonError(
"Hay {$pendientes} consentimiento(s) pendiente(s). "
. "El turno no puede pasar a 'en_servicio' hasta que todos estén firmados o rechazados.",
422
);
}
}
// ── Construir UPDATE dinámico ─────────────────────────────
$sets = ['estado = ?'];
$binds = [$nuevoEstado];
switch ($nuevoEstado) {
case 'en_recepcion':
$sets[] = 'llamado_recepcion_at = COALESCE(llamado_recepcion_at, NOW())';
$sets[] = 'inicio_recepcion_at = COALESCE(inicio_recepcion_at, NOW())';
$sets[] = 'atendido_recepcion_por = COALESCE(atendido_recepcion_por, ?)';
$binds[] = adminId();
break;
case 'en_espera_lugar':
// Requiere lugar_id
if (!$lugarId) {
$pdo->rollBack();
jsonError('lugar_id es obligatorio para pasar a en_espera_lugar.', 422);
}
$sets[] = 'fin_recepcion_at = COALESCE(fin_recepcion_at, NOW())';
$sets[] = 'lugar_destino_id = ?';
$binds[] = $lugarId;
$sets[] = 'atendido_recepcion_por = COALESCE(atendido_recepcion_por, ?)';
$binds[] = adminId();
break;
case 'en_servicio':
$sets[] = 'llamado_lugar_at = COALESCE(llamado_lugar_at, NOW())';
$sets[] = 'inicio_lugar_at = COALESCE(inicio_lugar_at, NOW())';
$sets[] = 'atendido_lugar_por = COALESCE(atendido_lugar_por, ?)';
$binds[] = adminId();
break;
case 'finalizado':
$sets[] = 'fin_lugar_at = COALESCE(fin_lugar_at, NOW())';
$sets[] = 'atendido_lugar_por = COALESCE(atendido_lugar_por, ?)';
$binds[] = adminId();
break;
case 'ausente':
case 'cancelado':
// Sin timestamps adicionales; simplemente cambio de estado
break;
}
$sets[] = 'actualizado_at = NOW()';
$sql = 'UPDATE turnero_turnos SET ' . implode(', ', $sets) . ' WHERE id = ?';
$binds[] = $turnoId;
$pdo->prepare($sql)->execute($binds);
$pdo->commit();
// Refrescar y devolver turno actualizado
$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], "Estado actualizado a '{$nuevoEstado}'");
} catch (\Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
jsonError('Error al cambiar estado: ' . $e->getMessage(), 500);
}
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* POST /modules/turnero/api/create_solicitud.php
* Crea la solicitud interna de un turno: vincula paciente, lugar y exámenes.
* Además devuelve qué consentimientos serán necesarios (sin enviarlos aún).
* Requiere login + módulo turnero.
*
* Body JSON:
* turno_id int requerido
* paciente_id int requerido
* lugar_id int requerido
* exam_tipo_ids int[] requerido (al menos 1)
* total_cobrado float opcional
* metodo_pago string opcional
* observaciones string opcional
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$datos = inputJson();
$turnoId = isset($datos['turno_id']) ? (int) $datos['turno_id'] : 0;
$pacienteId = isset($datos['paciente_id']) ? (int) $datos['paciente_id'] : 0;
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : 0;
$examIds = isset($datos['exam_tipo_ids']) && is_array($datos['exam_tipo_ids'])
? array_filter(array_map('intval', $datos['exam_tipo_ids'])) : [];
$total = isset($datos['total_cobrado']) && $datos['total_cobrado'] !== null
? (float) $datos['total_cobrado'] : null;
$metodoPago = isset($datos['metodo_pago']) ? trim((string) $datos['metodo_pago']) : null;
$obs = isset($datos['observaciones']) ? trim((string) $datos['observaciones']) : null;
// ── Validaciones ──────────────────────────────────────────────
if ($turnoId <= 0) jsonError('turno_id inválido.');
if ($pacienteId <= 0) jsonError('paciente_id inválido.');
if ($lugarId <= 0) jsonError('lugar_id inválido.');
if (empty($examIds)) jsonError('Debe seleccionar al menos un examen.');
$metodosValidos = ['efectivo', 'transferencia', 'tarjeta', 'eps', 'cortesia', ''];
if ($metodoPago && !in_array($metodoPago, $metodosValidos, true)) {
jsonError('metodo_pago inválido.');
}
$pdo = db();
$pdo->beginTransaction();
try {
// Verificar que el turno existe y está en recepción
$stmt = $pdo->prepare(
"SELECT id, estado FROM turnero_turnos WHERE id = ? FOR UPDATE"
);
$stmt->execute([$turnoId]);
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$turno) {
$pdo->rollBack();
jsonError('Turno no encontrado.', 404);
}
if (!in_array($turno['estado'], ['en_recepcion', 'espera'], true)) {
$pdo->rollBack();
jsonError("El turno está en estado '{$turno['estado']}' y no se puede editar su solicitud.", 422);
}
// Verificar que el lugar existe
$stmt = $pdo->prepare("SELECT id FROM turnero_lugares WHERE id = ? AND activo = 1");
$stmt->execute([$lugarId]);
if (!$stmt->fetch()) {
$pdo->rollBack();
jsonError('Lugar destino no encontrado o inactivo.', 404);
}
// Verificar que todos los exam_tipo_ids existen
$in = implode(',', array_fill(0, count($examIds), '?'));
$stmt = $pdo->prepare("SELECT COUNT(*) FROM exam_tipos WHERE id IN ({$in}) AND activo = 1");
$stmt->execute($examIds);
if ((int)$stmt->fetchColumn() !== count($examIds)) {
$pdo->rollBack();
jsonError('Uno o más exámenes no existen o están inactivos.', 422);
}
// ── Insertar o reemplazar solicitud (DELETE + INSERT para poder re-guardar) ──
$stmt = $pdo->prepare("SELECT id FROM turnero_solicitudes WHERE turno_id = ?");
$stmt->execute([$turnoId]);
$solAnterior = $stmt->fetch(PDO::FETCH_ASSOC);
if ($solAnterior) {
// Eliminar ítemes anteriores (CASCADE borrará los turnero_examen_items)
$pdo->prepare("DELETE FROM turnero_solicitudes WHERE turno_id = ?")->execute([$turnoId]);
}
$stmt = $pdo->prepare(
"INSERT INTO turnero_solicitudes
(turno_id, paciente_id, lugar_id, total_cobrado, metodo_pago, observaciones, creado_por)
VALUES (?, ?, ?, ?, ?, ?, ?)"
);
$stmt->execute([
$turnoId, $pacienteId, $lugarId,
$total, $metodoPago ?: null, $obs ?: null, adminId(),
]);
$solicitudId = (int) $pdo->lastInsertId();
// ── Insertar ítemes de examen ──────────────────────────────
$stmtItem = $pdo->prepare(
"INSERT INTO turnero_examen_items (solicitud_id, exam_tipo_id) VALUES (?, ?)"
);
foreach ($examIds as $examId) {
$stmtItem->execute([$solicitudId, $examId]);
}
// ── Calcular consentimientos necesarios (sin crearlos todavía) ──
// Deduplica: varios exámenes que apuntan al mismo formulario → 1 solo ítem.
$in = implode(',', array_fill(0, count($examIds), '?'));
$stmt = $pdo->prepare(
"SELECT DISTINCT etc.formulario_id, f.nombre AS formulario_nombre
FROM exam_tipo_consentimientos etc
JOIN lab_formularios f ON f.id = etc.formulario_id
WHERE etc.exam_tipo_id IN ({$in})"
);
$stmt->execute($examIds);
$consentimientosRequeridos = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Agregar estado actual de cada consentimiento (si ya fue enviado antes)
foreach ($consentimientosRequeridos as &$c) {
$stC = $pdo->prepare(
"SELECT estado FROM turnero_consentimientos WHERE turno_id = ? AND formulario_id = ?"
);
$stC->execute([$turnoId, $c['formulario_id']]);
$row = $stC->fetch(PDO::FETCH_ASSOC);
$c['estado'] = $row['estado'] ?? 'pendiente';
}
unset($c);
$pdo->commit();
notificarSSE(obtenerOCrearSesionHoy());
// Recuperar solicitud completa
$stmt = $pdo->prepare(
"SELECT s.*,
p.full_name AS paciente_nombre,
l.nombre AS lugar_nombre
FROM turnero_solicitudes s
JOIN lab_pacientes l2 ON l2.id = s.lugar_id -- placeholder; join real abajo
-- joins reales:
LEFT JOIN lab_pacientes p ON p.id = s.paciente_id
LEFT JOIN turnero_lugares l ON l.id = s.lugar_id
WHERE s.id = ?"
);
// Simplificado para evitar error de sintaxis de comentarios SQL embebidos:
$stmt = $pdo->prepare(
"SELECT s.id, s.turno_id, s.paciente_id, s.lugar_id, s.total_cobrado,
s.metodo_pago, s.observaciones, s.creado_at,
p.full_name AS paciente_nombre,
p.documento AS paciente_documento,
l.nombre AS lugar_nombre
FROM turnero_solicitudes s
LEFT JOIN lab_pacientes p ON p.id = s.paciente_id
LEFT JOIN turnero_lugares l ON l.id = s.lugar_id
WHERE s.id = ?"
);
$stmt->execute([$solicitudId]);
$solicitud = $stmt->fetch(PDO::FETCH_ASSOC);
jsonOk([
'solicitud' => $solicitud,
'consentimientos_requeridos' => $consentimientosRequeridos,
], 'Solicitud guardada correctamente');
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
jsonError('Error al crear solicitud: ' . $e->getMessage(), 500);
}
+113
View File
@@ -0,0 +1,113 @@
<?php
/**
* POST /modules/turnero/api/create_turno.php
* Genera un nuevo turno desde el kiosko o recepción.
* No requiere login (kiosko es público).
*
* Body JSON:
* prioridad_codigo string requerido "A"|"B"|"C"|"D"|"E"|"F"
* paciente_nombre string opcional
* paciente_cel string opcional
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
// Este endpoint es público (kiosko no requiere login)
// Solo validamos CSRF básico: debe ser POST con JSON
$datos = inputJson();
// ── Validación ────────────────────────────────────────────────
$prioCodigo = strtoupper(trim($datos['prioridad_codigo'] ?? ''));
if (!in_array($prioCodigo, ['A', 'B', 'C', 'D', 'E', 'F'], true)) {
jsonError('prioridad_codigo inválido. Use A, B, C, D, E o F.');
}
$pacienteNombre = substr(trim($datos['paciente_nombre'] ?? ''), 0, 150);
$pacienteCel = preg_replace('/[^0-9+\- ]/', '', $datos['paciente_cel'] ?? '');
$pacienteCel = substr($pacienteCel, 0, 20);
// ── Lógica principal (dentro de transacción para evitar race conditions) ──
$pdo = db();
$pdo->beginTransaction();
try {
$sesionId = obtenerOCrearSesionHoy();
$numero = siguienteNumero($sesionId);
// Buscar prioridad_id
$stmt = $pdo->prepare(
'SELECT id FROM turnero_prioridades WHERE codigo = ? AND activo = 1 LIMIT 1'
);
$stmt->execute([$prioCodigo]);
$prioridad = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$prioridad) {
$pdo->rollBack();
jsonError("La prioridad '$prioCodigo' no existe o no está activa.", 422);
}
$prioridadId = (int) $prioridad['id'];
// Código visual: "A001", "E042", etc.
$codigo = $prioCodigo . str_pad($numero, 3, '0', STR_PAD_LEFT);
$stmt = $pdo->prepare(
'INSERT INTO turnero_turnos
(sesion_id, numero, codigo, prioridad_id, paciente_nombre, paciente_cel,
estado, creado_at)
VALUES
(?, ?, ?, ?, ?, ?, "espera", NOW())'
);
$stmt->execute([
$sesionId,
$numero,
$codigo,
$prioridadId,
$pacienteNombre ?: null,
$pacienteCel ?: null,
]);
$turnoId = (int) $pdo->lastInsertId();
$pdo->commit();
notificarSSE($sesionId);
// Contar posición en la cola
$stmt = $pdo->prepare(
'
SELECT COUNT(*) AS posicion
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.estado = "espera"
AND t.sesion_id = ?
AND (
p.orden_peso < (SELECT orden_peso FROM turnero_prioridades WHERE id = ?)
OR (
p.orden_peso = (SELECT orden_peso FROM turnero_prioridades WHERE id = ?)
AND t.creado_at < (SELECT creado_at FROM turnero_turnos WHERE id = ?)
)
)
'
);
$stmt->execute([$sesionId, $prioridadId, $prioridadId, $turnoId]);
$posicion = (int) $stmt->fetchColumn() + 1;
jsonOk([
'turno' => [
'id' => $turnoId,
'codigo' => $codigo,
'numero' => $numero,
'prioridad_codigo' => $prioCodigo,
'posicion_cola' => $posicion,
'sesion_id' => $sesionId,
],
], "Turno $codigo asignado correctamente");
} catch (\Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
jsonError('Error al crear el turno: ' . $e->getMessage(), 500);
}
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* modules/turnero/api/export_csv.php
* GET ?fecha=YYYY-MM-DD → descarga CSV con todos los turnos del día
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
$fecha = trim($_GET['fecha'] ?? '');
if ($fecha === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $fecha)) {
$fecha = date('Y-m-d');
}
$pdo = db();
// Buscar sesión del día
$stmtS = $pdo->prepare('SELECT id FROM turnero_sesiones WHERE fecha = ? LIMIT 1');
$stmtS->execute([$fecha]);
$sesion = $stmtS->fetch(PDO::FETCH_ASSOC);
if (!$sesion) {
// Sin sesión: CSV vacío con encabezado
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="turnero_' . $fecha . '.csv"');
echo "\xEF\xBB\xBF"; // BOM UTF-8 para Excel
echo "Sin datos para la fecha {$fecha}\n";
exit;
}
$sesionId = (int)$sesion['id'];
$stmt = $pdo->prepare(
"SELECT
t.codigo,
t.numero,
p.codigo AS prioridad,
p.nombre AS prioridad_nombre,
COALESCE(s.nombre_completo, t.paciente_nombre) AS paciente,
s.numero_documento,
s.tipo_documento,
t.paciente_cel AS celular,
l.nombre AS lugar,
t.estado,
t.creado_at,
t.llamado_recepcion_at,
t.inicio_recepcion_at,
t.fin_recepcion_at,
t.llamado_lugar_at,
t.inicio_lugar_at,
t.fin_lugar_at,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, t.fin_recepcion_at, NOW())) / 60.0, 1)
AS espera_recepcion_min,
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1)
AS servicio_lugar_min,
sol.total_cobrado,
sol.metodo_pago,
GROUP_CONCAT(et.nombre ORDER BY et.nombre SEPARATOR ' | ') AS examenes,
SUM(CASE WHEN tc.estado = 'firmado' THEN 1 ELSE 0 END) AS consent_firmados,
SUM(CASE WHEN tc.estado = 'pendiente' THEN 1 ELSE 0 END) AS consent_pendientes
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN turnero_solicitudes sol ON sol.turno_id = t.id
LEFT JOIN lab_pacientes s ON s.id = sol.paciente_id
LEFT JOIN turnero_examen_items tei ON tei.solicitud_id = sol.id
LEFT JOIN exam_tipos et ON et.id = tei.exam_tipo_id
LEFT JOIN turnero_consentimientos tc ON tc.turno_id = t.id
WHERE t.sesion_id = ?
GROUP BY t.id
ORDER BY t.numero ASC"
);
$stmt->execute([$sesionId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
// ── Encabezados CSV ───────────────────────────────────────────
$headers = [
'Código', 'Número', 'Prioridad', 'Prioridad nombre',
'Paciente', 'Documento', 'Tipo doc.', 'Celular',
'Lugar', 'Estado',
'Creado', 'Llamado recepción', 'Inicio recepción', 'Fin recepción',
'Llamado lugar', 'Inicio lugar', 'Fin lugar',
'Espera recepción (min)', 'Servicio lugar (min)',
'Total cobrado', 'Método pago',
'Exámenes',
'Consentimientos firmados', 'Consentimientos pendientes',
];
$fields = [
'codigo', 'numero', 'prioridad', 'prioridad_nombre',
'paciente', 'numero_documento', 'tipo_documento', 'celular',
'lugar', 'estado',
'creado_at', 'llamado_recepcion_at', 'inicio_recepcion_at', 'fin_recepcion_at',
'llamado_lugar_at', 'inicio_lugar_at', 'fin_lugar_at',
'espera_recepcion_min', 'servicio_lugar_min',
'total_cobrado', 'metodo_pago',
'examenes',
'consent_firmados', 'consent_pendientes',
];
// ── Output ────────────────────────────────────────────────────
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="turnero_' . $fecha . '.csv"');
header('Cache-Control: no-cache, no-store, must-revalidate');
$out = fopen('php://output', 'w');
echo "\xEF\xBB\xBF"; // BOM UTF-8 para Excel
fputcsv($out, $headers, ',', '"');
foreach ($rows as $row) {
$line = [];
foreach ($fields as $f) {
$v = $row[$f] ?? '';
// Formatear fechas: quitar segundos fraccionarios si los hay
if ($v && in_array($f, ['creado_at','llamado_recepcion_at','inicio_recepcion_at','fin_recepcion_at','llamado_lugar_at','inicio_lugar_at','fin_lugar_at'])) {
$v = $v ? date('d/m/Y H:i:s', strtotime($v)) : '';
}
$line[] = $v ?? '';
}
fputcsv($out, $line, ',', '"');
}
fclose($out);
exit;
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* GET /modules/turnero/api/get_cola.php
* Devuelve el estado de la cola para pantallas TV y kiosko.
* No requiere autenticación (acceso público).
*
* Query params:
* area string requerido "recepcion" | "lugar"
* lugar_id int requerido si area = "lugar"
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
$area = isset($_GET['area']) ? trim($_GET['area']) : '';
$lugarId = isset($_GET['lugar_id']) ? (int) $_GET['lugar_id'] : null;
if (!in_array($area, ['recepcion', 'lugar'], true)) {
jsonError('area debe ser "recepcion" o "lugar".');
}
if ($area === 'lugar' && !$lugarId) {
jsonError('lugar_id es obligatorio cuando area = "lugar".');
}
$pdo = db();
// ── Sesión de hoy ─────────────────────────────────────────────
$sesionId = obtenerOCrearSesionHoy();
// ── Cola según área ───────────────────────────────────────────
if ($area === 'recepcion') {
$estadosCola = ["'espera'", "'en_recepcion'"];
$filtroLugar = '';
$bindsCola = [];
} else {
$estadosCola = ["'en_espera_lugar'", "'en_servicio'"];
$filtroLugar = 'AND t.lugar_destino_id = ?';
$bindsCola = [$lugarId];
}
$inEstados = implode(', ', $estadosCola);
$stmt = $pdo->prepare(
"SELECT t.id,
t.codigo,
t.numero,
t.estado,
t.paciente_nombre,
t.creado_at,
t.llamado_recepcion_at,
t.llamado_lugar_at,
p.codigo AS prioridad_codigo,
p.nombre AS prioridad_nombre,
p.color AS prioridad_color,
p.orden_peso
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.sesion_id = ?
AND t.estado IN ({$inEstados})
{$filtroLugar}
ORDER BY p.orden_peso ASC, t.creado_at ASC"
);
array_unshift($bindsCola, $sesionId);
$stmt->execute($bindsCola);
$cola = $stmt->fetchAll(PDO::FETCH_ASSOC);
// ── Turno actualmente en pantalla (último llamado) ────────────
$campoLlamado = $area === 'recepcion' ? 'llamado_recepcion_at' : 'llamado_lugar_at';
$estadoActivo = $area === 'recepcion' ? "'en_recepcion'" : "'en_servicio'";
$bindActivo = $area === 'lugar' ? [$sesionId, $lugarId] : [$sesionId];
$filtroActivo = $area === 'lugar' ? 'AND t.lugar_destino_id = ?' : '';
$stmt = $pdo->prepare(
"SELECT t.id,
t.codigo,
t.numero,
t.estado,
t.paciente_nombre,
t.{$campoLlamado} AS llamado_at,
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.sesion_id = ?
AND t.estado = {$estadoActivo}
{$filtroActivo}
ORDER BY t.{$campoLlamado} DESC
LIMIT 1"
);
$stmt->execute($bindActivo);
$activo = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
// ── Estadísticas de la sesión ─────────────────────────────────
$stmt = $pdo->prepare(
"SELECT
COUNT(*) AS total,
SUM(estado = 'espera') AS en_espera,
SUM(estado = 'en_recepcion') AS en_recepcion,
SUM(estado = 'en_espera_lugar') AS en_espera_lugar,
SUM(estado = 'en_servicio') AS en_servicio,
SUM(estado = 'finalizado') AS finalizados,
SUM(estado IN ('ausente','cancelado')) AS no_atendidos,
SEC_TO_TIME(
AVG(
CASE WHEN fin_lugar_at IS NOT NULL AND creado_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, creado_at, fin_lugar_at)
ELSE NULL END
)
) AS tiempo_promedio_atencion
FROM turnero_turnos
WHERE sesion_id = ?"
);
$stmt->execute([$sesionId]);
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
jsonOk([
'sesion_id' => $sesionId,
'area' => $area,
'lugar_id' => $lugarId,
'activo' => $activo,
'cola' => $cola,
'stats' => $stats,
'timestamp' => date('c'),
]);
@@ -0,0 +1,98 @@
<?php
/**
* GET /modules/turnero/api/get_consentimientos.php
* Retorna el estado actualizado de los consentimientos de un turno.
* Usado por lugar.php (polling cada 5 s) y recepcion.php.
* No requiere autenticación (las pantallas internas ya están protegidas).
*
* Query params:
* turno_id int requerido
* incluir_solicitud int opcional 1 → también retorna solicitud, paciente y exámenes
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
$turnoId = isset($_GET['turno_id']) ? (int) $_GET['turno_id'] : 0;
$incluirSolicitud = isset($_GET['incluir_solicitud']) ? (bool)(int)$_GET['incluir_solicitud'] : false;
if ($turnoId <= 0) jsonError('turno_id inválido.');
$pdo = db();
// ── Verificar que el turno existe ─────────────────────────────
$stmt = $pdo->prepare("SELECT id, estado FROM turnero_turnos WHERE id = ?");
$stmt->execute([$turnoId]);
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$turno) jsonError('Turno no encontrado.', 404);
// ── Consentimientos ───────────────────────────────────────────
$stmt = $pdo->prepare(
"SELECT tc.id,
tc.turno_id,
tc.formulario_id,
tc.token,
tc.estado,
tc.enviado_at,
tc.firmado_at,
f.nombre AS formulario_nombre
FROM turnero_consentimientos tc
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
WHERE tc.turno_id = ?
ORDER BY tc.id ASC"
);
$stmt->execute([$turnoId]);
$consentimientos = $stmt->fetchAll(PDO::FETCH_ASSOC);
$respuesta = [
'turno_id' => $turnoId,
'turno_estado' => $turno['estado'],
'consentimientos' => $consentimientos,
];
// ── Datos adicionales si solicita solicitud ───────────────────
if ($incluirSolicitud) {
$stmt = $pdo->prepare(
"SELECT s.id, s.turno_id, s.paciente_id, s.lugar_id,
s.total_cobrado, s.metodo_pago, s.observaciones, s.creado_at,
l.nombre AS lugar_nombre
FROM turnero_solicitudes s
LEFT JOIN turnero_lugares l ON l.id = s.lugar_id
WHERE s.turno_id = ?
LIMIT 1"
);
$stmt->execute([$turnoId]);
$solicitud = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
$paciente = null;
$examenes = [];
if ($solicitud) {
// Paciente
$stmt = $pdo->prepare(
"SELECT id, full_name, tipo_documento, documento,
fecha_nacimiento, telefono, celular
FROM lab_pacientes
WHERE id = ?"
);
$stmt->execute([$solicitud['paciente_id']]);
$paciente = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
// Exámenes
$stmt = $pdo->prepare(
"SELECT et.id, et.codigo, et.nombre, et.categoria
FROM turnero_examen_items tei
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
WHERE tei.solicitud_id = ?
ORDER BY et.categoria, et.nombre"
);
$stmt->execute([$solicitud['id']]);
$examenes = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
$respuesta['solicitud'] = $solicitud;
$respuesta['paciente'] = $paciente;
$respuesta['examenes'] = $examenes;
}
jsonOk($respuesta);
+161
View File
@@ -0,0 +1,161 @@
<?php
/**
* modules/turnero/api/get_dashboard.php
* GET ?fecha=YYYY-MM-DD (por defecto = hoy)
*
* Devuelve métricas del día para el dashboard del turnero:
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
* - desglose por prioridad
* - desglose por lugar
* - lista de turnos del día (para tabla detalle)
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('GET');
$fecha = trim($_GET['fecha'] ?? '');
if ($fecha === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $fecha)) {
$fecha = date('Y-m-d');
}
$pdo = db();
// ── 1. Sesión del día ─────────────────────────────────────────
$stmtSesion = $pdo->prepare(
'SELECT id, inicio_at, fin_at FROM turnero_sesiones WHERE fecha = ? LIMIT 1'
);
$stmtSesion->execute([$fecha]);
$sesion = $stmtSesion->fetch(PDO::FETCH_ASSOC);
if (!$sesion) {
jsonOk([
'fecha' => $fecha,
'sesion' => null,
'resumen' => ['total' => 0, 'atendidos' => 0, 'en_espera' => 0,
'ausentes' => 0, 'cancelados' => 0, 'tiempo_promedio_min' => null],
'por_prioridad' => [],
'por_lugar' => [],
'turnos' => [],
]);
}
$sesionId = (int)$sesion['id'];
// ── 2. Resumen global ─────────────────────────────────────────
$stmtRes = $pdo->prepare(
"SELECT
COUNT(*) AS total,
SUM(estado IN ('finalizado','en_servicio')) AS atendidos,
SUM(estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
SUM(estado = 'ausente') AS ausentes,
SUM(estado = 'cancelado') AS cancelados,
ROUND(
AVG(
CASE
WHEN inicio_recepcion_at IS NOT NULL AND creado_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, creado_at, inicio_recepcion_at) / 60.0
END
), 1
) AS tiempo_espera_promedio_min,
ROUND(
AVG(
CASE
WHEN fin_lugar_at IS NOT NULL AND inicio_lugar_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, inicio_lugar_at, fin_lugar_at) / 60.0
END
), 1
) AS tiempo_servicio_promedio_min
FROM turnero_turnos
WHERE sesion_id = ?"
);
$stmtRes->execute([$sesionId]);
$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC);
// ── 3. Desglose por prioridad ─────────────────────────────────
$stmtPri = $pdo->prepare(
"SELECT p.codigo, p.nombre, p.color,
COUNT(t.id) AS total,
SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
SUM(t.estado IN ('espera','en_recepcion','en_espera_lugar')) AS pendientes,
SUM(t.estado = 'ausente') AS ausentes
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.sesion_id = ?
GROUP BY p.id
ORDER BY p.orden_peso ASC"
);
$stmtPri->execute([$sesionId]);
$porPrioridad = $stmtPri->fetchAll(PDO::FETCH_ASSOC);
// ── 4. Desglose por lugar ─────────────────────────────────────
$stmtLug = $pdo->prepare(
"SELECT l.nombre AS lugar,
COUNT(t.id) AS total,
SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
SUM(t.estado IN ('en_espera_lugar')) AS en_espera,
ROUND(AVG(
CASE
WHEN t.fin_lugar_at IS NOT NULL AND t.inicio_lugar_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, t.fin_lugar_at) / 60.0
END
), 1) AS tiempo_servicio_promedio_min
FROM turnero_turnos t
JOIN turnero_lugares l ON l.id = t.lugar_destino_id
WHERE t.sesion_id = ?
GROUP BY l.id
ORDER BY l.sort_order ASC, l.nombre ASC"
);
$stmtLug->execute([$sesionId]);
$porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC);
// ── 5. Lista de turnos del día ────────────────────────────────
$stmtTurnos = $pdo->prepare(
"SELECT t.id, t.codigo, t.numero, t.estado,
t.paciente_nombre, t.paciente_cel,
t.creado_at, t.inicio_recepcion_at, t.fin_recepcion_at,
t.inicio_lugar_at, t.fin_lugar_at,
p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, p.color AS prioridad_color,
l.nombre AS lugar_nombre,
s.nombre_completo AS paciente_bd,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, NOW())) / 60.0, 1) AS espera_min,
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1) AS servicio_min
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN turnero_solicitudes ts ON ts.turno_id = t.id
LEFT JOIN lab_pacientes s ON s.id = ts.paciente_id
WHERE t.sesion_id = ?
ORDER BY t.numero ASC"
);
$stmtTurnos->execute([$sesionId]);
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
// ── 6. Consentimientos del día ────────────────────────────────
$stmtcons = $pdo->prepare(
"SELECT tc.estado, COUNT(*) AS cnt
FROM turnero_consentimientos tc
JOIN turnero_turnos t ON t.id = tc.turno_id
WHERE t.sesion_id = ?
GROUP BY tc.estado"
);
$stmtcons->execute([$sesionId]);
$consentStats = [];
foreach ($stmtcons->fetchAll(PDO::FETCH_ASSOC) as $r) {
$consentStats[$r['estado']] = (int)$r['cnt'];
}
jsonOk([
'fecha' => $fecha,
'sesion' => [
'id' => $sesionId,
'inicio' => $sesion['inicio_at'] ? date('H:i', strtotime($sesion['inicio_at'])) : null,
'fin' => $sesion['fin_at'] ? date('H:i', strtotime($sesion['fin_at'])) : null,
'abierta' => $sesion['fin_at'] === null,
],
'resumen' => $resumen,
'por_prioridad' => $porPrioridad,
'por_lugar' => $porLugar,
'consent_stats' => $consentStats,
'turnos' => $turnos,
]);
+106
View File
@@ -0,0 +1,106 @@
<?php
/**
* POST /modules/turnero/api/llamar_turno.php
* Llama el siguiente turno de una cola con el motor de prioridades.
* Requiere login + módulo turnero.
*
* Body JSON:
* area string requerido "recepcion" | "lugar"
* lugar_id int requerido si area = "lugar"
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$datos = inputJson();
$area = $datos['area'] ?? '';
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : null;
// ── Validación ────────────────────────────────────────────────
if (!in_array($area, ['recepcion', 'lugar'], true)) {
jsonError('area debe ser "recepcion" o "lugar".');
}
if ($area === 'lugar' && !$lugarId) {
jsonError('lugar_id es obligatorio cuando area = "lugar".');
}
$pdo = db();
$pdo->beginTransaction();
try {
if ($area === 'recepcion') {
// Cola recepción: turnos en estado 'espera' sin filtro de lugar
$turno = siguienteTurnoEnCola('espera');
if (!$turno) {
$pdo->rollBack();
jsonOk(['turno' => null], 'Cola de recepción vacía');
}
// Actualizar estado + timestamp
$stmt = $pdo->prepare(
'UPDATE turnero_turnos
SET estado = "en_recepcion",
llamado_recepcion_at = NOW(),
inicio_recepcion_at = NOW(),
atendido_recepcion_por = ?
WHERE id = ? AND estado = "espera"'
);
$stmt->execute([adminId(), $turno['id']]);
if ($stmt->rowCount() === 0) {
// Otro proceso lo tomó primero (race condition)
$pdo->rollBack();
jsonError('El turno ya fue tomado por otro operador. Intente de nuevo.', 409);
}
} else {
// Cola de un Lugar específico: turnos 'en_espera_lugar' asignados a ese lugar
$turno = siguienteTurnoEnCola('en_espera_lugar', $lugarId);
if (!$turno) {
$pdo->rollBack();
jsonOk(['turno' => null], 'Cola del lugar vacía');
}
$stmt = $pdo->prepare(
'UPDATE turnero_turnos
SET estado = "en_servicio",
llamado_lugar_at = NOW(),
inicio_lugar_at = NOW(),
atendido_lugar_por = ?
WHERE id = ? AND estado = "en_espera_lugar"'
);
$stmt->execute([adminId(), $turno['id']]);
if ($stmt->rowCount() === 0) {
$pdo->rollBack();
jsonError('El turno ya fue tomado por otro operador. Intente de nuevo.', 409);
}
}
// Refrescar datos del turno actualizado
$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([$turno['id']]);
$turnoActualizado = $stmt->fetch(PDO::FETCH_ASSOC);
$pdo->commit();
notificarSSE((int) $turnoActualizado['sesion_id']);
jsonOk(['turno' => $turnoActualizado], "Turno {$turnoActualizado['codigo']} llamado");
} catch (\Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
jsonError('Error al llamar turno: ' . $e->getMessage(), 500);
}
+100
View File
@@ -0,0 +1,100 @@
<?php
/**
* modules/turnero/api/save_ex_tipo.php
* GET ?id=X → devuelve datos de un tipo de examen (incluyendo formulario_id vinculado)
* POST {codigo, nombre, categoria, formulario_id, activo} → crear
* POST {id, codigo, nombre, categoria, formulario_id, activo} → actualizar
* POST {id, _delete: true} → eliminar
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
// ── GET: devolver datos de un tipo de examen ─────────────────
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$id = (int)($_GET['id'] ?? 0);
if (!$id) jsonError('ID requerido');
$stmt = db()->prepare(
'SELECT et.*,
(SELECT formulario_id FROM exam_tipo_consentimientos
WHERE exam_tipo_id = et.id LIMIT 1) AS formulario_id
FROM exam_tipos et
WHERE et.id = ?'
);
$stmt->execute([$id]);
$data = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$data) jsonError('Examen no encontrado', 404);
jsonOk(['data' => $data]);
}
requireMethod('POST');
$input = inputJson();
$id = (int)($input['id'] ?? 0);
$delete = !empty($input['_delete']);
// ── ELIMINAR ─────────────────────────────────────────────────
if ($delete) {
if (!$id) jsonError('ID requerido');
// Bloquear si tiene ítems en solicitudes activas
$stmt = db()->prepare(
'SELECT COUNT(*) FROM turnero_examen_items WHERE exam_tipo_id = ?'
);
$stmt->execute([$id]);
if ($stmt->fetchColumn() > 0) {
jsonError('No se puede eliminar: hay solicitudes que incluyen este examen. Desactívelo en su lugar.');
}
// Eliminar relación de consentimiento primero (FK)
db()->prepare('DELETE FROM exam_tipo_consentimientos WHERE exam_tipo_id = ?')->execute([$id]);
db()->prepare('DELETE FROM exam_tipos WHERE id = ?')->execute([$id]);
jsonOk([], 'Examen eliminado');
}
// ── CREAR / ACTUALIZAR ───────────────────────────────────────
$codigo = strtoupper(trim($input['codigo'] ?? ''));
$nombre = trim($input['nombre'] ?? '');
$categoria = trim($input['categoria'] ?? '');
$formId = (int)($input['formulario_id'] ?? 0) ?: null;
$activo = (int)($input['activo'] ?? 1);
if ($codigo === '') jsonError('El código es requerido');
if ($nombre === '') jsonError('El nombre es requerido');
if (strlen($codigo) > 20) jsonError('El código no puede tener más de 20 caracteres');
$pdo = db();
$pdo->beginTransaction();
try {
if ($id) {
$stmt = $pdo->prepare(
'UPDATE exam_tipos SET codigo=?, nombre=?, categoria=?, activo=? WHERE id=?'
);
$stmt->execute([$codigo, $nombre, $categoria ?: null, $activo, $id]);
$examId = $id;
} else {
$stmt = $pdo->prepare(
'INSERT INTO exam_tipos (codigo, nombre, categoria, activo) VALUES (?, ?, ?, ?)'
);
$stmt->execute([$codigo, $nombre, $categoria ?: null, $activo]);
$examId = (int)$pdo->lastInsertId();
}
// Actualizar relación formulario ↔ examen
$pdo->prepare('DELETE FROM exam_tipo_consentimientos WHERE exam_tipo_id = ?')->execute([$examId]);
if ($formId) {
$pdo->prepare(
'INSERT INTO exam_tipo_consentimientos (exam_tipo_id, formulario_id) VALUES (?, ?)'
)->execute([$examId, $formId]);
}
$pdo->commit();
jsonOk(['id' => $examId], $id ? 'Examen actualizado' : 'Examen creado');
} catch (\Throwable $e) {
$pdo->rollBack();
// Código SQLSTATE 23000 = violación de unicidad
if ($e->getCode() === '23000') {
jsonError('Ya existe un examen con ese código');
}
jsonError('Error interno: ' . $e->getMessage(), 500);
}
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* modules/turnero/api/save_lugar.php
* GET ?id=X → devuelve datos de un lugar
* POST {nombre, descripcion, sort_order, activo} → crear
* POST {id, nombre, descripcion, sort_order, activo} → actualizar
* POST {id, _delete: true} → eliminar
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
// ── GET: devolver datos de un lugar ──────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$id = (int)($_GET['id'] ?? 0);
if (!$id) jsonError('ID requerido');
$row = db()->prepare('SELECT * FROM turnero_lugares WHERE id = ?');
$row->execute([$id]);
$data = $row->fetch(PDO::FETCH_ASSOC);
if (!$data) jsonError('Lugar no encontrado', 404);
jsonOk(['data' => $data]);
}
requireMethod('POST');
$input = inputJson();
$id = (int)($input['id'] ?? 0);
$delete = !empty($input['_delete']);
// ── ELIMINAR ─────────────────────────────────────────────────
if ($delete) {
if (!$id) jsonError('ID requerido');
// Bloquear si tiene turnos asignados
$stmt = db()->prepare('SELECT COUNT(*) FROM turnero_turnos WHERE lugar_destino_id = ?');
$stmt->execute([$id]);
if ($stmt->fetchColumn() > 0) {
jsonError('No se puede eliminar: tiene turnos asignados. Desactívelo en su lugar.');
}
$stmt = db()->prepare('DELETE FROM turnero_lugares WHERE id = ?');
$stmt->execute([$id]);
jsonOk([], 'Lugar eliminado');
}
// ── CREAR / ACTUALIZAR ───────────────────────────────────────
$nombre = trim($input['nombre'] ?? '');
$desc = trim($input['descripcion'] ?? '');
$sortOrder = (int)($input['sort_order'] ?? 99);
$activo = (int)($input['activo'] ?? 1);
if ($nombre === '') jsonError('El nombre es requerido');
if ($sortOrder < 1) jsonError('El orden debe ser mayor a 0');
if ($id) {
$stmt = db()->prepare(
'UPDATE turnero_lugares SET nombre=?, descripcion=?, sort_order=?, activo=? WHERE id=?'
);
$stmt->execute([$nombre, $desc ?: null, $sortOrder, $activo, $id]);
jsonOk(['id' => $id], 'Lugar actualizado');
} else {
$stmt = db()->prepare(
'INSERT INTO turnero_lugares (nombre, descripcion, sort_order, activo) VALUES (?, ?, ?, ?)'
);
$stmt->execute([$nombre, $desc ?: null, $sortOrder, $activo]);
jsonOk(['id' => (int)db()->lastInsertId()], 'Lugar creado');
}
+34
View File
@@ -0,0 +1,34 @@
<?php
/**
* modules/turnero/api/save_prioridad.php
* POST {id, nombre, color, orden_peso, activo} → actualizar prioridad existente
* (Las prioridades no se crean ni eliminan vía API — se gestionan en la migración)
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('POST');
$input = inputJson();
$id = (int)($input['id'] ?? 0);
$nombre = trim($input['nombre'] ?? '');
$color = trim($input['color'] ?? '#6b7280');
$ordenPeso = (int)($input['orden_peso'] ?? 1);
$activo = (int)($input['activo'] ?? 1);
if (!$id) jsonError('ID requerido');
if ($nombre === '') jsonError('El nombre es requerido');
if ($ordenPeso < 1) jsonError('El orden debe ser mayor a 0');
if (!preg_match('/^#[0-9a-f]{6}$/i', $color)) jsonError('Color hexadecimal inválido (formato: #RRGGBB)');
$stmt = db()->prepare(
'UPDATE turnero_prioridades SET nombre=?, color=?, orden_peso=?, activo=? WHERE id=?'
);
$stmt->execute([$nombre, $color, $ordenPeso, $activo, $id]);
if ($stmt->rowCount() === 0) {
jsonError('Prioridad no encontrada', 404);
}
jsonOk(['id' => $id], 'Prioridad actualizada');
+236
View File
@@ -0,0 +1,236 @@
<?php
/**
* POST /modules/turnero/api/send_consentimiento.php
* Genera y/o reenvía consentimientos informados por WhatsApp para un turno.
*
* Lógica:
* 1. Lee los turnero_examen_items del turno.
* 2. Para cada exam_tipo_id busca en exam_tipo_consentimientos qué formulario_id requiere.
* 3. Deduplica: varios exámenes que apunten al mismo formulario → 1 solo envío.
* 4. Por cada formulario distinto:
* a. Si ya existe un turnero_consentimientos con ese (turno_id, formulario_id) → reutiliza / reenvía.
* b. Si no existe: genera token UUID, inserta con estado 'pendiente'.
* 5. Envía mensaje WhatsApp al celular del turno / paciente con enlace de firma.
* 6. Actualiza estado → 'enviado' y registra enviado_at.
*
* Body JSON:
* turno_id int requerido
*
* Requiere login + módulo turnero.
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
require_once __DIR__ . '/../../../services/WhatsAppService.php';
$datos = inputJson();
$turnoId = isset($datos['turno_id']) ? (int) $datos['turno_id'] : 0;
if ($turnoId <= 0) jsonError('turno_id inválido.');
$pdo = db();
// ── 1. Cargar turno y su solicitud ────────────────────────────
$stmt = $pdo->prepare(
"SELECT t.id, t.codigo, t.paciente_nombre, t.paciente_cel,
t.estado,
s.id AS solicitud_id,
s.paciente_id,
p.telefono AS pac_telefono,
p.celular AS pac_celular,
p.full_name AS pac_nombre
FROM turnero_turnos t
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
LEFT JOIN lab_pacientes p ON p.id = s.paciente_id
WHERE t.id = ?"
);
$stmt->execute([$turnoId]);
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$turno) jsonError('Turno no encontrado.', 404);
if (!$turno['solicitud_id']) jsonError('El turno no tiene solicitud registrada. Guarde la solicitud primero.', 422);
// Determinar celular de contacto (preferencia: paciente → kiosko)
$celular = $turno['pac_celular'] ?? $turno['pac_telefono'] ?? $turno['paciente_cel'] ?? null;
if (!$celular) {
jsonError('El paciente no tiene número de celular registrado. Actualice el paciente antes de enviar.', 422);
}
// Normalizar celular (quitar espacios / guiones, agregar +57 si no tiene código)
$celular = preg_replace('/[\s\-\.]/', '', $celular);
if (!str_starts_with($celular, '+')) {
// Asumir Colombia si no tiene prefijo internacional
$celular = '+57' . ltrim($celular, '0');
}
// ── 2. Obtener exámenes de la solicitud ───────────────────────
$stmt = $pdo->prepare(
"SELECT exam_tipo_id FROM turnero_examen_items WHERE solicitud_id = ?"
);
$stmt->execute([$turno['solicitud_id']]);
$examIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (empty($examIds)) {
jsonError('La solicitud no tiene exámenes registrados.', 422);
}
// ── 3. Obtener formularios de consentimiento (deduplicados) ───
$in = implode(',', array_fill(0, count($examIds), '?'));
$stmt = $pdo->prepare(
"SELECT DISTINCT etc.formulario_id, f.nombre AS formulario_nombre
FROM exam_tipo_consentimientos etc
JOIN lab_formularios f ON f.id = etc.formulario_id
WHERE etc.exam_tipo_id IN ({$in})"
);
$stmt->execute($examIds);
$formulariosRequeridos = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($formulariosRequeridos)) {
jsonOk(['consentimientos' => [], 'mensaje' => 'Ningún examen requiere consentimiento.']);
}
// ── 4. Generar / recuperar tokens UUID ────────────────────────
function generarUuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
$pdo->beginTransaction();
$consentimientosResultado = [];
$erroresEnvio = [];
try {
$stmtBuscar = $pdo->prepare(
"SELECT id, token, estado FROM turnero_consentimientos
WHERE turno_id = ? AND formulario_id = ?"
);
$stmtInsertar = $pdo->prepare(
"INSERT INTO turnero_consentimientos (turno_id, formulario_id, token, estado)
VALUES (?, ?, ?, 'pendiente')"
);
$stmtActualizar = $pdo->prepare(
"UPDATE turnero_consentimientos
SET estado = 'enviado', enviado_at = NOW()
WHERE id = ?"
);
$wa = new WhatsAppService();
// URL base del sistema (para generar el enlace de firma)
$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http')
. '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
// Si BASE_URL está definida, úsala
if (defined('BASE_URL')) {
$baseUrl = rtrim(BASE_URL, '/');
}
foreach ($formulariosRequeridos as $form) {
$formularioId = (int) $form['formulario_id'];
$formularioNom = $form['formulario_nombre'];
// Buscar si ya existe
$stmtBuscar->execute([$turnoId, $formularioId]);
$existente = $stmtBuscar->fetch(PDO::FETCH_ASSOC);
if ($existente) {
$consentId = (int) $existente['id'];
$token = $existente['token'];
// Si ya está firmado/rechazado, no reenviar
if (in_array($existente['estado'], ['firmado', 'rechazado'], true)) {
$consentimientosResultado[] = [
'id' => $consentId,
'formulario_id' => $formularioId,
'formulario_nombre'=> $formularioNom,
'estado' => $existente['estado'],
'enviado' => false,
];
continue;
}
} else {
$token = generarUuid();
$stmtInsertar->execute([$turnoId, $formularioId, $token]);
$consentId = (int) $pdo->lastInsertId();
}
// ── Construir enlace de firma ──────────────────────────
$enlaceFirma = $baseUrl . '/ver_formulario_enviado.php?token=' . urlencode($token);
// ── Nombre del paciente para el mensaje ───────────────
$nombrePac = $turno['pac_nombre'] ?? $turno['paciente_nombre'] ?? 'Paciente';
// ── Enviar mensaje WhatsApp ────────────────────────────
// Intentamos enviar con template "consentimiento_turno".
// Si el template no existe, enviamos texto plano como fallback.
$enviado = false;
try {
$wa->sendTemplateMessage(
$celular,
'consentimiento_turno',
'es',
// Parámetros del body: {{1}} = nombre, {{2}} = nombre formulario, {{3}} = código turno
[
htmlspecialchars($nombrePac, ENT_QUOTES),
htmlspecialchars($formularioNom, ENT_QUOTES),
$turno['codigo'],
],
// Parámetro del header o botón URL (URL del enlace)
[$enlaceFirma]
);
$enviado = true;
} catch (\Throwable $eTemplate) {
// Fallback: enviar mensaje de texto plano
try {
$mensajeTexto = "Hola {$nombrePac}, le informamos que para su turno *{$turno['codigo']}* "
. "debe firmar el siguiente consentimiento informado:\n\n"
. "*{$formularioNom}*\n\n"
. "Puede firmarlo en el siguiente enlace:\n{$enlaceFirma}\n\n"
. "Si ya firmó este documento presencial, ignore este mensaje.";
$wa->sendTextMessage($celular, $mensajeTexto);
$enviado = true;
} catch (\Throwable $eTexto) {
$erroresEnvio[] = "Formulario '{$formularioNom}': " . $eTexto->getMessage();
}
}
if ($enviado) {
$stmtActualizar->execute([$consentId]);
$estado = 'enviado';
} else {
$estado = 'pendiente';
}
$consentimientosResultado[] = [
'id' => $consentId,
'formulario_id' => $formularioId,
'formulario_nombre' => $formularioNom,
'token' => $token,
'estado' => $estado,
'enviado' => $enviado,
'enlace_firma' => $enlaceFirma,
];
}
$pdo->commit();
$msgExtra = !empty($erroresEnvio)
? ' (Advertencias: ' . implode('; ', $erroresEnvio) . ')'
: '';
jsonOk(
['consentimientos' => $consentimientosResultado],
count(array_filter($consentimientosResultado, fn($c) => $c['enviado']))
. ' consentimiento(s) enviado(s) por WhatsApp' . $msgExtra
);
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
jsonError('Error al procesar consentimientos: ' . $e->getMessage(), 500);
}
+103
View File
@@ -0,0 +1,103 @@
<?php
/**
* modules/turnero/api/sesion_turno.php
*
* POST {accion: 'abrir'} → Crear/abrir sesión de hoy
* POST {accion: 'cerrar', sesion_id: X} → Cerrar sesión activa
* POST {accion: 'reabrir', sesion_id: X} → Reabrir sesión (borra fin_at)
* POST {accion: 'config_wa', template, lang} → Guardar config WhatsApp turnero
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('POST');
$input = inputJson();
$accion = trim($input['accion'] ?? '');
if (!in_array($accion, ['abrir', 'cerrar', 'reabrir', 'config_wa'], true)) {
jsonError('Acción no válida');
}
$pdo = db();
$uid = adminId();
// ── ABRIR sesión del día ─────────────────────────────────────
if ($accion === 'abrir') {
$hoy = date('Y-m-d');
// Verificar si ya existe
$stmt = $pdo->prepare('SELECT id, fin_at FROM turnero_sesiones WHERE fecha = ? LIMIT 1');
$stmt->execute([$hoy]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existing) {
if ($existing['fin_at'] === null) {
jsonError('Ya hay una sesión abierta para hoy');
}
// Re-abrir (fue cerrada)
$pdo->prepare('UPDATE turnero_sesiones SET fin_at=NULL, cerrado_por=NULL WHERE id=?')
->execute([$existing['id']]);
jsonOk(['sesion_id' => $existing['id']], 'Sesión del día reabierta');
}
$stmt = $pdo->prepare(
'INSERT INTO turnero_sesiones (fecha, abierto_por, inicio_at) VALUES (?, ?, NOW())'
);
$stmt->execute([$hoy, $uid]);
jsonOk(['sesion_id' => (int)$pdo->lastInsertId()], 'Sesión del día abierta');
}
// ── CERRAR sesión ────────────────────────────────────────────
if ($accion === 'cerrar') {
$sesionId = (int)($input['sesion_id'] ?? 0);
if (!$sesionId) jsonError('sesion_id requerido');
$stmt = $pdo->prepare('SELECT id, fin_at FROM turnero_sesiones WHERE id = ?');
$stmt->execute([$sesionId]);
$s = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$s) jsonError('Sesión no encontrada', 404);
if ($s['fin_at']) jsonError('La sesión ya está cerrada');
$pdo->prepare('UPDATE turnero_sesiones SET fin_at=NOW(), cerrado_por=? WHERE id=?')
->execute([$uid, $sesionId]);
jsonOk(['sesion_id' => $sesionId], 'Sesión cerrada correctamente');
}
// ── REABRIR sesión ───────────────────────────────────────────
if ($accion === 'reabrir') {
$sesionId = (int)($input['sesion_id'] ?? 0);
if (!$sesionId) jsonError('sesion_id requerido');
$stmt = $pdo->prepare('SELECT id, fin_at FROM turnero_sesiones WHERE id = ?');
$stmt->execute([$sesionId]);
$s = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$s) jsonError('Sesión no encontrada', 404);
$pdo->prepare('UPDATE turnero_sesiones SET fin_at=NULL, cerrado_por=NULL WHERE id=?')
->execute([$sesionId]);
jsonOk(['sesion_id' => $sesionId], 'Sesión reabierta');
}
// ── CONFIG WHATSAPP ──────────────────────────────────────────
if ($accion === 'config_wa') {
$template = trim($input['template'] ?? '');
$lang = trim($input['lang'] ?? 'es_CO');
if ($template === '') jsonError('El nombre de plantilla es requerido');
if (!preg_match('/^[a-z0-9_]{1,64}$/i', $template)) {
jsonError('Nombre de plantilla inválido — solo letras, números y guiones bajos');
}
if (!preg_match('/^[a-z]{2}_[A-Z]{2}$/i', $lang)) {
jsonError('Código de idioma inválido (formato: es_CO)');
}
// Upsert en lab_config
$upsert = $pdo->prepare(
'INSERT INTO lab_config (clave, valor) VALUES (?, ?)
ON DUPLICATE KEY UPDATE valor = VALUES(valor)'
);
$upsert->execute(['turnero_wa_template', $template]);
$upsert->execute(['turnero_wa_lang', $lang]);
jsonOk([], 'Configuración WhatsApp guardada');
}
+153
View File
@@ -0,0 +1,153 @@
<?php
/**
* GET /modules/turnero/api/sse_turno.php
* Server-Sent Events — actualiza pantallas TV y kiosko en tiempo real.
* No requiere autenticación (acceso público).
*
* Query params:
* area string opcional "recepcion" | "lugar" (default: "recepcion")
* lugar_id int requerido si area = "lugar"
*
* Eventos emitidos:
* cola_update — estado completo de la cola cada vez que haya cambio o cada 30 s
* ping — comentario de keep-alive cada 15 s
*/
// ── Asegurar que la columna sse_ping_at exista (idempotente) ──
require_once __DIR__ . '/_helpers.php';
$pdo = db();
$pdo->exec(
"ALTER TABLE turnero_sesiones
ADD COLUMN IF NOT EXISTS sse_ping_at DATETIME NULL DEFAULT NULL"
);
// ── Headers SSE ───────────────────────────────────────────────
@set_time_limit(0);
@ini_set('output_buffering', 'off');
@ini_set('zlib.output_compression', false);
header('Content-Type: text/event-stream; charset=UTF-8');
header('Cache-Control: no-store, no-cache');
header('X-Accel-Buffering: no'); // Nginx: deshabilitar buffering
header('Connection: keep-alive');
// ── Parámetros ────────────────────────────────────────────────
$area = isset($_GET['area']) ? trim($_GET['area']) : 'recepcion';
$lugarId = isset($_GET['lugar_id']) ? (int) $_GET['lugar_id'] : null;
if (!in_array($area, ['recepcion', 'lugar'], true)) {
$area = 'recepcion';
}
// ── Helpers de emisión ────────────────────────────────────────
function sseEvent(string $event, mixed $payload): void
{
echo "event: {$event}\n";
echo 'data: ' . json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
}
function ssePing(): void
{
echo ": ping\n\n";
flush();
}
// ── Función que arma el snapshot de la cola ───────────────────
function buildSnapshot(PDO $pdo, int $sesionId, string $area, ?int $lugarId): array
{
if ($area === 'recepcion') {
$estadosCola = ["'espera'", "'en_recepcion'"];
$filtroLugar = '';
$bindsCola = [$sesionId];
$estadoActivo = "'en_recepcion'";
$campoLlamado = 'llamado_recepcion_at';
$bindActivo = [$sesionId];
$filtroActivo = '';
} else {
$estadosCola = ["'en_espera_lugar'", "'en_servicio'"];
$filtroLugar = 'AND t.lugar_destino_id = ?';
$bindsCola = [$sesionId, $lugarId];
$estadoActivo = "'en_servicio'";
$campoLlamado = 'llamado_lugar_at';
$bindActivo = [$sesionId, $lugarId];
$filtroActivo = 'AND t.lugar_destino_id = ?';
}
$inEstados = implode(', ', $estadosCola);
$stmt = $pdo->prepare(
"SELECT t.id, t.codigo, t.numero, t.estado, t.paciente_nombre, t.creado_at,
p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre,
p.color AS prioridad_color, p.orden_peso
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.sesion_id = ?
AND t.estado IN ({$inEstados})
{$filtroLugar}
ORDER BY p.orden_peso ASC, t.creado_at ASC"
);
$stmt->execute($bindsCola);
$cola = $stmt->fetchAll(PDO::FETCH_ASSOC);
$stmt = $pdo->prepare(
"SELECT t.id, t.codigo, t.numero, t.estado, t.paciente_nombre,
t.{$campoLlamado} AS llamado_at,
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.sesion_id = ?
AND t.estado = {$estadoActivo}
{$filtroActivo}
ORDER BY t.{$campoLlamado} DESC
LIMIT 1"
);
$stmt->execute($bindActivo);
$activo = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
return [
'activo' => $activo,
'cola' => $cola,
'timestamp' => date('c'),
];
}
// ── Loop principal ────────────────────────────────────────────
$sesionId = obtenerOCrearSesionHoy();
$lastPing = null; // sse_ping_at de la última iteración
$lastKeepalive = time();
$lastEmit = 0;
// Emitir estado inicial inmediatamente
$snapshot = buildSnapshot($pdo, $sesionId, $area, $lugarId);
sseEvent('cola_update', $snapshot);
while (!connection_aborted()) {
sleep(2);
// Leer el último sse_ping_at de la sesión
$stmt = $pdo->prepare('SELECT sse_ping_at FROM turnero_sesiones WHERE id = ?');
$stmt->execute([$sesionId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$pingActual = $row['sse_ping_at'] ?? null;
// Emitir si hubo cambio (notificarSSE actualiza sse_ping_at) o cada 30 s
$ahora = time();
$forzar = ($ahora - $lastEmit) >= 30;
if ($pingActual !== $lastPing || $forzar) {
$snapshot = buildSnapshot($pdo, $sesionId, $area, $lugarId);
sseEvent('cola_update', $snapshot);
$lastPing = $pingActual;
$lastEmit = $ahora;
}
// Keep-alive cada 15 s
if (($ahora - $lastKeepalive) >= 15) {
ssePing();
$lastKeepalive = $ahora;
}
}
+857
View File
@@ -0,0 +1,857 @@
<?php
/**
* modules/turnero/views/configuracion.php
* Panel de configuración del módulo Turnero — 4 pestañas:
* TAB 1: Lugares / estaciones
* TAB 2: Exámenes y Consentimientos
* TAB 3: Prioridades
* TAB 4: Sesión y WhatsApp
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
$db = Database::getInstance();
$pdo = $db->getConnection();
// ── Cargar datos para los tabs ────────────────────────────────
$lugares = $pdo->query(
'SELECT * FROM turnero_lugares ORDER BY sort_order ASC, id ASC'
)->fetchAll(PDO::FETCH_ASSOC);
$prioridades = $pdo->query(
'SELECT * FROM turnero_prioridades ORDER BY orden_peso ASC'
)->fetchAll(PDO::FETCH_ASSOC);
$examTipos = $pdo->query(
'SELECT et.*, GROUP_CONCAT(etc.formulario_id) AS form_ids
FROM exam_tipos et
LEFT JOIN exam_tipo_consentimientos etc ON etc.exam_tipo_id = et.id
GROUP BY et.id
ORDER BY et.categoria ASC, et.nombre ASC'
)->fetchAll(PDO::FETCH_ASSOC);
$formulariosCons = $pdo->query(
"SELECT id, nombre FROM lab_formularios WHERE tipo='consentimiento' AND activo=1 ORDER BY nombre ASC"
)->fetchAll(PDO::FETCH_ASSOC);
// Historial de sesiones (últimas 30)
$sesiones = $pdo->query(
'SELECT s.*,
a1.full_name AS abierto_nombre,
a2.full_name AS cerrado_nombre
FROM turnero_sesiones s
LEFT JOIN admin_users a1 ON a1.id = s.abierto_por
LEFT JOIN admin_users a2 ON a2.id = s.cerrado_por
ORDER BY s.fecha DESC
LIMIT 30'
)->fetchAll(PDO::FETCH_ASSOC);
// Sesión abierta hoy
$sesionHoy = $pdo->prepare(
"SELECT s.*,
(SELECT COUNT(*) FROM turnero_turnos WHERE sesion_id = s.id) AS total_turnos
FROM turnero_sesiones s
WHERE s.fecha = CURDATE()"
);
$sesionHoy->execute();
$sesionHoy = $sesionHoy->fetch(PDO::FETCH_ASSOC);
// Config WhatsApp del turnero
$cfgRows = $pdo->query("SELECT clave, valor FROM lab_config WHERE clave LIKE 'turnero_%'")->fetchAll(PDO::FETCH_ASSOC);
$cfg = [];
foreach ($cfgRows as $r) { $cfg[$r['clave']] = $r['valor']; }
$tab = $_GET['tab'] ?? 'lugares';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Configuración Turnero</title>
<link rel="stylesheet" href="<?= BASE_URL ?>assets/css/bootstrap.min.css">
<link rel="stylesheet" href="<?= BASE_URL ?>assets/css/fontawesome.min.css">
<style>
body { background: #f1f5f9; }
.page-header { background:#fff; border-bottom:1px solid #e2e8f0; padding:16px 24px; display:flex; align-items:center; gap:12px; }
.page-header h1 { font-size:1.25rem; font-weight:700; color:#1e293b; margin:0; }
.page-header .back-btn { color:#64748b; text-decoration:none; font-size:.9rem; }
.page-header .back-btn:hover { color:#0f172a; }
.content-wrap { max-width:1000px; margin:24px auto; padding:0 16px; }
.nav-tabs .nav-link { color:#475569; font-weight:500; }
.nav-tabs .nav-link.active { color:#1565c0; border-bottom:2px solid #1565c0; }
.tab-card { background:#fff; border:1px solid #e2e8f0; border-top:none; border-radius:0 0 10px 10px; padding:24px; }
.section-title { font-size:.75rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:#64748b; margin:0 0 14px; }
.item-row { display:flex; align-items:center; gap:8px; padding:10px 0; border-bottom:1px solid #f1f5f9; }
.item-row:last-child { border-bottom:none; }
.item-row .item-name { flex:1; font-weight:500; color:#1e293b; }
.btn-icon { border:none; background:none; color:#94a3b8; cursor:pointer; padding:4px 6px; border-radius:6px; }
.btn-icon:hover { background:#f1f5f9; color:#475569; }
.badge-color { width:14px; height:14px; border-radius:50%; display:inline-block; flex-shrink:0; }
.form-add { background:#f8fafc; border:1.5px dashed #cbd5e1; border-radius:8px; padding:16px; margin-top:16px; }
.url-box { font-family:monospace; font-size:.78rem; background:#f0f4ff; border:1px solid #c7d2fe; border-radius:6px; padding:6px 10px; color:#3730a3; word-break:break-all; }
.url-row { display:flex; align-items:center; gap:8px; }
.toast-stack { position:fixed; bottom:24px; right:24px; z-index:9999; display:flex; flex-direction:column; gap:8px; }
.session-badge { font-size:.75rem; padding:2px 8px; border-radius:99px; font-weight:600; }
.session-open { background:#dcfce7; color:#15803d; }
.session-closed{ background:#f1f5f9; color:#64748b; }
.consent-tag { font-size:.7rem; background:#ede9fe; color:#5b21b6; border-radius:99px; padding:1px 7px; white-space:nowrap; }
</style>
</head>
<body>
<div class="page-header">
<a href="<?= BASE_URL ?>modules/turnero/views/recepcion.php" class="back-btn">
<i class="fas fa-arrow-left me-1"></i>Volver
</a>
<h1><i class="fas fa-cogs me-2 text-primary"></i>Configuración del Turnero</h1>
</div>
<div class="content-wrap">
<ul class="nav nav-tabs" id="cfgTabs">
<li class="nav-item">
<a class="nav-link <?= $tab==='lugares'?'active':'' ?>" href="?tab=lugares">
<i class="fas fa-map-marker-alt me-1"></i>Lugares
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $tab==='examenes'?'active':'' ?>" href="?tab=examenes">
<i class="fas fa-flask me-1"></i>Exámenes y Consentimientos
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $tab==='prioridades'?'active':'' ?>" href="?tab=prioridades">
<i class="fas fa-sort-amount-up me-1"></i>Prioridades
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $tab==='sesion'?'active':'' ?>" href="?tab=sesion">
<i class="fas fa-calendar-day me-1"></i>Sesión y WhatsApp
</a>
</li>
</ul>
<div class="tab-card">
<!-- ════════════════════════════════════════
TAB 1 — LUGARES
════════════════════════════════════════ -->
<?php if ($tab === 'lugares'): ?>
<p class="section-title">Estaciones de servicio configurables</p>
<div id="lista-lugares">
<?php foreach ($lugares as $lu): ?>
<div class="item-row" data-id="<?= $lu['id'] ?>">
<span class="drag-handle text-muted me-1" style="cursor:grab" title="Reordenar">
<i class="fas fa-grip-vertical"></i>
</span>
<span class="item-name"><?= htmlspecialchars($lu['nombre']) ?></span>
<?php if ($lu['descripcion']): ?>
<small class="text-muted d-none d-md-inline"><?= htmlspecialchars($lu['descripcion']) ?></small>
<?php endif; ?>
<span class="badge <?= $lu['activo'] ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary' ?> ms-1">
<?= $lu['activo'] ? 'Activo' : 'Inactivo' ?>
</span>
<button class="btn-icon" onclick="copiarUrlDisplay(<?= $lu['id'] ?>)" title="Copiar URL pantalla TV">
<i class="fas fa-tv"></i>
</button>
<button class="btn-icon text-primary" onclick="editarLugar(<?= $lu['id'] ?>,'<?= addslashes($lu['nombre']) ?>','<?= addslashes($lu['descripcion'] ?? '') ?>',<?= $lu['activo'] ?>,<?= $lu['sort_order'] ?>)" title="Editar">
<i class="fas fa-pencil-alt"></i>
</button>
<button class="btn-icon text-danger" onclick="eliminarLugar(<?= $lu['id'] ?>, '<?= addslashes($lu['nombre']) ?>')" title="Eliminar">
<i class="fas fa-trash"></i>
</button>
</div>
<?php endforeach; ?>
<?php if (empty($lugares)): ?>
<p class="text-muted text-center py-3">No hay lugares configurados</p>
<?php endif; ?>
</div>
<div class="form-add mt-4">
<p class="section-title mb-3"><i class="fas fa-plus me-1"></i>Agregar lugar</p>
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small fw-semibold">Nombre <span class="text-danger">*</span></label>
<input type="text" id="lu-nombre" class="form-control form-control-sm" placeholder="Toma de Muestras 3">
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Descripción</label>
<input type="text" id="lu-desc" class="form-control form-control-sm" placeholder="Opcional">
</div>
<div class="col-md-2">
<label class="form-label small fw-semibold">Orden</label>
<input type="number" id="lu-orden" class="form-control form-control-sm" value="<?= count($lugares) + 1 ?>" min="1">
</div>
<div class="col-md-2">
<button class="btn btn-primary btn-sm w-100" onclick="guardarLugar()">
<i class="fas fa-save me-1"></i>Guardar
</button>
</div>
</div>
</div>
<!-- Modal editar lugar -->
<div class="modal fade" id="modalEditarLugar" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2 px-3">
<h6 class="modal-title">Editar lugar</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-lu-id">
<div class="mb-2">
<label class="form-label small fw-semibold">Nombre</label>
<input type="text" id="edit-lu-nombre" class="form-control form-control-sm">
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Descripción</label>
<input type="text" id="edit-lu-desc" class="form-control form-control-sm">
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Orden</label>
<input type="number" id="edit-lu-orden" class="form-control form-control-sm" min="1">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit-lu-activo">
<label class="form-check-label small" for="edit-lu-activo">Activo</label>
</div>
</div>
<div class="modal-footer py-2 px-3">
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-primary btn-sm" onclick="guardarEditarLugar()">Guardar</button>
</div>
</div>
</div>
</div>
<!-- URL panel TV por lugar -->
<div class="mt-4">
<p class="section-title"><i class="fas fa-desktop me-1"></i>URLs de pantalla TV por lugar</p>
<?php foreach ($lugares as $lu): ?>
<div class="mb-2">
<small class="fw-semibold text-muted d-block mb-1"><?= htmlspecialchars($lu['nombre']) ?></small>
<div class="url-row">
<div class="url-box flex-1"
id="url-lugar-<?= $lu['id'] ?>"
data-url="<?= htmlspecialchars(BASE_URL . 'modules/turnero/views/display.php?display=lugar&lugar_id=' . $lu['id']) ?>"
><?= htmlspecialchars(BASE_URL . 'modules/turnero/views/display.php?display=lugar&lugar_id=' . $lu['id']) ?></div>
<button class="btn btn-outline-secondary btn-sm" onclick="copiarUrl('url-lugar-<?= $lu['id'] ?>')">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<?php endforeach; ?>
<div class="mb-2 mt-3">
<small class="fw-semibold text-muted d-block mb-1">Recepción (pantalla general)</small>
<div class="url-row">
<div class="url-box flex-1"
id="url-recepcion"
data-url="<?= htmlspecialchars(BASE_URL . 'modules/turnero/views/display.php?display=recepcion') ?>"
><?= htmlspecialchars(BASE_URL . 'modules/turnero/views/display.php?display=recepcion') ?></div>
<button class="btn btn-outline-secondary btn-sm" onclick="copiarUrl('url-recepcion')">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="mb-2 mt-1">
<small class="fw-semibold text-muted d-block mb-1">Kiosko de turnos (tablet/pantalla entrada)</small>
<div class="url-row">
<div class="url-box flex-1"
id="url-kiosko"
data-url="<?= htmlspecialchars(BASE_URL . 'modules/turnero/views/kiosko.php') ?>"
><?= htmlspecialchars(BASE_URL . 'modules/turnero/views/kiosko.php') ?></div>
<button class="btn btn-outline-secondary btn-sm" onclick="copiarUrl('url-kiosko')">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</div>
<!-- ════════════════════════════════════════
TAB 2 — EXÁMENES Y CONSENTIMIENTOS
════════════════════════════════════════ -->
<?php elseif ($tab === 'examenes'): ?>
<p class="section-title">Tipos de examen y sus formularios de consentimiento</p>
<?php
// Agrupar por categoría
$categorias = [];
foreach ($examTipos as $et) {
$cat = $et['categoria'] ?: 'Sin categoría';
$categorias[$cat][] = $et;
}
?>
<?php foreach ($categorias as $cat => $items): ?>
<div class="mb-3">
<div class="fw-semibold text-uppercase small text-secondary mb-1" style="font-size:.7rem;letter-spacing:.07em">
<i class="fas fa-tag me-1"></i><?= htmlspecialchars($cat) ?>
</div>
<?php foreach ($items as $et):
$formIds = $et['form_ids'] ? array_map('intval', explode(',', $et['form_ids'])) : [];
?>
<div class="item-row" data-exam-id="<?= $et['id'] ?>">
<span class="item-name">
<span class="badge bg-primary-subtle text-primary me-2" style="font-size:.7rem"><?= htmlspecialchars($et['codigo']) ?></span>
<?= htmlspecialchars($et['nombre']) ?>
</span>
<?php if (!$et['activo']): ?>
<span class="badge bg-secondary-subtle text-secondary" style="font-size:.65rem">Inactivo</span>
<?php endif; ?>
<?php
// Etiquetas de consentimientos vinculados
foreach ($formIds as $fid):
$fNombre = '';
foreach ($formulariosCons as $fc) {
if ($fc['id'] == $fid) { $fNombre = $fc['nombre']; break; }
}
?>
<span class="consent-tag"><i class="fas fa-file-signature me-1"></i><?= htmlspecialchars($fNombre ?: 'Form #'.$fid) ?></span>
<?php endforeach; ?>
<button class="btn-icon text-primary" onclick="editarExamen(<?= $et['id'] ?>)" title="Editar">
<i class="fas fa-pencil-alt"></i>
</button>
<button class="btn-icon text-danger" onclick="eliminarExamen(<?= $et['id'] ?>, '<?= addslashes($et['nombre']) ?>')" title="Eliminar">
<i class="fas fa-trash"></i>
</button>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<?php if (empty($examTipos)): ?>
<p class="text-muted text-center py-3">No hay tipos de examen configurados</p>
<?php endif; ?>
<div class="form-add mt-4">
<p class="section-title mb-3"><i class="fas fa-plus me-1"></i>Agregar tipo de examen</p>
<div class="row g-2">
<div class="col-md-2">
<label class="form-label small fw-semibold">Código <span class="text-danger">*</span></label>
<input type="text" id="ex-codigo" class="form-control form-control-sm" placeholder="HEM" maxlength="20">
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Nombre <span class="text-danger">*</span></label>
<input type="text" id="ex-nombre" class="form-control form-control-sm" placeholder="Hemograma completo">
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold">Categoría</label>
<input type="text" id="ex-categoria" class="form-control form-control-sm" placeholder="Hematología">
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold">Consentimiento</label>
<select id="ex-form" class="form-select form-select-sm">
<option value="">— Sin consentimiento —</option>
<?php foreach ($formulariosCons as $fc): ?>
<option value="<?= $fc['id'] ?>"><?= htmlspecialchars($fc['nombre']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 text-end">
<button class="btn btn-primary btn-sm" onclick="guardarExamen()">
<i class="fas fa-save me-1"></i>Guardar
</button>
</div>
</div>
</div>
<?php if (!empty($formulariosCons)): ?>
<div class="mt-4">
<p class="section-title"><i class="fas fa-file-signature me-1"></i>Vista inversa: por consentimiento</p>
<?php foreach ($formulariosCons as $fc):
$exsVinculados = [];
foreach ($examTipos as $et) {
$fids = $et['form_ids'] ? array_map('intval', explode(',', $et['form_ids'])) : [];
if (in_array((int)$fc['id'], $fids)) $exsVinculados[] = $et;
}
?>
<div class="mb-2">
<span class="fw-semibold small"><?= htmlspecialchars($fc['nombre']) ?></span>
<span class="text-muted small ms-2">
<?= empty($exsVinculados)
? '<em>Sin exámenes vinculados</em>'
: implode(', ', array_map(fn($e)=>'<code>'.$e['codigo'].'</code>', $exsVinculados))
?>
</span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Modal editar examen -->
<div class="modal fade" id="modalEditarExamen" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header py-2 px-3">
<h6 class="modal-title">Editar tipo de examen</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-ex-id">
<div class="row g-2">
<div class="col-md-3">
<label class="form-label small fw-semibold">Código</label>
<input type="text" id="edit-ex-codigo" class="form-control form-control-sm" maxlength="20">
</div>
<div class="col-md-9">
<label class="form-label small fw-semibold">Nombre</label>
<input type="text" id="edit-ex-nombre" class="form-control form-control-sm">
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Categoría</label>
<input type="text" id="edit-ex-categoria" class="form-control form-control-sm">
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Consentimiento</label>
<select id="edit-ex-form" class="form-select form-select-sm">
<option value="">— Sin consentimiento —</option>
<?php foreach ($formulariosCons as $fc): ?>
<option value="<?= $fc['id'] ?>"><?= htmlspecialchars($fc['nombre']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit-ex-activo">
<label class="form-check-label small" for="edit-ex-activo">Activo</label>
</div>
</div>
</div>
</div>
<div class="modal-footer py-2 px-3">
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-primary btn-sm" onclick="guardarEditarExamen()">Guardar</button>
</div>
</div>
</div>
</div>
<!-- ════════════════════════════════════════
TAB 3 — PRIORIDADES
════════════════════════════════════════ -->
<?php elseif ($tab === 'prioridades'): ?>
<p class="section-title">Códigos de prioridad (menor orden_peso = mayor prioridad)</p>
<div id="lista-prioridades">
<?php foreach ($prioridades as $pr): ?>
<div class="item-row" data-id="<?= $pr['id'] ?>">
<span class="badge-color me-2" style="background:<?= htmlspecialchars($pr['color']) ?>"></span>
<span class="fw-bold me-2" style="min-width:28px; color:<?= htmlspecialchars($pr['color']) ?>"><?= htmlspecialchars($pr['codigo']) ?></span>
<span class="item-name"><?= htmlspecialchars($pr['nombre']) ?></span>
<small class="text-muted me-2">Orden: <?= $pr['orden_peso'] ?></small>
<span class="badge <?= $pr['activo'] ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary' ?>">
<?= $pr['activo'] ? 'Activo' : 'Inactivo' ?>
</span>
<button class="btn-icon text-primary" onclick="editarPrioridad(<?= $pr['id'] ?>,'<?= addslashes($pr['codigo']) ?>','<?= addslashes($pr['nombre']) ?>','<?= htmlspecialchars($pr['color']) ?>',<?= $pr['orden_peso'] ?>,<?= $pr['activo'] ?>)" title="Editar">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
<?php endforeach; ?>
</div>
<p class="text-muted small mt-2">
<i class="fas fa-info-circle me-1"></i>
Los códigos de prioridad no se pueden eliminar (están vinculados a turnos). Solo puede editarlos.
</p>
<!-- Modal editar prioridad -->
<div class="modal fade" id="modalEditarPrioridad" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2 px-3">
<h6 class="modal-title">Editar prioridad</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-pr-id">
<div class="mb-2">
<label class="form-label small fw-semibold">Código</label>
<input type="text" id="edit-pr-codigo" class="form-control form-control-sm" maxlength="1" readonly>
<small class="text-muted">El código no puede cambiarse</small>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Nombre</label>
<input type="text" id="edit-pr-nombre" class="form-control form-control-sm">
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label small fw-semibold">Color</label>
<div class="input-group input-group-sm">
<input type="color" id="edit-pr-color" class="form-control form-control-color" style="width:40px;padding:2px">
<input type="text" id="edit-pr-color-text" class="form-control form-control-sm" maxlength="7" placeholder="#6b7280">
</div>
</div>
<div class="col-6">
<label class="form-label small fw-semibold">Orden</label>
<input type="number" id="edit-pr-orden" class="form-control form-control-sm" min="1">
</div>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="edit-pr-activo">
<label class="form-check-label small" for="edit-pr-activo">Activo</label>
</div>
</div>
<div class="modal-footer py-2 px-3">
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-primary btn-sm" onclick="guardarPrioridad()">Guardar</button>
</div>
</div>
</div>
</div>
<!-- ════════════════════════════════════════
TAB 4 — SESIÓN Y WHATSAPP
════════════════════════════════════════ -->
<?php elseif ($tab === 'sesion'): ?>
<!-- Sesión de hoy -->
<div class="mb-4">
<p class="section-title"><i class="fas fa-calendar-day me-1"></i>Sesión de hoy</p>
<?php if ($sesionHoy): ?>
<div class="d-flex align-items-center gap-3 flex-wrap">
<div>
<span class="session-badge <?= $sesionHoy['fin_at'] ? 'session-closed' : 'session-open' ?>">
<?= $sesionHoy['fin_at'] ? 'Cerrada' : 'Abierta' ?>
</span>
</div>
<div class="small text-muted">
Fecha: <strong><?= htmlspecialchars($sesionHoy['fecha']) ?></strong> &bull;
Turnos: <strong><?= $sesionHoy['total_turnos'] ?></strong>
<?php if ($sesionHoy['inicio_at']): ?>
&bull; Inicio: <strong><?= date('H:i', strtotime($sesionHoy['inicio_at'])) ?></strong>
<?php endif; ?>
<?php if ($sesionHoy['fin_at']): ?>
&bull; Cierre: <strong><?= date('H:i', strtotime($sesionHoy['fin_at'])) ?></strong>
<?php endif; ?>
</div>
<?php if (!$sesionHoy['fin_at']): ?>
<button class="btn btn-danger btn-sm" onclick="cambiarSesion('cerrar',<?= $sesionHoy['id'] ?>)">
<i class="fas fa-lock me-1"></i>Cerrar sesión del día
</button>
<?php else: ?>
<button class="btn btn-success btn-sm" onclick="cambiarSesion('reabrir',<?= $sesionHoy['id'] ?>)">
<i class="fas fa-lock-open me-1"></i>Reabrir sesión
</button>
<?php endif; ?>
</div>
<?php else: ?>
<p class="text-muted">No hay sesión creada para hoy.</p>
<button class="btn btn-success btn-sm" onclick="cambiarSesion('abrir',0)">
<i class="fas fa-play me-1"></i>Abrir sesión de hoy
</button>
<?php endif; ?>
</div>
<!-- Historial de sesiones -->
<div class="mb-4">
<p class="section-title"><i class="fas fa-history me-1"></i>Historial de sesiones (últimas 30)</p>
<?php if (empty($sesiones)): ?>
<p class="text-muted">Sin historial</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm table-hover small">
<thead class="table-light">
<tr>
<th>Fecha</th>
<th>Inicio</th>
<th>Cierre</th>
<th>Abierto por</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
<?php foreach ($sesiones as $s): ?>
<tr>
<td><?= htmlspecialchars($s['fecha']) ?></td>
<td><?= $s['inicio_at'] ? date('H:i', strtotime($s['inicio_at'])) : '—' ?></td>
<td><?= $s['fin_at'] ? date('H:i', strtotime($s['fin_at'])) : '—' ?></td>
<td><?= htmlspecialchars($s['abierto_nombre'] ?? '—') ?></td>
<td>
<span class="session-badge <?= $s['fin_at'] ? 'session-closed' : 'session-open' ?>">
<?= $s['fin_at'] ? 'Cerrada' : 'Abierta' ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<!-- Config WhatsApp turnero -->
<div>
<p class="section-title"><i class="fab fa-whatsapp me-1" style="color:#25d366"></i>Plantilla de consentimiento WhatsApp</p>
<div class="row g-2">
<div class="col-md-6">
<label class="form-label small fw-semibold">Nombre de plantilla Meta aprobada</label>
<input type="text" id="wa-template" class="form-control form-control-sm"
value="<?= htmlspecialchars($cfg['turnero_wa_template'] ?? 'consentimiento_turno') ?>"
placeholder="consentimiento_turno">
<small class="text-muted">Nombre exacto en Meta Business Manager</small>
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold">Código de idioma</label>
<input type="text" id="wa-lang" class="form-control form-control-sm"
value="<?= htmlspecialchars($cfg['turnero_wa_lang'] ?? 'es_CO') ?>"
placeholder="es_CO">
</div>
<div class="col-md-3 d-flex align-items-end">
<button class="btn btn-primary btn-sm w-100" onclick="guardarWhatsApp()">
<i class="fas fa-save me-1"></i>Guardar
</button>
</div>
</div>
<div class="alert alert-info py-2 px-3 mt-3" style="font-size:.82rem">
<i class="fas fa-info-circle me-1"></i>
La plantilla debe estar aprobada en Meta y contener una variable de URL
(<code>{{1}}</code>) para el enlace de firma del consentimiento.
El sistema usará texto plano como fallback si la plantilla falla.
</div>
</div>
<?php endif; ?>
</div><!-- /tab-card -->
</div><!-- /content-wrap -->
<!-- Toast stack -->
<div class="toast-stack" id="toastStack"></div>
<script src="<?= BASE_URL ?>assets/js/bootstrap.bundle.min.js"></script>
<script>
/* ═══════════════════════════════════════════════════════════
Configuración Turnero — JS
═══════════════════════════════════════════════════════════ */
const API = '<?= BASE_URL ?>modules/turnero/api/';
// ── Toast helper ────────────────────────────────────────────
function toast(msg, type = 'success') {
const stack = document.getElementById('toastStack');
const el = document.createElement('div');
el.className = `toast align-items-center text-white bg-${type === 'error' ? 'danger' : 'success'} border-0`;
el.setAttribute('role', 'alert');
el.innerHTML = `<div class="d-flex"><div class="toast-body">${msg}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
stack.appendChild(el);
new bootstrap.Toast(el, { delay: 3500 }).show();
el.addEventListener('hidden.bs.toast', () => el.remove());
}
// ── Copiar URL al portapapeles ───────────────────────────────
function copiarUrl(elId) {
const url = document.getElementById(elId)?.dataset?.url || document.getElementById(elId)?.textContent || '';
navigator.clipboard.writeText(url.trim()).then(() => toast('URL copiada al portapapeles'));
}
function copiarUrlDisplay(lugarId) {
copiarUrl('url-lugar-' + lugarId);
}
// ─────────────────────────────────────────────────────────────
// TAB 1: LUGARES
// ─────────────────────────────────────────────────────────────
async function guardarLugar(id = null) {
const nombre = (id ? document.getElementById('edit-lu-nombre') : document.getElementById('lu-nombre'))?.value.trim();
const desc = (id ? document.getElementById('edit-lu-desc') : document.getElementById('lu-desc'))?.value.trim();
const orden = parseInt((id ? document.getElementById('edit-lu-orden') : document.getElementById('lu-orden'))?.value) || 99;
const activo = id ? (document.getElementById('edit-lu-activo')?.checked ? 1 : 0) : 1;
if (!nombre) { toast('El nombre es requerido', 'error'); return; }
const body = { nombre, descripcion: desc, sort_order: orden, activo };
if (id) body.id = id;
try {
const res = await fetch(API + 'save_lugar.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
toast(id ? 'Lugar actualizado' : 'Lugar creado');
if (id) bootstrap.Modal.getInstance(document.getElementById('modalEditarLugar'))?.hide();
setTimeout(() => location.reload(), 600);
} catch (e) { toast('Error de conexión', 'error'); }
}
function editarLugar(id, nombre, desc, activo, orden) {
document.getElementById('edit-lu-id').value = id;
document.getElementById('edit-lu-nombre').value = nombre;
document.getElementById('edit-lu-desc').value = desc;
document.getElementById('edit-lu-orden').value = orden;
document.getElementById('edit-lu-activo').checked = !!activo;
new bootstrap.Modal(document.getElementById('modalEditarLugar')).show();
}
function guardarEditarLugar() {
guardarLugar(parseInt(document.getElementById('edit-lu-id').value));
}
async function eliminarLugar(id, nombre) {
if (!confirm(`¿Eliminar el lugar "${nombre}"?\nSolo se puede si no tiene turnos asignados.`)) return;
try {
const res = await fetch(API + 'save_lugar.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, _delete: true })
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error al eliminar', 'error'); return; }
toast('Lugar eliminado');
setTimeout(() => location.reload(), 600);
} catch (e) { toast('Error de conexión', 'error'); }
}
// ─────────────────────────────────────────────────────────────
// TAB 2: EXÁMENES
// ─────────────────────────────────────────────────────────────
async function guardarExamen(id = null) {
const codigo = (id ? document.getElementById('edit-ex-codigo') : document.getElementById('ex-codigo'))?.value.trim().toUpperCase();
const nombre = (id ? document.getElementById('edit-ex-nombre') : document.getElementById('ex-nombre'))?.value.trim();
const categoria = (id ? document.getElementById('edit-ex-categoria'): document.getElementById('ex-categoria'))?.value.trim();
const formId = parseInt((id ? document.getElementById('edit-ex-form') : document.getElementById('ex-form'))?.value) || null;
const activo = id ? (document.getElementById('edit-ex-activo')?.checked ? 1 : 0) : 1;
if (!codigo || !nombre) { toast('Código y nombre son requeridos', 'error'); return; }
const body = { codigo, nombre, categoria, formulario_id: formId, activo };
if (id) body.id = id;
try {
const res = await fetch(API + 'save_ex_tipo.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
toast(id ? 'Examen actualizado' : 'Examen creado');
if (id) bootstrap.Modal.getInstance(document.getElementById('modalEditarExamen'))?.hide();
setTimeout(() => location.reload(), 600);
} catch (e) { toast('Error de conexión', 'error'); }
}
function editarExamen(id) {
const row = document.querySelector(`[data-exam-id="${id}"]`);
const tags = row ? row.querySelectorAll('.consent-tag') : [];
// Leer datos de los atributos del servidor (via fetch de datos ya cargados)
fetch(API + 'save_ex_tipo.php?id=' + id)
.then(r => r.json())
.then(json => {
if (!json.ok) return;
const d = json.data;
document.getElementById('edit-ex-id').value = d.id;
document.getElementById('edit-ex-codigo').value = d.codigo;
document.getElementById('edit-ex-nombre').value = d.nombre;
document.getElementById('edit-ex-categoria').value= d.categoria || '';
document.getElementById('edit-ex-form').value = d.formulario_id || '';
document.getElementById('edit-ex-activo').checked = !!d.activo;
new bootstrap.Modal(document.getElementById('modalEditarExamen')).show();
})
.catch(() => toast('Error cargando datos', 'error'));
}
function guardarEditarExamen() {
guardarExamen(parseInt(document.getElementById('edit-ex-id').value));
}
async function eliminarExamen(id, nombre) {
if (!confirm(`¿Eliminar el examen "${nombre}"?`)) return;
try {
const res = await fetch(API + 'save_ex_tipo.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, _delete: true })
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
toast('Examen eliminado');
setTimeout(() => location.reload(), 600);
} catch (e) { toast('Error de conexión', 'error'); }
}
// ─────────────────────────────────────────────────────────────
// TAB 3: PRIORIDADES
// ─────────────────────────────────────────────────────────────
function editarPrioridad(id, codigo, nombre, color, orden, activo) {
document.getElementById('edit-pr-id').value = id;
document.getElementById('edit-pr-codigo').value = codigo;
document.getElementById('edit-pr-nombre').value = nombre;
document.getElementById('edit-pr-color').value = color;
document.getElementById('edit-pr-color-text').value = color;
document.getElementById('edit-pr-orden').value = orden;
document.getElementById('edit-pr-activo').checked = !!activo;
new bootstrap.Modal(document.getElementById('modalEditarPrioridad')).show();
}
// Sincronizar input color ↔ texto hex
document.addEventListener('DOMContentLoaded', () => {
const colorPicker = document.getElementById('edit-pr-color');
const colorText = document.getElementById('edit-pr-color-text');
if (colorPicker && colorText) {
colorPicker.addEventListener('input', () => { colorText.value = colorPicker.value; });
colorText.addEventListener('input', () => {
if (/^#[0-9a-f]{6}$/i.test(colorText.value)) {
colorPicker.value = colorText.value;
}
});
}
});
async function guardarPrioridad() {
const id = parseInt(document.getElementById('edit-pr-id').value);
const nombre = document.getElementById('edit-pr-nombre').value.trim();
const color = document.getElementById('edit-pr-color-text').value.trim() || document.getElementById('edit-pr-color').value;
const orden = parseInt(document.getElementById('edit-pr-orden').value) || 1;
const activo = document.getElementById('edit-pr-activo').checked ? 1 : 0;
if (!nombre) { toast('El nombre es requerido', 'error'); return; }
if (!id) { toast('ID no válido', 'error'); return; }
try {
const res = await fetch(API + 'save_prioridad.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, nombre, color, orden_peso: orden, activo })
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
toast('Prioridad actualizada');
bootstrap.Modal.getInstance(document.getElementById('modalEditarPrioridad'))?.hide();
setTimeout(() => location.reload(), 600);
} catch (e) { toast('Error de conexión', 'error'); }
}
// ─────────────────────────────────────────────────────────────
// TAB 4: SESIÓN Y WHATSAPP
// ─────────────────────────────────────────────────────────────
async function cambiarSesion(accion, sesionId) {
const msgs = {
cerrar: '¿Confirma el cierre de la sesión del día?',
reabrir: '¿Reabrir la sesión de hoy?',
abrir: '¿Abrir una nueva sesión para hoy?',
};
if (!confirm(msgs[accion] || '¿Confirmar?')) return;
try {
const res = await fetch(API + 'sesion_turno.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accion, sesion_id: sesionId })
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
toast(json.message || 'Operación completada');
setTimeout(() => location.reload(), 600);
} catch (e) { toast('Error de conexión', 'error'); }
}
async function guardarWhatsApp() {
const template = document.getElementById('wa-template')?.value.trim();
const lang = document.getElementById('wa-lang')?.value.trim();
if (!template) { toast('El nombre de plantilla es requerido', 'error'); return; }
try {
const res = await fetch(API + 'sesion_turno.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accion: 'config_wa', template, lang })
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
toast('Configuración WhatsApp guardada');
} catch (e) { toast('Error de conexión', 'error'); }
}
</script>
</body>
</html>
+374
View File
@@ -0,0 +1,374 @@
<?php
/**
* modules/turnero/views/dashboard.php
* Panel de resumen diario del Turnero.
* Carga datos vía fetch a get_dashboard.php (permite cambiar fecha sin recargar).
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dashboard Turnero</title>
<link rel="stylesheet" href="<?= BASE_URL ?>assets/css/bootstrap.min.css">
<link rel="stylesheet" href="<?= BASE_URL ?>assets/css/fontawesome.min.css">
<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; }
.page-header .back-btn { color:#64748b; text-decoration:none; font-size:.9rem; }
.content-wrap { max-width:1100px; margin:24px auto; padding:0 16px 48px; }
/* ── KPI cards ── */
.kpi-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:14px; margin-bottom:24px; }
.kpi-card {
background:#fff; border:1px solid #e2e8f0; border-radius:12px;
padding:16px 18px; text-align:center;
}
.kpi-card .kpi-val { font-size:2rem; font-weight:800; line-height:1.1; }
.kpi-card .kpi-lbl { font-size:.72rem; text-transform:uppercase; letter-spacing:.07em; color:#64748b; margin-top:4px; }
.kpi-card .kpi-sub { font-size:.75rem; color:#94a3b8; margin-top:2px; }
/* ── Charts / tables section ── */
.section-card { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:20px; margin-bottom:20px; }
.section-title { font-size:.72rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:#64748b; margin:0 0 14px; }
/* ── Priority bar ── */
.pri-row { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
.pri-dot { width:10px; height:10px; border-radius:50%; flex-shrink:0; }
.pri-name { width:160px; font-size:.82rem; color:#334155; }
.pri-bar-wrap { flex:1; background:#f1f5f9; border-radius:99px; height:10px; overflow:hidden; }
.pri-bar-fill { height:100%; border-radius:99px; transition:width .5s; }
.pri-nums { font-size:.78rem; color:#64748b; white-space:nowrap; }
/* ── Lugar cards ── */
.lugar-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:12px; }
.lugar-card { border:1px solid #e2e8f0; border-radius:10px; padding:14px; }
.lugar-card .lc-name { font-weight:600; font-size:.9rem; color:#1e293b; margin-bottom:8px; }
.lugar-card .lc-row { display:flex; justify-content:space-between; font-size:.8rem; color:#475569; margin-bottom:4px; }
.lugar-card .lc-row span:last-child { font-weight:600; color:#0f172a; }
/* ── Tabla de turnos ── */
.estado-badge { font-size:.65rem; padding:2px 7px; border-radius:99px; font-weight:600; white-space:nowrap; }
.eb-espera { background:#fef9c3; color:#92400e; }
.eb-en_recepcion { background:#dbeafe; color:#1e40af; }
.eb-en_espera_lugar { background:#fde68a; color:#92400e; }
.eb-en_servicio { background:#dcfce7; color:#166534; }
.eb-finalizado { background:#f1f5f9; color:#475569; }
.eb-ausente { background:#fee2e2; color:#991b1b; }
.eb-cancelado { background:#f1f5f9; color:#9ca3af; }
/* ── Sesión badge ── */
.sesion-open { background:#dcfce7; color:#15803d; }
.sesion-closed { background:#f1f5f9; color:#64748b; }
/* ── Spinner / empty ── */
#loadingOverlay { min-height:180px; display:flex; align-items:center; justify-content:center; }
.empty-msg { color:#94a3b8; font-size:.9rem; text-align:center; padding:40px 0; }
</style>
</head>
<body>
<div class="page-header">
<a href="<?= BASE_URL ?>modules/turnero/views/recepcion.php" class="back-btn">
<i class="fas fa-arrow-left me-1"></i>Volver
</a>
<h1><i class="fas fa-chart-bar me-2 text-primary"></i>Dashboard Turnero</h1>
<!-- Selector de fecha -->
<div class="d-flex align-items-center gap-2">
<label class="small fw-semibold text-muted mb-0">Fecha:</label>
<input type="date" id="fechaInput" class="form-control form-control-sm" style="width:150px"
value="<?= date('Y-m-d') ?>" max="<?= date('Y-m-d') ?>">
</div>
<button class="btn btn-outline-success btn-sm" id="btnExport" title="Exportar CSV del día">
<i class="fas fa-file-csv me-1"></i>Exportar CSV
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="cargarDatos()" title="Refrescar">
<i class="fas fa-sync-alt"></i>
</button>
<a href="<?= BASE_URL ?>modules/turnero/views/configuracion.php" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-cogs"></i>
</a>
</div>
<div class="content-wrap">
<!-- Estado sesión -->
<div id="sesionInfo" class="mb-3 small text-muted"></div>
<!-- KPIs -->
<div class="kpi-grid" id="kpiGrid">
<div id="loadingOverlay" class="col-span-full">
<div class="text-center text-muted"><i class="fas fa-spinner fa-spin me-2"></i>Cargando...</div>
</div>
</div>
<!-- Consentimientos (solo si hay) -->
<div class="section-card" id="sectionConsent" style="display:none">
<div class="section-title"><i class="fas fa-file-signature me-1"></i>Consentimientos del día</div>
<div id="consentKpis" class="d-flex gap-4 flex-wrap"></div>
</div>
<!-- Desglose por prioridad -->
<div class="section-card" id="sectionPrioridad" style="display:none">
<div class="section-title"><i class="fas fa-sort-amount-up me-1"></i>Por prioridad</div>
<div id="barrasPrioridad"></div>
</div>
<!-- Desglose por lugar -->
<div class="section-card" id="sectionLugar" style="display:none">
<div class="section-title"><i class="fas fa-map-marker-alt me-1"></i>Por lugar / estación</div>
<div class="lugar-grid" id="lugarGrid"></div>
</div>
<!-- Tabla detalle de turnos -->
<div class="section-card">
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
<div class="section-title mb-0"><i class="fas fa-list me-1"></i>Detalle de turnos</div>
<input type="text" id="filtroBusqueda" class="form-control form-control-sm"
style="max-width:220px" placeholder="Buscar paciente / código...">
</div>
<div class="table-responsive">
<table class="table table-sm table-hover small align-middle" id="tablaTurnos">
<thead class="table-light">
<tr>
<th>Código</th>
<th>Prioridad</th>
<th>Paciente</th>
<th>Lugar</th>
<th>Estado</th>
<th>Espera</th>
<th>Servicio</th>
<th>Entrada</th>
</tr>
</thead>
<tbody id="tbodyTurnos">
<tr><td colspan="8" class="empty-msg">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<script src="<?= BASE_URL ?>assets/js/bootstrap.bundle.min.js"></script>
<script>
/* ═══════════════════════════════════════════════════════════
Dashboard Turnero
═══════════════════════════════════════════════════════════ */
const API = '<?= BASE_URL ?>modules/turnero/api/';
const BASE_WA = '<?= BASE_URL ?>';
let _turnos = []; // cache para búsqueda local
let _autoReloadId = null;
// ── Entrada ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
cargarDatos();
document.getElementById('fechaInput').addEventListener('change', cargarDatos);
document.getElementById('filtroBusqueda').addEventListener('input', filtrarTabla);
document.getElementById('btnExport').addEventListener('click', exportarCSV);
// Auto-refresh cada 30s solo si la fecha es hoy
_autoReloadId = setInterval(() => {
const hoy = new Date().toISOString().slice(0, 10);
if (document.getElementById('fechaInput').value === hoy) cargarDatos();
}, 30000);
});
// ── Carga principal ──────────────────────────────────────────
async function cargarDatos() {
const fecha = document.getElementById('fechaInput').value;
document.getElementById('kpiGrid').innerHTML =
'<div id="loadingOverlay"><div class="text-center text-muted"><i class="fas fa-spinner fa-spin me-2"></i>Cargando...</div></div>';
document.getElementById('sesionInfo').textContent = '';
try {
const res = await fetch(`${API}get_dashboard.php?fecha=${fecha}`);
const json = await res.json();
if (!json.ok) { mostrarError(json.error || 'Error'); return; }
renderDashboard(json);
} catch (e) {
mostrarError('Error de conexión');
}
}
function mostrarError(msg) {
document.getElementById('kpiGrid').innerHTML =
`<div class="empty-msg text-danger"><i class="fas fa-exclamation-circle me-1"></i>${escHtml(msg)}</div>`;
}
// ── Render ───────────────────────────────────────────────────
function renderDashboard(json) {
const { sesion, resumen, por_prioridad, por_lugar, consent_stats, turnos } = json;
// ── Sesión info ────────────────────────────────────────
const si = document.getElementById('sesionInfo');
if (!sesion) {
si.innerHTML = '<span class="badge bg-secondary-subtle text-secondary">Sin sesión para este día</span>';
} else {
const cls = sesion.abierta ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary';
const est = sesion.abierta ? 'Abierta' : 'Cerrada';
si.innerHTML = `<span class="badge ${cls} me-2">${est}</span>
<span>Inicio: <strong>${sesion.inicio || '—'}</strong></span>
${sesion.fin ? ` &bull; Cierre: <strong>${sesion.fin}</strong>` : ''}`;
}
// ── KPIs ───────────────────────────────────────────────
const total = parseInt(resumen.total || 0);
const atendidos = parseInt(resumen.atendidos || 0);
const enEspera = parseInt(resumen.en_espera || 0);
const ausentes = parseInt(resumen.ausentes || 0);
const cancelados= parseInt(resumen.cancelados || 0);
const tEspera = resumen.tiempo_espera_promedio_min;
const tServicio = resumen.tiempo_servicio_promedio_min;
const pct = total > 0 ? Math.round(atendidos / total * 100) : 0;
document.getElementById('kpiGrid').innerHTML = `
${kpiCard(total, '📋 Total turnos', '')}
${kpiCard(atendidos,'✅ Atendidos', pct + '%')}
${kpiCard(enEspera, '⏳ En espera', '')}
${kpiCard(ausentes, '🚫 Ausentes', '')}
${kpiCard(cancelados,'❌ Cancelados', '')}
${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera prom.', 'hasta recepción')}
${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Servicio prom.', 'en lugar')}
`;
// ── Consentimientos ────────────────────────────────────
const totalConsent = Object.values(consent_stats).reduce((a,b)=>a+(+b),0);
const secCons = document.getElementById('sectionConsent');
if (totalConsent > 0) {
secCons.style.display = '';
const labels = { pendiente:'Pendientes', enviado:'Enviados', visto:'Vistos', firmado:'Firmados', rechazado:'Rechazados' };
const colors = { pendiente:'#fef9c3', enviado:'#dbeafe', visto:'#e0f2fe', firmado:'#dcfce7', rechazado:'#fee2e2' };
const tcolors= { pendiente:'#92400e', enviado:'#1e40af', visto:'#0369a1', firmado:'#166534', rechazado:'#991b1b' };
document.getElementById('consentKpis').innerHTML =
Object.entries(consent_stats).map(([est, cnt]) =>
`<div style="text-align:center">
<div style="font-size:1.5rem;font-weight:800;color:${tcolors[est]||'#0f172a'}">${cnt}</div>
<div style="font-size:.7rem;color:#64748b;text-transform:uppercase;letter-spacing:.05em">${labels[est]||est}</div>
</div>`
).join('');
} else {
secCons.style.display = 'none';
}
// ── Barras por prioridad ───────────────────────────────
const secPri = document.getElementById('sectionPrioridad');
if (por_prioridad.length) {
secPri.style.display = '';
const maxTotal = Math.max(...por_prioridad.map(p => +p.total), 1);
document.getElementById('barrasPrioridad').innerHTML = por_prioridad.map(p => {
const pct2 = Math.round(+p.total / maxTotal * 100);
return `<div class="pri-row">
<div class="pri-dot" style="background:${p.color}"></div>
<div class="pri-name">${escHtml(p.codigo + ' ' + p.nombre)}</div>
<div class="pri-bar-wrap">
<div class="pri-bar-fill" style="width:${pct2}%;background:${p.color}"></div>
</div>
<div class="pri-nums">${p.total} (✅${p.atendidos} ⏳${p.pendientes} 🚫${p.ausentes})</div>
</div>`;
}).join('');
} else {
secPri.style.display = 'none';
}
// ── Tarjetas por lugar ─────────────────────────────────
const secLug = document.getElementById('sectionLugar');
if (por_lugar.length) {
secLug.style.display = '';
document.getElementById('lugarGrid').innerHTML = por_lugar.map(l => `
<div class="lugar-card">
<div class="lc-name"><i class="fas fa-map-marker-alt text-primary me-1"></i>${escHtml(l.lugar)}</div>
<div class="lc-row"><span>Total</span><span>${l.total}</span></div>
<div class="lc-row"><span>Atendidos</span><span>${l.atendidos}</span></div>
<div class="lc-row"><span>En espera</span><span>${l.en_espera}</span></div>
<div class="lc-row"><span>T. servicio prom.</span><span>${l.tiempo_servicio_promedio_min !== null ? l.tiempo_servicio_promedio_min + ' min' : '—'}</span></div>
</div>`).join('');
} else {
secLug.style.display = 'none';
}
// ── Tabla de turnos ────────────────────────────────────
_turnos = turnos;
renderTabla(turnos);
}
function kpiCard(val, lbl, sub) {
return `<div class="kpi-card">
<div class="kpi-val">${val !== null && val !== undefined ? escHtml(String(val)) : '—'}</div>
<div class="kpi-lbl">${lbl}</div>
${sub ? `<div class="kpi-sub">${escHtml(sub)}</div>` : ''}
</div>`;
}
// ── Tabla detalle ─────────────────────────────────────────────
function renderTabla(turnos) {
const tbody = document.getElementById('tbodyTurnos');
if (!turnos.length) {
tbody.innerHTML = '<tr><td colspan="8" class="empty-msg">Sin turnos para este día</td></tr>';
return;
}
const mapEstado = {
espera: 'eb-espera',
en_recepcion: 'eb-en_recepcion',
en_espera_lugar: 'eb-en_espera_lugar',
en_servicio: 'eb-en_servicio',
finalizado: 'eb-finalizado',
ausente: 'eb-ausente',
cancelado: 'eb-cancelado',
};
const labelEstado = {
espera: 'Espera', en_recepcion: 'Recepción', en_espera_lugar: 'Esp. lugar',
en_servicio: 'En servicio', finalizado: 'Finalizado', ausente: 'Ausente', cancelado: 'Cancelado',
};
tbody.innerHTML = turnos.map(t => {
const nombrePac = escHtml(t.paciente_bd || t.paciente_nombre || '—');
const lugar = escHtml(t.lugar_nombre || '—');
const estadoCls = mapEstado[t.estado] || 'eb-finalizado';
const estadoLbl = labelEstado[t.estado] || t.estado;
const hora = t.creado_at ? new Date(t.creado_at.replace(' ', 'T')).toLocaleTimeString('es-CO', {hour:'2-digit',minute:'2-digit'}) : '—';
const espMin = t.espera_min !== null ? t.espera_min + ' m' : '—';
const srvMin = t.servicio_min !== null ? t.servicio_min + ' m' : '—';
return `<tr>
<td><span class="fw-bold" style="color:${escHtml(t.prioridad_color || '#333')}">${escHtml(t.codigo)}</span></td>
<td><span class="estado-badge" style="background:${escHtml(t.prioridad_color+'22'||'#eee')};color:${escHtml(t.prioridad_color||'#333')}">${escHtml(t.prioridad_codigo)}</span></td>
<td>${nombrePac}</td>
<td>${lugar}</td>
<td><span class="estado-badge ${estadoCls}">${estadoLbl}</span></td>
<td>${espMin}</td>
<td>${srvMin}</td>
<td>${hora}</td>
</tr>`;
}).join('');
}
function filtrarTabla() {
const q = document.getElementById('filtroBusqueda').value.toLowerCase().trim();
if (!q) { renderTabla(_turnos); return; }
renderTabla(_turnos.filter(t =>
(t.codigo || '').toLowerCase().includes(q) ||
(t.paciente_bd || '').toLowerCase().includes(q) ||
(t.paciente_nombre || '').toLowerCase().includes(q)
));
}
// ── Exportar CSV ──────────────────────────────────────────────
function exportarCSV() {
const fecha = document.getElementById('fechaInput').value;
window.location.href = `${API}export_csv.php?fecha=${fecha}`;
}
// ── Helper ────────────────────────────────────────────────────
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(str ?? '')));
return d.innerHTML;
}
</script>
</body>
</html>
+425
View File
@@ -0,0 +1,425 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pantalla de Turnos</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
/* ── Base ─────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
height: 100%; width: 100%;
overflow: hidden;
background: #0a0f1e;
color: #f0f4ff;
font-family: 'Segoe UI', system-ui, sans-serif;
}
/* ── Layout ───────────────────────────────────────── */
.display-grid {
display: grid;
grid-template-rows: auto 1fr auto;
height: 100vh;
gap: 0;
}
.display-header {
display: flex; align-items: center; justify-content: space-between;
padding: .75rem 2rem;
background: #0f172a;
border-bottom: 1px solid #1e293b;
flex-shrink: 0;
}
.display-header .titulo {
font-size: clamp(1rem, 2.5vw, 1.5rem);
font-weight: 700; color: #94a3b8;
display: flex; align-items: center; gap: .6rem;
}
.display-header .reloj {
font-size: clamp(1.2rem, 3vw, 1.8rem);
font-weight: 800; color: #e2e8f0;
font-variant-numeric: tabular-nums;
}
.display-header .estado-chip {
font-size: .8rem; padding: .25rem .75rem; border-radius: 20px;
font-weight: 700;
}
.estado-chip.conectado { background: #14532d; color: #86efac; }
.estado-chip.conectando { background: #422006; color: #fdba74; }
.estado-chip.error { background: #450a0a; color: #fca5a5; }
/* ── Panel central ────────────────────────────────── */
.display-body {
display: grid;
grid-template-columns: 1fr 340px;
height: 100%;
overflow: hidden;
}
@media (max-width: 768px) {
.display-body { grid-template-columns: 1fr; }
.cola-panel { display: none !important; }
}
/* ── Turno activo ─────────────────────────────────── */
.turno-activo {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 2rem; position: relative;
overflow: hidden;
background: radial-gradient(ellipse at center, #111827 0%, #0a0f1e 70%);
}
.turno-activo .etiqueta {
font-size: clamp(.9rem, 2vw, 1.2rem);
color: #64748b; font-weight: 600;
text-transform: uppercase; letter-spacing: 3px;
margin-bottom: .5rem;
}
.turno-activo .codigo-grande {
font-size: clamp(5rem, 22vw, 16rem);
font-weight: 900; line-height: 1;
letter-spacing: -4px;
transition: color .4s;
}
.turno-activo .nombre-pac {
font-size: clamp(1rem, 3vw, 2rem);
font-weight: 600; color: #cbd5e1;
margin-top: .5rem; text-align: center;
}
.turno-activo .prio-badge {
display: inline-flex; align-items: center; gap: .5rem;
padding: .4rem 1.2rem; border-radius: 30px;
font-size: clamp(.8rem, 1.8vw, 1.15rem); font-weight: 700;
margin-top: 1rem;
}
.turno-activo .sin-turno {
font-size: clamp(1.5rem, 4vw, 3rem);
color: #334155; text-align: center;
}
/* Anillo de animación al llamar turno */
@keyframes ping-ring {
0% { transform: scale(.8); opacity: .9; }
100% { transform: scale(2.5); opacity: 0; }
}
.ring-anim {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
pointer-events: none; z-index: 0;
}
.ring-anim::before {
content: '';
width: 35vmin; height: 35vmin;
border-radius: 50%;
background: transparent;
border: 4px solid currentColor;
opacity: 0;
}
.ring-anim.animar::before {
animation: ping-ring .7s ease-out 1;
}
.turno-activo > *:not(.ring-anim) { position: relative; z-index: 1; }
/* ── Panel de cola ────────────────────────────────── */
.cola-panel {
background: #0f172a;
border-left: 1px solid #1e293b;
display: flex; flex-direction: column;
overflow: hidden;
}
.cola-panel-header {
padding: 1rem 1.2rem .6rem;
font-size: .85rem; font-weight: 700;
color: #64748b; text-transform: uppercase; letter-spacing: 2px;
border-bottom: 1px solid #1e293b;
flex-shrink: 0;
}
.cola-lista {
flex: 1; overflow-y: auto;
padding: .5rem;
scrollbar-width: thin; scrollbar-color: #1e293b transparent;
}
.cola-item {
display: flex; align-items: center; gap: .75rem;
padding: .6rem .8rem; border-radius: 10px;
margin-bottom: .35rem;
background: #1e293b;
transition: background .2s;
}
.cola-item:first-child { background: #1e3a5f; }
.cola-item .cod-badge {
font-size: 1.2rem; font-weight: 900;
min-width: 52px; text-align: center;
padding: .2rem .4rem; border-radius: 8px;
}
.cola-item .pac-info { flex: 1; min-width: 0; }
.cola-item .pac-nombre {
font-size: .85rem; color: #e2e8f0; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cola-item .pac-hora { font-size: .72rem; color: #475569; }
.cola-vacia { color: #334155; text-align: center; padding: 3rem 1rem; font-size: .95rem; }
/* ── Stats (footer) ──────────────────────────────── */
.display-footer {
display: flex; justify-content: center; gap: 2.5rem; flex-wrap: wrap;
padding: .65rem 2rem;
background: #0f172a;
border-top: 1px solid #1e293b;
flex-shrink: 0;
}
.stat-item { text-align: center; }
.stat-item .val { font-size: 1.3rem; font-weight: 800; color: #e2e8f0; }
.stat-item .lbl { font-size: .7rem; color: #64748b; text-transform: uppercase; letter-spacing: 1px; }
</style>
</head>
<body>
<div class="display-grid">
<!-- ── Cabecera ─────────────────────────────────────── -->
<header class="display-header">
<div class="titulo">
<i class="fas fa-ticket-alt"></i>
<span id="lbl-area">Sistema de Turnos</span>
</div>
<span id="chip-estado" class="estado-chip conectando">
<i class="fas fa-circle-notch fa-spin me-1"></i>Conectando…
</span>
<div class="reloj" id="reloj">--:--:--</div>
</header>
<!-- ── Cuerpo ────────────────────────────────────────── -->
<div class="display-body">
<!-- Turno activo -->
<div class="turno-activo" id="zona-activo">
<div class="ring-anim" id="ring" style="color:#3b82f6"></div>
<div class="etiqueta">Turno en atención</div>
<div class="codigo-grande" id="codigo-activo" style="color:#3b82f6"></div>
<div class="nombre-pac" id="nombre-activo"></div>
<div class="prio-badge" id="prio-activo" style="background:#1e293b;color:#94a3b8">
<i class="fas fa-ticket-alt"></i> Esperando turno
</div>
</div>
<!-- Lista de cola -->
<div class="cola-panel">
<div class="cola-panel-header">
<i class="fas fa-list-ol me-1"></i>EN ESPERA
</div>
<div class="cola-lista" id="cola-lista">
<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>
Cola vacía
</div>
</div>
</div>
</div>
<!-- ── Footer stats ──────────────────────────────────── -->
<footer class="display-footer">
<div class="stat-item">
<div class="val" id="stat-espera"></div>
<div class="lbl">En espera</div>
</div>
<div class="stat-item">
<div class="val" id="stat-atendidos"></div>
<div class="lbl">Atendidos hoy</div>
</div>
<div class="stat-item">
<div class="val" id="stat-total"></div>
<div class="lbl">Total del día</div>
</div>
<div class="stat-item">
<div class="val" id="stat-tiempo"></div>
<div class="lbl">Tiempo prom.</div>
</div>
</footer>
</div>
<script>
// ── Configuración desde URL ───────────────────────────────────
const params = new URLSearchParams(location.search);
const area = params.get('display') || 'recepcion';
const lugarId = params.get('lugar_id') || '';
const BASE_API = '../api/';
// Etiqueta de área
document.getElementById('lbl-area').textContent =
area === 'lugar' && lugarId ? `Lugar #${lugarId} — Turnos` : 'Recepción — Turnos';
// ── Reloj ─────────────────────────────────────────────────────
function actualizarReloj() {
document.getElementById('reloj').textContent =
new Date().toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
actualizarReloj();
setInterval(actualizarReloj, 1000);
// ── Audio (Web Audio API — sin archivos externos) ─────────────
let audioCtx = null;
function playBeep() {
try {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(880, audioCtx.currentTime);
osc.frequency.setValueAtTime(660, audioCtx.currentTime + 0.12);
gain.gain.setValueAtTime(0.25, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + 0.5);
osc.start(audioCtx.currentTime);
osc.stop(audioCtx.currentTime + 0.5);
} catch (_) { /* Sin audio si el contexto no está disponible */ }
}
// Desbloquear AudioContext ante primer tap/clic (requerido por navegadores)
document.addEventListener('click', () => {
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
}, { once: true });
// ── Estado previo ─────────────────────────────────────────────
let lastCodigoActivo = null;
// ── Render ────────────────────────────────────────────────────
function renderSnapshot(snap) {
const activo = snap.activo;
const cola = snap.cola || [];
const stats = snap.stats || {};
// Turno activo
const elCodigo = document.getElementById('codigo-activo');
const elNombre = document.getElementById('nombre-activo');
const elPrio = document.getElementById('prio-activo');
const elRing = document.getElementById('ring');
if (activo) {
const color = activo.prioridad_color || '#3b82f6';
// ¿Cambió el turno? → animar + sonido
if (activo.codigo !== lastCodigoActivo) {
playBeep();
elRing.style.color = color;
elRing.classList.remove('animar');
// Forzar reflow para reiniciar animación
void elRing.offsetWidth;
elRing.classList.add('animar');
lastCodigoActivo = activo.codigo;
}
elCodigo.textContent = activo.codigo;
elCodigo.style.color = color;
elNombre.textContent = activo.paciente_nombre || '';
elPrio.style.background = color + '33'; // 20% opacity
elPrio.style.color = color;
elPrio.innerHTML = `<i class="fas fa-ticket-alt"></i> ${activo.prioridad_codigo} &mdash; ${activo.prioridad_nombre}`;
} else {
elCodigo.textContent = '—';
elCodigo.style.color = '#334155';
elNombre.textContent = '';
elPrio.style.background = '#1e293b';
elPrio.style.color = '#94a3b8';
elPrio.innerHTML = '<i class="fas fa-ticket-alt"></i> Esperando turno';
lastCodigoActivo = null;
}
// Lista de cola (excluir el activo si está ahí)
const colaEspera = cola.filter(t =>
t.estado === 'espera' || t.estado === 'en_espera_lugar'
);
const elLista = document.getElementById('cola-lista');
if (colaEspera.length === 0) {
elLista.innerHTML = `
<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>
Cola vacía
</div>`;
} else {
elLista.innerHTML = colaEspera.map((t, idx) => {
const hora = t.creado_at ? new Date(t.creado_at).toLocaleTimeString('es-CO',
{ hour: '2-digit', minute: '2-digit' }) : '';
return `
<div class="cola-item">
<span class="cod-badge" style="background:${t.prioridad_color}22;color:${t.prioridad_color}">${t.codigo}</span>
<div class="pac-info">
<div class="pac-nombre">${escHtml(t.paciente_nombre || 'Paciente')}</div>
<div class="pac-hora">${t.prioridad_nombre} &bull; ${hora}</div>
</div>
${idx === 0 ? '<i class="fas fa-arrow-right" style="color:#3b82f6;font-size:.8rem"></i>' : ''}
</div>`;
}).join('');
}
// Stats
const enEspera = parseInt(stats.en_espera || 0) + parseInt(stats.en_espera_lugar || 0);
document.getElementById('stat-espera').textContent = enEspera;
document.getElementById('stat-atendidos').textContent = stats.finalizados || 0;
document.getElementById('stat-total').textContent = stats.total || 0;
document.getElementById('stat-tiempo').textContent = stats.tiempo_promedio_atencion
? stats.tiempo_promedio_atencion.substring(0, 5) : '—';
}
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(str));
return d.innerHTML;
}
// ── Carga inicial ─────────────────────────────────────────────
async function cargarInicial() {
const url = new URL(BASE_API + 'get_cola.php', location.href);
url.searchParams.set('area', area);
if (lugarId) url.searchParams.set('lugar_id', lugarId);
try {
const res = await fetch(url.toString());
const json = await res.json();
if (json.ok) renderSnapshot(json.data);
} catch (_) { /* SSE tomará el relevo */ }
}
// ── SSE ───────────────────────────────────────────────────────
const chipEstado = document.getElementById('chip-estado');
let esSource = null;
let reconnectDelay = 2000;
function conectarSSE() {
const sseUrl = new URL(BASE_API + 'sse_turno.php', location.href);
sseUrl.searchParams.set('area', area);
if (lugarId) sseUrl.searchParams.set('lugar_id', lugarId);
esSource = new EventSource(sseUrl.toString());
esSource.addEventListener('open', () => {
reconnectDelay = 2000;
chipEstado.className = 'estado-chip conectado';
chipEstado.innerHTML = '<i class="fas fa-circle me-1" style="font-size:.5rem"></i>En vivo';
});
esSource.addEventListener('cola_update', (e) => {
try {
const snap = JSON.parse(e.data);
renderSnapshot(snap);
} catch (_) {}
});
esSource.addEventListener('error', () => {
chipEstado.className = 'estado-chip error';
chipEstado.innerHTML = '<i class="fas fa-exclamation-circle me-1"></i>Reconectando…';
esSource.close();
setTimeout(conectarSSE, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
});
}
// ── Inicio ────────────────────────────────────────────────────
cargarInicial();
conectarSSE();
</script>
</body>
</html>
+394
View File
@@ -0,0 +1,394 @@
<?php
/**
* Kiosko de turnos — pantalla táctil pública
* Ruta: /modules/turnero/views/kiosko.php
* Sin login requerido. NUNCA expone datos de otros pacientes.
*/
require_once __DIR__ . '/../../../config/config.php';
// Cargar prioridades activas desde BD
$prioridades = [];
try {
$pdo = Database::getInstance()->getConnection();
$stmt = $pdo->query(
"SELECT id, codigo, nombre, color, icono, descripcion, orden_peso
FROM turnero_prioridades
WHERE activo = 1
ORDER BY orden_peso ASC"
);
$prioridades = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable $e) {
// Fallback con prioridades por defecto si la BD no está disponible
$prioridades = [
['codigo'=>'A','nombre'=>'Niños', 'color'=>'#ef4444','icono'=>'fas fa-child', 'descripcion'=>'Pacientes menores de edad'],
['codigo'=>'B','nombre'=>'Embarazadas', 'color'=>'#f97316','icono'=>'fas fa-heart', 'descripcion'=>'Mujeres en estado de embarazo'],
['codigo'=>'C','nombre'=>'Adulto mayor', 'color'=>'#eab308','icono'=>'fas fa-person-cane', 'descripcion'=>'Mayores de 60 años'],
['codigo'=>'D','nombre'=>'Discapacidad', 'color'=>'#8b5cf6','icono'=>'fas fa-wheelchair', 'descripcion'=>'Personas con discapacidad'],
['codigo'=>'E','nombre'=>'Paciente general', 'color'=>'#3b82f6','icono'=>'fas fa-user', 'descripcion'=>'Atención general'],
['codigo'=>'F','nombre'=>'Muestra pendiente', 'color'=>'#6b7280','icono'=>'fas fa-vial', 'descripcion'=>'Entrega de muestra tomada'],
];
}
// Iconos por defecto por código (si la BD no tiene el campo icono populado)
$iconosDefecto = [
'A' => 'fas fa-child',
'B' => 'fas fa-heart',
'C' => 'fas fa-person-cane',
'D' => 'fas fa-wheelchair',
'E' => 'fas fa-user',
'F' => 'fas fa-vial',
];
foreach ($prioridades as &$p) {
if (empty($p['icono'])) {
$p['icono'] = $iconosDefecto[$p['codigo']] ?? 'fas fa-ticket-alt';
}
}
unset($p);
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Kiosko de Turnos</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
/* ── Reset fullscreen ── */
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
height: 100%; width: 100%;
overflow: hidden;
background: #0f172a;
color: #f8fafc;
font-family: 'Segoe UI', system-ui, sans-serif;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
/* ── Pantalla / paso ── */
.screen {
position: fixed; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 2rem;
transition: opacity .35s ease, transform .35s ease;
}
.screen.hidden { opacity: 0; pointer-events: none; transform: scale(.97); }
.screen.visible { opacity: 1; pointer-events: all; transform: scale(1); }
/* ── Logo / cabecera ── */
.kiosko-header { text-align: center; margin-bottom: 2.5rem; }
.kiosko-header h1 { font-size: clamp(1.6rem, 4vw, 3rem); font-weight: 700; letter-spacing: -.5px; }
.kiosko-header p { font-size: clamp(.9rem, 2vw, 1.2rem); color: #94a3b8; margin: 0; }
/* ── Botones de prioridad ── */
.prioridad-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
gap: 1rem;
width: 100%;
max-width: 900px;
}
.btn-prioridad {
border: none; border-radius: 16px;
padding: 1.6rem 1.2rem;
cursor: pointer;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: .6rem;
transition: transform .12s, box-shadow .12s, filter .12s;
color: #fff; font-weight: 600;
box-shadow: 0 4px 20px rgba(0,0,0,.35);
-webkit-user-select: none; user-select: none;
}
.btn-prioridad:active { transform: scale(.95); filter: brightness(.88); }
.btn-prioridad .letra { font-size: clamp(2rem, 6vw, 4rem); line-height: 1; font-weight: 800; }
.btn-prioridad .nombre { font-size: clamp(.85rem, 2vw, 1.1rem); text-align: center; line-height: 1.2; }
.btn-prioridad .desc { font-size: clamp(.7rem, 1.5vw, .85rem); opacity: .8; text-align: center; }
.btn-prioridad i { font-size: clamp(1.4rem, 3.5vw, 2.2rem); }
/* ── Formulario opcional ── */
.form-kiosko { width: 100%; max-width: 520px; }
.form-kiosko label { color: #cbd5e1; font-size: 1rem; margin-bottom: .35rem; }
.form-kiosko .form-control {
background: #1e293b; border: 1.5px solid #334155;
color: #f8fafc; border-radius: 12px;
padding: .8rem 1rem; font-size: 1.1rem;
}
.form-kiosko .form-control:focus {
background: #1e293b; border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96,165,250,.2); color: #f8fafc;
}
.form-kiosko .hint { color: #64748b; font-size: .85rem; margin-top: .4rem; }
.btn-kiosko-main {
border: none; border-radius: 14px;
padding: 1rem 2.5rem; font-size: 1.15rem; font-weight: 700;
cursor: pointer; color: #fff;
background: linear-gradient(135deg, #3b82f6, #6366f1);
box-shadow: 0 4px 16px rgba(99,102,241,.4);
transition: transform .12s, box-shadow .12s;
width: 100%; margin-top: 1rem;
}
.btn-kiosko-main:active { transform: scale(.97); }
.btn-kiosko-back {
background: transparent; border: 1.5px solid #475569;
color: #94a3b8; border-radius: 12px; padding: .7rem 1.5rem;
font-size: .95rem; cursor: pointer; margin-top: .6rem; width: 100%;
transition: background .12s;
}
.btn-kiosko-back:active { background: #1e293b; }
/* ── Pantalla de ticket ── */
.ticket-box {
background: #1e293b; border-radius: 24px;
padding: 2.5rem 3rem; text-align: center;
box-shadow: 0 8px 40px rgba(0,0,0,.5);
max-width: 480px; width: 100%;
}
.ticket-codigo {
font-size: clamp(4rem, 18vw, 9rem);
font-weight: 900; line-height: 1;
letter-spacing: -2px;
}
.ticket-subtitle { color: #94a3b8; font-size: 1rem; margin-top: .5rem; }
.ticket-prioridad { font-size: 1.05rem; margin-top: .8rem; }
.ticket-numero { font-size: 2rem; font-weight: 700; color: #e2e8f0; }
.ticket-instruc {
margin-top: 1.5rem; color: #64748b; font-size: .9rem; line-height: 1.5;
}
.btn-nuevo {
margin-top: 2rem; border: none; border-radius: 14px;
padding: .85rem 2rem; font-size: 1rem; font-weight: 600;
cursor: pointer; color: #fff;
background: #475569;
transition: background .15s;
}
.btn-nuevo:hover { background: #64748b; }
/* ── Spinner ── */
.spinner-overlay {
position: fixed; inset: 0;
background: rgba(15,23,42,.75);
display: flex; align-items: center; justify-content: center;
z-index: 999; opacity: 0; pointer-events: none;
transition: opacity .2s;
}
.spinner-overlay.active { opacity: 1; pointer-events: all; }
.spinner { width: 56px; height: 56px; border: 5px solid #334155;
border-top-color: #60a5fa; border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Error toast ── */
.toast-error {
position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%);
background: #ef4444; color: #fff; padding: .8rem 1.6rem;
border-radius: 12px; font-size: .95rem; font-weight: 600;
box-shadow: 0 4px 20px rgba(0,0,0,.4);
opacity: 0; pointer-events: none; transition: opacity .25s;
z-index: 1000; white-space: nowrap;
}
.toast-error.show { opacity: 1; }
/* ── Responsive: pantalla grande (TV táctil) ── */
@media (min-height: 800px) {
.prioridad-grid { gap: 1.4rem; }
.btn-prioridad { padding: 2rem 1.5rem; }
}
</style>
</head>
<body>
<!-- ══ PASO 1: Selección de prioridad ══════════════════════════ -->
<div id="screen-prio" class="screen visible">
<div class="kiosko-header">
<i class="fas fa-ticket-alt fa-2x mb-3" style="color:#60a5fa"></i>
<h1>Solicitar Turno</h1>
<p>Seleccione su tipo de atención</p>
</div>
<div class="prioridad-grid">
<?php foreach ($prioridades as $prio): ?>
<button
class="btn-prioridad"
style="background: <?= htmlspecialchars($prio['color']) ?>;"
data-codigo="<?= htmlspecialchars($prio['codigo']) ?>"
data-nombre="<?= htmlspecialchars($prio['nombre']) ?>"
data-color="<?= htmlspecialchars($prio['color']) ?>"
onclick="seleccionarPrioridad(this)"
>
<i class="<?= htmlspecialchars($prio['icono']) ?>"></i>
<span class="letra"><?= htmlspecialchars($prio['codigo']) ?></span>
<span class="nombre"><?= htmlspecialchars($prio['nombre']) ?></span>
<?php if (!empty($prio['descripcion'])): ?>
<span class="desc"><?= htmlspecialchars($prio['descripcion']) ?></span>
<?php endif; ?>
</button>
<?php endforeach; ?>
</div>
</div>
<!-- ══ PASO 2: Datos opcionales ════════════════════════════════ -->
<div id="screen-datos" class="screen hidden">
<div class="kiosko-header">
<div id="badge-prio" style="display:inline-block; padding:.5rem 1.4rem; border-radius:40px; font-size:1.2rem; font-weight:700; margin-bottom:1rem;"></div>
<h1>Datos de contacto</h1>
<p>Opcional — para notificaciones por WhatsApp</p>
</div>
<div class="form-kiosko">
<div class="mb-4">
<label for="inp-nombre">Nombre completo</label>
<input type="text" id="inp-nombre" class="form-control" placeholder="Ej: Juan Pérez" maxlength="120" autocomplete="off">
</div>
<div class="mb-4">
<label for="inp-cel">Celular (WhatsApp)</label>
<input type="tel" id="inp-cel" class="form-control" placeholder="Ej: 3001234567" maxlength="20" autocomplete="off" inputmode="numeric">
<div class="hint">Incluya código de país si es diferente a Colombia (+57)</div>
</div>
<button class="btn-kiosko-main" onclick="confirmarTurno()">
<i class="fas fa-ticket-alt me-2"></i>Obtener mi turno
</button>
<button class="btn-kiosko-back" onclick="volverPrioridades()">
<i class="fas fa-arrow-left me-1"></i>Cambiar tipo
</button>
</div>
</div>
<!-- ══ PASO 3: Ticket asignado ══════════════════════════════════ -->
<div id="screen-ticket" class="screen hidden">
<div class="ticket-box">
<div class="ticket-subtitle">Su número de turno es</div>
<div id="tick-codigo" class="ticket-codigo">—</div>
<div id="tick-prioridad" class="ticket-prioridad"></div>
<div class="ticket-subtitle mt-2">Posición en cola</div>
<div id="tick-posicion" class="ticket-numero">—</div>
<div class="ticket-instruc">
Por favor espere ser llamado.<br>
Recuerde traer su documento de identidad y la orden médica.
</div>
</div>
<button class="btn-nuevo" onclick="reiniciar()">
<i class="fas fa-plus me-1"></i>Nuevo turno
</button>
</div>
<!-- ══ Spinner de carga ════════════════════════════════════════ -->
<div class="spinner-overlay" id="spinner">
<div class="spinner"></div>
</div>
<!-- ══ Toast de error ════════════════════════════════════════ -->
<div class="toast-error" id="toast-error"></div>
<script>
// ── Estado local ──────────────────────────────────────────
let prioCodigo = '';
let prioNombre = '';
let prioColor = '';
const API_URL = '../api/create_turno.php';
// ── Navegación entre pasos ────────────────────────────────
function mostrar(id) {
document.querySelectorAll('.screen').forEach(s => {
s.classList.toggle('visible', s.id === id);
s.classList.toggle('hidden', s.id !== id);
});
}
function seleccionarPrioridad(btn) {
prioCodigo = btn.dataset.codigo;
prioNombre = btn.dataset.nombre;
prioColor = btn.dataset.color;
document.getElementById('badge-prio').textContent = prioCodigo + ' — ' + prioNombre;
document.getElementById('badge-prio').style.background = prioColor;
mostrar('screen-datos');
document.getElementById('inp-nombre').focus();
}
function volverPrioridades() {
mostrar('screen-prio');
}
// ── Crear turno ───────────────────────────────────────────
async function confirmarTurno() {
const nombre = document.getElementById('inp-nombre').value.trim();
const cel = document.getElementById('inp-cel').value.trim();
// Validación mínima de celular
if (cel && !/^\+?\d{7,15}$/.test(cel.replace(/\s/g, ''))) {
mostrarError('Ingrese un número de celular válido (solo dígitos).');
return;
}
setSpinner(true);
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prioridad_codigo: prioCodigo,
paciente_nombre: nombre || null,
paciente_cel: cel || null,
}),
});
const json = await res.json();
if (!json.ok) {
throw new Error(json.error ?? 'Error al crear turno');
}
const t = json.data.turno;
mostrarTicket(t);
} catch (err) {
mostrarError(err.message);
} finally {
setSpinner(false);
}
}
// ── Mostrar ticket ────────────────────────────────────────
function mostrarTicket(t) {
document.getElementById('tick-codigo').textContent = t.codigo;
document.getElementById('tick-codigo').style.color = prioColor;
document.getElementById('tick-prioridad').innerHTML =
'<span style="background:' + prioColor + ';padding:.3rem .9rem;border-radius:20px;font-weight:700;">'
+ prioCodigo + ' — ' + prioNombre + '</span>';
document.getElementById('tick-posicion').textContent = '#' + t.posicion_cola;
mostrar('screen-ticket');
// Auto-reinicio tras 30 s para liberar el kiosko
clearTimeout(window._reinicioTimer);
window._reinicioTimer = setTimeout(reiniciar, 30000);
}
// ── Reinicio ──────────────────────────────────────────────
function reiniciar() {
clearTimeout(window._reinicioTimer);
document.getElementById('inp-nombre').value = '';
document.getElementById('inp-cel').value = '';
prioCodigo = prioNombre = prioColor = '';
mostrar('screen-prio');
}
// ── Utilidades ────────────────────────────────────────────
function setSpinner(on) {
document.getElementById('spinner').classList.toggle('active', on);
}
function mostrarError(msg) {
const el = document.getElementById('toast-error');
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 4000);
}
</script>
</body>
</html>
+760
View File
@@ -0,0 +1,760 @@
<?php
/**
* Puesto Lugar / Estación de Servicio — Módulo Turnero
* Genérica: sirve para Toma de Muestras 1, 2, Rayos X, etc.
* Requiere login + módulo turnero.
*
* URL: /modules/turnero/views/lugar.php?lugar_id=X
* Si no se pasa lugar_id, muestra un selector al entrar.
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) {
header('Location: ' . BASE_URL . 'login.php');
exit;
}
// Cargar lista de lugares activos
try {
$pdo = Database::getInstance()->getConnection();
$lugares = $pdo->query(
"SELECT id, nombre, descripcion FROM turnero_lugares WHERE activo = 1 ORDER BY sort_order ASC"
)->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable) {
$lugares = [];
}
$lugarIdParam = isset($_GET['lugar_id']) ? (int)$_GET['lugar_id'] : 0;
// Resolver nombre del lugar para el título
$lugarNombre = 'Estación de Servicio';
foreach ($lugares as $l) {
if ((int)$l['id'] === $lugarIdParam) {
$lugarNombre = $l['nombre'];
break;
}
}
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Operador';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($lugarNombre) ?> — Turnero</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="<?= BASE_URL ?>assets/css/styles.css?v=12" rel="stylesheet">
<style>
/* ── Layout ── */
.lugar-layout {
display: grid;
grid-template-columns: 300px 1fr;
height: calc(100vh - 52px);
overflow: hidden;
}
@media (max-width: 860px) {
.lugar-layout { grid-template-columns: 1fr; }
.lugar-cola { max-height: 240px; }
}
/* ── Columna cola ── */
.lugar-cola {
background: #f8fafc;
border-right: 1px solid #e2e8f0;
display: flex; flex-direction: column; overflow: hidden;
}
.lugar-cola-hdr {
padding: .85rem 1rem .5rem;
background: #fff; border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
}
.cola-items { flex: 1; overflow-y: auto; padding: .4rem; }
.cola-card {
background: #fff; border: 1px solid #e2e8f0; border-radius: 10px;
padding: .55rem .8rem; margin-bottom: .35rem;
display: flex; align-items: center; gap: .55rem;
}
.cola-card.activo { border-color: #6366f1; box-shadow: 0 0 0 2px #c7d2fe; }
.prio-dot {
width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 1rem; font-weight: 800; color: #fff;
}
.turno-info .cod { font-size: .98rem; font-weight: 700; }
.turno-info .pac { font-size: .75rem; color: #64748b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cola-vacia { text-align: center; padding: 2.5rem 1rem; color: #94a3b8; font-size: .88rem; }
/* ── Columna ficha ── */
.lugar-ficha { overflow-y: auto; padding: 1.2rem 1.5rem; background: #fff; }
.ficha-placeholder {
height: 100%; display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: #94a3b8; text-align: center;
}
/* ── Secciones ── */
.ficha-sec {
background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 12px; padding: 1.1rem 1.2rem; margin-bottom: 1rem;
}
.ficha-sec h6 {
font-size: .75rem; text-transform: uppercase; letter-spacing: .08em;
color: #475569; margin-bottom: .75rem;
}
/* ── Datos paciente ── */
.pac-dato { display: flex; gap: .5rem; align-items: baseline; margin-bottom: .3rem; }
.pac-dato .lbl { font-size: .75rem; color: #94a3b8; min-width: 80px; }
.pac-dato .val { font-size: .88rem; color: #1e293b; font-weight: 500; }
/* ── Exámenes ── */
.exam-pill {
display: inline-flex; align-items: center; gap: .3rem;
background: #eff6ff; color: #1d4ed8; border-radius: 20px;
padding: .2rem .65rem; font-size: .78rem; font-weight: 600;
margin: .15rem;
}
/* ── Consentimientos ── */
.consent-row {
display: flex; align-items: center; gap: .6rem;
padding: .5rem .65rem; border-radius: 8px; margin-bottom: .35rem;
font-size: .85rem;
}
.consent-row.firmado { background: #f0fdf4; color: #166534; }
.consent-row.rechazado { background: #f1f5f9; color: #475569; }
.consent-row.enviado { background: #eff6ff; color: #1e40af; }
.consent-row.pendiente { background: #fffbeb; color: #92400e; }
.consent-row.visto { background: #faf5ff; color: #6b21a8; }
.consent-row .nom-form { flex: 1; }
.consent-row .acciones-consent { display: flex; gap: .4rem; }
/* ── Barra de acciones ── */
.ficha-acciones {
position: sticky; bottom: 0;
background: #fff; border-top: 1px solid #e2e8f0;
padding: .85rem 0 .2rem; margin-top: 1rem;
display: flex; gap: .5rem; flex-wrap: wrap; align-items: center;
}
/* ── Modal de firma ── */
.modal-firma-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.55);
z-index: 1050; display: none; align-items: center; justify-content: center;
}
.modal-firma-backdrop.open { display: flex; }
.modal-firma-box {
background: #fff; border-radius: 16px;
width: min(96vw, 800px); height: min(90vh, 700px);
display: flex; flex-direction: column;
overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,.35);
}
.modal-firma-hdr {
padding: .75rem 1rem; border-bottom: 1px solid #e2e8f0;
display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0;
}
.modal-firma-box iframe {
flex: 1; border: none; width: 100%;
}
/* ── Selector de lugar (pantalla inicial) ── */
.selector-lugar-overlay {
position: fixed; inset: 0; background: #f1f5f9;
z-index: 200; display: flex; align-items: center; justify-content: center;
}
.selector-lugar-box {
background: #fff; border-radius: 20px; padding: 2.5rem;
width: min(90vw, 480px); box-shadow: 0 8px 40px rgba(0,0,0,.12); text-align: center;
}
.selector-lugar-box h2 { font-size: 1.4rem; font-weight: 700; margin-bottom: 1.5rem; }
/* ── Indicador de bloqueo ── */
.bloqueo-banner {
background: #fef2f2; border: 1px solid #fca5a5; border-radius: 10px;
padding: .65rem 1rem; font-size: .85rem; color: #991b1b;
display: flex; align-items: center; gap: .5rem; margin-bottom: .75rem;
}
</style>
</head>
<body>
<?php
$SIDEBAR_TITLE = 'Turnero';
$SIDEBAR_ICON = 'fas fa-ticket-alt';
require_once __DIR__ . '/../../../shared/components/sidebar.php';
?>
<!-- ══ Selector inicial de lugar (si no viene en URL) ════════ -->
<div id="overlay-selector" class="selector-lugar-overlay" <?= $lugarIdParam ? 'style="display:none"' : '' ?>>
<div class="selector-lugar-box">
<i class="fas fa-map-marker-alt fa-2x text-primary mb-3"></i>
<h2>Seleccione su estación</h2>
<select id="sel-lugar-init" class="form-select mb-3">
<option value="">— Elija un lugar —</option>
<?php foreach ($lugares as $l): ?>
<option value="<?= (int)$l['id'] ?>"><?= htmlspecialchars($l['nombre']) ?></option>
<?php endforeach; ?>
</select>
<button class="btn btn-primary w-100" onclick="confirmarLugar()">
<i class="fas fa-check me-1"></i>Confirmar
</button>
</div>
</div>
<main class="main-content" style="padding:0">
<!-- ── Top bar ──────────────────────────────────────────── -->
<div class="d-flex align-items-center justify-content-between px-3 py-2 border-bottom bg-white"
style="position:sticky;top:0;z-index:100;min-height:52px">
<div class="d-flex align-items-center gap-2">
<i class="fas fa-map-marker-alt text-primary"></i>
<strong id="lbl-lugar-titulo"><?= htmlspecialchars($lugarNombre) ?></strong>
<span id="badge-turno-activo" class="badge bg-primary ms-1 d-none" id="badge-cod-activo"></span>
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-outline-secondary" onclick="cambiarLugar()">
<i class="fas fa-exchange-alt me-1"></i>Cambiar lugar
</button>
<span class="text-muted small"><?= htmlspecialchars($adminNombre) ?></span>
<button class="btn btn-primary btn-sm" id="btn-llamar" onclick="llamarSiguiente()">
<i class="fas fa-bell me-1"></i>Llamar siguiente
</button>
</div>
</div>
<div class="lugar-layout">
<!-- ══ Cola ═══════════════════════════════════════════ -->
<div class="lugar-cola">
<div class="lugar-cola-hdr">
<div class="d-flex align-items-center justify-content-between">
<span class="fw-semibold text-secondary" style="font-size:.78rem;text-transform:uppercase;letter-spacing:.05em">
<i class="fas fa-list-ol me-1"></i>EN ESPERA
</span>
<span class="badge bg-primary rounded-pill" id="badge-count">0</span>
</div>
</div>
<div class="cola-items" id="cola-items">
<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>
Cola vacía
</div>
</div>
</div>
<!-- ══ Ficha del turno ════════════════════════════════ -->
<div class="lugar-ficha" id="lugar-ficha">
<div class="ficha-placeholder" id="ficha-placeholder">
<i class="fas fa-stethoscope fa-3x mb-3" style="color:#cbd5e1"></i>
<p class="fw-semibold mb-1">Sin turno activo</p>
<small class="text-muted">Pulse "Llamar siguiente" o espere a que llegue el turno</small>
</div>
<div id="ficha-turno" class="d-none">
<!-- Encabezado turno -->
<div class="d-flex align-items-center gap-3 mb-3">
<div id="ficha-prio-dot" class="prio-dot"
style="width:52px;height:52px;border-radius:12px;font-size:1.6rem;font-weight:900">—</div>
<div>
<div class="h4 mb-0 fw-bold" id="ficha-codigo">—</div>
<div class="text-muted small" id="ficha-prio-nombre"></div>
</div>
<span class="ms-auto badge bg-success-subtle text-success border border-success-subtle"
id="ficha-estado-badge">en espera</span>
</div>
<!-- ── Sección: Paciente ── -->
<div class="ficha-sec">
<h6><i class="fas fa-user me-1"></i>Paciente</h6>
<div id="bloque-pac-info">
<div class="pac-dato"><span class="lbl">Nombre</span><span class="val" id="pac-nombre">—</span></div>
<div class="pac-dato"><span class="lbl">Documento</span><span class="val" id="pac-doc">—</span></div>
<div class="pac-dato"><span class="lbl">Fecha nac.</span><span class="val" id="pac-fec">—</span></div>
<div class="pac-dato"><span class="lbl">Celular</span><span class="val" id="pac-cel">—</span></div>
</div>
<div id="bloque-pac-sin" class="text-muted small">
<i class="fas fa-info-circle me-1"></i>Sin paciente vinculado
</div>
</div>
<!-- ── Sección: Exámenes ── -->
<div class="ficha-sec">
<h6><i class="fas fa-vial me-1"></i>Exámenes solicitados</h6>
<div id="lista-examenes-ficha">
<span class="text-muted small">Sin exámenes registrados</span>
</div>
</div>
<!-- ── Sección: Consentimientos ── -->
<div class="ficha-sec" id="sec-consent">
<h6><i class="fas fa-file-signature me-1"></i>Consentimientos informados</h6>
<div id="bloqueo-banner" class="bloqueo-banner d-none">
<i class="fas fa-lock"></i>
<span>Hay consentimientos pendientes. No puede iniciar la atención hasta que estén <strong>firmados</strong> o <strong>rechazados</strong>.</span>
</div>
<div id="lista-consent"></div>
<div id="sin-consent" class="text-muted small">
<i class="fas fa-check-circle text-success me-1"></i>No se requieren consentimientos
</div>
</div>
<!-- ── Barra de acciones ── -->
<div class="ficha-acciones">
<button class="btn btn-success" id="btn-iniciar" onclick="iniciarAtencion()">
<i class="fas fa-play me-1"></i>Iniciar atención
</button>
<button class="btn btn-primary d-none" id="btn-finalizar" onclick="finalizarAtencion()">
<i class="fas fa-flag-checkered me-1"></i>Finalizar
</button>
<button class="btn btn-outline-warning d-none" id="btn-regresar" onclick="regresarCola()">
<i class="fas fa-undo me-1"></i>Regresar a cola
</button>
<button class="btn btn-secondary ms-auto" id="btn-ausente" onclick="marcarAusente()">
<i class="fas fa-user-slash me-1"></i>Ausente
</button>
</div>
</div><!-- /ficha-turno -->
</div><!-- /lugar-ficha -->
</div><!-- /lugar-layout -->
</main>
<!-- ══ Modal de firma presencial ══════════════════════════════ -->
<div class="modal-firma-backdrop" id="modal-firma">
<div class="modal-firma-box">
<div class="modal-firma-hdr">
<strong id="modal-firma-titulo">Firmar consentimiento</strong>
<button class="btn btn-sm btn-outline-secondary" onclick="cerrarModalFirma()">
<i class="fas fa-times"></i>
</button>
</div>
<iframe id="firma-iframe" src="" title="Formulario de consentimiento"></iframe>
</div>
</div>
<script>
// ── Estado global ─────────────────────────────────────────────
let lugarId = <?= $lugarIdParam ?: 0 ?>;
let turnoActivo = null;
let tieneConsent = false;
let hayPendientes = false;
let pollingColaId = null;
let pollingConsentId = null;
const API = '../api/';
const BASE_WA = '<?= BASE_URL ?>';
// ── Arranque ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
if (lugarId) {
iniciarPuesto();
}
});
function iniciarPuesto() {
cargarCola();
pollingColaId = setInterval(cargarCola, 7000);
}
// ── Selector de lugar ─────────────────────────────────────────
function confirmarLugar() {
const sel = document.getElementById('sel-lugar-init');
const id = parseInt(sel.value);
if (!id) return alert('Seleccione un lugar.');
lugarId = id;
const txt = sel.options[sel.selectedIndex].text;
document.getElementById('lbl-lugar-titulo').textContent = txt;
document.getElementById('overlay-selector').style.display = 'none';
document.title = txt + ' — Turnero';
iniciarPuesto();
}
function cambiarLugar() {
clearInterval(pollingColaId);
clearInterval(pollingConsentId);
turnoActivo = null;
resetFicha();
document.getElementById('overlay-selector').style.display = 'flex';
}
// ── Cola ──────────────────────────────────────────────────────
async function cargarCola() {
if (!lugarId) return;
try {
const res = await fetch(`${API}get_cola.php?area=lugar&lugar_id=${lugarId}`);
const json = await res.json();
if (!json.ok) return;
renderCola(json.data);
} catch (_) {}
}
function renderCola(snap) {
const espera = (snap.cola || []).filter(t =>
t.estado === 'en_espera_lugar'
);
document.getElementById('badge-count').textContent = espera.length;
const lista = document.getElementById('cola-items');
if (!espera.length) {
lista.innerHTML = `<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>Cola vacía</div>`;
return;
}
lista.innerHTML = espera.map(t => `
<div class="cola-card ${turnoActivo?.id === t.id ? 'activo' : ''}">
<div class="prio-dot" style="background:${t.prioridad_color}">${t.prioridad_codigo}</div>
<div class="turno-info">
<div class="cod">${escHtml(t.codigo)}</div>
<div class="pac">${escHtml(t.paciente_nombre || 'Paciente')}</div>
</div>
</div>`).join('');
}
// ── Llamar siguiente ──────────────────────────────────────────
async function llamarSiguiente() {
if (!lugarId) { alert('Primero seleccione un lugar.'); return; }
const btn = document.getElementById('btn-llamar');
btn.disabled = true;
try {
const res = await fetch(API + 'llamar_turno.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ area: 'lugar', lugar_id: lugarId }),
});
const json = await res.json();
if (!json.ok) { alert('Error: ' + json.error); return; }
if (!json.data.turno) {
alert('Cola del lugar vacía.');
return;
}
await abrirFicha(json.data.turno);
cargarCola();
} catch (err) {
alert('Error: ' + err.message);
} finally {
btn.disabled = false;
}
}
// ── Abrir ficha ───────────────────────────────────────────────
async function abrirFicha(turno) {
turnoActivo = turno;
document.getElementById('ficha-placeholder').classList.add('d-none');
document.getElementById('ficha-turno').classList.remove('d-none');
// Header
document.getElementById('ficha-codigo').textContent = turno.codigo;
document.getElementById('ficha-prio-nombre').textContent = turno.prioridad_nombre || '';
const dot = document.getElementById('ficha-prio-dot');
dot.textContent = turno.prioridad_codigo || '—';
dot.style.background = turno.prioridad_color || '#6366f1';
const badgeCod = document.getElementById('badge-turno-activo');
badgeCod.textContent = turno.codigo;
badgeCod.classList.remove('d-none');
actualizarEstadoBadge(turno.estado);
// Cargar ficha del paciente/exámenes desde la solicitud
await cargarFichaSolicitud(turno.id);
// Iniciar polling de consentimientos
clearInterval(pollingConsentId);
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
}
// ── Cargar datos de la solicitud del turno ────────────────────
async function cargarFichaSolicitud(turnoId) {
try {
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}&incluir_solicitud=1`);
const json = await res.json();
if (!json.ok) return;
const sol = json.data.solicitud;
const pac = json.data.paciente;
const exams = json.data.examenes || [];
const consts = json.data.consentimientos || [];
// Datos del paciente
if (pac) {
document.getElementById('bloque-pac-info').style.display = '';
document.getElementById('bloque-pac-sin').classList.add('d-none');
document.getElementById('pac-nombre').textContent =
(pac.full_name || (pac.nombre||'') + ' ' + (pac.apellido||'')).trim() || '—';
document.getElementById('pac-doc').textContent =
(pac.tipo_documento||'') + ' ' + (pac.documento||'') || '—';
document.getElementById('pac-fec').textContent = pac.fecha_nacimiento || '—';
document.getElementById('pac-cel').textContent = pac.celular || pac.telefono || '—';
} else {
document.getElementById('bloque-pac-info').style.display = 'none';
document.getElementById('bloque-pac-sin').classList.remove('d-none');
}
// Exámenes
const listaEx = document.getElementById('lista-examenes-ficha');
if (exams.length) {
listaEx.innerHTML = exams.map(e =>
`<span class="exam-pill"><i class="fas fa-vial"></i>${escHtml(e.codigo)} ${escHtml(e.nombre)}</span>`
).join('');
} else {
listaEx.innerHTML = '<span class="text-muted small">Sin exámenes registrados</span>';
}
// Consentimientos
renderConsentimientos(consts);
} catch (_) {}
}
// ── Consentimientos ───────────────────────────────────────────
function renderConsentimientos(lista) {
tieneConsent = lista.length > 0;
hayPendientes = lista.some(c => !['firmado', 'rechazado'].includes(c.estado));
const sec = document.getElementById('sec-consent');
const listEl = document.getElementById('lista-consent');
const sinEl = document.getElementById('sin-consent');
const banner = document.getElementById('bloqueo-banner');
const btnIni = document.getElementById('btn-iniciar');
if (!tieneConsent) {
listEl.innerHTML = '';
sinEl.classList.remove('d-none');
banner.classList.add('d-none');
btnIni.disabled = false;
return;
}
sinEl.classList.add('d-none');
banner.classList.toggle('d-none', !hayPendientes);
btnIni.disabled = hayPendientes;
listEl.innerHTML = lista.map(c => {
const cls = c.estado;
const ico = c.estado === 'firmado' ? 'fa-check-circle'
: c.estado === 'rechazado' ? 'fa-ban'
: c.estado === 'enviado' ? 'fa-envelope'
: c.estado === 'visto' ? 'fa-eye'
: 'fa-clock';
const label = c.estado === 'firmado' ? 'Firmado'
: c.estado === 'rechazado' ? 'Rechazado'
: c.estado === 'enviado' ? 'Enviado'
: c.estado === 'visto' ? 'Visto'
: 'Pendiente';
const botonesAccion = !['firmado','rechazado'].includes(c.estado) ? `
<div class="acciones-consent">
<button class="btn btn-outline-secondary btn-sm py-0" title="Reenviar WhatsApp"
onclick="reenviarConsentimiento(${c.turno_id})">
<i class="fas fa-paper-plane" style="font-size:.75rem"></i>
</button>
<button class="btn btn-outline-primary btn-sm py-0" title="Firmar presencialmente"
onclick="abrirFirmaPresencial('${c.token}', ${c.id}, ${JSON.stringify(c.formulario_nombre).replace(/"/g,'&quot;')})">
<i class="fas fa-signature" style="font-size:.75rem"></i> Firmar aquí
</button>
</div>` : '';
return `<div class="consent-row ${cls}" data-consent-id="${c.id}">
<i class="fas ${ico}"></i>
<span class="nom-form">${escHtml(c.formulario_nombre || 'Consentimiento')}</span>
<span class="badge">${escHtml(label)}</span>
${botonesAccion}
</div>`;
}).join('');
}
async function actualizarConsentimientos(turnoId) {
if (!turnoId) return;
try {
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}`);
const json = await res.json();
if (!json.ok) return;
renderConsentimientos(json.data.consentimientos || []);
} catch (_) {}
}
async function reenviarConsentimiento(turnoId) {
try {
const res = await fetch(API + 'send_consentimiento.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoId }),
});
const json = await res.json();
if (!json.ok) { alert('Error: ' + json.error); return; }
await actualizarConsentimientos(turnoId);
} catch (err) {
alert('Error: ' + err.message);
}
}
// ── Modal de firma presencial ─────────────────────────────────
function abrirFirmaPresencial(token, consentId, nombreForm) {
const url = BASE_WA + 'ver_formulario_enviado.php?token=' + encodeURIComponent(token);
document.getElementById('modal-firma-titulo').textContent = 'Firmar: ' + nombreForm;
document.getElementById('firma-iframe').src = url;
document.getElementById('modal-firma').classList.add('open');
// Al cerrar, refrescar consentimientos
window._firmaConsentId = consentId;
}
function cerrarModalFirma() {
document.getElementById('modal-firma').classList.remove('open');
document.getElementById('firma-iframe').src = '';
if (turnoActivo) actualizarConsentimientos(turnoActivo.id);
}
// Cerrar modal al hacer clic fuera del cuadro
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('modal-firma').addEventListener('click', function(e) {
if (e.target === this) cerrarModalFirma();
});
// Escuchar mensaje del iframe (firma completada desde ver_formulario_enviado.php)
window.addEventListener('message', function(e) {
if (e.data && e.data.type === 'turneroFirmado') {
cerrarModalFirma();
}
});
});
// ── Acciones del turno ────────────────────────────────────────
async function iniciarAtencion() {
if (!turnoActivo) return;
if (hayPendientes) return; // botón ya deshabilitado, doble seguro
const btn = document.getElementById('btn-iniciar');
btn.disabled = true;
try {
const res = await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: 'en_servicio' }),
});
const json = await res.json();
if (!json.ok) {
alert('Error: ' + json.error);
btn.disabled = hayPendientes;
return;
}
turnoActivo = json.data.turno;
actualizarEstadoBadge('en_servicio');
// Mostrar "Finalizar" y "Regresar", ocultar "Iniciar"
btn.classList.add('d-none');
document.getElementById('btn-finalizar').classList.remove('d-none');
document.getElementById('btn-regresar').classList.remove('d-none');
cargarCola();
} catch (err) {
alert('Error: ' + err.message);
btn.disabled = false;
}
}
async function finalizarAtencion() {
if (!turnoActivo) return;
if (!confirm(`¿Finalizar atención del turno ${turnoActivo.codigo}?`)) return;
await cambiarEstadoTurno('finalizado');
}
async function marcarAusente() {
if (!turnoActivo) return;
if (!confirm(`¿Marcar turno ${turnoActivo.codigo} como AUSENTE?`)) return;
await cambiarEstadoTurno('ausente');
}
async function regresarCola() {
if (!turnoActivo) return;
if (!confirm(`¿Regresar turno ${turnoActivo.codigo} a la cola del lugar?`)) return;
// Regresar a en_espera_lugar
try {
const res = await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
turno_id: turnoActivo.id,
nuevo_estado: 'en_espera_lugar',
lugar_id: lugarId,
}),
});
const json = await res.json();
if (!json.ok) { alert('Error: ' + json.error); return; }
resetFicha();
cargarCola();
} catch (err) {
alert('Error: ' + err.message);
}
}
async function cambiarEstadoTurno(nuevoEstado) {
try {
const res = await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: nuevoEstado }),
});
const json = await res.json();
if (!json.ok) { alert('Error: ' + json.error); return; }
resetFicha();
cargarCola();
} catch (err) {
alert('Error: ' + err.message);
}
}
// ── Reset ─────────────────────────────────────────────────────
function resetFicha() {
clearInterval(pollingConsentId);
turnoActivo = null;
tieneConsent = false;
hayPendientes = false;
document.getElementById('ficha-turno').classList.add('d-none');
document.getElementById('ficha-placeholder').classList.remove('d-none');
document.getElementById('badge-turno-activo').classList.add('d-none');
// Botones al estado inicial
document.getElementById('btn-iniciar').classList.remove('d-none');
document.getElementById('btn-iniciar').disabled = false;
document.getElementById('btn-finalizar').classList.add('d-none');
document.getElementById('btn-regresar').classList.add('d-none');
}
// ── Helpers ───────────────────────────────────────────────────
function actualizarEstadoBadge(estado) {
const el = document.getElementById('ficha-estado-badge');
const mapa = {
'en_espera_lugar': ['bg-warning-subtle text-warning border border-warning-subtle', 'En espera'],
'en_servicio': ['bg-success-subtle text-success border border-success-subtle', 'En servicio'],
'finalizado': ['bg-secondary-subtle text-secondary border', 'Finalizado'],
'ausente': ['bg-danger-subtle text-danger border border-danger-subtle', 'Ausente'],
};
const [cls, lbl] = mapa[estado] || ['bg-secondary-subtle text-secondary border', estado];
el.className = 'ms-auto badge ' + cls;
el.textContent = lbl;
}
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(str ?? '')));
return d.innerHTML;
}
</script>
</body>
</html>
+706
View File
@@ -0,0 +1,706 @@
<?php
/**
* Puesto de Recepción — Módulo Turnero
* Requiere login + módulo turnero.
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) {
header('Location: ' . BASE_URL . 'login.php');
exit;
}
// Cargar catálogos para la ficha
try {
$pdo = Database::getInstance()->getConnection();
$lugares = $pdo->query(
"SELECT id, nombre FROM turnero_lugares WHERE activo = 1 ORDER BY sort_order ASC"
)->fetchAll(PDO::FETCH_ASSOC);
$examenes = $pdo->query(
"SELECT id, codigo, nombre, categoria FROM exam_tipos WHERE activo = 1 ORDER BY categoria, nombre"
)->fetchAll(PDO::FETCH_ASSOC);
// Agrupar exámenes por categoría
$examenesAgrupados = [];
foreach ($examenes as $ex) {
$cat = $ex['categoria'] ?: 'General';
$examenesAgrupados[$cat][] = $ex;
}
} catch (\Throwable $e) {
$lugares = [];
$examenesAgrupados = [];
}
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Operador';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recepción — Turnero</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="<?= BASE_URL ?>assets/css/styles.css?v=12" rel="stylesheet">
<style>
/* ── Layout de dos columnas ── */
.rec-layout {
display: grid;
grid-template-columns: 320px 1fr;
height: calc(100vh - 56px);
overflow: hidden;
}
@media (max-width: 900px) {
.rec-layout { grid-template-columns: 1fr; }
.rec-cola { max-height: 260px; }
}
/* ── Columna cola ── */
.rec-cola {
background: #f8fafc;
border-right: 1px solid #e2e8f0;
display: flex; flex-direction: column;
overflow: hidden;
}
.rec-cola-header {
padding: 1rem 1.2rem .6rem;
background: #fff; border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
}
.cola-items { flex: 1; overflow-y: auto; padding: .5rem; }
.cola-card {
background: #fff; border: 1px solid #e2e8f0;
border-radius: 10px; padding: .65rem .9rem;
margin-bottom: .4rem; cursor: default;
display: flex; align-items: center; gap: .6rem;
transition: box-shadow .15s;
}
.cola-card.activo { border-color: #3b82f6; box-shadow: 0 0 0 2px #bfdbfe; }
.cola-card .prio-dot {
width: 36px; height: 36px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 1.1rem; font-weight: 800; color: #fff; flex-shrink: 0;
}
.cola-card .turno-info { flex: 1; min-width: 0; }
.cola-card .turno-info .cod { font-size: 1rem; font-weight: 700; }
.cola-card .turno-info .pac { font-size: .78rem; color: #64748b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cola-vacia { text-align: center; padding: 3rem 1rem; color: #94a3b8; }
/* ── Columna ficha ── */
.rec-ficha {
overflow-y: auto; padding: 1.5rem;
background: #fff;
}
.ficha-placeholder {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
height: 100%; color: #94a3b8; text-align: center;
}
/* ── Secciones de la ficha ── */
.ficha-section {
background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 12px; padding: 1.2rem; margin-bottom: 1rem;
}
.ficha-section h6 { color: #475569; font-size: .78rem;
text-transform: uppercase; letter-spacing: .08em; margin-bottom: .8rem; }
/* ── Búsqueda de paciente ── */
.pac-resultado {
border: 1px solid #e2e8f0; border-radius: 8px; padding: .5rem .75rem;
cursor: pointer; margin-top: .25rem; transition: background .12s;
}
.pac-resultado:hover { background: #f1f5f9; }
.pac-resultado .pac-nombre { font-weight: 600; font-size: .9rem; }
.pac-resultado .pac-doc { font-size: .78rem; color: #64748b; }
.pac-seleccionado {
background: #eff6ff; border: 1px solid #93c5fd;
border-radius: 8px; padding: .65rem .9rem;
display: flex; align-items: center; justify-content: space-between;
}
/* ── Checkboxes de exámenes ── */
.exam-group-title { font-size: .75rem; color: #94a3b8;
text-transform: uppercase; letter-spacing: .08em; margin: .6rem 0 .3rem; }
.exam-check-item { display: flex; align-items: center; gap: .5rem;
padding: .3rem .4rem; border-radius: 6px; cursor: pointer;
transition: background .1s; font-size: .88rem; }
.exam-check-item:hover { background: #f1f5f9; }
.exam-check-item input[type=checkbox] { cursor: pointer; }
/* ── Consentimientos ── */
.consent-item {
display: flex; align-items: center; gap: .6rem;
padding: .4rem .6rem; border-radius: 8px;
font-size: .85rem; margin-bottom: .3rem;
}
.consent-item.firmado { background: #f0fdf4; color: #166534; }
.consent-item.pendiente{ background: #fffbeb; color: #92400e; }
.consent-item.enviado { background: #eff6ff; color: #1e40af; }
/* ── Barra de acciones ── */
.ficha-acciones {
position: sticky; bottom: 0;
background: #fff; border-top: 1px solid #e2e8f0;
padding: 1rem 0 .25rem; margin-top: 1rem;
display: flex; gap: .5rem; flex-wrap: wrap;
}
/* ── Badge de turno activo en topbar ── */
.badge-turno-activo {
background: #eff6ff; color: #1d4ed8;
border: 1px solid #bfdbfe; border-radius: 8px;
padding: .25rem .75rem; font-size: .82rem; font-weight: 700;
}
</style>
</head>
<body>
<?php
$SIDEBAR_TITLE = 'Turnero';
$SIDEBAR_ICON = 'fas fa-ticket-alt';
require_once __DIR__ . '/../../../shared/components/sidebar.php';
?>
<main class="main-content" style="padding:0">
<!-- ── Top bar ──────────────────────────────────────────── -->
<div class="d-flex align-items-center justify-content-between px-3 py-2 border-bottom bg-white"
style="position:sticky;top:0;z-index:100">
<div class="d-flex align-items-center gap-2">
<i class="fas fa-ticket-alt text-primary"></i>
<strong>Recepción</strong>
<span id="badge-turno-activo" class="badge-turno-activo d-none">
Turno: <span id="badge-codigo">—</span>
</span>
</div>
<div class="d-flex align-items-center gap-2">
<span class="text-muted small"><?= htmlspecialchars($adminNombre) ?></span>
<button class="btn btn-primary btn-sm" id="btn-llamar" onclick="llamarSiguiente()">
<i class="fas fa-bell me-1"></i>Llamar siguiente
</button>
</div>
</div>
<div class="rec-layout">
<!-- ══ Columna izquierda: cola ════════════════════════ -->
<div class="rec-cola">
<div class="rec-cola-header">
<div class="d-flex align-items-center justify-content-between">
<span class="fw-semibold text-secondary small">
<i class="fas fa-list-ol me-1"></i>EN ESPERA
</span>
<span class="badge bg-primary rounded-pill" id="badge-count">0</span>
</div>
</div>
<div class="cola-items" id="cola-items">
<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>
Cola vacía
</div>
</div>
</div>
<!-- ══ Columna derecha: ficha del turno ═══════════════ -->
<div class="rec-ficha" id="rec-ficha">
<div class="ficha-placeholder" id="ficha-placeholder">
<i class="fas fa-ticket-alt fa-3x mb-3" style="color:#cbd5e1"></i>
<p class="mb-0 fw-semibold">Sin turno activo</p>
<small class="text-muted">Haga clic en "Llamar siguiente" o seleccione un turno de la cola</small>
</div>
<!-- Ficha de turno (oculta hasta llamar) -->
<div id="ficha-turno" class="d-none">
<!-- Encabezado del turno -->
<div class="d-flex align-items-center gap-3 mb-3">
<div id="ficha-prio-dot" class="prio-dot"
style="width:52px;height:52px;border-radius:12px;display:flex;align-items:center;
justify-content:center;font-size:1.6rem;font-weight:900;color:#fff;background:#3b82f6">
</div>
<div>
<div class="h4 mb-0 fw-bold" id="ficha-codigo">—</div>
<div class="text-muted small" id="ficha-prio-nombre">—</div>
</div>
</div>
<!-- ── Sección 1: Paciente ── -->
<div class="ficha-section">
<h6><i class="fas fa-user me-1"></i>Paciente</h6>
<!-- Si el kiosko capturó un nombre -->
<div id="bloque-nombre-kiosko" class="d-none mb-2">
<small class="text-muted">Nombre del kiosko:</small>
<div class="fw-semibold" id="lbl-nombre-kiosko"></div>
</div>
<div id="bloque-pac-no-vinculado">
<div class="input-group mb-2">
<input type="text" id="inp-buscar-pac"
class="form-control form-control-sm"
placeholder="Buscar por nombre o documento…"
autocomplete="off">
<button class="btn btn-outline-secondary btn-sm" onclick="buscarPaciente()" type="button">
<i class="fas fa-search"></i>
</button>
</div>
<div id="lista-pacientes-res"></div>
</div>
<div id="bloque-pac-seleccionado" class="d-none">
<div class="pac-seleccionado">
<div>
<div class="fw-semibold" id="lbl-pac-nombre">—</div>
<div class="text-muted small" id="lbl-pac-doc">—</div>
<div class="text-muted small" id="lbl-pac-cel"></div>
</div>
<button class="btn btn-link btn-sm text-secondary p-0" onclick="desvincularPaciente()">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<!-- ── Sección 2: Lugar destino ── -->
<div class="ficha-section">
<h6><i class="fas fa-map-marker-alt me-1"></i>Lugar destino</h6>
<select id="sel-lugar" class="form-select form-select-sm">
<option value="">— Seleccione lugar —</option>
<?php foreach ($lugares as $l): ?>
<option value="<?= (int)$l['id'] ?>"><?= htmlspecialchars($l['nombre']) ?></option>
<?php endforeach; ?>
</select>
</div>
<!-- ── Sección 3: Exámenes ── -->
<div class="ficha-section">
<h6><i class="fas fa-vial me-1"></i>Exámenes solicitados</h6>
<div id="lista-examenes">
<?php foreach ($examenesAgrupados as $cat => $items): ?>
<div class="exam-group-title"><?= htmlspecialchars($cat) ?></div>
<?php foreach ($items as $ex): ?>
<label class="exam-check-item">
<input type="checkbox" class="exam-chk"
value="<?= (int)$ex['id'] ?>"
data-nombre="<?= htmlspecialchars($ex['nombre']) ?>">
<span><strong><?= htmlspecialchars($ex['codigo']) ?></strong>
— <?= htmlspecialchars($ex['nombre']) ?></span>
</label>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
<div class="mt-2 d-flex gap-2 flex-wrap">
<button class="btn btn-outline-secondary btn-sm" onclick="toggleTodosExamenes(false)">
Limpiar
</button>
</div>
</div>
<!-- ── Sección 4: Pago ── -->
<div class="ficha-section">
<h6><i class="fas fa-dollar-sign me-1"></i>Cobro (opcional)</h6>
<div class="row g-2">
<div class="col-6">
<input type="number" id="inp-total" class="form-control form-control-sm"
placeholder="Total $" step="100" min="0">
</div>
<div class="col-6">
<select id="sel-pago" class="form-select form-select-sm">
<option value="">— Forma de pago —</option>
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
<option value="tarjeta">Tarjeta</option>
<option value="eps">EPS / Convenio</option>
<option value="cortesia">Cortesía</option>
</select>
</div>
</div>
<textarea id="inp-obs" class="form-control form-control-sm mt-2"
rows="2" placeholder="Observaciones…" maxlength="500"></textarea>
</div>
<!-- ── Sección 5: Consentimientos ── -->
<div class="ficha-section" id="sec-consentimientos" style="display:none!important">
<h6><i class="fas fa-file-signature me-1"></i>Consentimientos</h6>
<div id="lista-consentimientos"></div>
<button class="btn btn-outline-primary btn-sm mt-2"
id="btn-reenviar-consent" onclick="enviarConsentimientos()">
<i class="fas fa-paper-plane me-1"></i>Enviar / Reenviar por WhatsApp
</button>
</div>
<!-- ── Acciones ── -->
<div class="ficha-acciones">
<button class="btn btn-success" id="btn-guardar" onclick="guardarSolicitud()">
<i class="fas fa-save me-1"></i>Guardar solicitud
</button>
<button class="btn btn-primary d-none" id="btn-pasar-lugar" onclick="pasarALugar()">
<i class="fas fa-arrow-right me-1"></i>Pasar a lugar
</button>
<button class="btn btn-warning d-none" id="btn-enviar-consent" onclick="enviarConsentimientos()">
<i class="fas fa-paper-plane me-1"></i>Enviar consentimientos
</button>
<button class="btn btn-secondary ms-auto" onclick="marcarAusente()">
<i class="fas fa-user-slash me-1"></i>Ausente
</button>
</div>
</div><!-- /ficha-turno -->
</div><!-- /rec-ficha -->
</div><!-- /rec-layout -->
</main>
<script>
// ── Estado ────────────────────────────────────────────────────
let turnoActivo = null;
let pacienteActivo = null;
let solicitudActiva = null;
let consentimientos = [];
let pollingColaId = null;
const API = '../api/';
const API_PAC = '<?= BASE_URL ?>api/lab/get_pacientes.php';
// ── Arranque ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
cargarCola();
pollingColaId = setInterval(cargarCola, 8000);
document.getElementById('inp-buscar-pac')
.addEventListener('keydown', e => { if (e.key === 'Enter') buscarPaciente(); });
});
// ── Cola ──────────────────────────────────────────────────────
async function cargarCola() {
try {
const res = await fetch(API + 'get_cola.php?area=recepcion');
const json = await res.json();
if (!json.ok) return;
renderCola(json.data);
} catch (_) {}
}
function renderCola(snap) {
const lista = document.getElementById('cola-items');
const espera = (snap.cola || []).filter(t => t.estado === 'espera');
const badge = document.getElementById('badge-count');
badge.textContent = espera.length;
if (espera.length === 0) {
lista.innerHTML = `<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>Cola vacía</div>`;
return;
}
lista.innerHTML = espera.map(t => `
<div class="cola-card ${turnoActivo?.id === t.id ? 'activo' : ''}"
onclick="seleccionarTurno(${t.id})">
<div class="prio-dot" style="background:${t.prioridad_color}">${t.prioridad_codigo}</div>
<div class="turno-info">
<div class="cod">${escHtml(t.codigo)}</div>
<div class="pac">${escHtml(t.paciente_nombre || 'Paciente')}</div>
</div>
</div>`).join('');
}
// ── Llamar siguiente ──────────────────────────────────────────
async function llamarSiguiente() {
const btn = document.getElementById('btn-llamar');
btn.disabled = true;
try {
const res = await fetch(API + 'llamar_turno.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ area: 'recepcion' }),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
if (!json.data.turno) {
mostrarAviso('Cola vacía', 'No hay turnos en espera.');
return;
}
abrirFicha(json.data.turno);
cargarCola();
} catch (err) {
mostrarError(err.message);
} finally {
btn.disabled = false;
}
}
// ── Seleccionar turno de la cola (para ver ficha de uno ya llamado) ─
async function seleccionarTurno(turnoId) {
// No abrir si ya hay uno en recepción (confuso)
if (turnoActivo && turnoActivo.estado === 'en_recepcion') return;
}
// ── Abrir ficha ───────────────────────────────────────────────
function abrirFicha(turno) {
turnoActivo = turno;
pacienteActivo = null;
solicitudActiva = null;
document.getElementById('ficha-placeholder').classList.add('d-none');
document.getElementById('ficha-turno').classList.remove('d-none');
// Datos del turno
document.getElementById('ficha-codigo').textContent = turno.codigo;
document.getElementById('ficha-prio-nombre').textContent = turno.prioridad_nombre || '';
const dot = document.getElementById('ficha-prio-dot');
dot.textContent = turno.prioridad_codigo || '—';
dot.style.background = turno.prioridad_color || '#3b82f6';
// Badge top
document.getElementById('badge-turno-activo').classList.remove('d-none');
document.getElementById('badge-codigo').textContent = turno.codigo;
// Nombre del kiosko
if (turno.paciente_nombre) {
document.getElementById('bloque-nombre-kiosko').classList.remove('d-none');
document.getElementById('lbl-nombre-kiosko').textContent = turno.paciente_nombre;
document.getElementById('inp-buscar-pac').value = turno.paciente_nombre;
}
// Reset secciones
resetCheckboxes();
document.getElementById('sec-consentimientos').style.removeProperty('display');
document.getElementById('sec-consentimientos').style.display = 'none';
document.getElementById('btn-pasar-lugar').classList.add('d-none');
document.getElementById('btn-enviar-consent').classList.add('d-none');
document.getElementById('btn-guardar').classList.remove('d-none');
document.getElementById('btn-guardar').disabled = false;
desvincularPaciente();
// Buscar consentimientos si ya tiene solicitud
verificarSolicitudExistente(turno.id);
}
async function verificarSolicitudExistente(turnoId) {
try {
const res = await fetch(API + 'get_cola.php?area=recepcion');
// La solicitud se carga solo después de guardar
} catch (_) {}
}
// ── Buscador de paciente ──────────────────────────────────────
async function buscarPaciente() {
const q = document.getElementById('inp-buscar-pac').value.trim();
if (q.length < 2) return;
try {
const res = await fetch(`${API_PAC}?busqueda=${encodeURIComponent(q)}&limit=8`);
const json = await res.json();
const lista = document.getElementById('lista-pacientes-res');
const datos = json.data || json.registros || [];
if (!datos.length) {
lista.innerHTML = `<div class="text-muted small mt-1">Sin resultados.
<a href="#" onclick="abrirNuevoPaciente('${escHtml(q)}');return false">Crear nuevo</a></div>`;
return;
}
lista.innerHTML = datos.map(p => `
<div class="pac-resultado" onclick="seleccionarPaciente(${JSON.stringify(p).replace(/"/g,'&quot;')})">
<div class="pac-nombre">${escHtml((p.full_name||p.nombre||'') + ' ' + (p.apellido||''))}</div>
<div class="pac-doc">${escHtml(p.tipo_documento||'')} ${escHtml(p.documento||p.numero_documento||'')}
${p.telefono||p.celular ? '· ' + escHtml(p.telefono||p.celular) : ''}</div>
</div>`).join('');
} catch (_) {}
}
function seleccionarPaciente(pac) {
pacienteActivo = pac;
document.getElementById('bloque-pac-no-vinculado').classList.add('d-none');
document.getElementById('bloque-pac-seleccionado').classList.remove('d-none');
document.getElementById('lbl-pac-nombre').textContent =
(pac.full_name || (pac.nombre||'') + ' ' + (pac.apellido||'')).trim();
document.getElementById('lbl-pac-doc').textContent =
(pac.tipo_documento||'') + ' ' + (pac.documento||pac.numero_documento||'');
document.getElementById('lbl-pac-cel').textContent =
pac.telefono || pac.celular || '';
document.getElementById('lista-pacientes-res').innerHTML = '';
}
function desvincularPaciente() {
pacienteActivo = null;
document.getElementById('bloque-pac-no-vinculado').classList.remove('d-none');
document.getElementById('bloque-pac-seleccionado').classList.add('d-none');
document.getElementById('lista-pacientes-res').innerHTML = '';
document.getElementById('inp-buscar-pac').value = '';
}
function abrirNuevoPaciente(nombre) {
window.open('<?= BASE_URL ?>lab_pacientes.php?nuevo=1&nombre=' + encodeURIComponent(nombre), '_blank');
}
// ── Guardar solicitud ─────────────────────────────────────────
async function guardarSolicitud() {
if (!turnoActivo) return;
if (!pacienteActivo) { mostrarError('Seleccione un paciente antes de guardar.'); return; }
const lugarId = parseInt(document.getElementById('sel-lugar').value);
if (!lugarId) { mostrarError('Seleccione el lugar destino.'); return; }
const examIds = Array.from(document.querySelectorAll('.exam-chk:checked')).map(c => parseInt(c.value));
if (!examIds.length) { mostrarError('Seleccione al menos un examen.'); return; }
const btn = document.getElementById('btn-guardar');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando…';
try {
const res = await fetch(API + 'create_solicitud.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
turno_id: turnoActivo.id,
paciente_id: pacienteActivo.id,
lugar_id: lugarId,
exam_tipo_ids: examIds,
total_cobrado: parseFloat(document.getElementById('inp-total').value) || null,
metodo_pago: document.getElementById('sel-pago').value || null,
observaciones: document.getElementById('inp-obs').value.trim() || null,
}),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
solicitudActiva = json.data.solicitud;
consentimientos = json.data.consentimientos_requeridos || [];
btn.innerHTML = '<i class="fas fa-check me-1"></i>Guardado';
// Mostrar sección de consentimientos si aplica
if (consentimientos.length > 0) {
renderConsentimientos(consentimientos);
document.getElementById('sec-consentimientos').style.display = '';
document.getElementById('btn-enviar-consent').classList.remove('d-none');
}
document.getElementById('btn-pasar-lugar').classList.remove('d-none');
document.getElementById('btn-guardar').classList.add('d-none');
} catch (err) {
mostrarError(err.message);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar solicitud';
}
}
function renderConsentimientos(lista) {
document.getElementById('lista-consentimientos').innerHTML = lista.map(c => {
const cls = c.estado === 'firmado' ? 'firmado'
: c.estado === 'enviado' ? 'enviado'
: 'pendiente';
const ico = c.estado === 'firmado' ? 'fa-check-circle'
: c.estado === 'enviado' ? 'fa-envelope'
: 'fa-clock';
return `<div class="consent-item ${cls}">
<i class="fas ${ico}"></i>
<span class="flex-1">${escHtml(c.formulario_nombre || 'Consentimiento')}</span>
<span class="badge">${escHtml(c.estado)}</span>
</div>`;
}).join('');
}
// ── Enviar consentimientos ────────────────────────────────────
async function enviarConsentimientos() {
if (!turnoActivo || !solicitudActiva) return;
const btn = document.getElementById('btn-enviar-consent');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Enviando…';
try {
const res = await fetch(API + 'send_consentimiento.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id }),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
consentimientos = json.data.consentimientos || consentimientos;
renderConsentimientos(consentimientos);
btn.innerHTML = '<i class="fas fa-check me-1"></i>Enviado';
} catch (err) {
mostrarError(err.message);
btn.innerHTML = '<i class="fas fa-paper-plane me-1"></i>Enviar consentimientos';
} finally {
btn.disabled = false;
}
}
// ── Pasar a lugar ─────────────────────────────────────────────
async function pasarALugar() {
if (!turnoActivo || !solicitudActiva) return;
const btn = document.getElementById('btn-pasar-lugar');
btn.disabled = true;
try {
const res = await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
turno_id: turnoActivo.id,
nuevo_estado: 'en_espera_lugar',
lugar_id: solicitudActiva.lugar_id,
}),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); btn.disabled = false; return; }
// Reiniciar ficha
turnoActivo = null;
solicitudActiva = null;
document.getElementById('ficha-turno').classList.add('d-none');
document.getElementById('ficha-placeholder').classList.remove('d-none');
document.getElementById('badge-turno-activo').classList.add('d-none');
cargarCola();
} catch (err) {
mostrarError(err.message);
btn.disabled = false;
}
}
// ── Ausente ───────────────────────────────────────────────────
async function marcarAusente() {
if (!turnoActivo) return;
if (!confirm(`¿Marcar turno ${turnoActivo.codigo} como AUSENTE?`)) return;
await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: 'ausente' }),
});
turnoActivo = null;
solicitudActiva = null;
document.getElementById('ficha-turno').classList.add('d-none');
document.getElementById('ficha-placeholder').classList.remove('d-none');
document.getElementById('badge-turno-activo').classList.add('d-none');
cargarCola();
}
// ── Helpers ───────────────────────────────────────────────────
function resetCheckboxes() {
document.querySelectorAll('.exam-chk').forEach(c => c.checked = false);
}
function toggleTodosExamenes(val) {
document.querySelectorAll('.exam-chk').forEach(c => c.checked = val);
}
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(str)));
return d.innerHTML;
}
function mostrarError(msg) {
alert('Error: ' + msg);
}
function mostrarAviso(titulo, msg) {
alert(titulo + '\n' + msg);
}
</script>
</body>
</html>