Actas: fotos de la galería y documentos terminados (PDF, Word, Excel)
Pedido del equipo de campo (Leo, 28-oct 12:10). Cada foto de un acta tiene «Tomar foto» (cámara) y «Elegir de la galería»; sección «Documentos» al final de toda acta para el documento terminado. /api/archivos acepta PDF, Word, Excel, ODT/ODS y PowerPoint revisando los primeros bytes; /api/adjunto con nosniff, descarga lo que no es foto, PDF o texto. Pruebas: tests-1028.sh (15) + 1027 (55) + 1023 (35) + campeón (25). Desplegado: páginas con rsync y backend con HUP a gunicorn (DESPLIEGUE A12). Lote 2026-10-28_actas-galeria-documentos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SfRSvDd335NPYEbBrDFGE
This commit is contained in:
parent
34d142527f
commit
cb22d0913a
@ -560,6 +560,25 @@ Lo mismo a mano: `sync-docs.sh` → commit/push de hbox-docs y del submódulo
|
|||||||
| `cambiar-entrada.sh aplicar` dice que el backend no tiene la entrada | falta el paso `servicio` (reiniciar con los módulos nuevos) | correr `servicio` primero |
|
| `cambiar-entrada.sh aplicar` dice que el backend no tiene la entrada | falta el paso `servicio` (reiniciar con los módulos nuevos) | correr `servicio` primero |
|
||||||
| Quiero cerrar la sesión de todos | — | `sudo rm /srv/projects/drogheda/.sesion-secreto && sudo systemctl restart actas-hbox` |
|
| Quiero cerrar la sesión de todos | — | `sudo rm /srv/projects/drogheda/.sesion-secreto && sudo systemctl restart actas-hbox` |
|
||||||
|
|
||||||
|
## A12 · 28-oct: fotos de la galería y documentos en las actas
|
||||||
|
|
||||||
|
Lo pidió el equipo de campo (Leo, 28-oct 12:10): en los formularios solo se podía tomar la foto en vivo, no elegirla
|
||||||
|
de la galería, y no había dónde subir un documento terminado.
|
||||||
|
|
||||||
|
- `docs/formularios/acta.js` + `acta.css`: cada foto tiene dos botones, **Tomar foto** (cámara) y **Elegir de la
|
||||||
|
galería** (sin `capture`); una foto HEIC que el navegador no puede convertir lo dice en vez de fallar en el servidor.
|
||||||
|
Sección **Documentos** al final de toda acta (opcional; `cfg.sinDocumentos` la quita): PDF, Word, Excel, ODT/ODS,
|
||||||
|
PowerPoint o la foto de una hoja. Los botones de subir se desactivan en un acta ya enviada.
|
||||||
|
- `backend/app.py`: `/api/archivos` acepta además `EXT_DOC` (.pdf .docx .xlsx .pptx .odt .ods .doc .xls .ppt) y revisa
|
||||||
|
los primeros bytes (un HTML renombrado a .pdf se rechaza). `/api/adjunto` manda `nosniff`, abre en el navegador fotos,
|
||||||
|
PDF y texto, y descarga lo demás con su nombre original.
|
||||||
|
- `docs/tableros/unidad.md`: el expediente dice «Archivos y documentos».
|
||||||
|
- Pruebas: `bash backend/tests-1028.sh .` (15) además de 1027 (55), 1023 (35) y campeón (25); en teléfono, claro y oscuro.
|
||||||
|
|
||||||
|
Ya está desplegado (28-oct, 12:17): páginas con rsync después de la comparación con el servidor, y el backend con
|
||||||
|
`kill -HUP` al proceso maestro de gunicorn (corre como `leo`: recarga los workers sin sudo y sin cortar). Si algo
|
||||||
|
falla: `git revert` del commit y `./scripts/deploy.sh`, luego `ssh -t leo@git.helgen.online 'sudo systemctl restart actas-hbox'`.
|
||||||
|
|
||||||
## B · Verificación después de desplegar
|
## B · Verificación después de desplegar
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -70,6 +70,11 @@ app.config["MAX_CONTENT_LENGTH"] = (max(MAX_MB, LOG_MAX_MB) + 2) * 1024 * 1024
|
|||||||
EXT_OK = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
EXT_OK = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||||
# Lista blanca de logs: texto plano y capturas binarias de u-blox. Nada ejecutable.
|
# Lista blanca de logs: texto plano y capturas binarias de u-blox. Nada ejecutable.
|
||||||
EXT_LOG = {".txt", ".log", ".csv", ".nmea", ".ubx", ".json", ".cfg", ".md"}
|
EXT_LOG = {".txt", ".log", ".csv", ".nmea", ".ubx", ".json", ".cfg", ".md"}
|
||||||
|
# Documentos terminados que respaldan un acta (28-oct): la sección «Documentos» de los formularios. Se revisa que el
|
||||||
|
# contenido sea de verdad ese tipo (firma de los primeros bytes). Word/Excel se descargan; el PDF abre en el visor.
|
||||||
|
EXT_DOC = {".pdf": (b"%PDF",), ".docx": (b"PK\x03\x04",), ".xlsx": (b"PK\x03\x04",), ".pptx": (b"PK\x03\x04",),
|
||||||
|
".odt": (b"PK\x03\x04",), ".ods": (b"PK\x03\x04",),
|
||||||
|
".doc": (b"\xd0\xcf\x11\xe0",), ".xls": (b"\xd0\xcf\x11\xe0",), ".ppt": (b"\xd0\xcf\x11\xe0",)}
|
||||||
|
|
||||||
SAFE = re.compile(r"[^A-Za-z0-9_\-]")
|
SAFE = re.compile(r"[^A-Za-z0-9_\-]")
|
||||||
SAFE_FILE = re.compile(r"[^A-Za-z0-9_\-.]")
|
SAFE_FILE = re.compile(r"[^A-Za-z0-9_\-.]")
|
||||||
@ -408,7 +413,8 @@ def subir_adjuntos():
|
|||||||
|
|
||||||
@app.post("/api/archivos")
|
@app.post("/api/archivos")
|
||||||
def subir_archivos():
|
def subir_archivos():
|
||||||
"""Sube archivos de log de campo/banco. multipart: tipo, serie, archivo (1..10), etiqueta."""
|
"""Sube archivos de log de campo/banco y documentos terminados (PDF, Word, Excel…).
|
||||||
|
multipart: tipo, serie, archivo (1..10), etiqueta."""
|
||||||
tipo = slug(request.form.get("tipo")); serie = slug(request.form.get("serie"), 24)
|
tipo = slug(request.form.get("tipo")); serie = slug(request.form.get("serie"), 24)
|
||||||
etiqueta = str(request.form.get("etiqueta", ""))[:80]
|
etiqueta = str(request.form.get("etiqueta", ""))[:80]
|
||||||
if not tipo or not serie:
|
if not tipo or not serie:
|
||||||
@ -425,15 +431,19 @@ def subir_archivos():
|
|||||||
for fh in subidos:
|
for fh in subidos:
|
||||||
nombre = SAFE_FILE.sub("_", os.path.basename(fh.filename or "captura"))[:80]
|
nombre = SAFE_FILE.sub("_", os.path.basename(fh.filename or "captura"))[:80]
|
||||||
ext = os.path.splitext(nombre)[1].lower()
|
ext = os.path.splitext(nombre)[1].lower()
|
||||||
if ext not in EXT_LOG:
|
if ext not in EXT_LOG and ext not in EXT_DOC:
|
||||||
rechazados.append({"nombre": nombre,
|
rechazados.append({"nombre": nombre,
|
||||||
"error": "extensión no permitida (usa .txt .log .csv .nmea .ubx .json)"})
|
"error": "extensión no permitida (logs: .txt .log .csv .nmea .ubx .json; "
|
||||||
|
"documentos: .pdf .docx .xlsx .doc .xls .odt .ods .pptx)"})
|
||||||
continue
|
continue
|
||||||
datos = fh.read(LOG_MAX_MB * 1024 * 1024 + 1)
|
datos = fh.read(LOG_MAX_MB * 1024 * 1024 + 1)
|
||||||
if len(datos) > LOG_MAX_MB * 1024 * 1024:
|
if len(datos) > LOG_MAX_MB * 1024 * 1024:
|
||||||
rechazados.append({"nombre": nombre, "error": f"pesa más de {LOG_MAX_MB} MB"}); continue
|
rechazados.append({"nombre": nombre, "error": f"pesa más de {LOG_MAX_MB} MB"}); continue
|
||||||
if not datos:
|
if not datos:
|
||||||
rechazados.append({"nombre": nombre, "error": "el archivo está vacío"}); continue
|
rechazados.append({"nombre": nombre, "error": "el archivo está vacío"}); continue
|
||||||
|
if ext in EXT_DOC and not datos.startswith(EXT_DOC[ext]):
|
||||||
|
rechazados.append({"nombre": nombre, "error": f"no es un {ext[1:].upper()} válido (¿está dañado o le cambiaron la extensión?)"})
|
||||||
|
continue
|
||||||
final = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:4] + "_" + nombre
|
final = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:4] + "_" + nombre
|
||||||
with open(os.path.join(carpeta, final), "wb") as f:
|
with open(os.path.join(carpeta, final), "wb") as f:
|
||||||
f.write(datos)
|
f.write(datos)
|
||||||
@ -454,7 +464,14 @@ def ver_adjunto():
|
|||||||
fp = os.path.realpath(os.path.join(ADJ, ruta))
|
fp = os.path.realpath(os.path.join(ADJ, ruta))
|
||||||
if not fp.startswith(os.path.realpath(ADJ) + os.sep) or not os.path.isfile(fp):
|
if not fp.startswith(os.path.realpath(ADJ) + os.sep) or not os.path.isfile(fp):
|
||||||
return jsonify(ok=False, error="no existe"), 404
|
return jsonify(ok=False, error="no existe"), 404
|
||||||
return send_file(fp)
|
ext = os.path.splitext(fp)[1].lower()
|
||||||
|
# Fotos y PDF se ven en el navegador; lo demás (Word, Excel, logs) se descarga. nosniff: que el navegador no
|
||||||
|
# adivine otro tipo a partir del contenido.
|
||||||
|
nombre = re.sub(r"^\d{8}-\d{6}-[0-9a-f]{4}_", "", os.path.basename(fp)) or os.path.basename(fp)
|
||||||
|
resp = send_file(fp, as_attachment=ext not in {".jpg", ".png", ".webp", ".pdf", ".txt", ".log", ".csv"},
|
||||||
|
download_name=nombre)
|
||||||
|
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(413)
|
@app.errorhandler(413)
|
||||||
|
|||||||
16
backend/lotes/2026-10-28_actas-galeria-documentos.json
Normal file
16
backend/lotes/2026-10-28_actas-galeria-documentos.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"lote": "2026-10-28_actas-galeria-documentos",
|
||||||
|
"autor": "claude",
|
||||||
|
"motivo": "28-oct, 12:10 (Leo): el equipo no podía elegir fotos de la galería en las actas, solo tomarlas en vivo, ni subir documentos terminados. Hecho y publicado el mismo día.",
|
||||||
|
"fuente": "/formularios/instalacion.html",
|
||||||
|
"cambios": [
|
||||||
|
{ "crear": { "id": "t-actas-galeria-documentos",
|
||||||
|
"titulo": "Actas: elegir fotos de la galería y subir documentos terminados (PDF, Word, Excel)",
|
||||||
|
"pista": "campo", "estado": "hecho", "responsable": "claude",
|
||||||
|
"sitios": ["qro", "icave", "timsa", "eit", "lzc"], "etiquetas": ["quick-win"],
|
||||||
|
"descripcion": "Pedido del equipo (Leo, 28-oct 12:10). En toda acta: cada foto tiene «Tomar foto» y «Elegir de la galería»; al final, la sección «Documentos» para el documento terminado (hoja firmada, PDF, Word, Excel o la foto de una hoja), que queda en el expediente de la serie. Evidencia: 15 pruebas nuevas del servidor (tests-1028.sh) y las anteriores (55 + 35 + 25) bien; probado en teléfono en claro y oscuro; publicado el 28-oct a las 12:17 (DESPLIEGUE A12).",
|
||||||
|
"enlaces": [{ "titulo": "Acta de instalación", "url": "/formularios/instalacion.html" },
|
||||||
|
{ "titulo": "Expediente de una unidad", "url": "/tableros/unidad/" }] },
|
||||||
|
"si_no_hay": ["fotos de la galería"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
62
backend/tests-1028.sh
Executable file
62
backend/tests-1028.sh
Executable file
@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pruebas del 28-oct: documentos terminados en las actas (/api/archivos acepta PDF, Word, Excel…, con revisión del
|
||||||
|
# contenido) y cómo se sirven (/api/adjunto: PDF y fotos en el navegador, lo demás se descarga, nosniff).
|
||||||
|
# Arranca app.py con carpetas temporales. No toca el servidor.
|
||||||
|
# Uso: bash backend/tests-1028.sh <ruta-a-hbox-docs> [puerto]
|
||||||
|
set -u
|
||||||
|
REPO=${1:?ruta a hbox-docs}; PORT=${2:-8098}
|
||||||
|
T=$(mktemp -d); export ACTAS_DIR=$T/actas HTPASSWD_FILE=$T/htpasswd HTPASSWD_LOCK=$T/.lock SESION_COOKIE_INSEGURA=1 \
|
||||||
|
ACTAS_ADMINS=leo PORT ADJUNTOS_DIR=$T/adjuntos
|
||||||
|
mkdir -p $ACTAS_DIR $T/equipo $T/tareas $T/f
|
||||||
|
touch $T/htpasswd
|
||||||
|
python3 $REPO/backend/app.py > $T/app.log 2>&1 &
|
||||||
|
APP=$!; trap "kill $APP 2>/dev/null" EXIT; sleep 1.8
|
||||||
|
U="http://127.0.0.1:$PORT"
|
||||||
|
ok=0; fail=0
|
||||||
|
check() { if [[ "$3" == *"$2"* ]]; then ok=$((ok+1)); echo " ✓ $1"; else fail=$((fail+1)); echo " ✕ $1 — esperaba «$2», obtuve: ${3:0:220}"; fi; }
|
||||||
|
sube() { curl -s -H "X-Remote-User: jesush" -F tipo=instalacion-campo -F serie=HBOX5-E15 -F etiqueta=Documento -F "archivo=@$1" $U/api/archivos; }
|
||||||
|
|
||||||
|
# archivos de prueba
|
||||||
|
python3 - "$T/f" <<'EOF'
|
||||||
|
import sys, os, zipfile, io
|
||||||
|
d = sys.argv[1]
|
||||||
|
open(os.path.join(d, "hoja-firmada.pdf"), "wb").write(b"%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n")
|
||||||
|
z = io.BytesIO(); zipfile.ZipFile(z, "w").writestr("word/document.xml", "<w:document/>")
|
||||||
|
open(os.path.join(d, "reporte.docx"), "wb").write(z.getvalue())
|
||||||
|
open(os.path.join(d, "conteo.xlsx"), "wb").write(z.getvalue())
|
||||||
|
open(os.path.join(d, "viejo.xls"), "wb").write(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\0" * 64)
|
||||||
|
open(os.path.join(d, "falso.pdf"), "wb").write(b"<html><script>alert(1)</script></html>")
|
||||||
|
open(os.path.join(d, "pagina.html"), "wb").write(b"<html></html>")
|
||||||
|
open(os.path.join(d, "programa.exe"), "wb").write(b"MZ\x90\x00")
|
||||||
|
open(os.path.join(d, "captura.log"), "wb").write(b"$GNGGA,1,2,3\n")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "## documentos en /api/archivos"
|
||||||
|
R=$(sube $T/f/hoja-firmada.pdf); check "PDF se acepta" '"ok":true' "$R"
|
||||||
|
PDF=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['archivos'][0]['ruta'])")
|
||||||
|
R=$(sube $T/f/reporte.docx); check "Word (.docx) se acepta" '"ok":true' "$R"
|
||||||
|
DOCX=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['archivos'][0]['ruta'])")
|
||||||
|
check "Excel (.xlsx) se acepta" '"ok":true' "$(sube $T/f/conteo.xlsx)"
|
||||||
|
check "Excel viejo (.xls) se acepta" '"ok":true' "$(sube $T/f/viejo.xls)"
|
||||||
|
check "PDF falso (HTML con .pdf) se rechaza" 'no es un PDF' "$(sube $T/f/falso.pdf)"
|
||||||
|
check ".html se rechaza" 'n no permitida' "$(sube $T/f/pagina.html)"
|
||||||
|
check ".exe se rechaza" 'n no permitida' "$(sube $T/f/programa.exe)"
|
||||||
|
check "los logs siguen igual" '"ok":true' "$(sube $T/f/captura.log)"
|
||||||
|
|
||||||
|
echo "## cómo se sirven (/api/adjunto)"
|
||||||
|
H=$(curl -s -D - -o /dev/null -H "X-Remote-User: leo" "$U/api/adjunto?ruta=$PDF")
|
||||||
|
check "PDF abre en el navegador (inline)" 'inline' "$H"
|
||||||
|
check "PDF con su nombre original" 'hoja-firmada.pdf' "$H"
|
||||||
|
check "PDF con nosniff" 'nosniff' "$H"
|
||||||
|
check "PDF como application/pdf" 'application/pdf' "$H"
|
||||||
|
H=$(curl -s -D - -o /dev/null -H "X-Remote-User: leo" "$U/api/adjunto?ruta=$DOCX")
|
||||||
|
check "Word se descarga (attachment)" 'attachment' "$H"
|
||||||
|
check "Word con su nombre original" 'reporte.docx' "$H"
|
||||||
|
|
||||||
|
echo "## el expediente los lista"
|
||||||
|
E=$(curl -s -H "X-Remote-User: leo" "$U/api/unidad?serie=HBOX5-E15")
|
||||||
|
check "expediente cuenta 5 archivos (4 documentos + 1 log)" '"total_logs":5' "$E"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "RESULTADO: $ok correctas · $fail fallidas · datos en $T"
|
||||||
|
[ $fail -eq 0 ]
|
||||||
@ -130,6 +130,9 @@ button.btn-foto{width:100%;background:transparent;color:var(--hg-liga);border:2p
|
|||||||
border-radius:12px;padding:16px;font:inherit;font-size:16px;font-weight:700;cursor:pointer}
|
border-radius:12px;padding:16px;font:inherit;font-size:16px;font-weight:700;cursor:pointer}
|
||||||
button.btn-foto:hover{border-color:var(--hg-liga)}
|
button.btn-foto:hover{border-color:var(--hg-liga)}
|
||||||
button.btn-foto:active{background:var(--hg-acento-suave)}
|
button.btn-foto:active{background:var(--hg-acento-suave)}
|
||||||
|
.btns-foto{display:flex;gap:10px}
|
||||||
|
.btns-foto .btn-foto{flex:1 1 0;min-width:0}
|
||||||
|
button.btn-foto:disabled{opacity:.45;cursor:default}
|
||||||
.miniaturas{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px}
|
.miniaturas{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px}
|
||||||
.miniaturas:empty{margin-top:0}
|
.miniaturas:empty{margin-top:0}
|
||||||
.mini{width:104px;border:1px solid var(--hg-linea);border-radius:10px;padding:6px;background:var(--hg-plano);
|
.mini{width:104px;border:1px solid var(--hg-linea);border-radius:10px;padding:6px;background:var(--hg-plano);
|
||||||
|
|||||||
@ -14,8 +14,11 @@
|
|||||||
item.si = v => v.led==='apagado' → muestra el ítem/sección solo si se cumple
|
item.si = v => v.led==='apagado' → muestra el ítem/sección solo si se cumple
|
||||||
item.req = true → obligatorio para poder enviar
|
item.req = true → obligatorio para poder enviar
|
||||||
item.guardaEn = 'clasificacion' → además copia el valor a la raíz del acta (índice/tablero)
|
item.guardaEn = 'clasificacion' → además copia el valor a la raíz del acta (índice/tablero)
|
||||||
item.tipo = 'foto' → cámara del teléfono; sube a /api/adjuntos y guarda la ruta
|
item.tipo = 'foto' → dos botones: «Tomar foto» (cámara) y «Elegir de la galería» (28-oct);
|
||||||
|
se reduce a JPEG y sube a /api/adjuntos
|
||||||
item.tipo = 'archivo' → captura de log (.txt .log .csv .nmea .ubx); sube a /api/archivos
|
item.tipo = 'archivo' → captura de log (.txt .log .csv .nmea .ubx); sube a /api/archivos
|
||||||
|
Sección «Documentos» (28-oct): al final de TODA acta, opcional — el documento terminado que respalda
|
||||||
|
el acta (PDF, Word, Excel o la foto de una hoja). cfg.sinDocumentos = true la quita.
|
||||||
item.tipo = 'fecha' → selector de fecha; arranca con la de hoy
|
item.tipo = 'fecha' → selector de fecha; arranca con la de hoy
|
||||||
item.tipo = 'sitio' → lista de sitios (puertos/terminales) desde /api/sitios; con
|
item.tipo = 'sitio' → lista de sitios (puertos/terminales) desde /api/sitios; con
|
||||||
guardaEn:'sitio' el slug queda en la raíz del acta y la página
|
guardaEn:'sitio' el slug queda en la raíz del acta y la página
|
||||||
@ -34,6 +37,11 @@
|
|||||||
const $ = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a){ if(p==='html')e.innerHTML=a[p]; else if(p==='text')e.textContent=a[p]; else e.setAttribute(p,a[p]); } k.forEach(c=>e.append(c)); return e; };
|
const $ = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a){ if(p==='html')e.innerHTML=a[p]; else if(p==='text')e.textContent=a[p]; else e.setAttribute(p,a[p]); } k.forEach(c=>e.append(c)); return e; };
|
||||||
const num = s => (String(s || '').match(/(\d+)\s*$/) || [,''])[1];
|
const num = s => (String(s || '').match(/(\d+)\s*$/) || [,''])[1];
|
||||||
const esPrueba = s => String(s || '').toUpperCase().indexOf('PRUEBA') >= 0;
|
const esPrueba = s => String(s || '').toUpperCase().indexOf('PRUEBA') >= 0;
|
||||||
|
/* Documentos terminados (28-oct): lo que acepta /api/archivos además de los logs. */
|
||||||
|
const DOC_ACCEPT = '.pdf,.doc,.docx,.xls,.xlsx,.odt,.ods,.ppt,.pptx,application/pdf,image/*';
|
||||||
|
const esImagen = f => /^image\//i.test(f.type || '') || /\.(jpe?g|png|webp|hei[cf])$/i.test(f.name || '');
|
||||||
|
const esHeic = f => /hei[cf]/i.test(f.type || '') || /\.hei[cf]$/i.test(f.name || '');
|
||||||
|
const extDe = n => ((String(n || '').match(/\.([A-Za-z0-9]{1,5})$/) || [, 'archivo'])[1]).toUpperCase();
|
||||||
|
|
||||||
/* Reduce la foto antes de subirla: en el patio la señal es mala y una foto de 4 MB
|
/* Reduce la foto antes de subirla: en el patio la señal es mala y una foto de 4 MB
|
||||||
no sube. 1600 px de lado mayor + JPEG 0.8 ≈ 200-400 kB y se sigue leyendo una
|
no sube. 1600 px de lado mayor + JPEG 0.8 ≈ 200-400 kB y se sigue leyendo una
|
||||||
@ -357,19 +365,51 @@
|
|||||||
box.classList.add('foto');
|
box.classList.add('foto');
|
||||||
box.append($('span', { class: 'txt', html: (it.req ? '<b>Foto obligatoria</b> — ' : 'Foto — ') + it.txt }));
|
box.append($('span', { class: 'txt', html: (it.req ? '<b>Foto obligatoria</b> — ' : 'Foto — ') + it.txt }));
|
||||||
if (it.ayuda) box.append($('div', { class: 'nota-item', html: it.ayuda }));
|
if (it.ayuda) box.append($('div', { class: 'nota-item', html: it.ayuda }));
|
||||||
const inp = $('input', { type: 'file', accept: 'image/*', capture: 'environment', multiple: 'multiple', style: 'display:none' });
|
// 28-oct: «capture» obliga a la cámara en el teléfono; la galería va en su propio botón.
|
||||||
const btn = $('button', { type: 'button', class: 'btn-foto', text: 'Tomar o elegir foto' });
|
const camara = $('input', { type: 'file', accept: 'image/*', capture: 'environment', multiple: 'multiple', style: 'display:none' });
|
||||||
|
const galeria = $('input', { type: 'file', accept: 'image/*', multiple: 'multiple', style: 'display:none' });
|
||||||
|
const bCam = $('button', { type: 'button', class: 'btn-foto', text: 'Tomar foto' });
|
||||||
|
const bGal = $('button', { type: 'button', class: 'btn-foto', text: 'Elegir de la galería' });
|
||||||
|
const botones = $('div', { class: 'btns-foto' }); botones.append(bCam, bGal);
|
||||||
const tiras = $('div', { class: 'miniaturas', 'data-fotos': id });
|
const tiras = $('div', { class: 'miniaturas', 'data-fotos': id });
|
||||||
const marca = $('input', { type: 'hidden', 'data-id': id, 'data-lbl': 'Fotos: ' + String(it.txt || '').replace(/<[^>]+>/g, ''), value: '' });
|
const marca = $('input', { type: 'hidden', 'data-id': id, 'data-lbl': 'Fotos: ' + String(it.txt || '').replace(/<[^>]+>/g, ''), value: '' });
|
||||||
btn.addEventListener('click', () => inp.click());
|
const etq = it.etiqueta || String(it.txt || '').replace(/<[^>]+>/g, '').slice(0, 60);
|
||||||
inp.addEventListener('change', () => { subirFotos(inp.files, id, it.etiqueta || String(it.txt || '').replace(/<[^>]+>/g, '').slice(0, 60), tiras, marca); inp.value = ''; });
|
bCam.addEventListener('click', () => camara.click());
|
||||||
box.append(btn, inp, tiras, marca);
|
bGal.addEventListener('click', () => galeria.click());
|
||||||
|
[camara, galeria].forEach(inp => inp.addEventListener('change', () => { subirFotos(inp.files, id, etq, tiras, marca); inp.value = ''; }));
|
||||||
|
box.append(botones, camara, galeria, tiras, marca);
|
||||||
}
|
}
|
||||||
s.append(box);
|
s.append(box);
|
||||||
});
|
});
|
||||||
wrap.append(s);
|
wrap.append(s);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------- documentos (28-oct) ----------------
|
||||||
|
// El documento terminado que respalda el acta: hoja firmada, reporte en PDF, Excel, Word o la foto de
|
||||||
|
// una hoja de papel. Opcional. Las imágenes van como foto (/api/adjuntos); lo demás, a /api/archivos.
|
||||||
|
if (!cfg.sinDocumentos) {
|
||||||
|
const s = $('section', { class: 'paso' });
|
||||||
|
s.append($('h2', { text: 'Documentos' }));
|
||||||
|
s.append($('p', { class: 'sub', html: 'Opcional. Sube aquí el <b>documento terminado</b> que respalda esta acta: la hoja firmada por el cliente, un reporte en PDF, un Excel o Word, o la foto de una hoja de papel. Se guarda con la serie y se ve en el expediente.' }));
|
||||||
|
const box = $('div', { class: 'item foto' });
|
||||||
|
const id = '_documentos';
|
||||||
|
const inp = $('input', { type: 'file', accept: DOC_ACCEPT, multiple: 'multiple', style: 'display:none' });
|
||||||
|
const btn = $('button', { type: 'button', class: 'btn-foto', text: 'Subir documento' });
|
||||||
|
const tiras = $('div', { class: 'miniaturas', 'data-fotos': id });
|
||||||
|
const marca = $('input', { type: 'hidden', 'data-id': id, 'data-lbl': 'Documentos', value: '' });
|
||||||
|
btn.addEventListener('click', () => inp.click());
|
||||||
|
inp.addEventListener('change', () => {
|
||||||
|
const todos = Array.from(inp.files);
|
||||||
|
const imgs = todos.filter(esImagen), docs = todos.filter(f => !esImagen(f));
|
||||||
|
if (imgs.length) subirFotos(imgs, id, 'Documento', tiras, marca);
|
||||||
|
if (docs.length) subirArchivos(docs, id, 'Documento', tiras, marca);
|
||||||
|
inp.value = '';
|
||||||
|
});
|
||||||
|
box.append(btn, inp, tiras, marca);
|
||||||
|
s.append(box);
|
||||||
|
wrap.append(s);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- firma ----------------
|
// ---------------- firma ----------------
|
||||||
const firma = $('section', { class: 'paso firma' });
|
const firma = $('section', { class: 'paso firma' });
|
||||||
firma.append($('h2', { text: 'Cierre y firma' }));
|
firma.append($('h2', { text: 'Cierre y firma' }));
|
||||||
@ -407,6 +447,7 @@
|
|||||||
fields().forEach(el => el.disabled = !on);
|
fields().forEach(el => el.disabled = !on);
|
||||||
tecnico.disabled = !on; conforme().disabled = !on;
|
tecnico.disabled = !on; conforme().disabled = !on;
|
||||||
wrap.querySelectorAll('input[name=resultado]').forEach(el => el.disabled = !on);
|
wrap.querySelectorAll('input[name=resultado]').forEach(el => el.disabled = !on);
|
||||||
|
wrap.querySelectorAll('button.btn-foto').forEach(b => b.disabled = !on);
|
||||||
}
|
}
|
||||||
function pintarFotos() {
|
function pintarFotos() {
|
||||||
wrap.querySelectorAll('.miniaturas').forEach(t => t.innerHTML = '');
|
wrap.querySelectorAll('.miniaturas').forEach(t => t.innerHTML = '');
|
||||||
@ -426,7 +467,7 @@
|
|||||||
const t = wrap.querySelector(`.miniaturas[data-fotos="${a.campo}"]`);
|
const t = wrap.querySelector(`.miniaturas[data-fotos="${a.campo}"]`);
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
const li = $('div', { class: 'mini doc ok' });
|
const li = $('div', { class: 'mini doc ok' });
|
||||||
li.append($('span', { class: 'ico', text: 'Log' }),
|
li.append($('span', { class: 'ico', text: extDe(a.nombre) }),
|
||||||
$('span', { class: 'nom', text: String(a.nombre || '').slice(0, 28) }),
|
$('span', { class: 'nom', text: String(a.nombre || '').slice(0, 28) }),
|
||||||
$('span', { class: 'est', text: 'Guardado' }));
|
$('span', { class: 'est', text: 'Guardado' }));
|
||||||
t.append(li);
|
t.append(li);
|
||||||
@ -560,7 +601,7 @@
|
|||||||
if (!serieActual()) { fail('Primero elige la unidad: los archivos se guardan con su número de serie.'); return; }
|
if (!serieActual()) { fail('Primero elige la unidad: los archivos se guardan con su número de serie.'); return; }
|
||||||
for (const file of Array.from(lista).slice(0, 10)) {
|
for (const file of Array.from(lista).slice(0, 10)) {
|
||||||
const li = $('div', { class: 'mini doc subiendo' });
|
const li = $('div', { class: 'mini doc subiendo' });
|
||||||
li.append($('span', { class: 'ico', text: 'Log' }),
|
li.append($('span', { class: 'ico', text: extDe(file.name) }),
|
||||||
$('span', { class: 'nom', text: file.name.slice(0, 28) }));
|
$('span', { class: 'nom', text: file.name.slice(0, 28) }));
|
||||||
const est = $('span', { class: 'est', text: 'subiendo…' }); li.append(est); tiras.append(li);
|
const est = $('span', { class: 'est', text: 'subiendo…' }); li.append(est); tiras.append(li);
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
@ -592,8 +633,13 @@
|
|||||||
const li = $('div', { class: 'mini subiendo' });
|
const li = $('div', { class: 'mini subiendo' });
|
||||||
const img = $('img'); const est = $('span', { class: 'est', text: 'subiendo…' });
|
const img = $('img'); const est = $('span', { class: 'est', text: 'subiendo…' });
|
||||||
li.append(img, est); tiras.append(li);
|
li.append(img, est); tiras.append(li);
|
||||||
let blob = file;
|
let blob = file, convertida = false;
|
||||||
try { blob = await reducir(file); } catch (e) {}
|
try { blob = await reducir(file); convertida = blob !== file; } catch (e) {}
|
||||||
|
if (!convertida && esHeic(file)) {
|
||||||
|
li.className = 'mini err';
|
||||||
|
est.textContent = 'foto HEIC: este navegador no la convierte. Ábrela en Safari, o en el iPhone: Ajustes → Cámara → Formatos → Más compatible';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try { img.src = URL.createObjectURL(blob); } catch (e) {}
|
try { img.src = URL.createObjectURL(blob); } catch (e) {}
|
||||||
await enviarFoto(blob, campo, etiqueta, li, est, marca);
|
await enviarFoto(blob, campo, etiqueta, li, est, marca);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -99,7 +99,7 @@ function render(j){
|
|||||||
+(m.accion?'<br>Disposición: '+m.accion:'')}));
|
+(m.accion?'<br>Disposición: '+m.accion:'')}));
|
||||||
}
|
}
|
||||||
const kp=el('div',{class:'kpis'});
|
const kp=el('div',{class:'kpis'});
|
||||||
[[j.total,'Actas registradas'],[j.total_fotos,'Fotos'],[j.total_logs,'Archivos de log'],
|
[[j.total,'Actas registradas'],[j.total_fotos,'Fotos'],[j.total_logs,'Archivos y documentos'],
|
||||||
[j.relacionadas.length,'Actas que la mencionan']]
|
[j.relacionadas.length,'Actas que la mencionan']]
|
||||||
.forEach(([n,l])=>kp.append(el('div',{class:'kpi'},el('div',{class:'n',text:String(n)}),el('div',{class:'l',text:l}))));
|
.forEach(([n,l])=>kp.append(el('div',{class:'kpi'},el('div',{class:'n',text:String(n)}),el('div',{class:'l',text:l}))));
|
||||||
cont.append(kp);
|
cont.append(kp);
|
||||||
@ -146,9 +146,9 @@ function render(j){
|
|||||||
|
|
||||||
// ---- todos los archivos ----
|
// ---- todos los archivos ----
|
||||||
const f=el('section',{class:'bloque'});
|
const f=el('section',{class:'bloque'});
|
||||||
f.append(el('h2',{text:'Archivos de log'}),
|
f.append(el('h2',{text:'Archivos y documentos'}),
|
||||||
el('p',{class:'sub',text:'Todas las capturas subidas para esta serie, vengan del acta que vengan.'}));
|
el('p',{class:'sub',text:'Todas las capturas subidas para esta serie, vengan del acta que vengan.'}));
|
||||||
if(!j.logs.length) f.append(el('div',{class:'vacio',text:'Sin archivos de log.'}));
|
if(!j.logs.length) f.append(el('div',{class:'vacio',text:'Sin archivos ni documentos.'}));
|
||||||
else{
|
else{
|
||||||
const g=el('div',{class:'logs'});
|
const g=el('div',{class:'logs'});
|
||||||
j.logs.forEach(l=>g.append(el('a',{href:'/api/adjunto?ruta='+encodeURIComponent(l.ruta),target:'_blank',
|
j.logs.forEach(l=>g.append(el('a',{href:'/api/adjunto?ruta='+encodeURIComponent(l.ruta),target:'_blank',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user