From c68a79a3a724860a660d39049e11ccf6073ebedd Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:31:12 -0500 Subject: [PATCH] subir mas de un archivo, reprogramados --- api/lab/upload_orden.php | 183 ++++++++++++++++----------- enfermero_portal.php | 48 +++++-- lab_domicilios.php | 266 ++++++++++++++++++--------------------- 3 files changed, 267 insertions(+), 230 deletions(-) diff --git a/api/lab/upload_orden.php b/api/lab/upload_orden.php index 527e4ee..5f9a5b5 100644 --- a/api/lab/upload_orden.php +++ b/api/lab/upload_orden.php @@ -1,99 +1,132 @@ 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', + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'application/pdf' => 'pdf', + 'application/msword' => 'doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', + 'application/vnd.ms-excel' => 'xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', + 'text/plain' => 'txt', + 'text/csv' => 'csv', ]; -$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) { - $mimeToExt = [ - 'image/jpeg' => 'jpg', - 'image/png' => 'png', - 'image/gif' => 'gif', - 'image/webp' => 'webp', - 'application/pdf' => 'pdf', - 'application/msword' => 'doc', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', +// Normalizar: acepta files[] o file (retrocompat) +$rawFiles = []; +if (!empty($_FILES['files']['name'][0])) { + // files[] (múltiples) + $count = count($_FILES['files']['name']); + for ($i = 0; $i < $count; $i++) { + $rawFiles[] = [ + 'name' => $_FILES['files']['name'][$i], + 'tmp_name' => $_FILES['files']['tmp_name'][$i], + 'error' => $_FILES['files']['error'][$i], + 'size' => $_FILES['files']['size'][$i], + ]; + } +} elseif (!empty($_FILES['file']['tmp_name'])) { + // file (singular, retrocompat) + $rawFiles[] = [ + 'name' => $_FILES['file']['name'], + 'tmp_name' => $_FILES['file']['tmp_name'], + 'error' => $_FILES['file']['error'], + 'size' => $_FILES['file']['size'], ]; - $origExt = $mimeToExt[$mime] ?? 'bin'; } -$newName = 'orden_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $origExt; +if (empty($rawFiles)) { + jsonError('No se recibió ningún archivo'); +} $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; + jsonError('El directorio de uploads no está disponible'); } -$destPath = $uploadDir . DIRECTORY_SEPARATOR . $newName; +$domicilioId = isset($_POST['domicilio_id']) ? (int)$_POST['domicilio_id'] : 0; +$pacienteId = isset($_POST['paciente_id']) ? (int)$_POST['paciente_id'] : 0; +$om = ($domicilioId && $pacienteId) ? new OrdenMedica() : null; +$adminId = adminId(); -if (!move_uploaded_file($file['tmp_name'], $destPath)) { - echo json_encode(['success' => false, 'error' => 'Error al guardar el archivo en el servidor']); - exit; +$archivos = []; + +foreach ($rawFiles as $file) { + if ($file['error'] !== UPLOAD_ERR_OK) { + continue; // saltar archivos con error de upload + } + + // Validar tamaño máximo: 10 MB + if ($file['size'] > 10 * 1024 * 1024) { + jsonError('El archivo "' . basename($file['name']) . '" supera el límite de 10 MB'); + } + + // Validar tipo MIME real + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + + if (!array_key_exists($mime, $allowedMimes)) { + jsonError('Tipo de archivo no permitido: ' . $mime); + } + + // Generar nombre único y seguro + $origExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + $origExt = preg_replace('/[^a-z0-9]/', '', $origExt); + if (!$origExt) { + $origExt = $allowedMimes[$mime]; + } + + $newName = 'orden_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $origExt; + $destPath = $uploadDir . DIRECTORY_SEPARATOR . $newName; + + if (!move_uploaded_file($file['tmp_name'], $destPath)) { + jsonError('Error al guardar el archivo en el servidor'); + } + + $ordenId = null; + if ($om) { + // Crear registro en lab_ordenes_medicas vinculado al domicilio + $ordenId = $om->crear([ + 'paciente_id' => $pacienteId, + 'domicilio_id' => $domicilioId, + 'local_file' => $newName, + ], $adminId); + } + + $archivos[] = [ + 'local_file' => $newName, + 'file_name' => $file['name'], + 'file_size' => $file['size'], + 'mime' => $mime, + 'orden_id' => $ordenId, + ]; } -echo json_encode([ - 'success' => true, - 'local_file' => $newName, - 'file_name' => $file['name'], - 'file_size' => $file['size'], - 'mime' => $mime, +if (empty($archivos)) { + jsonError('No se pudo procesar ningún archivo'); +} + +jsonOk([ + 'archivos' => $archivos, + 'local_file' => $archivos[0]['local_file'], // retrocompat ]); diff --git a/enfermero_portal.php b/enfermero_portal.php index 47cf718..653a326 100644 --- a/enfermero_portal.php +++ b/enfermero_portal.php @@ -58,6 +58,7 @@ $hoy = date('Y-m-d'); .domcard.estado-en_domicilio { border-color:#20c997; } .domcard.estado-completado { border-color:#198754; opacity:.75; } .domcard.estado-cancelado { border-color:#dc3545; opacity:.6; } + .domcard.estado-reprogramado { border-color:#6f42c1; opacity:.65; } /* ── Badges estado ── */ .badge-programado { background:#6c757d; } @@ -66,6 +67,7 @@ $hoy = date('Y-m-d'); .badge-en_domicilio { background:#20c997; } .badge-completado { background:#198754; } .badge-cancelado { background:#dc3545; } + .badge-reprogramado { background:#6f42c1; } /* ── Botones acción ── */ .btn-accion { font-size:.78rem; padding:.28rem .65rem; border-radius:20px; } @@ -434,6 +436,7 @@ const ESTADOS_LABEL = { en_domicilio: { label:'En domicilio', icon:'🏠', cls:'badge-en_domicilio' }, completado: { label:'Completado', icon:'🎉', cls:'badge-completado' }, cancelado: { label:'Cancelado', icon:'❌', cls:'badge-cancelado' }, + reprogramado: { label:'Reprogramado', icon:'📅', cls:'badge-reprogramado' }, }; // Transiciones que el enfermero puede hacer @@ -507,7 +510,7 @@ const portal = { return; } const ACTIVOS = ['programado','confirmado','en_camino','en_domicilio']; - const CERRADOS = ['completado','cancelado']; + const CERRADOS = ['completado','cancelado','reprogramado']; const sorted = [...this._agenda].sort((a,b) => (a.hora_programada||'99:99').localeCompare(b.hora_programada||'99:99')); const activos = sorted.filter(i => ACTIVOS.includes(i.domicilio_estado||'programado')); @@ -516,9 +519,12 @@ const portal = { let html = activos.map((item, idx) => this._cardHTML(item, idx === 0)).join(''); if (cerrados.length) { + const secLabel = cerrados.some(i => !['completado','cancelado'].includes(i.domicilio_estado||'')) + ? `Cerrados / Reprogramados (${cerrados.length})` + : `Finalizados (${cerrados.length})`; html += `
@@ -1225,14 +1231,16 @@ const formVer = { placeholder="Instrucciones adicionales para el servicio…">
- +
- -
+ +
@@ -1336,6 +1344,9 @@ const agendaNueva = { document.getElementById('na-valor-dom').value = ''; document.getElementById('na-valor-cop').value = ''; document.getElementById('na-fecha').value = document.getElementById('portal-fecha').value; + // Reset archivo adjunto + document.getElementById('na-orden-file').value = ''; + document.getElementById('na-orden-preview').innerHTML = ''; document.getElementById('na-sugerencias').classList.add('d-none'); document.getElementById('na-paciente-elegido').classList.add('d-none'); document.getElementById('na-paciente-buscar').classList.remove('d-none'); @@ -1447,6 +1458,20 @@ const agendaNueva = { document.getElementById('na-direccion').focus(); }, + _previsualizarArchivos(files) { + const prev = document.getElementById('na-orden-preview'); + prev.innerHTML = ''; + for (const f of files) { + const isImg = f.type.startsWith('image/'); + const chip = document.createElement('span'); + chip.className = 'badge bg-light text-dark border'; + chip.style.cssText = 'font-size:.74rem;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block'; + chip.title = `${f.name} (${(f.size/1024).toFixed(0)} KB)`; + chip.innerHTML = `${esc(f.name)}`; + prev.appendChild(chip); + } + }, + limpiarPaciente() { document.getElementById('na-paciente-id').value = ''; document.getElementById('na-paciente-buscar').value = ''; @@ -1568,19 +1593,20 @@ const agendaNueva = { const d = await r.json(); if (!d.success) throw new Error(d.error || 'Error al guardar'); - // Subir archivo adjunto si el usuario seleccionó uno + // Subir archivos adjuntos si el usuario seleccionó alguno const archivoInput = document.getElementById('na-orden-file'); if (archivoInput?.files?.length && d.id) { const fd = new FormData(); - fd.append('file', archivoInput.files[0]); + for (const f of archivoInput.files) fd.append('files[]', f); fd.append('domicilio_id', d.id); + fd.append('paciente_id', pacId); try { const rFile = await fetch('api/lab/upload_orden.php', { method: 'POST', body: fd }); // Si falla el upload no bloqueamos; el domicilio ya fue creado const dFile = await rFile.json(); - if (!dFile.success) console.warn('No se pudo adjuntar el archivo:', dFile.error); + if (!dFile.success) console.warn('No se pudo adjuntar archivos:', dFile.error); } catch (eFile) { - console.warn('Error al subir archivo:', eFile); + console.warn('Error al subir archivos:', eFile); } } diff --git a/lab_domicilios.php b/lab_domicilios.php index df94c62..17498a3 100644 --- a/lab_domicilios.php +++ b/lab_domicilios.php @@ -398,36 +398,21 @@ $domIdParam = (int)($_GET['id'] ?? 0);
- -
-
- -
- -
-
-
-
-
-
-
+ +
- Sin orden adjunta + Sin archivos adjuntos
-
- - Imagen, PDF o DOC — máx. 10 MB + + Imágenes, PDF, DOC, XLS — máx. 10 MB por archivo
@@ -590,91 +575,85 @@ const modal = new bootstrap.Modal('#modalDomicilio'); let _modalRespForm = null; let _modalAsignarEnf = null; -// ── Estado orden en el modal ─────────────────────────────────────────────── -let _domOrderFile = null; // File pendiente de subir -let _domCurrentLocalFile = null; // local_file actual de la orden -let _domRemoveOrder = false; // el usuario quiere desvincularse de la orden +// ── Estado órdenes en el modal (multi-archivo) ──────────────────────────── +let _domNewFiles = []; // File[] nuevos pendientes de subir +let _domCurrentFiles = []; // [{orden_id, local_file}] ya guardados +let _domRemovedOrdenIds = []; // orden_ids a desvincular al guardar -function _domResetOrden() { - _domOrderFile = null; - _domCurrentLocalFile = null; - _domRemoveOrder = false; - document.getElementById('dom-orden-id-form').value = ''; - document.getElementById('dom-orden-input').value = ''; - _domRenderOrdenPreview(); +function _domResetOrdenes() { + _domNewFiles = []; + _domCurrentFiles = []; + _domRemovedOrdenIds = []; + document.getElementById('dom-orden-input').value = ''; + _domRenderOrdenes(); +} +// Alias para retrocompat con llamadas _domResetOrden() +const _domResetOrden = _domResetOrdenes; + +function _domRenderOrdenes() { + const list = document.getElementById('dom-orden-list'); + const empty = document.getElementById('dom-orden-empty'); + const btn = document.getElementById('dom-orden-btn-lbl'); + const chipStyle = 'width:72px;height:72px;border:1px solid #dee2e6;border-radius:6px;background:#f8f9fa;display:flex;align-items:center;justify-content:center;overflow:hidden'; + const delStyle = 'position:absolute;top:-5px;right:-5px;width:18px;height:18px;border-radius:50%;background:#dc3545;color:#fff;border:none;font-size:.7rem;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0'; + + list.innerHTML = ''; + + _domCurrentFiles.forEach((f, i) => { + const lf = f.local_file || ''; + const isImg = /\.(jpe?g|png|gif|webp)$/i.test(lf); + const thumb = isImg + ? `` + : ``; + const fname = lf.split('/').pop(); + list.insertAdjacentHTML('beforeend', ` +
+
${thumb}
+ ${esc(fname)} + +
`); + }); + + _domNewFiles.forEach((f, i) => { + const isImg = f.type.startsWith('image/'); + const thumb = isImg + ? `` + : ``; + list.insertAdjacentHTML('beforeend', ` +
+
${thumb}
+ ${esc(f.name)} + nuevo + +
`); + }); + + const total = _domCurrentFiles.length + _domNewFiles.length; + empty.classList.toggle('d-none', total > 0); + btn.textContent = total > 0 ? 'Agregar más archivos' : 'Adjuntar archivos'; } -function _domRenderOrdenPreview() { - const preview = document.getElementById('dom-orden-preview'); - const empty = document.getElementById('dom-orden-empty'); - const img = document.getElementById('dom-orden-img'); - const icon = document.getElementById('dom-orden-icon'); - const fname = document.getElementById('dom-orden-fname'); - const fsize = document.getElementById('dom-orden-fsize'); - const btnQuitar = document.getElementById('dom-orden-quitar'); - const btnLbl = document.getElementById('dom-orden-btn-lbl'); - - if (_domOrderFile) { - // Archivo local seleccionado aún no subido - const isImg = _domOrderFile.type.startsWith('image/'); - if (isImg) { - img.src = URL.createObjectURL(_domOrderFile); - img.style.display = ''; - icon.classList.add('d-none'); - } else { - img.style.display = 'none'; - icon.classList.remove('d-none'); +function domSeleccionarOrdenFiles(input) { + for (const file of Array.from(input.files)) { + if (file.size > 10 * 1024 * 1024) { + alert(`"${file.name}" supera los 10 MB permitidos`); + continue; } - fname.textContent = _domOrderFile.name; - fsize.textContent = (_domOrderFile.size / 1024).toFixed(1) + ' KB — pendiente de guardar'; - preview.classList.remove('d-none'); - empty.classList.add('d-none'); - btnQuitar.classList.remove('d-none'); - btnLbl.textContent = 'Cambiar archivo'; - } else if (_domCurrentLocalFile && !_domRemoveOrder) { - // Archivo ya guardado en servidor - const isImg = /\.(jpe?g|png|gif|webp)$/i.test(_domCurrentLocalFile); - if (isImg) { - img.src = 'uploads/media/' + encodeURIComponent(_domCurrentLocalFile); - img.style.display = ''; - icon.classList.add('d-none'); - } else { - img.style.display = 'none'; - icon.classList.remove('d-none'); - } - fname.textContent = _domCurrentLocalFile; - fsize.textContent = 'Archivo guardado'; - preview.classList.remove('d-none'); - empty.classList.add('d-none'); - btnQuitar.classList.remove('d-none'); - btnLbl.textContent = 'Cambiar archivo'; - } else { - preview.classList.add('d-none'); - empty.classList.remove('d-none'); - btnQuitar.classList.add('d-none'); - btnLbl.textContent = 'Adjuntar orden'; + _domNewFiles.push(file); } + input.value = ''; + _domRenderOrdenes(); } -function domSeleccionarOrdenFile(input) { - const file = input.files[0]; - if (!file) return; - if (file.size > 10 * 1024 * 1024) { - alert('El archivo supera los 10 MB permitidos'); - input.value = ''; - return; - } - _domOrderFile = file; - _domRemoveOrder = false; - _domRenderOrdenPreview(); +function domQuitarExistente(i) { + const removed = _domCurrentFiles.splice(i, 1)[0]; + if (removed?.orden_id) _domRemovedOrdenIds.push(removed.orden_id); + _domRenderOrdenes(); } -function domQuitarOrden() { - _domOrderFile = null; - _domRemoveOrder = true; - document.getElementById('dom-orden-id-form').value = ''; - document.getElementById('dom-orden-input').value = ''; - _domRenderOrdenPreview(); +function domQuitarNuevo(i) { + _domNewFiles.splice(i, 1); + _domRenderOrdenes(); } const COLOR_DOM = { programado:'secondary', confirmado:'info', en_camino:'primary', en_domicilio:'warning', completado:'success', cancelado:'danger', reprogramado:'dark' }; const PUEDE_ESCRIBIR = ; @@ -1255,11 +1234,15 @@ async function editarDomicilio(id) { document.getElementById('dom-copago-lab').value = dom.copago_laboratorio || ''; document.getElementById('dom-notas').value = dom.notas_admin || ''; - // Orden médica adjunta - _domResetOrden(); - document.getElementById('dom-orden-id-form').value = dom.orden_id || ''; - _domCurrentLocalFile = dom.orden_local_file || null; - _domRenderOrdenPreview(); + // Órdenes médicas adjuntas (multi-archivo) + _domResetOrdenes(); + let archivosExistentes = dom.archivos_ordenes || []; + if (!archivosExistentes.length && dom.orden_local_file) { + // Retrocompat: domicilio viejo con solo orden_id FK + archivosExistentes = [{ orden_id: dom.orden_id || null, local_file: dom.orden_local_file }]; + } + _domCurrentFiles = archivosExistentes; + _domRenderOrdenes(); modal.show(); } @@ -1277,57 +1260,52 @@ async function guardarDomicilio() { const datos = Object.fromEntries(new FormData(form).entries()); if (!datos.id) delete datos.id; - // Tomar orden_id del campo oculto real (no el parámetro URL) - datos.orden_id = document.getElementById('dom-orden-id-form').value || null; + delete datos.orden_id; // archivos se vinculan por domicilio_id; no pasamos FK delete datos.orden_id_url; - // ── Gestión del archivo de orden médica ────────────────────────────── - if (_domRemoveOrder) { - datos.orden_id = null; - } else if (_domOrderFile) { - // Subir el archivo primero - const fd = new FormData(); - fd.append('file', _domOrderFile); - const upR = await fetch('api/lab/upload_orden.php', { method:'POST', body: fd }); - const upD = await upR.json(); - if (!upD.success) { - mostrarToast('Error subiendo el archivo: ' + (upD.error || 'desconocido'), 'danger'); - return; - } - // Actualizar o crear la orden médica - const orId = document.getElementById('dom-orden-id-form').value; - const orPayload = orId - ? { id: parseInt(orId), local_file: upD.local_file } - : { paciente_id: parseInt(pacienteId), local_file: upD.local_file }; - const orR = await fetch('api/lab/save_orden.php', { - method:'POST', headers:{'Content-Type':'application/json'}, - body: JSON.stringify(orPayload), - }); - const orD = await orR.json(); - if (!orD.success) { - mostrarToast('Error guardando la orden: ' + (orD.error || 'desconocido'), 'danger'); - return; - } - datos.orden_id = orD.id ?? orD.data?.id ?? (parseInt(orId) || null); + // Desvincular archivos que el usuario eliminó + for (const ordenId of _domRemovedOrdenIds) { + try { + await fetch('api/lab/save_orden.php', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: ordenId, domicilio_id: null }), + }); + } catch (_e) { /* silencioso */ } } - // ── Guardar domicilio ──────────────────────────────────────────────── - if (!datos.orden_id) delete datos.orden_id; + _domRemovedOrdenIds = []; + // Guardar domicilio const r = await fetch('api/lab/save_domicilio.php', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(datos), }); const d = await r.json(); - if (d.success) { - modal.hide(); - mostrarToast(d.message, 'success'); - const editadoId = datos.id ? parseInt(datos.id) : (d.id || null); - _domResetOrden(); - await cargarLista(paginaActual); - if (editadoId) verDomicilio(editadoId); - } else { + if (!d.success) { mostrarToast(d.error||'Error', 'danger'); + return; } + + const domicilioId = datos.id ? parseInt(datos.id) : (d.id || null); + + // Subir nuevos archivos vinculados al domicilio + if (_domNewFiles.length && domicilioId) { + const fd = new FormData(); + for (const f of _domNewFiles) fd.append('files[]', f); + fd.append('domicilio_id', domicilioId); + fd.append('paciente_id', pacienteId); + try { + const upR = await fetch('api/lab/upload_orden.php', { method: 'POST', body: fd }); + const upD = await upR.json(); + if (!upD.success) mostrarToast('No se pudieron subir algunos archivos: ' + (upD.error || ''), 'warning'); + } catch (_e) { mostrarToast('Error al subir archivos', 'warning'); } + } + _domNewFiles = []; + + modal.hide(); + mostrarToast(d.message, 'success'); + _domResetOrdenes(); + await cargarLista(paginaActual); + if (domicilioId) verDomicilio(domicilioId); } // Autocompletado de paciente