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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 01:43:29 +00:00
Lizandro GuarnizoandClaude Sonnet 4.6 339ca8a40d debug: habilitar display_errors temporalmente para diagnosticar 500
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 08:47:07 -05:00
3 changed files with 152 additions and 5 deletions
+53 -3
View File
@@ -64,10 +64,60 @@ try {
$estadoActual = $turno['estado'];
// Estados finales no modificables
// Estados finales — solo admin (sin role_id) puede reabrir
if (in_array($estadoActual, ['finalizado', 'ausente', 'cancelado'], true)) {
$pdo->rollBack();
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422);
$roleId = $_SESSION['admin_user']['role_id'] ?? null;
if (!empty($roleId)) {
$pdo->rollBack();
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422);
}
$permitidosAdmin = ['en_servicio', 'en_espera_lugar', 'en_recepcion', 'espera'];
if (!in_array($nuevoEstado, $permitidosAdmin, true)) {
$pdo->rollBack();
jsonError("Estado destino inválido para reapertura. Opciones: " . implode(', ', $permitidosAdmin), 422);
}
$sets = ['estado = ?'];
$binds = [$nuevoEstado];
if ($nuevoEstado === 'en_servicio') {
$sets[] = 'fin_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = COALESCE(inicio_lugar_at, NOW())';
$sets[] = 'atendido_lugar_por = COALESCE(atendido_lugar_por, ?)';
$binds[] = adminId();
} elseif ($nuevoEstado === 'en_espera_lugar') {
$sets[] = 'fin_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = NULL';
$sets[] = 'llamado_lugar_at = NULL';
} elseif ($nuevoEstado === 'espera') {
$sets[] = 'llamado_recepcion_at = NULL';
$sets[] = 'inicio_recepcion_at = NULL';
$sets[] = 'fin_recepcion_at = NULL';
$sets[] = 'llamado_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = NULL';
$sets[] = 'fin_lugar_at = NULL';
$sets[] = 'atendido_recepcion_por = NULL';
$sets[] = 'atendido_lugar_por = NULL';
$sets[] = 'recepcion_desk_id = NULL';
}
$sql = 'UPDATE turnero_turnos SET ' . implode(', ', $sets) . ' WHERE id = ?';
$binds[] = $turnoId;
$pdo->prepare($sql)->execute($binds);
$pdo->commit();
$stmt = $pdo->prepare(
'SELECT t.*, p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, p.color AS prioridad_color
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.id = ?'
);
$stmt->execute([$turnoId]);
$turnoActualizado = $stmt->fetch(PDO::FETCH_ASSOC);
notificarSSE((int) $turnoActualizado['sesion_id']);
jsonOk(['turno' => $turnoActualizado], "Turno reabierto: estado actualizado a '{$nuevoEstado}'");
}
// Verificar que la transición sea válida
+98 -2
View File
@@ -111,6 +111,21 @@ Layout::open('Historial de Turnos', 'fas fa-history');
.doc-pill.enviado { background:#fef9c3; color:#78350f; border:1px solid #fde68a; }
.doc-pill.pendiente{ background:#f1f5f9; color:#64748b; border:1px solid #e2e8f0; }
.docs-empty { font-size:.78rem; color:#94a3b8; font-style:italic; }
/* ── Modal cambio de estado ── */
.estado-modal-backdrop {
position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:1050;
display:flex; align-items:center; justify-content:center;
}
.estado-modal {
background:#fff; border-radius:14px; padding:24px 28px; width:340px;
box-shadow:0 20px 60px rgba(0,0,0,.2);
}
.estado-modal h5 { font-size:.95rem; font-weight:700; margin-bottom:4px; }
.estado-modal .sub { font-size:.78rem; color:#64748b; margin-bottom:16px; }
.estado-modal select { width:100%; padding:8px 10px; border:1px solid #e2e8f0;
border-radius:8px; font-size:.85rem; margin-bottom:16px; }
.estado-modal .actions { display:flex; gap:8px; justify-content:flex-end; }
</style>
<!-- ── Encabezado ────────────────────────────────────────────── -->
@@ -445,12 +460,17 @@ function renderTabla(turnos) {
<td><span class="eb ${cls}">${lbl}</span></td>
<td class="text-nowrap">${espMin}</td>
<td class="text-nowrap">${srvMin}</td>
<td>
<button class="btn btn-sm btn-outline-secondary py-0 px-2"
<td class="text-nowrap">
<button class="btn btn-sm btn-outline-secondary py-0 px-2 me-1"
onclick="toggleDetalle('${rowId}', this, ${t.id})"
title="Ver detalles">
<i class="fas fa-chevron-down" style="font-size:.65rem"></i>
</button>
<button class="btn btn-sm btn-outline-warning py-0 px-2"
onclick="abrirModalEstado(${t.id}, '${t.estado}', '${nombre.replace(/'/g,\'\\\'')}')"
title="Cambiar estado">
<i class="fas fa-exchange-alt" style="font-size:.65rem"></i>
</button>
</td>
</tr>
<tr id="${rowId}" class="detail-row" style="display:none">
@@ -641,6 +661,82 @@ function exportarCSV() {
window.open(`${API}export_historial.php?${params}`, '_blank');
}
// ── Cambio de estado ─────────────────────────────────────────
const ESTADOS_OPCIONES = {
espera : [{v:'en_recepcion', l:'En recepción'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
en_recepcion : [{v:'en_espera_lugar', l:'Esp. lugar'}, {v:'espera', l:'Espera'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
en_espera_lugar : [{v:'en_servicio', l:'En servicio'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
en_servicio : [{v:'finalizado', l:'Finalizado'}, {v:'en_espera_lugar', l:'Esp. lugar'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
// estados finales: reapertura (admin)
finalizado : [{v:'en_servicio', l:'Reabrir → En servicio'}, {v:'en_espera_lugar', l:'Reabrir → Esp. lugar'}, {v:'espera', l:'Reabrir → Espera'}],
ausente : [{v:'espera', l:'Reabrir → Espera'}, {v:'en_servicio', l:'Reabrir → En servicio'}],
cancelado : [{v:'espera', l:'Reabrir → Espera'}, {v:'en_servicio', l:'Reabrir → En servicio'}],
};
let _modalTurnoId = null;
function abrirModalEstado(turnoId, estadoActual, nombre) {
_modalTurnoId = turnoId;
const opciones = ESTADOS_OPCIONES[estadoActual] || [];
if (!opciones.length) { alert('No hay transiciones disponibles para este estado.'); return; }
const sel = opciones.map(o => `<option value="${o.v}">${o.l}</option>`).join('');
const esReopetura = ['finalizado','ausente','cancelado'].includes(estadoActual);
const backdrop = document.createElement('div');
backdrop.className = 'estado-modal-backdrop';
backdrop.id = 'estadoModalBackdrop';
backdrop.innerHTML = `
<div class="estado-modal" onclick="event.stopPropagation()">
<h5><i class="fas fa-exchange-alt me-2 text-warning"></i>Cambiar estado del turno</h5>
<div class="sub">${nombre}${esReopetura ? ' <span class="badge bg-warning text-dark ms-1">Reapertura admin</span>' : ''}</div>
<select id="selectNuevoEstado">${sel}</select>
<div class="actions">
<button class="btn btn-sm btn-outline-secondary" onclick="cerrarModalEstado()">Cancelar</button>
<button class="btn btn-sm btn-warning" onclick="confirmarCambioEstado()">
<i class="fas fa-check me-1"></i>Confirmar
</button>
</div>
</div>`;
backdrop.addEventListener('click', cerrarModalEstado);
document.body.appendChild(backdrop);
}
function cerrarModalEstado() {
document.getElementById('estadoModalBackdrop')?.remove();
_modalTurnoId = null;
}
async function confirmarCambioEstado() {
const nuevoEstado = document.getElementById('selectNuevoEstado').value;
if (!_modalTurnoId || !nuevoEstado) return;
const btn = document.querySelector('#estadoModalBackdrop .btn-warning');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando…';
try {
const res = await fetch(`${API}cambiar_estado.php`, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ turno_id: _modalTurnoId, nuevo_estado: nuevoEstado }),
});
const json = await res.json();
if (json.ok) {
cerrarModalEstado();
buscar(_state.page);
} else {
alert('Error: ' + (json.error || 'No se pudo cambiar el estado.'));
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-check me-1"></i>Confirmar';
}
} catch(e) {
alert('Error de conexión');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-check me-1"></i>Confirmar';
}
}
// ── Helpers visuales ──────────────────────────────────────────
function mostrarSpinner() {
document.getElementById('tbody').innerHTML =
+1
View File
@@ -1,4 +1,5 @@
<?php
ini_set('display_errors', 1); error_reporting(E_ALL); // ponytail: quitar después de depurar
/**
* ver_formulario_enviado.php — Ver respuesta de un formulario (imprimible / PDF)
* Acceso admin/enfermero: ver_formulario_enviado.php?id=ENVIO_ID (requiere sesión)