morphos / frontend /src /lab-import.ts
Jose Salazar
Agrego boton para el syncing de dispositivos de laboratorio en la UI de desktop
1fec72e
Raw
History Blame Contribute Delete
11.1 kB
// Importación de resultados desde un analizador de laboratorio. El veterinario introduce
// (o escanea) el ID de muestra; se consulta el backend, que ya devuelve los analitos con las
// claves canónicas mapeadas, y se inyectan en el formulario reutilizando form-inject.ts.
//
// El puente local (bridge/) es quien habla con las máquinas; el navegador sólo consulta
// /api/lab/resultados del mismo origen con la cookie de sesión (sin CSRF en GET).
import {
aplicarValoresAFormulario,
aplicarPacienteAFormulario,
mostrarToast,
type ValoresInyectables,
type PacienteInyectable,
} from './form-inject.js';
import { manejadorAsync } from './async.js';
interface ValorAnalitoResp {
clave: string;
valor: number | string;
es_semicuantitativo?: boolean;
}
interface PacientePistasResp {
nombre_mascota?: string | null;
especie_texto?: string | null;
raza?: string | null;
sexo?: string | null;
edad_texto?: string | null;
}
interface ResultadoMapeadoResp {
muestra_id: string;
instrumento_id: string;
momento: string;
analitos: Record<string, ValorAnalitoResp>;
paciente?: PacientePistasResp | null;
no_mapeados: string[];
}
interface ResumenPendiente {
muestra_id: string;
instrumento_id: string;
momento: string;
analitos: number;
no_mapeados: number;
}
const INTERVALO_MS = 3000;
const MAX_INTENTOS = 30; // ~90 s de espera
const TEXTO_IMPORTAR = 'Importar del analizador';
const TEXTO_CANCELAR = 'Cancelar búsqueda';
// Normaliza las pistas de paciente (texto libre del equipo) al formato del formulario.
function pistasAPaciente(p?: PacientePistasResp | null): PacienteInyectable {
const out: PacienteInyectable = {};
if (!p) return out;
const esp = (p.especie_texto || '').toLowerCase();
if (/can|perro|dog/.test(esp)) out.especie = 'Canino';
else if (/fel|gat|cat/.test(esp)) out.especie = 'Felino';
if (p.raza) out.raza = p.raza;
const sx = (p.sexo || '').toLowerCase();
if (/macho|male/.test(sx)) out.sexo = 'Macho';
else if (/hembra|female/.test(sx)) out.sexo = 'Hembra';
const edad = (p.edad_texto || '').match(/(\d+(?:[.,]\d+)?)\s*(mes|month|a|y)/i);
if (edad) {
out.edad = parseFloat(edad[1].replace(',', '.'));
out.edadUnidad = /^m/i.test(edad[2]) ? 'meses' : 'anyos';
}
return out;
}
// Aplica un resultado ya mapeado al formulario y avisa (incluyendo códigos sin reconocer).
function aplicarResultado(data: ResultadoMapeadoResp, evaluar: () => void): void {
const resultados: ValoresInyectables = {};
for (const [clave, va] of Object.entries(data.analitos)) resultados[clave] = va.valor;
const n = aplicarValoresAFormulario(resultados, evaluar, { resaltar: true });
aplicarPacienteAFormulario(pistasAPaciente(data.paciente));
let extra = '';
if (data.no_mapeados.length) {
const muestra = data.no_mapeados.slice(0, 3).join(', ');
const mas = data.no_mapeados.length > 3 ? '…' : '';
extra = ` (${data.no_mapeados.length} sin reconocer: ${muestra}${mas})`;
}
mostrarToast(
n > 0
? `${n} valor${n !== 1 ? 'es' : ''} importado${n !== 1 ? 's' : ''} del analizador${extra}. Verifica antes de interpretar.`
: 'El resultado no contenía valores reconocibles.',
n === 0,
);
}
export function inicializarImportLab(evaluar: () => void): void {
const btn = document.getElementById('btn-importar-lab');
const input = document.getElementById('lab-muestra-id') as HTMLInputElement | null;
if (!btn || !input) return;
let buscando = false;
let cancelar = false;
const detener = (): void => {
cancelar = true;
buscando = false;
btn.textContent = TEXTO_IMPORTAR;
btn.classList.remove('buscando');
};
// Descarga un resultado concreto por ID y lo aplica. Devuelve el estado HTTP relevante.
const cargarYAplicar = async (muestra: string): Promise<'ok' | '404' | '401' | 'error'> => {
try {
const resp = await fetch(`/api/lab/resultados?muestra=${encodeURIComponent(muestra)}`, {
credentials: 'same-origin',
});
if (resp.status === 200) {
aplicarResultado((await resp.json()) as ResultadoMapeadoResp, evaluar);
return 'ok';
}
if (resp.status === 404) return '404';
if (resp.status === 401) return '401';
return 'error';
} catch {
return 'error';
}
};
const buscar = async (): Promise<void> => {
if (buscando) {
detener();
return;
}
const muestra = input.value.trim();
if (!muestra) {
mostrarToast('Introduce el ID de muestra.', true);
return;
}
buscando = true;
cancelar = false;
btn.textContent = TEXTO_CANCELAR;
btn.classList.add('buscando');
for (let intento = 0; intento < MAX_INTENTOS && !cancelar; intento++) {
const estado = await cargarYAplicar(muestra);
if (estado === 'ok') {
detener();
return;
}
if (estado === '401') {
detener();
mostrarToast('Inicia sesión para importar resultados.', true);
return;
}
if (estado === 'error') {
detener();
mostrarToast('Error al consultar el analizador.', true);
return;
}
// '404' → el resultado aún no ha llegado; espera y reintenta.
await new Promise((r) => setTimeout(r, INTERVALO_MS));
}
if (!cancelar) {
detener();
mostrarToast('No llegó ningún resultado para esa muestra (tiempo agotado).', true);
}
};
btn.addEventListener('click', manejadorAsync('Búsqueda de muestra', buscar));
// Un lector de código de barras teclea el ID y pulsa Enter: eso dispara la búsqueda.
input.addEventListener('keydown', manejadorAsync('Búsqueda de muestra', async (e: Event) => {
if ((e as KeyboardEvent).key === 'Enter') {
e.preventDefault();
await buscar();
}
}));
inicializarCola(cargarYAplicar, detener);
inicializarModalLab(detener);
}
// El botón del analizador en la cabecera (sólo escritorio, donde #panel-paciente está oculto).
// En vez de duplicar los controles —que duplicaría los IDs y, peor, el estado del sondeo, con
// dos búsquedas vivas a la vez— se MUEVE el nodo #lab-controles entre su sitio en el panel de
// paciente (móvil) y el modal (escritorio). Al mover un elemento se conservan sus listeners,
// así que el cableado de arriba sigue valiendo para los dos casos.
function inicializarModalLab(detenerBusqueda: () => void): void {
const btn = document.getElementById('btn-lab');
const modal = document.getElementById('modal-lab');
const overlay = document.getElementById('modal-lab-overlay');
const cuerpo = document.getElementById('modal-lab-cuerpo');
const controles = document.getElementById('lab-controles');
const anclaje = document.getElementById('lab-anclaje-mob');
if (!btn || !modal || !overlay || !cuerpo || !controles || !anclaje) return;
const esEscritorio = (): boolean => window.innerWidth > 1100;
// Idempotente: si ya está donde toca no toca el DOM (se llama en cada `resize`).
const ubicar = (): void => {
if (esEscritorio()) {
if (controles.parentElement !== cuerpo) cuerpo.appendChild(controles);
} else if (controles.previousElementSibling !== anclaje) {
anclaje.after(controles);
}
};
const cerrar = (): void => {
modal.classList.remove('visible');
overlay.classList.remove('activo');
btn.setAttribute('aria-expanded', 'false');
setTimeout(() => modal.setAttribute('hidden', ''), 250);
};
const abrir = (): void => {
ubicar();
modal.removeAttribute('hidden');
overlay.classList.add('activo');
btn.setAttribute('aria-expanded', 'true');
requestAnimationFrame(() => modal.classList.add('visible'));
(document.getElementById('lab-muestra-id') as HTMLInputElement | null)?.focus();
};
btn.addEventListener('click', () => {
if (modal.hasAttribute('hidden')) abrir();
else cerrar();
});
overlay.addEventListener('click', cerrar);
document.getElementById('modal-lab-cerrar')?.addEventListener('click', cerrar);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !modal.hasAttribute('hidden')) cerrar();
});
// Al cruzar el breakpoint hacia móvil los controles vuelven al panel de paciente. Si el modal
// estaba abierto se cierra, y con él cualquier búsqueda en curso: su botón dejaría de estar a
// la vista y el sondeo seguiría vivo en segundo plano sin forma de cancelarlo.
window.addEventListener('resize', () => {
if (!esEscritorio() && !modal.hasAttribute('hidden')) {
detenerBusqueda();
cerrar();
}
ubicar();
});
ubicar();
}
// Cola de resultados recibidos: lista para elegir sin teclear el ID de muestra.
function inicializarCola(
cargarYAplicar: (muestra: string) => Promise<'ok' | '404' | '401' | 'error'>,
detenerBusqueda: () => void,
): void {
const btn = document.getElementById('btn-pendientes-lab');
const lista = document.getElementById('lab-pendientes');
if (!btn || !lista) return;
const formatearHora = (iso: string): string => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString();
};
const render = (pendientes: ResumenPendiente[]): void => {
lista.innerHTML = '';
if (pendientes.length === 0) {
const vacio = document.createElement('li');
vacio.className = 'lab-pendientes-vacio';
vacio.textContent = 'No hay resultados recibidos.';
lista.appendChild(vacio);
lista.hidden = false;
return;
}
for (const p of pendientes) {
const li = document.createElement('li');
const item = document.createElement('button');
item.type = 'button';
item.className = 'lab-pendiente-item';
const noMap = p.no_mapeados ? ` · ${p.no_mapeados} sin reconocer` : '';
item.textContent = `${p.muestra_id} · ${p.instrumento_id} · ${formatearHora(p.momento)} · ${p.analitos} valores${noMap}`;
item.addEventListener('click', manejadorAsync('Importar resultado', async () => {
detenerBusqueda();
const estado = await cargarYAplicar(p.muestra_id);
if (estado === '401') mostrarToast('Inicia sesión para importar resultados.', true);
else if (estado !== 'ok') mostrarToast('No se pudo cargar ese resultado.', true);
else lista.hidden = true;
}));
li.appendChild(item);
lista.appendChild(li);
}
lista.hidden = false;
};
btn.addEventListener('click', manejadorAsync('Resultados pendientes', async () => {
if (!lista.hidden) {
lista.hidden = true;
return;
}
try {
const resp = await fetch('/api/lab/pendientes', { credentials: 'same-origin' });
if (resp.status === 401) {
mostrarToast('Inicia sesión para ver los resultados recibidos.', true);
return;
}
if (!resp.ok) {
mostrarToast('Error al consultar los resultados recibidos.', true);
return;
}
render((await resp.json()) as ResumenPendiente[]);
} catch {
mostrarToast('Sin conexión con el servidor.', true);
}
}));
}