This commit is contained in:
Lizandro Guarnizo
2026-03-14 12:52:45 -05:00
parent 405a3751e7
commit bbfe0417df
2 changed files with 276 additions and 37 deletions
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* API: Subir archivo de orden médica desde el modal de agendamiento
*
* POST (multipart/form-data) con campo 'file'
* Devuelve: { success, local_file, file_name, file_size, mime }
*/
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../classes/Database.php';
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
requireAuthentication();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Método no permitido']);
exit;
}
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
$errCode = $_FILES['file']['error'] ?? -1;
echo json_encode(['success' => false, 'error' => 'No se recibió ningún archivo (código ' . $errCode . ')']);
exit;
}
$file = $_FILES['file'];
// Validar tamaño máximo: 10 MB
if ($file['size'] > 10 * 1024 * 1024) {
echo json_encode(['success' => false, 'error' => 'El archivo supera el tamaño máximo permitido (10 MB)']);
exit;
}
// Validar tipo MIME real (no confiar solo en la extensión del cliente)
$allowedMimes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime, $allowedMimes, true)) {
echo json_encode(['success' => false, 'error' => 'Tipo de archivo no permitido (' . $mime . ')']);
exit;
}
// Generar nombre único y seguro
$origExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$origExt = preg_replace('/[^a-z0-9]/', '', $origExt);
if (!$origExt) $origExt = 'bin';
$newName = 'orden_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $origExt;
$uploadDir = realpath(__DIR__ . '/../../uploads/media');
if (!$uploadDir) {
// Intentar crear el directorio si no existe
@mkdir(__DIR__ . '/../../uploads/media', 0755, true);
$uploadDir = realpath(__DIR__ . '/../../uploads/media');
}
if (!$uploadDir || !is_writable($uploadDir)) {
echo json_encode(['success' => false, 'error' => 'El directorio de uploads no está disponible']);
exit;
}
$destPath = $uploadDir . DIRECTORY_SEPARATOR . $newName;
if (!move_uploaded_file($file['tmp_name'], $destPath)) {
echo json_encode(['success' => false, 'error' => 'Error al guardar el archivo en el servidor']);
exit;
}
echo json_encode([
'success' => true,
'local_file' => $newName,
'file_name' => $file['name'],
'file_size' => $file['size'],
'mime' => $mime,
]);
+188 -37
View File
@@ -838,6 +838,22 @@ if (!isset($_SESSION['user_id'])) {
.conversation-item.unread { background: rgba(255, 248, 220, 0.9); }
.unread-badge { margin-left: 8px; font-size: 12px; }
/* Tooltip «Copiado» al hacer doble clic en un mensaje */
.copy-feedback {
position: absolute;
top: -26px; left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.72);
color: #fff;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px; font-weight: 600;
white-space: nowrap; pointer-events: none;
animation: cfadeIn .15s ease;
z-index: 100;
}
@keyframes cfadeIn { from { opacity:0; transform: translateX(-50%) translateY(4px); } to { opacity:1; transform: translateX(-50%) translateY(0); } }
</style>
</head>
<body>
@@ -2100,6 +2116,34 @@ if (!isset($_SESSION['user_id'])) {
// Expose small helper to other methods
this._updateSendMicVisibility = updateSendMicVisibility;
// Doble clic en burbuja de mensaje → copiar texto al portapapeles
const chatConvContainer = document.getElementById('chat-conversations');
if (chatConvContainer) {
chatConvContainer.addEventListener('dblclick', (e) => {
const bubble = e.target.closest('.message-bubble');
if (!bubble) return;
const contentEl = bubble.querySelector('.message-content');
if (!contentEl) return;
const text = (contentEl.innerText || contentEl.textContent || '').trim();
if (!text) return;
const doFeedback = () => this._showCopyToast(bubble);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(doFeedback).catch(() => {
try { document.execCommand('copy'); doFeedback(); } catch(e2) {}
});
} else {
try {
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(contentEl);
sel.removeAllRanges(); sel.addRange(range);
document.execCommand('copy');
sel.removeAllRanges();
doFeedback();
} catch(e2) {}
}
});
}
// File input selected -> show send button
const fileInput = document.getElementById('file-input');
@@ -4002,7 +4046,7 @@ if (!isset($_SESSION['user_id'])) {
<div class="message-actions mt-1">
<button class="btn btn-sm btn-link" onclick="chat.promptReply('${window.escapeHtml(msg.message_id || msg.id || '')}')" title="Responder"><i class="fas fa-reply"></i></button>
<button class="btn btn-sm btn-link" onclick="chat.openReactionPicker(event, '${window.escapeHtml(msg.message_id || msg.id || '')}')" title="Reaccionar"><i class="far fa-grin"></i></button>
${(msg.message_type === 'image' && msg.direction !== 'outgoing') ? `<button class="btn btn-sm btn-link text-primary" data-msg-id="${msg.id||0}" data-local-file="${msg.local_file||msg.local_thumb||''}" data-conv-id="${chat ? chat.currentConversationId : 0}" onclick="labDomicilio.abrir(+this.dataset.msgId, this.dataset.localFile, +this.dataset.convId)" title="Agendar domicilio (con imagen)"><i class="fas fa-house-medical"></i></button>` : ''}${window._labAddrBtn ? window._labAddrBtn(msg, chat ? chat.currentConversationId : 0) : ''}
${(['image','document','video','audio','sticker'].includes(msg.message_type) && msg.direction !== 'outgoing') ? `<button class="btn btn-sm btn-link text-primary" data-msg-id="${msg.id||0}" data-local-file="${msg.local_file||msg.local_thumb||''}" data-conv-id="${chat ? chat.currentConversationId : 0}" onclick="labDomicilio.abrir(+this.dataset.msgId, this.dataset.localFile, +this.dataset.convId)" title="Agendar domicilio"><i class="fas fa-house-medical"></i></button>` : ''}${window._labAddrBtn ? window._labAddrBtn(msg, chat ? chat.currentConversationId : 0) : ''}
</div>
<div class="message-time">${time} ${msg.direction === 'outgoing' ? `<span class="message-status">${statusIcon}</span>` : ''}</div>
</div>
@@ -5264,6 +5308,16 @@ if (!isset($_SESSION['user_id'])) {
container.scrollTop = container.scrollHeight;
}
_showCopyToast(bubble) {
const existing = bubble.querySelector('.copy-feedback');
if (existing) existing.remove();
const tip = document.createElement('span');
tip.className = 'copy-feedback';
tip.textContent = '✓ Copiado';
bubble.appendChild(tip);
setTimeout(() => { if (tip.parentNode) tip.remove(); }, 1800);
}
// Insertar mensaje saliente en la vista inmediatamente (simular envío)
addMessageToView(content, type = 'outgoing', options = {}) {
const msg = {
@@ -6543,13 +6597,30 @@ if (!isset($_SESSION['user_id'])) {
<!-- ── FORMULARIO ── -->
<div id="labdom-form-panel" class="modal-body pb-2">
<!-- Imagen adjunta -->
<div id="labdom-img-prev" class="mb-3 d-flex align-items-center gap-3 p-2 bg-light rounded border" style="display:none!important">
<img id="labdom-img-el" src="" class="rounded border" style="max-height:80px;max-width:110px;object-fit:cover">
<div>
<p class="mb-0 fw-semibold small text-secondary">Orden médica adjunta</p>
<span class="badge bg-primary-subtle text-primary border border-primary-subtle"><i class="fas fa-image me-1"></i>Foto de la conversación</span>
<!-- Orden médica (adjunta / agregar / cambiar) -->
<div class="mb-3">
<div id="labdom-img-prev" class="d-flex align-items-center gap-3 p-2 bg-light rounded border" style="display:none">
<div id="labdom-file-thumb" class="flex-shrink-0">
<img id="labdom-img-el" src="" class="rounded border" style="max-height:80px;max-width:110px;object-fit:cover">
<div id="labdom-file-icon" class="rounded border bg-white text-center" style="width:80px;height:80px;display:none;align-items:center;justify-content:center"></div>
</div>
<div class="flex-grow-1 overflow-hidden">
<p class="mb-1 fw-semibold small text-secondary"><i class="fas fa-paperclip me-1"></i>Orden médica adjunta</p>
<small id="labdom-file-name" class="text-muted d-block mb-2" style="max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></small>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" onclick="labDomicilio._cambiarOrden()" title="Cambiar archivo">
<i class="fas fa-exchange-alt me-1"></i>Cambiar
</button>
<button type="button" class="btn btn-sm btn-outline-danger py-0 px-2" onclick="labDomicilio._eliminarOrden()" title="Eliminar orden">
<i class="fas fa-trash me-1"></i>Eliminar
</button>
</div>
</div>
</div>
<button type="button" id="labdom-btn-add-orden" class="btn btn-sm btn-outline-primary w-100 py-1" onclick="labDomicilio._cambiarOrden()" style="display:none">
<i class="fas fa-paperclip me-1"></i>Adjuntar orden médica <span class="fw-normal text-muted">(opcional)</span>
</button>
<input type="file" id="labdom-orden-input" accept="image/*,.pdf,.doc,.docx" style="display:none">
</div>
<!-- ── TIPO (particular/seguro) PRIMERO y llamativo ── -->
@@ -6783,13 +6854,14 @@ if (!isset($_SESSION['user_id'])) {
// ══════════════════════════════════════════════════════════════════════════════
const labDomicilio = (() => {
// Estado interno
let _msgId = null;
let _localFile = null;
let _convId = null;
let _pacienteId = null;
let _pacNombre = '';
let _histDirs = []; // direcciones únicas previas del paciente
let _bsModal = null;
let _msgId = null;
let _localFile = null;
let _newOrderFile = null; // nuevo archivo de orden cargado desde el modal
let _convId = null;
let _pacienteId = null;
let _pacNombre = '';
let _histDirs = []; // direcciones únicas previas del paciente
let _bsModal = null;
// Sesiones minimizadas (múltiples chats en paralelo)
let _sesiones = []; // [{ id, chatUserId, chatName, chatPhone, convId, pacienteId, pacNombre, histDirs, msgId, localFile, cardVisible, pacInfo, fields }]
@@ -6887,12 +6959,7 @@ const labDomicilio = (() => {
mostrarFormulario();
labDomicilio._resetFull();
if (_localFile) {
$('labdom-img-el').src = `uploads/media/${_localFile}`;
$('labdom-img-prev').style.cssText = ''; // quita display:none!important
} else {
$('labdom-img-prev').style.display = 'none';
}
labDomicilio._updateOrdenPreview();
if (!_bsModal) _bsModal = new bootstrap.Modal('#modalAgendarDomicilio', { backdrop: true, keyboard: true });
_bsModal.show();
@@ -6903,9 +6970,10 @@ const labDomicilio = (() => {
/* Reset completo (nombre paciente + todos los campos) */
_resetFull() {
_pacienteId = null;
_pacNombre = '';
_histDirs = [];
_pacienteId = null;
_pacNombre = '';
_histDirs = [];
_newOrderFile = null;
$('labdom-pac-busq').value = '';
$('labdom-pac-list').style.display = 'none';
$('labdom-pac-buscar').classList.remove('d-none');
@@ -6922,6 +6990,73 @@ const labDomicilio = (() => {
labDomicilio._resetDomicilio();
},
/* Actualiza la previa de orden médica en el modal (imagen o icono de archivo) */
_updateOrdenPreview() {
const prevDiv = document.getElementById('labdom-img-prev');
const addBtn = document.getElementById('labdom-btn-add-orden');
const imgEl = document.getElementById('labdom-img-el');
const iconEl = document.getElementById('labdom-file-icon');
const nameEl = document.getElementById('labdom-file-name');
if (!prevDiv) return;
if (_localFile || _newOrderFile) {
prevDiv.style.display = '';
if (addBtn) addBtn.style.display = 'none';
const fileName = _newOrderFile ? _newOrderFile.name : (_localFile || '');
if (nameEl) nameEl.textContent = fileName;
const isImage = /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(fileName);
if (isImage) {
if (imgEl) {
imgEl.style.display = '';
if (_newOrderFile) {
const reader = new FileReader();
reader.onload = ev => { imgEl.src = ev.target.result; };
reader.readAsDataURL(_newOrderFile);
} else {
imgEl.src = `uploads/media/${_localFile}`;
}
}
if (iconEl) iconEl.style.display = 'none';
} else {
if (imgEl) imgEl.style.display = 'none';
if (iconEl) {
const ext = (fileName || '').split('.').pop().toLowerCase();
const iconMap = { pdf: 'fas fa-file-pdf text-danger', doc: 'fas fa-file-word text-primary', docx: 'fas fa-file-word text-primary', mp4: 'fas fa-file-video text-warning', mkv: 'fas fa-file-video text-warning', mp3: 'fas fa-file-audio text-success', ogg: 'fas fa-file-audio text-success', webm: 'fas fa-file-video text-warning' };
iconEl.innerHTML = `<i class="${iconMap[ext] || 'fas fa-file text-secondary'}" style="font-size:2rem"></i>`;
iconEl.style.display = 'flex';
}
}
} else {
prevDiv.style.display = 'none';
if (addBtn) addBtn.style.display = '';
}
},
/* Abre el selector de archivo para cambiar o agregar la orden médica */
_cambiarOrden() {
const inp = document.getElementById('labdom-orden-input');
if (!inp) return;
const handler = (e) => {
inp.removeEventListener('change', handler);
const file = e.target.files && e.target.files[0];
if (!file) return;
_newOrderFile = file;
_localFile = null;
labDomicilio._updateOrdenPreview();
inp.value = '';
};
inp.addEventListener('change', handler);
inp.click();
},
/* Elimina la orden médica adjunta */
_eliminarOrden() {
if (!confirm('¿Eliminar la orden médica adjunta?')) return;
_localFile = null;
_newOrderFile = null;
_msgId = null;
labDomicilio._updateOrdenPreview();
},
/* Muestra/oculta el campo «nombre del seguro» según la selección */
_toggleSeguro() {
const esSeguro = document.getElementById('tc-seguro')?.checked;
@@ -7163,8 +7298,21 @@ const labDomicilio = (() => {
notas_admin: fv('labdom-notas') || null,
};
// Si la ventana se abrió desde una imagen (orden médica), crear primero
// la OrdenMedica y luego enlazarla al domicilio via orden_id
// Subir nuevo archivo de orden si el usuario cambió o agregó uno desde el modal
if (_newOrderFile) {
try {
const fd = new FormData();
fd.append('file', _newOrderFile);
const upRes = await fetch('api/lab/upload_orden.php', { method: 'POST', body: fd });
const upData = await upRes.json();
if (upData.success && upData.local_file) {
_localFile = upData.local_file;
_newOrderFile = null;
}
} catch (_e) { /* silencioso — no bloquea el flujo */ }
}
// Si hay orden médica (original o recién subida), crear registro y enlazar
if (_localFile) {
try {
const convIdNum = (_convId && !String(_convId).startsWith('__user:'))
@@ -7328,9 +7476,10 @@ const labDomicilio = (() => {
pacienteId: _pacienteId,
pacNombre: _pacNombre,
histDirs: [..._histDirs],
msgId: _msgId,
localFile: _localFile,
cardVisible: !document.getElementById('labdom-pac-card')?.classList.contains('d-none'),
msgId: _msgId,
localFile: _localFile,
newOrderFile: _newOrderFile,
cardVisible: !document.getElementById('labdom-pac-card')?.classList.contains('d-none'),
pacInfo: {
nombre: document.getElementById('labdom-pac-nombre')?.textContent || '',
info: document.getElementById('labdom-pac-info')?.textContent || '',
@@ -7406,18 +7555,19 @@ const labDomicilio = (() => {
}
}
// Restaurar estado interno
_convId = sesion.convId;
_pacienteId = sesion.pacienteId;
_pacNombre = sesion.pacNombre;
_histDirs = sesion.histDirs || [];
_msgId = sesion.msgId;
_localFile = sesion.localFile;
// Mostrar formulario
// Mostrar formulario y limpiar DOM
mostrarFormulario();
labDomicilio._resetFull(); // limpia el DOM
// Restaurar estado interno DESPUÉS del reset (para que no se sobreescriba)
_convId = sesion.convId;
_pacienteId = sesion.pacienteId;
_pacNombre = sesion.pacNombre;
_histDirs = sesion.histDirs || [];
_msgId = sesion.msgId;
_localFile = sesion.localFile;
_newOrderFile = sesion.newOrderFile || null;
// Restaurar campos del formulario
const f = sesion.fields || {};
['labdom-fecha','labdom-hora','labdom-tipo','labdom-direccion','labdom-barrio',
@@ -7461,6 +7611,7 @@ const labDomicilio = (() => {
}
labDomicilio._calcTotal();
labDomicilio._updateOrdenPreview();
// Abrir el modal
if (!_bsModal) _bsModal = new bootstrap.Modal('#modalAgendarDomicilio', { backdrop: true, keyboard: true });