multiples documentos
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/lab/upload_nota_imagen.php
|
||||
* Sube una imagen adjunta a una nota libre de domicilio.
|
||||
* Sube uno o varios archivos adjuntos a una nota de domicilio.
|
||||
* Acepta: imágenes (jpg, png, webp, gif), PDF, Word, Excel, texto.
|
||||
* Límite: 10 MB por archivo.
|
||||
*
|
||||
* POST multipart/form-data con campo 'file'
|
||||
* Devuelve: { success, imagen_path, file_size }
|
||||
* POST multipart/form-data con campo 'file' (uno) o 'files[]' (varios)
|
||||
* Devuelve: { success, archivos: [{nombre, path, mime, size}] }
|
||||
*/
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once __DIR__ . '/../../classes/Database.php';
|
||||
@@ -20,49 +22,100 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||
$code = $_FILES['file']['error'] ?? -1;
|
||||
echo json_encode(['success' => 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,
|
||||
]);
|
||||
|
||||
+100
-35
@@ -1931,9 +1931,7 @@ const notasManager = {
|
||||
</div>
|
||||
</div>
|
||||
<div class="nota-cuerpo">${n.cuerpo ? n.cuerpo.replace(/\n/g,'<br>') : ''}</div>
|
||||
${n.imagen_path ? `<img src="uploads/media/${esc(n.imagen_path)}"
|
||||
class="img-fluid rounded mt-1" style="max-height:160px;cursor:pointer"
|
||||
onclick="notasManager._verImg(this.src)">` : ''}
|
||||
${_renderArchivosNota(n)}
|
||||
<div class="nota-fecha">${esc(n.created_at||'')}</div>
|
||||
</div>`;
|
||||
});
|
||||
@@ -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}</div>
|
||||
</div>
|
||||
<div class="nota-section">
|
||||
<label class="nota-label"><i class="fas fa-image me-1"></i>Imagen (opcional)</label>
|
||||
<div class="d-flex gap-2">
|
||||
<label class="nota-label"><i class="fas fa-paperclip me-1"></i>Archivos adjuntos (opcional)</label>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<label class="btn btn-sm btn-outline-secondary" style="font-size:.76rem">
|
||||
<i class="fas fa-camera me-1"></i>Cámara
|
||||
<input type="file" accept="image/*" capture="environment" class="d-none"
|
||||
onchange="notasManager._prevImagen(this)">
|
||||
<input type="file" accept="image/*" capture="environment" class="d-none" multiple
|
||||
onchange="notasManager._agregarArchivos(this)">
|
||||
</label>
|
||||
<label class="btn btn-sm btn-outline-secondary" style="font-size:.76rem">
|
||||
<i class="fas fa-image me-1"></i>Galería
|
||||
<input type="file" accept="image/*" class="d-none"
|
||||
onchange="notasManager._prevImagen(this)">
|
||||
<input type="file" accept="image/*" class="d-none" multiple
|
||||
onchange="notasManager._agregarArchivos(this)">
|
||||
</label>
|
||||
<label class="btn btn-sm btn-outline-secondary" style="font-size:.76rem">
|
||||
<i class="fas fa-file me-1"></i>Archivos
|
||||
<input type="file" accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.csv,image/*" class="d-none" multiple
|
||||
onchange="notasManager._agregarArchivos(this)">
|
||||
</label>
|
||||
</div>
|
||||
<div id="ns-img-preview" class="mt-2"></div>
|
||||
<div id="ns-archivos-preview" class="mt-2 d-flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
<button class="nota-btn-guardar" style="background:#059669"
|
||||
onclick="notasManager._guardarLibre()">
|
||||
@@ -2092,14 +2097,15 @@ const notasManager = {
|
||||
const cuerpo = document.getElementById('ns-cuerpo')?.innerHTML || '';
|
||||
if (!titulo) { alert('Escribe un título para la nota.'); return; }
|
||||
|
||||
let imagenPath = null;
|
||||
if (this._imagen) {
|
||||
// Subir archivos pendientes
|
||||
let archivos = [];
|
||||
if (this._archivos && this._archivos.length) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', this._imagen);
|
||||
this._archivos.forEach(f => fd.append('files[]', f));
|
||||
const ru = await fetch('api/lab/upload_nota_imagen.php', { method:'POST', body: fd });
|
||||
const du = await ru.json();
|
||||
if (!du.success) { alert('No se pudo subir la imagen: ' + (du.error||'')); return; }
|
||||
imagenPath = du.imagen_path;
|
||||
if (!du.success) { alert('No se pudo subir uno o más archivos: ' + (du.error||'')); return; }
|
||||
archivos = du.archivos || [];
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -2108,7 +2114,7 @@ const notasManager = {
|
||||
domicilio_id: this._domId,
|
||||
titulo,
|
||||
cuerpo,
|
||||
imagen_path: imagenPath,
|
||||
archivos,
|
||||
};
|
||||
if (this._notaId) payload.id = this._notaId;
|
||||
|
||||
@@ -2159,24 +2165,51 @@ const notasManager = {
|
||||
document.getElementById('nota-backdrop').style.display = 'none';
|
||||
document.getElementById('nota-sheet').style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
this._domId = null; this._notaId = null; this._imagen = null;
|
||||
this._domId = null; this._notaId = null; this._imagen = null; this._archivos = [];
|
||||
},
|
||||
_fmt(cmd) { document.execCommand(cmd, false, null); document.getElementById('ns-cuerpo')?.focus(); },
|
||||
_toggleAcudiente(on) {
|
||||
document.getElementById('ns-acudiente-bloque')?.classList.toggle('d-none', !on);
|
||||
},
|
||||
_prevImagen(input) {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
this._imagen = file;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const prev = document.getElementById('ns-img-preview');
|
||||
if (prev) prev.innerHTML = `<img src="${e.target.result}"
|
||||
class="img-fluid rounded" style="max-height:140px">
|
||||
<div class="small text-muted mt-1">${esc(file.name)}</div>`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
_agregarArchivos(input) {
|
||||
if (!input.files?.length) return;
|
||||
if (!this._archivos) this._archivos = [];
|
||||
const prev = document.getElementById('ns-archivos-preview');
|
||||
Array.from(input.files).forEach(file => {
|
||||
const idx = this._archivos.length;
|
||||
this._archivos.push(file);
|
||||
const esImg = file.type.startsWith('image/');
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'position-relative';
|
||||
chip.id = `arch-chip-${idx}`;
|
||||
if (esImg) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
chip.innerHTML = `
|
||||
<img src="${e.target.result}" class="rounded" style="height:70px;width:70px;object-fit:cover">
|
||||
<button onclick="notasManager._quitarArchivo(${idx})" class="btn btn-danger btn-xs py-0 px-1 position-absolute" style="top:-6px;right:-6px;font-size:.65rem;border-radius:50%"><i class="fas fa-times"></i></button>`;
|
||||
};
|
||||
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 = `
|
||||
<div class="border rounded p-1 text-center" style="width:70px;height:70px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px">
|
||||
<i class="fas ${icon} fa-lg"></i>
|
||||
<div style="font-size:.6rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:65px">${esc(file.name)}</div>
|
||||
</div>
|
||||
<button onclick="notasManager._quitarArchivo(${idx})" class="btn btn-danger btn-xs py-0 px-1 position-absolute" style="top:-6px;right:-6px;font-size:.65rem;border-radius:50%"><i class="fas fa-times"></i></button>`;
|
||||
}
|
||||
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 '<div class="d-flex flex-wrap gap-2 mt-2">' + lista.map(a => {
|
||||
const src = `uploads/media/${esc(a.path)}`;
|
||||
const esImg = (a.mime || '').startsWith('image/');
|
||||
if (esImg) {
|
||||
return `<img src="${src}" class="rounded" style="height:70px;width:70px;object-fit:cover;cursor:pointer"
|
||||
onclick="notasManager._verImg(this.src)" title="${esc(a.nombre)}">`;
|
||||
}
|
||||
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 `<a href="${src}" target="_blank" class="border rounded p-1 text-center text-decoration-none"
|
||||
style="width:70px;height:70px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;color:inherit">
|
||||
<i class="fas ${icon} fa-lg"></i>
|
||||
<div style="font-size:.6rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:65px">${esc(a.nombre)}</div>
|
||||
</a>`;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
</script>
|
||||
<!-- ── Bottom sheet: editor de notas ──────────────────────────── -->
|
||||
<div id="nota-backdrop" onclick="notasManager._cerrarSheet()"></div>
|
||||
|
||||
@@ -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`;
|
||||
Reference in New Issue
Block a user