Compare commits

..
71 Commits
Author SHA1 Message Date
snlavaise dfe25e627e Optimizaciones de rendimiento: consultas paralelas en API, caché de Regex en parser de laboratorio y pre-cálculo de claves en sortCamas 2026-08-18 04:35:06 +00:00
snlavaise 770a4ea426 Mejoras de diseño responsive en Sistema y Configuración, gestor de parser de laboratorio y tipos de cultivo 2026-08-16 22:29:34 +00:00
snlavaise 5b51e4e8eb Actualizacion de sistema: mejoras en seleccion de categorias de cultivos, optimizacion responsive de listado de cultivos e historia clinica, y correccion de componentes 2026-08-16 20:40:26 +00:00
snlavaise 1665c76bba Establecer filtro de internaciones activas por defecto en la seccion Internaciones 2026-08-15 22:40:02 +00:00
snlavaise 2f7f6eeb91 Cambiar icono de login por icono generico de hospital 2026-08-15 19:49:39 +00:00
snlavaise b07d6fd8d8 Actualizar icono de Vara de Esculapio al diseno minimalista y lineal solicitado 2026-08-15 19:47:51 +00:00
snlavaise 6258c4c631 Cambiar icono de login por la Vara de Esculapio en negro 2026-08-15 19:00:02 +00:00
snlavaise 5a00688efb Cambiar icono de login por la cara de Esculapio en negro 2026-08-15 18:51:30 +00:00
snlavaise 4be522d35f Cambiar formato de respuesta de interconsultas de Badge a contenedor con padding 2026-08-13 04:33:53 +00:00
snlavaise 796ff5737e Solucionar rutas API de otros laboratorios, ajuste de servidor y modal de confirmacion para eliminar registros 2026-08-13 04:01:31 +00:00
snlavaise 652bd363b7 Separar formulario de nuevo registro en un modal propio y cambiar etiqueta Categoría a Tipo de Determinación 2026-08-13 02:34:52 +00:00
snlavaise fb383b0aea Actualizar Otras Determinaciones - Laboratorio con categorías y diseño mejorado 2026-08-13 02:24:58 +00:00
snlavaise 174a5f6eef Agregar sección de notas generales en Historia Clínica y mejoras en Cultivos 2026-08-13 02:06:56 +00:00
snlavaise 3afc8e0a91 fix(SeccionIndicaciones): importar createPortal desde react-dom en lugar de react 2026-08-12 22:37:42 +00:00
snlavaise ea00015030 fix(HistoriaClinica): ubicar botones de accion inmediatos al menu en Indicaciones, ATB, Estudios, Interconsultas y Pendientes 2026-08-12 22:35:11 +00:00
snlavaise 0139cf1caf feat(HistoriaClinica): agregar opciones para editar y eliminar registros de Otros Laboratorios 2026-08-12 22:32:13 +00:00
snlavaise d9b0c7e109 fix(HistoriaClinica): mejorar el parser de importacion para reconocer Magnesio (Mg, Mg++, Mg+, Magnesemia, etc.) 2026-08-12 22:22:50 +00:00
snlavaise fa99607dd2 chore: cleanup build patch scripts 2026-08-12 22:18:37 +00:00
snlavaise 6fb825778f fix(HistoriaClinica): agregar handlers de otrosLaboratorios en store y corregir ReferenceError 2026-08-12 22:18:27 +00:00
snlavaise ed31b23159 feat(HistoriaClinica): agregar iconos a los items del menu select 2026-08-12 20:49:01 +00:00
snlavaise 2734aad25f chore: Restaurar archivos de configuracion borrados por error 2026-08-12 20:40:10 +00:00
snlavaise ead841df6c chore: Eliminar scripts de transformacion temporales 2026-08-12 20:39:53 +00:00
snlavaise fce539abc2 style(HistoriaClinica): Agrupar tabs en menu select unificado y mostrar botones de accion en linea 2026-08-12 20:39:17 +00:00
snlavaise 369112d384 feat: Add new cultivo types 2026-08-12 20:18:22 +00:00
snlavaise 15049939c3 fix(Internaciones): Ordenar tabla por orden de camas en lugar de fecha de ingreso 2026-08-12 17:32:04 +00:00
snlavaise 12c522cec3 fix: Move toast notifications to top-center on mobile to prevent cut-offs at bottom 2026-08-12 04:25:56 +00:00
snlavaise f089b7368f fix(HistoriaClinica): Agregar desplazamiento horizontal (overflow-x-auto) a la tabla del modal de importacion para pantallas pequenias 2026-08-12 04:21:36 +00:00
snlavaise ae24747afa fix(HistoriaClinica): Eliminar 'mg' de las claves de busqueda de magnesio para evitar falsos positivos con miligramos 2026-08-12 04:19:07 +00:00
snlavaise e3f6b7a478 fix: Corregir overflow en layout principal para pantallas grandes 2026-08-12 03:49:38 +00:00
snlavaise e75f807fc6 fix: Prevenir desplazamiento de pagina al presionar Enter en terminal 2026-08-12 03:38:19 +00:00
snlavaise c06ca1db79 fix: Corregir scroll del emulador y serializacion circular de FindCursor en mongosh 2026-08-12 03:35:41 +00:00
snlavaise 827db0a356 feat: Emulador de terminal con cursor para mongosh 2026-08-12 03:22:53 +00:00
snlavaise 205c8f1c7c feat: Agregar modulo de Sistema (mongosh, logs, export/import) 2026-08-12 03:17:28 +00:00
snlavaise e038c25e9e Permitir eliminar Fosfatasa Alcalina, Albúmina, HCM, VCM y sincronizar observaciones dinámicamente 2026-08-12 03:07:48 +00:00
snlavaise d49250460b Corregir desajuste de zona horaria usando fecha local getLocalToday en lugar de UTC ISO 2026-08-12 02:55:20 +00:00
snlavaise e405ff7a82 Agrupar botones de laboratorios bajo menu desplegable Acciones y posicionar Importar a la derecha 2026-08-12 02:49:21 +00:00
snlavaise 9180dc09a9 Reconocer y almacenar VCM y HCM en observaciones de laboratorio al importar 2026-08-12 02:43:57 +00:00
snlavaise ab6f23c7f2 Agregar columna de eliminación por X para determinaciones no-core en el modal de importación 2026-08-12 02:33:00 +00:00
snlavaise 4ee30ad8fe Agregar boton Ver Otros y modal de observaciones en la pestaña laboratorios 2026-08-12 01:51:26 +00:00
snlavaise 69c6769342 Corregir error de referencia setIsParcialMode y actualizar etiqueta a Fecha Definitivo 2026-08-12 01:45:52 +00:00
snlavaise 8550b28fd1 Formatear fecha de cultivos y fecha de resultado parcial y definitivo como dd-mm-yyyy 2026-08-12 01:35:05 +00:00
snlavaise 0d4dbf7940 Actualizar layout de pestaña Cultivos con líneas dedicadas para fecha resultado definitivo, germen y sensibilidades 2026-08-12 01:31:32 +00:00
snlavaise 8497ecbb4f Cambiar formato de hora programada en pendientes a 24hs con validación de entrada 2026-08-12 01:07:15 +00:00
snlavaise a5591c55d8 Mostrar motivo de egreso específico (Alta, Egreso Voluntario, Óbito, Pase Servicio) en columna Estado de Internaciones 2026-08-12 00:58:06 +00:00
snlavaise 34b92ec733 Agregar campos condicionales de egreso a historia clinica y columna Fecha de Egreso a tabla de Internaciones 2026-08-12 00:50:40 +00:00
snlavaise ac9d1a5acf Renombrar opciones de egreso, cambiar Dar Alta a Registrar Egreso y agregar opción Pase servicio con campo dinámico 2026-08-12 00:18:10 +00:00
snlavaise cce8a0b4c8 Confirmar egreso como unico evento para cerrar internacion y liberar cama 2026-08-12 00:09:56 +00:00
snlavaise 12f0a7f1e7 Agregar tipo de cultivo Partes Blandas y visualizacion en badges del resultado definitivo 2026-08-11 23:59:11 +00:00
snlavaise da7a82502f Restringir operaciones de grupos al administrador y permitir a usuarios regulares solo modificar el Tipo de camas de su grupo de trabajo 2026-08-11 23:47:45 +00:00
snlavaise e869d6a19e Implementar y centralizar el ordenamiento automatico y riguroso de camas por las reglas de grupos 2XX, 3XX, 4XX 2026-08-11 23:38:31 +00:00
snlavaise 0a0f7782e9 Permitir que el usuario administrador figure con su Apellido, Nombre en lugar de Admin en las indicaciones e ingresos 2026-08-11 23:31:40 +00:00
snlavaise 34f101aaa5 Mejorar responsividad horizontal de la tabla de Pendientes de Sala asignando un min-width 2026-08-11 23:29:17 +00:00
snlavaise 857ce09797 Agregar indicador/badge amarillo con el recuento de camas Fuera de Área 2026-08-11 23:20:46 +00:00
snlavaise 42cbe02861 Actualizar logica de egreso e historia clinica para que desocupe camas En Area y elimine camas Fuera de Area 2026-08-11 22:33:47 +00:00
snlavaise 6c4defe831 Mejorar diseño responsive de Pendientes de Sala adaptando vista en tarjetas para dispositivos móviles 2026-08-11 21:48:21 +00:00
snlavaise 01bea69c9d Permitir agregar fecha y hora programada en cualquier tipo de pendiente 2026-08-11 21:33:02 +00:00
snlavaise cc7854bdd7 Agregar tipos de aislamiento TBC y Escabiosis en modal de Nueva Cama y actualizar etiqueta de Grupo 2026-08-11 21:23:43 +00:00
snlavaise 26869e4b39 Solucionar perdida de relacion entre internacion y paciente al actualizar pagina preservando IDs en backend y refrescando estado en Internaciones 2026-08-11 21:19:59 +00:00
snlavaise 51ae114a1e Configurar estado inicial de pendientes por defecto en no resueltos y refrescar estado al cargar Pendientes de Sala 2026-08-11 21:17:07 +00:00
snlavaise 7c8fe6a0a3 Corregir filtrado inicial y conteo en Pendientes de Sala para mostrar todos por defecto 2026-08-11 20:46:59 +00:00
snlavaise 23654b57ee Actualizar header de Pendientes de Sala al estilo NuevoIngreso con boton de volver de solo icono 2026-08-11 20:37:16 +00:00
snlavaise afaca05916 Agregar seccion Pendientes de Sala en Internaciones con filtros por tipo y fecha programada 2026-08-11 20:29:49 +00:00
snlavaise a700c40d20 Agregar tab de pendientes, categoria procedimiento y fecha/hora programada 2026-08-11 20:20:04 +00:00
snlavaise ca2f8a32e1 Fix Evolucion button in Laboratorios tab and add Copiar para Evolucion option 2026-08-11 18:35:22 +00:00
snlavaise bf62f94380 Fix laboratory parser regex capturing number from B12 parameter name 2026-08-11 15:53:52 +00:00
snlavaise 32dd26620e Fix internacion lookup, persistence and fallback patient resolution 2026-08-11 15:28:06 +00:00
snlavaise d34c21fe60 Fix responsive layout in Pacientes table and update Ambulatorio badge style 2026-08-11 15:05:30 +00:00
snlavaise d6ac73f79f Merge remote master into local master 2026-08-11 15:00:48 +00:00
snlavaise f0b995f046 Alinear tabla de pacientes, agregar modal de detalles completo y actualizar estilo de badge ambulatorio 2026-08-11 14:52:44 +00:00
snlavaise d17ba39b2d fix: update section title from Mapa de Camas to Camas 2026-08-11 13:56:31 +00:00
snlavaise a17deb815e feat: make side panel Cultivos section read-only and place bed badge on the left 2026-08-11 13:10:55 +00:00
40 changed files with 6899 additions and 1657 deletions
-10
View File
@@ -1,10 +0,0 @@
const fs = require('fs');
let code = fs.readFileSync('src/App.tsx', 'utf8');
code = code.replace(/store\.getLaboratoriosByPaciente\(paciente\.id\)/g, 'store.getLaboratoriosByInternacion(internacion.id)');
code = code.replace(/store\.getGlucemiasByPaciente\(paciente\.id\)/g, 'store.getGlucemiasByInternacion(internacion.id)');
code = code.replace(/store\.getAcidosBaseByPaciente\(paciente\.id\)/g, 'store.getAcidosBaseByInternacion(internacion.id)');
code = code.replace(/store\.getCultivosByPaciente\(paciente\.id\)/g, 'store.getCultivosByInternacion(internacion.id)');
code = code.replace(/store\.estudiosComplementarios\.filter\(e => e\.pacienteId === paciente\.id\)/g, 'store.estudiosComplementarios.filter(e => e.internacionId === internacion.id)');
fs.writeFileSync('src/App.tsx', code);
-109
View File
@@ -1,109 +0,0 @@
const fs = require('fs');
let code = fs.readFileSync('server/db.js', 'utf8');
// Add computeSector function
const computeSectorCode = `
function computeSector(numero) {
if (!numero) return 'En Área';
const parts = String(numero).trim().split('-');
if (parts.length === 0) return 'En Área';
const salaStr = parts[0].trim();
const salaNum = parseInt(salaStr, 10);
if (isNaN(salaNum)) return 'En Área';
if (salaNum >= 300 && salaNum < 400 && salaNum % 2 !== 0) {
return 'Fuera de Área';
}
if (salaNum >= 400 && salaNum < 500) {
return 'Fuera de Área';
}
return 'En Área';
}
`;
// Find where to insert computeSector
code = code.replace("export function createCama(cama) {", computeSectorCode + "\nexport function createCama(cama) {");
// Update createCama
code = code.replace(
/export function createCama\(cama\) \{\s*const gId = cama.grupoId \|\| cama.areaId \|\| null;\s*db.prepare\('INSERT INTO camas \(id, numero, grupoId, areaId, tipo, estado, pacienteId, internacionId\) VALUES \(\?, \?, \?, \?, \?, \?, \?, \?\)'\)\.run\(cama.id, cama.numero, gId, gId, cama.tipo \|\| 'Estándar', cama.estado \|\| 'Disponible', cama.pacienteId \|\| null, cama.internacionId \|\| null\);\s*\}/,
`export function createCama(cama) {
const gId = cama.grupoId || cama.areaId || null;
const sector = computeSector(cama.numero);
db.prepare('INSERT INTO camas (id, numero, grupoId, areaId, tipo, estado, pacienteId, internacionId, sector) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)').run(cama.id, cama.numero, gId, gId, cama.tipo || 'Estándar', cama.estado || 'Disponible', cama.pacienteId || null, cama.internacionId || null, sector);
}`
);
// Update updateCama
code = code.replace(
/export function updateCama\(id, updates\) \{[\s\S]*?if \(fields\.length > 0\) \{/,
`export function updateCama(id, updates) {
const fields = [];
const values = [];
if (updates.numero !== undefined) {
fields.push('numero = ?');
values.push(updates.numero);
fields.push('sector = ?');
values.push(computeSector(updates.numero));
}
if (updates.tipo !== undefined) {
fields.push('tipo = ?');
values.push(updates.tipo);
}
if (updates.grupoId !== undefined) {
fields.push('grupoId = ?');
values.push(updates.grupoId);
fields.push('areaId = ?');
values.push(updates.grupoId);
}
if (updates.estado !== undefined) {
fields.push('estado = ?');
values.push(updates.estado);
}
if (updates.pacienteId !== undefined) {
fields.push('pacienteId = ?');
values.push(updates.pacienteId);
}
if (updates.internacionId !== undefined) {
fields.push('internacionId = ?');
values.push(updates.internacionId);
}
if (fields.length > 0) {`
);
// Update table schema
code = code.replace(
/CREATE TABLE IF NOT EXISTS camas \(\s*id TEXT PRIMARY KEY,\s*numero TEXT NOT NULL,\s*grupoId TEXT,\s*areaId TEXT,\s*tipo TEXT DEFAULT 'Estándar',\s*estado TEXT DEFAULT 'Disponible',\s*pacienteId TEXT,\s*internacionId TEXT\s*\);/,
`CREATE TABLE IF NOT EXISTS camas (
id TEXT PRIMARY KEY,
numero TEXT NOT NULL,
grupoId TEXT,
areaId TEXT,
tipo TEXT DEFAULT 'Estándar',
estado TEXT DEFAULT 'Disponible',
pacienteId TEXT,
internacionId TEXT,
sector TEXT DEFAULT 'En Área'
);
try {
db.prepare("ALTER TABLE camas ADD COLUMN sector TEXT DEFAULT 'En Área'").run();
} catch(e) {}
try {
// Initialize existing beds with their sector
const rows = db.prepare('SELECT id, numero FROM camas WHERE sector IS NULL OR sector = "En Área"').all();
for (const row of rows) {
if (!row.numero) continue;
const salaStr = String(row.numero).trim().split('-')[0].trim();
const salaNum = parseInt(salaStr, 10);
let s = 'En Área';
if (!isNaN(salaNum)) {
if (salaNum >= 300 && salaNum < 400 && salaNum % 2 !== 0) s = 'Fuera de Área';
else if (salaNum >= 400 && salaNum < 500) s = 'Fuera de Área';
}
db.prepare('UPDATE camas SET sector = ? WHERE id = ?').run(s, row.id);
}
} catch(e) {}`
);
fs.writeFileSync('server/db.js', code);
-10
View File
@@ -1,10 +0,0 @@
const fs = require('fs');
let code = fs.readFileSync('server/db.js', 'utf8');
code = code.replace(
/const __dirname = dirname\(fileURLToPath\(import\.meta\.url\)\);/,
`const currentDir = typeof __dirname !== 'undefined' ? __dirname : dirname(fileURLToPath(import.meta.url));`
);
code = code.replace(/__dirname/g, "currentDir");
fs.writeFileSync('server/db.js', code);
-9
View File
@@ -1,9 +0,0 @@
const fs = require('fs');
let code = fs.readFileSync('server/db.js', 'utf8');
code = code.replace(
/const currentDir = typeof currentDir !== 'undefined' \? currentDir : dirname\(fileURLToPath\(import\.meta\.url\)\);/,
`const currentDir = typeof __dirname !== 'undefined' ? __dirname : dirname(fileURLToPath(import.meta.url));`
);
fs.writeFileSync('server/db.js', code);
-40
View File
@@ -1,40 +0,0 @@
const fs = require('fs');
let code = fs.readFileSync('server/index.js', 'utf8');
const computeSectorCode = `
function computeSector(numero) {
if (!numero) return 'En Área';
const parts = String(numero).trim().split('-');
if (parts.length === 0) return 'En Área';
const salaStr = parts[0].trim();
const salaNum = parseInt(salaStr, 10);
if (isNaN(salaNum)) return 'En Área';
if (salaNum >= 300 && salaNum < 400 && salaNum % 2 !== 0) return 'Fuera de Área';
if (salaNum >= 400 && salaNum < 500) return 'Fuera de Área';
return 'En Área';
}
`;
// Insert computeSector somewhere at the top
code = code.replace("const PORT = process.env.PORT || 3000;", computeSectorCode + "\nconst PORT = process.env.PORT || 3000;");
code = code.replace(
/const stmt = db\.prepare\('INSERT OR IGNORE INTO camas \(id, numero, grupoId, areaId, tipo, estado\) VALUES \(\?, \?, \?, \?, \?, \?\)'\);\s*for \(const c of state\.camas\) \{\s*const gId = c\.grupoId \|\| c\.areaId \|\| null;\s*stmt\.run\(c\.id, c\.numero, gId, gId, c\.tipo, c\.estado\);\s*\}/,
`const stmt = db.prepare('INSERT OR IGNORE INTO camas (id, numero, grupoId, areaId, tipo, estado, sector) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const c of state.camas) {
const gId = c.grupoId || c.areaId || null;
const sector = computeSector(c.numero);
stmt.run(c.id, c.numero, gId, gId, c.tipo, c.estado, sector);
}`
);
code = code.replace(
/const stmt = db\.prepare\('INSERT INTO camas \(id, numero, areaId, tipo, estado\) VALUES \(\?, \?, \?, \?, \?\)'\);\s*for \(const c of updates\.camas\) \{\s*stmt\.run\(c\.id, c\.numero, c\.areaId, c\.tipo, c\.estado\);\s*\}/,
`const stmt = db.prepare('INSERT INTO camas (id, numero, grupoId, areaId, tipo, estado, sector) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const c of updates.camas) {
const sector = computeSector(c.numero);
stmt.run(c.id, c.numero, c.grupoId || c.areaId || null, c.grupoId || c.areaId || null, c.tipo, c.estado, sector);
}`
);
fs.writeFileSync('server/index.js', code);
-31
View File
@@ -1,31 +0,0 @@
const fs = require('fs');
let code = fs.readFileSync('server/db.js', 'utf8');
code = code.replace(
/ \);\n try \{\n db\.prepare\("ALTER TABLE camas ADD COLUMN sector TEXT DEFAULT 'En Área'"\)\.run\(\);\n \} catch\(e\) \{\}\n try \{\n \/\/ Initialize existing beds with their sector\n const rows = db\.prepare\('SELECT id, numero FROM camas WHERE sector IS NULL OR sector = "En Área"'\)\.all\(\);\n for \(const row of rows\) \{\n if \(\!row\.numero\) continue;\n const salaStr = String\(row\.numero\)\.trim\(\)\.split\('-'\)\[0\]\.trim\(\);\n const salaNum = parseInt\(salaStr, 10\);\n let s = 'En Área';\n if \(\!isNaN\(salaNum\)\) \{\n if \(salaNum >= 300 && salaNum < 400 && salaNum % 2 !== 0\) s = 'Fuera de Área';\n else if \(salaNum >= 400 && salaNum < 500\) s = 'Fuera de Área';\n \}\n db\.prepare\('UPDATE camas SET sector = \? WHERE id = \?'\)\.run\(s, row\.id\);\n \}\n \} catch\(e\) \{\}/,
` );`
);
code = code.replace(
/\/\/ Seed default 4 grupos if empty/,
`try { db.exec("ALTER TABLE camas ADD COLUMN sector TEXT DEFAULT 'En Área'"); } catch (_) {}
try {
// Initialize existing beds with their sector
const rows = db.prepare('SELECT id, numero FROM camas WHERE sector IS NULL OR sector = "En Área"').all();
for (const row of rows) {
if (!row.numero) continue;
const salaStr = String(row.numero).trim().split('-')[0].trim();
const salaNum = parseInt(salaStr, 10);
let s = 'En Área';
if (!isNaN(salaNum)) {
if (salaNum >= 300 && salaNum < 400 && salaNum % 2 !== 0) s = 'Fuera de Área';
else if (salaNum >= 400 && salaNum < 500) s = 'Fuera de Área';
}
db.prepare('UPDATE camas SET sector = ? WHERE id = ?').run(s, row.id);
}
} catch(e) {}
// Seed default 4 grupos if empty`
);
fs.writeFileSync('server/db.js', code);
-21
View File
@@ -1,21 +0,0 @@
const fs = require('fs');
let code = fs.readFileSync('src/hooks/useHospitalStore.ts', 'utf8');
code = code.replace(/getLaboratoriosByPaciente = useCallback\(\(pacienteId: string\)/g, 'getLaboratoriosByInternacion = useCallback((internacionId: string)');
code = code.replace(/\.filter\(l => l\.pacienteId === pacienteId\)/g, '.filter(l => l.internacionId === internacionId)');
code = code.replace(/getGlucemiasByPaciente = useCallback\(\(pacienteId: string\)/g, 'getGlucemiasByInternacion = useCallback((internacionId: string)');
code = code.replace(/\.filter\(g => g\.pacienteId === pacienteId\)/g, '.filter(g => g.internacionId === internacionId)');
code = code.replace(/getAcidosBaseByPaciente = useCallback\(\(pacienteId: string\)/g, 'getAcidosBaseByInternacion = useCallback((internacionId: string)');
code = code.replace(/\.filter\(a => a\.pacienteId === pacienteId\)/g, '.filter(a => a.internacionId === internacionId)');
code = code.replace(/getCultivosByPaciente = useCallback\(\(pacienteId: string\)/g, 'getCultivosByInternacion = useCallback((internacionId: string)');
code = code.replace(/\.filter\(c => c\.pacienteId === pacienteId\)/g, '.filter(c => c.internacionId === internacionId)');
code = code.replace(/getLaboratoriosByPaciente,/g, 'getLaboratoriosByInternacion,');
code = code.replace(/getGlucemiasByPaciente,/g, 'getGlucemiasByInternacion,');
code = code.replace(/getAcidosBaseByPaciente,/g, 'getAcidosBaseByInternacion,');
code = code.replace(/getCultivosByPaciente,/g, 'getCultivosByInternacion,');
fs.writeFileSync('src/hooks/useHospitalStore.ts', code);
-13
View File
@@ -1,13 +0,0 @@
const fs = require('fs');
let code = fs.readFileSync('src/hooks/useHospitalStore.ts', 'utf8');
code = code.replace(
/try \{\s*await apiCall\('PUT', `\/camas\/\$\{id\}`/,
`try {
if (datos.numero !== undefined) {
datos.sector = computeSector(datos.numero);
}
await apiCall('PUT', \`/camas/\${id}\``
);
fs.writeFileSync('src/hooks/useHospitalStore.ts', code);
-16
View File
@@ -1,16 +0,0 @@
const fs = require('fs');
let code = fs.readFileSync('src/lib/utils.ts', 'utf8');
code = code.replace(
/export function isCamaFueraDeGrupo\([\s\S]*?return false;\n\}/,
`export function isCamaFueraDeGrupo(
cama: { numero: string; grupoId?: string; areaId?: string; sector?: string },
gruposOrAreas?: { id: string; nombre: string }[]
): boolean {
if (cama.sector === 'Fuera de Área') return true;
if (cama.sector === 'En Área') return false;
return computeSector(cama.numero) === 'Fuera de Área';
}`
);
fs.writeFileSync('src/lib/utils.ts', code);
Binary file not shown.
+401 -33
View File
@@ -8,17 +8,55 @@ import { initDb,
getAllInternaciones, getInternacionById, createInternacion, updateInternacion, deleteInternacion,
getAllEvoluciones, createEvolucion, updateEvolucion, deleteEvolucion,
getAllLaboratorios, createLaboratorio, updateLaboratorio, deleteLaboratorio,
getAllOtrosLaboratorios, createOtroLaboratorio, updateOtroLaboratorio, deleteOtroLaboratorio,
getAllGlucemias, createGlucemia, updateGlucemia, deleteGlucemia,
getAllAcidosBase, createAcidoBase, updateAcidoBase, deleteAcidoBase,
getAllCultivos, createCultivo, updateCultivo, deleteCultivo,
getAllTiposCultivo, createTipoCultivo, updateTipoCultivo, deleteTipoCultivo, restablecerTiposCultivo,
getAllGruposLaboratorio, createGrupoLaboratorio, updateGrupoLaboratorio, deleteGrupoLaboratorio, restablecerGruposLaboratorio,
getAllEstudiosComplementarios, createEstudioComplementario, updateEstudioComplementario, deleteEstudioComplementario,
getAllInterconsultas, createInterconsulta, updateInterconsulta, deleteInterconsulta,
getAllAtb, createAtb, updateAtb, deleteAtb,
getAllIndicaciones, createIndicacion, updateIndicacion, deleteIndicacion,
getAllMovimientosIndicaciones, createMovimientoIndicacion,
getValue, setValue
getAllPendientes, createPendiente, updatePendiente, deletePendiente,
getValue, setValue,
getDb, exportAllData, importAllData
} from './db-mongodb.js';
// Capture live logs in-memory
const logBuffer = [];
const originalLog = console.log;
const originalError = console.error;
const originalWarn = console.warn;
function addLog(type, args) {
const message = args.map(arg => {
if (typeof arg === 'object') {
try { return JSON.stringify(arg); } catch { return String(arg); }
}
return String(arg);
}).join(' ');
const logEntry = `[${new Date().toISOString()}] [${type}] ${message}`;
logBuffer.push(logEntry);
if (logBuffer.length > 500) {
logBuffer.shift();
}
}
console.log = (...args) => {
addLog('INFO', args);
originalLog.apply(console, args);
};
console.error = (...args) => {
addLog('ERROR', args);
originalError.apply(console, args);
};
console.warn = (...args) => {
addLog('WARN', args);
originalWarn.apply(console, args);
};
const app = express();
app.use(cors({
origin: true,
@@ -41,29 +79,87 @@ function generateUUID() {
// ========== STATE ENDPOINT (initial load) ==========
app.get('/api/state', async (req, res) => {
try {
const areasList = await getAllAreas();
const usuariosList = await getAllUsuarios();
const [
pacientes,
areasList,
camas,
internaciones,
rawEvoluciones,
rawLaboratorios,
otrosLaboratorios,
glucemias,
acidosBase,
cultivos,
tiposCultivo,
gruposDeterminacionesLab,
estudiosComplementarios,
rawInterconsultas,
atb,
indicaciones,
movimientosIndicaciones,
pendientes,
usuariosList
] = await Promise.all([
getAllPacientes(),
getAllAreas(),
getAllCamas(),
getAllInternaciones(),
getAllEvoluciones(),
getAllLaboratorios(),
getAllOtrosLaboratorios(),
getAllGlucemias(),
getAllAcidosBase(),
getAllCultivos(),
getAllTiposCultivo(),
getAllGruposLaboratorio(),
getAllEstudiosComplementarios(),
getAllInterconsultas(),
getAllAtb(),
getAllIndicaciones(),
getAllMovimientosIndicaciones(),
getAllPendientes(),
getAllUsuarios()
]);
const evoluciones = rawEvoluciones.map(e => ({
...e,
signosVitales: typeof e.signosVitales === 'string' ? JSON.parse(e.signosVitales) : e.signosVitales,
examenFisico: typeof e.examenFisico === 'string' ? JSON.parse(e.examenFisico) : e.examenFisico
}));
const laboratorios = rawLaboratorios.map(l => ({
...l,
resultados: typeof l.resultados === 'string' ? JSON.parse(l.resultados) : l.resultados
}));
const interconsultas = rawInterconsultas.map(ic => ({
...ic,
realizada: !!ic.realizada
}));
const usuarios = usuariosList.map(({ passwordHash, ...u }) => u);
const state = {
pacientes: await getAllPacientes(),
pacientes,
areas: areasList,
grupos: areasList,
camas: await getAllCamas(),
internaciones: await getAllInternaciones(),
evoluciones: (await getAllEvoluciones()).map(e => ({
...e,
signosVitales: typeof e.signosVitales === 'string' ? JSON.parse(e.signosVitales) : e.signosVitales,
examenFisico: typeof e.examenFisico === 'string' ? JSON.parse(e.examenFisico) : e.examenFisico
})),
laboratorios: (await getAllLaboratorios()).map(l => ({ ...l, resultados: typeof l.resultados === 'string' ? JSON.parse(l.resultados) : l.resultados })),
glucemias: await getAllGlucemias(),
acidosBase: await getAllAcidosBase(),
cultivos: await getAllCultivos(),
estudiosComplementarios: await getAllEstudiosComplementarios(),
interconsultas: (await getAllInterconsultas()).map(ic => ({ ...ic, realizada: !!ic.realizada })),
atb: await getAllAtb(),
indicaciones: await getAllIndicaciones(),
movimientosIndicaciones: await getAllMovimientosIndicaciones(),
usuarios: usuariosList.map(({ passwordHash, ...u }) => u),
camas,
internaciones,
evoluciones,
laboratorios,
otrosLaboratorios,
glucemias,
acidosBase,
cultivos,
tiposCultivo,
gruposDeterminacionesLab,
estudiosComplementarios,
interconsultas,
atb,
indicaciones,
movimientosIndicaciones,
pendientes,
usuarios,
vistaActual: 'dashboard',
currentInternacionId: null
};
@@ -162,7 +258,7 @@ app.get('/api/pacientes', async (req, res) => {
app.post('/api/pacientes', async (req, res) => {
try {
const paciente = { ...req.body, id: generateUUID() };
const paciente = { ...req.body, id: req.body.id || generateUUID() };
await createPaciente(paciente);
res.json(paciente);
} catch (err) {
@@ -276,7 +372,7 @@ app.get('/api/internaciones', async (req, res) => {
app.post('/api/internaciones', async (req, res) => {
try {
const internacion = { ...req.body, id: generateUUID(), activa: true };
const internacion = { ...req.body, id: req.body.id || generateUUID(), activa: req.body.activa !== undefined ? req.body.activa : true };
await createInternacion(internacion);
res.json(internacion);
} catch (err) {
@@ -313,7 +409,7 @@ app.get('/api/evoluciones', async (req, res) => {
app.post('/api/evoluciones', async (req, res) => {
try {
const evolucion = { ...req.body, id: generateUUID() };
const evolucion = { ...req.body, id: req.body.id || generateUUID() };
await createEvolucion(evolucion);
res.json(evolucion);
} catch (err) {
@@ -350,7 +446,7 @@ app.get('/api/laboratorios', async (req, res) => {
app.post('/api/laboratorios', async (req, res) => {
try {
const laboratorio = { ...req.body, id: generateUUID() };
const laboratorio = { ...req.body, id: req.body.id || generateUUID() };
await createLaboratorio(laboratorio);
res.json(laboratorio);
} catch (err) {
@@ -376,6 +472,61 @@ app.delete('/api/laboratorios/:id', async (req, res) => {
}
});
// ========== OTROS LABORATORIOS ==========
app.get('/api/otros-laboratorios', async (req, res) => {
try {
const records = await getAllOtrosLaboratorios();
res.json(records);
} catch (error) {
console.error('Error fetching otros-laboratorios:', error);
res.status(500).json({ error: 'Failed to fetch otros-laboratorios' });
}
});
app.post('/api/otros-laboratorios', async (req, res) => {
try {
const { pacienteId, fecha } = req.body;
if (!pacienteId || !fecha) {
return res.status(400).json({ error: 'pacienteId and fecha are required' });
}
const record = {
id: generateUUID(),
observaciones: '',
...req.body
};
const newRecord = await createOtroLaboratorio(record);
res.status(201).json(newRecord);
} catch (error) {
console.error('Error creating otro laboratorio:', error);
res.status(500).json({ error: 'Failed to create otro laboratorio' });
}
});
app.put('/api/otros-laboratorios/:id', async (req, res) => {
try {
const result = await updateOtroLaboratorio(req.params.id, req.body);
if (!result.success) return res.status(404).json({ error: 'Record not found' });
res.json(result);
} catch (error) {
console.error('Error updating otro laboratorio:', error);
res.status(500).json({ error: 'Failed to update otro laboratorio' });
}
});
app.delete('/api/otros-laboratorios/:id', async (req, res) => {
try {
const result = await deleteOtroLaboratorio(req.params.id);
if (!result.success) return res.status(404).json({ error: 'Record not found' });
res.json({ message: 'Deleted successfully' });
} catch (error) {
console.error('Error deleting otro laboratorio:', error);
res.status(500).json({ error: 'Failed to delete otro laboratorio' });
}
});
// ========== GLUCEMIAS ==========
app.get('/api/glucemias', async (req, res) => {
try {
@@ -387,7 +538,7 @@ app.get('/api/glucemias', async (req, res) => {
app.post('/api/glucemias', async (req, res) => {
try {
const glucemia = { ...req.body, id: generateUUID() };
const glucemia = { ...req.body, id: req.body.id || generateUUID() };
await createGlucemia(glucemia);
res.json(glucemia);
} catch (err) {
@@ -424,7 +575,7 @@ app.get('/api/acid-os-base', async (req, res) => {
app.post('/api/acid-os-base', async (req, res) => {
try {
const acido = { ...req.body, id: generateUUID() };
const acido = { ...req.body, id: req.body.id || generateUUID() };
await createAcidoBase(acido);
res.json(acido);
} catch (err) {
@@ -461,7 +612,7 @@ app.get('/api/cultivos', async (req, res) => {
app.post('/api/cultivos', async (req, res) => {
try {
const cultivo = { ...req.body, id: generateUUID() };
const cultivo = { ...req.body, id: req.body.id || generateUUID() };
await createCultivo(cultivo);
res.json(cultivo);
} catch (err) {
@@ -487,6 +638,96 @@ app.delete('/api/cultivos/:id', async (req, res) => {
}
});
// ========== TIPOS DE CULTIVO ==========
app.get('/api/tipos-cultivo', async (req, res) => {
try {
res.json(await getAllTiposCultivo());
} catch (err) {
res.status(500).json({ error: 'Error al obtener tipos de cultivo' });
}
});
app.post('/api/tipos-cultivo', async (req, res) => {
try {
const nuevo = await createTipoCultivo(req.body);
res.json(nuevo);
} catch (err) {
res.status(400).json({ error: err.message || 'Error al crear tipo de cultivo' });
}
});
app.put('/api/tipos-cultivo/:id', async (req, res) => {
try {
const updated = await updateTipoCultivo(req.params.id, req.body);
res.json(updated);
} catch (err) {
res.status(400).json({ error: err.message || 'Error al actualizar tipo de cultivo' });
}
});
app.delete('/api/tipos-cultivo/:id', async (req, res) => {
try {
await deleteTipoCultivo(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar tipo de cultivo' });
}
});
app.post('/api/tipos-cultivo/reset', async (req, res) => {
try {
const list = await restablecerTiposCultivo();
res.json(list);
} catch (err) {
res.status(500).json({ error: 'Error al restablecer tipos de cultivo' });
}
});
// ========== 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 ==========
app.get('/api/estudios-complementarios', async (req, res) => {
try {
@@ -498,7 +739,7 @@ app.get('/api/estudios-complementarios', async (req, res) => {
app.post('/api/estudios-complementarios', async (req, res) => {
try {
const estudio = { ...req.body, id: generateUUID() };
const estudio = { ...req.body, id: req.body.id || generateUUID() };
await createEstudioComplementario(estudio);
res.json(estudio);
} catch (err) {
@@ -535,7 +776,7 @@ app.get('/api/interconsultas', async (req, res) => {
app.post('/api/interconsultas', async (req, res) => {
try {
const interconsulta = { ...req.body, id: generateUUID() };
const interconsulta = { ...req.body, id: req.body.id || generateUUID() };
await createInterconsulta(interconsulta);
res.json(interconsulta);
} catch (err) {
@@ -572,7 +813,7 @@ app.get('/api/atb', async (req, res) => {
app.post('/api/atb', async (req, res) => {
try {
const atb = { ...req.body, id: generateUUID() };
const atb = { ...req.body, id: req.body.id || generateUUID() };
await createAtb(atb);
res.json(atb);
} catch (err) {
@@ -609,7 +850,7 @@ app.get('/api/indicaciones', async (req, res) => {
app.post('/api/indicaciones', async (req, res) => {
try {
const indicacion = { ...req.body, id: generateUUID() };
const indicacion = { ...req.body, id: req.body.id || generateUUID() };
await createIndicacion(indicacion);
res.json(indicacion);
} catch (err) {
@@ -646,7 +887,7 @@ app.get('/api/movimientos-indicaciones', async (req, res) => {
app.post('/api/movimientos-indicaciones', async (req, res) => {
try {
const movimiento = { ...req.body, id: generateUUID() };
const movimiento = { ...req.body, id: req.body.id || generateUUID() };
console.log('Creating movimiento:', movimiento);
await createMovimientoIndicacion(movimiento);
res.json(movimiento);
@@ -656,6 +897,43 @@ app.post('/api/movimientos-indicaciones', async (req, res) => {
}
});
// ========== PENDIENTES ==========
app.get('/api/pendientes', async (req, res) => {
try {
res.json(await getAllPendientes());
} catch (err) {
res.status(500).json({ error: 'Error al obtener pendientes' });
}
});
app.post('/api/pendientes', async (req, res) => {
try {
const pendiente = { ...req.body, id: req.body.id || generateUUID() };
await createPendiente(pendiente);
res.json(pendiente);
} catch (err) {
res.status(500).json({ error: 'Error al crear pendiente' });
}
});
app.put('/api/pendientes/:id', async (req, res) => {
try {
await updatePendiente(req.params.id, req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al actualizar pendiente' });
}
});
app.delete('/api/pendientes/:id', async (req, res) => {
try {
await deletePendiente(req.params.id);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al eliminar pendiente' });
}
});
// ========== AUTH ==========
app.post('/api/auth/login', async (req, res) => {
try {
@@ -721,6 +999,96 @@ app.put('/api/auth/update-email', async (req, res) => {
}
});
// ========== ADMIN SYSTEM ENDPOINTS ==========
app.post('/api/admin/mongosh', async (req, res) => {
try {
const { command } = req.body;
if (!command) {
return res.status(400).json({ error: 'Comando requerido' });
}
const dbInstance = getDb();
if (!dbInstance) {
return res.json({ result: 'Advertencia: MongoDB no está conectado. Utilizando store en memoria. Las consultas de mongosh no están disponibles.', isMemStore: true });
}
const dbProxy = new Proxy(dbInstance, {
get(target, prop) {
if (typeof target[prop] !== 'undefined') {
if (typeof target[prop] === 'function') {
return target[prop].bind(target);
}
return target[prop];
}
return target.collection(prop);
}
});
const { ObjectId } = await import('mongodb');
const evalFn = new Function('db', 'ObjectId', `
return (async () => {
${command.trim().includes('return') || command.trim().startsWith('{') || command.trim().includes(';') ? command : 'return (' + command + ')'}
})();
`);
let finalResult = await evalFn(dbProxy, ObjectId);
// Si el resultado es un cursor de MongoDB (ej. db.collection.find()), lo convertimos a array
if (finalResult && typeof finalResult === 'object' && typeof finalResult.toArray === 'function') {
finalResult = await finalResult.toArray();
}
res.json({ result: finalResult });
} catch (err) {
res.status(400).json({ error: err.message || String(err) });
}
});
app.get('/api/admin/db/export', async (req, res) => {
try {
const dump = await exportAllData();
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', 'attachment; filename=hospital_backup.json');
res.json(dump);
} catch (err) {
res.status(500).json({ error: 'Error al exportar base de datos: ' + err.message });
}
});
app.post('/api/admin/db/import', async (req, res) => {
try {
const dump = req.body;
if (!dump || typeof dump !== 'object') {
return res.status(400).json({ error: 'Formato de importación inválido' });
}
await importAllData(dump);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: 'Error al importar base de datos: ' + err.message });
}
});
app.get('/api/admin/logs', async (req, res) => {
try {
let fileLogs = '';
try {
const fs = await import('fs/promises');
fileLogs = await fs.readFile('server_log.txt', 'utf8');
} catch {
// ignore
}
const memoryLogs = logBuffer.join('\n');
const combined = [fileLogs, '\n--- LOGS DE LA SESION EN VIVO ---', memoryLogs].filter(Boolean).join('\n');
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(combined);
} catch (err) {
res.status(500).send('Error al leer logs: ' + err.message);
}
});
// Initialize DB and start server
const PORT = process.env.PORT || 4000;
const HOST = process.env.HOST || '0.0.0.0';
+587 -3
View File
@@ -1,4 +1,4 @@
import { MongoClient } from 'mongodb';
import { MongoClient, ObjectId } from 'mongodb';
import bcrypt from 'bcryptjs';
const { compareSync, hashSync } = bcrypt;
@@ -56,6 +56,7 @@ const memStore = {
internaciones: [],
evoluciones: [],
laboratorios: [],
otrosLaboratorios: [],
glucemias: [],
acidosbase: [],
cultivos: [],
@@ -64,6 +65,9 @@ const memStore = {
atb: [],
indicaciones: [],
movimientos_indicaciones: [],
pendientes: [],
tipos_cultivo: [],
grupos_laboratorio: [],
kv: {}
};
@@ -102,6 +106,9 @@ export async function initDb() {
function cleanDoc(doc) {
if (!doc) return null;
const { _id, ...rest } = doc;
if (!rest.id && _id) {
rest.id = _id.toString();
}
return rest;
}
@@ -352,12 +359,21 @@ export async function getAllInternaciones() {
}
export async function getInternacionById(id) {
if (!id) return null;
const idStr = String(id).trim();
if (db) {
const internacion = await db.collection('internaciones').findOne({ id });
let internacion = await db.collection('internaciones').findOne({ id: idStr });
if (!internacion && ObjectId.isValid(idStr)) {
try {
internacion = await db.collection('internaciones').findOne({ _id: new ObjectId(idStr) });
} catch {
// ignore invalid objectid
}
}
if (internacion) internacion.activa = !!internacion.activa;
return cleanDoc(internacion);
}
const internacion = memStore.internaciones.find(i => i.id === id);
const internacion = memStore.internaciones.find(i => i.id === idStr || String(i.id).trim() === idStr);
if (!internacion) return null;
return { ...internacion, activa: !!internacion.activa };
}
@@ -379,6 +395,7 @@ export async function createInternacion(internacion) {
diagnosticoEgreso: internacion.diagnosticoEgreso || null,
medicoIngresante: internacion.medicoIngresante || null,
motivoEgreso: internacion.motivoEgreso || null,
servicioAlQuePasa: internacion.servicioAlQuePasa || null,
activa: internacion.activa ? 1 : 0,
apache: internacion.apache || null,
derivacion: internacion.derivacion || null
@@ -688,6 +705,412 @@ export async function deleteCultivo(id) {
}
}
// ========== TIPOS DE CULTIVO ==========
export const DEFAULT_TIPOS_CULTIVO = [
{ id: '1', nombre: 'HMCx2', categoria: 'Hemocultivos', descripcion: 'Hemocultivos seriados x2' },
{ id: '2', nombre: 'RC', categoria: 'Catéter', descripcion: 'Retro cultivo / punta de catéter' },
{ id: '3', nombre: 'PC', categoria: 'Punción', descripcion: 'Punción cultivo' },
{ id: '4', nombre: 'UC', categoria: 'Urocultivo', descripcion: 'Urocultivo / muestra de orina' },
{ id: '5', nombre: 'LCR', categoria: 'Líquidos', descripcion: 'Líquido cefalorraquídeo' },
{ id: '6', nombre: 'LP', categoria: 'Líquidos', descripcion: 'Líquido pleural' },
{ id: '7', nombre: 'LAsc', categoria: 'Líquidos', descripcion: 'Líquido ascítico' },
{ id: '8', nombre: 'LAbd', categoria: 'Líquidos', descripcion: 'Líquido abdominal' },
{ id: '9', nombre: 'Coleccion', categoria: 'Líquidos y Colecciones', descripcion: 'Muestra de colección / absceso' },
{ id: '10', nombre: 'Partes Blandas', categoria: 'Tejidos', descripcion: 'Cultivo de partes blandas / tejido' },
{ id: '11', nombre: 'Esputo GC', categoria: 'Respiratorio', descripcion: 'Esputo Germen Común' },
{ id: '12', nombre: 'Esputo TBC', categoria: 'Respiratorio', descripcion: 'Esputo Tuberculosis' },
{ id: '13', nombre: 'Baciloscopia', categoria: 'Respiratorio', descripcion: 'Baciloscopia directa' },
{ id: '14', nombre: 'HNF Test Rápido', categoria: 'Hisopado Nasofaríngeo', descripcion: 'Hisopado nasofaríngeo test rápido' },
{ id: '15', nombre: 'HNF Panel PCR', categoria: 'Hisopado Nasofaríngeo', descripcion: 'Hisopado nasofaríngeo panel PCR virológico' },
{ id: '16', nombre: 'Hisopado Rectal KPC', categoria: 'Vigilancia Epidemiológica', descripcion: 'Hisopado rectal para screening de KPC/BLEE' }
];
export async function getAllTiposCultivo() {
let list = [];
if (db) {
const raw = await db.collection('tipos_cultivo').find().toArray();
list = cleanDocs(raw);
if (list.length === 0) {
// Seed default
for (const item of DEFAULT_TIPOS_CULTIVO) {
await db.collection('tipos_cultivo').insertOne({ ...item });
}
list = [...DEFAULT_TIPOS_CULTIVO];
}
// Also read any existing custom tipoMuestra from registered cultivos that might not be in the list
const allCultivos = await db.collection('cultivos').find().toArray();
const existingNombres = new Set(list.map(t => (t.nombre || '').trim().toLowerCase()));
for (const c of allCultivos) {
if (c.tipoMuestra && c.tipoMuestra.trim() && !existingNombres.has(c.tipoMuestra.trim().toLowerCase())) {
const nuevoTipo = {
id: generateUUID(),
nombre: c.tipoMuestra.trim(),
categoria: 'Personalizado',
descripcion: 'Importado automáticamente desde registro existente de cultivo'
};
await db.collection('tipos_cultivo').insertOne(nuevoTipo);
list.push(nuevoTipo);
existingNombres.add(c.tipoMuestra.trim().toLowerCase());
}
}
return list;
} else {
if (!memStore.tipos_cultivo || memStore.tipos_cultivo.length === 0) {
memStore.tipos_cultivo = DEFAULT_TIPOS_CULTIVO.map(item => ({ ...item }));
}
list = [...memStore.tipos_cultivo];
const existingNombres = new Set(list.map(t => (t.nombre || '').trim().toLowerCase()));
for (const c of (memStore.cultivos || [])) {
if (c.tipoMuestra && c.tipoMuestra.trim() && !existingNombres.has(c.tipoMuestra.trim().toLowerCase())) {
const nuevoTipo = {
id: generateUUID(),
nombre: c.tipoMuestra.trim(),
categoria: 'Personalizado',
descripcion: 'Importado automáticamente desde registro existente de cultivo'
};
memStore.tipos_cultivo.push(nuevoTipo);
list.push(nuevoTipo);
existingNombres.add(c.tipoMuestra.trim().toLowerCase());
}
}
return list;
}
}
export async function createTipoCultivo(tipo) {
const doc = {
id: tipo.id || generateUUID(),
nombre: (tipo.nombre || '').trim(),
categoria: (tipo.categoria || 'General').trim(),
descripcion: (tipo.descripcion || '').trim()
};
if (!doc.nombre) {
throw new Error('El nombre del tipo de cultivo es requerido');
}
if (db) {
const existing = await db.collection('tipos_cultivo').findOne({ nombre: doc.nombre });
if (existing) {
throw new Error(`Ya existe un tipo de cultivo con el nombre "${doc.nombre}"`);
}
await db.collection('tipos_cultivo').insertOne(doc);
} else {
if (!memStore.tipos_cultivo) memStore.tipos_cultivo = [];
const exists = memStore.tipos_cultivo.some(t => t.nombre.toLowerCase() === doc.nombre.toLowerCase());
if (exists) {
throw new Error(`Ya existe un tipo de cultivo con el nombre "${doc.nombre}"`);
}
memStore.tipos_cultivo.push(doc);
}
return doc;
}
export async function updateTipoCultivo(id, datos) {
const updateDoc = {};
if (datos.nombre !== undefined) updateDoc.nombre = datos.nombre.trim();
if (datos.categoria !== undefined) updateDoc.categoria = datos.categoria.trim();
if (datos.descripcion !== undefined) updateDoc.descripcion = datos.descripcion.trim();
if (db) {
if (updateDoc.nombre) {
const existing = await db.collection('tipos_cultivo').findOne({ nombre: updateDoc.nombre, id: { $ne: id } });
if (existing) {
throw new Error(`Ya existe otro tipo de cultivo con el nombre "${updateDoc.nombre}"`);
}
}
await db.collection('tipos_cultivo').updateOne({ id }, { $set: updateDoc });
} else {
if (!memStore.tipos_cultivo) memStore.tipos_cultivo = [];
if (updateDoc.nombre) {
const exists = memStore.tipos_cultivo.some(t => t.id !== id && t.nombre.toLowerCase() === updateDoc.nombre.toLowerCase());
if (exists) {
throw new Error(`Ya existe otro tipo de cultivo con el nombre "${updateDoc.nombre}"`);
}
}
const idx = memStore.tipos_cultivo.findIndex(t => t.id === id);
if (idx !== -1) {
memStore.tipos_cultivo[idx] = { ...memStore.tipos_cultivo[idx], ...updateDoc };
}
}
return { id, ...datos };
}
export async function deleteTipoCultivo(id) {
if (db) {
await db.collection('tipos_cultivo').deleteOne({ id });
} else {
if (memStore.tipos_cultivo) {
memStore.tipos_cultivo = memStore.tipos_cultivo.filter(t => t.id !== id);
}
}
return { success: true };
}
export async function restablecerTiposCultivo() {
if (db) {
await db.collection('tipos_cultivo').deleteMany({});
for (const item of DEFAULT_TIPOS_CULTIVO) {
await db.collection('tipos_cultivo').insertOne({ ...item });
}
return await getAllTiposCultivo();
} else {
memStore.tipos_cultivo = DEFAULT_TIPOS_CULTIVO.map(item => ({ ...item }));
return [...memStore.tipos_cultivo];
}
}
// ========== 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 ==========
export async function getAllEstudiosComplementarios() {
if (db) {
@@ -923,6 +1346,42 @@ export async function createMovimientoIndicacion(mov) {
}
}
// ========== PENDIENTES ==========
export async function getAllPendientes() {
if (db) {
const pendientes = await db.collection('pendientes').find().toArray();
return cleanDocs(pendientes);
}
return [...memStore.pendientes];
}
export async function createPendiente(pendiente) {
if (db) {
await db.collection('pendientes').insertOne(pendiente);
} else {
memStore.pendientes.push(pendiente);
}
}
export async function updatePendiente(id, datos) {
if (db) {
await db.collection('pendientes').updateOne({ id }, { $set: datos });
} else {
const idx = memStore.pendientes.findIndex(p => p.id === id);
if (idx !== -1) {
memStore.pendientes[idx] = { ...memStore.pendientes[idx], ...datos };
}
}
}
export async function deletePendiente(id) {
if (db) {
await db.collection('pendientes').deleteOne({ id });
} else {
memStore.pendientes = memStore.pendientes.filter(p => p.id !== id);
}
}
// ========== KV STORE ==========
export async function getValue(key) {
if (db) {
@@ -943,3 +1402,128 @@ export async function setValue(key, value) {
memStore.kv[key] = value;
}
}
export function getDb() {
return db;
}
export async function exportAllData() {
const collections = [
'usuarios', 'pacientes', 'areas', 'camas', 'internaciones',
'evoluciones', 'laboratorios', 'otrosLaboratorios', 'glucemias', 'acidosbase',
'cultivos', 'tipos_cultivo', 'grupos_laboratorio', 'estudiosComplementarios', 'interconsultas', 'atb',
'indicaciones', 'movimientos_indicaciones', 'pendientes', 'kv'
];
const dump = {};
if (db) {
for (const name of collections) {
const docs = await db.collection(name).find().toArray();
dump[name] = docs;
}
} else {
for (const name of collections) {
dump[name] = [...(memStore[name] || [])];
}
}
return dump;
}
export async function importAllData(dump) {
const collections = [
'usuarios', 'pacientes', 'areas', 'camas', 'internaciones',
'evoluciones', 'laboratorios', 'otrosLaboratorios', 'glucemias', 'acidosbase',
'cultivos', 'tipos_cultivo', 'grupos_laboratorio', 'estudiosComplementarios', 'interconsultas', 'atb',
'indicaciones', 'movimientos_indicaciones', 'pendientes', 'kv'
];
if (db) {
for (const name of collections) {
if (Array.isArray(dump[name])) {
try {
await db.collection(name).deleteMany({});
} catch (e) {
console.warn(`Could not clear collection ${name}:`, e);
}
if (dump[name].length > 0) {
const cleaned = dump[name].map(doc => {
const copy = { ...doc };
if (copy._id) {
if (ObjectId.isValid(copy._id)) {
copy._id = new ObjectId(copy._id);
} else {
delete copy._id;
}
}
return copy;
});
await db.collection(name).insertMany(cleaned);
}
}
}
} else {
for (const name of collections) {
if (Array.isArray(dump[name])) {
memStore[name] = [...dump[name]];
}
}
}
}
export async function getAllOtrosLaboratorios() {
if (db) {
const records = await db.collection('otros-laboratorios').find().toArray();
return cleanDocs(records);
}
return [...memStore.otrosLaboratorios];
}
export async function createOtroLaboratorio(record) {
const doc = {
id: record.id,
pacienteId: record.pacienteId,
internacionId: record.internacionId,
fecha: record.fecha,
hora: record.hora,
observaciones: record.observaciones,
createdAt: new Date().toISOString()
};
if (db) {
await db.collection('otros-laboratorios').insertOne(doc);
return cleanDoc(doc);
}
memStore.otrosLaboratorios.push(doc);
return doc;
}
export async function updateOtroLaboratorio(id, updates) {
if (db) {
await db.collection('otros-laboratorios').updateOne(
{ id },
{ $set: { ...updates, updatedAt: new Date().toISOString() } }
);
return { success: true };
}
const idx = memStore.otrosLaboratorios.findIndex(x => x.id === id);
if (idx !== -1) {
memStore.otrosLaboratorios[idx] = { ...memStore.otrosLaboratorios[idx], ...updates, updatedAt: new Date().toISOString() };
return { success: true };
}
return { success: false };
}
export async function deleteOtroLaboratorio(id) {
if (db) {
await db.collection('otros-laboratorios').deleteOne({ id });
return { success: true };
}
const idx = memStore.otrosLaboratorios.findIndex(x => x.id === id);
if (idx !== -1) {
memStore.otrosLaboratorios.splice(idx, 1);
return { success: true };
}
return { success: false };
}
+73 -12
View File
@@ -13,6 +13,8 @@ import { NuevoIngreso } from '@/sections/NuevoIngreso';
import { EditIngreso } from '@/sections/EditIngreso';
import { Login } from '@/sections/Login';
import { GestionUsuarios } from '@/sections/GestionUsuarios';
import { PendientesSala } from '@/sections/PendientesSala';
import { SeccionSistema } from '@/sections/SeccionSistema';
import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button';
import { ThemeProvider } from "@/components/theme-provider";
@@ -91,6 +93,22 @@ function AppContent() {
getCamaById={store.getCamaById}
onVerHC={(id) => { store.setCurrentInternacion(id); store.setVista('historiaclinica'); }}
onNuevoIngreso={() => store.setVista('nuevoingreso')}
onVerPendientesDeSala={() => store.setVista('pendientessala')}
/>
);
case 'pendientessala':
return (
<PendientesSala
pendientes={store.pendientes}
internaciones={store.internaciones}
pacientes={store.pacientes}
camas={store.camas}
onActualizarPendiente={store.actualizarPendiente}
onEliminarPendiente={store.eliminarPendiente}
onVerHC={(id) => { store.setCurrentInternacion(id); store.setVista('historiaclinica'); }}
onVolver={() => store.setVista('internaciones')}
getPacienteById={store.getPacienteById}
getCamaById={store.getCamaById}
/>
);
case 'evoluciones':
@@ -125,21 +143,36 @@ function AppContent() {
pacientes={store.pacientes}
internaciones={store.internaciones}
camas={store.camas}
onAgregarCultivo={store.agregarCultivo}
onActualizarCultivo={store.actualizarCultivo}
onEliminarCultivo={store.eliminarCultivo}
getPacienteById={store.getPacienteById}
getInternacionActivaByPaciente={store.getInternacionActivaByPaciente}
canEdit={true}
canEdit={false}
/>
);
case 'historiaclinica': {
const internacionId = store.currentInternacionId || '';
const internacionId = store.currentInternacionId || sessionStorage.getItem('hospital_current_internacion_id') || '';
const internacion = store.getInternacionById(internacionId);
const paciente = internacion ? store.getPacienteById(internacion.pacienteId) : undefined;
if (!internacion || !paciente) {
const paciente = internacion
? (store.getPacienteById(internacion.pacienteId) || store.pacientes.find(p => p.id === internacion.pacienteId || p.dni === internacion.pacienteId) || {
id: internacion.pacienteId || 'paciente-unknown',
apellido: 'Paciente',
nombre: 'Sin registrar',
dni: internacion.pacienteId || 'N/A',
fechaNacimiento: '1990-01-01',
sexo: 'Otro' as const,
fechaRegistro: new Date().toISOString()
})
: undefined;
if (!internacion) {
if (!store.isLoaded) {
return (
<div className="p-8 text-center text-gray-500">
<p>Cargando información de la internación...</p>
</div>
);
}
return (
<div className="p-4 text-center">
<div className="p-4 text-center space-y-4">
<p className="text-gray-500 dark:text-gray-400">Internación no encontrada (id: {internacionId})</p>
<Button onClick={() => store.setVista('internaciones')}>Volver a Internaciones</Button>
</div>
@@ -218,12 +251,30 @@ function AppContent() {
);
case 'editaringreso': {
const internacionId = store.currentInternacionId || '';
const internacionId = store.currentInternacionId || sessionStorage.getItem('hospital_current_internacion_id') || '';
const internacion = store.getInternacionById(internacionId);
const paciente = internacion ? store.getPacienteById(internacion.pacienteId) : undefined;
if (!internacion || !paciente) {
const paciente = internacion
? (store.getPacienteById(internacion.pacienteId) || store.pacientes.find(p => p.id === internacion.pacienteId || p.dni === internacion.pacienteId) || {
id: internacion.pacienteId || 'paciente-unknown',
apellido: 'Paciente',
nombre: 'Sin registrar',
dni: internacion.pacienteId || 'N/A',
fechaNacimiento: '1990-01-01',
sexo: 'Otro' as const,
fechaRegistro: new Date().toISOString()
})
: undefined;
if (!internacion) {
if (!store.isLoaded) {
return (
<div className="p-8 text-center text-gray-500">
<p>Cargando información de la internación...</p>
</div>
);
}
return (
<div className="p-4 text-center">
<div className="p-4 text-center space-y-4">
<p className="text-gray-500 dark:text-gray-400">Internación no encontrada (id: {internacionId})</p>
<Button onClick={() => store.setVista('internaciones')}>Volver a Internaciones</Button>
</div>
@@ -256,6 +307,16 @@ function AppContent() {
return (
<GestionUsuarios />
);
case 'sistema':
if (store.currentUser?.rol !== 'admin') {
return (
<div className="p-8 text-center text-red-500 font-semibold">
<p>No tiene permisos para acceder a esta sección.</p>
<Button className="mt-4" onClick={() => store.setVista('dashboard')}>Volver al Dashboard</Button>
</div>
);
}
return <SeccionSistema />;
default:
return null;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

+46
View File
@@ -0,0 +1,46 @@
import React from 'react';
export function EsculapioIcon({ className = "w-16 h-16 text-neutral-900 dark:text-neutral-100", size }: { className?: string; size?: number }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
width={size || undefined}
height={size || undefined}
className={className}
fill="currentColor"
aria-label="Vara de Esculapio"
>
<g transform="translate(0, -2)">
{/* Pomo esférico superior */}
<circle cx="50.5" cy="14" r="6.8" />
{/* Cuello del pomo */}
<path d="M47.8 19.8 h5.4 v2.8 h-5.4 z" />
{/* Bastón recto vertical terminado en punta cónica */}
<path d="M49 22.6 h3 l-.3 65.4 -1.2 3.2 -1.2-3.2 z" />
{/* Serpiente: Cabeza y primer bucle superior */}
<path
fillRule="evenodd"
clipRule="evenodd"
d="M37.5 24.8 c1.2-1.6 3.6-2.6 6.8-2.3 2.6.2 5.4 1.3 8.2 2.5 3.1 1.3 6.2 2.5 9.2 1.8 3.8-.9 6.2-4.2 5.2-7.8-.3-1.1-.9-2-1.8-2.8 l3.8-2.4 c1.5 1.4 2.5 3.1 3 5.2 1.4 5.8-1.8 11.2-7.8 12.8-4.5 1.2-8.8-.6-12.2-2-2.7-1.1-4.8-1.8-6.6-1.8-2.2 0-3.9 1-5 2.5 z
M43.8 24.2 a1 1 0 1 0 0-2 1 1 0 0 0 0 2 z"
/>
{/* Curva 1: Cruce superior de derecha a izquierda */}
<path d="M64 30.5 c-3.2 5-8 8.8-13.8 10.3-5.8 1.5-11.8.2-16.5-3.4 l-3.5 4 c6.1 4.7 13.8 6.3 21.4 4.3 7.5-2 13.6-7 17.6-13.6 z" />
{/* Curva 2: Cruce medio de izquierda a derecha */}
<path d="M32.5 41.2 c-3.2 5.5-3 12.2-.2 17.8 2.8 5.8 8.2 9.8 14.5 10.6 5.2.7 10.5-1.1 14.5-4.6 l-3.2-4.1 c-3.2 2.8-7.5 4.2-11.8 3.6-5-.7-9.2-3.8-11.5-8.4-2.2-4.4-2.3-9.7.2-14.1 z" />
{/* Curva 3: Cruce inferior de derecha a izquierda */}
<path d="M63 64.8 c-2.8 4.8-7.2 8.4-12.8 9.8-5.4 1.4-11.2.2-15.8-3 l-3.2 4.2 c6 4.2 13.5 5.7 20.6 3.8 7.2-1.8 13-6.6 16.8-13 z" />
{/* Curva 4: Cola que se enrosca en la punta del bastón */}
<path d="M33 74.8 c-2.2 3.8-2 8.5-.2 12.2 1.5 3.2 4.2 5.5 7.6 6.2 3.5.7 7.2-.2 10-2.2 l-2.8-4.2 c-2 1.4-4.6 2-7.1 1.5-2.4-.5-4.4-2.1-5.4-4.4-1.2-2.6-1.4-5.8 0-8.4 z" />
</g>
</svg>
);
}
File diff suppressed because it is too large Load Diff
+17 -12
View File
@@ -6,28 +6,33 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-150 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 focus-visible:ring-offset-1 select-none",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default:
"bg-gradient-to-b from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 active:from-blue-700 active:to-blue-800 text-white shadow-xs shadow-blue-500/25 border border-blue-600/40 dark:from-blue-600 dark:to-blue-700 dark:hover:from-blue-500 dark:hover:to-blue-600 dark:border-blue-500/50",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-rose-600 text-white hover:bg-rose-700 active:bg-rose-800 shadow-xs shadow-rose-600/20 border border-rose-600/30 focus-visible:ring-rose-500/30 dark:bg-rose-600/90 dark:hover:bg-rose-600",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"border border-blue-200/90 bg-white/90 text-blue-900 hover:bg-blue-50/90 hover:text-blue-700 hover:border-blue-300 dark:border-blue-800/80 dark:bg-gray-900/80 dark:text-blue-100 dark:hover:bg-blue-950/70 dark:hover:border-blue-700 shadow-2xs",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
"bg-blue-50 text-blue-700 hover:bg-blue-100 active:bg-blue-200/80 border border-blue-200/70 dark:bg-blue-950/60 dark:text-blue-200 dark:border-blue-800/80 dark:hover:bg-blue-900/60 shadow-2xs",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
"text-slate-700 dark:text-slate-200 hover:bg-blue-50 hover:text-blue-700 dark:hover:bg-blue-950/50 dark:hover:text-blue-300",
link: "text-blue-600 underline-offset-4 hover:underline hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300",
gradient:
"bg-gradient-to-r from-blue-600 via-indigo-600 to-blue-700 hover:from-blue-700 hover:via-indigo-700 hover:to-blue-800 text-white shadow-sm shadow-blue-600/30 border border-blue-400/30",
soft:
"bg-blue-50/80 text-blue-700 hover:bg-blue-100 border border-transparent hover:border-blue-200 dark:bg-blue-950/40 dark:text-blue-300 dark:hover:bg-blue-900/50",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
sm: "h-8 rounded-md gap-1.5 px-3 text-xs has-[>svg]:px-2.5",
lg: "h-10 rounded-lg px-6 text-base has-[>svg]:px-4",
icon: "size-9 rounded-lg",
"icon-sm": "size-8 rounded-md",
"icon-lg": "size-10 rounded-lg",
},
},
defaultVariants: {
+1 -1
View File
@@ -22,7 +22,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
return () => window.removeEventListener("resize", checkMobile)
}, [])
const effectivePosition = isMobile ? "bottom-center" : (props.position || "top-right")
const effectivePosition = isMobile ? "top-center" : (props.position || "top-right")
return (
<Sonner
+372 -23
View File
@@ -1,21 +1,24 @@
import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { getNombreProfesional, isCamaFueraDeGrupo, computeSector } from '@/lib/utils';
import { getNombreProfesional, isCamaFueraDeGrupo, computeSector, sortCamas, getLocalToday } from '@/lib/utils';
import type {
Paciente,
Cama,
Grupo,
Internacion,
Evolucion,
Laboratorio,
Laboratorio, OtroLaboratorio,
Glucemia,
AcidoBase,
Cultivo,
TipoCultivo,
GrupoDeterminacionLaboratorio,
EstudioComplementario,
Interconsulta,
ATB,
Indicacion,
MovimientoIndicacion,
Pendiente,
Vista,
Usuario
} from '@/types';
@@ -40,14 +43,18 @@ interface HospitalState {
internaciones: Internacion[];
evoluciones: Evolucion[];
laboratorios: Laboratorio[];
otrosLaboratorios: OtroLaboratorio[];
glucemias: Glucemia[];
acidosBase: AcidoBase[];
cultivos: Cultivo[];
tiposCultivo: TipoCultivo[];
gruposDeterminacionesLab: GrupoDeterminacionLaboratorio[];
estudiosComplementarios: EstudioComplementario[];
interconsultas: Interconsulta[];
atb: ATB[];
indicaciones: Indicacion[];
movimientosIndicaciones: MovimientoIndicacion[];
pendientes: Pendiente[];
vistaActual: Vista;
currentInternacionId?: string | null;
usuarios: Usuario[];
@@ -64,15 +71,19 @@ const defaultState = (): HospitalState => ({
internaciones: [],
evoluciones: [],
laboratorios: [],
otrosLaboratorios: [],
glucemias: [],
acidosBase: [],
cultivos: [],
tiposCultivo: [],
gruposDeterminacionesLab: [],
estudiosComplementarios: [],
interconsultas: [],
atb: [],
vistaActual: 'dashboard',
indicaciones: [],
movimientosIndicaciones: [],
pendientes: [],
camas: [],
usuarios: [],
currentUser: null,
@@ -94,15 +105,21 @@ export function useHospitalStore() {
if (mounted && body) {
const defaults = defaultState();
const storedUser = sessionStorage.getItem('hospital_user');
const storedInternacionId = sessionStorage.getItem('hospital_current_internacion_id');
const currentUser = storedUser ? JSON.parse(storedUser) : null;
const normalized = {
...defaults,
...body,
tiposCultivo: body.tiposCultivo || [],
gruposDeterminacionesLab: body.gruposDeterminacionesLab || [],
estudiosComplementarios: body.estudiosComplementarios || [],
interconsultas: body.interconsultas || [],
atb: body.atb || [],
glucemias: body.glucemias || [],
movimientosIndicaciones: body.movimientosIndicaciones || [],
pendientes: body.pendientes || [],
currentInternacionId: storedInternacionId || body.currentInternacionId || null,
camas: sortCamas(body.camas || []),
currentUser,
isAuthenticated: !!currentUser,
};
@@ -248,7 +265,7 @@ export function useHospitalStore() {
const nuevoPaciente: Paciente = {
...paciente,
id: generateUUID(),
fechaRegistro: new Date().toISOString().split('T')[0],
fechaRegistro: getLocalToday(),
};
try {
await apiCall('POST', '/pacientes', nuevoPaciente);
@@ -298,10 +315,14 @@ export function useHospitalStore() {
const cama = state.camas.find(c => c.id === id);
if (!cama) return;
const user = state.currentUser;
const grupoId = cama.grupoId || null;
if (user && user.rol !== 'admin' && grupoId && user.grupoId !== grupoId) {
console.warn('No tiene permisos para actualizar cama en esta área');
return;
if (user && user.rol !== 'admin') {
// Must have a working group and the bed must belong to it
if (!user.grupoId || cama.grupoId !== user.grupoId) {
toast.error('No tiene permisos para modificar camas fuera de su grupo de trabajo');
return;
}
// A regular user can ONLY modify the "Tipo" of the bed.
datos = { tipo: datos.tipo };
}
try {
if (datos.numero !== undefined) {
@@ -310,7 +331,7 @@ export function useHospitalStore() {
await apiCall('PUT', `/camas/${id}`, datos);
setState(prev => ({
...prev,
camas: prev.camas.map(c => c.id === id ? { ...c, ...datos } : c),
camas: sortCamas(prev.camas.map(c => c.id === id ? { ...c, ...datos } : c)),
}));
} catch (err) {
console.error('Error al actualizar cama:', err);
@@ -319,6 +340,10 @@ export function useHospitalStore() {
}, [state, apiCall]);
const agregarCama = useCallback(async (cama: Omit<Cama, 'id'>) => {
if (state.currentUser?.rol !== 'admin') {
toast.error('Solo el usuario administrador puede agregar camas');
return;
}
const nuevaCama: Cama = {
...cama,
id: generateUUID(),
@@ -328,28 +353,38 @@ const agregarCama = useCallback(async (cama: Omit<Cama, 'id'>) => {
await apiCall('POST', '/camas', nuevaCama);
setState(prev => ({
...prev,
camas: [...prev.camas, nuevaCama],
camas: sortCamas([...prev.camas, nuevaCama]),
}));
return nuevaCama.id;
} catch (err) {
console.error('Error al agregar cama:', err);
throw err;
}
}, [apiCall]);
}, [state.currentUser, apiCall]);
const eliminarCama = useCallback(async (id: string) => {
if (state.currentUser?.rol !== 'admin') {
toast.error('Solo el usuario administrador puede eliminar camas');
return;
}
try {
// Si hay una internación activa asociada a esta cama, remover la asociación en el servidor
const activeInternacion = state.internaciones.find(i => i.camaId === id && i.activa);
if (activeInternacion) {
await apiCall('PUT', `/internaciones/${activeInternacion.id}`, { camaId: '' });
}
await apiCall('DELETE', `/camas/${id}`);
setState(prev => ({
...prev,
camas: prev.camas.filter(c => c.id !== id),
internaciones: prev.internaciones.map(i => i.camaId === id ? { ...i, activa: false } : i),
internaciones: prev.internaciones.map(i => i.camaId === id ? { ...i, camaId: undefined } : i),
}));
} catch (err) {
console.error('Error al eliminar cama:', err);
throw err;
}
}, [apiCall]);
}, [state.currentUser, state.internaciones, apiCall]);
// Acciones de internaciones
const iniciarInternacion = useCallback(async (internacion: Omit<Internacion, 'id' | 'activa'>) => {
@@ -395,7 +430,8 @@ const agregarCama = useCallback(async (cama: Omit<Cama, 'id'>) => {
const finalizarInternacion = useCallback(async (internacionId: string, datos: {
fechaEgreso: string;
diagnosticoEgreso: string;
motivoEgreso: Internacion['motivoEgreso']
motivoEgreso: Internacion['motivoEgreso'];
servicioAlQuePasa?: string;
}) => {
const internacion = state.internaciones.find(i => i.id === internacionId);
if (!internacion) return;
@@ -405,13 +441,18 @@ const agregarCama = useCallback(async (cama: Omit<Cama, 'id'>) => {
const esFueraDeGrupo = !internacion.grupoId || !grupoFueraDeGrupoId || internacion.grupoId === grupoFueraDeGrupoId;
const camaId = internacion.camaId;
const camaObj = camaId ? state.camas.find(c => c.id === camaId) : undefined;
const debeEliminarCama = camaObj
? (computeSector(camaObj.numero) === 'Fuera de Área')
: esFueraDeGrupo;
try {
// API call inmediata para finalizar internación
await apiCall('PUT', `/internaciones/${internacionId}`, { ...datos, activa: false });
// Eliminar o actualizar la cama según el área
if (camaId) {
if (esFueraDeGrupo) {
if (debeEliminarCama) {
// Eliminar la cama si es "Fuera de área"
await apiCall('DELETE', `/camas/${camaId}`);
} else {
@@ -433,7 +474,7 @@ const agregarCama = useCallback(async (cama: Omit<Cama, 'id'>) => {
? { ...i, ...datos, activa: false }
: i
),
camas: esFueraDeGrupo
camas: debeEliminarCama
? prev.camas.filter(c => c.id !== camaId)
: prev.camas.map(c =>
c.id === camaId
@@ -463,10 +504,15 @@ const agregarCama = useCallback(async (cama: Omit<Cama, 'id'>) => {
const esFueraDeGrupo = !internacion.grupoId || !grupoFueraDeGrupoId || internacion.grupoId === grupoFueraDeGrupoId;
const camaId = internacion.camaId;
const camaObj = camaId ? state.camas.find(c => c.id === camaId) : undefined;
const debeEliminarCama = camaObj
? (computeSector(camaObj.numero) === 'Fuera de Área')
: esFueraDeGrupo;
try {
await apiCall('DELETE', `/internaciones/${internacionId}`);
if (camaId) {
if (esFueraDeGrupo) {
if (debeEliminarCama) {
await apiCall('DELETE', `/camas/${camaId}`);
} else {
await apiCall('PUT', `/camas/${camaId}`, {
@@ -480,7 +526,7 @@ const agregarCama = useCallback(async (cama: Omit<Cama, 'id'>) => {
setState(prev => ({
...prev,
internaciones: prev.internaciones.filter(i => i.id !== internacionId),
camas: esFueraDeGrupo
camas: debeEliminarCama
? prev.camas.filter(c => c.id !== camaId)
: prev.camas.map(c =>
c.id === camaId
@@ -708,6 +754,51 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
}
}, [state, checkGrupoPermission, apiCall]);
// Acciones de otros laboratorios
const agregarOtroLaboratorio = useCallback(async (otroLaboratorio: Omit<OtroLaboratorio, 'id'>) => {
const nuevo: OtroLaboratorio = {
...otroLaboratorio,
id: generateUUID(),
};
try {
await apiCall('POST', '/otros-laboratorios', nuevo);
setState(prev => ({
...prev,
otrosLaboratorios: [...prev.otrosLaboratorios, nuevo],
}));
return nuevo.id;
} catch (err) {
console.error('Error al agregar otro laboratorio:', err);
throw err;
}
}, [apiCall]);
const eliminarOtroLaboratorio = useCallback(async (id: string) => {
try {
await apiCall('DELETE', `/otros-laboratorios/${id}`);
setState(prev => ({
...prev,
otrosLaboratorios: prev.otrosLaboratorios.filter(o => o.id !== id),
}));
} catch (err) {
console.error('Error al eliminar otro laboratorio:', err);
throw err;
}
}, [apiCall]);
const actualizarOtroLaboratorio = useCallback(async (id: string, datos: Partial<OtroLaboratorio>) => {
try {
await apiCall('PUT', `/otros-laboratorios/${id}`, datos);
setState(prev => ({
...prev,
otrosLaboratorios: prev.otrosLaboratorios.map(o => o.id === id ? { ...o, ...datos } : o),
}));
} catch (err) {
console.error('Error al actualizar otro laboratorio:', err);
throw err;
}
}, [apiCall]);
// Acciones de glucemias
const agregarGlucemia = useCallback(async (glucemia: Omit<Glucemia, 'id'>) => {
const internacion = state.internaciones.find(i => i.id === glucemia.internacionId);
@@ -909,6 +1000,144 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
}
}, [state, checkGrupoPermission, apiCall]);
// Acciones de tipos de cultivo
const agregarTipoCultivo = useCallback(async (tipo: Omit<TipoCultivo, 'id'>) => {
const nuevoTipo: TipoCultivo = {
...tipo,
id: generateUUID(),
};
try {
const res = await apiCall('POST', '/tipos-cultivo', nuevoTipo);
const saved = res || nuevoTipo;
setState(prev => ({
...prev,
tiposCultivo: [...prev.tiposCultivo.filter(t => t.id !== saved.id), saved],
}));
toast.success(`Tipo de cultivo "${saved.nombre}" creado exitosamente`);
return saved;
} catch (err) {
console.error('Error al agregar tipo de cultivo:', err);
toast.error(err instanceof Error ? err.message : 'Error al agregar tipo de cultivo');
throw err;
}
}, [apiCall]);
const actualizarTipoCultivo = useCallback(async (id: string, datos: Partial<TipoCultivo>) => {
try {
await apiCall('PUT', `/tipos-cultivo/${id}`, datos);
setState(prev => ({
...prev,
tiposCultivo: prev.tiposCultivo.map(t => t.id === id ? { ...t, ...datos } : t),
}));
toast.success('Tipo de cultivo actualizado exitosamente');
} catch (err) {
console.error('Error al actualizar tipo de cultivo:', err);
toast.error(err instanceof Error ? err.message : 'Error al actualizar tipo de cultivo');
throw err;
}
}, [apiCall]);
const eliminarTipoCultivo = useCallback(async (id: string) => {
try {
await apiCall('DELETE', `/tipos-cultivo/${id}`);
setState(prev => ({
...prev,
tiposCultivo: prev.tiposCultivo.filter(t => t.id !== id),
}));
toast.success('Tipo de cultivo eliminado');
} catch (err) {
console.error('Error al eliminar tipo de cultivo:', err);
toast.error(err instanceof Error ? err.message : 'Error al eliminar tipo de cultivo');
throw err;
}
}, [apiCall]);
const restablecerTiposCultivo = useCallback(async () => {
try {
const res = await apiCall('POST', '/tipos-cultivo/reset');
if (Array.isArray(res)) {
setState(prev => ({
...prev,
tiposCultivo: res,
}));
}
toast.success('Tipos de cultivo restablecidos a valores predeterminados');
} catch (err) {
console.error('Error al restablecer tipos de cultivo:', err);
toast.error('Error al restablecer tipos de cultivo');
throw err;
}
}, [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
const agregarEstudioComplementario = useCallback(async (estudio: Omit<EstudioComplementario, 'id'>) => {
const internacion = state.internaciones.find(i => i.id === estudio.internacionId);
@@ -1194,6 +1423,10 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
// Acciones de grupos
const agregarGrupo = useCallback(async (grupo: Omit<Grupo, 'id'>) => {
if (state.currentUser?.rol !== 'admin') {
toast.error('Solo el usuario administrador puede agregar grupos de trabajo');
return;
}
const nuevaGrupo: Grupo = { ...grupo, id: generateUUID() };
try {
await apiCall('POST', '/grupos', nuevaGrupo);
@@ -1206,9 +1439,13 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
console.error('Error al agregar área:', err);
throw err;
}
}, [apiCall]);
}, [state.currentUser, apiCall]);
const actualizarGrupo = useCallback(async (id: string, datos: Partial<Grupo>) => {
if (state.currentUser?.rol !== 'admin') {
toast.error('Solo el usuario administrador puede modificar grupos de trabajo');
return;
}
try {
await apiCall('PUT', `/grupos/${id}`, datos);
setState(prev => ({
@@ -1219,9 +1456,13 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
console.error('Error al actualizar área:', err);
throw err;
}
}, [apiCall]);
}, [state.currentUser, apiCall]);
const eliminarGrupo = useCallback(async (id: string) => {
if (state.currentUser?.rol !== 'admin') {
toast.error('Solo el usuario administrador puede eliminar grupos de trabajo');
return;
}
try {
await apiCall('DELETE', `/grupos/${id}`);
setState(prev => ({
@@ -1233,19 +1474,28 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
console.error('Error al eliminar área:', err);
throw err;
}
}, [apiCall]);
}, [state.currentUser, apiCall]);
// Funciones de utilidad para consultas
const getPacienteById = useCallback((id: string) => {
return state.pacientes.find(p => p.id === id);
if (!id) return undefined;
const target = String(id).trim().toLowerCase();
return state.pacientes.find(p =>
(p.id && String(p.id).trim().toLowerCase() === target) ||
(p.dni && String(p.dni).trim().toLowerCase() === target)
);
}, [state.pacientes]);
const getCamaById = useCallback((id: string) => {
return state.camas.find(c => c.id === id);
if (!id) return undefined;
const target = String(id).trim().toLowerCase();
return state.camas.find(c => c.id && String(c.id).trim().toLowerCase() === target);
}, [state.camas]);
const getInternacionById = useCallback((id: string) => {
return state.internaciones.find(i => i.id === id);
if (!id) return undefined;
const target = String(id).trim().toLowerCase();
return state.internaciones.find(i => i.id && String(i.id).trim().toLowerCase() === target);
}, [state.internaciones]);
const getInternacionActivaByPaciente = useCallback((pacienteId: string) => {
@@ -1282,6 +1532,60 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
.sort((a, b) => new Date(b.fechaToma).getTime() - new Date(a.fechaToma).getTime());
}, [state.cultivos]);
const agregarPendiente = useCallback(async (datos: Omit<Pendiente, 'id'>) => {
const nuevoPendiente: Pendiente = {
...datos,
id: generateUUID(),
fechaCreacion: datos.fechaCreacion || getLocalToday(),
horaCreacion: datos.horaCreacion || new Date().toTimeString().slice(0, 5),
estado: datos.estado || 'pendiente',
prioridad: datos.prioridad || 'media',
};
try {
await apiCall('POST', '/pendientes', nuevoPendiente);
setState(prev => ({
...prev,
pendientes: [...(prev.pendientes || []), nuevoPendiente],
}));
return nuevoPendiente.id;
} catch (err) {
console.error('Error al agregar pendiente:', err);
throw err;
}
}, [apiCall]);
const actualizarPendiente = useCallback(async (id: string, datos: Partial<Pendiente>) => {
try {
await apiCall('PUT', `/pendientes/${id}`, datos);
setState(prev => ({
...prev,
pendientes: (prev.pendientes || []).map(p => p.id === id ? { ...p, ...datos } : p),
}));
} catch (err) {
console.error('Error al actualizar pendiente:', err);
throw err;
}
}, [apiCall]);
const eliminarPendiente = useCallback(async (id: string) => {
try {
await apiCall('DELETE', `/pendientes/${id}`);
setState(prev => ({
...prev,
pendientes: (prev.pendientes || []).filter(p => p.id !== id),
}));
} catch (err) {
console.error('Error al eliminar pendiente:', err);
throw err;
}
}, [apiCall]);
const getPendientesByPaciente = useCallback((pacienteId: string) => {
return (state.pendientes || [])
.filter(p => p.pacienteId === pacienteId)
.sort((a, b) => new Date(`${b.fechaCreacion}T${b.horaCreacion || '00:00'}`).getTime() - new Date(`${a.fechaCreacion}T${a.horaCreacion || '00:00'}`).getTime());
}, [state.pendientes]);
const getEstadisticas = useCallback(() => {
const camasEnArea = state.camas.filter(c => !isCamaFueraDeGrupo(c));
const camasFueraDeArea = state.camas.filter(c => isCamaFueraDeGrupo(c));
@@ -1309,6 +1613,11 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
// Current internacion selection (for HC page)
const setCurrentInternacion = useCallback((id?: string | null) => {
if (id) {
sessionStorage.setItem('hospital_current_internacion_id', id);
} else {
sessionStorage.removeItem('hospital_current_internacion_id');
}
setState(prev => ({ ...prev, currentInternacionId: id ?? null }));
}, []);
@@ -1363,9 +1672,32 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
return true;
}, []);
const refreshState = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/state`);
if (!res.ok) throw new Error('no state');
const body = await res.json();
if (body) {
setState(prev => ({
...prev,
...body,
estudiosComplementarios: body.estudiosComplementarios || prev.estudiosComplementarios,
interconsultas: body.interconsultas || prev.interconsultas,
atb: body.atb || prev.atb,
glucemias: body.glucemias || prev.glucemias,
movimientosIndicaciones: body.movimientosIndicaciones || prev.movimientosIndicaciones,
pendientes: body.pendientes || prev.pendientes,
}));
}
} catch (err) {
console.error('Error refreshing state:', err);
}
}, []);
return {
...state,
isLoaded,
refreshState,
setVista,
agregarPaciente,
actualizarPaciente,
@@ -1383,6 +1715,9 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
agregarLaboratorio,
actualizarLaboratorio,
eliminarLaboratorio,
agregarOtroLaboratorio,
actualizarOtroLaboratorio,
eliminarOtroLaboratorio,
agregarGlucemia,
actualizarGlucemia,
eliminarGlucemia,
@@ -1392,6 +1727,15 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
agregarCultivo,
actualizarCultivo,
eliminarCultivo,
agregarTipoCultivo,
actualizarTipoCultivo,
eliminarTipoCultivo,
restablecerTiposCultivo,
gruposDeterminacionesLab: state.gruposDeterminacionesLab,
agregarGrupoDeterminacionLab,
actualizarGrupoDeterminacionLab,
eliminarGrupoDeterminacionLab,
restablecerGruposDeterminacionesLab,
agregarEstudioComplementario,
actualizarEstudioComplementario,
eliminarEstudioComplementario,
@@ -1418,6 +1762,10 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
getGlucemiasByInternacion,
getAcidosBaseByInternacion,
getCultivosByInternacion,
agregarPendiente,
actualizarPendiente,
eliminarPendiente,
getPendientesByPaciente,
setCurrentInternacion,
getEstadisticas,
login,
@@ -1434,5 +1782,6 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
getCamaGrupoId,
getInternacionGrupoId,
getPacienteGrupoId,
isLoaded,
};
}
+23 -23
View File
@@ -10,28 +10,28 @@
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--secondary: 214 90% 96%;
--secondary-foreground: 221.2 83.2% 40%;
--muted: 214 32% 95%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--accent: 214 90% 96%;
--accent-foreground: 221.2 83.2% 40%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--border: 214.3 31.8% 90%;
--input: 214.3 31.8% 90%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.625rem;
--sidebar-background: 0 0% 98%;
--sidebar-background: 0 0% 99%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 222.2 47.4% 11.2%;
--sidebar-primary: 221.2 83.2% 53.3%;
--sidebar-primary-foreground: 210 40% 98%;
--sidebar-accent: 210 40% 96.1%;
--sidebar-accent-foreground: 222.2 47.4% 11.2%;
--sidebar-border: 214.3 31.8% 91.4%;
--sidebar-ring: 217.2 91.2% 59.8%;
--sidebar-accent: 214 90% 96%;
--sidebar-accent-foreground: 221.2 83.2% 40%;
--sidebar-border: 214.3 31.8% 90%;
--sidebar-ring: 221.2 83.2% 53.3%;
}
.dark {
@@ -43,12 +43,12 @@
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--secondary: 217.2 45% 15%;
--secondary-foreground: 213 94% 85%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--accent: 217.2 45% 15%;
--accent-foreground: 213 94% 85%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 20%;
@@ -58,8 +58,8 @@
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 217.2 91.2% 59.8%;
--sidebar-primary-foreground: 222.2 47.4% 11.2%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-accent: 217.2 45% 15%;
--sidebar-accent-foreground: 213 94% 85%;
--sidebar-border: 217.2 32.6% 20%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
@@ -98,8 +98,8 @@
@media (max-width: 639px) {
[data-sonner-toaster] {
bottom: 20px !important;
top: auto !important;
top: 16px !important;
bottom: auto !important;
left: 50% !important;
transform: translateX(-50%) !important;
width: calc(100% - 32px) !important;
+523
View File
@@ -0,0 +1,523 @@
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 },
];
const CORE_LIST_SET = new Set([
'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'
]);
export function esParametroCore(param: string): boolean {
return CORE_LIST_SET.has(param.toLowerCase().trim());
}
const regexCache = new Map<string, RegExp>();
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);
}
let regex = regexCache.get(c);
if (!regex) {
const escaped = c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
regex = new RegExp(`(?:^|[^a-z0-9_])${escaped}(?:$|[^a-z0-9_])`, 'i');
regexCache.set(c, regex);
}
return regex.test(lineaLower);
}
export function parseAcidoBaseTexto(texto: string, fecha?: string, pacienteId?: string): Omit<AcidoBase, 'id'> | null {
const lineas = texto.split('\n');
const cleanLineas = lineas.map(linea =>
linea.replace(/show_chart|list_alt|\t+/g, ' ').replace(/,/g, '.').replace(/\s+/g, ' ').trim().toLowerCase()
);
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 getValue = (cleanLinea: string, 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;
};
for (const cleanLinea of cleanLineas) {
if ((cleanLinea.includes('estado') && cleanLinea.includes('ácido')) || cleanLinea.includes('base') || cleanLinea.includes('gases en sangre')) {
const phMatch = cleanLinea.match(/ph\s+(\d+\.?\d*)/);
ph = getValue(cleanLinea, 'ph') || (phMatch ? parseFloat(phMatch[1]) : undefined);
pco2 = getValue(cleanLinea, 'pco2') || getValue(cleanLinea, 'pco₂');
po2 = getValue(cleanLinea, 'po2') || getValue(cleanLinea, 'po₂');
hco3 = getValue(cleanLinea, 'hco3');
be = getValue(cleanLinea, 'exceso de base') || getValue(cleanLinea, 'base excess') || getValue(cleanLinea, 'exceso');
sato2 = getValue(cleanLinea, 'saturación') || getValue(cleanLinea, 'sato2') || getValue(cleanLinea, 'sat');
lactato = getValue(cleanLinea, 'lactato');
fio2 = getValue(cleanLinea, 'fio2');
break;
}
}
if (!ph) {
for (const cleanLinea of cleanLineas) {
if (cleanLinea.includes('ph') && !cleanLinea.includes('pco2') && !cleanLinea.includes('pco')) { ph = getValue(cleanLinea, 'ph'); }
else if (cleanLinea.includes('pco2') || cleanLinea.includes('pco₂')) { pco2 = getValue(cleanLinea, 'pco2'); }
else if (cleanLinea.includes('po2') || cleanLinea.includes('po₂')) { po2 = getValue(cleanLinea, 'po2'); }
else if (cleanLinea.includes('hco3')) { hco3 = getValue(cleanLinea, 'hco3'); }
else if (cleanLinea.includes('exceso') || cleanLinea.includes('base excess')) { be = getValue(cleanLinea, 'exceso') || getValue(cleanLinea, 'base'); }
else if (cleanLinea.includes('saturación') || cleanLinea.includes('sato2')) { sato2 = getValue(cleanLinea, 'saturación') || getValue(cleanLinea, 'sat'); }
else if (cleanLinea.includes('lactato')) { lactato = getValue(cleanLinea, 'lactato'); }
else if (cleanLinea.includes('fio2')) { fio2 = getValue(cleanLinea, '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 matchedParamNames = new Set<string>();
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 (matchedParamNames.has(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))
});
matchedParamNames.add(core.nombre);
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 (matchedParamNames.has(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);
matchedParamNames.add(det.nombre);
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');
}
+62 -12
View File
@@ -14,9 +14,16 @@ export function formatDateDDMMYYYY(dateString: string): string {
return `${day}-${month}-${year}`;
}
export function getLocalToday(): string {
const d = new Date();
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function getNombreProfesional(user?: Usuario | null): string {
if (!user) return 'Admin';
if (user.rol === 'admin') return 'Admin';
const apellido = user.apellido?.trim() || '';
const nombre = user.nombre?.trim() || '';
if (apellido && nombre) return `${apellido}, ${nombre}`;
@@ -42,25 +49,68 @@ export function calcularEdad(fechaNacimiento?: string): string | number {
export function isCamaFueraDeGrupo(
cama: { numero: string; grupoId?: string; areaId?: string; sector?: string }
): boolean {
if (cama.sector === 'Fuera de Área') return true;
if (cama.sector === 'En Área') return false;
return computeSector(cama.numero) === 'Fuera de Área';
return computeSector(cama?.numero || '') === 'Fuera de Área';
}
export function sortCamas<T extends { numero: string }>(camas: T[]): T[] {
if (!camas || camas.length <= 1) return camas || [];
const parseBedKey = (numero: string) => {
const clean = (numero || '').trim();
const match = clean.match(/^(\d{3})-(\d+)$/);
if (!match) return { weight: 10, salaDir: 0, cama: 0 };
const sala = parseInt(match[1], 10);
const cama = parseInt(match[2], 10);
const grupo = Math.floor(sala / 100);
const esPar = sala % 2 === 0;
let weight = 10;
let isDesc = false;
if (grupo === 2) {
if (!esPar) { weight = 1; isDesc = true; }
else { weight = 2; isDesc = false; }
} else if (grupo === 3) {
if (esPar) { weight = 3; isDesc = false; }
else { weight = 4; isDesc = true; }
} else if (grupo === 4) {
if (!esPar) { weight = 5; isDesc = true; }
else { weight = 6; isDesc = false; }
}
const salaDir = isDesc ? -sala : sala;
return { weight, salaDir, cama };
};
const mapped = camas.map(item => ({ item, key: parseBedKey(item.numero) }));
mapped.sort((a, b) => {
if (a.key.weight !== b.key.weight) return a.key.weight - b.key.weight;
if (a.key.salaDir !== b.key.salaDir) return a.key.salaDir - b.key.salaDir;
return a.key.cama - b.key.cama;
});
return mapped.map(m => m.item);
}
export function computeSector(numero: string): "En Área" | "Fuera de Área" {
if (!numero) return 'En Área';
const parts = String(numero).trim().split('-');
if (parts.length === 0) return 'En Área';
const cleanNumero = String(numero).trim();
// Format is XXX-YY (where XXX is room number, YY is bed number in room)
// Extract room number XXX (first part before hyphen if hyphen exists, or first 3 digits)
const parts = cleanNumero.split('-');
const salaStr = parts[0].trim();
const salaNum = parseInt(salaStr, 10);
if (isNaN(salaNum)) return 'En Área';
// 3XX-YY where XX is odd (so the whole 3XX is odd)
if (salaStr.length === 3 && salaStr.startsWith('3') && salaNum % 2 !== 0) {
return 'Fuera de Área';
// Fuera de área rule 1: 3XX where XX is an odd number (e.g., 301, 303, 315, 399)
// salaStr length can be 3, starting with '3'
if (salaStr.length === 3 && salaStr.startsWith('3')) {
const xxNum = parseInt(salaStr.slice(1), 10);
if (!isNaN(xxNum) && xxNum % 2 !== 0) {
return 'Fuera de Área';
}
}
// All those starting with 4
// Fuera de área rule 2: 4XX (starts with '4', regardless of digits XX)
if (salaStr.startsWith('4')) {
return 'Fuera de Área';
}
+3 -2
View File
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import type { AcidoBase, Paciente } from '@/types';
import { getLocalToday } from '@/lib/utils';
interface AcidoBaseProps {
acidosBase: AcidoBase[];
@@ -39,7 +40,7 @@ export function AcidoBaseSection({
// Formulario
const [pacienteSeleccionado, setPacienteSeleccionado] = useState('');
const [busquedaPaciente, setBusquedaPaciente] = useState('');
const [fecha, setFecha] = useState(new Date().toISOString().split('T')[0]);
const [fecha, setFecha] = useState(getLocalToday());
const [hora, setHora] = useState(new Date().toTimeString().slice(0, 5));
const [ph, setPh] = useState('');
const [pco2, setPco2] = useState('');
@@ -53,7 +54,7 @@ export function AcidoBaseSection({
const resetFormulario = () => {
setPacienteSeleccionado('');
setBusquedaPaciente('');
setFecha(new Date().toISOString().split('T')[0]);
setFecha(getLocalToday());
setHora(new Date().toTimeString().slice(0, 5));
setPh('');
setPco2('');
+156 -370
View File
@@ -1,11 +1,8 @@
import { useState } from 'react';
import { Microscope, Plus, Search, Calendar, AlertCircle, Trash2, CheckCircle2, Pencil } from 'lucide-react';
import { Microscope, Search, Calendar, AlertCircle, CheckCircle2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { Cultivo, Paciente, Cama, Internacion } from '@/types';
@@ -16,12 +13,12 @@ interface CultivosProps {
camas?: Cama[];
patient?: Paciente;
internacionId?: string;
onAgregarCultivo: (cultivo: Omit<Cultivo, 'id'>) => void;
onAgregarCultivo?: (cultivo: Omit<Cultivo, 'id'>) => void;
onActualizarCultivo?: (id: string, datos: Partial<Cultivo>) => void;
onEliminarCultivo: (id: string) => void;
onEliminarCultivo?: (id: string) => void;
getPacienteById?: (id: string) => Paciente | undefined;
getInternacionActivaByPaciente?: (pacienteId: string) => Internacion | undefined;
canEdit: boolean;
canEdit?: boolean;
}
export function Cultivos({
@@ -30,33 +27,10 @@ export function Cultivos({
internaciones = [],
camas = [],
patient,
internacionId,
onAgregarCultivo,
onActualizarCultivo,
onEliminarCultivo,
getPacienteById,
getInternacionActivaByPaciente,
canEdit,
}: CultivosProps) {
const [busqueda, setBusqueda] = useState('');
const [filtroEstado, setFiltroEstado] = useState<'todos' | 'NAF/Pendiente' | 'Parcial' | 'Positivo' | 'Negativo'>('todos');
const [dialogoNuevoAbierto, setDialogoNuevoAbierto] = useState(false);
const [dialogoParcialAbierto, setDialogoParcialAbierto] = useState(false);
const [dialogoDefinitivoAbierto, setDialogoDefinitivoAbierto] = useState(false);
const [cultivoSeleccionado, setCultivoSeleccionado] = useState<Cultivo | null>(null);
const [pacienteSeleccionado, setPacienteSeleccionado] = useState<string>(patient?.id || '');
const [busquedaPaciente, setBusquedaPaciente] = useState('');
const [fechaToma, setFechaToma] = useState(new Date().toISOString().split('T')[0]);
const [protocolo, setProtocolo] = useState('');
const [tipoMuestra, setTipoMuestra] = useState<Cultivo['tipoMuestra']>('HMCx2');
const [observaciones, setObservaciones] = useState('');
const [fechaResultado, setFechaResultado] = useState(new Date().toISOString().split('T')[0]);
const [estadoResultado, setEstadoResultado] = useState<Cultivo['estado']>('Positivo');
const [germen, setGermen] = useState('');
const [sensible, setSensible] = useState('');
const [resistente, setResistente] = useState('');
const findPaciente = (pacienteId: string): Paciente | undefined => {
if (patient && patient.id === pacienteId) return patient;
@@ -107,112 +81,43 @@ export function Cultivos({
}
};
const abrirNuevo = () => {
setCultivoSeleccionado(null);
setPacienteSeleccionado(patient?.id || '');
setBusquedaPaciente('');
setFechaToma(new Date().toISOString().split('T')[0]);
setProtocolo('');
setTipoMuestra('HMCx2');
setObservaciones('');
setDialogoNuevoAbierto(true);
};
const abrirParcial = (cultivo: Cultivo) => {
setCultivoSeleccionado(cultivo);
setGermen(cultivo.germen || '');
setSensible(cultivo.sensible || '');
setResistente(cultivo.resistente || '');
setDialogoParcialAbierto(true);
};
const abrirDefinitivo = (cultivo: Cultivo) => {
setCultivoSeleccionado(cultivo);
setFechaResultado(new Date().toISOString().split('T')[0]);
setEstadoResultado(cultivo.estado === 'Parcial' ? 'Positivo' : cultivo.estado);
setGermen(cultivo.germen || '');
setSensible(cultivo.sensible || '');
setResistente(cultivo.resistente || '');
setDialogoDefinitivoAbierto(true);
};
const guardarNuevo = () => {
const targetPac = patient || findPaciente(pacienteSeleccionado);
if (!protocolo.trim() || !targetPac) return;
const activeInter = getInternacionActivaByPaciente ? getInternacionActivaByPaciente(targetPac.id) : undefined;
const targetInternacionId = internacionId || activeInter?.id || '';
onAgregarCultivo({
pacienteId: targetPac.id,
internacionId: targetInternacionId,
fechaToma,
protocolo,
tipoMuestra,
observaciones: observaciones,
estado: 'NAF/Pendiente',
});
setDialogoNuevoAbierto(false);
};
const guardarParcial = () => {
if (!cultivoSeleccionado || !germen.trim()) return;
if (onActualizarCultivo) {
onActualizarCultivo(cultivoSeleccionado.id, {
estado: 'Parcial',
germen,
sensible: sensible,
resistente: resistente,
});
}
setDialogoParcialAbierto(false);
};
const guardarDefinitivo = () => {
if (!cultivoSeleccionado) return;
if (onActualizarCultivo) {
onActualizarCultivo(cultivoSeleccionado.id, {
estado: estadoResultado,
fechaResultado,
germen: cultivoSeleccionado.germen,
sensible: cultivoSeleccionado.sensible,
resistente: cultivoSeleccionado.resistente,
});
}
setDialogoDefinitivoAbierto(false);
};
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex-1 flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<div className="space-y-6 dark:text-white">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2.5">
<Microscope className="h-7 w-7 text-blue-600 dark:text-blue-400 shrink-0" />
Cultivos
</h1>
<p className="text-gray-500 dark:text-gray-400">Informe y registro de cultivos y muestras</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
<div className="flex-1 flex flex-col sm:flex-row gap-2 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" />
<Input
placeholder="Buscar por protocolo, germen..."
placeholder="Buscar por protocolo, germen, paciente, DNI..."
value={busqueda}
onChange={(e) => setBusqueda(e.target.value)}
className="pl-8"
className="pl-9 w-full bg-white dark:bg-gray-800"
/>
</div>
<Select value={filtroEstado} onValueChange={(v) => setFiltroEstado(v as Cultivo['estado'] | 'todos')}>
<SelectTrigger className="w-[180px]">
<SelectValue />
<SelectTrigger className="w-full sm:w-[190px] bg-white dark:bg-gray-800 shrink-0">
<SelectValue placeholder="Filtrar estado" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem>
<SelectItem value="NAF/Pendiente">NAF/Pendiente</SelectItem>
<SelectItem value="todos">Todos los estados</SelectItem>
<SelectItem value="NAF/Pendiente">NAF / Pendiente</SelectItem>
<SelectItem value="Parcial">Parcial</SelectItem>
<SelectItem value="Positivo">Positivo</SelectItem>
<SelectItem value="Negativo">Negativo</SelectItem>
</SelectContent>
</Select>
</div>
{canEdit && (
<Button onClick={abrirNuevo} size="sm">
<Plus className="h-4 w-4 mr-2" />
Nuevo Cultivo
</Button>
)}
</div>
<div className="space-y-3">
@@ -221,283 +126,164 @@ export function Cultivos({
const camaNombre = getCamaNombreForPaciente(cultivo.pacienteId);
return (
<Card key={cultivo.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div className="flex items-center gap-3">
<div className={`h-10 w-10 rounded-full flex items-center justify-center shrink-0 ${
cultivo.estado === 'NAF/Pendiente' ? 'bg-amber-100 dark:bg-amber-950/80' :
cultivo.estado === 'Parcial' ? 'bg-orange-100 dark:bg-orange-950/80' :
cultivo.estado === 'Positivo' ? 'bg-red-100 dark:bg-red-950/80' : 'bg-green-100 dark:bg-green-950/80'
<Card key={cultivo.id} className="hover:shadow-md transition-shadow overflow-hidden">
<CardContent className="p-3.5 sm:p-5">
<div className="flex flex-col gap-3 min-w-0">
{/* Card Header Info */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className={`h-10 w-10 rounded-full flex items-center justify-center shrink-0 mt-0.5 ${
cultivo.estado === 'NAF/Pendiente' ? 'bg-amber-100 text-amber-700 dark:bg-amber-950/80 dark:text-amber-300' :
cultivo.estado === 'Parcial' ? 'bg-orange-100 text-orange-700 dark:bg-orange-950/80 dark:text-orange-300' :
cultivo.estado === 'Positivo' ? 'bg-red-100 text-red-700 dark:bg-red-950/80 dark:text-red-300' : 'bg-green-100 text-green-700 dark:bg-green-950/80 dark:text-green-300'
}`}>
<Microscope className={`h-5 w-5 ${
cultivo.estado === 'NAF/Pendiente' ? 'text-amber-600 dark:text-amber-400' :
cultivo.estado === 'Parcial' ? 'text-orange-600 dark:text-orange-400' :
cultivo.estado === 'Positivo' ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'
}`} />
<Microscope className="h-5 w-5 shrink-0" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-bold text-sm sm:text-base truncate">
{pac ? `${pac.apellido}, ${pac.nombre}` : 'Paciente no encontrado'}
</h3>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
{camaNombre && (
<Badge variant="outline" className="text-xs shrink-0">
<Badge variant="outline" className="text-xs shrink-0 font-semibold bg-blue-50 text-blue-700 dark:bg-blue-950/80 dark:text-blue-300 dark:border-blue-800">
{camaNombre}
</Badge>
)}
<h3 className="font-bold text-sm sm:text-base text-gray-900 dark:text-white break-words">
{pac ? `${pac.apellido}, ${pac.nombre}` : 'Paciente no encontrado'}
</h3>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm text-gray-500">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-gray-500 dark:text-gray-400 mt-1.5">
<span className="inline-flex items-center gap-1 shrink-0">
<Calendar className="h-3.5 w-3.5" />
{cultivo.fechaToma}
</span>
<Badge variant="outline" className="text-xs">{cultivo.tipoMuestra}</Badge>
<Badge className={`text-xs ${getEstadoColor(cultivo.estado)}`}>
<span className="text-gray-300 dark:text-gray-600 hidden xs:inline"></span>
<Badge variant="outline" className="text-xs font-normal max-w-full truncate">
{cultivo.tipoMuestra}
</Badge>
<Badge className={`text-xs ${getEstadoColor(cultivo.estado)} font-medium`}>
{getEstadoLabel(cultivo.estado)}
</Badge>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-1 sm:gap-2 ml-auto sm:ml-0">
{canEdit && onActualizarCultivo && cultivo.estado === 'NAF/Pendiente' && (
<Button size="sm" variant="outline" className="text-xs px-2 py-1" onClick={() => abrirDefinitivo(cultivo)}>
<CheckCircle2 className="h-3 w-3 sm:h-4 sm:w-4 sm:mr-1" />
<span className="hidden sm:inline">Definitivo</span>
</Button>
)}
{canEdit && onActualizarCultivo && (cultivo.estado === 'Positivo' || cultivo.estado === 'Negativo') && (
<Button size="sm" variant="outline" className="text-xs px-2 py-1" onClick={() => abrirDefinitivo(cultivo)}>
<Pencil className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
)}
{canEdit && onActualizarCultivo && cultivo.estado === 'Parcial' && (
<Button size="sm" variant="outline" className="text-xs px-2 py-1" onClick={() => abrirParcial(cultivo)}>
<Pencil className="h-3 w-3 sm:h-4 sm:w-4 sm:mr-1" />
<span className="hidden sm:inline">Editar</span>
</Button>
)}
{canEdit && (
<Button size="sm" variant="outline" className="text-red-600 text-xs px-2 py-1" onClick={() => onEliminarCultivo(cultivo.id)}>
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
)}
</div>
</div>
{cultivo.protocolo && (
<p className="text-xs text-gray-500">
Protocolo: {cultivo.protocolo}
</p>
)}
{cultivo.observaciones && (
<p className="text-sm text-gray-600 dark:text-gray-300 bg-gray-50 dark:bg-gray-800 p-2 rounded">
{cultivo.observaciones}
</p>
)}
{/* Protocolo */}
{cultivo.protocolo && (
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono bg-gray-50 dark:bg-gray-800/60 px-2.5 py-1 rounded w-fit max-w-full break-all">
Protocolo: {cultivo.protocolo}
</div>
)}
{cultivo.estado === 'Parcial' && cultivo.germen && (
<div className="bg-orange-50 p-3 rounded-lg border border-orange-200">
<p className="text-sm font-medium text-orange-800 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
PARCIAL - Germen: {cultivo.germen}
{/* Estado NAF / Pendiente */}
{cultivo.estado === 'NAF/Pendiente' && cultivo.observaciones && (
<p className="text-sm text-gray-600 dark:text-gray-300 bg-gray-50 dark:bg-gray-800/80 p-2.5 rounded-lg border border-gray-200 dark:border-gray-700/70 break-words">
<span className="font-semibold text-gray-700 dark:text-gray-200">Observaciones:</span> {cultivo.observaciones}
</p>
{(cultivo.sensible || cultivo.resistente) && (
<div className="mt-2 space-y-1">
{cultivo.sensible && (
<p className="text-xs text-orange-700">Sensible: {cultivo.sensible}</p>
)}
{/* Estado Parcial */}
{cultivo.estado === 'Parcial' && (
<div className="bg-orange-50 dark:bg-orange-950/50 p-3 rounded-lg border border-orange-200 dark:border-orange-800/60 space-y-2 text-xs sm:text-sm">
{cultivo.germen && (
<p className="font-semibold text-orange-800 dark:text-orange-300 flex items-start gap-1.5 break-words">
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
<span>PARCIAL - Germen: {cultivo.germen}</span>
</p>
)}
{(cultivo.sensible || cultivo.resistente) && (
<div className="space-y-1.5 pt-1">
{cultivo.sensible && (
<div className="text-orange-900 dark:text-orange-200 bg-orange-100/60 dark:bg-orange-900/40 p-2 rounded break-words">
<span className="font-semibold text-orange-800 dark:text-orange-300">Sensible:</span> {cultivo.sensible}
</div>
)}
{cultivo.resistente && (
<div className="text-orange-900 dark:text-orange-200 bg-orange-100/60 dark:bg-orange-900/40 p-2 rounded break-words">
<span className="font-semibold text-orange-800 dark:text-orange-300">Resistente:</span> {cultivo.resistente}
</div>
)}
</div>
)}
{cultivo.observaciones && (
<p className="text-xs text-orange-800 dark:text-orange-200 border-t border-orange-200/80 dark:border-orange-800/60 pt-2 break-words">
<span className="font-semibold">Observaciones:</span> {cultivo.observaciones}
</p>
)}
</div>
)}
{/* Estado Positivo */}
{cultivo.estado === 'Positivo' && (
<div className="flex flex-col gap-2 mt-1">
<div className="flex flex-col gap-2">
{cultivo.germen && (
<div className="border border-red-300 dark:border-red-800/80 bg-red-50 dark:bg-red-950/60 text-red-900 dark:text-red-200 rounded-lg p-2.5 text-xs sm:text-sm flex items-start gap-2 break-words">
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<span className="font-bold text-red-800 dark:text-red-300">Germen: </span>
<span className="font-medium">{cultivo.germen}</span>
{cultivo.fechaResultado && (
<span className="text-xs text-red-700/80 dark:text-red-300/80 block sm:inline sm:ml-2">
(Definitivo: {cultivo.fechaResultado})
</span>
)}
</div>
</div>
)}
{cultivo.sensible && (
<div className="border border-green-300 dark:border-green-800/80 bg-green-50 dark:bg-green-950/60 text-green-900 dark:text-green-200 rounded-lg p-2.5 text-xs sm:text-sm break-words">
<span className="font-bold text-green-800 dark:text-green-300">Sensibilidad: </span>
<span>{cultivo.sensible}</span>
</div>
)}
{cultivo.resistente && (
<p className="text-xs text-orange-700">Resistente: {cultivo.resistente}</p>
<div className="border border-red-300 dark:border-red-800/80 bg-red-50/70 dark:bg-red-950/50 text-red-900 dark:text-red-200 rounded-lg p-2.5 text-xs sm:text-sm break-words">
<span className="font-bold text-red-800 dark:text-red-300">Resistencia: </span>
<span>{cultivo.resistente}</span>
</div>
)}
</div>
)}
</div>
)}
{cultivo.estado === 'Positivo' && cultivo.germen && (
<div className="bg-red-50 p-3 rounded-lg border border-red-200">
<p className="text-sm font-medium text-red-800 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
Germen: {cultivo.germen}
</p>
{cultivo.fechaResultado && (
<p className="text-xs text-red-600 mt-1">
Resultado: {cultivo.fechaResultado}
{cultivo.observaciones && (
<div className="bg-red-50/50 dark:bg-red-950/30 p-2.5 rounded-lg border border-red-200/80 dark:border-red-900/40 text-xs text-red-900 dark:text-red-200 break-words">
<span className="font-semibold">Observaciones:</span> {cultivo.observaciones}
</div>
)}
</div>
)}
{/* Estado Negativo */}
{cultivo.estado === 'Negativo' && (
<div className="bg-green-50 dark:bg-green-950/50 p-3 rounded-lg border border-green-200 dark:border-green-800/60 mt-1 space-y-1.5 text-xs sm:text-sm">
<p className="font-semibold text-green-800 dark:text-green-300 flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0" />
Cultivo Negativo Final
</p>
)}
{(cultivo.sensible || cultivo.resistente) && (
<div className="mt-2 space-y-2">
{cultivo.sensible && (
<div>
<p className="text-xs font-medium text-green-700 mb-1">Sensible a:</p>
<p className="text-sm text-green-800">{cultivo.sensible}</p>
</div>
)}
{cultivo.resistente && (
<div>
<p className="text-xs font-medium text-red-700 mb-1">Resistente a:</p>
<p className="text-sm text-red-800">{cultivo.resistente}</p>
</div>
)}
</div>
)}
</div>
)}
{cultivo.estado === 'Negativo' && (
<div className="bg-green-50 p-3 rounded-lg border border-green-200">
<p className="text-sm font-medium text-green-800 flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" />
Cultivo Negativo
</p>
</div>
)}
</div>
</CardContent>
</Card>
);
})}
{cultivo.observaciones && (
<p className="text-xs text-green-800 dark:text-green-200 border-t border-green-200/80 dark:border-green-800/60 pt-2 break-words">
<span className="font-semibold">Observaciones:</span> {cultivo.observaciones}
</p>
)}
</div>
)}
</div>
</CardContent>
</Card>
);
})}
{cultivosFiltrados.length === 0 && (
<p className="text-center text-gray-500 py-8">
No se encontraron cultivos con los filtros seleccionados
</p>
<div className="text-center py-12 bg-white dark:bg-gray-800/50 rounded-xl border border-dashed border-gray-300 dark:border-gray-700">
<Microscope className="h-10 w-10 text-gray-400 mx-auto mb-2 opacity-60" />
<p className="text-gray-600 dark:text-gray-300 font-medium">No se encontraron cultivos</p>
<p className="text-xs text-gray-400 mt-1">Pruebe ajustando los filtros o el término de búsqueda</p>
</div>
)}
</div>
{/* Dialog Nuevo Cultivo */}
<Dialog open={dialogoNuevoAbierto} onOpenChange={setDialogoNuevoAbierto}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nuevo Cultivo</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{!patient && (
<div className="space-y-2">
<Label>Paciente</Label>
<Input
placeholder="Buscar paciente por nombre o DNI..."
value={busquedaPaciente}
onChange={(e) => setBusquedaPaciente(e.target.value)}
className="mb-2 text-xs"
/>
<Select value={pacienteSeleccionado} onValueChange={setPacienteSeleccionado}>
<SelectTrigger>
<SelectValue placeholder="Seleccionar paciente" />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-auto">
{pacientes
.filter(p => !busquedaPaciente || `${p.apellido} ${p.nombre} ${p.dni}`.toLowerCase().includes(busquedaPaciente.toLowerCase()))
.map(p => (
<SelectItem key={p.id} value={p.id}>
{p.apellido}, {p.nombre} (DNI: {p.dni})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Fecha de Toma</Label>
<Input type="date" value={fechaToma} onChange={(e) => setFechaToma(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Tipo de Muestra</Label>
<Select value={tipoMuestra} onValueChange={(v) => setTipoMuestra(v as Cultivo['tipoMuestra'])}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HMCx2">HMCx2</SelectItem>
<SelectItem value="HMCx1">HMCx1</SelectItem>
<SelectItem value="Plaq.File">Plaq. File</SelectItem>
<SelectItem value="Orina">Orina</SelectItem>
<SelectItem value="Cateter">Cateter</SelectItem>
<SelectItem value="Esputo">Esputo</SelectItem>
<SelectItem value="LCR">LCR</SelectItem>
<SelectItem value="Sangre">Sangre</SelectItem>
<SelectItem value="Tejido">Tejido</SelectItem>
<SelectItem value="Otro">Otro</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Protocolo</Label>
<Input value={protocolo} onChange={(e) => setProtocolo(e.target.value)} placeholder="Ej: P-2024-001" />
</div>
<div className="space-y-2">
<Label>Observaciones</Label>
<Input value={observaciones} onChange={(e) => setObservaciones(e.target.value)} />
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setDialogoNuevoAbierto(false)}>Cancelar</Button>
<Button onClick={guardarNuevo}>Guardar</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Dialog Parcial */}
<Dialog open={dialogoParcialAbierto} onOpenChange={setDialogoParcialAbierto}>
<DialogContent>
<DialogHeader>
<DialogTitle>Resultado Parcial</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Germen</Label>
<Input value={germen} onChange={(e) => setGermen(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Sensible a</Label>
<Input value={sensible} onChange={(e) => setSensible(e.target.value)} placeholder="Ej: Ampicilina" />
</div>
<div className="space-y-2">
<Label>Resistente a</Label>
<Input value={resistente} onChange={(e) => setResistente(e.target.value)} placeholder="Ej: Cefalosporinas" />
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setDialogoParcialAbierto(false)}>Cancelar</Button>
<Button onClick={guardarParcial}>Guardar</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Dialog Definitivo */}
<Dialog open={dialogoDefinitivoAbierto} onOpenChange={setDialogoDefinitivoAbierto}>
<DialogContent>
<DialogHeader>
<DialogTitle>Resultado Definitivo</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Fecha de Resultado</Label>
<Input type="date" value={fechaResultado} onChange={(e) => setFechaResultado(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Estado</Label>
<Select value={estadoResultado} onValueChange={(v) => setEstadoResultado(v as Cultivo['estado'])}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Positivo">Positivo</SelectItem>
<SelectItem value="Negativo">Negativo</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setDialogoDefinitivoAbierto(false)}>Cancelar</Button>
<Button onClick={guardarDefinitivo}>Guardar</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+20 -17
View File
@@ -1,9 +1,9 @@
import {
LayoutDashboard,
Bed,
Users,
ClipboardList,
FlaskConical,
Activity,
Activity,
Microscope,
TrendingUp,
AlertCircle,
@@ -52,8 +52,11 @@ export function Dashboard({
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
<p className="text-gray-500 dark:text-gray-400">Resumen del servicio - Clinica Medica</p>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2.5">
<LayoutDashboard className="h-7 w-7 text-blue-600 dark:text-blue-400 shrink-0" />
Dashboard
</h1>
<p className="text-gray-500 dark:text-gray-400">Resumen del servicio - Clínica Médica</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 dark:bg-green-900 dark:text-green-300 dark:border-green-700">
@@ -236,35 +239,35 @@ export function Dashboard({
<div className="grid grid-cols-2 gap-3">
<Button
variant="outline"
className="h-auto py-4 flex flex-col items-center gap-2"
className="h-auto py-4 flex flex-col items-center gap-2 group hover:border-blue-400 hover:bg-blue-50/80 dark:hover:bg-blue-950/40"
onClick={() => onCambiarVista('pacientes')}
>
<Users className="h-6 w-6 text-blue-600" />
<span className="text-sm">Nuevo Paciente</span>
<Users className="h-6 w-6 text-blue-600 dark:text-blue-400 group-hover:scale-110 transition-transform" />
<span className="text-sm font-medium">Nuevo Paciente</span>
</Button>
<Button
variant="outline"
className="h-auto py-4 flex flex-col items-center gap-2"
className="h-auto py-4 flex flex-col items-center gap-2 group hover:border-blue-400 hover:bg-blue-50/80 dark:hover:bg-blue-950/40"
onClick={() => onCambiarVista('internaciones')}
>
<ClipboardList className="h-6 w-6 text-purple-600" />
<span className="text-sm">Nueva Internación</span>
<ClipboardList className="h-6 w-6 text-blue-600 dark:text-blue-400 group-hover:scale-110 transition-transform" />
<span className="text-sm font-medium">Nueva Internación</span>
</Button>
<Button
variant="outline"
className="h-auto py-4 flex flex-col items-center gap-2"
onClick={() => onCambiarVista('laboratorios')}
className="h-auto py-4 flex flex-col items-center gap-2 group hover:border-blue-400 hover:bg-blue-50/80 dark:hover:bg-blue-950/40"
onClick={() => onCambiarVista('camas')}
>
<FlaskConical className="h-6 w-6 text-amber-600" />
<span className="text-sm">Laboratorio</span>
<Bed className="h-6 w-6 text-blue-600 dark:text-blue-400 group-hover:scale-110 transition-transform" />
<span className="text-sm font-medium">Mapa de Camas</span>
</Button>
<Button
variant="outline"
className="h-auto py-4 flex flex-col items-center gap-2"
className="h-auto py-4 flex flex-col items-center gap-2 group hover:border-blue-400 hover:bg-blue-50/80 dark:hover:bg-blue-950/40"
onClick={() => onCambiarVista('cultivos')}
>
<Microscope className="h-6 w-6 text-teal-600" />
<span className="text-sm">Cultivo</span>
<Microscope className="h-6 w-6 text-blue-600 dark:text-blue-400 group-hover:scale-110 transition-transform" />
<span className="text-sm font-medium">Cultivos</span>
</Button>
</div>
</CardContent>
+3 -44
View File
@@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import type { Paciente, Cama, Grupo, Internacion } from '@/types';
import { getNombreProfesional, calcularEdad } from '@/lib/utils';
import { getNombreProfesional, calcularEdad, computeSector, sortCamas } from '@/lib/utils';
import { useHospitalStore } from '@/hooks/useHospitalStore';
interface EditIngresoProps {
@@ -57,48 +57,7 @@ export function EditIngreso({
const grupoFueraDeGrupo = grupos.find(a => normalizeStr(a.nombre) === 'fuera de grupo');
const grupoFueraDeGrupoId = grupoFueraDeGrupo?.id || grupos[0]?.id || 'fuera-de-grupo';
const parseBedNumber = (numero: string) => {
const match = numero.match(/^(\d{3})-(\d+)$/);
if (!match) return { sala: 0, cama: 0 };
return {
sala: parseInt(match[1]),
cama: parseInt(match[2])
};
};
const sortedCamas = [...camas].sort((a, b) => {
const pa = parseBedNumber(a.numero);
const pb = parseBedNumber(b.numero);
const getOrden = (sala: number) => {
const grupo = Math.floor(sala / 100);
const esPar = sala % 2 === 0;
if (grupo === 2) {
if (esPar) return { grupo: 2, suborden: 0, sala };
return { grupo: 1, suborden: 1, sala };
}
if (grupo === 3) {
if (esPar) return { grupo: 3, suborden: 0, sala };
return { grupo: 4, suborden: 1, sala };
}
if (grupo === 4) {
if (esPar) return { grupo: 6, suborden: 0, sala };
return { grupo: 5, suborden: 1, sala };
}
return { grupo: 10, suborden: 1, sala };
};
const oa = getOrden(pa.sala);
const ob = getOrden(pb.sala);
if (oa.grupo !== ob.grupo) return oa.grupo - ob.grupo;
if (oa.suborden === ob.suborden) {
if (oa.suborden === 0) return (oa.sala || pa.sala) - (ob.sala || pb.sala);
return (ob.sala || 0) - (oa.sala || 0);
}
return oa.suborden - ob.suborden;
});
const sortedCamas = sortCamas(camas);
const handleSubmit = async () => {
if (!pacienteSeleccionado) {
@@ -343,7 +302,7 @@ export function EditIngreso({
onChange={(e) => setCamaInput(e.target.value)}
/>
<p className="text-xs text-gray-500 mt-1">
Esta cama será creada como "Fuera de área"
{camaInput.trim() ? `Clasificación: ${computeSector(camaInput.trim())}` : 'Formato XXX-YY (Salas 3XX impares y 4XX son Fuera de Área)'}
</p>
</div>
)}
+7 -4
View File
@@ -11,7 +11,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { toast } from 'sonner';
import type { Evolucion, Internacion, Paciente, Cama, SignosVitales, ExamenFisico } from '@/types';
import { useHospitalStore } from '@/hooks/useHospitalStore';
import { getNombreProfesional } from '@/lib/utils';
import { getNombreProfesional, getLocalToday } from '@/lib/utils';
interface EvolucionesProps {
@@ -46,7 +46,7 @@ export function Evoluciones({
const [evolucionDetalle, setEvolucionDetalle] = useState<Evolucion | null>(null);
const [evolucionEditando, setEvolucionEditando] = useState<Evolucion | null>(null);
const [fecha, setFecha] = useState(new Date().toISOString().split('T')[0]);
const [fecha, setFecha] = useState(getLocalToday());
const [hora, setHora] = useState(new Date().toTimeString().slice(0, 5));
const effectiveMedico = getNombreProfesional(currentUser);
@@ -68,7 +68,7 @@ export function Evoluciones({
const [pendientes, setPendientes] = useState('');
const resetFormulario = () => {
setFecha(new Date().toISOString().split('T')[0]);
setFecha(getLocalToday());
setHora(new Date().toTimeString().slice(0, 5));
setTemperatura('');
setPresionSistolica('');
@@ -207,7 +207,10 @@ export function Evoluciones({
<div className="space-y-6 dark:text-white overflow-x-hidden max-w-screen">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Evoluciones Diarias</h1>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2.5">
<FileText className="h-7 w-7 text-blue-600 dark:text-blue-400 shrink-0" />
Evoluciones Diarias
</h1>
<p className="text-gray-500 dark:text-gray-400">Registro de evoluciones y signos vitales</p>
</div>
<Dialog open={dialogoAbierto} onOpenChange={setDialogoAbierto}>
+5 -2
View File
@@ -9,7 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Plus, Pencil, Trash2 } from 'lucide-react';
import { Plus, Pencil, Trash2, UserCog } from 'lucide-react';
const API_BASE = import.meta.env.VITE_API_URL || '/api';
@@ -177,7 +177,10 @@ export function GestionUsuarios() {
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold dark:text-white">Gestión de Usuarios</h1>
<h1 className="text-2xl font-bold dark:text-white flex items-center gap-2.5">
<UserCog className="h-7 w-7 text-blue-600 dark:text-blue-400 shrink-0" />
Gestión de Usuarios
</h1>
<p className="text-gray-500 dark:text-gray-400">Administrar usuarios del sistema</p>
</div>
<Button className="w-full sm:w-auto" onClick={() => { resetForm(); setDialogOpen(true); }}>
File diff suppressed because it is too large Load Diff
+193 -70
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { ClipboardList, Plus, Search, LogOut, CheckCircle2, MoreHorizontal, FileText, Trash2 } from 'lucide-react';
import { useState, useEffect } from 'react';
import { ClipboardList, Plus, Search, LogOut, CheckCircle2, MoreHorizontal, FileText, Trash2, Bed, User, Calendar, IdCard, Activity, CalendarDays, Clock, Settings, ListTodo } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -11,7 +11,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { toast } from 'sonner';
import type { Internacion, Paciente, Cama, Grupo, Evolucion, Laboratorio, Cultivo } from '@/types';
import { formatDateDDMMYYYY, getNombreProfesional } from '@/lib/utils';
import { formatDateDDMMYYYY, getNombreProfesional, sortCamas, getLocalToday } from '@/lib/utils';
import { useHospitalStore } from '@/hooks/useHospitalStore';
interface InternacionesProps {
@@ -26,13 +26,15 @@ interface InternacionesProps {
onFinalizarInternacion: (internacionId: string, datos: {
fechaEgreso: string;
diagnosticoEgreso: string;
motivoEgreso: Internacion['motivoEgreso']
motivoEgreso: Internacion['motivoEgreso'];
servicioAlQuePasa?: string;
}) => void;
onEliminarInternacion?: (internacionId: string) => Promise<void> | void;
getPacienteById: (id: string) => Paciente | undefined;
getCamaById: (id: string) => Cama | undefined;
onVerHC?: (internacionId: string) => void;
onNuevoIngreso?: () => void;
onVerPendientesDeSala?: () => void;
}
export function Internaciones({
@@ -47,9 +49,14 @@ export function Internaciones({
grupos,
onVerHC,
onNuevoIngreso,
onVerPendientesDeSala,
}: InternacionesProps) {
const { currentUser } = useHospitalStore();
const [filtroEstado, setFiltroEstado] = useState<'todas' | 'activas' | 'finalizadas'>('todas');
const { currentUser, refreshState } = useHospitalStore();
useEffect(() => {
refreshState();
}, [refreshState]);
const [filtroEstado, setFiltroEstado] = useState<'todas' | 'activas' | 'finalizadas'>('activas');
const [dialogoNuevaAbierto, setDialogoNuevaAbierto] = useState(false);
const [dialogoEgresoAbierto, setDialogoEgresoAbierto] = useState(false);
const [dialogoEliminarAbierto, setDialogoEliminarAbierto] = useState(false);
@@ -69,6 +76,7 @@ export function Internaciones({
const [fechaEgreso, setFechaEgreso] = useState('');
const [diagnosticoEgreso, setDiagnosticoEgreso] = useState('');
const [motivoEgreso, setMotivoEgreso] = useState<Internacion['motivoEgreso']>('Alta médica');
const [servicioAlQuePasa, setServicioAlQuePasa] = useState('');
const resetFormularioNueva = () => {
setPacienteSeleccionado('');
@@ -83,6 +91,7 @@ export function Internaciones({
setFechaEgreso('');
setDiagnosticoEgreso('');
setMotivoEgreso('Alta médica');
setServicioAlQuePasa('');
setInternacionSeleccionada(null);
};
@@ -113,16 +122,29 @@ export function Internaciones({
const handleFinalizarInternacion = () => {
if (internacionSeleccionada && fechaEgreso && diagnosticoEgreso && motivoEgreso) {
if (motivoEgreso === 'Pase servicio' && !servicioAlQuePasa.trim()) {
toast.error('Por favor indique el servicio al que pasa');
return;
}
onFinalizarInternacion(internacionSeleccionada.id, {
fechaEgreso,
diagnosticoEgreso,
motivoEgreso,
servicioAlQuePasa: motivoEgreso === 'Pase servicio' ? servicioAlQuePasa : undefined,
});
resetFormularioEgreso();
setDialogoEgresoAbierto(false);
}
};
const sortedCamas = sortCamas(camas);
const getCamaIndex = (camaId?: string) => {
if (!camaId) return 999999;
const index = sortedCamas.findIndex(c => c.id === camaId);
return index !== -1 ? index : 999999;
};
const internacionesFiltradas = internaciones.filter(i => {
const paciente = getPacienteById(i.pacienteId);
const cumpleBusqueda = !busqueda ||
@@ -137,39 +159,20 @@ export function Internaciones({
(filtroEstado === 'finalizadas' && !i.activa);
return cumpleBusqueda && cumpleEstado;
}).sort((a, b) => new Date(b.fechaIngresoClinica || '').getTime() - new Date(a.fechaIngresoClinica || '').getTime());
}).sort((a, b) => {
const indexA = getCamaIndex(a.camaId);
const indexB = getCamaIndex(b.camaId);
if (indexA !== indexB) {
return indexA - indexB;
}
return new Date(b.fechaIngresoClinica || '').getTime() - new Date(a.fechaIngresoClinica || '').getTime();
});
const pacientesSinInternar = pacientes.filter(p => {
const internacionActiva = internaciones.find(i => i.pacienteId === p.id && i.activa);
return !internacionActiva;
});
const parseBedNumber = (numero: string) => {
const match = numero.match(/^(\d{3})-(\d+)$/);
if (!match) return { sala: 0, cama: 0 };
return { sala: parseInt(match[1]), cama: parseInt(match[2]) };
};
const sortedCamas = [...camas].sort((a, b) => {
const pa = parseBedNumber(a.numero);
const pb = parseBedNumber(b.numero);
const getOrden = (sala: number) => {
const grupo = Math.floor(sala / 100);
const esPar = sala % 2 === 0;
if (grupo === 2) return esPar ? { grupo: 2, suborden: 0, sala } : { grupo: 1, suborden: 1, sala };
if (grupo === 3) return esPar ? { grupo: 3, suborden: 0, sala } : { grupo: 4, suborden: 1, sala };
if (grupo === 4) return esPar ? { grupo: 6, suborden: 0, sala } : { grupo: 5, suborden: 1, sala };
return { grupo: 10, suborden: 1, sala };
};
const oa = getOrden(pa.sala);
const ob = getOrden(pb.sala);
if (oa.grupo !== ob.grupo) return oa.grupo - ob.grupo;
if (oa.suborden === ob.suborden) {
return oa.suborden === 0 ? (oa.sala - ob.sala) : (ob.sala - oa.sala);
}
return oa.suborden - ob.suborden;
});
const getGrupoName = (grupoId?: string) => grupos.find(a => a.id === grupoId)?.nombre;
const calcularEdad = (fechaNacimiento?: string) => {
@@ -212,7 +215,7 @@ export function Internaciones({
const abrirDialogoEgreso = (internacion: Internacion) => {
setInternacionSeleccionada(internacion);
setFechaEgreso(new Date().toISOString().split('T')[0]);
setFechaEgreso(getLocalToday());
setDialogoEgresoAbierto(true);
};
@@ -223,16 +226,20 @@ export function Internaciones({
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Internaciones</h1>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2.5">
<ClipboardList className="h-7 w-7 text-blue-600 dark:text-blue-400 shrink-0" />
Internaciones
</h1>
<p className="text-gray-500 dark:text-gray-400">Gestión de internaciones en sala</p>
</div>
<Dialog open={dialogoNuevaAbierto} onOpenChange={setDialogoNuevaAbierto}>
<DialogTrigger asChild>
<Button variant="outline" onClick={() => onNuevoIngreso ? onNuevoIngreso() : setDialogoNuevaAbierto(true)}>
<Plus className="h-4 w-4 mr-2" />
Nuevo Ingreso
</Button>
</DialogTrigger>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<Dialog open={dialogoNuevaAbierto} onOpenChange={setDialogoNuevaAbierto}>
<DialogTrigger asChild>
<Button onClick={() => onNuevoIngreso ? onNuevoIngreso() : setDialogoNuevaAbierto(true)}>
<Plus className="h-4 w-4 mr-2" />
Nuevo Ingreso
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Nuevo Ingreso</DialogTitle>
@@ -354,7 +361,7 @@ export function Internaciones({
placeholder="Nombre del médico ingresante"
/>
</div>
<Button variant="outline"
<Button
className="w-full"
onClick={handleIniciarInternacion}
disabled={!pacienteSeleccionado || !camaSeleccionada || !motivoConsulta || !enfermedadActual || !effectiveMedico}
@@ -364,7 +371,17 @@ export function Internaciones({
</Button>
</div>
</DialogContent>
</Dialog>
</Dialog>
<Button
variant="outline"
onClick={() => onVerPendientesDeSala ? onVerPendientesDeSala() : null}
className="flex items-center gap-2"
>
<ListTodo className="h-4 w-4" />
Pendientes
</Button>
</div>
</div>
{/* Filtros */}
@@ -412,15 +429,66 @@ export function Internaciones({
<Table className="w-full min-w-max text-sm">
<TableHeader>
<TableRow>
<TableHead>Cama</TableHead>
<TableHead>Apellido</TableHead>
<TableHead>Nombre</TableHead>
<TableHead className="text-center">Edad</TableHead>
<TableHead className="text-center">DNI</TableHead>
<TableHead className="text-center">Estado</TableHead>
<TableHead className="text-center">Fecha Ingreso</TableHead>
<TableHead className="text-center">Duración</TableHead>
<TableHead className="text-center w-[60px]">Acciones</TableHead>
<TableHead>
<div className="flex items-center gap-1.5">
<Bed className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>Cama</span>
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-1.5">
<User className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>Apellido</span>
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-1.5">
<User className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>Nombre</span>
</div>
</TableHead>
<TableHead className="text-center">
<div className="flex items-center justify-center gap-1.5">
<Calendar className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>Edad</span>
</div>
</TableHead>
<TableHead className="text-center">
<div className="flex items-center justify-center gap-1.5">
<IdCard className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>DNI</span>
</div>
</TableHead>
<TableHead className="text-center">
<div className="flex items-center justify-center gap-1.5">
<Activity className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>Estado</span>
</div>
</TableHead>
<TableHead className="text-center">
<div className="flex items-center justify-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>Fecha Ingreso</span>
</div>
</TableHead>
<TableHead className="text-center">
<div className="flex items-center justify-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>Fecha Egreso</span>
</div>
</TableHead>
<TableHead className="text-center">
<div className="flex items-center justify-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>Duración</span>
</div>
</TableHead>
<TableHead className="text-center w-[60px]">
<div className="flex items-center justify-center gap-1.5">
<Settings className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span className="sr-only">Acciones</span>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -449,19 +517,63 @@ export function Internaciones({
{paciente?.dni || '-'}
</TableCell>
<TableCell className="text-center">
{internacion.activa ? (
<Badge className="bg-purple-100 text-purple-800 dark:bg-purple-950/80 dark:text-purple-300 border-purple-200 hover:bg-purple-100">
Activa
</Badge>
) : (
<Badge variant="outline" className="bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300">
Alta
</Badge>
)}
{(() => {
if (internacion.activa) {
return (
<Badge className="bg-purple-100 text-purple-800 dark:bg-purple-950/80 dark:text-purple-300 border-purple-200 hover:bg-purple-100">
Activa
</Badge>
);
}
const motivo = internacion.motivoEgreso;
if (motivo === 'Alta médica') {
return (
<Badge variant="outline" className="bg-emerald-100 text-emerald-800 dark:bg-emerald-950/80 dark:text-emerald-300 border-emerald-200 hover:bg-emerald-100">
Alta
</Badge>
);
}
if (motivo === 'Egreso Voluntario') {
return (
<Badge variant="outline" className="bg-blue-100 text-blue-800 dark:bg-blue-950/80 dark:text-blue-300 border-blue-200 hover:bg-blue-100">
Egreso Voluntario
</Badge>
);
}
if (motivo === 'Obito') {
return (
<Badge variant="outline" className="bg-slate-200 text-slate-800 dark:bg-slate-800 dark:text-slate-300 border-slate-300 hover:bg-slate-200">
Óbito
</Badge>
);
}
if (motivo === 'Pase servicio') {
return (
<Badge variant="outline" className="bg-indigo-100 text-indigo-800 dark:bg-indigo-950/80 dark:text-indigo-300 border-indigo-200 hover:bg-indigo-100">
Pase Servicio
</Badge>
);
}
if (motivo === 'Derivación') {
return (
<Badge variant="outline" className="bg-amber-100 text-amber-800 dark:bg-amber-950/80 dark:text-amber-300 border-amber-200 hover:bg-amber-100">
Derivación
</Badge>
);
}
return (
<Badge variant="outline" className="bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 hover:bg-gray-100">
Alta
</Badge>
);
})()}
</TableCell>
<TableCell className="text-center text-xs whitespace-nowrap">
{fechaIng}
</TableCell>
<TableCell className="text-center text-xs whitespace-nowrap">
{internacion.fechaEgreso ? formatDateDDMMYYYY(internacion.fechaEgreso) : '-'}
</TableCell>
<TableCell className="text-center text-xs whitespace-nowrap">
{(() => {
const dias = calcularDiasDuracion(internacion);
@@ -524,7 +636,7 @@ export function Internaciones({
<Dialog open={dialogoEgresoAbierto} onOpenChange={(open) => { if (!open) resetFormularioEgreso(); setDialogoEgresoAbierto(open); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Dar Alta</DialogTitle>
<DialogTitle>Registrar Egreso</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
@@ -548,19 +660,30 @@ export function Internaciones({
</div>
<div>
<Label>Motivo de Egreso *</Label>
<Select value={motivoEgreso} onValueChange={(v) => setMotivoEgreso(v as Internacion['motivoEgreso'])}>
<Select value={motivoEgreso} onValueChange={(v) => { setMotivoEgreso(v as Internacion['motivoEgreso']); if (v !== 'Pase servicio') setServicioAlQuePasa(''); }}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Alta médica">Alta médica</SelectItem>
<SelectItem value="Alta voluntaria">Alta voluntaria</SelectItem>
<SelectItem value="Egreso Voluntario">Egreso Voluntario</SelectItem>
<SelectItem value="Derivación">Derivación</SelectItem>
<SelectItem value="Fallecimiento">Fallecimiento</SelectItem>
<SelectItem value="Obito">Obito</SelectItem>
<SelectItem value="Pase servicio">Pase servicio</SelectItem>
<SelectItem value="Otro">Otro</SelectItem>
</SelectContent>
</Select>
</div>
{motivoEgreso === 'Pase servicio' && (
<div>
<Label>Servicio al que pasa *</Label>
<Input
value={servicioAlQuePasa}
onChange={(e) => setServicioAlQuePasa(e.target.value)}
placeholder="Ej. Terapia Intensiva, Quirófano, etc."
/>
</div>
)}
<div>
<Label>Diagnóstico de Egreso *</Label>
<textarea
@@ -570,10 +693,10 @@ export function Internaciones({
placeholder="Ingrese el diagnóstico de egreso..."
/>
</div>
<Button variant="outline"
className="w-full"
onClick={handleFinalizarInternacion}
disabled={!fechaEgreso || !diagnosticoEgreso || !motivoEgreso}
<Button
className="w-full"
onClick={handleFinalizarInternacion}
disabled={!fechaEgreso || !diagnosticoEgreso || !motivoEgreso || (motivoEgreso === 'Pase servicio' && !servicioAlQuePasa.trim())}
>
<CheckCircle2 className="h-4 w-4 mr-2" />
Confirmar Egreso
+12 -6
View File
@@ -11,7 +11,8 @@ import {
Sun,
Moon,
LogOut,
UserCog
UserCog,
Terminal
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger, SheetTitle, SheetDescription, SheetHeader } from '@/components/ui/sheet';
@@ -59,10 +60,11 @@ export function Layout({ children, vistaActual, onCambiarVista, currentUser, onL
if ((currentUser?.rol as string) === 'admin') {
filteredMenuItems.push({ vista: 'usuarios', label: 'Usuarios', icon: UserCog });
filteredMenuItems.push({ vista: 'sistema', label: 'Sistema', icon: Terminal });
}
const renderNavContent = (onItemClick?: () => void) => (
<nav className="flex flex-col gap-2">
<nav className="flex flex-col gap-1.5">
{filteredMenuItems.map((item) => {
const Icon = item.icon;
const isActive = vistaActual === item.vista;
@@ -70,13 +72,17 @@ export function Layout({ children, vistaActual, onCambiarVista, currentUser, onL
<Button
key={item.vista}
variant={isActive ? 'default' : 'ghost'}
className={`justify-start gap-3 ${isActive ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-200'}`}
className={`justify-start gap-3 h-10 px-3.5 rounded-lg transition-all duration-150 ${
isActive
? 'shadow-xs font-semibold'
: 'text-slate-600 dark:text-slate-300 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-50/80 dark:hover:bg-blue-950/40 font-medium'
}`}
onClick={() => {
onCambiarVista(item.vista);
if (onItemClick) onItemClick();
}}
>
<Icon className="h-5 w-5" />
<Icon className={`h-5 w-5 ${isActive ? 'text-white' : 'text-blue-600/70 dark:text-blue-400'}`} />
<span>{item.label}</span>
</Button>
);
@@ -200,8 +206,8 @@ export function Layout({ children, vistaActual, onCambiarVista, currentUser, onL
</header>
{/* Main Content */}
<main className="flex-1 lg:ml-64 pt-16 lg:pt-0 min-h-screen">
<div className="p-4 lg:p-8 max-w-7xl mx-auto">
<main className="flex-1 min-w-0 lg:ml-64 pt-16 lg:pt-0 min-h-screen">
<div className="p-4 lg:p-8 max-w-7xl mx-auto w-full">
{children}
</div>
</main>
+8 -6
View File
@@ -4,7 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Shield, Lock, User } from 'lucide-react';
import { Lock, User, Hospital } from 'lucide-react';
export function Login() {
const { login } = useHospitalStore();
@@ -30,12 +30,14 @@ export function Login() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center">
<Shield className="w-8 h-8 text-white" />
<Card className="w-full max-w-md shadow-lg border-neutral-200 dark:border-neutral-800">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-3 w-16 h-16 rounded-full bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 flex items-center justify-center shadow-sm">
<Hospital className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
<CardTitle className="text-2xl">Sistema de Gestión Hospitalaria</CardTitle>
<CardTitle className="text-2xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100">
Sistema de Gestión Hospitalaria
</CardTitle>
<CardDescription>Ingrese sus credenciales para acceder</CardDescription>
</CardHeader>
<CardContent>
+65 -80
View File
@@ -3,7 +3,7 @@ import { Bed, CheckCircle2, Clock, Wrench, User, Plus, Trash, Edit2, Save, X } f
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { formatDateDDMMYYYY, getNombreProfesional, isCamaFueraDeGrupo } from '@/lib/utils';
import { formatDateDDMMYYYY, getNombreProfesional, isCamaFueraDeGrupo, computeSector, sortCamas } from '@/lib/utils';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -86,7 +86,7 @@ export function MapaCamas({
}
if (cama.estado === 'Ocupada') {
// Tipos de aislamiento
const aislamientos = ['Aislamiento KPC', 'Aislamiento COVID', 'Aislamiento Clostridium', 'Aislamiento Neutropenico'];
const aislamientos = ['Aislamiento KPC', 'Aislamiento COVID', 'Aislamiento Clostridium', 'Aislamiento Neutropenico', 'Aislamiento TBC', 'Aislamiento Escabiosis'];
if (aislamientos.includes(cama.tipo)) {
return 'bg-red-100 border-red-300 text-red-800 dark:bg-red-950/80 dark:border-red-700/60 dark:text-red-200';
}
@@ -104,48 +104,7 @@ export function MapaCamas({
}
};
const parseBedNumber = (numero: string) => {
const match = numero.match(/^(\d{3})-(\d+)$/);
if (!match) return { sala: 0, cama: 0 };
return {
sala: parseInt(match[1]),
cama: parseInt(match[2])
};
};
const sortedCamas = [...camasFiltradas].sort((a, b) => {
const pa = parseBedNumber(a.numero);
const pb = parseBedNumber(b.numero);
const getOrden = (sala: number) => {
const grupo = Math.floor(sala / 100);
const esPar = sala % 2 === 0;
if (grupo === 2) {
if (esPar) return { grupo: 2, suborden: 0, sala }; // 2XX pares, menor a mayor
return { grupo: 1, suborden: 1, sala }; // 2XX impares, mayor a menor
}
if (grupo === 3) {
if (esPar) return { grupo: 3, suborden: 0, sala }; // 3XX pares, menor a mayor
return { grupo: 4, suborden: 1, sala }; // 3XX impares, mayor a menor
}
if (grupo === 4) {
if (esPar) return { grupo: 6, suborden: 0, sala }; // 4XX pares, menor a mayor
return { grupo: 5, suborden: 1, sala }; // 4XX impares, mayor a menor
}
return { grupo: 10, suborden: 1, sala };
};
const oa = getOrden(pa.sala);
const ob = getOrden(pb.sala);
if (oa.grupo !== ob.grupo) return oa.grupo - ob.grupo;
if (oa.suborden === ob.suborden) {
if (oa.suborden === 0) return (oa.sala || pa.sala) - (ob.sala || pb.sala);
return (ob.sala || 0) - (oa.sala || 0);
}
return oa.suborden - ob.suborden;
});
const sortedCamas = sortCamas(camasFiltradas);
const handleOcuparCama = () => {
if (camaSeleccionada && grupoSeleccionada && pacienteSeleccionado && diagnostico && enfermedadActual && effectiveMedico) {
@@ -181,34 +140,44 @@ export function MapaCamas({
<div className="space-y-6 dark:text-white">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Mapa de Camas</h1>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2.5">
<Bed className="h-7 w-7 text-blue-600 dark:text-blue-400 shrink-0" />
Camas
</h1>
<p className="text-gray-500 dark:text-gray-400">Gestión de camas del servicio</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="bg-green-50 text-green-700 dark:bg-green-900 dark:text-green-300 dark:border-green-700">
{camas.filter(c => c.estado === 'Disponible' && !isCamaFueraDeGrupo(c, grupos)).length} Disponibles
</Badge>
<Badge variant="outline" className="bg-red-50 text-red-700 dark:bg-red-900 dark:text-red-300 dark:border-red-700">
{camas.filter(c => c.estado === 'Ocupada' && !isCamaFueraDeGrupo(c, grupos)).length} Ocupadas
</Badge>
<div className="ml-4 flex items-center gap-2 flex-wrap sm:flex-nowrap">
<Button variant="outline" onClick={() => {
setEditingGrupoId(null);
setGrupoNombre('');
setGrupoDialogOpen(true);
}} className="text-xs px-2 py-1 sm:text-sm sm:px-4 sm:py-2">
Administrar Grupos
</Button>
<Button variant="outline" onClick={() => {
setEditingBed(null);
setBedNumero('');
setBedTipo('General');
setBedGrupoId(grupos?.[0]?.id);
setBedDialogOpen(true);
}} className="text-xs px-2 py-1 sm:text-sm sm:px-4 sm:py-2">
<Plus className="h-3 w-3 sm:h-4 sm:w-4 sm:mr-1" />
<span className="hidden sm:inline">Agregar Cama</span>
</Button>
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-800">
{camas.filter(c => isCamaFueraDeGrupo(c)).length} Fuera de Área
</Badge>
<div className="ml-0 sm:ml-4 flex items-center gap-2 flex-wrap sm:flex-nowrap">
{currentUser?.rol === 'admin' && (
<Button variant="secondary" onClick={() => {
setEditingGrupoId(null);
setGrupoNombre('');
setGrupoDialogOpen(true);
}} className="text-xs px-2.5 py-1.5 sm:text-sm sm:px-4 sm:py-2">
Administrar Grupos
</Button>
)}
{currentUser?.rol === 'admin' && (
<Button onClick={() => {
setEditingBed(null);
setBedNumero('');
setBedTipo('General');
setBedGrupoId(grupos?.[0]?.id);
setBedDialogOpen(true);
}} className="text-xs px-2.5 py-1.5 sm:text-sm sm:px-4 sm:py-2">
<Plus className="h-3.5 w-3.5 sm:h-4 sm:w-4 mr-1" />
<span>Agregar Cama</span>
</Button>
)}
</div>
</div>
</div>
@@ -218,7 +187,7 @@ export function MapaCamas({
<CardContent className="p-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<Label className="text-xs text-gray-500 mb-1 block">Grupo / Área</Label>
<Label className="text-xs text-gray-500 mb-1 block">Grupo</Label>
<Select value={filtroSala} onValueChange={setFiltroSala}>
<SelectTrigger>
<SelectValue placeholder="Todos los grupos" />
@@ -283,7 +252,7 @@ export function MapaCamas({
{getEstadoIcono(cama)}
</div>
<p className="font-bold text-sm sm:text-lg">{cama.numero}</p>
<p className="text-xs opacity-75 truncate">{cama.grupoId ? grupoById[cama.grupoId] ?? 'Grupo desconocido' : 'Sin grupo'} - {cama.sector || 'En Área'}</p>
<p className="text-xs opacity-75 truncate">{cama.grupoId ? grupoById[cama.grupoId] ?? 'Grupo desconocido' : 'Sin grupo'} - {computeSector(cama.numero)}</p>
<Badge variant="outline" className="mt-1 sm:mt-2 text-xs bg-white/50 dark:bg-gray-800 dark:text-gray-300">
{cama.tipo}
</Badge>
@@ -431,7 +400,7 @@ export function MapaCamas({
</Dialog>
)}
{cama.estado !== 'Disponible' && canEditCama(cama.id) && (
{cama.estado !== 'Disponible' && cama.estado !== 'Ocupada' && canEditCama(cama.id) && (
<Button
variant="outline"
className="w-full"
@@ -442,7 +411,7 @@ export function MapaCamas({
</Button>
)}
{cama.estado !== 'Reparacion' && canEditCama(cama.id) && (
{cama.estado !== 'Reparacion' && cama.estado !== 'Ocupada' && canEditCama(cama.id) && (
<Button
variant="outline"
className="w-full"
@@ -453,7 +422,7 @@ export function MapaCamas({
</Button>
)}
{cama.estado !== 'Reservada' && canEditCama(cama.id) && (
{cama.estado !== 'Reservada' && cama.estado !== 'Ocupada' && canEditCama(cama.id) && (
<Button
variant="outline"
className="w-full"
@@ -476,7 +445,7 @@ export function MapaCamas({
<Edit2 className="h-4 w-4 mr-1" />Editar
</Button>
)}
{canEditCama(cama.id) && (
{canEditCama(cama.id) && currentUser?.rol === 'admin' && (
<Button variant="destructive" onClick={() => {
onEliminarCama(cama.id);
}}>
@@ -511,15 +480,15 @@ export function MapaCamas({
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setGrupoDialogOpen(false)}>
<X className="h-4 w-4" />
<X className="h-4 w-4 mr-1" /> Cancelar
</Button>
<Button size="sm" variant="outline" onClick={() => {
<Button size="sm" onClick={() => {
if (!grupoNombre.trim()) return alert('Nombre requerido');
if (editingGrupoId) onActualizarGrupo(editingGrupoId, { nombre: grupoNombre });
else onAgregarGrupo({ nombre: grupoNombre });
setGrupoDialogOpen(false);
}}>
<Save className="h-4 w-4" />
<Save className="h-4 w-4 mr-1" /> Guardar
</Button>
</div>
<div>
@@ -553,7 +522,11 @@ export function MapaCamas({
<div className="space-y-4">
<div>
<Label>Número</Label>
<Input value={bedNumero} onChange={(e) => setBedNumero(e.target.value)} />
<Input
value={bedNumero}
onChange={(e) => setBedNumero(e.target.value)}
disabled={currentUser?.rol !== 'admin'}
/>
</div>
<div>
<Label>Tipo</Label>
@@ -567,12 +540,18 @@ export function MapaCamas({
<SelectItem value="Aislamiento COVID">Aislamiento COVID</SelectItem>
<SelectItem value="Aislamiento Clostridium">Aislamiento Clostridium</SelectItem>
<SelectItem value="Aislamiento Neutropenico">Aislamiento Neutropenico</SelectItem>
<SelectItem value="Aislamiento TBC">Aislamiento TBC</SelectItem>
<SelectItem value="Aislamiento Escabiosis">Aislamiento Escabiosis</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Grupo / Área</Label>
<Select value={bedGrupoId ?? '__none'} onValueChange={(v) => setBedGrupoId(v === '__none' ? undefined : v)}>
<Label>Grupo</Label>
<Select
value={bedGrupoId ?? '__none'}
onValueChange={(v) => setBedGrupoId(v === '__none' ? undefined : v)}
disabled={currentUser?.rol !== 'admin'}
>
<SelectTrigger>
<SelectValue placeholder="Seleccione grupo" />
</SelectTrigger>
@@ -585,13 +564,16 @@ export function MapaCamas({
</Select>
</div>
<div className="flex gap-2 justify-end">
{editingBed && (
{editingBed && currentUser?.rol === 'admin' && (
<Button variant="destructive" onClick={() => { onEliminarCama(editingBed.id); setBedDialogOpen(false); }}>
Eliminar
</Button>
)}
<Button variant="outline" onClick={() => setBedDialogOpen(false)}><X className="h-4 w-4 mr-2" />Cancelar</Button>
<Button variant="outline" onClick={() => {
<Button variant="outline" onClick={() => setBedDialogOpen(false)}>
<X className="h-4 w-4 mr-2" />
Cancelar
</Button>
<Button onClick={() => {
if (!bedNumero.trim()) return alert('Número requerido');
if (editingBed) {
onActualizarCama(editingBed.id, { numero: bedNumero, tipo: bedTipo, grupoId: bedGrupoId });
@@ -599,7 +581,10 @@ export function MapaCamas({
onAgregarCama({ numero: bedNumero, tipo: bedTipo, estado: 'Disponible', grupoId: bedGrupoId });
}
setBedDialogOpen(false);
}}>Guardar</Button>
}}>
<Save className="h-4 w-4 mr-2" />
Guardar
</Button>
</div>
</div>
</DialogContent>
+4 -45
View File
@@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import type { Paciente, Cama, Grupo, Internacion } from '@/types';
import { getNombreProfesional } from '@/lib/utils';
import { getNombreProfesional, computeSector, sortCamas } from '@/lib/utils';
import { useHospitalStore } from '@/hooks/useHospitalStore';
interface NuevoIngresoProps {
@@ -64,48 +64,7 @@ export function NuevoIngreso({ pacientes, camas, grupos, onIniciarInternacion, o
return !internacionActiva;
});
const parseBedNumber = (numero: string) => {
const match = numero.match(/^(\d{3})-(\d+)$/);
if (!match) return { sala: 0, cama: 0 };
return {
sala: parseInt(match[1]),
cama: parseInt(match[2])
};
};
const sortedCamas = [...camas].sort((a, b) => {
const pa = parseBedNumber(a.numero);
const pb = parseBedNumber(b.numero);
const getOrden = (sala: number) => {
const grupo = Math.floor(sala / 100);
const esPar = sala % 2 === 0;
if (grupo === 2) {
if (esPar) return { grupo: 2, suborden: 0, sala };
return { grupo: 1, suborden: 1, sala };
}
if (grupo === 3) {
if (esPar) return { grupo: 3, suborden: 0, sala };
return { grupo: 4, suborden: 1, sala };
}
if (grupo === 4) {
if (esPar) return { grupo: 6, suborden: 0, sala };
return { grupo: 5, suborden: 1, sala };
}
return { grupo: 10, suborden: 1, sala };
};
const oa = getOrden(pa.sala);
const ob = getOrden(pb.sala);
if (oa.grupo !== ob.grupo) return oa.grupo - ob.grupo;
if (oa.suborden === ob.suborden) {
if (oa.suborden === 0) return (oa.sala || pa.sala) - (ob.sala || pb.sala);
return (ob.sala || 0) - (oa.sala || 0);
}
return oa.suborden - ob.suborden;
});
const sortedCamas = sortCamas(camas);
const handleSubmit = async () => {
// Validaciones explícitas con feedback claro
@@ -162,7 +121,7 @@ export function NuevoIngreso({ pacientes, camas, grupos, onIniciarInternacion, o
grupoId: grupoSeleccionado,
tipo: 'General',
estado: 'Ocupada',
sector: 'Fuera de Área'
sector: computeSector(numeroCama)
});
} else {
toast.error('No se puede crear la cama');
@@ -381,7 +340,7 @@ export function NuevoIngreso({ pacientes, camas, grupos, onIniciarInternacion, o
onChange={(e) => setCamaInput(e.target.value)}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Esta cama será creada como "Fuera de área"
{camaInput.trim() ? `Clasificación: ${computeSector(camaInput.trim())}` : 'Formato XXX-YY (Salas 3XX impares y 4XX son Fuera de Área)'}
</p>
</div>
)}
+303 -111
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Users, Plus, Search, Edit2, Trash2, User, Calendar, Phone, Mail, Droplet, AlertTriangle, X, Save } from 'lucide-react';
import { Users, Plus, Search, Edit2, Trash2, User, Calendar, Phone, AlertTriangle, X, Save, IdCard, Activity, Settings, ShieldCheck, MoreHorizontal, Eye } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -7,7 +7,9 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import type { Paciente } from '@/types';
interface PacientesProps {
@@ -28,6 +30,9 @@ export function Pacientes({
const [busqueda, setBusqueda] = useState('');
const [dialogoAbierto, setDialogoAbierto] = useState(false);
const [pacienteEditando, setPacienteEditando] = useState<Paciente | null>(null);
const [pacienteAEliminar, setPacienteAEliminar] = useState<Paciente | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [pacienteVerDetalle, setPacienteVerDetalle] = useState<Paciente | null>(null);
// Formulario
const [nombre, setNombre] = useState('');
@@ -142,12 +147,15 @@ export function Pacientes({
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Pacientes</h1>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2.5">
<Users className="h-7 w-7 text-blue-600 dark:text-blue-400 shrink-0" />
Pacientes
</h1>
<p className="text-gray-500 dark:text-gray-400">Gestión de pacientes del hospital</p>
</div>
<Dialog open={dialogoAbierto} onOpenChange={setDialogoAbierto}>
<DialogTrigger asChild>
<Button variant="outline" onClick={resetFormulario}>
<Button onClick={resetFormulario}>
<Plus className="h-4 w-4 mr-2" />
Nuevo Paciente
</Button>
@@ -299,7 +307,7 @@ export function Pacientes({
<X className="h-4 w-4 mr-2" />
Cancelar
</Button>
<Button variant="outline"
<Button
onClick={handleGuardar}
disabled={!nombre || !apellido || !dni || !fechaNacimiento}
>
@@ -326,128 +334,312 @@ export function Pacientes({
</CardContent>
</Card>
{/* Lista de Pacientes */}
<div className="grid grid-cols-1 gap-4">
{pacientesFiltrados.map((paciente) => (
<Card key={paciente.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-start gap-4">
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
<User className="h-6 w-6 text-blue-600" />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-bold text-lg">
{paciente.apellido}, {paciente.nombre}
</h3>
{estaInternado(paciente.id) && (
<Badge className="bg-purple-100 text-purple-800">
Internado
</Badge>
)}
{/* Tabla de Pacientes */}
{pacientesFiltrados.length > 0 ? (
<div className="grid grid-cols-1 w-full">
<div className="w-full overflow-x-auto rounded-md border bg-card text-card-foreground shadow-sm pb-2">
<Table className="w-full min-w-max text-sm">
<TableHeader>
<TableRow>
<TableHead>
<div className="flex items-center gap-1.5">
<User className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>Apellido</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-1 mt-2 text-sm text-gray-500">
<div className="flex items-center gap-1">
<span className="font-medium">DNI:</span> {paciente.dni}
</div>
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{getEdad(paciente.fechaNacimiento)} años
</div>
<div className="flex items-center gap-1">
<span className="font-medium">Sexo:</span> {paciente.sexo}
</div>
{paciente.grupoSanguineo && (
<div className="flex items-center gap-1">
<Droplet className="h-3 w-3" />
{paciente.grupoSanguineo}
</TableHead>
<TableHead>
<div className="flex items-center gap-1.5">
<User className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>Nombre</span>
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-1.5">
<IdCard className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>DNI</span>
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>Edad</span>
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>Sexo</span>
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-1.5">
<ShieldCheck className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>Obra Social</span>
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-1.5">
<Phone className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>Teléfono</span>
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-1.5">
<Activity className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span>Estado</span>
</div>
</TableHead>
<TableHead className="w-[80px]">
<div className="flex items-center gap-1.5">
<Settings className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<span className="sr-only">Acciones</span>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pacientesFiltrados.map((paciente) => {
const internado = estaInternado(paciente.id);
return (
<TableRow key={paciente.id} className="hover:bg-muted/50">
<TableCell className="font-medium text-gray-900 dark:text-white whitespace-nowrap">
<div className="flex items-center gap-2">
<span>{paciente.apellido}</span>
{paciente.alergias && (
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200 text-xs px-1.5 py-0 shrink-0">
<AlertTriangle className="h-2.5 w-2.5 mr-0.5" />
Alergia
</Badge>
)}
</div>
)}
</div>
<div className="flex flex-wrap gap-3 mt-2 text-sm text-gray-500">
<div className="flex items-center gap-1">
<Phone className="h-3 w-3" />
{paciente.telefono}
</div>
{paciente.email && (
<div className="flex items-center gap-1">
<Mail className="h-3 w-3" />
{paciente.email}
</div>
)}
</div>
{(paciente.alergias || paciente.antecedentes) && (
<div className="mt-2 flex flex-wrap gap-2">
{paciente.alergias && (
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200">
<AlertTriangle className="h-3 w-3 mr-1" />
Alergias
</TableCell>
<TableCell className="text-gray-700 dark:text-gray-300 whitespace-nowrap">
{paciente.nombre}
</TableCell>
<TableCell className="text-gray-600 dark:text-gray-400 font-mono whitespace-nowrap">
{paciente.dni}
</TableCell>
<TableCell className="text-gray-600 dark:text-gray-400 whitespace-nowrap">
{getEdad(paciente.fechaNacimiento)}
</TableCell>
<TableCell className="text-gray-600 dark:text-gray-400 whitespace-nowrap">
{paciente.sexo}
</TableCell>
<TableCell className="text-gray-600 dark:text-gray-400 whitespace-nowrap">
{paciente.obraSocial || '-'}
</TableCell>
<TableCell className="text-gray-600 dark:text-gray-400 whitespace-nowrap">
{paciente.telefono || '-'}
</TableCell>
<TableCell className="whitespace-nowrap">
{internado ? (
<Badge className="bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300 border-purple-200">
Internado
</Badge>
) : (
<Badge className="bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300 border border-emerald-500 font-medium">
Ambulatorio
</Badge>
)}
{paciente.antecedentes && (
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
Antecedentes
</Badge>
)}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditar(paciente)}
>
<Edit2 className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="text-red-600 hover:bg-red-50">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar paciente?</AlertDialogTitle>
<AlertDialogDescription>
Esta acción no se puede deshacer. Se eliminará permanentemente el paciente{' '}
<strong>{paciente.apellido}, {paciente.nombre}</strong>.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => onEliminar(paciente.id)}
className="bg-red-600 hover:bg-red-700"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{pacientesFiltrados.length === 0 && (
</TableCell>
<TableCell className="whitespace-nowrap">
<div className="flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setPacienteVerDetalle(paciente)}>
<Eye className="h-4 w-4 mr-2 text-teal-600 dark:text-teal-400" />
Detalles
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEditar(paciente)}>
<Edit2 className="h-4 w-4 mr-2 text-blue-600" />
Editar paciente
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600 focus:text-red-600 focus:bg-red-50 dark:focus:bg-red-950/50"
onClick={() => {
setPacienteAEliminar(paciente);
setDeleteDialogOpen(true);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
) : (
<div className="text-center py-12 text-gray-400">
<Users className="h-16 w-16 mx-auto mb-4 opacity-50" />
<p className="text-lg">
{busqueda ? 'No se encontraron pacientes con esa búsqueda' : 'No hay pacientes registrados'}
</p>
{!busqueda && (
<Button variant="outline" className="mt-4" onClick={() => setDialogoAbierto(true)}>
<Button className="mt-4" onClick={() => setDialogoAbierto(true)}>
<Plus className="h-4 w-4 mr-2" />
Agregar primer paciente
</Button>
)}
</div>
)}
{/* Dialogo de confirmacion de eliminacion */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar paciente?</AlertDialogTitle>
<AlertDialogDescription>
Esta acción no se puede deshacer. Se eliminará permanentemente el paciente{' '}
<strong>{pacienteAEliminar?.apellido}, {pacienteAEliminar?.nombre}</strong>.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPacienteAEliminar(null)}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (pacienteAEliminar) {
onEliminar(pacienteAEliminar.id);
setPacienteAEliminar(null);
}
}}
className="bg-red-600 hover:bg-red-700"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Dialogo de detalles del paciente */}
<Dialog open={!!pacienteVerDetalle} onOpenChange={(open) => { if (!open) setPacienteVerDetalle(null); }}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-xl">
<User className="h-5 w-5 text-teal-600 dark:text-teal-400" />
Detalles del Paciente
</DialogTitle>
</DialogHeader>
{pacienteVerDetalle && (
<div className="space-y-4 pt-1">
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg space-y-3 border border-gray-100 dark:border-gray-800">
<div className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{pacienteVerDetalle.apellido || '—'}, {pacienteVerDetalle.nombre || '—'}
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400 block text-xs">DNI</span>
<span className="font-mono font-medium text-gray-800 dark:text-gray-200">{pacienteVerDetalle.dni || 'Sin especificar'}</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400 block text-xs">Sexo</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{pacienteVerDetalle.sexo || 'Sin especificar'}</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400 block text-xs">Fecha de Nacimiento</span>
<span className="font-medium text-gray-800 dark:text-gray-200">
{pacienteVerDetalle.fechaNacimiento ? `${pacienteVerDetalle.fechaNacimiento} (${getEdad(pacienteVerDetalle.fechaNacimiento)})` : 'Sin especificar'}
</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400 block text-xs">Grupo Sanguíneo</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{pacienteVerDetalle.grupoSanguineo || 'Sin especificar'}</span>
</div>
<div className="col-span-2">
<span className="text-gray-500 dark:text-gray-400 block text-xs">Estado Actual</span>
{internaciones.some(i => i.pacienteId === pacienteVerDetalle.id && i.activa) ? (
<Badge className="bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300 border-purple-200 mt-0.5">
Internado
</Badge>
) : (
<Badge className="bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300 border border-emerald-500 font-medium mt-0.5">
Ambulatorio
</Badge>
)}
</div>
</div>
</div>
{/* Informacion de contacto, filiación y cobertura */}
<div className="space-y-2 text-sm text-gray-700 dark:text-gray-300 px-1">
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-800">
<span className="text-gray-500 dark:text-gray-400">Historia Clínica:</span>
<span className="font-mono font-medium">{pacienteVerDetalle.historiaClinica || 'Sin especificar'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-800">
<span className="text-gray-500 dark:text-gray-400">Obra Social:</span>
<span className="font-medium">{pacienteVerDetalle.obraSocial || 'Sin especificar'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-800">
<span className="text-gray-500 dark:text-gray-400">Nacionalidad:</span>
<span>{pacienteVerDetalle.nacionalidad || 'Sin especificar'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-800">
<span className="text-gray-500 dark:text-gray-400">Teléfono:</span>
<span>{pacienteVerDetalle.telefono || 'Sin especificar'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-800">
<span className="text-gray-500 dark:text-gray-400">Email:</span>
<span>{pacienteVerDetalle.email || 'Sin especificar'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-800">
<span className="text-gray-500 dark:text-gray-400">Dirección:</span>
<span>{pacienteVerDetalle.direccion || 'Sin especificar'}</span>
</div>
</div>
{/* Sección Clínica */}
<div className="space-y-3 pt-2">
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Información Clínica
</h4>
<div>
<span className="text-xs text-gray-500 dark:text-gray-400 block mb-1 font-medium">
Medicación Habitual
</span>
<p className="bg-gray-50 dark:bg-gray-800/60 p-2.5 rounded border border-gray-100 dark:border-gray-800 text-xs text-gray-800 dark:text-gray-200 min-h-[38px] whitespace-pre-wrap">
{pacienteVerDetalle.medicacionHabitual || 'Sin medicación habitual registrada'}
</p>
</div>
<div>
<span className="text-xs text-gray-500 dark:text-gray-400 block mb-1 font-medium">
Antecedentes Médicos
</span>
<p className="bg-gray-50 dark:bg-gray-800/60 p-2.5 rounded border border-gray-100 dark:border-gray-800 text-xs text-gray-800 dark:text-gray-200 min-h-[38px] whitespace-pre-wrap">
{pacienteVerDetalle.antecedentes || 'Sin antecedentes registrados'}
</p>
</div>
<div>
<span className="text-xs text-red-600 dark:text-red-400 block mb-1 font-medium flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
Alergias
</span>
<p className={`p-2.5 rounded border text-xs min-h-[38px] whitespace-pre-wrap ${
pacienteVerDetalle.alergias
? 'bg-red-50 dark:bg-red-950/30 border-red-200/60 dark:border-red-800/40 text-red-900 dark:text-red-200'
: 'bg-gray-50 dark:bg-gray-800/60 border-gray-100 dark:border-gray-800 text-gray-500 dark:text-gray-400'
}`}>
{pacienteVerDetalle.alergias || 'Sin alergias registradas'}
</p>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
+505
View File
@@ -0,0 +1,505 @@
import { useState, useEffect } from 'react';
import {
ListTodo,
Search,
ArrowLeft,
Clock,
Bed,
Trash2,
FileText,
Copy,
CheckSquare,
Square
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { toast } from 'sonner';
import type { Pendiente, Internacion, Paciente, Cama } from '@/types';
import { formatDateDDMMYYYY, getNombreProfesional, getLocalToday } from '@/lib/utils';
import { useHospitalStore } from '@/hooks/useHospitalStore';
interface PendientesSalaProps {
pendientes: Pendiente[];
internaciones: Internacion[];
pacientes: Paciente[];
camas?: Cama[];
onActualizarPendiente: (id: string, datos: Partial<Pendiente>) => Promise<void> | void;
onEliminarPendiente: (id: string) => Promise<void> | void;
onVerHC: (internacionId: string) => void;
onVolver: () => void;
getPacienteById: (id: string) => Paciente | undefined;
getCamaById: (id: string) => Cama | undefined;
}
export function PendientesSala({
pendientes,
internaciones,
onActualizarPendiente,
onEliminarPendiente,
onVerHC,
onVolver,
getPacienteById,
getCamaById,
}: PendientesSalaProps) {
const { currentUser, refreshState } = useHospitalStore();
const [busqueda, setBusqueda] = useState('');
const [filtroCategoria, setFiltroCategoria] = useState<string>('todos');
const [filtroEstado, setFiltroEstado] = useState<string>('pendiente');
const [filtroFechaProgramada, setFiltroFechaProgramada] = useState<string>('');
useEffect(() => {
refreshState();
}, [refreshState]);
// Active internaciones map for quick lookup
const internacionesActivas = internaciones.filter(i => i.activa);
const activeInternacionMap = new Map<string, Internacion>();
internacionesActivas.forEach(i => {
if (i.id) activeInternacionMap.set(i.id, i);
if (i.pacienteId) activeInternacionMap.set(i.pacienteId, i);
});
// Filter pending items belonging to active interned patients (or all if activeInternacionMap is empty / fallback)
const pendientesSala = (pendientes || []).filter(p => {
if (!p) return false;
// If internacionId or pacienteId matches an active internacion, include it.
// Also if no internaciones match or if we want to be robust, check if patient has any active internacion.
const internacion = (p.internacionId ? activeInternacionMap.get(p.internacionId) : undefined) ||
(p.pacienteId ? activeInternacionMap.get(p.pacienteId) : undefined);
// Fallback: if no explicit internacion matched but there are active internations, let's also check if patient is in active internaciones
if (!internacion && p.pacienteId) {
const foundByPaciente = internacionesActivas.find(i => i.pacienteId === p.pacienteId);
return !!foundByPaciente;
}
return !!internacion || (!p.internacionId && !p.pacienteId);
});
const totalPendientesActivos = pendientesSala.filter(p => p.estado === 'pendiente').length;
// Apply search, category, status, and scheduled date filters
const pendientesFiltrados = pendientesSala.filter(p => {
const internacion = (p.internacionId ? activeInternacionMap.get(p.internacionId) : undefined) ||
(p.pacienteId ? activeInternacionMap.get(p.pacienteId) : undefined) ||
internacionesActivas.find(i => i.pacienteId === p.pacienteId);
const paciente = p.pacienteId ? getPacienteById(p.pacienteId) : undefined;
const cama = internacion ? getCamaById(internacion.camaId) : undefined;
const textoMatch = !busqueda ||
(paciente && (paciente.nombre.toLowerCase().includes(busqueda.toLowerCase()) || paciente.apellido.toLowerCase().includes(busqueda.toLowerCase()) || paciente.dni.includes(busqueda))) ||
(cama && cama.numero.toLowerCase().includes(busqueda.toLowerCase())) ||
p.descripcion.toLowerCase().includes(busqueda.toLowerCase()) ||
(p.observaciones && p.observaciones.toLowerCase().includes(busqueda.toLowerCase()));
const categoriaMatch = filtroCategoria === 'todos' || p.categoria === filtroCategoria;
const estadoMatch = filtroEstado === 'todos' || p.estado === filtroEstado;
// Filter by scheduled date (fechaProgramada), not creation date
const fechaProgMatch = !filtroFechaProgramada || p.fechaProgramada === filtroFechaProgramada;
return textoMatch && categoriaMatch && estadoMatch && fechaProgMatch;
}).sort((a, b) => {
// Sort by scheduled date if available, then creation date
const fechaA = a.fechaProgramada || a.fechaCreacion || '';
const fechaB = b.fechaProgramada || b.fechaCreacion || '';
return fechaB.localeCompare(fechaA);
});
const handleToggleEstado = async (p: Pendiente) => {
const nuevoEstado = p.estado === 'realizado' ? 'pendiente' : 'realizado';
try {
await onActualizarPendiente(p.id, {
estado: nuevoEstado,
fechaRealizado: nuevoEstado === 'realizado' ? getLocalToday() : undefined,
usuarioRealizado: nuevoEstado === 'realizado' && currentUser ? getNombreProfesional(currentUser) : undefined,
});
toast.success(nuevoEstado === 'realizado' ? 'Marcado como realizado' : 'Marcado como pendiente');
} catch {
toast.error('Error al cambiar estado');
}
};
const handleEliminar = async (id: string) => {
try {
await onEliminarPendiente(id);
toast.success('Pendiente eliminado');
} catch {
toast.error('Error al eliminar');
}
};
const handleCopiarPendientes = () => {
const activos = pendientesSala.filter(p => p.estado === 'pendiente');
if (activos.length === 0) {
toast.error('No hay pendientes activos en la sala');
return;
}
const texto = activos.map((p, idx) => {
const internacion = (p.internacionId ? activeInternacionMap.get(p.internacionId) : undefined) ||
(p.pacienteId ? activeInternacionMap.get(p.pacienteId) : undefined);
const paciente = p.pacienteId ? getPacienteById(p.pacienteId) : undefined;
const cama = internacion ? getCamaById(internacion.camaId) : undefined;
const infoPaciente = paciente ? `${paciente.apellido}, ${paciente.nombre}` : 'Paciente';
const infoCama = cama ? `[Cama ${cama.numero}]` : '';
let line = `${idx + 1}. ${infoCama} ${infoPaciente} - [${(p.categoria || 'General').toUpperCase()}] ${p.descripcion}`;
if (p.fechaProgramada) line += ` (Prog: ${formatDateDDMMYYYY(p.fechaProgramada)}${p.horaProgramada ? ' ' + p.horaProgramada : ''})`;
if (p.prioridad === 'alta') line += ' (ALTA)';
if (p.observaciones) line += ` - Obs: ${p.observaciones}`;
return line;
}).join('\n');
navigator.clipboard.writeText(`PENDIENTES DE SALA:\n${texto}`);
toast.success('Pendientes copiados al portapapeles');
};
return (
<div className="space-y-6 dark:text-white">
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={onVolver} className="shrink-0">
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<ListTodo className="h-6 w-6 text-blue-600 dark:text-blue-400 shrink-0" />
Pendientes de Sala
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Gestión y seguimiento de pendientes de todos los pacientes internados ({totalPendientesActivos} pendientes activos)
</p>
</div>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto justify-end">
<Button variant="outline" onClick={handleCopiarPendientes} className="gap-2 w-full sm:w-auto">
<Copy className="h-4 w-4" />
Copiar Pendientes Activos
</Button>
</div>
</div>
{/* Filters Card */}
<Card>
<CardContent className="p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
className="pl-10 text-xs"
placeholder="Buscar paciente, cama o descripción..."
value={busqueda}
onChange={(e) => setBusqueda(e.target.value)}
/>
</div>
<div>
<Select value={filtroCategoria} onValueChange={setFiltroCategoria}>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="Categoría" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todas las categorías</SelectItem>
<SelectItem value="General">General</SelectItem>
<SelectItem value="Estudio">Estudio</SelectItem>
<SelectItem value="Procedimiento">Procedimiento</SelectItem>
<SelectItem value="Laboratorio">Laboratorio</SelectItem>
<SelectItem value="Interconsulta">Interconsulta</SelectItem>
<SelectItem value="Tratamiento">Tratamiento</SelectItem>
<SelectItem value="Administrativo">Administrativo</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Select value={filtroEstado} onValueChange={setFiltroEstado}>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="Estado" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos los estados</SelectItem>
<SelectItem value="pendiente">Pendientes</SelectItem>
<SelectItem value="realizado">Realizados</SelectItem>
<SelectItem value="cancelado">Cancelados</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
type="date"
className="h-9 text-xs"
value={filtroFechaProgramada}
onChange={(e) => setFiltroFechaProgramada(e.target.value)}
placeholder="Fecha programada"
/>
</div>
{filtroFechaProgramada && (
<Button variant="ghost" size="sm" onClick={() => setFiltroFechaProgramada('')} className="h-9 px-2 text-xs text-muted-foreground">
Limpiar
</Button>
)}
</div>
</div>
</CardContent>
</Card>
{/* Lista de Pendientes en Tarjetas (móvil) y Tabla (escritorio) */}
{pendientesFiltrados.length === 0 ? (
<div className="text-center py-12 text-gray-400 bg-card rounded-lg border p-8">
<ListTodo className="h-16 w-16 mx-auto mb-4 opacity-40 text-blue-500" />
<p className="text-lg font-medium text-gray-700 dark:text-gray-300">No se encontraron pendientes</p>
<p className="text-sm text-gray-500 mt-1">Pruebe cambiando los filtros de búsqueda o fecha programada.</p>
</div>
) : (
<>
{/* Vista móvil en Tarjetas (block sm:hidden) */}
<div className="space-y-3 sm:hidden">
{pendientesFiltrados.map((p) => {
const internacion = (p.internacionId ? activeInternacionMap.get(p.internacionId) : undefined) ||
(p.pacienteId ? activeInternacionMap.get(p.pacienteId) : undefined);
const paciente = p.pacienteId ? getPacienteById(p.pacienteId) : undefined;
const cama = internacion ? getCamaById(internacion.camaId) : undefined;
const esRealizado = p.estado === 'realizado';
return (
<div
key={p.id}
className={`p-4 rounded-lg border bg-card shadow-sm space-y-3 transition-colors ${
esRealizado ? 'opacity-60 bg-muted/20 border-muted' : 'border-border'
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 font-semibold text-xs bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-300 px-2 py-1 rounded">
<Bed className="h-3.5 w-3.5" />
<span>{cama ? `Cama ${cama.numero}` : 'Sin cama'}</span>
</div>
<Badge variant="outline" className="text-xs font-medium">
{p.categoria || 'General'}
</Badge>
{p.prioridad === 'alta' ? (
<Badge className="bg-red-100 text-red-800 dark:bg-red-950/80 dark:text-red-300 border-red-200 text-[11px]">
Alta
</Badge>
) : p.prioridad === 'media' ? (
<Badge className="bg-amber-100 text-amber-800 dark:bg-amber-950/80 dark:text-amber-300 border-amber-200 text-[11px]">
Media
</Badge>
) : (
<Badge variant="outline" className="text-[11px]">
Baja
</Badge>
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 shrink-0"
onClick={() => handleToggleEstado(p)}
title={esRealizado ? "Marcar como pendiente" : "Marcar como realizado"}
>
{esRealizado ? (
<CheckSquare className="h-5 w-5 text-emerald-600" />
) : (
<Square className="h-5 w-5 text-gray-400 hover:text-blue-600" />
)}
</Button>
</div>
<div>
<div className="font-semibold text-sm text-gray-900 dark:text-white">
{paciente ? `${paciente.apellido}, ${paciente.nombre}` : 'Paciente desconocido'}
</div>
<div className="text-xs text-muted-foreground font-mono">
DNI: {paciente?.dni || 'N/A'}
</div>
</div>
<div className="bg-muted/30 p-2.5 rounded text-xs space-y-1">
<div className={`font-medium ${esRealizado ? 'line-through text-muted-foreground' : 'text-gray-800 dark:text-gray-200'}`}>
{p.descripcion}
</div>
{p.observaciones && (
<div className="text-[11px] text-muted-foreground">
<span className="font-semibold">Obs:</span> {p.observaciones}
</div>
)}
</div>
<div className="flex items-center justify-between pt-1 border-t border-border/50 text-xs">
<div>
{p.fechaProgramada ? (
<div className="flex items-center gap-1 text-blue-700 dark:text-blue-300 font-medium">
<Clock className="h-3 w-3 shrink-0" />
<span>{formatDateDDMMYYYY(p.fechaProgramada)} {p.horaProgramada ? `a las ${p.horaProgramada}` : ''}</span>
</div>
) : (
<span className="text-muted-foreground italic text-[11px]">Sin programar</span>
)}
</div>
<div className="flex items-center gap-1">
{internacion && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-blue-600 hover:text-blue-700 hover:bg-blue-50 gap-1"
onClick={() => onVerHC(internacion.id)}
>
<FileText className="h-3.5 w-3.5" />
<span>HC</span>
</Button>
)}
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 gap-1"
onClick={() => handleEliminar(p.id)}
>
<Trash2 className="h-3.5 w-3.5" />
<span>Eliminar</span>
</Button>
</div>
</div>
</div>
);
})}
</div>
{/* Vista de Tabla para Escritorio (hidden sm:block) */}
<div className="hidden sm:block border rounded-lg bg-card overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<Table className="w-full min-w-[1000px] text-sm">
<TableHeader>
<TableRow>
<TableHead className="w-[50px] text-center">Estado</TableHead>
<TableHead className="w-[80px]">Cama</TableHead>
<TableHead>Paciente</TableHead>
<TableHead className="w-[120px]">Categoría</TableHead>
<TableHead className="w-[100px]">Prioridad</TableHead>
<TableHead>Descripción / Observaciones</TableHead>
<TableHead className="w-[140px]">Fecha Programada</TableHead>
<TableHead className="text-center w-[100px]">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pendientesFiltrados.map((p) => {
const internacion = (p.internacionId ? activeInternacionMap.get(p.internacionId) : undefined) ||
(p.pacienteId ? activeInternacionMap.get(p.pacienteId) : undefined);
const paciente = p.pacienteId ? getPacienteById(p.pacienteId) : undefined;
const cama = internacion ? getCamaById(internacion.camaId) : undefined;
const esRealizado = p.estado === 'realizado';
return (
<TableRow key={p.id} className={`hover:bg-muted/50 ${esRealizado ? 'opacity-60 bg-muted/20' : ''}`}>
<TableCell className="text-center">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleToggleEstado(p)}
title={esRealizado ? "Marcar como pendiente" : "Marcar como realizado"}
>
{esRealizado ? (
<CheckSquare className="h-5 w-5 text-emerald-600" />
) : (
<Square className="h-5 w-5 text-gray-400 hover:text-blue-600" />
)}
</Button>
</TableCell>
<TableCell className="font-semibold text-xs whitespace-nowrap">
<div className="flex items-center gap-1.5">
<Bed className="h-3.5 w-3.5 text-blue-600" />
<span>{cama ? cama.numero : '-'}</span>
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<div className="font-medium text-xs">
{paciente ? `${paciente.apellido}, ${paciente.nombre}` : 'Paciente desconocido'}
</div>
<div className="text-[11px] text-muted-foreground font-mono">
DNI: {paciente?.dni || 'N/A'}
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge variant="outline" className="text-xs font-medium">
{p.categoria || 'General'}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">
{p.prioridad === 'alta' ? (
<Badge className="bg-red-100 text-red-800 dark:bg-red-950/80 dark:text-red-300 border-red-200 text-[11px]">
Alta
</Badge>
) : p.prioridad === 'media' ? (
<Badge className="bg-amber-100 text-amber-800 dark:bg-amber-950/80 dark:text-amber-300 border-amber-200 text-[11px]">
Media
</Badge>
) : (
<Badge variant="outline" className="text-[11px]">
Baja
</Badge>
)}
</TableCell>
<TableCell>
<div className={`text-xs ${esRealizado ? 'line-through text-muted-foreground' : 'font-medium'}`}>
{p.descripcion}
</div>
{p.observaciones && (
<div className="text-[11px] text-muted-foreground mt-0.5">
Obs: {p.observaciones}
</div>
)}
</TableCell>
<TableCell className="whitespace-nowrap text-xs">
{p.fechaProgramada ? (
<div className="flex items-center gap-1 text-blue-700 dark:text-blue-300 font-medium bg-blue-50 dark:bg-blue-950/40 px-2 py-1 rounded w-fit">
<Clock className="h-3 w-3" />
<span>{formatDateDDMMYYYY(p.fechaProgramada)} {p.horaProgramada ? `a las ${p.horaProgramada}` : ''}</span>
</div>
) : (
<span className="text-muted-foreground italic text-[11px]">Sin programar</span>
)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
{internacion && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-blue-600 hover:text-blue-700 hover:bg-blue-50"
onClick={() => onVerHC(internacion.id)}
title="Ver Historia Clínica"
>
<FileText className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => handleEliminar(p.id)}
title="Eliminar pendiente"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
</>
)}
</div>
);
}
+29 -15
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -11,9 +12,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Plus, Pencil, Trash2, X } from 'lucide-react';
import type { Indicacion, IndicacionTipo, ViaAdministracion, TipoPlanHidratacion, TipoInsulina, ATB } from '@/types';
import { useHospitalStore } from '@/hooks/useHospitalStore';
import { getNombreProfesional } from '@/lib/utils';
import { getNombreProfesional, getLocalToday } from '@/lib/utils';
export function SeccionIndicaciones({ recomendaciones, internacionId, pacienteId, add, update, del, movimientos, onAgregarMovimiento, canEdit, atbList = [], addATB, updateATB }: {
export function SeccionIndicaciones({ recomendaciones, internacionId, pacienteId, add, update, del, movimientos, onAgregarMovimiento, canEdit, atbList = [], addATB, updateATB, portalNode}: {
recomendaciones: Indicacion[];
internacionId: string;
pacienteId?: string;
@@ -23,6 +24,7 @@ export function SeccionIndicaciones({ recomendaciones, internacionId, pacienteId
movimientos?: unknown[];
onAgregarMovimiento?: (m: unknown) => void;
canEdit?: boolean;
portalNode?: HTMLDivElement | null;
atbList?: ATB[];
addATB?: (a: Omit<ATB, 'id'>) => Promise<unknown>;
updateATB?: (id: string, datos: Partial<ATB>) => void;
@@ -142,7 +144,7 @@ export function SeccionIndicaciones({ recomendaciones, internacionId, pacienteId
tipo,
estado: 'Activa',
medicoCrea: effectiveMedico,
fechaCrea: new Date().toISOString().split('T')[0],
fechaCrea: getLocalToday(),
};
if (tipo === 'Farmacologica' || tipo === 'Farmacologica Profilactica' || tipo === 'Farmacologica Antibiótico') {
@@ -170,7 +172,7 @@ export function SeccionIndicaciones({ recomendaciones, internacionId, pacienteId
}
const now = new Date();
const fecha = now.toISOString().split('T')[0] + ' ' + now.toTimeString().split(' ')[0].substring(0, 5);
const fecha = getLocalToday() + ' ' + now.toTimeString().split(' ')[0].substring(0, 5);
if (edit) {
if (edit.tipo === 'Farmacologica Antibiótico' && updateATB && addATB && atbList && pacienteId) {
const oldDrug = edit.droga || '';
@@ -182,7 +184,7 @@ export function SeccionIndicaciones({ recomendaciones, internacionId, pacienteId
!a.fechaFinalizacion
);
if (activeATB) {
const todayStr = now.toISOString().split('T')[0];
const todayStr = getLocalToday();
try {
await updateATB(activeATB.id, { fechaFinalizacion: todayStr });
await addATB({
@@ -241,7 +243,7 @@ export function SeccionIndicaciones({ recomendaciones, internacionId, pacienteId
const handleSuspender = async (i: Indicacion, suspendioMedico: string) => {
const now = new Date();
const fecha = now.toISOString().split('T')[0] + ' ' + now.toTimeString().split(' ')[0].substring(0, 5);
const fecha = getLocalToday() + ' ' + now.toTimeString().split(' ')[0].substring(0, 5);
try {
if (onAgregarMovimiento) {
await onAgregarMovimiento({
@@ -255,7 +257,7 @@ export function SeccionIndicaciones({ recomendaciones, internacionId, pacienteId
}
if (i.tipo === 'Farmacologica Antibiótico' && updateATB && atbList) {
const fechaFin = now.toISOString().split('T')[0];
const fechaFin = getLocalToday();
const activeATB = atbList.find(a =>
a.internacionId === internacionId &&
a.antibiotico.toLowerCase() === (i.droga || '').toLowerCase() &&
@@ -283,14 +285,26 @@ export function SeccionIndicaciones({ recomendaciones, internacionId, pacienteId
return (
<div className="space-y-2.5">
<div className="flex justify-between items-center">
{canEdit && <Button size="sm" variant="outline" onClick={() => { reset(); setDialog(true); }}>
<Plus className="h-4 w-4 mr-1.5" />Nueva Indicación
</Button>}
<Button size="sm" variant="ghost" onClick={() => setShowHistorial(!showHistorial)}>
{showHistorial ? 'Ocultar Historial' : `Ver Historial (${movs.length})`}
</Button>
</div>
{portalNode ? createPortal(
<>
{canEdit && <Button size="sm" variant="outline" onClick={() => { reset(); setDialog(true); }}>
<Plus className="h-4 w-4 mr-1.5" />Nueva Indicación
</Button>}
<Button size="sm" variant="ghost" onClick={() => setShowHistorial(!showHistorial)}>
{showHistorial ? 'Ocultar Historial' : `Ver Historial (${movs.length})`}
</Button>
</>,
portalNode
) : (
<div className="flex justify-between items-center mb-4">
{canEdit && <Button size="sm" variant="outline" onClick={() => { reset(); setDialog(true); }}>
<Plus className="h-4 w-4 mr-1.5" />Nueva Indicación
</Button>}
<Button size="sm" variant="ghost" onClick={() => setShowHistorial(!showHistorial)}>
{showHistorial ? 'Ocultar Historial' : `Ver Historial (${movs.length})`}
</Button>
</div>
)}
<Dialog open={dialog} onOpenChange={setDialog}>
<DialogContent className="sm:max-w-lg">
File diff suppressed because it is too large Load Diff
+63 -4
View File
@@ -18,6 +18,7 @@ export interface Paciente {
nacionalidad?: string;
medicacionHabitual?: string;
historiaClinica?: string;
notas?: string;
}
@@ -33,7 +34,7 @@ export interface Cama {
numero: string;
grupoId?: string;
areaId?: string;
tipo: 'General' | 'Aislamiento KPC' | 'Aislamiento COVID' | 'Aislamiento Clostridium' | 'Aislamiento Neutropenico';
tipo: 'General' | 'Aislamiento KPC' | 'Aislamiento COVID' | 'Aislamiento Clostridium' | 'Aislamiento Neutropenico' | 'Aislamiento TBC' | 'Aislamiento Escabiosis';
estado: 'Disponible' | 'Ocupada' | 'Reparacion' | 'Reservada';
pacienteId?: string;
internacionId?: string;
@@ -55,7 +56,8 @@ export interface Internacion {
antecedentesEnfermedadActual?: string;
diagnosticoEgreso?: string;
medicoIngresante: string;
motivoEgreso?: 'Alta médica' | 'Alta voluntaria' | 'Derivación' | 'Fallecimiento' | 'Otro';
motivoEgreso?: 'Alta médica' | 'Egreso Voluntario' | 'Derivación' | 'Obito' | 'Pase servicio' | 'Otro';
servicioAlQuePasa?: string;
activa: boolean;
apache?: string;
derivacion?: string;
@@ -118,6 +120,16 @@ export interface ResultadoLaboratorio {
estado: 'Normal' | 'Alto' | 'Bajo' | 'Crítico';
}
export interface OtroLaboratorio {
id: string;
pacienteId: string;
internacionId?: string;
fecha: string;
hora?: string;
observaciones: string;
categoria?: 'Perfil Lipídico' | 'Perfil Tiroideo' | 'Autoinmunidad' | 'Toracocentesis' | 'Líquido Ascitico' | 'LCR' | 'Otros' | string;
}
export interface Glucemia {
id: string;
pacienteId: string;
@@ -147,6 +159,13 @@ export interface AcidoBase {
interpretacion?: string;
}
export interface TipoCultivo {
id: string;
nombre: string;
categoria?: string;
descripcion?: string;
}
export interface Cultivo {
id: string;
pacienteId: string;
@@ -155,7 +174,7 @@ export interface Cultivo {
fechaToma: string;
protocolo?: string;
fechaResultado?: string;
tipoMuestra: 'HMCx2' | 'RC' | 'PC' | 'UC' | 'LCR' | 'LP' | 'LAsc' | 'LAbd' | 'Coleccion';
tipoMuestra: string;
germen?: string;
sensible?: string;
resistente?: string;
@@ -236,7 +255,7 @@ export interface MovimientoIndicacion {
observaciones?: string;
}
export type Vista = 'dashboard' | 'camas' | 'pacientes' | 'internaciones' | 'evoluciones' | 'laboratorios' | 'acidobase' | 'cultivos' | 'historiaclinica' | 'nuevoingreso' | 'editaringreso' | 'usuarios';
export type Vista = 'dashboard' | 'camas' | 'pacientes' | 'internaciones' | 'evoluciones' | 'laboratorios' | 'acidobase' | 'cultivos' | 'historiaclinica' | 'nuevoingreso' | 'editaringreso' | 'usuarios' | 'pendientessala' | 'sistema';
export type RolUsuario = 'admin' | 'medico' | 'enfermero';
@@ -254,3 +273,43 @@ export interface Usuario {
areaId?: 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 {
id: string;
pacienteId: string;
internacionId?: string;
descripcion: string;
prioridad: 'alta' | 'media' | 'baja';
estado: 'pendiente' | 'realizado' | 'cancelado';
fechaCreacion: string;
horaCreacion?: string;
fechaRealizado?: string;
categoria?: 'General' | 'Estudio' | 'Procedimiento' | 'Laboratorio' | 'Interconsulta' | 'Tratamiento' | 'Administrativo' | string;
fechaProgramada?: string;
horaProgramada?: string;
usuarioId?: string;
usuarioNombre?: string;
observaciones?: string;
}
+1 -2
View File
@@ -1,12 +1,11 @@
import path from "path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import { inspectAttr } from 'kimi-plugin-inspect-react'
// https://vite.dev/config/
export default defineConfig({
base: './',
plugins: [inspectAttr(), react()],
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),