feat: agregar sistema de autenticación con usuarios y roles

- Agregar Login con autenticación por DNI y contraseña
- Crear tabla de usuarios con roles (admin, médico, enfermero)
- Implementar gestión de usuarios (CRUD) para administradores
- Agregar control de permisos basado en roles en el menú
- Persistir estado de sesión en backend
- Inicializar usuario admin por defecto (DNI: 12345678, pass: admin123)
- Corregir inicialización de arrays en el store para evitar errores al cargar estado
This commit is contained in:
2026-04-23 01:09:29 -03:00
parent 1d7a5e893f
commit 76e01bfd79
11 changed files with 869 additions and 31 deletions
+10 -2
View File
@@ -35,6 +35,7 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -78,7 +79,7 @@
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"vite": "^7.3.2"
}
},
"node_modules/@alloc/quick-lru": {
@@ -5500,6 +5501,14 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -10559,7 +10568,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
+2 -1
View File
@@ -37,6 +37,7 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -80,6 +81,6 @@
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"vite": "^7.3.2"
}
}
+98
View File
@@ -2,6 +2,7 @@ import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import bcrypt from 'bcryptjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PATH = __dirname + '/data/hospital.db';
@@ -19,6 +20,22 @@ export async function openDb() {
);
`);
await db.exec(`
CREATE TABLE IF NOT EXISTS usuarios (
id TEXT PRIMARY KEY,
apellido TEXT NOT NULL,
nombre TEXT NOT NULL,
dni TEXT UNIQUE NOT NULL,
fechaNacimiento TEXT NOT NULL,
email TEXT NOT NULL,
rol TEXT NOT NULL CHECK(rol IN ('admin', 'medico', 'enfermero')),
matriculaProfesional TEXT,
passwordHash TEXT NOT NULL,
areaId TEXT,
fechaCreacion TEXT NOT NULL
);
`);
return db;
}
@@ -34,3 +51,84 @@ export async function setValue(key, value) {
await db.run('INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value', key, value);
await db.close();
}
export async function getUsuarioByDni(dni) {
const db = await openDb();
const row = await db.get('SELECT * FROM usuarios WHERE dni = ?', dni);
await db.close();
return row || null;
}
export async function getUsuarioById(id) {
const db = await openDb();
const row = await db.get('SELECT * FROM usuarios WHERE id = ?', id);
await db.close();
return row || null;
}
export async function getAllUsuarios() {
const db = await openDb();
const rows = await db.all('SELECT * FROM usuarios ORDER BY apellido, nombre');
await db.close();
return rows;
}
export async function createUsuario(usuario) {
const db = await openDb();
await db.run(
`INSERT INTO usuarios (id, apellido, nombre, dni, fechaNacimiento, email, rol, matriculaProfesional, passwordHash, areaId, fechaCreacion)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
usuario.id,
usuario.apellido,
usuario.nombre,
usuario.dni,
usuario.fechaNacimiento,
usuario.email,
usuario.rol,
usuario.matriculaProfesional || null,
usuario.passwordHash,
usuario.areaId || null,
usuario.fechaCreacion
);
await db.close();
}
export async function updateUsuario(id, datos) {
const db = await openDb();
const fields = [];
const values = [];
if (datos.apellido !== undefined) { fields.push('apellido = ?'); values.push(datos.apellido); }
if (datos.nombre !== undefined) { fields.push('nombre = ?'); values.push(datos.nombre); }
if (datos.fechaNacimiento !== undefined) { fields.push('fechaNacimiento = ?'); values.push(datos.fechaNacimiento); }
if (datos.email !== undefined) { fields.push('email = ?'); values.push(datos.email); }
if (datos.rol !== undefined) { fields.push('rol = ?'); values.push(datos.rol); }
if (datos.matriculaProfesional !== undefined) { fields.push('matriculaProfesional = ?'); values.push(datos.matriculaProfesional); }
if (datos.passwordHash !== undefined) { fields.push('passwordHash = ?'); values.push(datos.passwordHash); }
if (datos.areaId !== undefined) { fields.push('areaId = ?'); values.push(datos.areaId); }
if (fields.length > 0) {
values.push(id);
await db.run(`UPDATE usuarios SET ${fields.join(', ')} WHERE id = ?`, values);
}
await db.close();
}
export async function deleteUsuario(id) {
const db = await openDb();
await db.run('DELETE FROM usuarios WHERE id = ?', id);
await db.close();
}
export async function verifyPassword(dni, password) {
const db = await openDb();
const row = await db.get('SELECT passwordHash FROM usuarios WHERE dni = ?', dni);
await db.close();
if (!row) return false;
return bcrypt.compareSync(password, row.passwordHash);
}
export async function hashPassword(password) {
return bcrypt.hashSync(password, 10);
}
+200 -1
View File
@@ -1,6 +1,6 @@
import express from 'express';
import cors from 'cors';
import { getValue, setValue } from './db.js';
import { getValue, setValue, getUsuarioByDni, getUsuarioById, getAllUsuarios, createUsuario, updateUsuario, deleteUsuario, verifyPassword, hashPassword } from './db.js';
const app = express();
app.use(cors());
@@ -34,3 +34,202 @@ app.put('/api/state', async (req, res) => {
app.listen(PORT, () => {
console.log(`API server listening on http://localhost:${PORT}`);
});
// Crear usuario admin inicial si no existe
async function initAdminUser() {
try {
const adminExists = await getUsuarioByDni('12345678');
if (!adminExists) {
const passwordHash = await hashPassword('admin123');
const adminUser = {
id: generateUUID(),
apellido: 'Admin',
nombre: 'Sistema',
dni: '12345678',
fechaNacimiento: '1970-01-01',
email: 'admin@hospital.gob.ar',
rol: 'admin',
matriculaProfesional: null,
passwordHash,
areaId: null,
fechaCreacion: new Date().toISOString().split('T')[0]
};
await createUsuario(adminUser);
console.log('Usuario admin creado: DNI 12345678, Password admin123');
}
} catch (err) {
console.error('Error creating admin user:', err);
}
}
initAdminUser();
// === AUTENTICACIÓN ===
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
app.post('/api/auth/login', async (req, res) => {
try {
const { dni, password } = req.body;
if (!dni || !password) {
return res.status(400).json({ error: 'DNI y contraseña requeridos' });
}
const usuario = await getUsuarioByDni(dni);
if (!usuario) {
return res.status(401).json({ error: 'Usuario no encontrado' });
}
const validPassword = await verifyPassword(dni, password);
if (!validPassword) {
return res.status(401).json({ error: 'Contraseña incorrecta' });
}
const { passwordHash, ...userWithoutPassword } = usuario;
res.json(userWithoutPassword);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error en autenticación' });
}
});
app.post('/api/auth/change-password', async (req, res) => {
try {
const { dni, oldPassword, newPassword } = req.body;
if (!dni || !oldPassword || !newPassword) {
return res.status(400).json({ error: 'Todos los campos son requeridos' });
}
const validPassword = await verifyPassword(dni, oldPassword);
if (!validPassword) {
return res.status(401).json({ error: 'Contraseña actual incorrecta' });
}
const newHash = await hashPassword(newPassword);
await updateUsuarioByDni(dni, { passwordHash: newHash });
res.json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error al cambiar contraseña' });
}
});
app.put('/api/auth/update-email', async (req, res) => {
try {
const { dni, newEmail } = req.body;
if (!dni || !newEmail) {
return res.status(400).json({ error: 'DNI y email son requeridos' });
}
await updateUsuarioByDni(dni, { email: newEmail });
res.json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error al actualizar email' });
}
});
// === GESTIÓN DE USUARIOS (ADMIN) ===
app.get('/api/usuarios', async (req, res) => {
try {
const usuarios = await getAllUsuarios();
const usuariosSinPassword = usuarios.map(({ passwordHash, ...u }) => u);
res.json(usuariosSinPassword);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error al obtener usuarios' });
}
});
app.post('/api/usuarios', async (req, res) => {
try {
const { apellido, nombre, dni, fechaNacimiento, email, rol, matriculaProfesional, password, areaId } = req.body;
if (!apellido || !nombre || !dni || !fechaNacimiento || !email || !rol || !password) {
return res.status(400).json({ error: 'Todos los campos obligatorios deben estar presentes' });
}
const existente = await getUsuarioByDni(dni);
if (existente) {
return res.status(400).json({ error: 'Ya existe un usuario con ese DNI' });
}
const passwordHash = await hashPassword(password);
const usuario = {
id: generateUUID(),
apellido,
nombre,
dni,
fechaNacimiento,
email,
rol,
matriculaProfesional: matriculaProfesional || null,
passwordHash,
areaId: areaId || null,
fechaCreacion: new Date().toISOString().split('T')[0]
};
await createUsuario(usuario);
res.json({ ...usuario, passwordHash: undefined });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error al crear usuario' });
}
});
app.put('/api/usuarios/:id', async (req, res) => {
try {
const { id } = req.params;
const { apellido, nombre, fechaNacimiento, email, rol, matriculaProfesional, areaId, password } = req.body;
const usuario = await getUsuarioById(id);
if (!usuario) {
return res.status(404).json({ error: 'Usuario no encontrado' });
}
const datos = {};
if (apellido !== undefined) datos.apellido = apellido;
if (nombre !== undefined) datos.nombre = nombre;
if (fechaNacimiento !== undefined) datos.fechaNacimiento = fechaNacimiento;
if (email !== undefined) datos.email = email;
if (rol !== undefined) datos.rol = rol;
if (matriculaProfesional !== undefined) datos.matriculaProfesional = matriculaProfesional;
if (areaId !== undefined) datos.areaId = areaId;
if (password) {
datos.passwordHash = await hashPassword(password);
}
await updateUsuario(id, datos);
res.json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error al actualizar usuario' });
}
});
app.delete('/api/usuarios/:id', async (req, res) => {
try {
const { id } = req.params;
await deleteUsuario(id);
res.json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error al eliminar usuario' });
}
});
// Helper para actualizar por DNI
async function updateUsuarioByDni(dni, datos) {
const { getUsuarioByDni, updateUsuario } = await import('./db.js');
const usuario = await getUsuarioByDni(dni);
if (usuario) {
await updateUsuario(usuario.id, datos);
}
}
+4 -2
View File
@@ -6,9 +6,11 @@
"start": "node index.js"
},
"dependencies": {
"better-sqlite3": "^11.7.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"express": "^4.21.2",
"http-proxy-middleware": "^3.0.5"
"http-proxy-middleware": "^3.0.5",
"sqlite": "^5.1.7",
"sqlite3": "^5.1.7"
}
}
+11 -1
View File
@@ -10,6 +10,8 @@ import { Cultivos } from '@/sections/Cultivos';
import { HistoriaClinica } from '@/sections/HistoriaClinica';
import { NuevoIngreso } from '@/sections/NuevoIngreso';
import { EditIngreso } from '@/sections/EditIngreso';
import { Login } from '@/sections/Login';
import { GestionUsuarios } from '@/sections/GestionUsuarios';
import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button';
import { ThemeProvider } from "@/components/theme-provider";
@@ -26,6 +28,10 @@ function AppContent() {
);
}
if (!store.isAuthenticated) {
return <Login />;
}
const renderVista = () => {
switch (store.vistaActual) {
case 'dashboard':
@@ -217,13 +223,17 @@ function AppContent() {
/>
);
}
case 'usuarios':
return (
<GestionUsuarios />
);
default:
return null;
}
};
return (
<Layout vistaActual={store.vistaActual} onCambiarVista={store.setVista}>
<Layout vistaActual={store.vistaActual} onCambiarVista={store.setVista} currentUser={store.currentUser} onLogout={store.logout}>
{renderVista()}
</Layout>
);
+113 -5
View File
@@ -12,7 +12,9 @@ import type {
Interconsulta,
ATB,
Indicacion,
Vista
Vista,
Usuario,
RolUsuario
} from '@/types';
// Fallback UUID generator for non-secure contexts (HTTP without localhost)
@@ -44,10 +46,13 @@ interface HospitalState {
movimientosIndicaciones: any[];
vistaActual: Vista;
currentInternacionId?: string | null;
usuarios: Usuario[];
currentUser: Usuario | null;
isAuthenticated: boolean;
}
const API_BASE = import.meta.env.VITE_API_URL ?? `${window.location.origin}/api`;
const API_BASE = 'http://localhost:4001/api';
const defaultState = (): HospitalState => ({
pacientes: [],
@@ -63,7 +68,10 @@ const defaultState = (): HospitalState => ({
vistaActual: 'dashboard',
indicaciones: [],
movimientosIndicaciones: [],
camas: []
camas: [],
usuarios: [],
currentUser: null,
isAuthenticated: false,
});
export function useHospitalStore() {
@@ -79,8 +87,15 @@ export function useHospitalStore() {
if (!res.ok) throw new Error('no state');
const body = await res.json();
if (mounted && body) {
const normalized = body as HospitalState;
normalized.movimientosIndicaciones = normalized.movimientosIndicaciones || [];
const defaults = defaultState();
const normalized = {
...defaults,
...body,
estudiosComplementarios: body.estudiosComplementarios || [],
interconsultas: body.interconsultas || [],
atb: body.atb || [],
movimientosIndicaciones: body.movimientosIndicaciones || [],
};
setState(normalized);
}
} catch (err) {
@@ -542,6 +557,93 @@ const getEstadisticas = useCallback(() => {
setState(prev => ({ ...prev, currentInternacionId: id ?? null }));
}, []);
// Auth functions
const login = useCallback(async (dni: string, password: string) => {
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dni, password }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Error en autenticación');
}
const user = await res.json();
setState(prev => ({ ...prev, currentUser: user, isAuthenticated: true }));
return user;
} catch (err) {
throw err;
}
}, []);
const logout = useCallback(() => {
setState(prev => {
const newState = { ...prev, currentUser: null, isAuthenticated: false };
fetch(`${API_BASE}/state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...newState }),
}).catch(() => {});
return newState;
});
}, []);
const changePassword = useCallback(async (dni: string, oldPassword: string, newPassword: string) => {
const res = await fetch(`${API_BASE}/auth/change-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dni, oldPassword, newPassword }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Error al cambiar contraseña');
}
return true;
}, []);
const updateEmail = useCallback(async (dni: string, newEmail: string) => {
const res = await fetch(`${API_BASE}/auth/update-email`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dni, newEmail }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Error al actualizar email');
}
return true;
}, []);
const hasPermission = useCallback((permission: 'read' | 'write', section: string): boolean => {
const user = state.currentUser;
if (!user) return false;
if (user.rol === 'admin') return true;
if (user.rol === 'enfermero') {
if (section === 'glucemias' || section === 'signosvitales') return permission === 'read' || permission === 'write';
return permission === 'read';
}
if (user.rol === 'medico') {
if (section === 'miArea') return permission === 'read' || permission === 'write';
return permission === 'read';
}
return false;
}, [state.currentUser]);
const canAccessInternacion = useCallback((internacionId: string): boolean => {
const user = state.currentUser;
if (!user) return false;
const rol = user.rol as string;
if (rol === 'admin') return true;
if (rol === 'medico' && user.areaId) {
const internacion = state.internaciones.find(i => i.id === internacionId);
if (!internacion) return false;
const cama = state.camas.find(c => c.id === internacion.camaId);
return cama?.areaId === user.areaId;
}
return rol === 'admin';
}, [state.currentUser, state.internaciones, state.camas]);
return {
...state,
isLoaded,
@@ -594,5 +696,11 @@ const getEstadisticas = useCallback(() => {
actualizarLaboratorio,
setCurrentInternacion,
getEstadisticas,
login,
logout,
changePassword,
updateEmail,
hasPermission,
canAccessInternacion,
};
}
+275
View File
@@ -0,0 +1,275 @@
import { useState, useEffect } from 'react';
import { useHospitalStore } from '@/hooks/useHospitalStore';
import type { Usuario, RolUsuario } from '@/types';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Plus, Pencil, Trash2, UserCog, Mail, Shield } from 'lucide-react';
const API_BASE = 'http://localhost:4001/api';
export function GestionUsuarios() {
const { logout, areas } = useHospitalStore();
const [usuarios, setUsuarios] = useState<Usuario[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editUsuario, setEditUsuario] = useState<Usuario | null>(null);
const [form, setForm] = useState({
apellido: '',
nombre: '',
dni: '',
fechaNacimiento: '',
email: '',
rol: 'medico' as RolUsuario,
matriculaProfesional: '',
password: '',
areaId: '',
});
useEffect(() => {
fetchUsuarios();
}, []);
const fetchUsuarios = async () => {
try {
const res = await fetch(`${API_BASE}/usuarios`);
const data = await res.json();
setUsuarios(data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const resetForm = () => {
setForm({
apellido: '',
nombre: '',
dni: '',
fechaNacimiento: '',
email: '',
rol: 'medico',
matriculaProfesional: '',
password: '',
areaId: '',
});
setEditUsuario(null);
};
const openEdit = (usu: Usuario) => {
setEditUsuario(usu);
setForm({
apellido: usu.apellido,
nombre: usu.nombre,
dni: usu.dni,
fechaNacimiento: usu.fechaNacimiento,
email: usu.email,
rol: usu.rol,
matriculaProfesional: usu.matriculaProfesional || '',
password: '',
areaId: usu.areaId || '',
});
setDialogOpen(true);
};
const handleSubmit = async () => {
try {
if (editUsuario) {
await fetch(`${API_BASE}/usuarios/${editUsuario.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
} else {
await fetch(`${API_BASE}/usuarios`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
}
setDialogOpen(false);
resetForm();
fetchUsuarios();
} catch (err) {
console.error(err);
alert('Error al guardar usuario');
}
};
const handleDelete = async (id: string) => {
if (!confirm('¿Está seguro de eliminar este usuario?')) return;
try {
await fetch(`${API_BASE}/usuarios/${id}`, { method: 'DELETE' });
fetchUsuarios();
} catch (err) {
console.error(err);
}
};
const getRolLabel = (rol: RolUsuario) => {
switch (rol) {
case 'admin': return 'Administrador';
case 'medico': return 'Médico';
case 'enfermero': return 'Enfermero';
}
};
const getAreaName = (areaId?: string) => {
if (!areaId) return '-';
return areas.find(a => a.id === areaId)?.nombre || '-';
};
if (loading) {
return <div>Cargando...</div>;
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold dark:text-white">Gestión de Usuarios</h1>
<p className="text-gray-500 dark:text-gray-400">Administrar usuarios del sistema</p>
</div>
<Button onClick={() => { resetForm(); setDialogOpen(true); }}>
<Plus className="h-4 w-4 mr-2" />
Nuevo Usuario
</Button>
</div>
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Apellido, Nombre</TableHead>
<TableHead>DNI</TableHead>
<TableHead>Rol</TableHead>
<TableHead>Área</TableHead>
<TableHead>Email</TableHead>
<TableHead>Matrícula</TableHead>
<TableHead>Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usuarios.map((usu) => (
<TableRow key={usu.id}>
<TableCell className="font-medium">{usu.apellido}, {usu.nombre}</TableCell>
<TableCell>{usu.dni}</TableCell>
<TableCell>
<Badge variant={usu.rol === 'admin' ? 'default' : 'secondary'}>
{getRolLabel(usu.rol)}
</Badge>
</TableCell>
<TableCell>{getAreaName(usu.areaId)}</TableCell>
<TableCell>{usu.email}</TableCell>
<TableCell>{usu.matriculaProfesional || '-'}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button variant="ghost" size="icon" onClick={() => openEdit(usu)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(usu.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editUsuario ? 'Editar Usuario' : 'Nuevo Usuario'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Apellido</Label>
<Input value={form.apellido} onChange={(e) => setForm({ ...form, apellido: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Nombre</Label>
<Input value={form.nombre} onChange={(e) => setForm({ ...form, nombre: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>DNI</Label>
<Input value={form.dni} onChange={(e) => setForm({ ...form, dni: e.target.value })} disabled={!!editUsuario} />
</div>
<div className="space-y-2">
<Label>Fecha de Nacimiento</Label>
<Input type="date" value={form.fechaNacimiento} onChange={(e) => setForm({ ...form, fechaNacimiento: e.target.value })} />
</div>
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Rol</Label>
<Select value={form.rol} onValueChange={(v) => setForm({ ...form, rol: v as RolUsuario })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Administrador</SelectItem>
<SelectItem value="medico">Médico</SelectItem>
<SelectItem value="enfermero">Enfermero</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Matrícula Profesional</Label>
<Input value={form.matriculaProfesional} onChange={(e) => setForm({ ...form, matriculaProfesional: e.target.value })} />
</div>
</div>
{form.rol === 'medico' && (
<div className="space-y-2">
<Label>Área Asignada</Label>
<Select value={form.areaId} onValueChange={(v) => setForm({ ...form, areaId: v })}>
<SelectTrigger>
<SelectValue placeholder="Seleccionar área" />
</SelectTrigger>
<SelectContent>
{areas.map((area) => (
<SelectItem key={area.id} value={area.id}>{area.nombre}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label>{editUsuario ? 'Nueva Contraseña (opcional)' : 'Contraseña'}</Label>
<Input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
</div>
</div>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancelar</Button>
<Button onClick={handleSubmit}>
{editUsuario ? 'Actualizar' : 'Crear'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+50 -17
View File
@@ -4,17 +4,18 @@ import {
Users,
ClipboardList,
FileText,
Activity,
Microscope,
Menu,
Sun,
Moon
Moon,
LogOut,
UserCog
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import type { Vista } from '@/types';
import type { Vista, Usuario } from '@/types';
import {
DropdownMenu,
@@ -28,27 +29,39 @@ interface LayoutProps {
children: React.ReactNode;
vistaActual: Vista;
onCambiarVista: (vista: Vista) => void;
currentUser: Usuario | null;
onLogout: () => void;
}
const menuItems: { vista: Vista; label: string; icon: React.ElementType }[] = [
{ vista: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ vista: 'camas', label: 'Camas', icon: Bed },
{ vista: 'pacientes', label: 'Pacientes', icon: Users },
{ vista: 'internaciones', label: 'Internaciones', icon: ClipboardList },
{ vista: 'evoluciones', label: 'Evoluciones', icon: FileText },
//{ vista: 'laboratorios', label: 'Laboratorios', icon: FlaskConical },
//{ vista: 'acidobase', label: 'Ácido-Base', icon: Activity },
{ vista: 'cultivos', label: 'Cultivos', icon: Microscope },
];
export function Layout({ children, vistaActual, onCambiarVista }: LayoutProps) {
export function Layout({ children, vistaActual, onCambiarVista, currentUser, onLogout }: LayoutProps) {
const { theme, setTheme } = useTheme()
const toggleDarkMode = () => setTheme(theme === "dark" ? "light" : "dark")
const menuItems: { vista: Vista; label: string; icon: React.ElementType; rolRequerido?: string }[] = [
{ vista: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ vista: 'camas', label: 'Camas', icon: Bed, rolRequerido: 'medico' },
{ vista: 'pacientes', label: 'Pacientes', icon: Users },
{ vista: 'internaciones', label: 'Internaciones', icon: ClipboardList },
{ vista: 'evoluciones', label: 'Evoluciones', icon: FileText },
{ vista: 'cultivos', label: 'Cultivos', icon: Microscope },
];
const filteredMenuItems = menuItems.filter(item => {
const rol = currentUser?.rol as string | undefined;
if (!item.rolRequerido) return true;
if (rol === 'admin') return true;
if (item.rolRequerido === 'medico' && (rol === 'medico' || rol === 'admin')) return true;
return false;
});
if ((currentUser?.rol as string) === 'admin') {
filteredMenuItems.push({ vista: 'usuarios', label: 'Usuarios', icon: UserCog });
}
const NavContent = () => (
<nav className="flex flex-col gap-2">
{menuItems.map((item) => {
{filteredMenuItems.map((item) => {
const Icon = item.icon;
const isActive = vistaActual === item.vista;
return (
@@ -85,6 +98,10 @@ export function Layout({ children, vistaActual, onCambiarVista }: LayoutProps) {
<NavContent />
</div>
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
<div className="text-sm text-gray-600 dark:text-gray-300 mb-2">
<div className="font-medium">{currentUser?.apellido}, {currentUser?.nombre}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">{currentUser?.rol}</div>
</div>
<div className="flex items-center justify-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -106,6 +123,9 @@ export function Layout({ children, vistaActual, onCambiarVista }: LayoutProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="icon" onClick={onLogout} title="Cerrar sesión">
<LogOut className="h-4 w-4" />
</Button>
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 text-center mt-2">
v2.0
@@ -132,11 +152,15 @@ export function Layout({ children, vistaActual, onCambiarVista }: LayoutProps) {
<div className="flex items-center justify-between">
<span className="font-bold text-lg dark:text-white">Menú</span>
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-300">
<div className="font-medium">{currentUser?.apellido}, {currentUser?.nombre}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">{currentUser?.rol}</div>
</div>
</div>
<div className="p-4">
<NavContent />
</div>
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
<div className="p-4 border-t border-gray-200 dark:border-gray-700 space-y-2">
<Button
variant="ghost"
size="sm"
@@ -146,6 +170,15 @@ export function Layout({ children, vistaActual, onCambiarVista }: LayoutProps) {
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
<span className="text-sm">{theme === "dark" ? 'Modo Claro' : 'Modo Oscuro'}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={onLogout}
className="w-full flex items-center justify-center gap-2 dark:text-white"
>
<LogOut className="h-4 w-4" />
<span className="text-sm">Cerrar Sesión</span>
</Button>
</div>
</SheetContent>
</Sheet>
+88
View File
@@ -0,0 +1,88 @@
import { useState } from 'react';
import { useHospitalStore } from '@/hooks/useHospitalStore';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Shield, Lock, User } from 'lucide-react';
export function Login() {
const { login } = useHospitalStore();
const [dni, setDni] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(dni, password);
} catch (err: any) {
setError(err.message || 'Error de autenticación');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center">
<Shield className="w-8 h-8 text-white" />
</div>
<CardTitle className="text-2xl">Sistema de Gestión Hospitalaria</CardTitle>
<CardDescription>Ingrese sus credenciales para acceder</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="dni">DNI</Label>
<div className="relative">
<User className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
id="dni"
type="text"
placeholder="Ingrese su DNI"
value={dni}
onChange={(e) => setDni(e.target.value)}
className="pl-10"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Contraseña</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
id="password"
type="password"
placeholder="Ingrese su contraseña"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10"
required
/>
</div>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-3 py-2 rounded-md text-sm">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Ingresando...' : 'Ingresar'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+17 -1
View File
@@ -204,4 +204,20 @@ export interface Indicacion {
fechaSuspension?: string;
}
export type Vista = 'dashboard' | 'camas' | 'pacientes' | 'internaciones' | 'evoluciones' | 'laboratorios' | 'acidobase' | 'cultivos' | 'historiaclinica' | 'nuevoingreso' | 'editaringreso';
export type Vista = 'dashboard' | 'camas' | 'pacientes' | 'internaciones' | 'evoluciones' | 'laboratorios' | 'acidobase' | 'cultivos' | 'historiaclinica' | 'nuevoingreso' | 'editaringreso' | 'usuarios';
export type RolUsuario = 'admin' | 'medico' | 'enfermero';
export interface Usuario {
id: string;
apellido: string;
nombre: string;
dni: string;
fechaNacimiento: string;
email: string;
rol: RolUsuario;
matriculaProfesional?: string;
passwordHash: string;
areaId?: string;
fechaCreacion: string;
}