up
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
-- Tabla para comentarios de seguimiento en el turnero
|
||||||
|
CREATE TABLE IF NOT EXISTS turnero_comentarios (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
turno_id INT UNSIGNED NOT NULL,
|
||||||
|
usuario_id INT,
|
||||||
|
usuario_nombre VARCHAR(150) NOT NULL DEFAULT '',
|
||||||
|
comentario TEXT NOT NULL,
|
||||||
|
tipo ENUM('recepcion','muestras','general') NOT NULL DEFAULT 'general',
|
||||||
|
creado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
actualizado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (turno_id) REFERENCES turnero_turnos(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_turno (turno_id),
|
||||||
|
INDEX idx_creado (creado_at)
|
||||||
|
);
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST/GET /modules/turnero/api/comentarios.php
|
||||||
|
*
|
||||||
|
* GET: obtener comentarios de un turno
|
||||||
|
* ?turno_id=X&incluir_examenes=1&incluir_consentimientos=1
|
||||||
|
*
|
||||||
|
* POST: guardar comentario
|
||||||
|
* { turno_id, comentario, tipo: 'recepcion|muestras|general' }
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireTurnero();
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
$userId = (int)adminId();
|
||||||
|
$userName = $_SESSION['admin_user']['full_name']
|
||||||
|
?? $_SESSION['admin_user']['username']
|
||||||
|
?? 'Usuario';
|
||||||
|
|
||||||
|
// ── GET: Obtener comentarios y datos relacionados ──
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||||
|
$turnoId = (int)($_GET['turno_id'] ?? 0);
|
||||||
|
if ($turnoId <= 0) jsonError('turno_id requerido.');
|
||||||
|
|
||||||
|
$datos = [];
|
||||||
|
|
||||||
|
// Comentarios del turno
|
||||||
|
$stmtCom = $pdo->prepare(
|
||||||
|
"SELECT id, usuario_nombre, comentario, tipo, creado_at
|
||||||
|
FROM turnero_comentarios
|
||||||
|
WHERE turno_id = ?
|
||||||
|
ORDER BY creado_at DESC"
|
||||||
|
);
|
||||||
|
$stmtCom->execute([$turnoId]);
|
||||||
|
$datos['comentarios'] = $stmtCom->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Exámenes solicitados (si lo pide)
|
||||||
|
if (!empty($_GET['incluir_examenes'])) {
|
||||||
|
$stmtExam = $pdo->prepare(
|
||||||
|
"SELECT DISTINCT et.nombre, et.codigo
|
||||||
|
FROM turnero_examen_items tei
|
||||||
|
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||||
|
WHERE tei.solicitud_id = (
|
||||||
|
SELECT id FROM turnero_solicitudes
|
||||||
|
WHERE turno_id = ? LIMIT 1
|
||||||
|
)
|
||||||
|
ORDER BY et.nombre ASC"
|
||||||
|
);
|
||||||
|
$stmtExam->execute([$turnoId]);
|
||||||
|
$datos['examenes'] = $stmtExam->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consentimientos (si lo pide)
|
||||||
|
if (!empty($_GET['incluir_consentimientos'])) {
|
||||||
|
$stmtCons = $pdo->prepare(
|
||||||
|
"SELECT tc.id, f.nombre, tc.estado, tc.firmado_at, tc.firma_profesional_svg
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||||
|
WHERE tc.turno_id = ?
|
||||||
|
ORDER BY tc.estado DESC, tc.firmado_at DESC"
|
||||||
|
);
|
||||||
|
$stmtCons->execute([$turnoId]);
|
||||||
|
$datos['consentimientos'] = $stmtCons->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonOk($datos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST: Guardar comentario ──
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$datos = inputJson();
|
||||||
|
$turnoId = (int)($datos['turno_id'] ?? 0);
|
||||||
|
$comentario = trim($datos['comentario'] ?? '');
|
||||||
|
$tipo = $datos['tipo'] ?? 'general';
|
||||||
|
|
||||||
|
if ($turnoId <= 0) jsonError('turno_id requerido.');
|
||||||
|
if (!$comentario) jsonError('Comentario vacío.');
|
||||||
|
if (!in_array($tipo, ['recepcion','muestras','general'], true)) $tipo = 'general';
|
||||||
|
|
||||||
|
// Validar que el turno existe
|
||||||
|
$stmtTurno = $pdo->prepare("SELECT id FROM turnero_turnos WHERE id = ?");
|
||||||
|
$stmtTurno->execute([$turnoId]);
|
||||||
|
if (!$stmtTurno->fetch()) jsonError('Turno no encontrado.', 404);
|
||||||
|
|
||||||
|
// Guardar comentario
|
||||||
|
$stmtIns = $pdo->prepare(
|
||||||
|
"INSERT INTO turnero_comentarios (turno_id, usuario_id, usuario_nombre, comentario, tipo)
|
||||||
|
VALUES (?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmtIns->execute([$turnoId, $userId, $userName, $comentario, $tipo]);
|
||||||
|
$comId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
jsonOk(['id' => $comId, 'creado_at' => date('c')]);
|
||||||
|
}
|
||||||
@@ -129,6 +129,44 @@ $stmtTurnos = $pdo->prepare($sqlTurnos);
|
|||||||
$stmtTurnos->execute($bindsPage);
|
$stmtTurnos->execute($bindsPage);
|
||||||
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
|
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// ── Enriquecer con exámenes y consentimientos ──────────────────
|
||||||
|
foreach ($turnos as &$turno) {
|
||||||
|
// Exámenes
|
||||||
|
$stmtExam = $pdo->prepare(
|
||||||
|
"SELECT DISTINCT et.nombre, et.codigo
|
||||||
|
FROM turnero_examen_items tei
|
||||||
|
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||||
|
WHERE tei.solicitud_id = (
|
||||||
|
SELECT id FROM turnero_solicitudes WHERE turno_id = ? LIMIT 1
|
||||||
|
)
|
||||||
|
ORDER BY et.nombre ASC"
|
||||||
|
);
|
||||||
|
$stmtExam->execute([(int)$turno['id']]);
|
||||||
|
$turno['examenes'] = $stmtExam->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Consentimientos
|
||||||
|
$stmtCons = $pdo->prepare(
|
||||||
|
"SELECT tc.id, f.nombre, tc.estado, tc.firmado_at
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||||
|
WHERE tc.turno_id = ?
|
||||||
|
ORDER BY tc.estado DESC, tc.firmado_at DESC"
|
||||||
|
);
|
||||||
|
$stmtCons->execute([(int)$turno['id']]);
|
||||||
|
$turno['consentimientos'] = $stmtCons->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Comentarios
|
||||||
|
$stmtCom = $pdo->prepare(
|
||||||
|
"SELECT usuario_nombre, comentario, tipo, creado_at
|
||||||
|
FROM turnero_comentarios
|
||||||
|
WHERE turno_id = ?
|
||||||
|
ORDER BY creado_at DESC
|
||||||
|
LIMIT 3"
|
||||||
|
);
|
||||||
|
$stmtCom->execute([(int)$turno['id']]);
|
||||||
|
$turno['comentarios'] = $stmtCom->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Resumen por estado (del rango filtrado) ───────────────────
|
// ── Resumen por estado (del rango filtrado) ───────────────────
|
||||||
$sqlRes = "
|
$sqlRes = "
|
||||||
SELECT t.estado, COUNT(*) AS cnt
|
SELECT t.estado, COUNT(*) AS cnt
|
||||||
|
|||||||
@@ -314,6 +314,25 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Sección: Comentarios y Notas ── -->
|
||||||
|
<div class="ficha-sec" id="sec-comentarios">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<h6 class="mb-0"><i class="fas fa-comments me-1" style="color:#8b5cf6"></i>Comentarios del seguimiento</h6>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:10px">
|
||||||
|
<textarea id="inp-comentario" class="form-control form-control-sm"
|
||||||
|
rows="2" placeholder="Añade un comentario sobre este turno…"
|
||||||
|
maxlength="500" style="resize:vertical"></textarea>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="guardarComentario()"
|
||||||
|
style="height:fit-content;align-self:flex-end">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="lista-comentarios" class="text-muted small py-2">
|
||||||
|
<i class="fas fa-spinner fa-spin me-1"></i>Cargando comentarios…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Barra de acciones ── -->
|
<!-- ── Barra de acciones ── -->
|
||||||
<div class="ficha-acciones">
|
<div class="ficha-acciones">
|
||||||
<button class="btn btn-primary" id="btn-iniciar" onclick="iniciarAtencion()">
|
<button class="btn btn-primary" id="btn-iniciar" onclick="iniciarAtencion()">
|
||||||
@@ -587,6 +606,9 @@ async function cargarFichaSolicitud(turnoId) {
|
|||||||
// Consentimientos
|
// Consentimientos
|
||||||
renderConsentimientos(consts);
|
renderConsentimientos(consts);
|
||||||
|
|
||||||
|
// Comentarios
|
||||||
|
cargarComentarios(turnoId);
|
||||||
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,6 +879,48 @@ function mostrarToast(msg, type = 'info', duration = 2500) {
|
|||||||
function mostrarError(msg) { mostrarToast(msg, 'error', 4000); }
|
function mostrarError(msg) { mostrarToast(msg, 'error', 4000); }
|
||||||
function mostrarLlamando(cod) { mostrarToast('Llamando turno ' + cod + '…', 'info', 2000); }
|
function mostrarLlamando(cod) { mostrarToast('Llamando turno ' + cod + '…', 'info', 2000); }
|
||||||
|
|
||||||
|
// ── Comentarios y notas ───────────────────────────────────────
|
||||||
|
async function cargarComentarios(turnoId) {
|
||||||
|
if (!turnoId) return;
|
||||||
|
const cont = document.getElementById('lista-comentarios');
|
||||||
|
cont.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Cargando…';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}comentarios.php?turno_id=${turnoId}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.ok || !json.comentarios) { cont.innerHTML = '<small class="text-muted">Sin comentarios</small>'; return; }
|
||||||
|
if (json.comentarios.length === 0) { cont.innerHTML = '<small class="text-muted">Sin comentarios aún</small>'; return; }
|
||||||
|
cont.innerHTML = json.comentarios.map(c => `
|
||||||
|
<div style="background:#f8fafc;border-left:3px solid #8b5cf6;padding:8px 10px;margin-bottom:8px;border-radius:4px">
|
||||||
|
<div style="font-size:.7rem;color:#94a3b8;margin-bottom:2px">${escHtml(c.usuario_nombre)} • ${new Date(c.creado_at).toLocaleString()}</div>
|
||||||
|
<div style="font-size:.82rem;color:#334155">${escHtml(c.comentario)}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch(_) {
|
||||||
|
cont.innerHTML = '<small class="text-danger">Error al cargar comentarios</small>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardarComentario() {
|
||||||
|
if (!turnoActivo) return;
|
||||||
|
const inp = document.getElementById('inp-comentario');
|
||||||
|
const txt = (inp.value || '').trim();
|
||||||
|
if (!txt) { mostrarError('El comentario no puede estar vacío'); return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + 'comentarios.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ turno_id: turnoActivo.id, comentario: txt, tipo: 'muestras' }),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.ok) { mostrarError(json.error || 'Error al guardar'); return; }
|
||||||
|
inp.value = '';
|
||||||
|
await cargarComentarios(turnoActivo.id);
|
||||||
|
mostrarToast('Comentario guardado', 'success', 2000);
|
||||||
|
} catch(err) {
|
||||||
|
mostrarError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Ocupación exclusiva del puesto ──────────────────────────── */
|
/* ── Ocupación exclusiva del puesto ──────────────────────────── */
|
||||||
(function(_ID, _NOMBRE, _VIEW, _PARAM) {
|
(function(_ID, _NOMBRE, _VIEW, _PARAM) {
|
||||||
if (!_ID) return;
|
if (!_ID) return;
|
||||||
|
|||||||
@@ -930,6 +930,9 @@ function renderHistorialTimeline(turnos, total) {
|
|||||||
en_recepcion: 'background:#dbeafe;color:#1e40af',
|
en_recepcion: 'background:#dbeafe;color:#1e40af',
|
||||||
espera: 'background:#fef9c3;color:#78350f',
|
espera: 'background:#fef9c3;color:#78350f',
|
||||||
};
|
};
|
||||||
|
const CONS_ICON = {
|
||||||
|
pendiente: '📋', enviado: '📤', visto: '👁', firmado: '✅', rechazado: '❌'
|
||||||
|
};
|
||||||
const totalLabel = total > turnos.length ? `<div class="text-end px-3 pb-1" style="font-size:.67rem;color:#94a3b8">Mostrando ${turnos.length} de ${total} visitas</div>` : '';
|
const totalLabel = total > turnos.length ? `<div class="text-end px-3 pb-1" style="font-size:.67rem;color:#94a3b8">Mostrando ${turnos.length} de ${total} visitas</div>` : '';
|
||||||
const items = turnos.map(t => {
|
const items = turnos.map(t => {
|
||||||
const color = t.prioridad_color || '#6366f1';
|
const color = t.prioridad_color || '#6366f1';
|
||||||
@@ -939,6 +942,38 @@ function renderHistorialTimeline(turnos, total) {
|
|||||||
const eStyle = ESTADO_STYLE[est] || 'background:#f1f5f9;color:#334155';
|
const eStyle = ESTADO_STYLE[est] || 'background:#f1f5f9;color:#334155';
|
||||||
const eLbl = ESTADO_LBL[est] || est;
|
const eLbl = ESTADO_LBL[est] || est;
|
||||||
const mins = t.servicio_min != null ? `<span style="font-size:.65rem;color:#94a3b8;margin-left:4px">${t.servicio_min} min</span>` : '';
|
const mins = t.servicio_min != null ? `<span style="font-size:.65rem;color:#94a3b8;margin-left:4px">${t.servicio_min} min</span>` : '';
|
||||||
|
|
||||||
|
// Exámenes
|
||||||
|
let examStr = '';
|
||||||
|
if (t.examenes && t.examenes.length) {
|
||||||
|
examStr = `<div style="margin-top:6px;font-size:.75rem">
|
||||||
|
<strong style="color:#64748b">Exámenes:</strong>
|
||||||
|
<div style="color:#475569">${t.examenes.map(e => escHtml(e.nombre)).join(', ')}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consentimientos
|
||||||
|
let consStr = '';
|
||||||
|
if (t.consentimientos && t.consentimientos.length) {
|
||||||
|
consStr = `<div style="margin-top:6px;font-size:.75rem">
|
||||||
|
<strong style="color:#64748b">Consentimientos:</strong>
|
||||||
|
<div>${t.consentimientos.map(c =>
|
||||||
|
`<span style="display:inline-block;margin-right:6px">${CONS_ICON[c.estado]||'?'} ${escHtml(c.nombre||'Consentimiento')}</span>`
|
||||||
|
).join('')}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comentarios
|
||||||
|
let comStr = '';
|
||||||
|
if (t.comentarios && t.comentarios.length) {
|
||||||
|
comStr = `<div style="margin-top:6px;font-size:.72rem;border-top:1px solid #e2e8f0;padding-top:6px">
|
||||||
|
<strong style="color:#64748b">📝 Últimos comentarios:</strong>
|
||||||
|
<div style="color:#475569;margin-top:4px">${t.comentarios.map(c =>
|
||||||
|
`<div style="margin-bottom:3px"><strong>${escHtml(c.usuario_nombre)}</strong>: ${escHtml(c.comentario)}</div>`
|
||||||
|
).join('')}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
return `<div class="timeline-item">
|
return `<div class="timeline-item">
|
||||||
<div class="timeline-dot" style="color:${color};background:${color}"></div>
|
<div class="timeline-dot" style="color:${color};background:${color}"></div>
|
||||||
<div class="timeline-content">
|
<div class="timeline-content">
|
||||||
@@ -946,6 +981,7 @@ function renderHistorialTimeline(turnos, total) {
|
|||||||
<div class="timeline-codigo" style="color:${color}">${escHtml(t.codigo)}</div>
|
<div class="timeline-codigo" style="color:${color}">${escHtml(t.codigo)}</div>
|
||||||
<div class="timeline-lugar">${lugar}</div>
|
<div class="timeline-lugar">${lugar}</div>
|
||||||
<span class="timeline-eb" style="${eStyle}">${escHtml(eLbl)}</span>${mins}
|
<span class="timeline-eb" style="${eStyle}">${escHtml(eLbl)}</span>${mins}
|
||||||
|
${examStr}${consStr}${comStr}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|||||||
Reference in New Issue
Block a user