Actualizacion de sistema: mejoras en seleccion de categorias de cultivos, optimizacion responsive de listado de cultivos e historia clinica, y correccion de componentes

This commit is contained in:
2026-08-16 20:40:26 +00:00
parent 1665c76bba
commit 5b51e4e8eb
14 changed files with 1349 additions and 327 deletions
+47
View File
@@ -12,6 +12,7 @@ import { initDb,
getAllGlucemias, createGlucemia, updateGlucemia, deleteGlucemia, getAllGlucemias, createGlucemia, updateGlucemia, deleteGlucemia,
getAllAcidosBase, createAcidoBase, updateAcidoBase, deleteAcidoBase, getAllAcidosBase, createAcidoBase, updateAcidoBase, deleteAcidoBase,
getAllCultivos, createCultivo, updateCultivo, deleteCultivo, getAllCultivos, createCultivo, updateCultivo, deleteCultivo,
getAllTiposCultivo, createTipoCultivo, updateTipoCultivo, deleteTipoCultivo, restablecerTiposCultivo,
getAllEstudiosComplementarios, createEstudioComplementario, updateEstudioComplementario, deleteEstudioComplementario, getAllEstudiosComplementarios, createEstudioComplementario, updateEstudioComplementario, deleteEstudioComplementario,
getAllInterconsultas, createInterconsulta, updateInterconsulta, deleteInterconsulta, getAllInterconsultas, createInterconsulta, updateInterconsulta, deleteInterconsulta,
getAllAtb, createAtb, updateAtb, deleteAtb, getAllAtb, createAtb, updateAtb, deleteAtb,
@@ -95,6 +96,7 @@ app.get('/api/state', async (req, res) => {
glucemias: await getAllGlucemias(), glucemias: await getAllGlucemias(),
acidosBase: await getAllAcidosBase(), acidosBase: await getAllAcidosBase(),
cultivos: await getAllCultivos(), cultivos: await getAllCultivos(),
tiposCultivo: await getAllTiposCultivo(),
estudiosComplementarios: await getAllEstudiosComplementarios(), estudiosComplementarios: await getAllEstudiosComplementarios(),
interconsultas: (await getAllInterconsultas()).map(ic => ({ ...ic, realizada: !!ic.realizada })), interconsultas: (await getAllInterconsultas()).map(ic => ({ ...ic, realizada: !!ic.realizada })),
atb: await getAllAtb(), atb: await getAllAtb(),
@@ -580,6 +582,51 @@ 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' });
}
});
// ========== ESTUDIOS COMPLEMENTARIOS ========== // ========== ESTUDIOS COMPLEMENTARIOS ==========
app.get('/api/estudios-complementarios', async (req, res) => { app.get('/api/estudios-complementarios', async (req, res) => {
try { try {
+158 -2
View File
@@ -66,6 +66,8 @@ const memStore = {
indicaciones: [], indicaciones: [],
movimientos_indicaciones: [], movimientos_indicaciones: [],
pendientes: [], pendientes: [],
tipos_cultivo: [],
otrosLaboratorios: [],
kv: {} kv: {}
}; };
@@ -703,6 +705,160 @@ 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];
}
}
// ========== ESTUDIOS COMPLEMENTARIOS ========== // ========== ESTUDIOS COMPLEMENTARIOS ==========
export async function getAllEstudiosComplementarios() { export async function getAllEstudiosComplementarios() {
if (db) { if (db) {
@@ -1003,7 +1159,7 @@ export async function exportAllData() {
const collections = [ const collections = [
'usuarios', 'pacientes', 'areas', 'camas', 'internaciones', 'usuarios', 'pacientes', 'areas', 'camas', 'internaciones',
'evoluciones', 'laboratorios', 'glucemias', 'acidosbase', 'evoluciones', 'laboratorios', 'glucemias', 'acidosbase',
'cultivos', 'estudiosComplementarios', 'interconsultas', 'atb', 'cultivos', 'tipos_cultivo', 'estudiosComplementarios', 'interconsultas', 'atb',
'indicaciones', 'movimientos_indicaciones', 'pendientes', 'kv' 'indicaciones', 'movimientos_indicaciones', 'pendientes', 'kv'
]; ];
const dump = {}; const dump = {};
@@ -1024,7 +1180,7 @@ export async function importAllData(dump) {
const collections = [ const collections = [
'usuarios', 'pacientes', 'areas', 'camas', 'internaciones', 'usuarios', 'pacientes', 'areas', 'camas', 'internaciones',
'evoluciones', 'laboratorios', 'glucemias', 'acidosbase', 'evoluciones', 'laboratorios', 'glucemias', 'acidosbase',
'cultivos', 'estudiosComplementarios', 'interconsultas', 'atb', 'cultivos', 'tipos_cultivo', 'estudiosComplementarios', 'interconsultas', 'atb',
'indicaciones', 'movimientos_indicaciones', 'pendientes', 'kv' 'indicaciones', 'movimientos_indicaciones', 'pendientes', 'kv'
]; ];
if (db) { if (db) {
+17 -12
View File
@@ -6,28 +6,33 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( 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: { variants: {
variant: { 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: 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: 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: 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: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "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-primary underline-offset-4 hover:underline", 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: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", 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", sm: "h-8 rounded-md gap-1.5 px-3 text-xs has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", lg: "h-10 rounded-lg px-6 text-base has-[>svg]:px-4",
icon: "size-9", icon: "size-9 rounded-lg",
"icon-sm": "size-8", "icon-sm": "size-8 rounded-md",
"icon-lg": "size-10", "icon-lg": "size-10 rounded-lg",
}, },
}, },
defaultVariants: { defaultVariants: {
+77
View File
@@ -11,6 +11,7 @@ import type {
Glucemia, Glucemia,
AcidoBase, AcidoBase,
Cultivo, Cultivo,
TipoCultivo,
EstudioComplementario, EstudioComplementario,
Interconsulta, Interconsulta,
ATB, ATB,
@@ -45,6 +46,7 @@ interface HospitalState {
glucemias: Glucemia[]; glucemias: Glucemia[];
acidosBase: AcidoBase[]; acidosBase: AcidoBase[];
cultivos: Cultivo[]; cultivos: Cultivo[];
tiposCultivo: TipoCultivo[];
estudiosComplementarios: EstudioComplementario[]; estudiosComplementarios: EstudioComplementario[];
interconsultas: Interconsulta[]; interconsultas: Interconsulta[];
atb: ATB[]; atb: ATB[];
@@ -71,6 +73,7 @@ const defaultState = (): HospitalState => ({
glucemias: [], glucemias: [],
acidosBase: [], acidosBase: [],
cultivos: [], cultivos: [],
tiposCultivo: [],
estudiosComplementarios: [], estudiosComplementarios: [],
interconsultas: [], interconsultas: [],
atb: [], atb: [],
@@ -104,6 +107,7 @@ export function useHospitalStore() {
const normalized = { const normalized = {
...defaults, ...defaults,
...body, ...body,
tiposCultivo: body.tiposCultivo || [],
estudiosComplementarios: body.estudiosComplementarios || [], estudiosComplementarios: body.estudiosComplementarios || [],
interconsultas: body.interconsultas || [], interconsultas: body.interconsultas || [],
atb: body.atb || [], atb: body.atb || [],
@@ -992,6 +996,75 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
} }
}, [state, checkGrupoPermission, apiCall]); }, [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 estudios complementarios // Acciones de estudios complementarios
const agregarEstudioComplementario = useCallback(async (estudio: Omit<EstudioComplementario, 'id'>) => { const agregarEstudioComplementario = useCallback(async (estudio: Omit<EstudioComplementario, 'id'>) => {
const internacion = state.internaciones.find(i => i.id === estudio.internacionId); const internacion = state.internaciones.find(i => i.id === estudio.internacionId);
@@ -1581,6 +1654,10 @@ const actualizarInternacion = useCallback(async (internacionId: string, datos: P
agregarCultivo, agregarCultivo,
actualizarCultivo, actualizarCultivo,
eliminarCultivo, eliminarCultivo,
agregarTipoCultivo,
actualizarTipoCultivo,
eliminarTipoCultivo,
restablecerTiposCultivo,
agregarEstudioComplementario, agregarEstudioComplementario,
actualizarEstudioComplementario, actualizarEstudioComplementario,
eliminarEstudioComplementario, eliminarEstudioComplementario,
+21 -21
View File
@@ -10,28 +10,28 @@
--card-foreground: 222.2 84% 4.9%; --card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%; --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%; --primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%; --secondary: 214 90% 96%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 221.2 83.2% 40%;
--muted: 210 40% 96.1%; --muted: 214 32% 95%;
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%; --accent: 214 90% 96%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 221.2 83.2% 40%;
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%; --border: 214.3 31.8% 90%;
--input: 214.3 31.8% 91.4%; --input: 214.3 31.8% 90%;
--ring: 222.2 84% 4.9%; --ring: 221.2 83.2% 53.3%;
--radius: 0.625rem; --radius: 0.625rem;
--sidebar-background: 0 0% 98%; --sidebar-background: 0 0% 99%;
--sidebar-foreground: 222.2 84% 4.9%; --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-primary-foreground: 210 40% 98%;
--sidebar-accent: 210 40% 96.1%; --sidebar-accent: 214 90% 96%;
--sidebar-accent-foreground: 222.2 47.4% 11.2%; --sidebar-accent-foreground: 221.2 83.2% 40%;
--sidebar-border: 214.3 31.8% 91.4%; --sidebar-border: 214.3 31.8% 90%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 221.2 83.2% 53.3%;
} }
.dark { .dark {
@@ -43,12 +43,12 @@
--popover-foreground: 210 40% 98%; --popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%; --primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%; --secondary: 217.2 45% 15%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 213 94% 85%;
--muted: 217.2 32.6% 17.5%; --muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%; --muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%; --accent: 217.2 45% 15%;
--accent-foreground: 210 40% 98%; --accent-foreground: 213 94% 85%;
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 20%; --border: 217.2 32.6% 20%;
@@ -58,8 +58,8 @@
--sidebar-foreground: 210 40% 98%; --sidebar-foreground: 210 40% 98%;
--sidebar-primary: 217.2 91.2% 59.8%; --sidebar-primary: 217.2 91.2% 59.8%;
--sidebar-primary-foreground: 222.2 47.4% 11.2%; --sidebar-primary-foreground: 222.2 47.4% 11.2%;
--sidebar-accent: 217.2 32.6% 17.5%; --sidebar-accent: 217.2 45% 15%;
--sidebar-accent-foreground: 210 40% 98%; --sidebar-accent-foreground: 213 94% 85%;
--sidebar-border: 217.2 32.6% 20%; --sidebar-border: 217.2 32.6% 20%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 217.2 91.2% 59.8%;
} }
+87 -73
View File
@@ -94,24 +94,24 @@ export function Cultivos({
</div> </div>
</div> </div>
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between"> <div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
<div className="flex-1 flex gap-2"> <div className="flex-1 flex flex-col sm:flex-row gap-2 w-full">
<div className="relative flex-1"> <div className="relative flex-1 w-full">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input <Input
placeholder="Buscar por protocolo, germen, paciente..." placeholder="Buscar por protocolo, germen, paciente, DNI..."
value={busqueda} value={busqueda}
onChange={(e) => setBusqueda(e.target.value)} onChange={(e) => setBusqueda(e.target.value)}
className="pl-8" className="pl-9 w-full bg-white dark:bg-gray-800"
/> />
</div> </div>
<Select value={filtroEstado} onValueChange={(v) => setFiltroEstado(v as Cultivo['estado'] | 'todos')}> <Select value={filtroEstado} onValueChange={(v) => setFiltroEstado(v as Cultivo['estado'] | 'todos')}>
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-full sm:w-[190px] bg-white dark:bg-gray-800 shrink-0">
<SelectValue /> <SelectValue placeholder="Filtrar estado" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="todos">Todos</SelectItem> <SelectItem value="todos">Todos los estados</SelectItem>
<SelectItem value="NAF/Pendiente">NAF/Pendiente</SelectItem> <SelectItem value="NAF/Pendiente">NAF / Pendiente</SelectItem>
<SelectItem value="Parcial">Parcial</SelectItem> <SelectItem value="Parcial">Parcial</SelectItem>
<SelectItem value="Positivo">Positivo</SelectItem> <SelectItem value="Positivo">Positivo</SelectItem>
<SelectItem value="Negativo">Negativo</SelectItem> <SelectItem value="Negativo">Negativo</SelectItem>
@@ -126,40 +126,42 @@ export function Cultivos({
const camaNombre = getCamaNombreForPaciente(cultivo.pacienteId); const camaNombre = getCamaNombreForPaciente(cultivo.pacienteId);
return ( return (
<Card key={cultivo.id} className="hover:shadow-md transition-shadow"> <Card key={cultivo.id} className="hover:shadow-md transition-shadow overflow-hidden">
<CardContent className="p-4"> <CardContent className="p-3.5 sm:p-5">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3 min-w-0">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2"> {/* Card Header Info */}
<div className="flex items-center gap-3"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className={`h-10 w-10 rounded-full flex items-center justify-center shrink-0 ${ <div className="flex items-start gap-3 min-w-0 flex-1">
cultivo.estado === 'NAF/Pendiente' ? 'bg-amber-100 dark:bg-amber-950/80' : <div className={`h-10 w-10 rounded-full flex items-center justify-center shrink-0 mt-0.5 ${
cultivo.estado === 'Parcial' ? 'bg-orange-100 dark:bg-orange-950/80' : cultivo.estado === 'NAF/Pendiente' ? 'bg-amber-100 text-amber-700 dark:bg-amber-950/80 dark:text-amber-300' :
cultivo.estado === 'Positivo' ? 'bg-red-100 dark:bg-red-950/80' : 'bg-green-100 dark:bg-green-950/80' 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 ${ <Microscope className="h-5 w-5 shrink-0" />
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'
}`} />
</div> </div>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{camaNombre && ( {camaNombre && (
<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"> <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} {camaNombre}
</Badge> </Badge>
)} )}
<h3 className="font-bold text-sm sm:text-base truncate"> <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'} {pac ? `${pac.apellido}, ${pac.nombre}` : 'Paciente no encontrado'}
</h3> </h3>
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm text-gray-500 mt-1">
<span className="flex items-center gap-1"> <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">
<Calendar className="h-3 w-3" /> <span className="inline-flex items-center gap-1 shrink-0">
<Calendar className="h-3.5 w-3.5" />
{cultivo.fechaToma} {cultivo.fechaToma}
</span> </span>
<Badge variant="outline" className="text-xs">{cultivo.tipoMuestra}</Badge> <span className="text-gray-300 dark:text-gray-600 hidden xs:inline"></span>
<Badge className={`text-xs ${getEstadoColor(cultivo.estado)}`}> <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)} {getEstadoLabel(cultivo.estado)}
</Badge> </Badge>
</div> </div>
@@ -167,92 +169,102 @@ export function Cultivos({
</div> </div>
</div> </div>
{/* Protocolo */}
{cultivo.protocolo && ( {cultivo.protocolo && (
<p className="text-xs text-gray-500"> <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} Protocolo: {cultivo.protocolo}
</p> </div>
)} )}
{/* Estado NAF / Pendiente */}
{cultivo.estado === 'NAF/Pendiente' && cultivo.observaciones && ( {cultivo.estado === 'NAF/Pendiente' && cultivo.observaciones && (
<p className="text-sm text-gray-600 dark:text-gray-300 bg-gray-50 dark:bg-gray-800 p-2.5 rounded border border-gray-200 dark:border-gray-700"> <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">Observaciones:</span> {cultivo.observaciones} <span className="font-semibold text-gray-700 dark:text-gray-200">Observaciones:</span> {cultivo.observaciones}
</p> </p>
)} )}
{/* Estado Parcial */}
{cultivo.estado === 'Parcial' && ( {cultivo.estado === 'Parcial' && (
<div className="bg-orange-50 dark:bg-orange-950/60 p-3 rounded-lg border border-orange-200 dark:border-orange-800/50 space-y-2"> <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 && ( {cultivo.germen && (
<p className="text-sm font-medium text-orange-800 dark:text-orange-300 flex items-center gap-2"> <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" /> <AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
PARCIAL - Germen: {cultivo.germen} <span>PARCIAL - Germen: {cultivo.germen}</span>
</p> </p>
)} )}
{(cultivo.sensible || cultivo.resistente) && ( {(cultivo.sensible || cultivo.resistente) && (
<div className="space-y-1"> <div className="space-y-1.5 pt-1">
{cultivo.sensible && ( {cultivo.sensible && (
<p className="text-xs text-orange-700 dark:text-orange-300">Sensible: {cultivo.sensible}</p> <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 && ( {cultivo.resistente && (
<p className="text-xs text-orange-700 dark:text-orange-300">Resistente: {cultivo.resistente}</p> <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> </div>
)} )}
{cultivo.observaciones && ( {cultivo.observaciones && (
<p className="text-xs text-orange-800 dark:text-orange-200 border-t border-orange-200 dark:border-orange-800/60 pt-2 mt-1"> <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} <span className="font-semibold">Observaciones:</span> {cultivo.observaciones}
</p> </p>
)} )}
</div> </div>
)} )}
{/* Estado Positivo */}
{cultivo.estado === 'Positivo' && ( {cultivo.estado === 'Positivo' && (
<div className="flex flex-col gap-2 mt-2"> <div className="flex flex-col gap-2 mt-1">
<div className="flex flex-wrap gap-2"> <div className="flex flex-col gap-2">
{cultivo.germen && ( {cultivo.germen && (
<Badge <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">
variant="outline" <AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
className="border-red-600 border bg-red-50 text-red-800 dark:bg-red-950/60 dark:text-red-300 dark:border-red-500 rounded px-2.5 py-1 font-medium text-sm inline-flex items-center gap-1.5" <div className="min-w-0 flex-1">
> <span className="font-bold text-red-800 dark:text-red-300">Germen: </span>
<AlertCircle className="h-4 w-4 text-red-600 shrink-0" /> <span className="font-medium">{cultivo.germen}</span>
<span>Germen: {cultivo.germen} {cultivo.fechaResultado ? `(Definitivo: ${cultivo.fechaResultado})` : ''}</span> {cultivo.fechaResultado && (
</Badge> <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 && ( {cultivo.sensible && (
<Badge <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">
variant="outline" <span className="font-bold text-green-800 dark:text-green-300">Sensibilidad: </span>
className="border-green-600 border bg-green-50 text-green-800 dark:bg-green-950/60 dark:text-green-300 dark:border-green-500 rounded px-2.5 py-1 font-medium text-sm inline-flex items-center gap-1.5" <span>{cultivo.sensible}</span>
> </div>
<span>Sensibilidad: {cultivo.sensible}</span>
</Badge>
)} )}
{cultivo.resistente && ( {cultivo.resistente && (
<Badge <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">
variant="outline" <span className="font-bold text-red-800 dark:text-red-300">Resistencia: </span>
className="border-red-600 border bg-red-50 text-red-800 dark:bg-red-950/60 dark:text-red-300 dark:border-red-500 rounded px-2.5 py-1 font-medium text-sm inline-flex items-center gap-1.5" <span>{cultivo.resistente}</span>
> </div>
<span>Resistencia: {cultivo.resistente}</span>
</Badge>
)} )}
</div> </div>
{cultivo.observaciones && ( {cultivo.observaciones && (
<div className="bg-red-50/60 dark:bg-red-950/40 p-2.5 rounded-md border border-red-200/80 dark:border-red-900/50 text-xs text-red-900 dark:text-red-200"> <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} <span className="font-semibold">Observaciones:</span> {cultivo.observaciones}
</div> </div>
)} )}
</div> </div>
)} )}
{/* Estado Negativo */}
{cultivo.estado === 'Negativo' && ( {cultivo.estado === 'Negativo' && (
<div className="bg-green-50 dark:bg-green-900/60 p-3 rounded-lg border border-green-200 dark:border-green-700 mt-2 space-y-2"> <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="text-sm font-medium text-green-800 dark:text-green-200 flex items-center gap-2"> <p className="font-semibold text-green-800 dark:text-green-300 flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" /> <CheckCircle2 className="h-4 w-4 shrink-0" />
Cultivo Negativo Cultivo Negativo Final
</p> </p>
{cultivo.observaciones && ( {cultivo.observaciones && (
<p className="text-xs text-green-800 dark:text-green-200 border-t border-green-200 dark:border-green-700/80 pt-2 mt-1"> <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} <span className="font-semibold">Observaciones:</span> {cultivo.observaciones}
</p> </p>
)} )}
@@ -265,9 +277,11 @@ export function Cultivos({
})} })}
{cultivosFiltrados.length === 0 && ( {cultivosFiltrados.length === 0 && (
<p className="text-center text-gray-500 py-8"> <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">
No se encontraron cultivos con los filtros seleccionados <Microscope className="h-10 w-10 text-gray-400 mx-auto mb-2 opacity-60" />
</p> <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> </div>
</div> </div>
+14 -15
View File
@@ -3,8 +3,7 @@ import {
Bed, Bed,
Users, Users,
ClipboardList, ClipboardList,
FlaskConical, Activity,
Activity,
Microscope, Microscope,
TrendingUp, TrendingUp,
AlertCircle, AlertCircle,
@@ -240,35 +239,35 @@ export function Dashboard({
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Button <Button
variant="outline" 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')} onClick={() => onCambiarVista('pacientes')}
> >
<Users className="h-6 w-6 text-blue-600" /> <Users className="h-6 w-6 text-blue-600 dark:text-blue-400 group-hover:scale-110 transition-transform" />
<span className="text-sm">Nuevo Paciente</span> <span className="text-sm font-medium">Nuevo Paciente</span>
</Button> </Button>
<Button <Button
variant="outline" 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')} onClick={() => onCambiarVista('internaciones')}
> >
<ClipboardList className="h-6 w-6 text-purple-600" /> <ClipboardList className="h-6 w-6 text-blue-600 dark:text-blue-400 group-hover:scale-110 transition-transform" />
<span className="text-sm">Nueva Internación</span> <span className="text-sm font-medium">Nueva Internación</span>
</Button> </Button>
<Button <Button
variant="outline" 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('laboratorios')} onClick={() => onCambiarVista('camas')}
> >
<FlaskConical className="h-6 w-6 text-amber-600" /> <Bed className="h-6 w-6 text-blue-600 dark:text-blue-400 group-hover:scale-110 transition-transform" />
<span className="text-sm">Laboratorio</span> <span className="text-sm font-medium">Mapa de Camas</span>
</Button> </Button>
<Button <Button
variant="outline" 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')} onClick={() => onCambiarVista('cultivos')}
> >
<Microscope className="h-6 w-6 text-teal-600" /> <Microscope className="h-6 w-6 text-blue-600 dark:text-blue-400 group-hover:scale-110 transition-transform" />
<span className="text-sm">Cultivo</span> <span className="text-sm font-medium">Cultivos</span>
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
+71 -70
View File
@@ -2840,13 +2840,35 @@ function SeccionCultivos({ cults, patient, internacionId, add, update, del, canE
canEdit?: boolean; canEdit?: boolean;
portalNode?: HTMLDivElement | null; portalNode?: HTMLDivElement | null;
}) { }) {
const { tiposCultivo } = useHospitalStore();
const [dialog, setDialog] = useState(false); const [dialog, setDialog] = useState(false);
const [resDialog, setResDialog] = useState(false); const [resDialog, setResDialog] = useState(false);
const [editDialog, setEditDialog] = useState(false); const [editDialog, setEditDialog] = useState(false);
const [selected, setSelected] = useState<Cultivo | null>(null); const [selected, setSelected] = useState<Cultivo | null>(null);
const [fechaToma, setFechaToma] = useState(getLocalToday()); const [fechaToma, setFechaToma] = useState(getLocalToday());
const [protocolo, setProtocolo] = useState(''); const [protocolo, setProtocolo] = useState('');
const [tipoMuestra, setTipoMuestra] = useState<Cultivo['tipoMuestra']>('HMCx2');
const defaultTipos = [
{ id: '1', nombre: 'HMCx2', categoria: 'Hemocultivos' },
{ id: '2', nombre: 'RC', categoria: 'Catéter' },
{ id: '3', nombre: 'PC', categoria: 'Punción' },
{ id: '4', nombre: 'UC', categoria: 'Urocultivo' },
{ id: '5', nombre: 'LCR', categoria: 'Líquidos' },
{ id: '6', nombre: 'LP', categoria: 'Líquidos' },
{ id: '7', nombre: 'LAsc', categoria: 'Líquidos' },
{ id: '8', nombre: 'LAbd', categoria: 'Líquidos' },
{ id: '9', nombre: 'Coleccion', categoria: 'Líquidos y Colecciones' },
{ id: '10', nombre: 'Partes Blandas', categoria: 'Tejidos' },
{ id: '11', nombre: 'Esputo GC', categoria: 'Respiratorio' },
{ id: '12', nombre: 'Esputo TBC', categoria: 'Respiratorio' },
{ id: '13', nombre: 'Baciloscopia', categoria: 'Respiratorio' },
{ id: '14', nombre: 'HNF Test Rápido', categoria: 'Hisopado Nasofaríngeo' },
{ id: '15', nombre: 'HNF Panel PCR', categoria: 'Hisopado Nasofaríngeo' },
{ id: '16', nombre: 'Hisopado Rectal KPC', categoria: 'Vigilancia Epidemiológica' },
];
const listaTipos = (tiposCultivo && tiposCultivo.length > 0) ? tiposCultivo : defaultTipos;
const [tipoMuestra, setTipoMuestra] = useState<string>('HMCx2');
const [observaciones, setObservaciones] = useState(''); const [observaciones, setObservaciones] = useState('');
const [fechaResultado, setFechaResultado] = useState(getLocalToday()); const [fechaResultado, setFechaResultado] = useState(getLocalToday());
const [estadoResultado, setEstadoResultado] = useState<Cultivo['estado']>('Parcial'); const [estadoResultado, setEstadoResultado] = useState<Cultivo['estado']>('Parcial');
@@ -2857,7 +2879,7 @@ function SeccionCultivos({ cults, patient, internacionId, add, update, del, canE
const reset = () => { const reset = () => {
setFechaToma(getLocalToday()); setFechaToma(getLocalToday());
setProtocolo(''); setProtocolo('');
setTipoMuestra('HMCx2'); setTipoMuestra(listaTipos[0]?.nombre || 'HMCx2');
setObservaciones(''); setObservaciones('');
setSelected(null); setSelected(null);
}; };
@@ -2960,34 +2982,24 @@ function SeccionCultivos({ cults, patient, internacionId, add, update, del, canE
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader><DialogTitle>Nuevo Cultivo</DialogTitle></DialogHeader> <DialogHeader><DialogTitle>Nuevo Cultivo</DialogTitle></DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div><Label>Fecha de Toma *</Label><Input type="date" value={fechaToma} onChange={e => setFechaToma(e.target.value)} /></div> <div><Label>Fecha de Toma *</Label><Input type="date" value={fechaToma} onChange={e => setFechaToma(e.target.value)} /></div>
<div><Label>Protocolo</Label><Input value={protocolo} onChange={e => setProtocolo(e.target.value)} placeholder="N° Protocolo" /></div> <div><Label>Protocolo</Label><Input value={protocolo} onChange={e => setProtocolo(e.target.value)} placeholder="N° Protocolo" /></div>
</div> </div>
<div> <div>
<Label>Tipo de Muestra *</Label> <Label>Tipo de Muestra *</Label>
<Select value={tipoMuestra} onValueChange={(v: Cultivo['tipoMuestra']) => setTipoMuestra(v)}> <Select value={tipoMuestra} onValueChange={(v: string) => setTipoMuestra(v)}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue placeholder="Seleccione tipo de muestra" /></SelectTrigger>
<SelectContent> <SelectContent className="max-h-72">
<SelectItem value="HMCx2">HMCx2</SelectItem> {listaTipos.map((t) => (
<SelectItem value="RC">RC</SelectItem> <SelectItem key={t.id} value={t.nombre}>
<SelectItem value="PC">PC</SelectItem> <span className="font-medium">{t.nombre}</span>
<SelectItem value="UC">UC</SelectItem> {t.categoria && <span className="text-xs text-gray-500 dark:text-gray-400 ml-2">({t.categoria})</span>}
<SelectItem value="LCR">LCR</SelectItem> </SelectItem>
<SelectItem value="LP">LP</SelectItem> ))}
<SelectItem value="LAsc">LAsc</SelectItem> {tipoMuestra && !listaTipos.some(t => t.nombre === tipoMuestra) && (
<SelectItem value="LAbd">LAbd</SelectItem> <SelectItem value={tipoMuestra}>{tipoMuestra}</SelectItem>
<SelectItem value="Coleccion">Coleccion</SelectItem> )}
<SelectItem value="Partes Blandas">Partes Blandas</SelectItem>
<SelectItem value="Esputo GC">Esputo GC</SelectItem>
<SelectItem value="Esputo TBC">Esputo TBC</SelectItem>
<SelectItem value="Baciloscopia">Baciloscopia</SelectItem>
<SelectGroup>
<SelectLabel>HNF</SelectLabel>
<SelectItem value="HNF Test Rápido">Test Rápido</SelectItem>
<SelectItem value="HNF Panel PCR">Panel PCR</SelectItem>
</SelectGroup>
<SelectItem value="Hisopado Rectal KPC">Hisopado Rectal KPC</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -3019,7 +3031,7 @@ function SeccionCultivos({ cults, patient, internacionId, add, update, del, canE
<DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>Cargar Resultado Definitivo</DialogTitle></DialogHeader> <DialogHeader><DialogTitle>Cargar Resultado Definitivo</DialogTitle></DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div><Label>Fecha de Resultado *</Label><Input type="date" value={fechaResultado} onChange={e => setFechaResultado(e.target.value)} /></div> <div><Label>Fecha de Resultado *</Label><Input type="date" value={fechaResultado} onChange={e => setFechaResultado(e.target.value)} /></div>
<div><Label>Resultado *</Label> <div><Label>Resultado *</Label>
<Select value={estadoResultado} onValueChange={(v: Cultivo['estado']) => setEstadoResultado(v)}> <Select value={estadoResultado} onValueChange={(v: Cultivo['estado']) => setEstadoResultado(v)}>
@@ -3049,22 +3061,22 @@ function SeccionCultivos({ cults, patient, internacionId, add, update, del, canE
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
{cs.length === 0 ? <div className="text-center py-8 text-gray-400"><Microscope className="h-12 w-12 mx-auto mb-2 opacity-50" /><p>No hay cultivos</p></div> : {cs.length === 0 ? <div className="text-center py-8 text-gray-400"><Microscope className="h-12 w-12 mx-auto mb-2 opacity-50" /><p>No hay cultivos</p></div> :
cs.map(c => ( cs.map(c => (
<Card key={c.id} className="hover:shadow-md transition-shadow"><CardContent className="p-4"> <Card key={c.id} className="hover:shadow-md transition-shadow overflow-hidden"><CardContent className="p-3.5 sm:p-4">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3 min-w-0">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="flex items-center 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 flex-shrink-0 ${c.estado === 'NAF/Pendiente' ? 'bg-amber-100' : c.estado === 'Parcial' ? 'bg-orange-100' : c.estado === 'Positivo' ? 'bg-red-100' : 'bg-green-100'}`}> <div className={`h-10 w-10 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${c.estado === 'NAF/Pendiente' ? 'bg-amber-100' : c.estado === 'Parcial' ? 'bg-orange-100' : c.estado === 'Positivo' ? 'bg-red-100' : 'bg-green-100'}`}>
<Microscope className={`h-5 w-5 ${c.estado === 'NAF/Pendiente' ? 'text-amber-600' : c.estado === 'Parcial' ? 'text-orange-600' : c.estado === 'Positivo' ? 'text-red-600' : 'text-green-600'}`} /> <Microscope className={`h-5 w-5 shrink-0 ${c.estado === 'NAF/Pendiente' ? 'text-amber-600' : c.estado === 'Parcial' ? 'text-orange-600' : c.estado === 'Positivo' ? 'text-red-600' : 'text-green-600'}`} />
</div> </div>
<div> <div className="min-w-0 flex-1">
<p className="font-medium">{formatDateDDMMYYYY(c.fechaToma)}</p> <p className="font-semibold text-gray-900 dark:text-white">{formatDateDDMMYYYY(c.fechaToma)}</p>
<div className="flex items-center gap-2 text-sm text-gray-500"> <div className="flex flex-wrap items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-gray-500 mt-1">
<Badge variant="outline">{c.tipoMuestra}</Badge> <Badge variant="outline" className="max-w-full truncate">{c.tipoMuestra}</Badge>
<Badge className={getEstadoColor(c.estado)}>{getEstadoLabel(c.estado)}</Badge> <Badge className={getEstadoColor(c.estado)}>{getEstadoLabel(c.estado)}</Badge>
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
{c.estado === 'NAF/Pendiente' && ( {c.estado === 'NAF/Pendiente' && (
<> <>
<Button size="sm" variant="outline" onClick={() => openParcial(c)}><AlertCircle className="h-4 w-4 mr-1" />Parcial</Button> <Button size="sm" variant="outline" onClick={() => openParcial(c)}><AlertCircle className="h-4 w-4 mr-1" />Parcial</Button>
@@ -3077,79 +3089,68 @@ function SeccionCultivos({ cults, patient, internacionId, add, update, del, canE
<Button size="sm" variant="outline" onClick={() => openDefinitivo(c)}><CheckCircle2 className="h-4 w-4 mr-1" />Definitivo</Button> <Button size="sm" variant="outline" onClick={() => openDefinitivo(c)}><CheckCircle2 className="h-4 w-4 mr-1" />Definitivo</Button>
</> </>
)} )}
{(c.estado === 'Positivo' || c.estado === 'Negativo') && <Button size="sm" variant="outline" onClick={() => openDefinitivo(c)}><Pencil className="h-4 w-4" /></Button>} {(c.estado === 'Positivo' || c.estado === 'Negativo') && <Button size="sm" variant="outline" onClick={() => openDefinitivo(c)}><Pencil className="h-4 w-4 mr-1" />Editar</Button>}
<Button size="sm" variant="outline" className="text-red-600" onClick={() => del(c.id)}><Trash2 /></Button> <Button size="sm" variant="outline" className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/40" onClick={() => del(c.id)}><Trash2 className="h-4 w-4" /></Button>
</div> </div>
</div> </div>
{c.protocolo && <p className="text-xs text-gray-500 dark:text-gray-400">Protocolo: {c.protocolo}</p>} {c.protocolo && <p className="text-xs text-gray-500 dark:text-gray-400 font-mono break-all">Protocolo: {c.protocolo}</p>}
{c.fechaResultado && (c.estado === 'Positivo' || c.estado === 'Negativo' || c.estado === 'Parcial') && ( {c.fechaResultado && (c.estado === 'Positivo' || c.estado === 'Negativo' || c.estado === 'Parcial') && (
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-gray-500 dark:text-gray-400">
{c.estado === 'Parcial' ? 'Fecha Resultado Parcial' : 'Fecha Definitivo'}: {formatDateDDMMYYYY(c.fechaResultado)} {c.estado === 'Parcial' ? 'Fecha Resultado Parcial' : 'Fecha Definitivo'}: {formatDateDDMMYYYY(c.fechaResultado)}
</p> </p>
)} )}
{c.estado === 'NAF/Pendiente' && c.observaciones && ( {c.estado === 'NAF/Pendiente' && c.observaciones && (
<p className="text-sm text-gray-600 dark:text-gray-300 bg-gray-50 dark:bg-gray-800 p-2.5 rounded border border-gray-200 dark:border-gray-700"> <p className="text-sm text-gray-600 dark:text-gray-300 bg-gray-50 dark:bg-gray-800 p-2.5 rounded border border-gray-200 dark:border-gray-700 break-words">
<span className="font-semibold">Observaciones:</span> {c.observaciones} <span className="font-semibold">Observaciones:</span> {c.observaciones}
</p> </p>
)} )}
{c.estado === 'Parcial' && ( {c.estado === 'Parcial' && (
<div className="bg-orange-50 dark:bg-orange-950/60 p-3 rounded-lg border border-orange-200 dark:border-orange-800/50 space-y-2"> <div className="bg-orange-50 dark:bg-orange-950/60 p-3 rounded-lg border border-orange-200 dark:border-orange-800/50 space-y-2 text-xs sm:text-sm">
{c.germen && ( {c.germen && (
<p className="text-sm font-medium text-orange-800 dark:text-orange-300 flex items-center gap-2"> <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" />PARCIAL - Germen: {c.germen} <AlertCircle className="h-4 w-4 shrink-0 mt-0.5" /><span>PARCIAL - Germen: {c.germen}</span>
</p> </p>
)} )}
{(c.sensible || c.resistente) && ( {(c.sensible || c.resistente) && (
<div className="space-y-1"> <div className="space-y-1">
{c.sensible && <p className="text-xs text-orange-700 dark:text-orange-300">Sensible: {c.sensible}</p>} {c.sensible && <p className="text-xs text-orange-700 dark:text-orange-300 break-words">Sensible: {c.sensible}</p>}
{c.resistente && <p className="text-xs text-orange-700 dark:text-orange-300">Resistente: {c.resistente}</p>} {c.resistente && <p className="text-xs text-orange-700 dark:text-orange-300 break-words">Resistente: {c.resistente}</p>}
</div> </div>
)} )}
{c.observaciones && ( {c.observaciones && (
<p className="text-xs text-orange-800 dark:text-orange-200 border-t border-orange-200 dark:border-orange-800/60 pt-2 mt-1"> <p className="text-xs text-orange-800 dark:text-orange-200 border-t border-orange-200 dark:border-orange-800/60 pt-2 mt-1 break-words">
<span className="font-semibold">Observaciones:</span> {c.observaciones} <span className="font-semibold">Observaciones:</span> {c.observaciones}
</p> </p>
)} )}
</div> </div>
)} )}
{c.estado === 'Positivo' && ( {c.estado === 'Positivo' && (
<div className="flex flex-col gap-2 mt-2"> <div className="flex flex-col gap-2 mt-1">
{c.germen && ( {c.germen && (
<div> <div className="border-2 border-red-600 bg-red-50 text-red-800 dark:bg-red-950/60 dark:text-red-300 dark:border-red-500 rounded-lg p-2.5 font-medium text-xs sm:text-sm flex items-start gap-1.5 break-words">
<Badge <AlertCircle className="h-4 w-4 text-red-600 shrink-0 mt-0.5" />
variant="outline" <span>Germen: {c.germen}</span>
className="border-solid border-2 border-red-600 bg-red-50 text-red-800 dark:bg-red-950/60 dark:text-red-300 dark:border-red-500 rounded px-2.5 py-1 font-semibold text-sm inline-flex items-center gap-1.5"
>
<AlertCircle className="h-4 w-4 text-red-600 shrink-0" />
<span>Germen: {c.germen}</span>
</Badge>
</div> </div>
)} )}
{(c.sensible || c.resistente) && ( {(c.sensible || c.resistente) && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-col gap-1.5">
{c.sensible && ( {c.sensible && (
<Badge <div className="border border-green-600 bg-green-50 text-green-800 dark:bg-green-950/60 dark:text-green-300 dark:border-green-500 rounded-lg p-2 text-xs sm:text-sm break-words">
variant="outline" <span className="font-semibold">Sensibilidad:</span> {c.sensible}
className="border-green-600 border bg-green-50 text-green-800 dark:bg-green-950/60 dark:text-green-300 dark:border-green-500 rounded px-2.5 py-1 font-medium text-sm inline-flex items-center gap-1.5" </div>
>
<span>Sensibilidad: {c.sensible}</span>
</Badge>
)} )}
{c.resistente && ( {c.resistente && (
<Badge <div className="border border-red-600 bg-red-50 text-red-800 dark:bg-red-950/60 dark:text-red-300 dark:border-red-500 rounded-lg p-2 text-xs sm:text-sm break-words">
variant="outline" <span className="font-semibold">Resistencia:</span> {c.resistente}
className="border-red-600 border bg-red-50 text-red-800 dark:bg-red-950/60 dark:text-red-300 dark:border-red-500 rounded px-2.5 py-1 font-medium text-sm inline-flex items-center gap-1.5" </div>
>
<span>Resistencia: {c.resistente}</span>
</Badge>
)} )}
</div> </div>
)} )}
{c.observaciones && ( {c.observaciones && (
<div className="bg-red-50/60 dark:bg-red-950/40 p-2.5 rounded-md border border-red-200/80 dark:border-red-900/50 text-xs text-red-900 dark:text-red-200"> <div className="bg-red-50/60 dark:bg-red-950/40 p-2.5 rounded-md border border-red-200/80 dark:border-red-900/50 text-xs text-red-900 dark:text-red-200 break-words">
<span className="font-semibold">Observaciones:</span> {c.observaciones} <span className="font-semibold">Observaciones:</span> {c.observaciones}
</div> </div>
)} )}
+3 -3
View File
@@ -235,7 +235,7 @@ export function Internaciones({
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<Dialog open={dialogoNuevaAbierto} onOpenChange={setDialogoNuevaAbierto}> <Dialog open={dialogoNuevaAbierto} onOpenChange={setDialogoNuevaAbierto}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" onClick={() => onNuevoIngreso ? onNuevoIngreso() : setDialogoNuevaAbierto(true)}> <Button onClick={() => onNuevoIngreso ? onNuevoIngreso() : setDialogoNuevaAbierto(true)}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Nuevo Ingreso Nuevo Ingreso
</Button> </Button>
@@ -361,7 +361,7 @@ export function Internaciones({
placeholder="Nombre del médico ingresante" placeholder="Nombre del médico ingresante"
/> />
</div> </div>
<Button variant="outline" <Button
className="w-full" className="w-full"
onClick={handleIniciarInternacion} onClick={handleIniciarInternacion}
disabled={!pacienteSeleccionado || !camaSeleccionada || !motivoConsulta || !enfermedadActual || !effectiveMedico} disabled={!pacienteSeleccionado || !camaSeleccionada || !motivoConsulta || !enfermedadActual || !effectiveMedico}
@@ -693,7 +693,7 @@ export function Internaciones({
placeholder="Ingrese el diagnóstico de egreso..." placeholder="Ingrese el diagnóstico de egreso..."
/> />
</div> </div>
<Button variant="outline" <Button
className="w-full" className="w-full"
onClick={handleFinalizarInternacion} onClick={handleFinalizarInternacion}
disabled={!fechaEgreso || !diagnosticoEgreso || !motivoEgreso || (motivoEgreso === 'Pase servicio' && !servicioAlQuePasa.trim())} disabled={!fechaEgreso || !diagnosticoEgreso || !motivoEgreso || (motivoEgreso === 'Pase servicio' && !servicioAlQuePasa.trim())}
+7 -3
View File
@@ -64,7 +64,7 @@ export function Layout({ children, vistaActual, onCambiarVista, currentUser, onL
} }
const renderNavContent = (onItemClick?: () => void) => ( const renderNavContent = (onItemClick?: () => void) => (
<nav className="flex flex-col gap-2"> <nav className="flex flex-col gap-1.5">
{filteredMenuItems.map((item) => { {filteredMenuItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = vistaActual === item.vista; const isActive = vistaActual === item.vista;
@@ -72,13 +72,17 @@ export function Layout({ children, vistaActual, onCambiarVista, currentUser, onL
<Button <Button
key={item.vista} key={item.vista}
variant={isActive ? 'default' : 'ghost'} 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={() => { onClick={() => {
onCambiarVista(item.vista); onCambiarVista(item.vista);
if (onItemClick) onItemClick(); 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> <span>{item.label}</span>
</Button> </Button>
); );
+18 -12
View File
@@ -158,24 +158,24 @@ export function MapaCamas({
</Badge> </Badge>
<div className="ml-0 sm:ml-4 flex items-center gap-2 flex-wrap sm:flex-nowrap"> <div className="ml-0 sm:ml-4 flex items-center gap-2 flex-wrap sm:flex-nowrap">
{currentUser?.rol === 'admin' && ( {currentUser?.rol === 'admin' && (
<Button variant="outline" onClick={() => { <Button variant="secondary" onClick={() => {
setEditingGrupoId(null); setEditingGrupoId(null);
setGrupoNombre(''); setGrupoNombre('');
setGrupoDialogOpen(true); setGrupoDialogOpen(true);
}} className="text-xs px-2 py-1 sm:text-sm sm:px-4 sm:py-2"> }} className="text-xs px-2.5 py-1.5 sm:text-sm sm:px-4 sm:py-2">
Administrar Grupos Administrar Grupos
</Button> </Button>
)} )}
{currentUser?.rol === 'admin' && ( {currentUser?.rol === 'admin' && (
<Button variant="outline" onClick={() => { <Button onClick={() => {
setEditingBed(null); setEditingBed(null);
setBedNumero(''); setBedNumero('');
setBedTipo('General'); setBedTipo('General');
setBedGrupoId(grupos?.[0]?.id); setBedGrupoId(grupos?.[0]?.id);
setBedDialogOpen(true); setBedDialogOpen(true);
}} className="text-xs px-2 py-1 sm:text-sm sm:px-4 sm:py-2"> }} className="text-xs px-2.5 py-1.5 sm:text-sm sm:px-4 sm:py-2">
<Plus className="h-3 w-3 sm:h-4 sm:w-4 sm:mr-1" /> <Plus className="h-3.5 w-3.5 sm:h-4 sm:w-4 mr-1" />
<span className="hidden sm:inline">Agregar Cama</span> <span>Agregar Cama</span>
</Button> </Button>
)} )}
</div> </div>
@@ -480,15 +480,15 @@ export function MapaCamas({
</Button> </Button>
)} )}
<Button size="sm" variant="outline" onClick={() => setGrupoDialogOpen(false)}> <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>
<Button size="sm" variant="outline" onClick={() => { <Button size="sm" onClick={() => {
if (!grupoNombre.trim()) return alert('Nombre requerido'); if (!grupoNombre.trim()) return alert('Nombre requerido');
if (editingGrupoId) onActualizarGrupo(editingGrupoId, { nombre: grupoNombre }); if (editingGrupoId) onActualizarGrupo(editingGrupoId, { nombre: grupoNombre });
else onAgregarGrupo({ nombre: grupoNombre }); else onAgregarGrupo({ nombre: grupoNombre });
setGrupoDialogOpen(false); setGrupoDialogOpen(false);
}}> }}>
<Save className="h-4 w-4" /> <Save className="h-4 w-4 mr-1" /> Guardar
</Button> </Button>
</div> </div>
<div> <div>
@@ -569,8 +569,11 @@ export function MapaCamas({
Eliminar Eliminar
</Button> </Button>
)} )}
<Button variant="outline" onClick={() => setBedDialogOpen(false)}><X className="h-4 w-4 mr-2" />Cancelar</Button> <Button variant="outline" onClick={() => setBedDialogOpen(false)}>
<Button variant="outline" onClick={() => { <X className="h-4 w-4 mr-2" />
Cancelar
</Button>
<Button onClick={() => {
if (!bedNumero.trim()) return alert('Número requerido'); if (!bedNumero.trim()) return alert('Número requerido');
if (editingBed) { if (editingBed) {
onActualizarCama(editingBed.id, { numero: bedNumero, tipo: bedTipo, grupoId: bedGrupoId }); onActualizarCama(editingBed.id, { numero: bedNumero, tipo: bedTipo, grupoId: bedGrupoId });
@@ -578,7 +581,10 @@ export function MapaCamas({
onAgregarCama({ numero: bedNumero, tipo: bedTipo, estado: 'Disponible', grupoId: bedGrupoId }); onAgregarCama({ numero: bedNumero, tipo: bedTipo, estado: 'Disponible', grupoId: bedGrupoId });
} }
setBedDialogOpen(false); setBedDialogOpen(false);
}}>Guardar</Button> }}>
<Save className="h-4 w-4 mr-2" />
Guardar
</Button>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
+3 -3
View File
@@ -155,7 +155,7 @@ export function Pacientes({
</div> </div>
<Dialog open={dialogoAbierto} onOpenChange={setDialogoAbierto}> <Dialog open={dialogoAbierto} onOpenChange={setDialogoAbierto}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" onClick={resetFormulario}> <Button onClick={resetFormulario}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Nuevo Paciente Nuevo Paciente
</Button> </Button>
@@ -307,7 +307,7 @@ export function Pacientes({
<X className="h-4 w-4 mr-2" /> <X className="h-4 w-4 mr-2" />
Cancelar Cancelar
</Button> </Button>
<Button variant="outline" <Button
onClick={handleGuardar} onClick={handleGuardar}
disabled={!nombre || !apellido || !dni || !fechaNacimiento} disabled={!nombre || !apellido || !dni || !fechaNacimiento}
> >
@@ -489,7 +489,7 @@ export function Pacientes({
{busqueda ? 'No se encontraron pacientes con esa búsqueda' : 'No hay pacientes registrados'} {busqueda ? 'No se encontraron pacientes con esa búsqueda' : 'No hay pacientes registrados'}
</p> </p>
{!busqueda && ( {!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" /> <Plus className="h-4 w-4 mr-2" />
Agregar primer paciente Agregar primer paciente
</Button> </Button>
+818 -112
View File
@@ -1,10 +1,65 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Terminal, Download, Upload, FileText, AlertCircle, RefreshCw } from 'lucide-react'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel } from '@/components/ui/select';
import {
Terminal,
Download,
Upload,
FileText,
AlertCircle,
RefreshCw,
Microscope,
Plus,
Pencil,
Trash2,
Search,
CheckCircle2,
Tag,
Info,
RotateCcw
} from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useHospitalStore } from '@/hooks/useHospitalStore';
import type { TipoCultivo } from '@/types';
export function SeccionSistema() { export function SeccionSistema() {
const {
tiposCultivo,
cultivos,
agregarTipoCultivo,
actualizarTipoCultivo,
eliminarTipoCultivo,
restablecerTiposCultivo,
refreshState
} = useHospitalStore();
const [activeTab, setActiveTab] = useState('cultivos');
// Tipos de Cultivo Management State
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategoria, setSelectedCategoria] = useState<string>('todas');
const [isNewDialogOpen, setIsNewDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
const [selectedTipo, setSelectedTipo] = useState<TipoCultivo | null>(null);
// Form states
const [formNombre, setFormNombre] = useState('');
const [formCategoria, setFormCategoria] = useState('General');
const [formDescripcion, setFormDescripcion] = useState('');
const [isCustomCategoriaNew, setIsCustomCategoriaNew] = useState(false);
const [isCustomCategoriaEdit, setIsCustomCategoriaEdit] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// Mongosh console states
const [mongoshCommand, setMongoshCommand] = useState(''); const [mongoshCommand, setMongoshCommand] = useState('');
const [consoleHistory, setConsoleHistory] = useState<{ id: number; type: 'input' | 'output' | 'error'; content: string }[]>([]); const [consoleHistory, setConsoleHistory] = useState<{ id: number; type: 'input' | 'output' | 'error'; content: string }[]>([]);
const [logs, setLogs] = useState<string>(''); const [logs, setLogs] = useState<string>('');
@@ -27,6 +82,178 @@ export function SeccionSistema() {
scrollToBottom(); scrollToBottom();
}, [consoleHistory, mongoshCommand]); }, [consoleHistory, mongoshCommand]);
// Categories list
const categoriasDisponibles = useMemo(() => {
const set = new Set<string>();
const defaultCats = [
'Hemocultivos',
'Catéter',
'Punción',
'Urocultivo',
'Líquidos y Colecciones',
'Tejidos',
'Respiratorio',
'Hisopado Nasofaríngeo',
'Vigilancia Epidemiológica',
'General'
];
defaultCats.forEach(c => set.add(c));
(tiposCultivo || []).forEach(t => {
if (t.categoria && t.categoria.trim()) {
set.add(t.categoria.trim());
}
});
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [tiposCultivo]);
// Count usage of each tipo in registered cultivos
const usageCountMap = useMemo(() => {
const map: Record<string, number> = {};
(cultivos || []).forEach(c => {
if (c.tipoMuestra) {
const key = c.tipoMuestra.trim();
map[key] = (map[key] || 0) + 1;
}
});
return map;
}, [cultivos]);
// Filtered Tipos
const tiposFiltrados = useMemo(() => {
const list = tiposCultivo || [];
return list.filter(tipo => {
const matchesSearch =
tipo.nombre.toLowerCase().includes(searchTerm.toLowerCase()) ||
(tipo.categoria && tipo.categoria.toLowerCase().includes(searchTerm.toLowerCase())) ||
(tipo.descripcion && tipo.descripcion.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesCategory =
selectedCategoria === 'todas' || (tipo.categoria || 'General') === selectedCategoria;
return matchesSearch && matchesCategory;
}).sort((a, b) => {
const catCompare = (a.categoria || 'General').localeCompare(b.categoria || 'General');
if (catCompare !== 0) return catCompare;
return a.nombre.localeCompare(b.nombre);
});
}, [tiposCultivo, searchTerm, selectedCategoria]);
// Open Add Dialog
const handleOpenNewDialog = () => {
setFormNombre('');
const defaultCat = selectedCategoria !== 'todas' ? selectedCategoria : (categoriasDisponibles[0] || 'General');
setFormCategoria(defaultCat);
setFormDescripcion('');
setIsCustomCategoriaNew(false);
setIsNewDialogOpen(true);
};
// Open Edit Dialog
const handleOpenEditDialog = (tipo: TipoCultivo) => {
setSelectedTipo(tipo);
setFormNombre(tipo.nombre);
const cat = tipo.categoria && tipo.categoria.trim() ? tipo.categoria.trim() : 'General';
setFormCategoria(cat);
setFormDescripcion(tipo.descripcion || '');
setIsCustomCategoriaEdit(false);
setIsEditDialogOpen(true);
};
// Open Delete Dialog
const handleOpenDeleteDialog = (tipo: TipoCultivo) => {
setSelectedTipo(tipo);
setIsDeleteDialogOpen(true);
};
// Submit New Tipo
const handleCreateTipo = async (e: React.FormEvent) => {
e.preventDefault();
if (!formNombre.trim()) {
toast.error('El nombre del tipo de cultivo es obligatorio');
return;
}
const exists = (tiposCultivo || []).some(
t => t.nombre.trim().toLowerCase() === formNombre.trim().toLowerCase()
);
if (exists) {
toast.error(`Ya existe un tipo de cultivo con el nombre "${formNombre.trim()}"`);
return;
}
setIsSubmitting(true);
try {
await agregarTipoCultivo({
nombre: formNombre.trim(),
categoria: formCategoria.trim() || 'General',
descripcion: formDescripcion.trim()
});
setIsNewDialogOpen(false);
} catch {
// Handled in hook
} finally {
setIsSubmitting(false);
}
};
// Submit Edit Tipo
const handleUpdateTipo = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedTipo) return;
if (!formNombre.trim()) {
toast.error('El nombre del tipo de cultivo es obligatorio');
return;
}
const exists = (tiposCultivo || []).some(
t => t.id !== selectedTipo.id && t.nombre.trim().toLowerCase() === formNombre.trim().toLowerCase()
);
if (exists) {
toast.error(`Ya existe otro tipo de cultivo con el nombre "${formNombre.trim()}"`);
return;
}
setIsSubmitting(true);
try {
await actualizarTipoCultivo(selectedTipo.id, {
nombre: formNombre.trim(),
categoria: formCategoria.trim() || 'General',
descripcion: formDescripcion.trim()
});
setIsEditDialogOpen(false);
} catch {
// Handled in hook
} finally {
setIsSubmitting(false);
}
};
// Submit Delete Tipo
const handleDeleteTipo = async () => {
if (!selectedTipo) return;
setIsSubmitting(true);
try {
await eliminarTipoCultivo(selectedTipo.id);
setIsDeleteDialogOpen(false);
} catch {
// Handled in hook
} finally {
setIsSubmitting(false);
}
};
// Submit Reset Tipos
const handleResetTipos = async () => {
setIsSubmitting(true);
try {
await restablecerTiposCultivo();
setIsResetDialogOpen(false);
} catch {
// Handled in hook
} finally {
setIsSubmitting(false);
}
};
// Mongosh console functions
const addHistory = (type: 'input' | 'output' | 'error', content: string) => { const addHistory = (type: 'input' | 'output' | 'error', content: string) => {
setConsoleHistory(prev => [...prev, { id: historyCounter.current++, type, content }]); setConsoleHistory(prev => [...prev, { id: historyCounter.current++, type, content }]);
}; };
@@ -132,14 +359,14 @@ export function SeccionSistema() {
throw new Error(data.error || 'Error al importar base de datos'); throw new Error(data.error || 'Error al importar base de datos');
} }
toast.success('Base de datos importada exitosamente. Recarga la página para ver los cambios.'); toast.success('Base de datos importada exitosamente.');
// Opcional: window.location.reload(); await refreshState();
} catch (error) { } catch (error) {
toast.error('Error: ' + String(error)); toast.error('Error: ' + String(error));
} }
}; };
reader.readAsText(file); reader.readAsText(file);
e.target.value = ''; // Reset input e.target.value = '';
}; };
const handleClearConsole = () => { const handleClearConsole = () => {
@@ -148,126 +375,605 @@ export function SeccionSistema() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h2 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">Sistema y Depuración</h2> <h2 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">Sistema y Configuración</h2>
<p className="text-gray-500 dark:text-gray-400">Herramientas avanzadas para administradores</p> <p className="text-gray-500 dark:text-gray-400">Administración de parámetros clínicos, base de datos y depuración</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<Card className="col-span-1 md:col-span-3"> <TabsList className="grid grid-cols-2 sm:w-96 mb-6">
<CardHeader> <TabsTrigger value="cultivos" className="flex items-center gap-2">
<div className="flex items-center justify-between"> <Microscope className="h-4 w-4" /> Tipos de Cultivos
<div> </TabsTrigger>
<CardTitle className="flex items-center gap-2"> <TabsTrigger value="mantenimiento" className="flex items-center gap-2">
<Terminal className="h-5 w-5" /> Consola mongosh <Terminal className="h-4 w-4" /> Consola & Mantenimiento
</CardTitle> </TabsTrigger>
<CardDescription>Ejecuta comandos de consulta directamente en la base de datos.</CardDescription> </TabsList>
</div>
<Button variant="outline" size="sm" onClick={handleClearConsole}> {/* TAB 1: GESTIÓN DE TIPOS DE CULTIVOS */}
<RefreshCw className="h-4 w-4 mr-2" /> Limpiar <TabsContent value="cultivos" className="space-y-6">
</Button> {/* Header Card with Metrics */}
</div> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
</CardHeader> <Card>
<CardContent> <CardContent className="p-5 flex items-center justify-between">
<div <div>
ref={terminalContainerRef} <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Total Tipos de Muestra</p>
className="bg-gray-900 text-green-400 font-mono text-sm p-4 rounded-md h-[400px] overflow-y-auto flex flex-col gap-1 relative cursor-text group" <p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">{(tiposCultivo || []).length}</p>
onClick={() => inputRef.current?.focus({ preventScroll: true })} </div>
> <div className="p-3 bg-blue-50 dark:bg-blue-950/60 rounded-xl text-blue-600 dark:text-blue-400">
{consoleHistory.length === 0 && ( <Microscope className="h-6 w-6" />
<div className="text-gray-500 italic mb-2">Conectado. Esperando comandos... Ej: db.pacientes.find()</div> </div>
)} </CardContent>
{consoleHistory.map((item) => ( </Card>
<div key={item.id} className={`break-words ${item.type === 'error' ? 'text-red-400' : item.type === 'input' ? 'text-blue-300' : 'text-gray-300'}`}>
{item.type === 'input' && <span className="text-blue-500 mr-2">{'>'}</span>} <Card>
{item.type === 'output' && <span className="text-gray-500 mr-2">{'<-'}</span>} <CardContent className="p-5 flex items-center justify-between">
{item.type === 'error' && <span className="text-red-500 mr-2">{'!'}</span>} <div>
<pre className="inline whitespace-pre-wrap font-inherit m-0">{item.content}</pre> <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Categorías Activas</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">{categoriasDisponibles.length}</p>
</div>
<div className="p-3 bg-indigo-50 dark:bg-indigo-950/60 rounded-xl text-indigo-600 dark:text-indigo-400">
<Tag className="h-6 w-6" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-5 flex items-center justify-between">
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Cultivos Registrados</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">{(cultivos || []).length}</p>
</div>
<div className="p-3 bg-emerald-50 dark:bg-emerald-950/60 rounded-xl text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-6 w-6" />
</div>
</CardContent>
</Card>
</div>
{/* Main Cultivos Management Card */}
<Card>
<CardHeader className="pb-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<CardTitle className="text-xl flex items-center gap-2">
<Microscope className="h-5 w-5 text-blue-600" />
Catálogo de Tipos de Cultivo y Muestras
</CardTitle>
<CardDescription>
Administre las opciones que aparecen al crear un nuevo cultivo en las Historias Clínicas.
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={() => setIsResetDialogOpen(true)}>
<RotateCcw className="h-4 w-4 mr-2" />
Restablecer
</Button>
<Button size="sm" onClick={handleOpenNewDialog}>
<Plus className="h-4 w-4 mr-2" />
Nuevo Tipo de Cultivo
</Button>
</div> </div>
))}
<div className="flex items-center text-blue-300 mt-1">
<span className="text-blue-500 mr-2">{'>'}</span>
<span className="whitespace-pre-wrap break-all">{mongoshCommand}</span>
<span className="w-2 bg-green-400 animate-pulse ml-[1px] inline-block h-4 opacity-0 group-focus-within:opacity-100 transition-opacity"></span>
</div> </div>
</CardHeader>
<div ref={consoleEndRef} className="h-1" />
<CardContent className="space-y-4">
<input {/* Search and Filters */}
ref={inputRef} <div className="flex flex-col sm:flex-row items-center gap-3">
type="text" <div className="relative flex-1 w-full">
className="opacity-0 w-[1px] h-[1px] absolute overflow-hidden p-0 m-0 border-0 pointer-events-none" <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
value={mongoshCommand} <Input
onChange={(e) => setMongoshCommand(e.target.value)} placeholder="Buscar por nombre, categoría o descripción..."
onKeyDown={handleKeyDown} value={searchTerm}
disabled={isExecuting} onChange={(e) => setSearchTerm(e.target.value)}
autoComplete="off" className="pl-9"
spellCheck="false" />
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<span className="text-xs text-gray-500 whitespace-nowrap font-medium">Categoría:</span>
<Select value={selectedCategoria} onValueChange={setSelectedCategoria}>
<SelectTrigger className="w-full sm:w-[220px]">
<SelectValue placeholder="Filtrar por categoría" />
</SelectTrigger>
<SelectContent className="max-h-60">
<SelectItem value="todas">Todas las categorías ({categoriasDisponibles.length})</SelectItem>
{categoriasDisponibles.map(cat => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Table of Tipos */}
<div className="border rounded-lg overflow-hidden border-gray-200 dark:border-gray-800">
<Table>
<TableHeader className="bg-gray-50 dark:bg-gray-800/50">
<TableRow>
<TableHead className="font-semibold">Nombre / Código</TableHead>
<TableHead className="font-semibold">Categoría</TableHead>
<TableHead className="font-semibold">Descripción</TableHead>
<TableHead className="font-semibold text-center">Uso en Cultivos</TableHead>
<TableHead className="font-semibold text-right">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tiposFiltrados.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-gray-500 dark:text-gray-400">
<Microscope className="h-8 w-8 mx-auto mb-2 opacity-40 text-gray-400" />
<p className="font-medium">No se encontraron tipos de cultivo</p>
<p className="text-xs text-gray-400 mt-1">Pruebe ajustando los filtros de búsqueda o agregue uno nuevo.</p>
</TableCell>
</TableRow>
) : (
tiposFiltrados.map((tipo) => {
const count = usageCountMap[tipo.nombre.trim()] || 0;
return (
<TableRow key={tipo.id} className="hover:bg-gray-50/60 dark:hover:bg-gray-800/40">
<TableCell className="font-bold text-gray-900 dark:text-gray-100">
<span className="bg-blue-50 text-blue-700 dark:bg-blue-950/80 dark:text-blue-300 px-2.5 py-1 rounded text-sm font-semibold border border-blue-200 dark:border-blue-800 inline-block">
{tipo.nombre}
</span>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs bg-gray-50 dark:bg-gray-800">
{tipo.categoria || 'General'}
</Badge>
</TableCell>
<TableCell className="text-sm text-gray-600 dark:text-gray-300 max-w-md">
{tipo.descripcion || <span className="text-gray-400 italic text-xs">Sin descripción</span>}
</TableCell>
<TableCell className="text-center">
{count > 0 ? (
<Badge className="bg-emerald-100 text-emerald-800 dark:bg-emerald-950/80 dark:text-emerald-300 text-xs font-semibold hover:bg-emerald-200">
{count} {count === 1 ? 'cultivo' : 'cultivos'}
</Badge>
) : (
<span className="text-xs text-gray-400">0 usos</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEditDialog(tipo)}
className="h-8 w-8 p-0 text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/50"
title="Modificar tipo de cultivo"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenDeleteDialog(tipo)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/50"
title="Eliminar tipo de cultivo"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
{/* TAB 2: CONSOLA MONGOSH & MANTENIMIENTO */}
<TabsContent value="mantenimiento" className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="col-span-1 md:col-span-3">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" /> Consola mongosh
</CardTitle>
<CardDescription>Ejecuta comandos de consulta directamente en la base de datos.</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={handleClearConsole}>
<RefreshCw className="h-4 w-4 mr-2" /> Limpiar
</Button>
</div>
</CardHeader>
<CardContent>
<div
ref={terminalContainerRef}
className="bg-gray-900 text-green-400 font-mono text-sm p-4 rounded-md h-[400px] overflow-y-auto flex flex-col gap-1 relative cursor-text group"
onClick={() => inputRef.current?.focus({ preventScroll: true })}
>
{consoleHistory.length === 0 && (
<div className="text-gray-500 italic mb-2">Conectado. Esperando comandos... Ej: db.pacientes.find()</div>
)}
{consoleHistory.map((item) => (
<div key={item.id} className={`break-words ${item.type === 'error' ? 'text-red-400' : item.type === 'input' ? 'text-blue-300' : 'text-gray-300'}`}>
{item.type === 'input' && <span className="text-blue-500 mr-2">{'>'}</span>}
{item.type === 'output' && <span className="text-gray-500 mr-2">{'<-'}</span>}
{item.type === 'error' && <span className="text-red-500 mr-2">{'!'}</span>}
<pre className="inline whitespace-pre-wrap font-inherit m-0">{item.content}</pre>
</div>
))}
<div className="flex items-center text-blue-300 mt-1">
<span className="text-blue-500 mr-2">{'>'}</span>
<span className="whitespace-pre-wrap break-all">{mongoshCommand}</span>
<span className="w-2 bg-green-400 animate-pulse ml-[1px] inline-block h-4 opacity-0 group-focus-within:opacity-100 transition-opacity"></span>
</div>
<div ref={consoleEndRef} className="h-1" />
<input
ref={inputRef}
type="text"
className="opacity-0 w-[1px] h-[1px] absolute overflow-hidden p-0 m-0 border-0 pointer-events-none"
value={mongoshCommand}
onChange={(e) => setMongoshCommand(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isExecuting}
autoComplete="off"
spellCheck="false"
/>
</div>
<div className="mt-2 text-xs text-gray-500 flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
Soporta asincronía. Variables globales: `db`, `ObjectId`. Flechas arriba/abajo para navegar el historial.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" /> Logs del Servidor
</CardTitle>
<CardDescription>Visualiza los registros recientes del servidor.</CardDescription>
</CardHeader>
<CardContent>
<Button className="w-full" onClick={handleViewLogs}>
Ver Logs Recientes
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" /> Exportar Datos
</CardTitle>
<CardDescription>Descarga una copia completa de la base de datos (JSON).</CardDescription>
</CardHeader>
<CardContent>
<Button className="w-full" variant="secondary" onClick={handleExportData}>
Exportar Todo
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" /> Importar Datos
</CardTitle>
<CardDescription>Restaura la base de datos desde un archivo (JSON).</CardDescription>
</CardHeader>
<CardContent>
<div className="relative">
<input
type="file"
accept=".json"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleImportData}
/>
<Button className="w-full" variant="destructive">
Seleccionar e Importar
</Button>
</div>
<p className="text-xs text-red-500 mt-2 text-center">Advertencia: Esto sobreescribirá todos los datos actuales.</p>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
{/* DIALOG: NUEVO TIPO DE CULTIVO */}
<Dialog open={isNewDialogOpen} onOpenChange={setIsNewDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5 text-blue-600" />
Nuevo Tipo de Cultivo / Muestra
</DialogTitle>
<DialogDescription>
Ingrese los detalles del nuevo tipo de muestra para registrar cultivos.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreateTipo} className="space-y-4 py-2">
<div>
<Label htmlFor="nuevo-nombre" className="text-sm font-semibold">Nombre / Código de Muestra *</Label>
<Input
id="nuevo-nombre"
placeholder="Ej: Hemocultivo Central, Líquido Peritoneal, etc."
value={formNombre}
onChange={(e) => setFormNombre(e.target.value)}
required
className="mt-1"
autoFocus
/> />
</div> </div>
<div className="mt-2 text-xs text-gray-500 flex items-center gap-1"> <div>
<AlertCircle className="h-3 w-3" /> <div className="flex items-center justify-between mb-1">
Soporta asincronía. Variables globales: `db`, `ObjectId`. Flechas arriba/abajo para navegar el historial. <Label htmlFor="nuevo-categoria" className="text-sm font-semibold">Categoría / Grupo</Label>
<button
type="button"
onClick={() => {
setIsCustomCategoriaNew(!isCustomCategoriaNew);
if (!isCustomCategoriaNew) setFormCategoria('');
else setFormCategoria(categoriasDisponibles[0] || 'General');
}}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline font-medium"
>
{isCustomCategoriaNew ? '← Elegir de existentes' : ' Crear nueva categoría'}
</button>
</div>
{!isCustomCategoriaNew ? (
<div>
<Select
value={categoriasDisponibles.includes(formCategoria) ? formCategoria : (categoriasDisponibles[0] || 'General')}
onValueChange={(val) => {
if (val === '__custom__') {
setIsCustomCategoriaNew(true);
setFormCategoria('');
} else {
setFormCategoria(val);
}
}}
>
<SelectTrigger id="nuevo-categoria" className="w-full">
<SelectValue placeholder="Seleccionar categoría" />
</SelectTrigger>
<SelectContent className="max-h-60">
<SelectGroup>
<SelectLabel>Categorías Existentes ({categoriasDisponibles.length})</SelectLabel>
{categoriasDisponibles.map(c => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectItem value="__custom__" className="text-blue-600 dark:text-blue-400 font-medium">
Escribir nueva categoría...
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<p className="text-xs text-gray-500 mt-1">Agrupa los tipos en el selector de creación de cultivo.</p>
</div>
) : (
<div className="space-y-1">
<Input
id="nuevo-categoria"
placeholder="Ej: Hemocultivos, Líquidos, Respiratorio..."
value={formCategoria}
onChange={(e) => setFormCategoria(e.target.value)}
required
autoFocus
/>
<p className="text-xs text-gray-500">Escriba el nombre para crear una nueva categoría.</p>
</div>
)}
</div> </div>
</CardContent>
</Card>
<Card> <div>
<CardHeader> <Label htmlFor="nuevo-desc" className="text-sm font-semibold">Descripción / Indicación (Opcional)</Label>
<CardTitle className="flex items-center gap-2"> <Input
<FileText className="h-5 w-5" /> Logs del Servidor id="nuevo-desc"
</CardTitle> placeholder="Ej: Muestra tomada por punción con técnica estéril"
<CardDescription>Visualiza los registros recientes del servidor.</CardDescription> value={formDescripcion}
</CardHeader> onChange={(e) => setFormDescripcion(e.target.value)}
<CardContent> className="mt-1"
<Button className="w-full" onClick={handleViewLogs}>
Ver Logs Recientes
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" /> Exportar Datos
</CardTitle>
<CardDescription>Descarga una copia completa de la base de datos (JSON).</CardDescription>
</CardHeader>
<CardContent>
<Button className="w-full" variant="secondary" onClick={handleExportData}>
Exportar Todo
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" /> Importar Datos
</CardTitle>
<CardDescription>Restaura la base de datos desde un archivo (JSON).</CardDescription>
</CardHeader>
<CardContent>
<div className="relative">
<input
type="file"
accept=".json"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleImportData}
/> />
<Button className="w-full" variant="destructive"> </div>
Seleccionar e Importar
<DialogFooter className="pt-4 flex gap-2">
<Button type="button" variant="outline" onClick={() => setIsNewDialogOpen(false)} disabled={isSubmitting}>
Cancelar
</Button> </Button>
</div> <Button type="submit" disabled={isSubmitting}>
<p className="text-xs text-red-500 mt-2 text-center">Advertencia: Esto sobreescribirá todos los datos actuales.</p> {isSubmitting ? 'Guardando...' : 'Crear Tipo de Cultivo'}
</CardContent> </Button>
</Card> </DialogFooter>
</div> </form>
</DialogContent>
</Dialog>
{/* DIALOG: EDITAR TIPO DE CULTIVO */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Pencil className="h-5 w-5 text-blue-600" />
Modificar Tipo de Cultivo
</DialogTitle>
<DialogDescription>
Edite el nombre, categoría o descripción de este tipo de cultivo.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleUpdateTipo} className="space-y-4 py-2">
<div>
<Label htmlFor="edit-nombre" className="text-sm font-semibold">Nombre / Código de Muestra *</Label>
<Input
id="edit-nombre"
value={formNombre}
onChange={(e) => setFormNombre(e.target.value)}
required
className="mt-1"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<Label htmlFor="edit-categoria" className="text-sm font-semibold">Categoría / Grupo</Label>
<button
type="button"
onClick={() => {
setIsCustomCategoriaEdit(!isCustomCategoriaEdit);
if (!isCustomCategoriaEdit) setFormCategoria('');
else setFormCategoria(categoriasDisponibles[0] || 'General');
}}
className="text-xs text-blue-600 dark:text-blue-400 hover:underline font-medium"
>
{isCustomCategoriaEdit ? '← Elegir de existentes' : ' Crear nueva categoría'}
</button>
</div>
{!isCustomCategoriaEdit ? (
<div>
<Select
value={categoriasDisponibles.includes(formCategoria) ? formCategoria : (categoriasDisponibles[0] || 'General')}
onValueChange={(val) => {
if (val === '__custom__') {
setIsCustomCategoriaEdit(true);
setFormCategoria('');
} else {
setFormCategoria(val);
}
}}
>
<SelectTrigger id="edit-categoria" className="w-full">
<SelectValue placeholder="Seleccionar categoría" />
</SelectTrigger>
<SelectContent className="max-h-60">
<SelectGroup>
<SelectLabel>Categorías Existentes ({categoriasDisponibles.length})</SelectLabel>
{categoriasDisponibles.map(c => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectItem value="__custom__" className="text-blue-600 dark:text-blue-400 font-medium">
Escribir nueva categoría...
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<p className="text-xs text-gray-500 mt-1">Categoría a la que pertenece este tipo de cultivo.</p>
</div>
) : (
<div className="space-y-1">
<Input
id="edit-categoria"
placeholder="Ej: Hemocultivos, Líquidos, Respiratorio..."
value={formCategoria}
onChange={(e) => setFormCategoria(e.target.value)}
required
autoFocus
/>
<p className="text-xs text-gray-500">Escriba el nombre de la nueva categoría.</p>
</div>
)}
</div>
<div>
<Label htmlFor="edit-desc" className="text-sm font-semibold">Descripción / Indicación</Label>
<Input
id="edit-desc"
value={formDescripcion}
onChange={(e) => setFormDescripcion(e.target.value)}
className="mt-1"
/>
</div>
<DialogFooter className="pt-4 flex gap-2">
<Button type="button" variant="outline" onClick={() => setIsEditDialogOpen(false)} disabled={isSubmitting}>
Cancelar
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Guardando...' : 'Guardar Cambios'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* DIALOG: ELIMINAR TIPO DE CULTIVO */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<Trash2 className="h-5 w-5" />
Eliminar Tipo de Cultivo
</DialogTitle>
<DialogDescription>
¿Está seguro de que desea eliminar el tipo de cultivo "{selectedTipo?.nombre}"?
</DialogDescription>
</DialogHeader>
{selectedTipo && (usageCountMap[selectedTipo.nombre.trim()] || 0) > 0 && (
<div className="bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 p-3 rounded-md flex items-start gap-2 text-amber-800 dark:text-amber-200 text-sm">
<Info className="h-4 w-4 mt-0.5 shrink-0 text-amber-600" />
<span>
<strong>Aviso:</strong> Existen {usageCountMap[selectedTipo.nombre.trim()]} registro(s) de cultivos que actualmente utilizan este tipo de muestra. Los registros existentes mantendrán el texto histórico, pero este tipo ya no aparecerá para nuevos registros.
</span>
</div>
)}
<DialogFooter className="pt-2 flex gap-2">
<Button type="button" variant="outline" onClick={() => setIsDeleteDialogOpen(false)} disabled={isSubmitting}>
Cancelar
</Button>
<Button type="button" variant="destructive" onClick={handleDeleteTipo} disabled={isSubmitting}>
{isSubmitting ? 'Eliminando...' : 'Sí, Eliminar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* DIALOG: RESTABLECER PREDETERMINADOS */}
<Dialog open={isResetDialogOpen} onOpenChange={setIsResetDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-blue-600">
<RotateCcw className="h-5 w-5" />
Restablecer Valores Predeterminados
</DialogTitle>
<DialogDescription>
Esta acción restaurará el catálogo estándar de tipos de cultivo (HMCx2, RC, UC, LCR, Esputos, Hisopados, etc.).
</DialogDescription>
</DialogHeader>
<p className="text-sm text-gray-600 dark:text-gray-300">
¿Desea restablecer los tipos de muestra a los valores predeterminados del hospital?
</p>
<DialogFooter className="pt-2 flex gap-2">
<Button type="button" variant="outline" onClick={() => setIsResetDialogOpen(false)} disabled={isSubmitting}>
Cancelar
</Button>
<Button type="button" onClick={handleResetTipos} disabled={isSubmitting}>
{isSubmitting ? 'Restableciendo...' : 'Restablecer Catálogo'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* LOGS MODAL */}
{isLogsModalOpen && ( {isLogsModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div className="bg-white dark:bg-gray-900 rounded-lg p-6 w-full max-w-4xl max-h-[90vh] flex flex-col shadow-xl"> <div className="bg-white dark:bg-gray-900 rounded-lg p-6 w-full max-w-4xl max-h-[90vh] flex flex-col shadow-xl">
+8 -1
View File
@@ -159,6 +159,13 @@ export interface AcidoBase {
interpretacion?: string; interpretacion?: string;
} }
export interface TipoCultivo {
id: string;
nombre: string;
categoria?: string;
descripcion?: string;
}
export interface Cultivo { export interface Cultivo {
id: string; id: string;
pacienteId: string; pacienteId: string;
@@ -167,7 +174,7 @@ export interface Cultivo {
fechaToma: string; fechaToma: string;
protocolo?: string; protocolo?: string;
fechaResultado?: string; fechaResultado?: string;
tipoMuestra: 'HMCx2' | 'RC' | 'PC' | 'UC' | 'LCR' | 'LP' | 'LAsc' | 'LAbd' | 'Coleccion' | 'Partes Blandas' | 'Esputo GC' | 'Esputo TBC' | 'Baciloscopia' | 'HNF Test Rápido' | 'HNF Panel PCR' | 'Hisopado Rectal KPC'; tipoMuestra: string;
germen?: string; germen?: string;
sensible?: string; sensible?: string;
resistente?: string; resistente?: string;