feat: migrate protocols to MongoDB and implement admin list view
Some checks are pending
Deploy Código 0 / deploy (push) Waiting to run

This commit is contained in:
Javier 2026-03-25 11:59:07 +01:00
parent 3963361aa8
commit 6c560de2bd
5 changed files with 208 additions and 12 deletions

View file

@ -2,10 +2,10 @@ import { Router } from 'express';
import { ProtocolController } from '../controllers/ProtocolController';
import { GetAllProtocolsUseCase } from '../../../application/usecases/GetAllProtocolsUseCase';
import { GetProtocolByIdUseCase } from '../../../application/usecases/GetProtocolByIdUseCase';
import { StaticProtocolRepository } from '../../persistence/StaticProtocolRepository';
import { MongoProtocolRepository } from '../../persistence/MongoProtocolRepository';
// manual dependency injection for routes
const repository = new StaticProtocolRepository();
const repository = new MongoProtocolRepository();
const getAllUseCase = new GetAllProtocolsUseCase(repository);
const getByIdUseCase = new GetProtocolByIdUseCase(repository);
const controller = new ProtocolController(getAllUseCase, getByIdUseCase);
@ -15,20 +15,32 @@ const router = Router();
router.get('/', controller.getAll);
router.get('/:id', controller.getById);
// Placeholders for future endpoints matching the original content.ts routes
router.post('/', (_req, res) => {
res.status(201).json({
message: 'Create content endpoint - to be implemented',
id: 'new-content-id'
});
// CRUD endpoints for Admin
router.post('/', async (req, res) => {
try {
await repository.save(req.body);
res.status(201).json({ message: 'Protocolo creado correctamente' });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/:id', (req, res) => {
res.json({ message: `Update content ${req.params.id} - to be implemented` });
router.put('/:id', async (req, res) => {
try {
await repository.save({ ...req.body, id: req.params.id });
res.json({ message: 'Protocolo actualizado correctamente' });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/:id', (req, res) => {
res.json({ message: `Delete content ${req.params.id} - to be implemented` });
router.delete('/:id', async (req, res) => {
try {
await repository.delete(req.params.id);
res.json({ message: 'Protocolo eliminado correctamente' });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

View file

@ -0,0 +1,37 @@
import { IProtocolRepository } from '../../domain/repositories/IProtocolRepository';
import { TelephoneProtocol, ProtocolCategory, AgeGroup } from '../../domain/entities/TelephoneProtocol';
import { ClinicalProtocol } from '../../domain/entities/ClinicalProtocol';
import { ProtocolModel } from '../database/models/ProtocolModel';
export class MongoProtocolRepository implements IProtocolRepository {
async findAll(): Promise<any[]> {
return await ProtocolModel.find().lean();
}
async findById(id: string): Promise<any | null> {
return await ProtocolModel.findOne({ id }).lean();
}
async findByCategory(category: ProtocolCategory): Promise<any[]> {
return await ProtocolModel.find({ categoria: category }).lean();
}
async findByAgeGroup(ageGroup: AgeGroup): Promise<any[]> {
// Nota: El modelo de ClinicalProtocol maneja grupos_edad de forma distinta
return await ProtocolModel.find({
$or: [{ 'grupos_edad.id': ageGroup }, { 'grupos_edad': { $exists: false } }]
}).lean();
}
async save(protocol: any): Promise<void> {
await ProtocolModel.findOneAndUpdate(
{ id: protocol.id },
protocol,
{ upsert: true, new: true }
);
}
async delete(id: string): Promise<void> {
await ProtocolModel.findOneAndDelete({ id });
}
}

View file

@ -42,6 +42,7 @@ const TriageStart = lazy(() => import("./pages/TriageStart"));
const BurnCalculator = lazy(() => import("./pages/BurnCalculator"));
const DoseCalculator = lazy(() => import("./pages/DoseCalculator"));
const MediaAdmin = lazy(() => import("./pages/admin/MediaAdmin"));
const ProtocolListAdmin = lazy(() => import("./pages/admin/ProtocolListAdmin"));
// Wrapper para pasar props a Home
const HomeWrapper = ({ onSearchClick }: { onSearchClick: () => void }) => {
@ -101,6 +102,7 @@ const App = () => {
<Route path="/herramientas/dosis" element={<DoseCalculator />} />
<Route path="/protocolo/:id" element={<Protocolo />} />
<Route path="/admin/media" element={<MediaAdmin />} />
<Route path="/admin/protocols" element={<ProtocolListAdmin />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>

View file

@ -33,6 +33,7 @@ const MenuSheet = ({ isOpen, onClose }: MenuSheetProps) => {
items: [
{ to: "/manual", label: "Manual del TES (Wiki)", icon: <BookOpen className="w-4 h-4" /> },
{ to: "/admin/media", label: "Gestión de Medios", icon: <Image className="w-4 h-4" /> },
{ to: "/admin/protocols", label: "Editor de Protocolos", icon: <FileText className="w-4 h-4" /> },
{ to: "/guia-refuerzo", label: "Guías de Refuerzo", icon: <GraduationCap className="w-4 h-4" /> },
{ to: "/galeria", label: "Galería de Técnicas", icon: <Image className="w-4 h-4" /> },
]

View file

@ -0,0 +1,144 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Edit3, Trash2, Search, FileText } from 'lucide-react';
interface ProtocolSummary {
id: string;
titulo: string;
categoria: string;
version: string;
urgencia: string;
}
const ProtocolListAdmin: React.FC = () => {
const [protocols, setProtocols] = useState<ProtocolSummary[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const navigate = useNavigate();
const fetchProtocols = async () => {
try {
const response = await fetch('/api/content');
const data = await response.json();
setProtocols(data);
} catch (err) {
console.error('Error fetching protocols:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchProtocols();
}, []);
const handleDelete = async (id: string) => {
if (!confirm(`¿Estás seguro de eliminar el protocolo ${id}?`)) return;
try {
const response = await fetch(`/api/content/${id}`, { method: 'DELETE' });
if (response.ok) fetchProtocols();
} catch (err) {
alert('Error al eliminar');
}
};
const filteredProtocols = protocols.filter(p =>
p.titulo.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.id.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="p-6 max-w-6xl mx-auto space-y-8">
<div className="flex justify-between items-end border-b-2 border-orange-500 pb-4">
<div>
<h1 className="text-4xl font-black uppercase tracking-tighter">Editor de Protocolos</h1>
<p className="text-zinc-500 font-mono text-sm mt-1">// Gestión clínica en tiempo real (MongoDB)</p>
</div>
<button
onClick={() => navigate('/admin/protocols/new')}
className="bg-orange-500 text-black px-6 py-3 font-bold uppercase hover:bg-orange-400 transition-colors flex items-center gap-2"
>
<Plus size={20} /> Nuevo Protocolo
</button>
</div>
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" size={20} />
<input
type="text"
placeholder="Buscar protocolo por título o ID..."
className="w-full bg-zinc-900 border border-zinc-800 py-3 pl-12 pr-4 text-white font-mono focus:border-orange-500 outline-none transition-colors"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="bg-zinc-900 border border-zinc-800 overflow-hidden">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-zinc-800/50 text-zinc-400 font-mono text-[10px] uppercase tracking-widest border-b border-zinc-800">
<th className="px-6 py-4">ID / Titulo</th>
<th className="px-6 py-4">Categoría</th>
<th className="px-6 py-4">Versión</th>
<th className="px-6 py-4">Urgencia</th>
<th className="px-6 py-4 text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{filteredProtocols.map((p) => (
<tr key={p.id} className="hover:bg-zinc-800/30 transition-colors group">
<td className="px-6 py-4">
<div className="font-bold uppercase tracking-tight text-zinc-100">{p.titulo}</div>
<div className="font-mono text-[10px] text-zinc-500">{p.id}</div>
</td>
<td className="px-6 py-4">
<span className="bg-zinc-800 text-zinc-400 text-[10px] px-2 py-1 rounded font-mono border border-zinc-700">
{p.categoria}
</span>
</td>
<td className="px-6 py-4 font-mono text-xs text-zinc-400">v{p.version}</td>
<td className="px-6 py-4">
<span className={`text-[10px] font-bold uppercase ${p.urgencia === 'critica' ? 'text-red-500' : 'text-orange-500'}`}>
{p.urgencia}
</span>
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => navigate(`/admin/protocols/edit/${p.id}`)}
className="p-2 bg-zinc-800 hover:bg-orange-500 hover:text-black transition-all text-zinc-400"
>
<Edit3 size={16} />
</button>
<button
onClick={() => handleDelete(p.id)}
className="p-2 bg-zinc-800 hover:bg-red-900/40 hover:text-red-500 transition-all text-zinc-400"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{loading && (
<div className="py-20 flex flex-col items-center justify-center text-zinc-600 font-mono uppercase text-sm animate-pulse">
Cargando protocolos...
</div>
)}
{!loading && filteredProtocols.length === 0 && (
<div className="py-20 flex flex-col items-center justify-center text-zinc-600 font-mono uppercase text-sm tracking-widest">
<FileText size={48} className="mb-4 opacity-20" />
No se encontraron protocolos
</div>
)}
</div>
</div>
);
};
export default ProtocolListAdmin;