feat: migrate protocols to MongoDB and implement admin list view
Some checks are pending
Deploy Código 0 / deploy (push) Waiting to run
Some checks are pending
Deploy Código 0 / deploy (push) Waiting to run
This commit is contained in:
parent
3963361aa8
commit
6c560de2bd
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" /> },
|
||||
]
|
||||
|
|
|
|||
144
frontend/src/pages/admin/ProtocolListAdmin.tsx
Normal file
144
frontend/src/pages/admin/ProtocolListAdmin.tsx
Normal 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;
|
||||
Loading…
Reference in a new issue