up
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
]),
|
||||
]);
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'],
|
||||
],
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// ── 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function escAtt(s) { return String(s||'').replace(/"/g,'"'); }
|
||||
})();
|
||||
</script>
|
||||
<?php Layout::close(); ?>
|
||||
@@ -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,'"')})">
|
||||
<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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function escAtt(s) { return String(s||'').replace(/"/g,'"'); }
|
||||
|
||||
// ── Inicio ───────────────────────────────────────────────
|
||||
cargarOrden();
|
||||
})();
|
||||
</script>
|
||||
<?php Layout::close(); ?>
|
||||
Reference in New Issue
Block a user