feat(turnero): Bandeja del día — lista de pacientes con detalle y cierre
Vista split: lista ordenada por numero_orden (activos arriba, cerrados abajo), stats bar con totales, panel de detalle con exámenes por categoría, muestras, consentimientos, comentarios, PDF via window.print() y acción de cerrar turno. Auto-refresh cada 30s. Visible para bacteriólogos y admins. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3c3e531e48
commit
0c6e522106
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/api/get_bandeja.php
|
||||
* GET → lista + stats del día (sesion activa)
|
||||
* GET ?turno_id=X → detalle completo de un turno
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireTurnero();
|
||||
|
||||
$pdo = db();
|
||||
|
||||
$sesionId = (int)($pdo->query(
|
||||
"SELECT id FROM turnero_sesiones ORDER BY id DESC LIMIT 1"
|
||||
)->fetchColumn() ?: 0);
|
||||
|
||||
// ── DETALLE ───────────────────────────────────────────────────
|
||||
if (isset($_GET['turno_id'])) {
|
||||
$tid = (int)$_GET['turno_id'];
|
||||
|
||||
$turno = $pdo->prepare("
|
||||
SELECT t.id, t.codigo, t.estado, t.paciente_nombre, t.inicio_lugar_at, t.fin_lugar_at,
|
||||
ts.id AS sol_id, ts.numero_orden,
|
||||
p.tipo_documento, p.numero_documento, p.fecha_nacimiento, p.telefono,
|
||||
l.nombre AS lugar_nombre
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_solicitudes ts ON ts.turno_id = t.id
|
||||
LEFT JOIN lab_pacientes p ON p.id = ts.paciente_id
|
||||
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||||
WHERE t.id = ? LIMIT 1
|
||||
");
|
||||
$turno->execute([$tid]);
|
||||
$data = $turno->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$data) jsonError('Turno no encontrado', 404);
|
||||
|
||||
$solId = (int)$data['sol_id'];
|
||||
|
||||
// Exámenes agrupados por categoría
|
||||
$stmtEx = $pdo->prepare("
|
||||
SELECT et.codigo, et.nombre, COALESCE(NULLIF(et.categoria,''),'Otros') AS categoria
|
||||
FROM turnero_examen_items tei
|
||||
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||
WHERE tei.solicitud_id = ?
|
||||
ORDER BY categoria, et.nombre
|
||||
");
|
||||
$stmtEx->execute([$solId]);
|
||||
$examenesPorCat = [];
|
||||
foreach ($stmtEx->fetchAll(PDO::FETCH_ASSOC) as $e) {
|
||||
$examenesPorCat[$e['categoria']][] = $e['codigo'];
|
||||
}
|
||||
|
||||
// Muestras
|
||||
$stmtM = $pdo->prepare("
|
||||
SELECT tm.id, tm.tipo_muestra, tm.estado, tm.motivo_rechazo, tm.recibida_at
|
||||
FROM turnero_muestras tm
|
||||
WHERE tm.solicitud_id = ?
|
||||
ORDER BY tm.id
|
||||
");
|
||||
$stmtM->execute([$solId]);
|
||||
$muestras = $stmtM->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Consentimientos
|
||||
$stmtC = $pdo->prepare("
|
||||
SELECT tc.id, tc.estado, tc.token, tc.firmado_at,
|
||||
lf.nombre AS formulario_nombre
|
||||
FROM turnero_consentimientos tc
|
||||
JOIN lab_formularios lf ON lf.id = tc.formulario_id
|
||||
WHERE tc.turno_id = ?
|
||||
ORDER BY tc.id
|
||||
");
|
||||
$stmtC->execute([$tid]);
|
||||
$consentimientos = $stmtC->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Comentarios (todos los tipos)
|
||||
$stmtCom = $pdo->prepare("
|
||||
SELECT tipo, usuario_nombre, comentario, creado_at
|
||||
FROM turnero_comentarios
|
||||
WHERE turno_id = ?
|
||||
ORDER BY creado_at ASC
|
||||
");
|
||||
$stmtCom->execute([$tid]);
|
||||
$comentarios = $stmtCom->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
jsonOk([
|
||||
'turno' => $data,
|
||||
'examenes_por_cat'=> $examenesPorCat,
|
||||
'muestras' => $muestras,
|
||||
'consentimientos' => $consentimientos,
|
||||
'comentarios' => $comentarios,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── LISTA ─────────────────────────────────────────────────────
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT t.id, t.codigo, t.paciente_nombre, t.estado,
|
||||
t.inicio_lugar_at, t.fin_lugar_at,
|
||||
ts.numero_orden,
|
||||
l.nombre AS lugar_nombre
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_solicitudes ts ON ts.turno_id = t.id
|
||||
JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||||
WHERE t.sesion_id = ? AND t.inicio_lugar_at IS NOT NULL
|
||||
ORDER BY ts.numero_orden ASC
|
||||
");
|
||||
$stmt->execute([$sesionId]);
|
||||
$todos = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$cerradosEstados = ['finalizado', 'ausente', 'cancelado'];
|
||||
$activos = array_values(array_filter($todos, fn($t) => !in_array($t['estado'], $cerradosEstados)));
|
||||
$cerrados = array_values(array_filter($todos, fn($t) => in_array($t['estado'], $cerradosEstados)));
|
||||
|
||||
$stmtMp = $pdo->prepare("
|
||||
SELECT COUNT(*) FROM turnero_muestras tm
|
||||
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
|
||||
JOIN turnero_turnos t ON t.id = ts.turno_id
|
||||
WHERE t.sesion_id = ? AND tm.estado = 'pendiente'
|
||||
");
|
||||
$stmtMp->execute([$sesionId]);
|
||||
$muestrasPendientes = (int)$stmtMp->fetchColumn();
|
||||
|
||||
jsonOk([
|
||||
'activos' => $activos,
|
||||
'cerrados' => $cerrados,
|
||||
'stats' => [
|
||||
'total' => count($todos),
|
||||
'activos' => count($activos),
|
||||
'cerrados' => count($cerrados),
|
||||
'muestras_pendientes' => $muestrasPendientes,
|
||||
],
|
||||
]);
|
||||
@@ -105,6 +105,8 @@ if ($_trIsRecep) {
|
||||
}
|
||||
} catch (\Throwable $_) {}
|
||||
|
||||
$_trLinks[] = ['name' => 'Bandeja del día', 'icon' => 'fas fa-layer-group', 'route' => '/erp.php?m=turnero&v=bandeja'];
|
||||
|
||||
if ($_trIpLugar) {
|
||||
// IP registrada: solo ese lugar en el sidebar
|
||||
foreach ($_trMuestras as $_m) {
|
||||
@@ -133,6 +135,7 @@ if ($_trIsRecep) {
|
||||
} else {
|
||||
$_trLinks[] = ['name' => 'Dashboard', 'icon' => 'fas fa-tachometer-alt', 'route' => '/erp.php?m=turnero&v=dashboard'];
|
||||
$_trLinks[] = ['name' => 'Historial', 'icon' => 'fas fa-history', 'route' => '/erp.php?m=turnero&v=historial'];
|
||||
$_trLinks[] = ['name' => 'Bandeja del día', 'icon' => 'fas fa-layer-group', 'route' => '/erp.php?m=turnero&v=bandeja'];
|
||||
$_trLinks[] = ['name' => 'Chat Turnero', 'icon' => 'fab fa-whatsapp', 'route' => '/erp.php?m=turnero&v=chat'];
|
||||
$_trLinks[] = ['name' => 'Verificar Paciente', 'icon' => 'fas fa-id-card', 'route' => '/erp.php?m=turnero&v=verificar_paciente'];
|
||||
$_trLinks[] = ['name' => 'Configuración', 'icon' => 'fas fa-sliders-h', 'route' => '/erp.php?m=turnero&v=configuracion'];
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
<?php
|
||||
Layout::open('Bandeja del día', 'fas fa-layer-group');
|
||||
?>
|
||||
<style>
|
||||
/* ── Layout principal ───────────────────────────────────────── */
|
||||
.bnd-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 60px); /* 60px = altura navbar ERP */
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.bnd-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 18px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.bnd-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.bnd-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.bnd-chip-total { background:#ede9fe; color:#5b21b6; }
|
||||
.bnd-chip-activos { background:#dcfce7; color:#15803d; }
|
||||
.bnd-chip-cerr { background:#f1f5f9; color:#475569; }
|
||||
.bnd-chip-mp { background:#fff7ed; color:#c2410c; }
|
||||
.bnd-refresh-btn {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.78rem;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.bnd-refresh-btn:hover { background:#f1f5f9; }
|
||||
|
||||
/* ── Cuerpo split ───────────────────────────────────────────── */
|
||||
.bnd-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Lista izquierda ────────────────────────────────────────── */
|
||||
.bnd-list {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
}
|
||||
.bnd-section-hdr {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 6px 14px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.bnd-card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
transition: background .12s;
|
||||
position: relative;
|
||||
}
|
||||
.bnd-card:hover { background: #f8fafc; }
|
||||
.bnd-card.selected { background: #eef2ff; }
|
||||
.bnd-card-bar {
|
||||
width: 3px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.bnd-card-bar.activo { background: #22c55e; }
|
||||
.bnd-card-bar.cerrado { background: #cbd5e1; }
|
||||
.bnd-card-inner {
|
||||
padding: 9px 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.bnd-card-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.bnd-orden {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: #7c3aed;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bnd-nombre {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.bnd-card.cerrado .bnd-nombre { color: #94a3b8; }
|
||||
.bnd-card-bot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.bnd-lugar-badge {
|
||||
font-size: 0.68rem;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bnd-hora {
|
||||
font-size: 0.68rem;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* ── Detalle derecha ────────────────────────────────────────── */
|
||||
.bnd-detail {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.bnd-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #cbd5e1;
|
||||
gap: 10px;
|
||||
}
|
||||
.bnd-empty i { font-size: 3rem; }
|
||||
.bnd-empty p { font-size: 0.9rem; }
|
||||
|
||||
/* Detail header */
|
||||
.bnd-dh {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 14px 20px 12px;
|
||||
}
|
||||
.bnd-dh-nombre {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.bnd-dh-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.bnd-dh-meta span {
|
||||
font-size: 0.78rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.bnd-dh-meta .sep { color: #cbd5e1; }
|
||||
.bnd-dh-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.bnd-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1.5px solid;
|
||||
transition: all .12s;
|
||||
}
|
||||
.bnd-btn-pdf { border-color:#3b82f6; color:#2563eb; background:#fff; }
|
||||
.bnd-btn-pdf:hover { background:#eff6ff; }
|
||||
.bnd-btn-cerrar { border-color:#22c55e; color:#15803d; background:#fff; }
|
||||
.bnd-btn-cerrar:hover { background:#f0fdf4; }
|
||||
.bnd-btn-cerrado { border-color:#cbd5e1; color:#94a3b8; background:#f8fafc; cursor:default; }
|
||||
|
||||
/* Sections */
|
||||
.bnd-sec {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.bnd-sec-title {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .07em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Exámenes chips */
|
||||
.bnd-cat { margin-bottom: 8px; }
|
||||
.bnd-cat-name {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.bnd-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.bnd-exam-chip {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Muestras */
|
||||
.bnd-muestra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.bnd-m-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bnd-m-dot.pendiente { background: #f59e0b; }
|
||||
.bnd-m-dot.recibida { background: #22c55e; }
|
||||
.bnd-m-dot.rechazada { background: #ef4444; }
|
||||
.bnd-m-label { font-weight: 600; color: #1e293b; }
|
||||
.bnd-m-estado { color: #64748b; font-size: 0.75rem; }
|
||||
.bnd-m-rechazado { color: #ef4444; font-size: 0.73rem; }
|
||||
|
||||
/* Consentimientos */
|
||||
.bnd-consent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.bnd-consent-ico { font-size: 0.85rem; }
|
||||
.bnd-consent-ico.firmado { color: #22c55e; }
|
||||
.bnd-consent-ico.pendiente{ color: #f59e0b; }
|
||||
.bnd-consent a { font-size: 0.75rem; color: #3b82f6; margin-left: 4px; }
|
||||
|
||||
/* Comentarios timeline */
|
||||
.bnd-com-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.bnd-com {
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.bnd-com-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.bnd-com-tipo {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.bnd-com-tipo.recepcion { background: #dbeafe; color: #1d4ed8; }
|
||||
.bnd-com-tipo.muestras { background: #dcfce7; color: #15803d; }
|
||||
.bnd-com-tipo.general { background: #fef3c7; color: #92400e; }
|
||||
.bnd-com-user { font-weight: 600; color: #1e293b; }
|
||||
.bnd-com-date { font-size: 0.7rem; color: #94a3b8; margin-left: auto; }
|
||||
.bnd-com-text { color: #334155; }
|
||||
|
||||
/* Agregar comentario */
|
||||
.bnd-add-com { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.bnd-add-com textarea {
|
||||
flex: 1;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.82rem;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
height: 56px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.bnd-add-com textarea:focus { border-color: #7c3aed; }
|
||||
.bnd-add-com button {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.bnd-add-com button:hover { background: #6d28d9; }
|
||||
.bnd-add-com button:disabled { background: #c4b5fd; cursor:default; }
|
||||
|
||||
/* Toast */
|
||||
.bnd-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: #1e293b;
|
||||
color: #fff;
|
||||
padding: 8px 18px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.82rem;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
transition: all .25s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.bnd-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
|
||||
/* ── Print ──────────────────────────────────────────────────── */
|
||||
@media print {
|
||||
.bnd-list, .bnd-topbar, .bnd-dh-actions,
|
||||
.bnd-add-com, nav, .sidebar, .erp-sidebar,
|
||||
#sidebar, .navbar, #navbar { display: none !important; }
|
||||
.bnd-wrap, .bnd-body { height: auto !important; overflow: visible !important; }
|
||||
.bnd-detail { overflow: visible !important; }
|
||||
.bnd-dh { position: static !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="bnd-wrap">
|
||||
<!-- Stats bar -->
|
||||
<div class="bnd-topbar" id="bnd-topbar">
|
||||
<span class="bnd-title"><i class="fas fa-layer-group"></i> Bandeja del día</span>
|
||||
<span class="bnd-chip bnd-chip-total" id="st-total"><i class="fas fa-users"></i> —</span>
|
||||
<span class="bnd-chip bnd-chip-activos" id="st-activos"><i class="fas fa-circle" style="font-size:.55rem"></i> Activos: —</span>
|
||||
<span class="bnd-chip bnd-chip-cerr" id="st-cerr"><i class="fas fa-check-double"></i> Cerrados: —</span>
|
||||
<span class="bnd-chip bnd-chip-mp" id="st-mp"><i class="fas fa-exclamation-triangle"></i> Muestras pend.: —</span>
|
||||
<button class="bnd-refresh-btn" onclick="_bndRefrescarLista()">
|
||||
<i class="fas fa-sync-alt" id="bnd-spin"></i> Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bnd-body">
|
||||
<!-- Lista -->
|
||||
<div class="bnd-list" id="bnd-list">
|
||||
<div class="bnd-section-hdr">Cargando...</div>
|
||||
</div>
|
||||
|
||||
<!-- Detalle -->
|
||||
<div class="bnd-detail" id="bnd-detail">
|
||||
<div class="bnd-empty">
|
||||
<i class="fas fa-hand-pointer"></i>
|
||||
<p>Selecciona un paciente para ver el detalle</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bnd-toast" id="bnd-toast"></div>
|
||||
|
||||
<script>
|
||||
const BND_API = 'modules/turnero/api/get_bandeja.php';
|
||||
const COM_API = 'modules/turnero/api/comentarios.php';
|
||||
const BASE_URL = 'https://erp.laboratorioximenacaicedo.com/';
|
||||
|
||||
let _bndTurnoId = null;
|
||||
let _bndInterval = null;
|
||||
let _bndListData = { activos: [], cerrados: [] };
|
||||
|
||||
// ── Utilidades ───────────────────────────────────────────────
|
||||
function _hora(dt) {
|
||||
if (!dt) return '';
|
||||
const d = new Date(dt.replace(' ', 'T'));
|
||||
return d.toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
function _toast(msg, ms = 2200) {
|
||||
const t = document.getElementById('bnd-toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), ms);
|
||||
}
|
||||
|
||||
// ── Lista ─────────────────────────────────────────────────────
|
||||
async function _bndRefrescarLista() {
|
||||
const spin = document.getElementById('bnd-spin');
|
||||
spin.style.transition = 'transform .4s';
|
||||
spin.style.transform = 'rotate(360deg)';
|
||||
setTimeout(() => { spin.style.transition=''; spin.style.transform=''; }, 400);
|
||||
|
||||
const res = await fetch(BND_API).then(r => r.json()).catch(() => null);
|
||||
if (!res?.ok) return;
|
||||
|
||||
_bndListData = res.data;
|
||||
const s = res.data.stats;
|
||||
document.getElementById('st-total').innerHTML = `<i class="fas fa-users"></i> Total: ${s.total}`;
|
||||
document.getElementById('st-activos').innerHTML = `<i class="fas fa-circle" style="font-size:.55rem"></i> Activos: ${s.activos}`;
|
||||
document.getElementById('st-cerr').innerHTML = `<i class="fas fa-check-double"></i> Cerrados: ${s.cerrados}`;
|
||||
document.getElementById('st-mp').innerHTML = `<i class="fas fa-exclamation-triangle"></i> Muestras pend.: ${s.muestras_pendientes}`;
|
||||
|
||||
_renderLista(res.data.activos, res.data.cerrados);
|
||||
}
|
||||
|
||||
function _renderLista(activos, cerrados) {
|
||||
const el = document.getElementById('bnd-list');
|
||||
let html = '';
|
||||
|
||||
const card = (t, tipo) => {
|
||||
const sel = t.id === _bndTurnoId ? ' selected' : '';
|
||||
const orden = t.numero_orden || t.codigo;
|
||||
const nombre = (t.paciente_nombre || '—').split(' ').slice(0,3).join(' ');
|
||||
return `<div class="bnd-card ${tipo}${sel}" onclick="_bndVerDetalle(${t.id})">
|
||||
<div class="bnd-card-bar ${tipo}"></div>
|
||||
<div class="bnd-card-inner">
|
||||
<div class="bnd-card-top">
|
||||
<span class="bnd-orden">${orden}</span>
|
||||
<span class="bnd-nombre">${nombre}</span>
|
||||
</div>
|
||||
<div class="bnd-card-bot">
|
||||
<span class="bnd-lugar-badge">${t.lugar_nombre || ''}</span>
|
||||
<span class="bnd-hora">${_hora(t.inicio_lugar_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
html += `<div class="bnd-section-hdr">Activos (${activos.length})</div>`;
|
||||
if (!activos.length) html += `<div style="padding:12px 14px;font-size:.78rem;color:#94a3b8">Sin pacientes activos</div>`;
|
||||
activos.forEach(t => html += card(t, 'activo'));
|
||||
|
||||
html += `<div class="bnd-section-hdr" style="top:0">Cerrados (${cerrados.length})</div>`;
|
||||
if (!cerrados.length) html += `<div style="padding:12px 14px;font-size:.78rem;color:#94a3b8">Ninguno cerrado</div>`;
|
||||
cerrados.forEach(t => html += card(t, 'cerrado'));
|
||||
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Detalle ───────────────────────────────────────────────────
|
||||
async function _bndVerDetalle(turnoId) {
|
||||
_bndTurnoId = turnoId;
|
||||
// Resaltar en lista
|
||||
document.querySelectorAll('.bnd-card').forEach(c => c.classList.remove('selected'));
|
||||
document.querySelectorAll('.bnd-card').forEach(c => {
|
||||
if (c.onclick?.toString().includes(`${turnoId}`)) c.classList.add('selected');
|
||||
});
|
||||
|
||||
const det = document.getElementById('bnd-detail');
|
||||
det.innerHTML = `<div class="bnd-empty"><i class="fas fa-spinner fa-spin"></i><p>Cargando...</p></div>`;
|
||||
|
||||
const res = await fetch(`${BND_API}?turno_id=${turnoId}`).then(r => r.json()).catch(() => null);
|
||||
if (!res?.ok) { det.innerHTML = `<div class="bnd-empty"><i class="fas fa-times-circle"></i><p>Error al cargar</p></div>`; return; }
|
||||
|
||||
_renderDetalle(res.data);
|
||||
}
|
||||
|
||||
function _renderDetalle(d) {
|
||||
const t = d.turno;
|
||||
const isCerrado = ['finalizado','ausente','cancelado'].includes(t.estado);
|
||||
const orden = t.numero_orden || t.codigo;
|
||||
|
||||
// Header
|
||||
let hdr = `<div class="bnd-dh">
|
||||
<div class="bnd-dh-nombre">${t.paciente_nombre || '—'}</div>
|
||||
<div class="bnd-dh-meta">
|
||||
<span><b>${orden}</b></span>
|
||||
<span class="sep">·</span>
|
||||
<span>${t.lugar_nombre || ''}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>${_hora(t.inicio_lugar_at)}</span>
|
||||
${t.fin_lugar_at ? `<span class="sep">·</span><span>Cerrado ${_hora(t.fin_lugar_at)}</span>` : ''}
|
||||
</div>
|
||||
<div class="bnd-dh-actions">
|
||||
<button class="bnd-btn bnd-btn-pdf" onclick="window.print()"><i class="fas fa-print"></i> PDF</button>`;
|
||||
|
||||
if (isCerrado) {
|
||||
hdr += `<button class="bnd-btn bnd-btn-cerrado" disabled><i class="fas fa-check"></i> Cerrado</button>`;
|
||||
} else {
|
||||
hdr += `<button class="bnd-btn bnd-btn-cerrar" onclick="_bndCerrar(${t.id})"><i class="fas fa-check-circle"></i> Cerrar</button>`;
|
||||
}
|
||||
hdr += `</div></div>`;
|
||||
|
||||
// Exámenes
|
||||
let secEx = `<div class="bnd-sec"><div class="bnd-sec-title"><i class="fas fa-vial"></i> Exámenes</div>`;
|
||||
const cats = Object.keys(d.examenes_por_cat);
|
||||
if (!cats.length) {
|
||||
secEx += `<span style="color:#94a3b8;font-size:.82rem">Sin exámenes registrados</span>`;
|
||||
} else {
|
||||
cats.forEach(cat => {
|
||||
const chips = d.examenes_por_cat[cat].map(c => `<span class="bnd-exam-chip">${c}</span>`).join('');
|
||||
secEx += `<div class="bnd-cat"><div class="bnd-cat-name">${cat}</div><div class="bnd-chips">${chips}</div></div>`;
|
||||
});
|
||||
}
|
||||
secEx += `</div>`;
|
||||
|
||||
// Muestras
|
||||
let secM = `<div class="bnd-sec"><div class="bnd-sec-title"><i class="fas fa-tint"></i> Muestras</div>`;
|
||||
if (!d.muestras.length) {
|
||||
secM += `<span style="color:#94a3b8;font-size:.82rem">Sin muestras registradas</span>`;
|
||||
} else {
|
||||
d.muestras.forEach(m => {
|
||||
const rechazo = m.estado === 'rechazada' && m.motivo_rechazo
|
||||
? `<span class="bnd-m-rechazado"> · ${m.motivo_rechazo}</span>` : '';
|
||||
secM += `<div class="bnd-muestra">
|
||||
<div class="bnd-m-dot ${m.estado}"></div>
|
||||
<span class="bnd-m-label">${m.tipo_muestra}</span>
|
||||
<span class="bnd-m-estado">${m.estado}${m.recibida_at ? ' · ' + _hora(m.recibida_at) : ''}</span>
|
||||
${rechazo}
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
secM += `</div>`;
|
||||
|
||||
// Consentimientos
|
||||
let secC = `<div class="bnd-sec"><div class="bnd-sec-title"><i class="fas fa-file-signature"></i> Consentimientos</div>`;
|
||||
if (!d.consentimientos.length) {
|
||||
secC += `<span style="color:#94a3b8;font-size:.82rem">Sin consentimientos</span>`;
|
||||
} else {
|
||||
d.consentimientos.forEach(c => {
|
||||
const esFirmado = c.estado === 'firmado';
|
||||
const ico = esFirmado ? 'fas fa-check-circle firmado' : 'fas fa-clock pendiente';
|
||||
const link = c.token
|
||||
? `<a href="${BASE_URL}consent.php?token=${c.token}" target="_blank"><i class="fas fa-external-link-alt"></i> Ver</a>` : '';
|
||||
secC += `<div class="bnd-consent">
|
||||
<i class="bnd-consent-ico ${ico}"></i>
|
||||
<span style="font-size:.82rem">${c.formulario_nombre}</span>
|
||||
${c.firmado_at ? `<span style="font-size:.72rem;color:#94a3b8">${_hora(c.firmado_at)}</span>` : ''}
|
||||
${link}
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
secC += `</div>`;
|
||||
|
||||
// Comentarios
|
||||
let secCom = `<div class="bnd-sec" id="bnd-com-sec"><div class="bnd-sec-title"><i class="fas fa-comments"></i> Comentarios</div>`;
|
||||
secCom += `<div class="bnd-com-list" id="bnd-com-list">`;
|
||||
if (!d.comentarios.length) {
|
||||
secCom += `<span style="color:#94a3b8;font-size:.82rem">Sin comentarios</span>`;
|
||||
} else {
|
||||
d.comentarios.forEach(c => {
|
||||
const fecha = new Date((c.creado_at || '').replace(' ','T'));
|
||||
const timeStr = fecha.toLocaleTimeString('es-CO', { hour:'2-digit', minute:'2-digit' });
|
||||
secCom += `<div class="bnd-com">
|
||||
<div class="bnd-com-header">
|
||||
<span class="bnd-com-tipo ${c.tipo}">${c.tipo}</span>
|
||||
<span class="bnd-com-user">${c.usuario_nombre || ''}</span>
|
||||
<span class="bnd-com-date">${timeStr}</span>
|
||||
</div>
|
||||
<div class="bnd-com-text">${c.comentario}</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
secCom += `</div>`;
|
||||
|
||||
// Agregar comentario
|
||||
secCom += `<div class="bnd-add-com" style="margin-top:12px">
|
||||
<textarea id="bnd-new-com" placeholder="Agregar comentario de toma de muestras..." rows="2"></textarea>
|
||||
<button onclick="_bndEnviarCom(${t.id})"><i class="fas fa-paper-plane"></i> Enviar</button>
|
||||
</div></div>`;
|
||||
|
||||
document.getElementById('bnd-detail').innerHTML = hdr + secEx + secM + secC + secCom;
|
||||
}
|
||||
|
||||
// ── Cerrar turno ─────────────────────────────────────────────
|
||||
async function _bndCerrar(turnoId) {
|
||||
const res = await fetch('modules/turnero/api/cambiar_estado.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ turno_id: turnoId, nuevo_estado: 'finalizado' })
|
||||
}).then(r => r.json()).catch(() => null);
|
||||
|
||||
if (!res?.ok) { _toast('Error al cerrar turno'); return; }
|
||||
_toast('Turno cerrado');
|
||||
await _bndRefrescarLista();
|
||||
_bndVerDetalle(turnoId);
|
||||
}
|
||||
|
||||
// ── Enviar comentario ────────────────────────────────────────
|
||||
async function _bndEnviarCom(turnoId) {
|
||||
const ta = document.getElementById('bnd-new-com');
|
||||
const txt = ta.value.trim();
|
||||
if (!txt) return;
|
||||
const btn = ta.nextElementSibling;
|
||||
btn.disabled = true;
|
||||
|
||||
const res = await fetch(COM_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ turno_id: turnoId, comentario: txt, tipo: 'muestras' })
|
||||
}).then(r => r.json()).catch(() => null);
|
||||
|
||||
btn.disabled = false;
|
||||
if (!res?.ok) { _toast('Error al guardar comentario'); return; }
|
||||
ta.value = '';
|
||||
_toast('Comentario guardado');
|
||||
// Recargar sólo la sección de comentarios
|
||||
_bndVerDetalle(turnoId);
|
||||
}
|
||||
|
||||
// ── Auto-refresh cada 30s ─────────────────────────────────────
|
||||
_bndRefrescarLista();
|
||||
_bndInterval = setInterval(_bndRefrescarLista, 30000);
|
||||
</script>
|
||||
|
||||
<?php Layout::close(); ?>
|
||||
Reference in New Issue
Block a user