diff --git a/api/lab/save_nota_domicilio.php b/api/lab/save_nota_domicilio.php index af22994..c666d82 100644 --- a/api/lab/save_nota_domicilio.php +++ b/api/lab/save_nota_domicilio.php @@ -117,9 +117,28 @@ function _extraerCampos(array $datos, string $tipo): array { $cuerpo = isset($datos['cuerpo']) ? strip_tags((string)$datos['cuerpo'], ALLOWED_HTML_TAGS) : null; + + // Validar y limpiar archivos (array de {nombre,path,mime,size}) + $archivos = null; + if (!empty($datos['archivos']) && is_array($datos['archivos'])) { + $limpios = []; + foreach ($datos['archivos'] as $a) { + $path = preg_replace('/[^a-zA-Z0-9._\-]/', '', (string)($a['path'] ?? '')); + if (!$path) continue; + $limpios[] = [ + 'nombre' => mb_substr(strip_tags((string)($a['nombre'] ?? $path)), 0, 100), + 'path' => $path, + 'mime' => preg_replace('/[^a-zA-Z0-9\/\-+.]/', '', (string)($a['mime'] ?? '')), + 'size' => (int)($a['size'] ?? 0), + ]; + } + if ($limpios) $archivos = json_encode($limpios, JSON_UNESCAPED_UNICODE); + } + return array_filter([ 'titulo' => isset($datos['titulo']) ? mb_substr(trim($datos['titulo']), 0, 200) : null, 'cuerpo' => $cuerpo ?: null, 'imagen_path' => isset($datos['imagen_path']) ? preg_replace('/[^a-zA-Z0-9._\-]/', '', (string)$datos['imagen_path']) : null, + 'archivos' => $archivos, ], fn($v) => $v !== null); } diff --git a/api/lab/upload_nota_imagen.php b/api/lab/upload_nota_imagen.php index 46cec3d..022814e 100644 --- a/api/lab/upload_nota_imagen.php +++ b/api/lab/upload_nota_imagen.php @@ -1,10 +1,12 @@ false, 'error' => "No se recibió imagen (código $code)"]); - exit; +$allowedMimes = [ + // Imágenes + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/gif' => 'gif', + // Documentos + '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', +]; + +// Normalizar: acepta 'file' (uno) o 'files[]' (varios) +$incoming = []; +if (!empty($_FILES['files']['name'][0])) { + // múltiples + for ($i = 0; $i < count($_FILES['files']['name']); $i++) { + $incoming[] = [ + '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'])) { + $incoming[] = [ + 'name' => $_FILES['file']['name'], + 'tmp_name' => $_FILES['file']['tmp_name'], + 'error' => $_FILES['file']['error'], + 'size' => $_FILES['file']['size'], + ]; } -$file = $_FILES['file']; - -// Máximo 5 MB para imágenes de notas -if ($file['size'] > 5 * 1024 * 1024) { - echo json_encode(['success' => false, 'error' => 'La imagen no puede superar 5 MB']); +if (empty($incoming)) { + echo json_encode(['success' => false, 'error' => 'No se recibieron archivos']); exit; } -// Validar MIME real (solo imágenes) -$allowedMimes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; -$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). Solo imágenes."]); - exit; -} - -// Extensión segura -$mimeToExt = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif']; -$ext = $mimeToExt[$mime] ?? 'jpg'; -$newName = 'nota_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $ext; - $uploadDir = __DIR__ . '/../../uploads/media'; if (!is_dir($uploadDir)) { @mkdir($uploadDir, 0755, true); } -$destPath = realpath($uploadDir) . '/' . $newName; -if (!move_uploaded_file($file['tmp_name'], $destPath)) { - echo json_encode(['success' => false, 'error' => 'Error al guardar el archivo en el servidor']); - exit; +$resultado = []; +$finfo = finfo_open(FILEINFO_MIME_TYPE); + +foreach ($incoming as $file) { + if ($file['error'] !== UPLOAD_ERR_OK) { + echo json_encode(['success' => false, 'error' => "Error al recibir archivo: código {$file['error']}"]); + finfo_close($finfo); + exit; + } + if ($file['size'] > 10 * 1024 * 1024) { + echo json_encode(['success' => false, 'error' => "El archivo '{$file['name']}' supera 10 MB"]); + finfo_close($finfo); + exit; + } + + $mime = finfo_file($finfo, $file['tmp_name']); + if (!isset($allowedMimes[$mime])) { + echo json_encode(['success' => false, 'error' => "Tipo no permitido ($mime). Imágenes, PDF o documentos Office."]); + finfo_close($finfo); + exit; + } + + $ext = $allowedMimes[$mime]; + $newName = 'nota_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $ext; + $dest = realpath($uploadDir) . '/' . $newName; + + if (!move_uploaded_file($file['tmp_name'], $dest)) { + echo json_encode(['success' => false, 'error' => 'Error al guardar el archivo en el servidor']); + finfo_close($finfo); + exit; + } + + $nombreOriginal = mb_substr(preg_replace('/[^\w.\-áéíóúÁÉÍÓÚñÑ ]/u', '', $file['name']), 0, 100); + + $resultado[] = [ + 'nombre' => $nombreOriginal ?: $newName, + 'path' => $newName, + 'mime' => $mime, + 'size' => $file['size'], + ]; } +finfo_close($finfo); + echo json_encode([ - 'success' => true, - 'imagen_path' => $newName, - 'file_size' => $file['size'], + 'success' => true, + 'archivos' => $resultado, + // retrocompatibilidad: si solo fue uno, devolver también imagen_path + 'imagen_path'=> count($resultado) === 1 ? $resultado[0]['path'] : null, ]); diff --git a/enfermero_portal.php b/enfermero_portal.php index 3bd6acf..47cf718 100644 --- a/enfermero_portal.php +++ b/enfermero_portal.php @@ -1931,9 +1931,7 @@ const notasManager = {
${n.cuerpo ? n.cuerpo.replace(/\n/g,'
') : ''}
- ${n.imagen_path ? `` : ''} + ${_renderArchivosNota(n)}
${esc(n.created_at||'')}
`; }); @@ -1948,9 +1946,10 @@ const notasManager = { // ── Bottom sheet: Ficha clínica ──────────────────────────────────────── abrirFicha(domId) { - this._domId = domId; - this._notaId = 'ficha'; - this._imagen = null; + this._domId = domId; + this._notaId = 'ficha'; + this._imagen = null; + this._archivos = []; const body = document.getElementById(`notas-body-${domId}`); const pac = body ? body.dataset.pacNac || '' : ''; @@ -1992,9 +1991,10 @@ const notasManager = { // ── Bottom sheet: Nota libre ─────────────────────────────────────────── abrirLibre(domId, notaId) { - this._domId = domId; - this._notaId = notaId; - this._imagen = null; + this._domId = domId; + this._notaId = notaId; + this._imagen = null; + this._archivos = []; let titulo = '', cuerpo = ''; if (notaId) { @@ -2023,20 +2023,25 @@ const notasManager = { data-placeholder="Escribe aquí la observación...">${cuerpo}
- -
+ +
+
-
+
`; + }; + reader.readAsDataURL(file); + } else { + const icon = file.type === 'application/pdf' ? 'fa-file-pdf text-danger' + : file.type.includes('word') ? 'fa-file-word text-primary' + : file.type.includes('excel') || file.type.includes('sheet') ? 'fa-file-excel text-success' + : 'fa-file text-secondary'; + chip.innerHTML = ` +
+ +
${esc(file.name)}
+
+ `; + } + if (prev) prev.appendChild(chip); + }); + input.value = ''; // reset para poder volver a elegir el mismo archivo + }, + _quitarArchivo(idx) { + if (this._archivos) this._archivos[idx] = null; + const chip = document.getElementById(`arch-chip-${idx}`); + if (chip) chip.remove(); }, _verImg(src) { const w = window.open('', '_blank'); @@ -2191,6 +2224,38 @@ const notasManager = { return años < 18; }, }; + +// Render de archivos adjuntos guardados en una nota +function _renderArchivosNota(n) { + // Nuevo: array archivos JSON + let lista = []; + if (n.archivos) { + try { lista = typeof n.archivos === 'string' ? JSON.parse(n.archivos) : n.archivos; } catch(e) {} + } + // Retrocompatibilidad: imagen_path antigua + if (!lista.length && n.imagen_path) { + lista = [{ nombre: n.imagen_path, path: n.imagen_path, mime: 'image/jpeg', size: 0 }]; + } + if (!lista.length) return ''; + + return '
' + lista.map(a => { + const src = `uploads/media/${esc(a.path)}`; + const esImg = (a.mime || '').startsWith('image/'); + if (esImg) { + return ``; + } + const icon = (a.mime||'').includes('pdf') ? 'fa-file-pdf text-danger' + : (a.mime||'').includes('word') ? 'fa-file-word text-primary' + : (a.mime||'').includes('excel') || (a.mime||'').includes('sheet') ? 'fa-file-excel text-success' + : 'fa-file text-secondary'; + return ` + +
${esc(a.nombre)}
+
`; + }).join('') + '
'; +}
diff --git a/migrations/20260408_lab_nota_archivos.sql b/migrations/20260408_lab_nota_archivos.sql new file mode 100644 index 0000000..126e1e5 --- /dev/null +++ b/migrations/20260408_lab_nota_archivos.sql @@ -0,0 +1,9 @@ +-- Migración: soporte multi-archivo en notas libres de domicilios +-- Agrega columna `archivos` (JSON) que almacena un array de objetos +-- { nombre, path, mime, size } para cada archivo adjunto a la nota. +-- Se preserva `imagen_path` para retrocompatibilidad con notas existentes. + +ALTER TABLE `lab_domicilio_notas` + ADD COLUMN `archivos` JSON DEFAULT NULL + COMMENT 'Array JSON de archivos adjuntos: [{nombre,path,mime,size}]' + AFTER `imagen_path`;