Mejoras de diseño responsive en Sistema y Configuración, gestor de parser de laboratorio y tipos de cultivo

This commit is contained in:
2026-08-16 22:29:34 +00:00
parent 5b51e4e8eb
commit 770a4ea426
9 changed files with 2132 additions and 441 deletions
+47
View File
@@ -13,6 +13,7 @@ import { initDb,
getAllAcidosBase, createAcidoBase, updateAcidoBase, deleteAcidoBase, getAllAcidosBase, createAcidoBase, updateAcidoBase, deleteAcidoBase,
getAllCultivos, createCultivo, updateCultivo, deleteCultivo, getAllCultivos, createCultivo, updateCultivo, deleteCultivo,
getAllTiposCultivo, createTipoCultivo, updateTipoCultivo, deleteTipoCultivo, restablecerTiposCultivo, getAllTiposCultivo, createTipoCultivo, updateTipoCultivo, deleteTipoCultivo, restablecerTiposCultivo,
getAllGruposLaboratorio, createGrupoLaboratorio, updateGrupoLaboratorio, deleteGrupoLaboratorio, restablecerGruposLaboratorio,
getAllEstudiosComplementarios, createEstudioComplementario, updateEstudioComplementario, deleteEstudioComplementario, getAllEstudiosComplementarios, createEstudioComplementario, updateEstudioComplementario, deleteEstudioComplementario,
getAllInterconsultas, createInterconsulta, updateInterconsulta, deleteInterconsulta, getAllInterconsultas, createInterconsulta, updateInterconsulta, deleteInterconsulta,
getAllAtb, createAtb, updateAtb, deleteAtb, getAllAtb, createAtb, updateAtb, deleteAtb,
@@ -97,6 +98,7 @@ app.get('/api/state', async (req, res) => {
acidosBase: await getAllAcidosBase(), acidosBase: await getAllAcidosBase(),
cultivos: await getAllCultivos(), cultivos: await getAllCultivos(),
tiposCultivo: await getAllTiposCultivo(), tiposCultivo: await getAllTiposCultivo(),
gruposDeterminacionesLab: await getAllGruposLaboratorio(),
estudiosComplementarios: await getAllEstudiosComplementarios(), estudiosComplementarios: await getAllEstudiosComplementarios(),
interconsultas: (await getAllInterconsultas()).map(ic => ({ ...ic, realizada: !!ic.realizada })), interconsultas: (await getAllInterconsultas()).map(ic => ({ ...ic, realizada: !!ic.realizada })),
atb: await getAllAtb(), atb: await getAllAtb(),
@@ -627,6 +629,51 @@ app.post('/api/tipos-cultivo/reset', async (req, res) => {
} }
}); });
// ========== GRUPOS DE DETERMINACIONES DE LABORATORIO ==========
app.get('/api/grupos-laboratorio', async (req, res) => {
try {
res.json(await getAllGruposLaboratorio());
} catch (err) {
res.status(500).json({ error: 'Error al obtener grupos de determinaciones de laboratorio' });
}
});
app.post('/api/grupos-laboratorio', async (req, res) => {
try {
const nuevo = await createGrupoLaboratorio(req.body);
res.json(nuevo);
} catch (err) {
res.status(400).json({ error: err.message || 'Error al crear grupo de determinaciones' });
}
});
app.put('/api/grupos-laboratorio/:id', async (req, res) => {
try {
const updated = await updateGrupoLaboratorio(req.params.id, req.body);
res.json(updated);
} catch (err) {
res.status(400).json({ error: err.message || 'Error al actualizar grupo de determinaciones' });
}
});
app.delete('/api/grupos-laboratorio/:id', async (req, res) => {
try {
await deleteGrupoLaboratorio(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar grupo de determinaciones' });
}
});
app.post('/api/grupos-laboratorio/reset', async (req, res) => {
try {
const list = await restablecerGruposLaboratorio();
res.json(list);
} catch (err) {
res.status(500).json({ error: 'Error al restablecer grupos de determinaciones' });
}
});
// ========== ESTUDIOS COMPLEMENTARIOS ========== // ========== ESTUDIOS COMPLEMENTARIOS ==========
app.get('/api/estudios-complementarios', async (req, res) => { app.get('/api/estudios-complementarios', async (req, res) => {
try { try {
+257 -5
View File
@@ -67,7 +67,7 @@ const memStore = {
movimientos_indicaciones: [], movimientos_indicaciones: [],
pendientes: [], pendientes: [],
tipos_cultivo: [], tipos_cultivo: [],
otrosLaboratorios: [], grupos_laboratorio: [],
kv: {} kv: {}
}; };
@@ -859,6 +859,258 @@ export async function restablecerTiposCultivo() {
} }
} }
// ========== GRUPOS DE DETERMINACIONES DE LABORATORIO ==========
export const DEFAULT_GRUPOS_LABORATORIO = [
{
id: 'grp-lipidos',
nombreGrupo: 'Perfil Lipídico',
descripcion: 'Determinaciones del metabolismo lipídico y riesgo aterogénico',
activo: true,
orden: 1,
determinaciones: [
{ id: 'lip-1', nombre: 'Colesterol Total', claves: ['colesterol total', 'colesterol', 'colest. total', 'col. total', 'colest total'], unidad: 'mg/dL', rangoReferencia: '< 200 mg/dL', esAdicional: true },
{ id: 'lip-2', nombre: 'Colesterol LDL', claves: ['colesterol ldl', 'ldl-c', 'ldl', 'colest. ldl', 'colest ldl'], unidad: 'mg/dL', rangoReferencia: '< 100 mg/dL', esAdicional: true },
{ id: 'lip-3', nombre: 'Colesterol No HDL', claves: ['colesterol no-hdl', 'colesterol no hdl', 'no-hdl', 'no hdl', 'no_hdl'], unidad: 'mg/dL', rangoReferencia: '< 130 mg/dL', esAdicional: true },
{ id: 'lip-4', nombre: 'Colesterol HDL', claves: ['colesterol hdl', 'hdl-c', 'hdl', 'colest. hdl', 'colest hdl'], unidad: 'mg/dL', rangoReferencia: '> 40 mg/dL', esAdicional: true },
{ id: 'lip-5', nombre: 'Triglicéridos', claves: ['triglicéridos', 'trigliceridos', 'tg', 'triglicérido', 'triglicerido'], unidad: 'mg/dL', rangoReferencia: '< 150 mg/dL', esAdicional: true }
]
},
{
id: 'grp-ferrico',
nombreGrupo: 'Perfil Férrico',
descripcion: 'Metabolismo del hierro, transferrina, ferritina y vitaminas hematopoyéticas',
activo: true,
orden: 2,
determinaciones: [
{ id: 'fer-1', nombre: 'Hierro', claves: ['hierro', 'sideremia', 'fe'], unidad: 'µg/dL', rangoReferencia: '60 - 170 µg/dL', esAdicional: true },
{ id: 'fer-2', nombre: 'Transferrina', claves: ['transferrina', 'transferrin'], unidad: 'mg/dL', rangoReferencia: '200 - 360 mg/dL', esAdicional: true },
{ id: 'fer-3', nombre: 'Porcentaje de Saturación de Transferrina', claves: ['porcentaje de saturacion de transferrina', 'porcentaje de saturación de transferrina', 'saturacion de transferrina', 'saturación de transferrina', 'sat. transferrina', 'sat transferrina', '% sat', '% de sat', '% saturacion', '% saturación'], unidad: '%', rangoReferencia: '20 - 50 %', esAdicional: true },
{ id: 'fer-4', nombre: 'Ferritina', claves: ['ferritina', 'ferritin'], unidad: 'ng/mL', rangoReferencia: '30 - 400 ng/mL', esAdicional: true },
{ id: 'fer-5', nombre: 'Ácido Fólico', claves: ['acido folico', 'ácido fólico', 'folato', 'folatos', 'folico', 'fólico'], unidad: 'ng/mL', rangoReferencia: '3.0 - 17.0 ng/mL', esAdicional: true },
{ id: 'fer-6', nombre: 'Vitamina B12', claves: ['vitamina b12', 'b12', 'vit. b12'], unidad: 'pg/mL', rangoReferencia: '200 - 900 pg/mL', esAdicional: true }
]
},
{
id: 'grp-fosfocalcico',
nombreGrupo: 'Metabolismo Fosfocálcico y Medio Interno Extra',
descripcion: 'Calcio total, calcio iónico, fósforo y magnesio sérico',
activo: true,
orden: 3,
determinaciones: [
{ id: 'fcal-1', nombre: 'Calcio Total', claves: ['calcio total', 'calcio', 'ca total', 'ca+', 'ca++'], unidad: 'mg/dL', rangoReferencia: '8.5 - 10.5 mg/dL', esAdicional: true },
{ id: 'fcal-2', nombre: 'Calcio Iónico', claves: ['calcio ionico', 'calcio iónico', 'ca ionico', 'ca iónico', 'ca++ ionico', 'ca++ iónico', 'calcio_ionico'], unidad: 'mmol/L', rangoReferencia: '1.15 - 1.33 mmol/L', esAdicional: true },
{ id: 'fcal-3', nombre: 'Fósforo', claves: ['fósforo', 'fosforo', 'fosfemia'], unidad: 'mg/dL', rangoReferencia: '2.5 - 4.5 mg/dL', esAdicional: true },
{ id: 'fcal-4', nombre: 'Magnesio', claves: ['magnesio', 'magnesemia', 'mg++', 'mg+', 'mg2+', 'mg 2+', 'mg.', 'magnesio plasmatico', 'magnesio plasmático', 'magnesio serico', 'magnesio sérico', 'magnesio en sangre', 'mg serico', 'mg sérico', 'mg plasmatico', 'mg plasmático', 'mg'], unidad: 'mg/dL', rangoReferencia: '1.7 - 2.4 mg/dL', esAdicional: true }
]
},
{
id: 'grp-enzimas-inflamacion',
nombreGrupo: 'Enzimas, Proteínas e Inflamación',
descripcion: 'Albúmina, FAL, LDH, Procalcitonina, PCR, eritrosedimentación y enzimas',
activo: true,
orden: 4,
determinaciones: [
{ id: 'enz-1', nombre: 'Albúmina', claves: ['albúmina', 'albumina'], unidad: 'g/dL', rangoReferencia: '3.5 - 5.0 g/dL', esAdicional: true },
{ id: 'enz-2', nombre: 'Fosfatasa Alcalina', claves: ['fosfatasa alcalina', 'fal'], unidad: 'U/L', rangoReferencia: '40 - 130 U/L', esAdicional: true },
{ id: 'enz-3', nombre: 'LDH', claves: ['ldh', 'lactato deshidrogenasa', 'lactatodeshidrogenasa'], unidad: 'U/L', rangoReferencia: '135 - 225 U/L', esAdicional: true },
{ id: 'enz-4', nombre: 'Procalcitonina', claves: ['procalcitonina', 'pct'], unidad: 'ng/mL', rangoReferencia: '< 0.5 ng/mL', esAdicional: true },
{ id: 'enz-5', nombre: 'Proteína C Reactiva', claves: ['proteina c reactiva', 'proteína c reactiva', 'pcr cuantitativa', 'pcr ultrasensible', 'pcr'], unidad: 'mg/L', rangoReferencia: '< 5 mg/L', esAdicional: true },
{ id: 'enz-6', nombre: 'Eritrosedimentación', claves: ['eritrosedimentacion', 'eritrosedimentación', 'vsg', 'esr', 'eritro'], unidad: 'mm/h', rangoReferencia: '< 20 mm/h', esAdicional: true },
{ id: 'enz-7', nombre: 'CPK', claves: ['cpk', 'creatinfosfoquinasa', 'creatin fosfoquinasa', 'ck total', 'ck'], unidad: 'U/L', rangoReferencia: '20 - 200 U/L', esAdicional: true },
{ id: 'enz-8', nombre: 'Amilasa', claves: ['amilasa', 'amilasemia'], unidad: 'U/L', rangoReferencia: '28 - 100 U/L', esAdicional: true },
{ id: 'enz-9', nombre: 'Lipasa', claves: ['lipasa', 'lipasemia'], unidad: 'U/L', rangoReferencia: '13 - 60 U/L', esAdicional: true }
]
},
{
id: 'grp-tiroideo',
nombreGrupo: 'Perfil Tiroideo',
descripcion: 'Hormonas tiroideas e hipofisarias (TSH, T4L, T4, T3)',
activo: true,
orden: 5,
determinaciones: [
{ id: 'tir-1', nombre: 'TSH', claves: ['tsh', 'tirotrofina', 'tirotropina', 'tsh ultrasensible'], unidad: 'uUI/mL', rangoReferencia: '0.4 - 4.0 uUI/mL', esAdicional: true },
{ id: 'tir-2', nombre: 'T4 Libre', claves: ['t4 libre', 't4l', 't4-l', 'tiroxina libre'], unidad: 'ng/dL', rangoReferencia: '0.8 - 1.8 ng/dL', esAdicional: true },
{ id: 'tir-3', nombre: 'T4 Total', claves: ['t4 total', 't4', 'tiroxina'], unidad: 'µg/dL', rangoReferencia: '4.5 - 12.0 µg/dL', esAdicional: true },
{ id: 'tir-4', nombre: 'T3 Total', claves: ['t3 total', 't3', 'triyodotironina'], unidad: 'ng/dL', rangoReferencia: '80 - 200 ng/dL', esAdicional: true }
]
},
{
id: 'grp-cardiacos',
nombreGrupo: 'Biomarcadores Cardíacos',
descripcion: 'Péptidos natriuréticos, troponinas y marcadores de isquemia/falla',
activo: true,
orden: 6,
determinaciones: [
{ id: 'car-1', nombre: 'NT-proBNP', claves: ['nt-probnp', 'nt probnp', 'nt_probnp', 'probnp', 'pro-bnp', 'pro bnp'], unidad: 'pg/mL', rangoReferencia: '< 125 pg/mL', esAdicional: true },
{ id: 'car-2', nombre: 'Troponina T / I', claves: ['troponina t', 'troponina i', 'troponina ultrasensible', 'troponina', 'tn-t', 'tn-i', 'tnt', 'tni'], unidad: 'ng/mL', rangoReferencia: '< 0.014 ng/mL', esAdicional: true },
{ id: 'car-3', nombre: 'CK-MB', claves: ['ck-mb', 'ckmb', 'ck mb'], unidad: 'U/L', rangoReferencia: '< 25 U/L', esAdicional: true }
]
},
{
id: 'grp-hemograma-indices',
nombreGrupo: 'Hemograma - Índices y Fórmula',
descripcion: 'Constantes corpusculares e índices hematimétricos adicionales',
activo: true,
orden: 7,
determinaciones: [
{ id: 'hem-1', nombre: 'VCM', claves: ['volumen corpuscular medio', 'vcm'], unidad: 'fL', rangoReferencia: '80 - 100 fL', esAdicional: true },
{ id: 'hem-2', nombre: 'HCM', claves: ['hemoglobina corpuscular media', 'hcm'], unidad: 'pg', rangoReferencia: '27 - 33 pg', esAdicional: true },
{ id: 'hem-3', nombre: 'CHCM', claves: ['concentracion de hemoglobina corpuscular media', 'chcm'], unidad: 'g/dL', rangoReferencia: '32 - 36 g/dL', esAdicional: true },
{ id: 'hem-4', nombre: 'RDW', claves: ['rdw', 'ide', 'ancho de distribucion eritrocitaria'], unidad: '%', rangoReferencia: '11.5 - 14.5 %', esAdicional: true },
{ id: 'hem-5', nombre: 'Neutrófilos', claves: ['neutrófilos', 'neutrofilos', 'neutrofilos segmentados', 'segmentados'], unidad: '%', rangoReferencia: '45 - 70 %', esAdicional: true },
{ id: 'hem-6', nombre: 'Linfocitos', claves: ['linfocitos', 'linfo'], unidad: '%', rangoReferencia: '20 - 45 %', esAdicional: true },
{ id: 'hem-7', nombre: 'Monocitos', claves: ['monocitos', 'mono'], unidad: '%', rangoReferencia: '2 - 10 %', esAdicional: true },
{ id: 'hem-8', nombre: 'Eosinófilos', claves: ['eosinófilos', 'eosinofilos', 'eosino'], unidad: '%', rangoReferencia: '1 - 4 %', esAdicional: true },
{ id: 'hem-9', nombre: 'Basófilos', claves: ['basófilos', 'basofilos'], unidad: '%', rangoReferencia: '0 - 1 %', esAdicional: true },
{ id: 'hem-10', nombre: 'Eritroblastos', claves: ['eritroblastos'], unidad: '%', rangoReferencia: '0 %', esAdicional: true },
{ id: 'hem-11', nombre: 'VPM', claves: ['volumen plaquetario medio', 'vpm'], unidad: 'fL', rangoReferencia: '7.5 - 11.5 fL', esAdicional: true }
]
}
];
export async function getAllGruposLaboratorio() {
let list = [];
if (db) {
const raw = await db.collection('grupos_laboratorio').find().sort({ orden: 1, nombreGrupo: 1 }).toArray();
list = cleanDocs(raw);
if (list.length === 0) {
for (const item of DEFAULT_GRUPOS_LABORATORIO) {
await db.collection('grupos_laboratorio').insertOne({ ...item });
}
list = [...DEFAULT_GRUPOS_LABORATORIO];
}
return list;
} else {
if (!memStore.grupos_laboratorio || memStore.grupos_laboratorio.length === 0) {
memStore.grupos_laboratorio = DEFAULT_GRUPOS_LABORATORIO.map(item => JSON.parse(JSON.stringify(item)));
}
return [...memStore.grupos_laboratorio];
}
}
export async function createGrupoLaboratorio(grupo) {
const doc = {
id: grupo.id || generateUUID(),
nombreGrupo: (grupo.nombreGrupo || '').trim(),
descripcion: (grupo.descripcion || '').trim(),
activo: grupo.activo !== false,
orden: typeof grupo.orden === 'number' ? grupo.orden : 99,
determinaciones: Array.isArray(grupo.determinaciones) ? grupo.determinaciones.map(d => ({
id: d.id || generateUUID(),
nombre: (d.nombre || '').trim(),
claves: Array.isArray(d.claves) ? d.claves.map(c => (c || '').trim().toLowerCase()).filter(Boolean) : [],
unidad: (d.unidad || '').trim(),
esPrincipal: Boolean(d.esPrincipal),
esAdicional: d.esAdicional !== false,
rangoReferencia: (d.rangoReferencia || '').trim(),
descripcion: (d.descripcion || '').trim()
})) : [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
if (!doc.nombreGrupo) {
throw new Error('El nombre del grupo de laboratorio es requerido');
}
if (db) {
const existing = await db.collection('grupos_laboratorio').findOne({ nombreGrupo: doc.nombreGrupo });
if (existing) {
throw new Error(`Ya existe un grupo de determinaciones con el nombre "${doc.nombreGrupo}"`);
}
await db.collection('grupos_laboratorio').insertOne(doc);
} else {
if (!memStore.grupos_laboratorio) memStore.grupos_laboratorio = [];
const exists = memStore.grupos_laboratorio.some(g => g.nombreGrupo.toLowerCase() === doc.nombreGrupo.toLowerCase());
if (exists) {
throw new Error(`Ya existe un grupo de determinaciones con el nombre "${doc.nombreGrupo}"`);
}
memStore.grupos_laboratorio.push(doc);
}
return doc;
}
export async function updateGrupoLaboratorio(id, datos) {
const updateDoc = { ...datos, updatedAt: new Date().toISOString() };
delete updateDoc._id;
delete updateDoc.id;
if (datos.nombreGrupo) {
updateDoc.nombreGrupo = datos.nombreGrupo.trim();
}
if (datos.descripcion !== undefined) {
updateDoc.descripcion = datos.descripcion.trim();
}
if (datos.activo !== undefined) {
updateDoc.activo = Boolean(datos.activo);
}
if (datos.orden !== undefined) {
updateDoc.orden = Number(datos.orden);
}
if (Array.isArray(datos.determinaciones)) {
updateDoc.determinaciones = datos.determinaciones.map(d => ({
id: d.id || generateUUID(),
nombre: (d.nombre || '').trim(),
claves: Array.isArray(d.claves) ? d.claves.map(c => (c || '').trim().toLowerCase()).filter(Boolean) : [],
unidad: (d.unidad || '').trim(),
esPrincipal: Boolean(d.esPrincipal),
esAdicional: d.esAdicional !== false,
rangoReferencia: (d.rangoReferencia || '').trim(),
descripcion: (d.descripcion || '').trim()
}));
}
if (db) {
if (updateDoc.nombreGrupo) {
const existing = await db.collection('grupos_laboratorio').findOne({
id: { $ne: id },
nombreGrupo: updateDoc.nombreGrupo
});
if (existing) {
throw new Error(`Ya existe otro grupo de determinaciones con el nombre "${updateDoc.nombreGrupo}"`);
}
}
await db.collection('grupos_laboratorio').updateOne({ id }, { $set: updateDoc });
} else {
if (!memStore.grupos_laboratorio) memStore.grupos_laboratorio = [];
if (updateDoc.nombreGrupo) {
const exists = memStore.grupos_laboratorio.some(g => g.id !== id && g.nombreGrupo.toLowerCase() === updateDoc.nombreGrupo.toLowerCase());
if (exists) {
throw new Error(`Ya existe otro grupo de determinaciones con el nombre "${updateDoc.nombreGrupo}"`);
}
}
const idx = memStore.grupos_laboratorio.findIndex(g => g.id === id);
if (idx !== -1) {
memStore.grupos_laboratorio[idx] = { ...memStore.grupos_laboratorio[idx], ...updateDoc };
}
}
return { id, ...updateDoc };
}
export async function deleteGrupoLaboratorio(id) {
if (db) {
await db.collection('grupos_laboratorio').deleteOne({ id });
} else {
if (memStore.grupos_laboratorio) {
memStore.grupos_laboratorio = memStore.grupos_laboratorio.filter(g => g.id !== id);
}
}
return { success: true };
}
export async function restablecerGruposLaboratorio() {
if (db) {
await db.collection('grupos_laboratorio').deleteMany({});
for (const item of DEFAULT_GRUPOS_LABORATORIO) {
await db.collection('grupos_laboratorio').insertOne({ ...item });
}
return await getAllGruposLaboratorio();
} else {
memStore.grupos_laboratorio = DEFAULT_GRUPOS_LABORATORIO.map(item => JSON.parse(JSON.stringify(item)));
return [...memStore.grupos_laboratorio];
}
}
// ========== ESTUDIOS COMPLEMENTARIOS ========== // ========== ESTUDIOS COMPLEMENTARIOS ==========
export async function getAllEstudiosComplementarios() { export async function getAllEstudiosComplementarios() {
if (db) { if (db) {
@@ -1158,8 +1410,8 @@ export function getDb() {
export async function exportAllData() { export async function exportAllData() {
const collections = [ const collections = [
'usuarios', 'pacientes', 'areas', 'camas', 'internaciones', 'usuarios', 'pacientes', 'areas', 'camas', 'internaciones',
'evoluciones', 'laboratorios', 'glucemias', 'acidosbase', 'evoluciones', 'laboratorios', 'otrosLaboratorios', 'glucemias', 'acidosbase',
'cultivos', 'tipos_cultivo', 'estudiosComplementarios', 'interconsultas', 'atb', 'cultivos', 'tipos_cultivo', 'grupos_laboratorio', 'estudiosComplementarios', 'interconsultas', 'atb',
'indicaciones', 'movimientos_indicaciones', 'pendientes', 'kv' 'indicaciones', 'movimientos_indicaciones', 'pendientes', 'kv'
]; ];
const dump = {}; const dump = {};
@@ -1179,8 +1431,8 @@ export async function exportAllData() {
export async function importAllData(dump) { export async function importAllData(dump) {
const collections = [ const collections = [
'usuarios', 'pacientes', 'areas', 'camas', 'internaciones', 'usuarios', 'pacientes', 'areas', 'camas', 'internaciones',
'evoluciones', 'laboratorios', 'glucemias', 'acidosbase', 'evoluciones', 'laboratorios', 'otrosLaboratorios', 'glucemias', 'acidosbase',
'cultivos', 'tipos_cultivo', 'estudiosComplementarios', 'interconsultas', 'atb', 'cultivos', 'tipos_cultivo', 'grupos_laboratorio', 'estudiosComplementarios', 'interconsultas', 'atb',
'indicaciones', 'movimientos_indicaciones', 'pendientes', 'kv' 'indicaciones', 'movimientos_indicaciones', 'pendientes', 'kv'
]; ];
if (db) { if (db) {
File diff suppressed because it is too large Load Diff
+78
View File
@@ -12,6 +12,7 @@ import type {
AcidoBase, AcidoBase,
Cultivo, Cultivo,
TipoCultivo, TipoCultivo,
GrupoDeterminacionLaboratorio,
EstudioComplementario, EstudioComplementario,
Interconsulta, Interconsulta,
ATB, ATB,
@@ -47,6 +48,7 @@ interface HospitalState {
acidosBase: AcidoBase[]; acidosBase: AcidoBase[];
cultivos: Cultivo[]; cultivos: Cultivo[];
tiposCultivo: TipoCultivo[]; tiposCultivo: TipoCultivo[];
gruposDeterminacionesLab: GrupoDeterminacionLaboratorio[];
estudiosComplementarios: EstudioComplementario[]; estudiosComplementarios: EstudioComplementario[];
interconsultas: Interconsulta[]; interconsultas: Interconsulta[];
atb: ATB[]; atb: ATB[];
@@ -74,6 +76,7 @@ const defaultState = (): HospitalState => ({
acidosBase: [], acidosBase: [],
cultivos: [], cultivos: [],
tiposCultivo: [], tiposCultivo: [],
gruposDeterminacionesLab: [],
estudiosComplementarios: [], estudiosComplementarios: [],
interconsultas: [], interconsultas: [],
atb: [], atb: [],
@@ -108,6 +111,7 @@ export function useHospitalStore() {
...defaults, ...defaults,
...body, ...body,
tiposCultivo: body.tiposCultivo || [], tiposCultivo: body.tiposCultivo || [],
gruposDeterminacionesLab: body.gruposDeterminacionesLab || [],
estudiosComplementarios: body.estudiosComplementarios || [], estudiosComplementarios: body.estudiosComplementarios || [],
interconsultas: body.interconsultas || [], interconsultas: body.interconsultas || [],
atb: body.atb || [], atb: body.atb || [],
@@ -1065,6 +1069,75 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
} }
}, [apiCall]); }, [apiCall]);
// Acciones de grupos de determinaciones de laboratorio (Entrenamiento del Parser)
const agregarGrupoDeterminacionLab = useCallback(async (grupo: Omit<GrupoDeterminacionLaboratorio, 'id'>) => {
const nuevoGrupo: GrupoDeterminacionLaboratorio = {
...grupo,
id: generateUUID(),
};
try {
const res = await apiCall('POST', '/grupos-laboratorio', nuevoGrupo);
const saved = res || nuevoGrupo;
setState(prev => ({
...prev,
gruposDeterminacionesLab: [...(prev.gruposDeterminacionesLab || []).filter(g => g.id !== saved.id), saved],
}));
toast.success(`Grupo de laboratorio "${saved.nombreGrupo}" guardado exitosamente`);
return saved;
} catch (err) {
console.error('Error al agregar grupo de determinaciones:', err);
toast.error(err instanceof Error ? err.message : 'Error al agregar grupo de determinaciones');
throw err;
}
}, [apiCall]);
const actualizarGrupoDeterminacionLab = useCallback(async (id: string, datos: Partial<GrupoDeterminacionLaboratorio>) => {
try {
const res = await apiCall('PUT', `/grupos-laboratorio/${id}`, datos);
setState(prev => ({
...prev,
gruposDeterminacionesLab: (prev.gruposDeterminacionesLab || []).map(g => g.id === id ? { ...g, ...datos, ...(res || {}) } : g),
}));
toast.success('Grupo de determinaciones actualizado exitosamente');
} catch (err) {
console.error('Error al actualizar grupo de determinaciones:', err);
toast.error(err instanceof Error ? err.message : 'Error al actualizar grupo de determinaciones');
throw err;
}
}, [apiCall]);
const eliminarGrupoDeterminacionLab = useCallback(async (id: string) => {
try {
await apiCall('DELETE', `/grupos-laboratorio/${id}`);
setState(prev => ({
...prev,
gruposDeterminacionesLab: (prev.gruposDeterminacionesLab || []).filter(g => g.id !== id),
}));
toast.success('Grupo de determinaciones eliminado');
} catch (err) {
console.error('Error al eliminar grupo de determinaciones:', err);
toast.error(err instanceof Error ? err.message : 'Error al eliminar grupo de determinaciones');
throw err;
}
}, [apiCall]);
const restablecerGruposDeterminacionesLab = useCallback(async () => {
try {
const res = await apiCall('POST', '/grupos-laboratorio/reset');
if (Array.isArray(res)) {
setState(prev => ({
...prev,
gruposDeterminacionesLab: res,
}));
}
toast.success('Grupos de determinaciones de laboratorio restablecidos a valores estándar');
} catch (err) {
console.error('Error al restablecer grupos de determinaciones:', err);
toast.error('Error al restablecer grupos de determinaciones');
throw err;
}
}, [apiCall]);
// Acciones de estudios complementarios // Acciones de estudios complementarios
const agregarEstudioComplementario = useCallback(async (estudio: Omit<EstudioComplementario, 'id'>) => { const agregarEstudioComplementario = useCallback(async (estudio: Omit<EstudioComplementario, 'id'>) => {
const internacion = state.internaciones.find(i => i.id === estudio.internacionId); const internacion = state.internaciones.find(i => i.id === estudio.internacionId);
@@ -1658,6 +1731,11 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
actualizarTipoCultivo, actualizarTipoCultivo,
eliminarTipoCultivo, eliminarTipoCultivo,
restablecerTiposCultivo, restablecerTiposCultivo,
gruposDeterminacionesLab: state.gruposDeterminacionesLab,
agregarGrupoDeterminacionLab,
actualizarGrupoDeterminacionLab,
eliminarGrupoDeterminacionLab,
restablecerGruposDeterminacionesLab,
agregarEstudioComplementario, agregarEstudioComplementario,
actualizarEstudioComplementario, actualizarEstudioComplementario,
eliminarEstudioComplementario, eliminarEstudioComplementario,
+522
View File
@@ -0,0 +1,522 @@
import type { ResultadoLaboratorio, AcidoBase, GrupoDeterminacionLaboratorio } from '@/types';
export const RANGOS_LABORATORIO_DEFAULT: Record<string, { min: number; max: number }> = {
'Hematocrito': { min: 36, max: 50 },
'Hemoglobina': { min: 12, max: 16.5 },
'Leucocitos': { min: 4000, max: 10000 },
'Plaquetas': { min: 150000, max: 400000 },
'Glucemia': { min: 70, max: 110 },
'Urea': { min: 15, max: 45 },
'Creatinina': { min: 0.6, max: 1.2 },
'Sodio': { min: 135, max: 145 },
'Potasio': { min: 3.5, max: 5.0 },
'Cloro': { min: 96, max: 106 },
'Bilirrubina Total': { min: 0.2, max: 1.2 },
'Bilirrubina Directa': { min: 0.0, max: 0.3 },
'GOT': { min: 0, max: 40 },
'GPT': { min: 0, max: 40 },
'Tiempo de Protrombina': { min: 70, max: 100 },
'KPTT': { min: 25, max: 38 },
'INR': { min: 0.8, max: 1.2 },
'Colesterol Total': { min: 0, max: 200 },
'Colesterol LDL': { min: 0, max: 100 },
'Colesterol No HDL': { min: 0, max: 130 },
'Colesterol HDL': { min: 40, max: 100 },
'Triglicéridos': { min: 0, max: 150 },
'Hierro': { min: 60, max: 170 },
'Transferrina': { min: 200, max: 360 },
'Porcentaje de Saturación de Transferrina': { min: 20, max: 50 },
'Ferritina': { min: 30, max: 400 },
'Ácido Fólico': { min: 3.0, max: 17.0 },
'Vitamina B12': { min: 200, max: 900 },
'Albúmina': { min: 3.5, max: 5.0 },
'Calcio Total': { min: 8.5, max: 10.5 },
'Fosfatasa Alcalina': { min: 40, max: 130 },
'LDH': { min: 135, max: 225 },
'NT-proBNP': { min: 0, max: 125 },
'Procalcitonina': { min: 0, max: 0.5 },
'Proteína C Reactiva': { min: 0, max: 5 },
'Eritrosedimentación': { min: 0, max: 20 },
'Fósforo': { min: 2.5, max: 4.5 },
'Magnesio': { min: 1.6, max: 2.6 },
'Calcio Iónico': { min: 1.12, max: 1.32 },
'TSH': { min: 0.4, max: 4.0 },
'T4 Libre': { min: 0.8, max: 1.8 },
'T4 Total': { min: 4.5, max: 12.0 },
'T3 Total': { min: 80, max: 200 },
'Troponina T / I': { min: 0, max: 0.014 },
'CK-MB': { min: 0, max: 25 },
'CPK': { min: 20, max: 200 },
'Amilasa': { min: 28, max: 100 },
'Lipasa': { min: 13, max: 60 },
'VCM': { min: 80, max: 100 },
'HCM': { min: 27, max: 33 },
'CHCM': { min: 32, max: 36 },
'RDW': { min: 11.5, max: 14.5 },
};
export function calcularEstadoLaboratorioExtendido(parametro: string, valor: string | number, rangoCustom?: string): 'Normal' | 'Alto' | 'Bajo' | 'Crítico' {
const num = typeof valor === 'number' ? valor : parseFloat(valor);
if (isNaN(num)) return 'Normal';
// Check custom range format like "0.4 - 4.0" or "< 200" or "> 40"
if (rangoCustom) {
const rangeMatch = rangoCustom.match(/(\d+(?:\.\d+)?)\s*[-–—]\s*(\d+(?:\.\d+)?)/);
if (rangeMatch) {
const min = parseFloat(rangeMatch[1]);
const max = parseFloat(rangeMatch[2]);
if (!isNaN(min) && !isNaN(max)) {
if (num < min) return 'Bajo';
if (num > max) return 'Alto';
return 'Normal';
}
}
const lessMatch = rangoCustom.match(/<\s*(\d+(?:\.\d+)?)/);
if (lessMatch) {
const max = parseFloat(lessMatch[1]);
if (!isNaN(max) && num > max) return 'Alto';
return 'Normal';
}
const greaterMatch = rangoCustom.match(/>\s*(\d+(?:\.\d+)?)/);
if (greaterMatch) {
const min = parseFloat(greaterMatch[1]);
if (!isNaN(min) && num < min) return 'Bajo';
return 'Normal';
}
}
const rango = RANGOS_LABORATORIO_DEFAULT[parametro];
if (!rango) return 'Normal';
if (parametro === 'Tiempo de Protrombina' && num > 50) {
if (num < 70) return 'Bajo';
if (num > 100) return 'Alto';
return 'Normal';
}
if (parametro === 'Calcio Iónico') {
if (num < 2) {
if (num < 1.12) return 'Bajo';
if (num > 1.32) return 'Alto';
return 'Normal';
} else {
if (num < 4.5) return 'Bajo';
if (num > 5.6) return 'Alto';
return 'Normal';
}
}
if (num < rango.min) return 'Bajo';
if (num > rango.max) return 'Alto';
return 'Normal';
}
export interface RecognizedDetermination {
grupoId: string;
grupoNombre: string;
parametro: string;
valor: number;
unidad: string;
estado: 'Normal' | 'Alto' | 'Bajo' | 'Crítico';
claveCoincidente: string;
rangoReferencia?: string;
lineaOriginal: string;
}
export interface PotentialUnknownDetermination {
linea: string;
posibleNombre: string;
posibleValor: number;
posibleUnidad: string;
}
export interface ParseResult {
resultados: ResultadoLaboratorio[];
observaciones: string;
observacionesPorGrupo: { grupo: string; lineas: string[] }[];
reconocidos: RecognizedDetermination[];
desconocidosPotenciales: PotentialUnknownDetermination[];
acidoBase: Omit<AcidoBase, 'id'> | null;
}
export const CORE_PARAMETERS_MAPPING = [
{ claves: ['hematocrito', 'hto'], nombre: 'Hematocrito', unidad: '%', esPrincipal: true },
{ claves: ['hemoglobina', 'hb'], nombre: 'Hemoglobina', unidad: 'g/dL', esPrincipal: true },
{ claves: ['recuento de leucocitos', 'glóbulos blancos', 'globulos blancos', 'leucocitos', 'gb'], nombre: 'Leucocitos', unidad: '10³/µl', esPrincipal: true },
{ claves: ['recuento de plaquetas', 'plaquetas', 'plaq'], nombre: 'Plaquetas', unidad: '10³/µl', esPrincipal: true },
{ claves: ['glucosa', 'glucemia'], nombre: 'Glucemia', unidad: 'mg/dL', esPrincipal: true },
{ claves: ['urea'], nombre: 'Urea', unidad: 'mg/dL', esPrincipal: true },
{ claves: ['creatinina', 'creat'], nombre: 'Creatinina', unidad: 'mg/dL', esPrincipal: true },
{ claves: ['sodio', 'na'], nombre: 'Sodio', unidad: 'mEq/L', esPrincipal: true },
{ claves: ['potasio', 'k+', 'k'], nombre: 'Potasio', unidad: 'mEq/L', esPrincipal: true },
{ claves: ['cloro', 'cl-', 'cl'], nombre: 'Cloro', unidad: 'mEq/L', esPrincipal: true },
{ claves: ['bilirrubina total', 'bt'], nombre: 'Bilirrubina Total', unidad: 'mg/dL', esPrincipal: true },
{ claves: ['bilirrubina directa', 'bd'], nombre: 'Bilirrubina Directa', unidad: 'mg/dL', esPrincipal: true },
{ claves: ['got', 'ast'], nombre: 'GOT', unidad: 'U/L', esPrincipal: true },
{ claves: ['gpt', 'alt'], nombre: 'GPT', unidad: 'U/L', esPrincipal: true },
{ claves: ['proteínas totales', 'proteinas totales'], nombre: 'Proteínas Totales', unidad: 'g/dL', esPrincipal: false },
{ claves: ['tiempo de protrombina', 't.p.', 't.p', 'tp', 'protrombina'], nombre: 'Tiempo de Protrombina', unidad: '%', esPrincipal: true },
{ claves: ['rin', 'inr'], nombre: 'INR', unidad: '', esPrincipal: true },
{ claves: ['aptt', 'kptt'], nombre: 'KPTT', unidad: 'seg', esPrincipal: true },
];
export function esParametroCore(param: string): boolean {
const coreList = [
'hematocrito', 'hto', 'hemoglobina', 'hb', 'leucocitos', 'gb', 'plaquetas', 'plaq',
'glucemia', 'glucosa', 'urea', 'creatinina', 'creat', 'sodio', 'na', 'potasio', 'k', 'k+',
'cloro', 'cl', 'cl-', 'bilirrubina total', 'bt', 'bilirrubina directa', 'bd', 'got', 'ast', 'gpt', 'alt',
'tiempo de protrombina', 'tp', 't.p.', 't.p', 'rin', 'inr', 'aptt', 'kptt', 'proteínas totales', 'proteinas totales'
];
return coreList.includes(param.toLowerCase().trim());
}
export function matchClaveTexto(lineaLower: string, clave: string): boolean {
const c = clave.toLowerCase().trim();
if (!c) return false;
if (c === 'mg') {
const execMatch = /(?:^|[^a-z0-9_])mg(?=[:\s=+\d]|$)(?!\/(?:dl|l|24h|ml)|%)/i.exec(lineaLower);
if (!execMatch) return false;
const prefix = lineaLower.substring(0, execMatch.index);
return !/\d+\s*$/.test(prefix);
}
if (c.length > 4) {
return lineaLower.includes(c);
}
const escaped = c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(?:^|[^a-z0-9_])${escaped}(?:$|[^a-z0-9_])`, 'i');
return regex.test(lineaLower);
}
export function parseAcidoBaseTexto(texto: string, fecha?: string, pacienteId?: string): Omit<AcidoBase, 'id'> | null {
const lineas = texto.split('\n');
let ph: number | undefined;
let pco2: number | undefined;
let po2: number | undefined;
let hco3: number | undefined;
let be: number | undefined;
let sato2: number | undefined;
let lactato: number | undefined;
let fio2: number | undefined;
for (const linea of lineas) {
const cleanLinea = linea.replace(/show_chart|list_alt|\t+/g, ' ').replace(/,/g, '.').replace(/\s+/g, ' ').trim().toLowerCase();
const getValue = (param: string): number | undefined => {
const idx = cleanLinea.indexOf(param);
if (idx === -1) return undefined;
const after = cleanLinea.slice(idx + param.length).trim();
const parts = after.split(' ');
for (const p of parts) {
const v = parseFloat(p);
if (!isNaN(v) && v > 0 && v < 1000) return v;
}
return undefined;
};
if ((cleanLinea.includes('estado') && cleanLinea.includes('ácido')) || cleanLinea.includes('base') || cleanLinea.includes('gases en sangre')) {
ph = getValue('ph') || (cleanLinea.match(/ph\s+(\d+\.?\d*)/)?.[1] ? parseFloat(cleanLinea.match(/ph\s+(\d+\.?\d*)/)![1]) : undefined);
pco2 = getValue('pco2') || getValue('pco₂');
po2 = getValue('po2') || getValue('po₂');
hco3 = getValue('hco3');
be = getValue('exceso de base') || getValue('base excess') || getValue('exceso');
sato2 = getValue('saturación') || getValue('sato2') || getValue('sat');
lactato = getValue('lactato');
fio2 = getValue('fio2');
break;
}
}
if (!ph) {
for (const linea of lineas) {
const cleanLinea = linea.replace(/show_chart|list_alt|\t+/g, ' ').replace(/,/g, '.').replace(/\s+/g, ' ').trim().toLowerCase();
const getValue = (param: string): number | undefined => {
const idx = cleanLinea.indexOf(param);
if (idx === -1) return undefined;
const after = cleanLinea.slice(idx + param.length).trim();
const parts = after.split(' ');
for (const p of parts) {
const v = parseFloat(p);
if (!isNaN(v) && v > 0 && v < 1000) return v;
}
return undefined;
};
if (cleanLinea.includes('ph') && !cleanLinea.includes('pco2') && !cleanLinea.includes('pco')) { ph = getValue('ph'); }
else if (cleanLinea.includes('pco2') || cleanLinea.includes('pco₂')) { pco2 = getValue('pco2'); }
else if (cleanLinea.includes('po2') || cleanLinea.includes('po₂')) { po2 = getValue('po2'); }
else if (cleanLinea.includes('hco3')) { hco3 = getValue('hco3'); }
else if (cleanLinea.includes('exceso') || cleanLinea.includes('base excess')) { be = getValue('exceso') || getValue('base'); }
else if (cleanLinea.includes('saturación') || cleanLinea.includes('sato2')) { sato2 = getValue('saturación') || getValue('sat'); }
else if (cleanLinea.includes('lactato')) { lactato = getValue('lactato'); }
else if (cleanLinea.includes('fio2')) { fio2 = getValue('fio2'); }
}
}
if (ph) {
return {
pacienteId: pacienteId || '',
fecha: fecha || new Date().toISOString().split('T')[0],
hora: '',
ph,
pco2: pco2 || 40,
po2: po2 || 85,
hco3: hco3 || 24,
be: be || 0,
sato2: sato2 || 97,
lactato,
fio2,
interpretacion: ''
};
}
return null;
}
export function parseLaboratorioTextoCompleto(
texto: string,
gruposConfigurados: GrupoDeterminacionLaboratorio[] = [],
fecha?: string,
pacienteId?: string
): ParseResult {
const resultados: ResultadoLaboratorio[] = [];
const reconocidos: RecognizedDetermination[] = [];
const processedLines = new Set<number>();
const lineas = texto.split('\n');
// 1. Process Core Parameters
for (let lIdx = 0; lIdx < lineas.length; lIdx++) {
const linea = lineas[lIdx];
const lineaLower = linea.toLowerCase().trim();
if (!lineaLower) continue;
for (const core of CORE_PARAMETERS_MAPPING) {
if (resultados.some(r => r.parametro === core.nombre)) continue;
let matchedClave = false;
let matchedClaveString = '';
for (const clave of core.claves) {
if (matchClaveTexto(lineaLower, clave)) {
matchedClave = true;
matchedClaveString = clave;
break;
}
}
if (matchedClave) {
let subLinea = linea;
const idxClave = lineaLower.indexOf(matchedClaveString);
if (idxClave !== -1) {
subLinea = linea.substring(idxClave + matchedClaveString.length);
}
const cleanSubLinea = subLinea.replace(/\([^)]*?(?:-||—|:|[0-9]\s*[-–—]\s*[0-9]|ref|vr|val|norm|min|max)[^)]*?\)/gi, ' ');
let match = cleanSubLinea.match(/(\d+(?:[.,]\d+)?)/);
if (!match) match = subLinea.match(/(\d+(?:[.,]\d+)?)/);
if (!match) match = linea.match(/[a-zA-ZáéíóúñÁÉÍÓÚÑ]+.*?(\d+(?:[.,]\d+)?)/);
if (match && match[1]) {
const valor = parseFloat(match[1].replace(',', '.'));
if (!isNaN(valor) && valor > 0 && valor < 1000000) {
let valorFinal = valor;
if (core.nombre === 'Leucocitos' || core.nombre === 'Plaquetas') {
if (valor < 200) valorFinal = valor * 1000;
}
let unidadFinal = core.unidad;
if (core.nombre === 'Tiempo de Protrombina') {
if (lineaLower.includes('seg') || lineaLower.includes('segundo')) {
unidadFinal = 'seg';
} else if (lineaLower.includes('%')) {
unidadFinal = '%';
}
}
resultados.push({
parametro: core.nombre,
valor: valorFinal,
unidad: unidadFinal,
estado: calcularEstadoLaboratorioExtendido(core.nombre, String(valorFinal))
});
processedLines.add(lIdx);
break;
}
}
}
}
}
// 2. Process Configured Dynamic Groups of Other Determinations
const activeGroups = (gruposConfigurados || []).filter(g => g.activo !== false);
for (const grupo of activeGroups) {
for (const det of (grupo.determinaciones || [])) {
if (resultados.some(r => r.parametro === det.nombre)) continue;
for (let lIdx = 0; lIdx < lineas.length; lIdx++) {
const linea = lineas[lIdx];
const lineaLower = linea.toLowerCase().trim();
if (!lineaLower) continue;
let matchedClave = false;
let matchedClaveString = '';
for (const clave of (det.claves || [])) {
if (det.nombre === 'Colesterol Total' && clave === 'colesterol') {
if (lineaLower.includes('ldl') || lineaLower.includes('hdl') || lineaLower.includes('no')) {
continue;
}
}
if (det.nombre === 'Transferrina' && clave === 'transferrina') {
if (lineaLower.includes('saturac') || lineaLower.includes('sat') || lineaLower.includes('%')) {
continue;
}
}
if (matchClaveTexto(lineaLower, clave)) {
matchedClave = true;
matchedClaveString = clave;
break;
}
}
if (matchedClave) {
let subLinea = linea;
const idxClave = lineaLower.indexOf(matchedClaveString);
if (idxClave !== -1) {
subLinea = linea.substring(idxClave + matchedClaveString.length);
}
const cleanSubLinea = subLinea.replace(/\([^)]*?(?:-||—|:|[0-9]\s*[-–—]\s*[0-9]|ref|vr|val|norm|min|max)[^)]*?\)/gi, ' ');
let match = cleanSubLinea.match(/(\d+(?:[.,]\d+)?)/);
if (!match) match = subLinea.match(/(\d+(?:[.,]\d+)?)/);
if (!match) match = linea.match(/[a-zA-ZáéíóúñÁÉÍÓÚÑ]+.*?(\d+(?:[.,]\d+)?)/);
if (match && match[1]) {
const valor = parseFloat(match[1].replace(',', '.'));
if (!isNaN(valor) && valor >= 0 && valor < 10000000) {
const estado = calcularEstadoLaboratorioExtendido(det.nombre, valor, det.rangoReferencia);
const recItem: RecognizedDetermination = {
grupoId: grupo.id,
grupoNombre: grupo.nombreGrupo,
parametro: det.nombre,
valor,
unidad: det.unidad,
estado,
claveCoincidente: matchedClaveString,
rangoReferencia: det.rangoReferencia,
lineaOriginal: linea.trim()
};
reconocidos.push(recItem);
processedLines.add(lIdx);
resultados.push({
parametro: det.nombre,
valor,
unidad: det.unidad,
estado,
valorReferencia: det.rangoReferencia
});
break;
}
}
}
}
}
}
// 3. Detect Potential Unrecognized Determinations (Training Suggestions)
const desconocidosPotenciales: PotentialUnknownDetermination[] = [];
for (let lIdx = 0; lIdx < lineas.length; lIdx++) {
if (processedLines.has(lIdx)) continue;
const rawLine = lineas[lIdx].trim();
if (!rawLine || rawLine.length < 3 || rawLine.length > 120) continue;
// Check if line looks like: Name : 12.3 mg/dL OR Name 12.3
const pat1 = /^([a-zA-ZáéíóúñÁÉÍÓÚÑ\s/().%+-]{3,40})[:=]\s*(\d+(?:[.,]\d+)?)\s*([a-zA-Z/µ%³0-9-]*)/;
const m1 = rawLine.match(pat1);
if (m1) {
const posNombre = m1[1].trim();
const posVal = parseFloat(m1[2].replace(',', '.'));
const posUnidad = m1[3].trim();
if (posNombre && !isNaN(posVal) && posNombre.toLowerCase() !== 'fecha' && posNombre.toLowerCase() !== 'hora' && posNombre.toLowerCase() !== 'cama' && posNombre.toLowerCase() !== 'dni') {
desconocidosPotenciales.push({
linea: rawLine,
posibleNombre: posNombre,
posibleValor: posVal,
posibleUnidad: posUnidad
});
}
}
}
// 4. Build Organized Observaciones per Group
const observacionesPorGrupo: { grupo: string; lineas: string[] }[] = [];
for (const grupo of activeGroups) {
const itemsDelGrupo = reconocidos.filter(r => r.grupoId === grupo.id);
if (itemsDelGrupo.length > 0) {
const lineasGrupo: string[] = [];
for (const item of itemsDelGrupo) {
lineasGrupo.push(`- ${item.parametro}: ${item.valor} ${item.unidad}`);
}
observacionesPorGrupo.push({
grupo: grupo.nombreGrupo.toUpperCase(),
lineas: lineasGrupo
});
}
}
// Build the complete formatted observations string
const blocks: string[] = [];
for (const g of observacionesPorGrupo) {
blocks.push(`${g.grupo}:\n${g.lineas.join('\n')}`);
}
const observacionesStr = blocks.join('\n\n');
// 5. Parse Gasometría / Ácido Base
const acidoBase = parseAcidoBaseTexto(texto, fecha, pacienteId);
return {
resultados,
observaciones: observacionesStr,
observacionesPorGrupo,
reconocidos,
desconocidosPotenciales,
acidoBase
};
}
export function buildObservacionesFromResults(
resultadosArray: ResultadoLaboratorio[],
gruposConfigurados: GrupoDeterminacionLaboratorio[] = []
): string {
if (!resultadosArray || resultadosArray.length === 0) return '';
const blocks: string[] = [];
const processedParams = new Set<string>();
const activeGroups = (gruposConfigurados || []).filter(g => g.activo !== false);
for (const grupo of activeGroups) {
const groupItems: string[] = [];
for (const det of (grupo.determinaciones || [])) {
const match = resultadosArray.find(r => r.parametro === det.nombre);
if (match) {
groupItems.push(`- ${match.parametro}: ${match.valor} ${match.unidad}`);
processedParams.add(match.parametro);
}
}
if (groupItems.length > 0) {
blocks.push(`${grupo.nombreGrupo.toUpperCase()}:\n${groupItems.join('\n')}`);
}
}
// Any other non-core results not belonging to known groups
const uncategorized = resultadosArray.filter(r => !esParametroCore(r.parametro) && !processedParams.has(r.parametro));
if (uncategorized.length > 0) {
const uncatLines = uncategorized.map(u => `- ${u.parametro}: ${u.valor} ${u.unidad}`);
blocks.push(`OTRAS DETERMINACIONES:\n${uncatLines.join('\n')}`);
}
return blocks.join('\n\n');
}
+10 -332
View File
@@ -4,6 +4,7 @@ import { toast } from 'sonner';
import { User } from 'lucide-react'; import { User } from 'lucide-react';
import { useHospitalStore } from '@/hooks/useHospitalStore'; import { useHospitalStore } from '@/hooks/useHospitalStore';
import { getNombreProfesional } from '@/lib/utils'; import { getNombreProfesional } from '@/lib/utils';
import { parseLaboratorioTextoCompleto, buildObservacionesFromResults, esParametroCore as esParamCoreHelper } from '@/lib/laboratoryParser';
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@@ -1028,6 +1029,7 @@ function SeccionLaboratorios({ lab, otrosLaboratorios = [], patientId, internaci
canEdit?: boolean; canEdit?: boolean;
portalNode?: HTMLDivElement | null; portalNode?: HTMLDivElement | null;
}) { }) {
const { gruposDeterminacionesLab } = useHospitalStore();
const [dialog, setDialog] = useState(false); const [dialog, setDialog] = useState(false);
const [obsDialog, setObsDialog] = useState(false); const [obsDialog, setObsDialog] = useState(false);
@@ -1247,345 +1249,21 @@ function SeccionLaboratorios({ lab, otrosLaboratorios = [], patientId, internaci
setEdit(null); setEdit(null);
}; };
const buildObservacionesFromResultados = (resultadosArray: ResultadoLaboratorio[]): string => {
const observacionesExtra: string[] = [];
// 1. Lípidos
const ordenLipidos = ['Colesterol Total', 'Colesterol LDL', 'Colesterol No HDL', 'Colesterol HDL', 'Triglicéridos'];
const lipidosEncontrados = resultadosArray.filter(r => ordenLipidos.includes(r.parametro));
if (lipidosEncontrados.length > 0) {
observacionesExtra.push('PERFIL LIPIDICO:');
for (const nombre of ordenLipidos) {
const item = lipidosEncontrados.find(l => l.parametro === nombre);
if (item) {
observacionesExtra.push(`- ${item.parametro}: ${item.valor} ${item.unidad}`);
}
}
}
// 2. Férricos
const ordenFerricos = ['Hierro', 'Transferrina', 'Porcentaje de Saturación de Transferrina', 'Ferritina', 'Ácido Fólico', 'Vitamina B12'];
const ferricosEncontrados = resultadosArray.filter(r => ordenFerricos.includes(r.parametro));
if (ferricosEncontrados.length > 0) {
if (observacionesExtra.length > 0) {
observacionesExtra.push('');
}
observacionesExtra.push('PERFIL FERRICO:');
for (const nombre of ordenFerricos) {
const item = ferricosEncontrados.find(f => f.parametro === nombre);
if (item) {
observacionesExtra.push(`- ${item.parametro}: ${item.valor} ${item.unidad}`);
}
}
}
// 3. Independientes
const ordenInd = [
'Albúmina',
'Calcio Total',
'Fosfatasa Alcalina',
'LDH',
'NT-proBNP',
'Procalcitonina',
'Fósforo',
'Magnesio',
'Calcio Iónico'
];
const indEncontrados = resultadosArray.filter(r => ordenInd.includes(r.parametro));
if (indEncontrados.length > 0) {
if (observacionesExtra.length > 0) {
observacionesExtra.push('');
}
for (const nombre of ordenInd) {
const item = indEncontrados.find(i => i.parametro === nombre);
if (item) {
observacionesExtra.push(`${item.parametro}: ${item.valor} ${item.unidad}`);
}
}
}
// 4. VCM & HCM
const ordenHemogramaAdicionales = ['VCM', 'HCM'];
const hemogramaAdicionalesEncontrados = resultadosArray.filter(r => ordenHemogramaAdicionales.includes(r.parametro));
if (hemogramaAdicionalesEncontrados.length > 0) {
if (observacionesExtra.length > 0) {
observacionesExtra.push('');
}
for (const nombre of ordenHemogramaAdicionales) {
const item = hemogramaAdicionalesEncontrados.find(h => h.parametro === nombre);
if (item) {
observacionesExtra.push(`${item.parametro}: ${item.valor} ${item.unidad}`);
}
}
}
return observacionesExtra.join('\n');
};
const parseLaboratorioTexto = (texto: string): { resultados: ResultadoLaboratorio[]; observaciones: string } => {
const resultados: ResultadoLaboratorio[] = [];
const mapeoParametros: { claves: string[]; nombre: string; unidad: string; esPrincipal: boolean; esAdicional?: boolean }[] = [
{ claves: ['hematocrito', 'hto'], nombre: 'Hematocrito', unidad: '%', esPrincipal: true },
{ claves: ['hemoglobina corpuscular media', 'hcm'], nombre: 'HCM', unidad: 'pg', esPrincipal: false, esAdicional: true },
{ claves: ['hemoglobina', 'hb'], nombre: 'Hemoglobina', unidad: 'g/dL', esPrincipal: true },
{ claves: ['volumen corpuscular medio', 'vcm'], nombre: 'VCM', unidad: 'fL', esPrincipal: false, esAdicional: true },
{ claves: ['rdw'], nombre: 'RDW', unidad: '%', esPrincipal: false },
{ claves: ['eritroblastos'], nombre: 'Eritroblastos', unidad: '%', esPrincipal: false },
{ claves: ['neutrófilos', 'neutrofilos'], nombre: 'Neutrófilos', unidad: '%', esPrincipal: false },
{ claves: ['linfocitos'], nombre: 'Linfocitos', unidad: '%', esPrincipal: false },
{ claves: ['monocitos'], nombre: 'Monocitos', unidad: '%', esPrincipal: false },
{ claves: ['eosinófilos', 'eosinofilos'], nombre: 'Eosinófilos', unidad: '%', esPrincipal: false },
{ claves: ['basófilos', 'basofilos'], nombre: 'Basófilos', unidad: '%', esPrincipal: false },
{ claves: ['recuento de leucocitos', 'glóbulos blancos', 'globulos blancos', 'leucocitos', 'gb'], nombre: 'Leucocitos', unidad: '10³/µl', esPrincipal: true },
{ claves: ['recuento de plaquetas', 'plaquetas', 'plaq'], nombre: 'Plaquetas', unidad: '10³/µl', esPrincipal: true },
{ claves: ['volumen plaquetario medio', 'vpm'], nombre: 'VPM', unidad: 'fL', esPrincipal: false },
{ claves: ['glucosa', 'glucemia'], nombre: 'Glucemia', unidad: 'mg/dL', esPrincipal: true },
{ claves: ['urea'], nombre: 'Urea', unidad: 'mg/dL', esPrincipal: true },
{ claves: ['creatinina', 'creat'], nombre: 'Creatinina', unidad: 'mg/dL', esPrincipal: true },
{ claves: ['mdrd'], nombre: 'Filtrado Glomerular (MDRD)', unidad: 'ml/min', esPrincipal: false },
{ claves: ['sodio', 'na'], nombre: 'Sodio', unidad: 'mEq/L', esPrincipal: true },
{ claves: ['potasio', 'k+', 'k'], nombre: 'Potasio', unidad: 'mEq/L', esPrincipal: true },
{ claves: ['cloro', 'cl-', 'cl'], nombre: 'Cloro', unidad: 'mEq/L', esPrincipal: true },
{ claves: ['bilirrubina total', 'bt'], nombre: 'Bilirrubina Total', unidad: 'mg/dL', esPrincipal: true },
{ claves: ['bilirrubina directa', 'bd'], nombre: 'Bilirrubina Directa', unidad: 'mg/dL', esPrincipal: true },
{ claves: ['got', 'ast'], nombre: 'GOT', unidad: 'U/L', esPrincipal: true },
{ claves: ['gpt', 'alt'], nombre: 'GPT', unidad: 'U/L', esPrincipal: true },
{ claves: ['proteínas totales', 'proteinas totales'], nombre: 'Proteínas Totales', unidad: 'g/dL', esPrincipal: false },
{ claves: ['tiempo de protrombina', 't.p.', 't.p', 'tp', 'protrombina'], nombre: 'Tiempo de Protrombina', unidad: '%', esPrincipal: true },
{ claves: ['rin', 'inr'], nombre: 'INR', unidad: '', esPrincipal: true },
{ claves: ['aptt', 'kptt'], nombre: 'KPTT', unidad: 'seg', esPrincipal: true },
// Additional requested determinations
{ claves: ['colesterol ldl', 'ldl-c', 'ldl', 'colest. ldl', 'colest ldl'], nombre: 'Colesterol LDL', unidad: 'mg/dL', esPrincipal: false, esAdicional: true },
{ claves: ['colesterol no-hdl', 'colesterol no hdl', 'no-hdl', 'no hdl', 'no_hdl'], nombre: 'Colesterol No HDL', unidad: 'mg/dL', esPrincipal: false, esAdicional: true },
{ claves: ['colesterol hdl', 'hdl-c', 'hdl', 'colest. hdl', 'colest hdl'], nombre: 'Colesterol HDL', unidad: 'mg/dL', esPrincipal: false, esAdicional: true },
{ claves: ['colesterol total', 'colesterol', 'colest. total', 'col. total', 'colest total'], nombre: 'Colesterol Total', unidad: 'mg/dL', esPrincipal: false, esAdicional: true },
{ claves: ['triglicéridos', 'trigliceridos', 'tg', 'triglicérido', 'triglicerido'], nombre: 'Triglicéridos', unidad: 'mg/dL', esPrincipal: false, esAdicional: true },
{ claves: ['albúmina', 'albumina'], nombre: 'Albúmina', unidad: 'g/dL', esPrincipal: false, esAdicional: true },
{ claves: ['calcio total', 'calcio', 'ca total', 'ca+', 'ca++'], nombre: 'Calcio Total', unidad: 'mg/dL', esPrincipal: false, esAdicional: true },
{ claves: ['fosfatasa alcalina', 'fal'], nombre: 'Fosfatasa Alcalina', unidad: 'U/L', esPrincipal: false, esAdicional: true },
{ claves: ['ldh', 'lactato deshidrogenasa', 'lactatodeshidrogenasa'], nombre: 'LDH', unidad: 'U/L', esPrincipal: false, esAdicional: true },
{ claves: ['nt-probnp', 'nt probnp', 'nt_probnp', 'probnp', 'pro-bnp', 'pro bnp'], nombre: 'NT-proBNP', unidad: 'pg/mL', esPrincipal: false, esAdicional: true },
{ claves: ['procalcitonina', 'pct'], nombre: 'Procalcitonina', unidad: 'ng/mL', esPrincipal: false, esAdicional: true },
{ claves: ['fósforo', 'fosforo', 'fosfemia'], nombre: 'Fósforo', unidad: 'mg/dL', esPrincipal: false, esAdicional: true },
{ claves: ['magnesio', 'magnesemia', 'mg++', 'mg+', 'mg2+', 'mg 2+', 'mg.', 'magnesio plasmatico', 'magnesio plasmático', 'magnesio serico', 'magnesio sérico', 'magnesio en sangre', 'mg serico', 'mg sérico', 'mg plasmatico', 'mg plasmático', 'mg'], nombre: 'Magnesio', unidad: 'mg/dL', esPrincipal: false, esAdicional: true },
{ claves: ['calcio ionico', 'calcio iónico', 'ca ionico', 'ca iónico', 'ca++ ionico', 'ca++ iónico', 'calcio_ionico'], nombre: 'Calcio Iónico', unidad: 'mmol/L', esPrincipal: false, esAdicional: true },
// Perfil Férrico requested determinations
{ claves: ['porcentaje de saturacion de transferrina', 'porcentaje de saturación de transferrina', 'saturacion de transferrina', 'saturación de transferrina', 'sat. transferrina', 'sat transferrina', '% sat', '% de sat', '% saturacion', '% saturación'], nombre: 'Porcentaje de Saturación de Transferrina', unidad: '%', esPrincipal: false, esAdicional: true },
{ claves: ['transferrina', 'transferrin'], nombre: 'Transferrina', unidad: 'mg/dL', esPrincipal: false, esAdicional: true },
{ claves: ['hierro', 'sideremia', 'fe'], nombre: 'Hierro', unidad: 'µg/dL', esPrincipal: false, esAdicional: true },
{ claves: ['ferritina', 'ferritin'], nombre: 'Ferritina', unidad: 'ng/mL', esPrincipal: false, esAdicional: true },
{ claves: ['acido folico', 'ácido fólico', 'folato', 'folatos', 'folico', 'fólico'], nombre: 'Ácido Fólico', unidad: 'ng/mL', esPrincipal: false, esAdicional: true },
{ claves: ['vitamina b12', 'b12', 'vit. b12'], nombre: 'Vitamina B12', unidad: 'pg/mL', esPrincipal: false, esAdicional: true }
];
const matchClave = (lineaLower: string, clave: string): boolean => {
if (clave === 'mg') {
const execMatch = /(?:^|[^a-z0-9_])mg(?=[:\s=+\d]|$)(?!\/(?:dl|l|24h|ml)|%)/i.exec(lineaLower);
if (!execMatch) return false;
const prefix = lineaLower.substring(0, execMatch.index);
return !/\d+\s*$/.test(prefix);
}
if (clave.length > 4) {
return lineaLower.includes(clave);
}
const escaped = clave.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(?:^|[^a-z0-9_])${escaped}(?:$|[^a-z0-9_])`, 'i');
return regex.test(lineaLower);
};
const lineas = texto.split('\n');
for (const linea of lineas) {
const lineaLower = linea.toLowerCase().trim();
if (!lineaLower) continue;
for (const group of mapeoParametros) {
// Skip if this parameter was already found
if (resultados.some(r => r.parametro === group.nombre)) continue;
let matchedClave = false;
let matchedClaveString = '';
for (const clave of group.claves) {
if (group.nombre === 'Colesterol Total' && clave === 'colesterol') {
if (lineaLower.includes('ldl') || lineaLower.includes('hdl') || lineaLower.includes('no')) {
continue;
}
}
if (group.nombre === 'Transferrina' && clave === 'transferrina') {
if (lineaLower.includes('saturac') || lineaLower.includes('sat') || lineaLower.includes('%')) {
continue;
}
}
if (matchClave(lineaLower, clave)) {
matchedClave = true;
matchedClaveString = clave;
break;
}
}
if (matchedClave) {
let subLinea = linea;
const idxClave = lineaLower.indexOf(matchedClaveString);
if (idxClave !== -1) {
subLinea = linea.substring(idxClave + matchedClaveString.length);
}
// Clean parenthesized reference values/ranges if present (e.g. "(1.6 - 2.6 mg/dL)")
const cleanSubLinea = subLinea.replace(/\([^)]*?(?:-||—|:|[0-9]\s*[-–—]\s*[0-9]|ref|vr|val|norm|min|max)[^)]*?\)/gi, ' ');
let match = cleanSubLinea.match(/(\d+(?:[.,]\d+)?)/);
if (!match) {
match = subLinea.match(/(\d+(?:[.,]\d+)?)/);
}
if (!match) {
match = linea.match(/[a-zA-ZáéíóúñÁÉÍÓÚÑ]+.*?(\d+(?:[.,]\d+)?)/);
}
if (match && match[1]) {
const valor = parseFloat(match[1].replace(',', '.'));
if (!isNaN(valor) && valor > 0 && valor < 1000000) {
const nombreNormalizado = group.nombre;
let valorFinal = valor;
if (nombreNormalizado === 'Leucocitos' || nombreNormalizado === 'Plaquetas') {
if (valor < 200) valorFinal = valor * 1000;
}
let unidadFinal = group.unidad;
if (nombreNormalizado === 'Tiempo de Protrombina') {
if (lineaLower.includes('seg') || lineaLower.includes('segundo')) {
unidadFinal = 'seg';
} else if (lineaLower.includes('%')) {
unidadFinal = '%';
}
}
if (group.esPrincipal || group.esAdicional) {
resultados.push({
parametro: nombreNormalizado,
valor: valorFinal,
unidad: unidadFinal,
estado: calcularEstadoLaboratorio(nombreNormalizado, String(valorFinal))
});
}
break;
}
}
}
}
}
const observacionesStr = buildObservacionesFromResultados(resultados);
return { resultados, observaciones: observacionesStr };
};
const parseAcidoBaseTexto = (texto: string): Omit<AcidoBase, 'id'> | null => {
const lineas = texto.split('\n');
let ph: number | undefined;
let pco2: number | undefined;
let po2: number | undefined;
let hco3: number | undefined;
let be: number | undefined;
let sato2: number | undefined;
let lactato: number | undefined;
let fio2: number | undefined;
const fecha = importFecha;
for (const linea of lineas) {
const cleanLinea = linea.replace(/show_chart|list_alt|\t+/g, ' ').replace(/,/g, '.').replace(/\s+/g, ' ').trim().toLowerCase();
const getValue = (param: string): number | undefined => {
const idx = cleanLinea.indexOf(param);
if (idx === -1) return undefined;
const after = cleanLinea.slice(idx + param.length).trim();
const parts = after.split(' ');
for (const p of parts) {
const v = parseFloat(p);
if (!isNaN(v) && v > 0 && v < 1000) return v;
}
return undefined;
};
if (cleanLinea.includes('estado') && cleanLinea.includes('ácido') || cleanLinea.includes('base')) {
ph = getValue('ph') || (cleanLinea.match(/ph\s+(\d+\.?\d*)/)?.[1] ? parseFloat(cleanLinea.match(/ph\s+(\d+\.?\d*)/)![1]) : undefined);
pco2 = getValue('pco2') || getValue('pco₂');
po2 = getValue('po2') || getValue('po₂');
hco3 = getValue('hco3');
be = getValue('exceso de base') || getValue('base excess') || getValue('exceso');
sato2 = getValue('saturación') || getValue('sato2') || getValue('sat');
lactato = getValue('lactato');
fio2 = getValue('fio2');
break;
}
}
if (!ph) {
for (const linea of lineas) {
const cleanLinea = linea.replace(/show_chart|list_alt|\t+/g, ' ').replace(/,/g, '.').replace(/\s+/g, ' ').trim().toLowerCase();
const getValue = (param: string): number | undefined => {
const idx = cleanLinea.indexOf(param);
if (idx === -1) return undefined;
const after = cleanLinea.slice(idx + param.length).trim();
const parts = after.split(' ');
for (const p of parts) {
const v = parseFloat(p);
if (!isNaN(v) && v > 0 && v < 1000) return v;
}
return undefined;
};
if (cleanLinea.includes('ph') && !cleanLinea.includes('pco2') && !cleanLinea.includes('pco')) { ph = getValue('ph'); }
else if (cleanLinea.includes('pco2') || cleanLinea.includes('pco₂')) { pco2 = getValue('pco2'); }
else if (cleanLinea.includes('po2') || cleanLinea.includes('po₂')) { po2 = getValue('po2'); }
else if (cleanLinea.includes('hco3')) { hco3 = getValue('hco3'); }
else if (cleanLinea.includes('exceso') || cleanLinea.includes('base excess')) { be = getValue('exceso') || getValue('base'); }
else if (cleanLinea.includes('saturación') || cleanLinea.includes('sato2')) { sato2 = getValue('saturación') || getValue('sat'); }
else if (cleanLinea.includes('lactato')) { lactato = getValue('lactato'); }
else if (cleanLinea.includes('fio2')) { fio2 = getValue('fio2'); }
}
}
if (ph) {
return { pacienteId: patientId, fecha, hora: '', ph, pco2: pco2 || 40, po2: po2 || 85, hco3: hco3 || 24, be: be || 0, sato2: sato2 || 97, lactato, fio2, interpretacion: '' };
}
return null;
};
const esParametroCore = (param: string): boolean => {
const coreParameters = [
// Hemograma
'hematocrito', 'hto', 'hemoglobina', 'hb', 'leucocitos', 'gb', 'plaquetas', 'plaq',
'rdw', 'eritroblastos', 'neutrófilos', 'neutrofilos', 'linfocitos', 'monocitos', 'eosinófilos', 'eosinofilos', 'basófilos', 'basofilos', 'vpm',
// Glucemia
'glucemia', 'glucosa',
// Urea
'urea',
// Creatinina
'creatinina', 'creat', 'filtrado glomerular (mdrd)', 'mdrd',
// Ionograma
'sodio', 'na', 'potasio', 'k', 'k+', 'cloro', 'cl', 'cl-',
// Hepatograma
'bilirrubina total', 'bt', 'bilirrubina directa', 'bd', 'got', 'ast', 'gpt', 'alt', 'proteínas totales', 'proteinas totales',
// Coagulograma
'tiempo de protrombina', 'tp', 't.p.', 't.p', 'rin', 'inr', 'aptt', 'kptt'
];
return coreParameters.includes(param.toLowerCase().trim());
};
const handleEliminarDeterminacion = (index: number) => { const handleEliminarDeterminacion = (index: number) => {
setImportResultados(prev => { setImportResultados(prev => {
const filtered = prev.filter((_, i) => i !== index); const filtered = prev.filter((_, i) => i !== index);
setImportObservaciones(buildObservacionesFromResultados(filtered)); setImportObservaciones(buildObservacionesFromResults(filtered, gruposDeterminacionesLab || []));
return filtered; return filtered;
}); });
}; };
const handleProcesarTexto = () => { const handleProcesarTexto = () => {
const { resultados, observaciones } = parseLaboratorioTexto(importTexto); const { resultados, observaciones, acidoBase } = parseLaboratorioTextoCompleto(
const acidoBase = parseAcidoBaseTexto(importTexto); importTexto,
gruposDeterminacionesLab || [],
importFecha,
patientId
);
setImportResultados(resultados); setImportResultados(resultados);
setImportObservaciones(observaciones); setImportObservaciones(observaciones);
@@ -2127,7 +1805,7 @@ function SeccionLaboratorios({ lab, otrosLaboratorios = [], patientId, internaci
{importResultados.map((r, idx) => ( {importResultados.map((r, idx) => (
<TableRow key={idx}> <TableRow key={idx}>
<TableCell className="text-center p-2 w-10"> <TableCell className="text-center p-2 w-10">
{!esParametroCore(r.parametro) && ( {!esParamCoreHelper(r.parametro) && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
+183 -102
View File
@@ -23,11 +23,13 @@ import {
CheckCircle2, CheckCircle2,
Tag, Tag,
Info, Info,
RotateCcw RotateCcw,
FlaskConical
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useHospitalStore } from '@/hooks/useHospitalStore'; import { useHospitalStore } from '@/hooks/useHospitalStore';
import type { TipoCultivo } from '@/types'; import type { TipoCultivo } from '@/types';
import { GestorParserLaboratorio } from '@/components/sistema/GestorParserLaboratorio';
export function SeccionSistema() { export function SeccionSistema() {
const { const {
@@ -377,21 +379,32 @@ export function SeccionSistema() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h2 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">Sistema y Configuración</h2> <h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-gray-900 dark:text-white">Sistema y Configuración</h2>
<p className="text-gray-500 dark:text-gray-400">Administración de parámetros clínicos, base de datos y depuración</p> <p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400">Administración de parámetros clínicos, base de datos y depuración</p>
</div> </div>
</div> </div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid grid-cols-2 sm:w-96 mb-6"> <TabsList className="grid grid-cols-1 sm:grid-cols-3 h-auto w-full max-w-2xl mb-6 p-1 gap-1">
<TabsTrigger value="cultivos" className="flex items-center gap-2"> <TabsTrigger value="cultivos" className="flex items-center justify-center gap-2 py-2 text-xs sm:text-sm">
<Microscope className="h-4 w-4" /> Tipos de Cultivos <Microscope className="h-4 w-4 shrink-0" />
<span>Tipos de Cultivos</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="mantenimiento" className="flex items-center gap-2"> <TabsTrigger value="parser-lab" className="flex items-center justify-center gap-2 py-2 text-xs sm:text-sm">
<Terminal className="h-4 w-4" /> Consola & Mantenimiento <FlaskConical className="h-4 w-4 shrink-0 text-indigo-600 dark:text-indigo-400" />
<span>Parser de Laboratorio</span>
</TabsTrigger>
<TabsTrigger value="mantenimiento" className="flex items-center justify-center gap-2 py-2 text-xs sm:text-sm">
<Terminal className="h-4 w-4 shrink-0" />
<span>Consola & Base de Datos</span>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{/* TAB: ENTRENAMIENTO DEL PARSER DE LABORATORIO */}
<TabsContent value="parser-lab" className="space-y-6">
<GestorParserLaboratorio />
</TabsContent>
{/* TAB 1: GESTIÓN DE TIPOS DE CULTIVOS */} {/* TAB 1: GESTIÓN DE TIPOS DE CULTIVOS */}
<TabsContent value="cultivos" className="space-y-6"> <TabsContent value="cultivos" className="space-y-6">
{/* Header Card with Metrics */} {/* Header Card with Metrics */}
@@ -438,22 +451,22 @@ export function SeccionSistema() {
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<CardTitle className="text-xl flex items-center gap-2"> <CardTitle className="text-lg sm:text-xl flex items-center gap-2">
<Microscope className="h-5 w-5 text-blue-600" /> <Microscope className="h-5 w-5 text-blue-600 shrink-0" />
Catálogo de Tipos de Cultivo y Muestras <span>Catálogo de Tipos de Cultivo y Muestras</span>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription className="text-xs sm:text-sm">
Administre las opciones que aparecen al crear un nuevo cultivo en las Historias Clínicas. Administre las opciones que aparecen al crear un nuevo cultivo en las Historias Clínicas.
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 w-full sm:w-auto">
<Button variant="outline" size="sm" onClick={() => setIsResetDialogOpen(true)}> <Button variant="outline" size="sm" onClick={() => setIsResetDialogOpen(true)} className="flex-1 sm:flex-none justify-center">
<RotateCcw className="h-4 w-4 mr-2" /> <RotateCcw className="h-4 w-4 mr-2 shrink-0" />
Restablecer <span>Restablecer</span>
</Button> </Button>
<Button size="sm" onClick={handleOpenNewDialog}> <Button size="sm" onClick={handleOpenNewDialog} className="flex-1 sm:flex-none justify-center">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2 shrink-0" />
Nuevo Tipo de Cultivo <span>Nuevo Tipo</span>
</Button> </Button>
</div> </div>
</div> </div>
@@ -461,20 +474,20 @@ export function SeccionSistema() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Search and Filters */} {/* Search and Filters */}
<div className="flex flex-col sm:flex-row items-center gap-3"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
<div className="relative flex-1 w-full"> <div className="relative flex-1 w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input <Input
placeholder="Buscar por nombre, categoría o descripción..." placeholder="Buscar por nombre, categoría..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9" className="pl-9 text-sm"
/> />
</div> </div>
<div className="flex items-center gap-2 w-full sm:w-auto"> <div className="flex flex-col sm:flex-row sm:items-center gap-1.5 w-full sm:w-auto">
<span className="text-xs text-gray-500 whitespace-nowrap font-medium">Categoría:</span> <span className="text-xs text-gray-500 whitespace-nowrap font-medium">Categoría:</span>
<Select value={selectedCategoria} onValueChange={setSelectedCategoria}> <Select value={selectedCategoria} onValueChange={setSelectedCategoria}>
<SelectTrigger className="w-full sm:w-[220px]"> <SelectTrigger className="w-full sm:w-[220px] text-sm">
<SelectValue placeholder="Filtrar por categoría" /> <SelectValue placeholder="Filtrar por categoría" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-60"> <SelectContent className="max-h-60">
@@ -487,8 +500,72 @@ export function SeccionSistema() {
</div> </div>
</div> </div>
{/* Table of Tipos */} {/* Mobile View: Cards for small screens */}
<div className="border rounded-lg overflow-hidden border-gray-200 dark:border-gray-800"> <div className="grid grid-cols-1 gap-3 md:hidden">
{tiposFiltrados.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/40 rounded-lg p-4 border border-gray-200 dark:border-gray-800">
<Microscope className="h-8 w-8 mx-auto mb-2 opacity-40 text-gray-400" />
<p className="font-medium text-sm">No se encontraron tipos de cultivo</p>
<p className="text-xs text-gray-400 mt-1">Pruebe ajustando los filtros de búsqueda o agregue uno nuevo.</p>
</div>
) : (
tiposFiltrados.map((tipo) => {
const count = usageCountMap[tipo.nombre.trim()] || 0;
return (
<div key={tipo.id} className="p-3.5 border rounded-lg bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-800 space-y-2.5 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="bg-blue-50 text-blue-700 dark:bg-blue-950/80 dark:text-blue-300 px-2.5 py-1 rounded text-sm font-semibold border border-blue-200 dark:border-blue-800">
{tipo.nombre}
</span>
<Badge variant="outline" className="text-xs bg-gray-50 dark:bg-gray-800">
{tipo.categoria || 'General'}
</Badge>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEditDialog(tipo)}
className="h-8 w-8 p-0 text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/50"
title="Modificar tipo de cultivo"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenDeleteDialog(tipo)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/50"
title="Eliminar tipo de cultivo"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{tipo.descripcion ? (
<p className="text-xs text-gray-600 dark:text-gray-300 leading-relaxed">{tipo.descripcion}</p>
) : (
<p className="text-xs text-gray-400 italic">Sin descripción</p>
)}
<div className="pt-2 flex items-center justify-between border-t border-gray-100 dark:border-gray-800 text-xs">
<span className="text-gray-500 font-medium">Uso en cultivos:</span>
{count > 0 ? (
<Badge className="bg-emerald-100 text-emerald-800 dark:bg-emerald-950/80 dark:text-emerald-300 text-xs font-semibold">
{count} {count === 1 ? 'cultivo' : 'cultivos'}
</Badge>
) : (
<span className="text-gray-400">0 usos</span>
)}
</div>
</div>
);
})
)}
</div>
{/* Desktop View: Table for medium & large screens */}
<div className="hidden md:block border rounded-lg overflow-x-auto border-gray-200 dark:border-gray-800">
<Table> <Table>
<TableHeader className="bg-gray-50 dark:bg-gray-800/50"> <TableHeader className="bg-gray-50 dark:bg-gray-800/50">
<TableRow> <TableRow>
@@ -573,14 +650,15 @@ export function SeccionSistema() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="col-span-1 md:col-span-3"> <Card className="col-span-1 md:col-span-3">
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div> <div>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2 text-base sm:text-lg">
<Terminal className="h-5 w-5" /> Consola mongosh <Terminal className="h-5 w-5 text-green-600 shrink-0" />
<span>Consola mongosh</span>
</CardTitle> </CardTitle>
<CardDescription>Ejecuta comandos de consulta directamente en la base de datos.</CardDescription> <CardDescription className="text-xs sm:text-sm">Ejecuta comandos de consulta directamente en la base de datos.</CardDescription>
</div> </div>
<Button variant="outline" size="sm" onClick={handleClearConsole}> <Button variant="outline" size="sm" onClick={handleClearConsole} className="self-start sm:self-auto">
<RefreshCw className="h-4 w-4 mr-2" /> Limpiar <RefreshCw className="h-4 w-4 mr-2" /> Limpiar
</Button> </Button>
</div> </div>
@@ -588,7 +666,7 @@ export function SeccionSistema() {
<CardContent> <CardContent>
<div <div
ref={terminalContainerRef} ref={terminalContainerRef}
className="bg-gray-900 text-green-400 font-mono text-sm p-4 rounded-md h-[400px] overflow-y-auto flex flex-col gap-1 relative cursor-text group" className="bg-gray-950 text-green-400 font-mono text-xs sm:text-sm p-3 sm:p-4 rounded-md h-[320px] sm:h-[400px] overflow-y-auto flex flex-col gap-1 relative cursor-text group border border-gray-800"
onClick={() => inputRef.current?.focus({ preventScroll: true })} onClick={() => inputRef.current?.focus({ preventScroll: true })}
> >
{consoleHistory.length === 0 && ( {consoleHistory.length === 0 && (
@@ -599,7 +677,7 @@ export function SeccionSistema() {
{item.type === 'input' && <span className="text-blue-500 mr-2">{'>'}</span>} {item.type === 'input' && <span className="text-blue-500 mr-2">{'>'}</span>}
{item.type === 'output' && <span className="text-gray-500 mr-2">{'<-'}</span>} {item.type === 'output' && <span className="text-gray-500 mr-2">{'<-'}</span>}
{item.type === 'error' && <span className="text-red-500 mr-2">{'!'}</span>} {item.type === 'error' && <span className="text-red-500 mr-2">{'!'}</span>}
<pre className="inline whitespace-pre-wrap font-inherit m-0">{item.content}</pre> <pre className="inline whitespace-pre-wrap break-all font-inherit m-0">{item.content}</pre>
</div> </div>
))} ))}
@@ -687,34 +765,34 @@ export function SeccionSistema() {
{/* DIALOG: NUEVO TIPO DE CULTIVO */} {/* DIALOG: NUEVO TIPO DE CULTIVO */}
<Dialog open={isNewDialogOpen} onOpenChange={setIsNewDialogOpen}> <Dialog open={isNewDialogOpen} onOpenChange={setIsNewDialogOpen}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="w-[95vw] max-w-[calc(100vw-1.5rem)] sm:max-w-lg max-h-[90vh] overflow-y-auto p-4 sm:p-6 rounded-lg">
<DialogHeader> <DialogHeader className="space-y-1">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<Plus className="h-5 w-5 text-blue-600" /> <Plus className="h-5 w-5 text-blue-600 shrink-0" />
Nuevo Tipo de Cultivo / Muestra <span>Nuevo Tipo de Cultivo / Muestra</span>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription className="text-xs sm:text-sm">
Ingrese los detalles del nuevo tipo de muestra para registrar cultivos. Ingrese los detalles del nuevo tipo de muestra para registrar cultivos.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleCreateTipo} className="space-y-4 py-2"> <form onSubmit={handleCreateTipo} className="space-y-4 py-2">
<div> <div>
<Label htmlFor="nuevo-nombre" className="text-sm font-semibold">Nombre / Código de Muestra *</Label> <Label htmlFor="nuevo-nombre" className="text-xs sm:text-sm font-semibold">Nombre / Código de Muestra *</Label>
<Input <Input
id="nuevo-nombre" id="nuevo-nombre"
placeholder="Ej: Hemocultivo Central, Líquido Peritoneal, etc." placeholder="Ej: Hemocultivo Central, Líquido Peritoneal, etc."
value={formNombre} value={formNombre}
onChange={(e) => setFormNombre(e.target.value)} onChange={(e) => setFormNombre(e.target.value)}
required required
className="mt-1" className="mt-1 text-sm"
autoFocus autoFocus
/> />
</div> </div>
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-1 mb-1.5">
<Label htmlFor="nuevo-categoria" className="text-sm font-semibold">Categoría / Grupo</Label> <Label htmlFor="nuevo-categoria" className="text-xs sm:text-sm font-semibold">Categoría / Grupo</Label>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -722,7 +800,7 @@ export function SeccionSistema() {
if (!isCustomCategoriaNew) setFormCategoria(''); if (!isCustomCategoriaNew) setFormCategoria('');
else setFormCategoria(categoriasDisponibles[0] || 'General'); else setFormCategoria(categoriasDisponibles[0] || 'General');
}} }}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline font-medium" className="text-xs text-blue-600 dark:text-blue-400 hover:underline font-medium text-left sm:text-right"
> >
{isCustomCategoriaNew ? '← Elegir de existentes' : ' Crear nueva categoría'} {isCustomCategoriaNew ? '← Elegir de existentes' : ' Crear nueva categoría'}
</button> </button>
@@ -741,26 +819,26 @@ export function SeccionSistema() {
} }
}} }}
> >
<SelectTrigger id="nuevo-categoria" className="w-full"> <SelectTrigger id="nuevo-categoria" className="w-full text-sm">
<SelectValue placeholder="Seleccionar categoría" /> <SelectValue placeholder="Seleccionar categoría" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-60"> <SelectContent className="max-h-60">
<SelectGroup> <SelectGroup>
<SelectLabel>Categorías Existentes ({categoriasDisponibles.length})</SelectLabel> <SelectLabel className="text-xs">Categorías Existentes ({categoriasDisponibles.length})</SelectLabel>
{categoriasDisponibles.map(c => ( {categoriasDisponibles.map(c => (
<SelectItem key={c} value={c}> <SelectItem key={c} value={c} className="text-sm">
{c} {c}
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
<SelectGroup> <SelectGroup>
<SelectItem value="__custom__" className="text-blue-600 dark:text-blue-400 font-medium"> <SelectItem value="__custom__" className="text-blue-600 dark:text-blue-400 font-medium text-sm">
Escribir nueva categoría... Escribir nueva categoría...
</SelectItem> </SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-gray-500 mt-1">Agrupa los tipos en el selector de creación de cultivo.</p> <p className="text-[11px] sm:text-xs text-gray-500 mt-1">Agrupa los tipos en el selector de creación de cultivo.</p>
</div> </div>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
@@ -771,28 +849,29 @@ export function SeccionSistema() {
onChange={(e) => setFormCategoria(e.target.value)} onChange={(e) => setFormCategoria(e.target.value)}
required required
autoFocus autoFocus
className="text-sm"
/> />
<p className="text-xs text-gray-500">Escriba el nombre para crear una nueva categoría.</p> <p className="text-[11px] sm:text-xs text-gray-500">Escriba el nombre para crear una nueva categoría.</p>
</div> </div>
)} )}
</div> </div>
<div> <div>
<Label htmlFor="nuevo-desc" className="text-sm font-semibold">Descripción / Indicación (Opcional)</Label> <Label htmlFor="nuevo-desc" className="text-xs sm:text-sm font-semibold">Descripción / Indicación (Opcional)</Label>
<Input <Input
id="nuevo-desc" id="nuevo-desc"
placeholder="Ej: Muestra tomada por punción con técnica estéril" placeholder="Ej: Muestra tomada por punción con técnica estéril"
value={formDescripcion} value={formDescripcion}
onChange={(e) => setFormDescripcion(e.target.value)} onChange={(e) => setFormDescripcion(e.target.value)}
className="mt-1" className="mt-1 text-sm"
/> />
</div> </div>
<DialogFooter className="pt-4 flex gap-2"> <DialogFooter className="pt-4 flex flex-col-reverse sm:flex-row gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={() => setIsNewDialogOpen(false)} disabled={isSubmitting}> <Button type="button" variant="outline" onClick={() => setIsNewDialogOpen(false)} disabled={isSubmitting} className="w-full sm:w-auto">
Cancelar Cancelar
</Button> </Button>
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? 'Guardando...' : 'Crear Tipo de Cultivo'} {isSubmitting ? 'Guardando...' : 'Crear Tipo de Cultivo'}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -802,32 +881,32 @@ export function SeccionSistema() {
{/* DIALOG: EDITAR TIPO DE CULTIVO */} {/* DIALOG: EDITAR TIPO DE CULTIVO */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}> <Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="w-[95vw] max-w-[calc(100vw-1.5rem)] sm:max-w-lg max-h-[90vh] overflow-y-auto p-4 sm:p-6 rounded-lg">
<DialogHeader> <DialogHeader className="space-y-1">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<Pencil className="h-5 w-5 text-blue-600" /> <Pencil className="h-5 w-5 text-blue-600 shrink-0" />
Modificar Tipo de Cultivo <span>Modificar Tipo de Cultivo</span>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription className="text-xs sm:text-sm">
Edite el nombre, categoría o descripción de este tipo de cultivo. Edite el nombre, categoría o descripción de este tipo de cultivo.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleUpdateTipo} className="space-y-4 py-2"> <form onSubmit={handleUpdateTipo} className="space-y-4 py-2">
<div> <div>
<Label htmlFor="edit-nombre" className="text-sm font-semibold">Nombre / Código de Muestra *</Label> <Label htmlFor="edit-nombre" className="text-xs sm:text-sm font-semibold">Nombre / Código de Muestra *</Label>
<Input <Input
id="edit-nombre" id="edit-nombre"
value={formNombre} value={formNombre}
onChange={(e) => setFormNombre(e.target.value)} onChange={(e) => setFormNombre(e.target.value)}
required required
className="mt-1" className="mt-1 text-sm"
/> />
</div> </div>
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-1 mb-1.5">
<Label htmlFor="edit-categoria" className="text-sm font-semibold">Categoría / Grupo</Label> <Label htmlFor="edit-categoria" className="text-xs sm:text-sm font-semibold">Categoría / Grupo</Label>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -835,7 +914,7 @@ export function SeccionSistema() {
if (!isCustomCategoriaEdit) setFormCategoria(''); if (!isCustomCategoriaEdit) setFormCategoria('');
else setFormCategoria(categoriasDisponibles[0] || 'General'); else setFormCategoria(categoriasDisponibles[0] || 'General');
}} }}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline font-medium" className="text-xs text-blue-600 dark:text-blue-400 hover:underline font-medium text-left sm:text-right"
> >
{isCustomCategoriaEdit ? '← Elegir de existentes' : ' Crear nueva categoría'} {isCustomCategoriaEdit ? '← Elegir de existentes' : ' Crear nueva categoría'}
</button> </button>
@@ -854,26 +933,26 @@ export function SeccionSistema() {
} }
}} }}
> >
<SelectTrigger id="edit-categoria" className="w-full"> <SelectTrigger id="edit-categoria" className="w-full text-sm">
<SelectValue placeholder="Seleccionar categoría" /> <SelectValue placeholder="Seleccionar categoría" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-60"> <SelectContent className="max-h-60">
<SelectGroup> <SelectGroup>
<SelectLabel>Categorías Existentes ({categoriasDisponibles.length})</SelectLabel> <SelectLabel className="text-xs">Categorías Existentes ({categoriasDisponibles.length})</SelectLabel>
{categoriasDisponibles.map(c => ( {categoriasDisponibles.map(c => (
<SelectItem key={c} value={c}> <SelectItem key={c} value={c} className="text-sm">
{c} {c}
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
<SelectGroup> <SelectGroup>
<SelectItem value="__custom__" className="text-blue-600 dark:text-blue-400 font-medium"> <SelectItem value="__custom__" className="text-blue-600 dark:text-blue-400 font-medium text-sm">
Escribir nueva categoría... Escribir nueva categoría...
</SelectItem> </SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-gray-500 mt-1">Categoría a la que pertenece este tipo de cultivo.</p> <p className="text-[11px] sm:text-xs text-gray-500 mt-1">Categoría a la que pertenece este tipo de cultivo.</p>
</div> </div>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
@@ -884,27 +963,29 @@ export function SeccionSistema() {
onChange={(e) => setFormCategoria(e.target.value)} onChange={(e) => setFormCategoria(e.target.value)}
required required
autoFocus autoFocus
className="text-sm"
/> />
<p className="text-xs text-gray-500">Escriba el nombre de la nueva categoría.</p> <p className="text-[11px] sm:text-xs text-gray-500">Escriba el nombre de la nueva categoría.</p>
</div> </div>
)} )}
</div> </div>
<div> <div>
<Label htmlFor="edit-desc" className="text-sm font-semibold">Descripción / Indicación</Label> <Label htmlFor="edit-desc" className="text-xs sm:text-sm font-semibold">Descripción / Indicación</Label>
<Input <Input
id="edit-desc" id="edit-desc"
value={formDescripcion} value={formDescripcion}
onChange={(e) => setFormDescripcion(e.target.value)} onChange={(e) => setFormDescripcion(e.target.value)}
className="mt-1" className="mt-1 text-sm"
placeholder="Ej: Indicación de toma de muestra o preparación"
/> />
</div> </div>
<DialogFooter className="pt-4 flex gap-2"> <DialogFooter className="pt-4 flex flex-col-reverse sm:flex-row gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={() => setIsEditDialogOpen(false)} disabled={isSubmitting}> <Button type="button" variant="outline" onClick={() => setIsEditDialogOpen(false)} disabled={isSubmitting} className="w-full sm:w-auto">
Cancelar Cancelar
</Button> </Button>
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? 'Guardando...' : 'Guardar Cambios'} {isSubmitting ? 'Guardando...' : 'Guardar Cambios'}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -914,19 +995,19 @@ export function SeccionSistema() {
{/* DIALOG: ELIMINAR TIPO DE CULTIVO */} {/* DIALOG: ELIMINAR TIPO DE CULTIVO */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}> <Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="sm:max-w-md"> <DialogContent className="w-[95vw] max-w-[calc(100vw-1.5rem)] sm:max-w-md max-h-[90vh] overflow-y-auto p-4 sm:p-6 rounded-lg">
<DialogHeader> <DialogHeader className="space-y-1">
<DialogTitle className="flex items-center gap-2 text-red-600"> <DialogTitle className="flex items-center gap-2 text-red-600 text-base sm:text-lg">
<Trash2 className="h-5 w-5" /> <Trash2 className="h-5 w-5 shrink-0" />
Eliminar Tipo de Cultivo <span>Eliminar Tipo de Cultivo</span>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription className="text-xs sm:text-sm">
¿Está seguro de que desea eliminar el tipo de cultivo "{selectedTipo?.nombre}"? ¿Está seguro de que desea eliminar el tipo de cultivo "{selectedTipo?.nombre}"?
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{selectedTipo && (usageCountMap[selectedTipo.nombre.trim()] || 0) > 0 && ( {selectedTipo && (usageCountMap[selectedTipo.nombre.trim()] || 0) > 0 && (
<div className="bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 p-3 rounded-md flex items-start gap-2 text-amber-800 dark:text-amber-200 text-sm"> <div className="bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 p-3 rounded-md flex items-start gap-2 text-amber-800 dark:text-amber-200 text-xs sm:text-sm">
<Info className="h-4 w-4 mt-0.5 shrink-0 text-amber-600" /> <Info className="h-4 w-4 mt-0.5 shrink-0 text-amber-600" />
<span> <span>
<strong>Aviso:</strong> Existen {usageCountMap[selectedTipo.nombre.trim()]} registro(s) de cultivos que actualmente utilizan este tipo de muestra. Los registros existentes mantendrán el texto histórico, pero este tipo ya no aparecerá para nuevos registros. <strong>Aviso:</strong> Existen {usageCountMap[selectedTipo.nombre.trim()]} registro(s) de cultivos que actualmente utilizan este tipo de muestra. Los registros existentes mantendrán el texto histórico, pero este tipo ya no aparecerá para nuevos registros.
@@ -934,11 +1015,11 @@ export function SeccionSistema() {
</div> </div>
)} )}
<DialogFooter className="pt-2 flex gap-2"> <DialogFooter className="pt-2 flex flex-col-reverse sm:flex-row gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={() => setIsDeleteDialogOpen(false)} disabled={isSubmitting}> <Button type="button" variant="outline" onClick={() => setIsDeleteDialogOpen(false)} disabled={isSubmitting} className="w-full sm:w-auto">
Cancelar Cancelar
</Button> </Button>
<Button type="button" variant="destructive" onClick={handleDeleteTipo} disabled={isSubmitting}> <Button type="button" variant="destructive" onClick={handleDeleteTipo} disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? 'Eliminando...' : 'Sí, Eliminar'} {isSubmitting ? 'Eliminando...' : 'Sí, Eliminar'}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -947,26 +1028,26 @@ export function SeccionSistema() {
{/* DIALOG: RESTABLECER PREDETERMINADOS */} {/* DIALOG: RESTABLECER PREDETERMINADOS */}
<Dialog open={isResetDialogOpen} onOpenChange={setIsResetDialogOpen}> <Dialog open={isResetDialogOpen} onOpenChange={setIsResetDialogOpen}>
<DialogContent className="sm:max-w-md"> <DialogContent className="w-[95vw] max-w-[calc(100vw-1.5rem)] sm:max-w-md max-h-[90vh] overflow-y-auto p-4 sm:p-6 rounded-lg">
<DialogHeader> <DialogHeader className="space-y-1">
<DialogTitle className="flex items-center gap-2 text-blue-600"> <DialogTitle className="flex items-center gap-2 text-blue-600 text-base sm:text-lg">
<RotateCcw className="h-5 w-5" /> <RotateCcw className="h-5 w-5 shrink-0" />
Restablecer Valores Predeterminados <span>Restablecer Valores Predeterminados</span>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription className="text-xs sm:text-sm">
Esta acción restaurará el catálogo estándar de tipos de cultivo (HMCx2, RC, UC, LCR, Esputos, Hisopados, etc.). Esta acción restaurará el catálogo estándar de tipos de cultivo (HMCx2, RC, UC, LCR, Esputos, Hisopados, etc.).
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-xs sm:text-sm text-gray-600 dark:text-gray-300">
¿Desea restablecer los tipos de muestra a los valores predeterminados del hospital? ¿Desea restablecer los tipos de muestra a los valores predeterminados del hospital?
</p> </p>
<DialogFooter className="pt-2 flex gap-2"> <DialogFooter className="pt-2 flex flex-col-reverse sm:flex-row gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={() => setIsResetDialogOpen(false)} disabled={isSubmitting}> <Button type="button" variant="outline" onClick={() => setIsResetDialogOpen(false)} disabled={isSubmitting} className="w-full sm:w-auto">
Cancelar Cancelar
</Button> </Button>
<Button type="button" onClick={handleResetTipos} disabled={isSubmitting}> <Button type="button" onClick={handleResetTipos} disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? 'Restableciendo...' : 'Restablecer Catálogo'} {isSubmitting ? 'Restableciendo...' : 'Restablecer Catálogo'}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -975,14 +1056,14 @@ export function SeccionSistema() {
{/* LOGS MODAL */} {/* LOGS MODAL */}
{isLogsModalOpen && ( {isLogsModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-3">
<div className="bg-white dark:bg-gray-900 rounded-lg p-6 w-full max-w-4xl max-h-[90vh] flex flex-col shadow-xl"> <div className="bg-white dark:bg-gray-900 rounded-lg p-4 sm:p-6 w-[95vw] max-w-4xl max-h-[90vh] flex flex-col shadow-xl border border-gray-200 dark:border-gray-800">
<h3 className="text-lg font-bold mb-4 flex justify-between items-center text-gray-900 dark:text-white"> <h3 className="text-base sm:text-lg font-bold mb-3 flex justify-between items-center text-gray-900 dark:text-white">
<span>Logs del Servidor</span> <span>Logs del Servidor</span>
<Button variant="ghost" size="sm" onClick={() => setIsLogsModalOpen(false)}>Cerrar</Button> <Button variant="ghost" size="sm" onClick={() => setIsLogsModalOpen(false)}>Cerrar</Button>
</h3> </h3>
<div className="flex-1 overflow-auto bg-gray-950 text-green-400 font-mono text-xs p-4 rounded border border-gray-800"> <div className="flex-1 overflow-auto bg-gray-950 text-green-400 font-mono text-xs p-3 sm:p-4 rounded border border-gray-800">
<pre className="whitespace-pre-wrap">{logs}</pre> <pre className="whitespace-pre-wrap break-all">{logs}</pre>
</div> </div>
<div className="mt-4 flex justify-end"> <div className="mt-4 flex justify-end">
<Button onClick={() => setIsLogsModalOpen(false)}>Aceptar</Button> <Button onClick={() => setIsLogsModalOpen(false)}>Aceptar</Button>
+22
View File
@@ -274,6 +274,28 @@ export interface Usuario {
fechaCreacion: string; fechaCreacion: string;
} }
export interface DeterminacionDefinicion {
id: string;
nombre: string;
claves: string[];
unidad: string;
esPrincipal?: boolean;
esAdicional?: boolean;
rangoReferencia?: string;
descripcion?: string;
}
export interface GrupoDeterminacionLaboratorio {
id: string;
nombreGrupo: string;
descripcion?: string;
activo: boolean;
orden?: number;
determinaciones: DeterminacionDefinicion[];
createdAt?: string;
updatedAt?: string;
}
export interface Pendiente { export interface Pendiente {
id: string; id: string;
pacienteId: string; pacienteId: string;
+1 -2
View File
@@ -1,12 +1,11 @@
import path from "path" import path from "path"
import react from "@vitejs/plugin-react" import react from "@vitejs/plugin-react"
import { defineConfig } from "vite" import { defineConfig } from "vite"
import { inspectAttr } from 'kimi-plugin-inspect-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
base: './', base: './',
plugins: [inspectAttr(), react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),