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 { ProtocolController } from '../controllers/ProtocolController';
|
||||||
import { GetAllProtocolsUseCase } from '../../../application/usecases/GetAllProtocolsUseCase';
|
import { GetAllProtocolsUseCase } from '../../../application/usecases/GetAllProtocolsUseCase';
|
||||||
import { GetProtocolByIdUseCase } from '../../../application/usecases/GetProtocolByIdUseCase';
|
import { GetProtocolByIdUseCase } from '../../../application/usecases/GetProtocolByIdUseCase';
|
||||||
import { StaticProtocolRepository } from '../../persistence/StaticProtocolRepository';
|
import { MongoProtocolRepository } from '../../persistence/MongoProtocolRepository';
|
||||||
|
|
||||||
// manual dependency injection for routes
|
// manual dependency injection for routes
|
||||||
const repository = new StaticProtocolRepository();
|
const repository = new MongoProtocolRepository();
|
||||||
const getAllUseCase = new GetAllProtocolsUseCase(repository);
|
const getAllUseCase = new GetAllProtocolsUseCase(repository);
|
||||||
const getByIdUseCase = new GetProtocolByIdUseCase(repository);
|
const getByIdUseCase = new GetProtocolByIdUseCase(repository);
|
||||||
const controller = new ProtocolController(getAllUseCase, getByIdUseCase);
|
const controller = new ProtocolController(getAllUseCase, getByIdUseCase);
|
||||||
|
|
@ -15,20 +15,32 @@ const router = Router();
|
||||||
router.get('/', controller.getAll);
|
router.get('/', controller.getAll);
|
||||||
router.get('/:id', controller.getById);
|
router.get('/:id', controller.getById);
|
||||||
|
|
||||||
// Placeholders for future endpoints matching the original content.ts routes
|
// CRUD endpoints for Admin
|
||||||
router.post('/', (_req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
res.status(201).json({
|
try {
|
||||||
message: 'Create content endpoint - to be implemented',
|
await repository.save(req.body);
|
||||||
id: 'new-content-id'
|
res.status(201).json({ message: 'Protocolo creado correctamente' });
|
||||||
});
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', async (req, res) => {
|
||||||
res.json({ message: `Update content ${req.params.id} - to be implemented` });
|
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) => {
|
router.delete('/:id', async (req, res) => {
|
||||||
res.json({ message: `Delete content ${req.params.id} - to be implemented` });
|
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;
|
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 BurnCalculator = lazy(() => import("./pages/BurnCalculator"));
|
||||||
const DoseCalculator = lazy(() => import("./pages/DoseCalculator"));
|
const DoseCalculator = lazy(() => import("./pages/DoseCalculator"));
|
||||||
const MediaAdmin = lazy(() => import("./pages/admin/MediaAdmin"));
|
const MediaAdmin = lazy(() => import("./pages/admin/MediaAdmin"));
|
||||||
|
const ProtocolListAdmin = lazy(() => import("./pages/admin/ProtocolListAdmin"));
|
||||||
|
|
||||||
// Wrapper para pasar props a Home
|
// Wrapper para pasar props a Home
|
||||||
const HomeWrapper = ({ onSearchClick }: { onSearchClick: () => void }) => {
|
const HomeWrapper = ({ onSearchClick }: { onSearchClick: () => void }) => {
|
||||||
|
|
@ -101,6 +102,7 @@ const App = () => {
|
||||||
<Route path="/herramientas/dosis" element={<DoseCalculator />} />
|
<Route path="/herramientas/dosis" element={<DoseCalculator />} />
|
||||||
<Route path="/protocolo/:id" element={<Protocolo />} />
|
<Route path="/protocolo/:id" element={<Protocolo />} />
|
||||||
<Route path="/admin/media" element={<MediaAdmin />} />
|
<Route path="/admin/media" element={<MediaAdmin />} />
|
||||||
|
<Route path="/admin/protocols" element={<ProtocolListAdmin />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ const MenuSheet = ({ isOpen, onClose }: MenuSheetProps) => {
|
||||||
items: [
|
items: [
|
||||||
{ to: "/manual", label: "Manual del TES (Wiki)", icon: <BookOpen className="w-4 h-4" /> },
|
{ 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/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: "/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" /> },
|
{ 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