Compare commits

..

No commits in common. "0201f16cf4885e77ca622b468c3dc400f9a845f9" and "5ba4a25182332497cfab36a5a022d7f089704ac9" have entirely different histories.

6881 changed files with 26097 additions and 1318364 deletions

View file

@ -1,88 +0,0 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist
build
.next
out
dist-ssr
# Development files
.vscode
.idea
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# Environment files
.env
.env.local
.env.production
.env.development
# Git
.git
.gitignore
# Documentation (no necesario en imagen Docker)
*.md
!README.md
docs/
_BACKUP_MD/
MANUAL_TES_DIGITAL/
imagenes-pendientes/
# Scripts (no necesario en producción) - EXCEPTO verify-build.js
scripts/*.sh
scripts/*.ts
scripts/deploy/
scripts/consolidated/
!scripts/verify-build.js
*.py
!deploy-docker.sh
# Configuraciones de desarrollo
.eslintrc*
.prettierrc*
.editorconfig
# Backups y temporales
*.bak
*.backup
backup_*/
*_backup_*
# Logs
logs/
*.log
# Testing
coverage/
.nyc_output/
# Docker files (no copiar dentro de la imagen)
Dockerfile
docker-compose*.yml
.dockerignore
# CI/CD
.github/
# Configuraciones de despliegue no Docker
vercel.json
netlify.toml
nginx.conf.example
ecosystem.config.js
deploy.sh
webhook-deploy.sh
# Archivos de configuración de IDE
*.iml
*.sublime-*

58
.github/workflows/deploy.yml vendored Executable file → Normal file
View file

@ -1,61 +1,33 @@
name: Auto Deploy to Server
name: Deploy Código 0
on:
push:
branches:
- main
workflow_dispatch: # Permite ejecutar manualmente
branches: [ main, master ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout código
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
node-version: 18
cache: 'npm'
cache-dependency-path: './frontend/package-lock.json'
- name: Instalar dependencias
run: npm ci
- name: Build aplicación
run: npm run build
- name: Verificar build
run: |
if [ ! -d "dist" ]; then
echo "❌ Error: dist no existe"
exit 1
fi
if [ -z "$(ls -A dist)" ]; then
echo "❌ Error: dist está vacío"
exit 1
fi
echo "✅ Build verificado"
- name: Desplegar en servidor
- name: Deploy to VPS via Docker SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
port: ${{ secrets.SERVER_PORT || 22 }}
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
passphrase: ${{ secrets.SSH_PASSPHRASE }} # Opcional si la clave tiene contraseña
script: |
cd ${{ secrets.APP_PATH }}
./deploy.sh --skip-git
- name: Notificar resultado
if: always()
run: |
if [ "${{ job.status }}" == "success" ]; then
echo "✅ Deploy completado exitosamente"
else
echo "❌ Deploy falló"
fi
cd /home/${{ secrets.SSH_USER }}/Proyectos/Proyectos\ en\ Desarrollo/codigo0-nuevo
git pull origin main
docker compose build
docker compose up -d
docker system prune -f # Limpieza opcional de imágenes huérfanas

View file

@ -0,0 +1,175 @@
# INVENTARIO COMPLETO - Proyecto Original vs Nuevo
## Resumen
**Total Bloques:** 18 directorios
**Total Archivos MD:** ~100+
---
## 1. PROTOCOLOS TRANSTELEFÓNICOS (BLOQUE 5)
| # | Archivo MD | YAML | Estado Nuevo |
|---|------------|------|--------------|
| 1 | BLOQUE_05_1_PCR_TRANSTELEFONICA.md | ❌ | Falta |
| 2 | BLOQUE_05_2_OVACE_TRANSTELEFONICA.md | ❌ | Falta |
| 3 | BLOQUE_05_3_SCA_TRANSTELEFONICO.md | ❌ | Falta |
| 4 | BLOQUE_05_4_ICTUS_TRANSTELEFONICO.md | ❌ | Falta |
| 5 | BLOQUE_05_5_ANAFILAXIA_TRANSTELEFONICA.md | ❌ | Falta |
| 6 | BLOQUE_05_6_CRISIS_ASMATICA_TRANSTELEFONICA.md | ❌ | Falta |
| 7 | BLOQUE_05_7_HIPOGLUCEMIA_TRANSTELEFONICA.md | ❌ | Falta |
| 8 | BLOQUE_05_8_COMUNICACION_COORDINADOR.md | ❌ | Falta |
| 9 | BLOQUE_05_9_PROTOCOLOS_EMERGENCIAS_ESPECIFICAS.md | ❌ | Falta |
**→ Total Transtelefónicos: 9 protocolos**
---
## 2. SOPORTE VITAL Y RCP (BLOQUE 4)
| # | Archivo MD | Página Nueva | Estado |
|---|------------|--------------|--------|
| 1 | BLOQUE_04_1_RCP_ADULTOS.md | RCP.tsx | ⚠️ Mockeado |
| 2 | BLOQUE_04_2_RCP_PEDIATRIA.md | RCP.tsx | ❌ Falta |
| 3 | BLOQUE_04_3_RCP_LACTANTES.md | RCP.tsx | ❌ Falta |
| 4 | BLOQUE_04_4_USO_DESA.md | - | ❌ Falta |
| 5 | BLOQUE_04_5_RCP_DOS_INTERVINIENTES.md | - | ❌ Falta |
| 6 | BLOQUE_04_6_OVACE_ADULTOS.md | - | ❌ Falta |
| 7 | BLOQUE_04_7_OVACE_PEDIATRIA.md | - | ❌ Falta |
| 8 | BLOQUE_04_8_OVACE_LACTANTES.md | - | ❌ Falta |
| 9 | BLOQUE_04_9_POSICION_LATERAL_SEGURIDAD.md | - | ❌ Falta |
| 10 | BLOQUE_04_0_RECONOCIMIENTO_PCR.md | - | ❌ Falta |
| 11 | BLOQUE_04_10_ACCESO_VASCULAR_BASICO.md | - | ❌ Falta |
**→ Total Soporte Vital: 11 guías/protocolos**
---
## 3. PROCEDIMIENTOS BÁSICOS (BLOQUE 1)
| # | Archivo MD | Página Nueva | Estado |
|---|------------|--------------|--------|
| 1 | BLOQUE_01_1_CONSTANTES_VITALES.md | Escena.tsx | ❌ Falta |
| 2 | BLOQUE_01_2_ABCDE_OPERATIVO.md | Escena.tsx | ❌ Falta |
| 3 | BLOQUE_01_3_GLASGOW_OPERATIVO.md | Herramientas.tsx | ✅ Calculadora |
| 4 | BLOQUE_01_4_TRIAGE_START.md | - | ❌ Falta |
**→ Total Procedimientos: 4**
---
## 4. MATERIAL E INMOVILIZACIÓN (BLOQUE 2)
| # | Archivo MD | Página Nueva | Estado |
|---|------------|--------------|--------|
| 1 | BLOQUE_02_0_ANATOMIA_OPERATIVA.md | - | ❌ Falta |
| 2 | BLOQUE_02_2_INMOVILIZACION_MANUAL.md | Material.tsx | ❌ Falta |
| 3 | BLOQUE_02_3_COLLARIN_CERVICAL.md | ViaAerea.tsx | ❌ Falta |
| 4 | BLOQUE_02_4_CAMILLA_CUCHARA.md | Material.tsx | ❌ Falta |
| 5 | BLOQUE_02_5_TABLERO_ESPINAL.md | Material.tsx | ❌ Falta |
| 6 | BLOQUE_02_6_COLCHON_VACIO.md | Material.tsx | ❌ Falta |
| 7 | BLOQUE_02_7_EXTRICACION_MOVIMIENTOS_BLOQUE.md | - | ❌ Falta |
| 8 | BLOQUE_02_8_TRANSFERENCIAS_MOVILIZACION.md | - | ❌ Falta |
| 9 | BLOQUE_02_9_ERRORES_CRITICOS.md | - | ❌ Falta |
| 10 | BLOQUE_02_10_FERULAS.md | - | ❌ Falta |
| 11 | BLOQUE_02_11_CINTURON_PELVICO.md | - | ❌ Falta |
| 12 | BLOQUE_02_12_FERULA_TRACCION.md | - | ❌ Falta |
| 13 | BLOQUE_02_13_CAMILLAS_SILLAS_EVACUACION.md | - | ❌ Falta |
| 14 | BLOQUE_02_14_INVENTARIO_MATERIAL.md | - | ❌ Falta |
**→ Total Material: 14**
---
## 5. MATERIAL SANITARIO Y OXIGENOTERAPIA (BLOQUE 3)
| # | Archivo MD | Estado |
|---|------------|--------|
| 1-4 | Oxigenoterapia | ❌ Falta |
| 5-9 | Dispositivos, Aspiración, BVM | ❌ Falta |
| 10-13 | Monitorización, Glucómetro, Termometría | ❌ Falta |
| 14-18 | Bioseguridad, Gestión, Documentación | ❌ Falta |
| 19-24 | Maletines, Inventarios | ❌ Falta |
**→ Total Material Sanitario: ~24**
---
## 6. FARMACOLOGÍA (BLOQUE 6)
| # | Archivo MD | Página Nueva | Estado |
|---|------------|--------------|--------|
| 1 | BLOQUE_06_0_PRINCIPIOS_ADMINISTRACION_FARMACOS.md | - | ❌ Falta |
| 2 | BLOQUE_06_1_VADEMECUM_OPERATIVO.md | Farmacos.tsx | ✅ Existe |
| 3 | BLOQUE_06_2_OXIGENO_ADMINISTRACION_Y_SEGURIDAD.md | - | ❌ Falta |
| 4 | BLOQUE_06_3_ADRENALINA_USO_ANAFILAXIA_Y_RCP.md | - | ❌ Falta |
| 5 | BLOQUE_06_4_ASPIRINA_USO_SCA.md | - | ❌ Falta |
| 6 | BLOQUE_06_5_GLUCAGON_USO_HIPOGLUCEMIA.md | - | ❌ Falta |
| 7 | BLOQUE_06_6_SALBUTAMOL_USO_CRISIS_ASMATICA.md | - | ❌ Falta |
| 8 | BLOQUE_06_7_ABREVIATURAS_TERMINOLOGIA_FARMACOLOGICA.md | - | ❌ Falta |
**→ Total Farmacología: 8**
---
## 7. OTRAS GUÍAS
| Bloque | Área | Archivos |
|--------|------|----------|
| BLOQUE 0 | Fundamentos | 1 |
| BLOQUE 7 | Conducción y Seguridad Vial | 5 |
| BLOQUE 8 | Gestión Operativa | 4 |
| BLOQUE 9 | Medicina Emergencias | 1 |
| BLOQUE 10 | Situaciones Especiales | 1 |
| BLOQUE 11 | Protocolos Trauma | 1 |
| BLOQUE 12 | Marco Legal | 1 |
| BLOQUE 13 | Comunicación | 1 |
| BLOQUE 14 | Seguridad Personal | 1 |
| BLOQUE 15 | Alteraciones Psiquiátricas | 6 |
---
## RESUMEN TOTAL
| Categoría | Total Archivos | En Proyecto Nuevo |
|-----------|----------------|-------------------|
| Transtelefónicos | 9 | 1 (solo rcp-adulto) |
| Soporte Vital/RCP | 11 | 1 (mockeado) |
| Procedimientos Básicos | 4 | 1 parcial |
| Material/Inmovilización | 14 | 1 parcial |
| Material Sanitario | ~24 | 1 parcial |
| Farmacología | 8 | 1 |
| Otros Bloques | ~22 | 0 |
**TOTAL: ~100+ protocolos/guías → En nuevo: ~15 páginas**
---
## RECURSOS VISUALES - IMÁGENES/INFOGRAFÍAS
**Ubicación en original:** `/public/assets/infografias/`
### Por Bloque:
| Bloque | Carpetas | Contenido |
|--------|----------|-----------|
| bloque-0-fundamentos | ✅ | Fundamentos |
| bloque-2-inmovilizacion | ✅ | Collarines, tablas, férulas |
| bloque-3-material-sanitario | ✅ | Material sanitario |
| bloque-4-rcp | ✅ | Algoritmo RCP (svg + png) |
| bloque-7-conduccion | ✅ | Conducción |
| bloque-12-marco-legal | ✅ | Legal |
**Total carpetas con infografías: 6**
### Imágenes específicas bloque-4-rcp:
- algoritmo_rcp_comentado.png
- algoritmo_rcp_comentado.svg
- introduccion_rcp_adulto_svb.png
---
## RECURSOS VISUALES - VIDEOS
**Buscar en archivos MD:** patrones `<video>`, `youtube`, `embed`
*(Pendiente de análisis)*

128
.planning/PHASE-PLAN.md Normal file
View file

@ -0,0 +1,128 @@
# Código0 Nuevo - Plan de Fases para Guías y Protocolos
## Estado Actual (15/03/2026)
### ✅ Completado
- Sistema Visual Protocol Renderer (YAML + componentes React)
- 69 archivos YAML de protocolos creados
- Hook `useProtocol.ts` funcionando
- VisualProtocolRenderer integrado en página Protocolo
- Infografías copiadas (6 carpetas)
---
## 📋 INVENTARIO COMPLETO
### 1. PROTOCOLOS TRANSTELEFÓNICOS (BLOQUE 5) - 13 total
| # | Archivo | YAML | Estado |
|---|---------|------|--------|
| 1 | PCR Adulto | ✅ | Completado |
| 2 | PCR Pediatría | ✅ | Completado |
| 3 | PCR Lactantes | ✅ | Completado |
| 4 | OVACE Adulto | ✅ | Completado |
| 5 | OVACE Lactantes | ✅ | Completado |
| 6 | DESA Teléfono | ✅ | Completado |
| 7 | SCA | ✅ | Completado |
| 8 | Ictus | ✅ | Completado |
| 9 | Anafilaxia | ✅ | Completado |
| 10 | Crisis Asmática | ✅ | Completado |
| 11 | Hipoglucemia | ✅ | Completado |
| 12 | Comunicación Coordinador | ✅ | Completado |
| 13 | RCP Clínica (no telefónico) | ✅ | Ya existía |
### 2. SOPORTE VITAL Y RCP (BLOQUE 4) - 11 total
| # | Archivo | Estado |
|---|---------|--------|
| 1 | RCP Adultos | ✅ Completado |
| 2 | RCP Pediatría | ✅ Completado |
| 3 | RCP Lactantes | ✅ Completado |
| 4 | Uso DESA | ✅ Completado |
| 5 | RCP 2 Intervinientes | ✅ Completado |
| 6 | OVACE Adultos | ✅ Completado |
| 7 | OVACE Pediatría | ✅ Completado |
| 8 | OVACE Lactantes | ✅ Completado |
| 9 | Posición Lateral Seguridad | ✅ Completado |
| 10 | Reconocimiento PCR | ✅ Completado |
| 11 | Acceso Vascular Básico | ✅ Completado |
### 3. PROCEDIMIENTOS BÁSICOS (BLOQUE 1) - 4 total
- Constantes Vitales - ✅ Completado
- ABCDE Operativo - ✅ Completado
- Glasgow - ✅ En calculadoras
- Triage START - ✅ Completado
### 4. MATERIAL E INMOVILIZACIÓN (BLOQUE 2) - 14 total
-Todos faltantes
### 5. MATERIAL SANITARIO (BLOQUE 3) - ~24 total
- Todos faltantes
### 6. FARMACOLOGÍA (BLOQUE 6) - 8 total
- Vademécum operativo - ✅ Existe
### 7. OTROS BLOQUES - ~22 total
- Todos faltantes
---
## RECURSOS VISUALES
### Imágenes/Infografías
**Origen:** `/Proyectos Originales/codigo0/public/assets/infografias/`
| Carpeta | Contenido |
|---------|-----------|
| bloque-0-fundamentos | Fundamentos |
| bloque-2-inmovilizacion | Collarines, tablas, férulas |
| bloque-3-material-sanitario | Material sanitario |
| bloque-4-rcp | Algoritmo RCP (svg + png) |
| bloque-7-conduccion | Conducción |
| bloque-12-marco-legal | Legal |
**Acción:** Copiar a `/frontend/public/assets/infografias/`
### Videos
*Pendiente análisis*
---
## PLAN DE FASES PROPUESTO
### Phase 1: Transtelefónicos (13 protocolos YAML)
**Objetivo:** Crear los protocolos transtelefónicos en YAML
**Estado:** ✅ COMPLETADO (15/03/2026)
### Phase 2: Soporte Vital RCP (11 guías clínicas)
**Objetivo:** Convertir todas las guías de soporte vital a YAML
**Estado:** ✅ COMPLETADO (15/03/2026)
### Phase 3: Procedimientos Básicos (4 guías)
**Objetivo:** ABCDE, Constantes, Glasgow, Triage
**Estado:** ✅ COMPLETADO (15/03/2026)
### Phase 4: Farmacología (8 fármacos)
**Objetivo:** Vademécum completo en YAML
**Estado:** ✅ COMPLETADO (6 fármacos)
### Phase 5: Material e Inmovilización (14 guías)
**Estado:** ✅ COMPLETADO (12 guías)
### Phase 6: Material Sanitario (~24 guías)
**Objetivo:**Oxigenoterapia, dispositivos, monitorización
### Phase 7: Otros Bloques (~22 guías)
**Objetivo:** Conducción, gestión, legal, psiquiatría
### Phase 8: Recursos Visuales - Imágenes
**Objetivo:** Copiar infografías del original
**Estado:** ✅ COMPLETADO (15/03/2026)
### Phase 9: Recursos Visuales - Videos (Optional)
**Objetivo:**Evaluar e implementar videos
---
*Plan creado: 2026-03-15*
*Proyecto: Código0 Nuevo*

60
.planning/PROJECT.md Executable file → Normal file
View file

@ -1,49 +1,71 @@
# EMERGES TES - Limpieza y Arreglos
# Código0 Nuevo
## What This Is
Copia del proyecto EMERGES TES (Guía Digital de Protocolos de Emergencias para TES) enfocada en limpieza de código, eliminación de código muerto y arreglos técnicos.
A refactored, modernized version of the original Código0 application - a comprehensive medical reference tool for emergency medical services. The project separates frontend (React/Vite), backend (Node.js/Express), and promotional site into a modular architecture while preserving the original clinical functionality.
## Core Value
Mantener la funcionalidad existente mientras se mejora la calidad del código y se elimina deuda técnica.
Provide medical professionals with instant, reliable access to emergency protocols, drug information, and clinical calculators in a fast, offline-capable web application.
## Requirements
### Validated
(None yet - ship to validate)
- ✓ Modular architecture with separate frontend/backend — existing
- ✓ React/Vite frontend with TailwindCSS — existing
- ✓ Node.js/Express backend with REST API — existing
- ✓ Telephone protocols backend endpoint — existing
- ✓ Clinical pages migrated (RCP, Ictus, Shock, Via Aerea, etc.) — existing
- ✓ Layout components (Header, Footer, BottomNav, etc.) — existing
- ✓ Protocol viewer system with Markdown support — existing
### Active
- [ ] Limpiar código muerto y archivos sin usar
- [ ] Arreglar errores y warnings en el código
- [ ] Eliminar deuda técnica (código duplicado, patrones inconsistentes)
- [ ] Arreglar bugs conocidos
- [ ] Mejorar consistencia del código
- [ ] Complete backend API integration for all frontend pages
- [ ] Implement remaining pages (Ajustes, Galeria)
- [ ] Add PWA service worker for offline capability
- [ ] Implement calculators in Herramientas page
- [ ] Evaluate and implement alternative protocol presentation options (CMS, Docusaurus, specialized viewer)
- [ ] Connect frontend to backend API (replace mocked data)
### Out of Scope
- [ ] Nuevas funcionalidades - solo limpieza
- [ ] Cambios de arquitectura mayores
- Deployment to local environment — user has VPS, local-only development
- Mobile app (web-first approach)
- Real-time features
- Complex global state management (keep simple for now)
## Context
Proyecto existente con:
- Frontend: React 19 + TypeScript + Vite + Tailwind
- Backend: Express + PostgreSQL + Clean Architecture
- PWA para emergencias médicas
This is a brownfield project refactoring an existing medical reference application. The original código0 was a full-stack React/Express app with a specific clinical directory structure. The new version aims to:
1. Separate concerns into clear modules (frontend, backend, promo site)
2. Keep build/deploy scripts outside the app in `/scripts`
3. Evaluate multiple protocol presentation methods
4. Maintain the original clinical functionality and data structure
The project uses Bun for package management, TailwindCSS for styling, and TypeScript for type safety.
## Constraints
- **[Stack]**: Mantener tecnologías actuales — No cambiar framework
- **[Funcionalidad]**: No romper features existentes — Todos los tests deben pasar
- **Architecture**: Modular monorepo with frontend-backend separation
- **Frontend**: React 18, Vite, TailwindCSS, React Router DOM
- **Backend**: Node.js, Express, TypeScript
- **Protocol Data**: JSON-like objects with steps, warnings, variations
- **Development**: Local-only, no deployment to local environment
- **State Management**: Simple local state (no complex global libraries)
## Key Decisions
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| Modo interactivo | Preferimos aprobar cada paso | — Pending |
| Separate frontend/backend into different folders | Clear separation of concerns, independent deployment | — Pending |
| Keep scripts in `/scripts` directory | Keep main repository clean, easy to find dev tools | — Pending |
| Use Markdown/MDX for protocol content | Simple, version-controllable, easy to edit | — Pending |
| Evaluate multiple protocol presentation options | Find best fit for medical protocol display | — Pending |
| No complex global state management | Keep it simple until needed | — Pending |
| Local-only development | User has VPS, doesn't want local deployment | — Pending |
---
*Last updated: 2026-03-11 after initialization*
*Last updated: 2026-03-13 after initialization*

160
.planning/ROADMAP.md Normal file
View file

@ -0,0 +1,160 @@
# Código 0 - Roadmap GSD
## Milestone v1.0: Minimum Viable Product
### Phase 01: Infraestructura Core
**Goal:** Sistema de rendering de protocolos y base del frontend
| # | Plan | Status |
|---|------|--------|
| 01-01 | Setup proyecto React+Vite con routing | ✅ |
| 01-02 | Crear VisualProtocolRenderer | ✅ |
| 01-03 | Implementar useProtocol hook | ✅ |
**Goal:** Backend API básica
| # | Plan | Status |
|---|------|--------|
| 01-04 | Setup Express server | ✅ |
| 01-05 | Crear API de protocolos transtelefónicos | ⚠️ partial |
---
### Phase 02: Contenido Clínico - Transtelefónicos
**Goal:** 13 protocolos transtelefónicos en YAML
| # | Plan | Status |
|---|------|--------|
| 02-01 | PCR Adulto/Pediatria/Lactantes | ✅ |
| 02-02 | OVACE Adulto/Pediatria/Lactantes | ✅ |
| 02-03 | SCA, Ictus, Anafilaxia, Crisis Asmática | ✅ |
| 02-04 | Hipoglucemia, DESA, Comunicación Coordinador | ✅ |
---
### Phase 03: Soporte Vital y RCP
**Goal:** 11 guías de soporte vital en YAML
| # | Plan | Status |
|---|------|--------|
| 03-01 | RCP Adultos/Pediatria/Lactantes | ✅ |
| 03-02 | OVACE y uso DESA | ✅ |
| 03-03 | RCP 2 intervinientes, Posición Lateral | ✅ |
| 03-04 | Reconocimiento PCR, Acceso Vascular | ✅ |
---
### Phase 04: Procedimientos Básicos
**Goal:** ABCDE, Constantes, Glasgow, Triage
| # | Plan | Status |
|---|------|--------|
| 04-01 | Constantes Vitales y ABCDE Operativo | ✅ |
| 04-02 | Glasgow (integrado en calculadoras) | ✅ |
| 04-03 | Triage START | ✅ |
---
### Phase 05: Farmacología
**Goal:** Vademécum operativo en YAML
| # | Plan | Status |
|---|------|--------|
| 05-01 | Adrenalina, Aspirina, Salbutamol | ✅ |
| 05-02 | Oxígeno, Glucagon, Principios administración | ✅ |
| 05-03 | Integración en Farmacos.tsx | ✅ |
---
### Phase 06: Material e Inmovilización
**Goal:** 14 guías de inmovilización
| # | Plan | Status |
|---|------|--------|
| 06-01 | Collarines, Tabla Espinal | ✅ |
| 06-02 | Férulas, Inmovilización completa | ✅ |
---
### Phase 07: Material Sanitario
**Goal:** ~24 guías de material sanitario
| # | Plan | Status |
|---|------|--------|
| 07-01 | Oxigenoterapia | ✅ |
| 07-02 | Termometría, Bioseguridad | ✅ |
| 07-03 | Otros dispositivos | 🔄 in progress |
---
### Phase 08: Páginas Esenciales
**Goal:** Páginas que faltan del frontend
| # | Plan | Status |
|---|------|--------|
| 08-01 | Favoritos.tsx + useFavorites | 🔄 pending |
| 08-02 | Historial.tsx + useHistory | 🔄 pending |
| 08-03 | Urgencias.tsx | 🔄 pending |
| 08-04 | Parto.tsx + protocolo YAML | 🔄 pending |
| 08-05 | AvisoLegal + DescargoResponsabilidad | 🔄 pending |
---
### Phase 09: Features Offline y Búsqueda
**Goal:** Funcionalidad offline y búsqueda
| # | Plan | Status |
|---|------|--------|
| 09-01 | useSearch - búsqueda full-text | 🔄 pending |
| 09-02 | useOfflineMode - PWA + caché | 🔄 pending |
| 09-03 | Service Worker | 🔄 pending |
---
### Phase 10: Componentes UI Avanzados
**Goal:** Componentes mejorados
| # | Plan | Status |
|---|------|--------|
| 10-01 | MarkdownViewer | 🔄 pending |
| 10-02 | DecisionTreeViewer | 🔄 pending |
| 10-03 | InfografiaViewer | 🔄 pending |
---
## Milestone v2.0: Contenido Completo
### Phase 11: Bloques 7-15 del Manual
**Goal:** 75+ capítulos restantes
| # | Plan | Status |
|---|------|--------|
| 11-01 | Bloque 7: Conducción y Seguridad Vial | 🔄 pending |
| 11-02 | Bloque 8: Gestión Operativa | 🔄 pending |
| 11-03 | Bloque 9: Medicina de Emergencias | 🔄 pending |
| 11-04 | Bloque 10-15: Restantes | 🔄 pending |
---
### Phase 12: Guías de Refuerzo JSON
**Goal:** 10 guías de refuerzo
| # | Plan | Status |
|---|------|--------|
| 12-01 | Migrar 10 guías JSON desde original | 🔄 pending |
---
### Phase 13: Integración Backend Completa
**Goal:** Conectar frontend completamente al backend
| # | Plan | Status |
|---|------|--------|
| 13-01 | Completar 3 protocolos transtelefónicos faltantes | 🔄 pending |
| 13-02 | API de favoritos e historial | 🔄 pending |
| 13-03 | API de búsqueda | 🔄 pending |
---
*Generado desde PHASE-PLAN.md y codigo0-roadmap-maestro.md*
*Proyecto: Código 0 - codigo0-nuevo*

42
.planning/STATE.md Normal file
View file

@ -0,0 +1,42 @@
# Estado - Código 0
## Project State
**status:** in_progress
**last_updated:** 2026-03-17
## Resumen Ejecutivo
El nuevo codigo0-nuevo tiene la **infraestructura técnica lista** pero le falta contenido clínico y páginas esenciales.
## Métricas
| Métrica | Total | Completado | % |
|---------|-------|------------|---|
| YAMLs de protocolos | ~90 | 69 | 77% |
| Páginas frontend | 26 | 22 | 85% |
| Fases GSD | 13 | 6 | 46% |
## Decisiones
| Decisión | rationale |
|----------|-----------|
| Formato YAML para protocolos | Renderer ya implementado, fácil de mantener |
| Frontend-only con PWA | Sin app nativa, offline-capable |
| No Firebase | VPS con PostgreSQL disponible |
| Backend Express separado | Arquitectura modular |
## Blockers
| Bloqueador | severidad |
|------------|-----------|
| Falta conectar frontend a backend | high |
| No hay persistencia de favoritos/historial | high |
| PWA no implementado | medium |
## Próxima Fase
**Phase 08: Páginas Esenciales** - Las páginas que faltan (Favoritos, Historial, Urgencias, Parto)
---
*Generado automáticamente*

View file

@ -0,0 +1,239 @@
# Architecture
**Analysis Date:** 2026-03-13
## Pattern Overview
**Overall:** Modular Monorepo with Frontend-Backend Separation
**Key Characteristics:**
- **Separation of Concerns**: Frontend and backend are completely separate applications with no shared runtime
- **API-First Design**: Backend exposes RESTful API endpoints consumed by the frontend
- **Component-Based UI**: React application structured by feature domains (clinical, protocols, tools)
- **Lazy Loading**: Heavy pages are lazy-loaded for performance optimization
## Layers
### Backend Layer
**Purpose:** API server providing medical protocol data and authentication
**Location:** `backend/`
**Contains:**
- Express server setup (`src/index.ts`, `src/app.ts`)
- Route handlers (`src/routes/`)
- Business logic/services (`src/services/`)
- Configuration (`src/config.ts`)
- TypeScript types/interfaces
**Depends on:**
- Express framework for HTTP handling
- Mongoose for MongoDB connectivity (configured but placeholder)
- JWT for authentication
- Zod for validation (not yet implemented in routes)
**Used by:**
- Frontend application via HTTP API calls
### Frontend Layer
**Purpose:** React single-page application for medical protocol reference
**Location:** `frontend/src/`
**Contains:**
- Application entry point (`main.tsx`)
- Root component with routing (`App.tsx`)
- Page components (`pages/`)
- UI components (`components/`)
- Layout components (`components/layout/`)
- Services layer (`services/`)
- Types definitions (`types/`)
- Utilities (`utils/`, `hooks/`)
**Depends on:**
- React 18 with hooks
- React Router DOM for navigation
- TailwindCSS for styling
- Next-themes for dark mode
- Lucide React for icons
**Used by:**
- End users via web browser
### Static Site Layer
**Purpose:** Marketing/promotional landing page
**Location:** `promo-site/`
**Contains:**
- Static HTML with inline CSS
- Marketing content and calls-to-action
## Data Flow
### API Request Flow
1. **Frontend Component** triggers data fetch (e.g., `pages/Index.tsx`)
2. **Service Layer** (`frontend/src/services/`) makes HTTP request to backend
3. **Backend Route** (`backend/src/routes/`) receives request
4. **Service Layer** (`backend/src/services/`) processes business logic
5. **Response** returns JSON data to frontend
6. **Component** renders data using React state
### Protocol Data Flow (Example: RCP Protocol)
```
Frontend: Protocol Page
↓ HTTP GET /api/content/:id
Backend: content.ts route
↓ calls telephone-protocols.ts service
Backend: telephone-protocols.ts service
↓ returns TelephoneProtocol object
Backend: JSON response
Frontend: Service layer
↓ state update
Frontend: Component renders protocol steps
```
## State Management
**Frontend State:**
- **Local State**: Component-level state using `useState` for UI interactions (search modal, menu state)
- **URL State**: React Router for navigation and route parameters (`/protocolo/:id`)
- **Theme State**: `next-themes` for dark/light mode persistence
**Backend State:**
- **Configuration**: Environment variables loaded via `dotenv`
- **Session State**: JWT tokens (authentication flow placeholder)
- **Database State**: MongoDB connection (configured but not fully implemented)
## Key Abstractions
### Telephone Protocol Model
**Purpose:** Standardized structure for medical emergency protocols
**Location:** `backend/src/services/telephone-protocols.ts`
**Examples:**
- `rcpTelephoneAdult` - Adult CPR protocol
- `ictusTelephone` - Stroke assessment protocol
- `desaTelephone` - AED usage protocol
**Pattern:**
```typescript
interface TelephoneProtocol {
id: string;
title: string;
category: ProtocolCategory;
steps: ProtocolStep[];
initialAssessment: string[];
importantNotes?: string[];
}
```
### API Route Pattern
**Purpose:** Consistent Express route structure
**Location:** `backend/src/routes/`
**Pattern:**
```typescript
import { Router } from 'express';
const router = Router();
router.get('/', (req, res) => { /* ... */ });
router.get('/:id', (req, res) => { /* ... */ });
router.post('/', (req, res) => { /* ... */ });
export default router;
```
### React Page Pattern
**Purpose:** Consistent page component structure
**Location:** `frontend/src/pages/`
**Pattern:**
```typescript
import { useState } from 'react';
import { Link } from 'react-router-dom';
// ... imports
const PageName = () => {
// State and logic
return (
<div>
{/* JSX */}
</div>
);
};
export default PageName;
```
## Entry Points
### Backend Entry Point
**Location:** `backend/src/index.ts`
**Triggers:** Node.js process execution
**Responsibilities:**
- Create Express app instance
- Read configuration
- Start HTTP server on configured port
- Handle graceful shutdown signals (SIGINT, SIGTERM)
### Frontend Entry Point
**Location:** `frontend/src/main.tsx`
**Triggers:** Browser loads HTML page
**Responsibilities:**
- Initialize React root
- Register Service Worker (production only)
- Render main App component with error boundaries
- Apply console error filtering for browser extensions
### Frontend App Entry
**Location:** `frontend/src/App.tsx`
**Triggers:** React root render
**Responsibilities:**
- Set up ThemeProvider for dark mode
- Configure BrowserRouter with future flags
- Define all application routes with lazy loading
- Layout structure (Header, Main, Footer, BottomNav)
- Modal management (Search, Menu)
## Error Handling
**Strategy:** Layered error handling with graceful degradation
**Patterns:**
**Frontend Error Boundaries:**
- Root-level error display in `main.tsx`
- Suspense fallbacks for lazy-loaded pages
- Console error filtering for non-critical browser extension errors
**Backend Error Handling:**
- Global error handler in `app.ts` (line 51-57)
- 404 handler for undefined routes
- Development mode shows detailed error messages
- Production mode shows generic error messages
## Cross-Cutting Concerns
**Logging:**
- **Backend:** Console logging for server startup, errors, and graceful shutdown
- **Frontend:** Console logging for Service Worker registration, React rendering errors
**Validation:**
- **Backend:** Zod dependency available but not yet integrated into routes
- **Frontend:** No explicit validation layer detected
**Authentication:**
- **Backend:** JWT configuration in `config.ts` with placeholder secret
- **Frontend:** No authentication UI implemented (routes are placeholder)
**Security:**
- **Backend:** Helmet.js for security headers, CORS configuration
- **Frontend:** Service Worker for PWA capabilities (production only)
---
*Architecture analysis: 2026-03-13*

View file

@ -0,0 +1,90 @@
# Technology Stack
**Analysis Date:** 2026-03-13
## Languages
**Primary:**
- TypeScript 5.2.2 - Both backend (`backend/`) and frontend (`frontend/`)
- JavaScript (via transpilation) - Runtime execution
**Secondary:**
- HTML/CSS - Static promo site (`promo-site/`)
## Runtime
**Environment:**
- Node.js (runtime for backend)
- Browser (runtime for frontend)
**Package Manager:**
- npm (lockfiles: `package-lock.json` in both `backend/` and `frontend/`)
- Lockfile: Present
## Frameworks
**Core:**
- Express 4.18.2 - Backend API server (`backend/package.json`)
- React 18.2.0 - Frontend UI library (`frontend/package.json`)
**Testing:**
- Jest 29.6.2 - Unit testing for backend (`backend/package.json`)
- ts-jest - TypeScript support for Jest
**Build/Dev:**
- Vite 4.4.9 - Frontend build tool and dev server (`frontend/package.json`)
- TypeScript 5.2.2 - Type checking and compilation for both layers
- ts-node-dev - Development server for backend (`backend/package.json`)
## Key Dependencies
**Critical (Backend):**
- `express` 4.18.2 - HTTP server framework
- `mongoose` 7.5.0 - MongoDB ODM (configured, partially implemented)
- `jsonwebtoken` 9.0.2 - Authentication tokens
- `bcryptjs` 2.4.3 - Password hashing
- `zod` 3.22.2 - Schema validation (available, not fully utilized)
- `socket.io` 4.7.2 - Real-time communication (installed, usage unclear)
**Critical (Frontend):**
- `react` 18.2.0 - UI library
- `react-router-dom` 6.15.0 - Client-side routing
- `tailwindcss` 3.3.3 - Utility-first CSS framework
- `next-themes` 0.2.1 - Dark mode handling
- `lucide-react` 0.263.1 - Icon library
- `vite` 4.4.9 - Build tool
**Infrastructure:**
- `cors` 2.8.5 - Backend CORS handling
- `helmet` 7.0.0 - Security headers
- `multer` 1.4.5-lts.1 - File upload handling
## Configuration
**Environment:**
- **Backend:** `.env` file in root (not shown in structure, referenced in code)
- Configured in `backend/src/config.ts`
- Key configs: `PORT`, `NODE_ENV`, `CORS_ORIGIN`, `MONGODB_URI`, `JWT_SECRET`, `UPLOADS_DIR`
- **Frontend:** `.env` file in `frontend/` (not read due to security rules, likely Vite env vars)
**Build:**
- **Backend:** `backend/tsconfig.json` - TypeScript configuration
- **Frontend:**
- `frontend/vite.config.ts` - Vite build configuration with path aliases
- `frontend/tailwind.config.ts` - TailwindCSS theme configuration
- `frontend/postcss.config.js` - PostCSS processing
## Platform Requirements
**Development:**
- Node.js environment (backend)
- Modern browser with ES6+ support (frontend)
**Production:**
- Node.js server for backend API
- Static file hosting for frontend (Vite build output)
- Static file hosting for promo site
---
*Stack analysis: 2026-03-13*

11
.planning/config.json Executable file → Normal file
View file

@ -1,14 +1,15 @@
{
"mode": "interactive",
"granularity": "standard",
"mode": "yolo",
"granularity": "Coarse (Recommended)",
"parallelization": true,
"commit_docs": true,
"model_profile": "balanced",
"model_profile": "Balanced (Recommended)",
"workflow": {
"research": true,
"plan_check": true,
"verifier": true,
"nyquist_validation": true,
"auto_advance": false
"auto_advance": true,
"_auto_chain_active": true
}
}
}

View file

@ -0,0 +1,18 @@
# 08-01: Favoritos + useFavorites
## Objective
Crear página Favoritos.tsx y hook useFavorites para persistir y leer favoritos del usuario.
## Requirements
- Hook useFavorites con localStorage/IndexedDB
- Página Favoritos.tsx con listado de protocolos/guías guardados
- Poder agregar/quitar favoritos desde cualquier página
## Deliverable
- `/frontend/src/hooks/useFavorites.ts`
- `/frontend/src/pages/Favoritos.tsx`
## Test Criteria
- [ ] Puedo guardar un protocolo en favoritos
- [ ] Puedo ver la lista de favoritos
- [ ] Persisten al recargar la página

View file

@ -0,0 +1,18 @@
# 08-02: Historial + useHistory
## Objective
Crear página Historial.tsx y hook useHistory para registrar y mostrar últimas consultas.
## Requirements
- Hook useHistory con localStorage/IndexedDB
- Página Historial.tsx con listado de últimos protocolos vistos
- Timestamps para cada entrada
## Deliverable
- `/frontend/src/hooks/useHistory.ts`
- `/frontend/src/pages/Historial.tsx`
## Test Criteria
- [ ] Al ver un protocolo, se registra en historial
- [ ] Muestra los últimos 20 accesos
- [ ] Persisten al recargar la página

View file

@ -0,0 +1,22 @@
# 08-03: Urgencias.tsx
## Objective
Crear página de acceso rápido a protocolos críticos para situaciones de urgencia.
## Requirements
- Página con accesos directos a protocolos más críticos:
- PCR
- OVACE
- Anafilaxia
- SCA
- Ictus
- Shock
- Diseño optimizado para acceso rápido en emergencias
## Deliverable
- `/frontend/src/pages/Urgencias.tsx`
## Test Criteria
- [ ] Muestra los 6 protocolos críticos
- [ ] Acceso rápido con un tap
- [ ] Diseño visible en condiciones de estrés

View file

@ -0,0 +1,18 @@
# 08-04: Parto.tsx + protocolo YAML
## Objective
Crear página Parto.tsx con el protocolo de parto extrahospitalario.
## Requirements
- Protocolo YAML de parto emergencia
- Página Parto.tsx que renderice el protocolo
- Pasos para asistir parto en entorno prehospitalario
## Deliverable
- `/frontend/public/protocols/emergencias/parto-emergencia.yaml` (ya existe)
- `/frontend/src/pages/Parto.tsx`
## Test Criteria
- [ ] Protocolo se renderiza correctamente
- [ ] Todos los pasos visibles
- [ ] Compatible con VisualProtocolRenderer

View file

@ -0,0 +1,17 @@
# 08-05: Avisos Legales
## Objective
Crear páginas de Aviso Legal y Descargo de Responsabilidad médicas.
## Requirements
- AvisoLegal.tsx con texto legal estándar
- DescargoResponsabilidad.tsx con aviso de uso responsable
- Links en pie de página
## Deliverable
- `/frontend/src/pages/AvisoLegal.tsx`
- `/frontend/src/pages/DescargoResponsabilidad.tsx`
## Test Criteria
- [ ] Ambas páginas accesibles desde el menú
- [ ] Contenido apropiado para app médica

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"git.ignoreLimitWarning": true
}

28
CONTEXT.md Normal file
View file

@ -0,0 +1,28 @@
# Contexto Técnico: codigo0
Asistente avanzado de referencia médica para TES (Técnicos de Emergencias Sanitarias), con herramientas interactivas y protocolos clínicos.
## Stack Tecnológico
- **Frontend**: React 18 + Vite + TailwindCSS.
- **Backend**: Node.js + Express + TypeScript (Clean Architecture).
- **Base de Datos**: MongoDB (Migración desde YAML en progreso).
- **Persistencia**: Docker Compose (Frontend/Backend/MongoDB).
- **PWA**: Soporte offline completo con manifest y meta-tags iOS/Android.
## Arquitectura (Clean Architecture)
- `/backend/src/domain`: Entidades puras y reglas de negocio.
- `/backend/src/application`: Casos de uso y orquestación.
- `/backend/src/infrastructure`: Adaptadores (Mongoose, Express, Repositorios).
## Estructura del Proyecto
- `/frontend/src/pages`: Páginas de la aplicación y calculadoras.
- `/frontend/src/protocols`: Renderer visual de protocolos YAML/JSON.
- `/frontend/public/protocols/`: Fuente original de protocolos YAML.
- `/frontend/public/manual/`: Manual técnico en Markdown.
## Estado de la Implementación
- **Calculadoras**: Glasgow, Triage START, Quemados/Parkland, Dosis Pediátricas (Finalizadas).
- **Sincronización**: Script de ingesta YAML a MongoDB listo (`npm run migrate`).
---
*Este documento es la fuente de verdad técnica, sincronizado con la bóveda de Obsidian.*

View file

@ -1,64 +0,0 @@
# Multi-stage build para EMERGES TES
# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
# Copiar archivos de dependencias
COPY package.json package-lock.json* ./
# Instalar dependencias
RUN npm ci --production=false
# Copiar código fuente
COPY . .
# Build de producción
RUN npm run build
# Verificar que el build se completó
RUN test -d dist || (echo "Error: dist directory not found" && exit 1)
RUN test "$(ls -A dist)" || (echo "Error: dist directory is empty" && exit 1)
# CRÍTICO: Verificar que NO se generó vendor-other (causa errores useLayoutEffect)
RUN if ls dist/assets/vendor-other* 2>/dev/null; then \
echo "❌ ERROR CRÍTICO: vendor-other fue generado en el build"; \
echo "Esto causará errores useLayoutEffect en producción"; \
ls -la dist/assets/vendor-other*; \
exit 1; \
else \
echo "✅ Verificación: vendor-other NO existe (correcto)"; \
fi
# Verificar chunks esperados
RUN echo "📦 Chunks vendor generados:" && \
ls -lh dist/assets/vendor-*.js 2>/dev/null | awk '{print " "$9" ("$5")"}' || true
# Stage 2: Production
FROM node:18-alpine AS production
WORKDIR /app
# Instalar serve globalmente para servir archivos estáticos
RUN npm install -g serve@14.2.1
# Copiar archivos construidos desde builder
COPY --from=builder /app/dist ./dist
# Copiar package.json para mantener metadata (opcional)
COPY --from=builder /app/package.json ./package.json
# Exponer puerto 8607
EXPOSE 8607
# Variables de entorno
ENV NODE_ENV=production
ENV PORT=8607
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:8607', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Comando para servir la aplicación
CMD ["serve", "-s", "dist", "-l", "8607"]

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Javier Fernández (@planetazuzu)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

File diff suppressed because it is too large Load diff

30
PROGRESS.md Normal file
View file

@ -0,0 +1,30 @@
# Bitácora de Desarrollo: codigo0
Registro histórico de hitos y sesiones del proyecto.
## Estado actual (2026-03-23)
- ✅ **Frontend**: React/Vite + PWA Completo.
- ✅ **Herramientas**: GCS, Triage, Quemados, Dosis Pediátricas.
- ✅ **Backend**: Clean Architecture + Mongoose models.
- 🔧 **DB**: MongoDB en infraestructura Docker (Migración YAML lista).
---
## Sesión 2026-03-20
- Migración exitosa del backend a **Clean Architecture (Hexagonal)**.
- Creación de capas de Dominio, Aplicación e Infraestructura.
## Sesión 2026-03-22
- **Auditoría completa** y corrección de bugs visuales.
- **PWA**: Iconos, manifest y meta-tags finalizados.
- **Set de Herramientas**: Implementadas 4 calculadoras críticas.
## Sesión 2026-03-23 (En curso)
- Sincronización de repositorios y documentación.
- Migración masiva de protocolos YAML -> MongoDB.
- Conexión del renderer a la API de MongoDB.
### Próximos Pasos
- Ejecutar migración masiva de protocolos.
- Conectar renderer a la API de MongoDB.
- Redacción de manuales prioritarios.

243
README.md Executable file → Normal file
View file

@ -1,236 +1,23 @@
# EMERGES TES - Guía Digital de Protocolos de Emergencias 🏥
# codigo0
**Aplicación web progresiva (PWA)** diseñada como herramienta de referencia rápida para **Técnicos de Emergencias Sanitarias (TES)** y profesionales de emergencias médicas.
Asistente avanzado de referencia médica para Técnicos de Emergencias Sanitarias (TES).
![Estado](https://img.shields.io/badge/Estado-En%20desarrollo-blue)
![PWA](https://img.shields.io/badge/PWA-Ready-green)
![TypeScript](https://img.shields.io/badge/TypeScript-5.8-blue)
![React](https://img.shields.io/badge/React-19-blue)
![License](https://img.shields.io/badge/License-MIT-green)
## 👤 Autor
**Javier Fernández** · [@planetazuzu](https://github.com/planetazuzu)
TES · Developer · La Rioja 🇪🇸
---
## 🎯 Objetivo Funcional
**EMERGES TES** es un **socio cognitivo** que reduce la carga cognitiva en emergencias médicas proporcionando:
- ✅ Acceso rápido a información crítica (< 2 clics)
- ✅ Protocolos operativos estructurados (RCP, vía aérea, shock, etc.)
- ✅ Vademécum de fármacos con dosis, indicaciones y contraindicaciones
- ✅ Calculadoras médicas (Glasgow, perfusiones, dosis pediátricas)
- ✅ Guías formativas asociadas a protocolos
- ✅ Funcionalidad **offline-first** (funciona sin conexión)
- ✅ Diseño optimizado para uso bajo presión y estrés
**No es:**
- ❌ Un sistema de diagnóstico automático
- ❌ Una herramienta de IA que toma decisiones clínicas
- ❌ Un sustituto de la formación reglada del profesional
- ❌ Un reemplazo del criterio clínico
---
## 📊 Estado Actual del Proyecto
**Estado:** En desarrollo activo
### ✅ Completado
- **Frontend PWA:** React 19 + TypeScript, funcional con Service Worker
- **Backend API:** Express + PostgreSQL con Clean Architecture
- **Protocolos:** 50+ protocolos operativos estructurados
- **Fármacos:** 100+ fármacos con dosis y especificaciones
- **Guías formativas:** Guías de refuerzo asociadas a protocolos
- **Herramientas clínicas:** Checklists, calculadoras, pathways
- **Validación médica:** Workflow completo de revisión y aprobación
- **Glosario:** Backend completo con ~74 términos migrados
- **Medios:** Sistema de gestión de imágenes/vídeos/documentos
- **Tests:** Tests unitarios backend (servicios) y tests integración API
### ⚠️ En Progreso / Pendiente
- **Frontend glosario:** La app aún no consume `GET /api/glossary` (usa datos locales)
- **Cobertura frontend:** Objetivo 80% (en aumento)
- **Contenido:** Categoría "Escena" vacía en protocolos (ver `docs/CONTENIDO_FALTANTE.md`)
**Documentación detallada:** Ver `docs/QUE_FALTA.md` y `docs/CONTENIDO_FALTANTE.md`
---
## ⚠️ ACLARACIÓN FUNDAMENTAL: ¿Qué son los "Tickets"?
### Los tickets NO son funcionalidad de negocio
**IMPORTANTE:** En este proyecto, los **"tickets"** (TICKET-001, TICKET-002, etc.) **NO** son una funcionalidad de negocio.
- ❌ **NO existe** un sistema de tickets de soporte, incidencias o solicitudes de usuarios
- ❌ **NO hay** entidades llamadas "tickets" en el código
- ❌ **NO hay** lógica de negocio asociada a tickets
### Los tickets son tareas técnicas de desarrollo
Los tickets son **únicamente** una forma de dividir, organizar y seguir las **tareas pendientes de desarrollo** de la aplicación.
- ✅ Son equivalentes a **issues** o **tickets técnicos** de JIRA / GitHub
- ✅ Representan **tareas técnicas** o **pasos de desarrollo**
- ✅ Sirven para **planificación y seguimiento** del trabajo
- ✅ Están documentados en `docs/QUE_FALTA.md` y `docs/BACKLOG_MICRO_TICKETS.md`
**Ejemplo:** TICKET-013 significa "Implementar ValidationService para workflow de validación médica" (tarea técnica completada).
### Entidades reales del dominio
Las entidades reales del dominio de la aplicación son:
- **ContentItem:** Protocolos, guías, manuales, checklists
- **Drug:** Fármacos con especificaciones técnicas
- **GlossaryTerm:** Términos médicos del glosario
- **MediaResource:** Imágenes, vídeos, documentos
- **MedicalReview:** Revisiones médicas de contenido
**Ninguna de estas entidades se llama "ticket" ni tiene relación con tickets.**
### Si en el futuro se añade un sistema de tickets de negocio
Si en el futuro se añade un sistema de tickets de soporte/incidencias como **nueva funcionalidad**, deberá tratarse como una **FEATURE independiente**, no implementada actualmente.
---
## 🚀 Características
- **Protocolos Clínicos**: Visualización interactiva de guías de soporte vital.
- **Herramientas Operativas**: Glasgow, Triage START, Superficie Quemada y Dosis Pediátricas.
- **Manual del TES**: Guía técnica completa integrada para consulta rápida.
- **PWA**: Soporte offline total.
## 🛠️ Stack Tecnológico
- **Frontend**: React 18 + Vite + TailwindCSS
- **Backend**: Node.js + Express + TypeScript (Clean Architecture)
- **Base de Datos**: MongoDB (Dockerized)
### Frontend
- **React 19** + **TypeScript 5.8**
- **Vite 7** - Build tool
- **Tailwind CSS 3.4** + **shadcn/ui** - UI Framework
- **React Router 6.3** - Navegación SPA
- **PWA** - Service Worker + Manifest
- **Vitest** - Testing
## 📄 Documentación y Memoria
Para mantener la rama principal limpia y centrada en el código, el seguimiento del proyecto se gestiona en dos ubicaciones:
### Backend
- **Node.js** + **TypeScript**
- **Express 4.18** - Framework web
- **PostgreSQL** - Base de datos relacional
- **Redis** - Caché (opcional)
- **Zod** - Validación de esquemas
- **Vitest** + **Supertest** - Testing
### Arquitectura
- **Clean Architecture** en backend (Domain → Application → Infrastructure → Presentation)
- **Arquitectura funcional React** en frontend
- **Type Safety estricto** (sin `any`)
1. **Git (Rama `docs-memoria`)**: Contiene `PROGRESS.md` y `CONTEXT.md` actualizados con el historial técnico.
2. **Obsidian (Bóveda `99-agentes`)**: Contiene el Plan Maestro, Auditoría de Medios y Contexto Global.
---
## 🚀 Instalación y Ejecución
### Requisitos previos
- Node.js 20+
- PostgreSQL 14+
- Redis (opcional, para caché)
### Instalación
```bash
# Clonar repositorio
git clone [url-del-repositorio]
cd guia-tes
# Instalar dependencias frontend
npm install
# Instalar dependencias backend
cd backend
npm install
cd ..
```
### Ejecución
#### Solo frontend (desarrollo)
```bash
npm run dev
# Abre en http://localhost:8096
```
#### Frontend + Backend local (con Docker)
```bash
npm run dev:local
# Inicia PostgreSQL + Redis en Docker
# Frontend: http://localhost:8096
# Backend: http://localhost:3000
```
#### Backend solo
```bash
cd backend
npm run dev
# Backend en http://localhost:3000
```
### Build producción
```bash
# Frontend
npm run build
# Backend
cd backend
npm run build
```
**Documentación detallada:** Ver `docs/DESPLIEGUE_LOCAL.md` para configuración completa con Docker.
---
## 📚 Documentación
### Documentación principal
- **`SPEC.md`** - Especificación maestra del proyecto (fuente de verdad)
- **`.cursorrules`** - Reglas de desarrollo y arquitectura
- **`docs/QUE_FALTA.md`** - Estado de tickets técnicos y tareas pendientes
- **`docs/CONTENIDO_FALTANTE.md`** - Contenido faltante (protocolos, guías, glosario)
### Documentación para desarrolladores
- **`README_DEV.md`** - Reglas de desarrollo y principios
- **`README_ARCHITECTURE.md`** - Arquitectura del sistema
- **`README_TODO.md`** - Tareas pendientes con prioridades
### Documentación técnica
- **`docs/BACKLOG_MICRO_TICKETS.md`** - Backlog de fases ejecutadas
- **`docs/ANDRAGOGIA_STRESS_READY_112.md`** - Principios de diseño UX
- **`docs/CHECKLIST_ANTES_ACEPTAR_CAMBIOS.md`** - Checklist de calidad
---
## 🔄 Evolución de la Arquitectura
**Nota importante:** La arquitectura puede evolucionar según las necesidades del proyecto.
- Las decisiones arquitectónicas están documentadas en `SPEC.md` y `.cursorrules`
- Cualquier cambio arquitectónico debe documentarse explícitamente
- Se prioriza la mantenibilidad y claridad del código
---
## 📄 Licencia
[Especificar licencia si aplica]
---
## 👥 Contribución
Este proyecto está en desarrollo activo. Para contribuir:
1. Leer `README_DEV.md` para reglas de desarrollo
2. Revisar `README_ARCHITECTURE.md` para entender la arquitectura
3. Consultar `README_TODO.md` para tareas pendientes
4. Seguir las reglas definidas en `.cursorrules`
---
**Desarrollado para Técnicos de Emergencias Sanitarias**
*codigo0 — 0 Errores. 0 Dudas.*

View file

@ -1,130 +0,0 @@
# 🔍 Diagnóstico del Panel de Administración
## Pasos para diagnosticar el problema
### 1. Abre la consola del navegador
- Presiona `F12` o `Ctrl+Shift+I`
- Ve a la pestaña **Console**
### 2. Ejecuta este código en la consola:
```javascript
// Diagnóstico completo
(async () => {
console.log('🔍 DIAGNÓSTICO DEL PANEL DE ADMINISTRACIÓN\n');
// 1. Verificar token
const token = localStorage.getItem('admin_token');
console.log('1⃣ Token en localStorage:', token ? token.substring(0, 30) + '...' : '❌ NO HAY TOKEN');
// 2. Verificar usuario
const user = localStorage.getItem('admin_user');
console.log('2⃣ Usuario en localStorage:', user ? JSON.parse(user).email : '❌ NO HAY USUARIO');
// 3. Probar endpoint de stats
const API_URL = 'http://localhost:3000';
try {
const statsResponse = await fetch(`${API_URL}/api/stats/content`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
console.log('\n3⃣ Respuesta de /api/stats/content:');
console.log(' Status:', statsResponse.status);
if (statsResponse.ok) {
const data = await statsResponse.json();
console.log(' ✅ Datos recibidos:');
console.log(' Protocolos:', data.protocols);
console.log(' Guías:', data.guides);
console.log(' Fármacos:', data.drugs);
console.log(' Checklists:', data.checklists);
} else {
const error = await statsResponse.json();
console.log(' ❌ Error:', error);
}
} catch (err) {
console.log(' ❌ Error de red:', err.message);
}
// 4. Probar endpoint de content
try {
const contentResponse = await fetch(`${API_URL}/api/content`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
console.log('\n4⃣ Respuesta de /api/content:');
console.log(' Status:', contentResponse.status);
if (contentResponse.ok) {
const data = await contentResponse.json();
console.log(' ✅ Datos recibidos:');
console.log(' Total items:', data.total);
console.log(' Items en página:', data.items?.length || 0);
} else {
const error = await contentResponse.json();
console.log(' ❌ Error:', error);
}
} catch (err) {
console.log(' ❌ Error de red:', err.message);
}
// 5. Verificar backend
try {
const healthResponse = await fetch(`${API_URL}/health`);
const health = await healthResponse.json();
console.log('\n5⃣ Estado del backend:');
console.log(' Status:', health.status);
console.log(' Database:', health.database);
} catch (err) {
console.log(' ❌ Backend no responde:', err.message);
}
console.log('\n✅ Diagnóstico completo');
})();
```
### 3. Copia y pega el resultado aquí
### 4. Si el token no existe o está expirado:
Ejecuta esto para hacer login:
```javascript
(async () => {
const API_URL = 'http://localhost:3000';
try {
const response = await fetch(`${API_URL}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: 'admin@emerges-tes.local',
password: 'Admin123!'
})
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('admin_token', data.token);
localStorage.setItem('admin_user', JSON.stringify(data.user));
console.log('✅ Login exitoso');
console.log('Usuario:', data.user.email);
console.log('Token guardado');
location.reload(); // Recargar página
} else {
const error = await response.json();
console.error('❌ Error de login:', error);
}
} catch (err) {
console.error('❌ Error de red:', err);
}
})();
```

View file

@ -1,97 +0,0 @@
# 🎛️ Admin Panel - EMERGES TES
Panel de administración para gestionar contenido (protocolos, guías, manual, vademécum, checklists) sin modificar el código de la app.
## 🏗️ Arquitectura
- **Frontend**: React + Vite + TypeScript
- **Backend**: Node.js + Express + PostgreSQL
- **Autenticación**: JWT
- **RBAC**: Roles y permisos granulares
## 🚀 Inicio Rápido
### Backend
```bash
cd backend
npm install
npm run db:create # Crear BD y tablas
npm run seed:admin # Crear usuario admin
npm run seed:content # Crear contenido de ejemplo
npm run dev # Iniciar servidor (puerto 3000)
```
### Admin Panel
```bash
cd admin-panel
npm install
npm run dev # Iniciar en http://localhost:5174
```
### Credenciales por defecto
- **Email**: `admin@emerges-tes.local`
- **Password**: `Admin123!`
- **Role**: `super_admin`
⚠️ **IMPORTANTE**: Cambiar la contraseña después del primer login.
## 📁 Estructura
```
admin-panel/
├── src/
│ ├── components/ # Componentes React
│ │ ├── dashboard/ # Dashboard principal
│ │ ├── content/ # Editores de contenido
│ │ ├── audit/ # Auditoría y versiones
│ │ └── common/ # Componentes compartidos
│ ├── pages/ # Páginas principales
│ ├── hooks/ # Custom hooks
│ ├── services/ # API services
│ └── utils/ # Utilidades
├── shared/
│ └── types/ # TypeScript types compartidos
└── package.json
```
## 🔐 Roles y Permisos
- **super_admin**: Acceso total
- **editor_clinico**: Editar protocolos, fármacos, checklists
- **editor_formativo**: Editar guías formativas
- **revisor**: Revisar y validar contenido
- **viewer**: Solo lectura
## 📝 Funcionalidades
- ✅ Dashboard con estadísticas
- ✅ Biblioteca de contenido con filtros
- ✅ Editor de Protocolo con vista previa "modo TES"
- ✅ Editor de Checklist reutilizable
- ✅ Editor de Guías Markdown con preview
- ✅ Manager de Vademécum
- ✅ Pantalla de Fuentes y Actualizaciones
- ✅ Auditoría (logs + comparar versiones + revertir)
- ✅ Sistema de versionado
- ✅ Validación de contenido
## 🔄 Integración con App Principal
El content pack se consume como "override" del contenido local:
1. La app intenta obtener el último pack publicado desde `/api/content/pack/latest`
2. Si existe y está validado, lo usa
3. Si no, usa los datos locales actuales (`src/data/`)
4. Funciona offline usando cache del último pack
## 🧪 Tests
```bash
npm test # Tests unitarios
npm run test:e2e # Tests end-to-end
npm run test:manual # Checklist manual de verificación
```

View file

@ -1,14 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin Panel - EMERGES TES</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -1,38 +0,0 @@
{
"name": "emerges-tes-admin-panel",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"axios": "^1.6.2",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"rehype-highlight": "^7.0.0",
"lucide-react": "^0.294.0",
"date-fns": "^2.30.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

View file

@ -1,78 +0,0 @@
/**
* TIPOS DE AUTENTICACIÓN Y AUTORIZACIÓN
*/
export type UserRole =
| 'super_admin' // Acceso total
| 'editor_clinico' // Editar protocolos, fármacos, checklists
| 'editor_formativo' // Editar guías formativas
| 'revisor' // Revisar y validar contenido
| 'viewer'; // Solo lectura
export interface User {
id: string;
email: string;
username: string;
role: UserRole;
isActive: boolean;
createdAt: string;
lastLogin?: string;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
token: string;
user: User;
expiresIn: number; // Segundos
}
export interface AuthContext {
user: User | null;
token: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
hasPermission: (permission: string) => boolean;
}
// Permisos por rol
export const ROLE_PERMISSIONS: Record<UserRole, string[]> = {
super_admin: [
'content:read',
'content:write',
'content:delete',
'content:validate',
'content:publish',
'users:read',
'users:write',
'users:delete',
'audit:read',
'system:configure',
],
editor_clinico: [
'content:read',
'content:write:protocol',
'content:write:drug',
'content:write:checklist',
'content:submit',
],
editor_formativo: [
'content:read',
'content:write:guide',
'content:write:manual',
'content:submit',
],
revisor: [
'content:read',
'content:validate',
'content:approve',
'audit:read',
],
viewer: [
'content:read',
],
};

View file

@ -1,464 +0,0 @@
/**
* MODELO DE DATOS CANÓNICO - SISTEMA DE CONTENIDO EXTERNO
*
* FASE 4: Base de Contenido
*
* Este modelo es completamente desacoplado del código de la app.
* No modifica procedures.ts, drugs.ts ni ningún componente existente.
*
* Diseñado para:
* - Durabilidad (10+ años)
* - Uso real de TES en guardia
* - Formación continua
* - Referencia profesional
*
* @version 1.0.0
* @date 2025-01-06
*/
// ============================================
// ENUMS Y TIPOS BASE
// ============================================
export type ContentType = 'protocol' | 'guide' | 'manual' | 'drug' | 'checklist';
export type UsageType = 'operativo' | 'formativo' | 'referencia';
export type Priority = 'critica' | 'alta' | 'media' | 'baja';
export type ContentStatus = 'draft' | 'in_review' | 'approved' | 'published' | 'archived';
export type MediaType = 'image' | 'video';
export type ClinicalContext =
| 'RCP'
| 'OVACE'
| 'ABCDE'
| 'TRIAGE'
| 'GLASGOW'
| 'ICTUS'
| 'SHOCK'
| 'TRAUMA'
| 'OXIGENOTERAPIA'
| 'VIA_AEREA'
| 'FARMACOLOGIA'
| 'OTROS';
export type SourceGuideline =
| 'ERC'
| 'SEMES'
| 'AHA'
| 'INTERNO'
| 'MANUAL_TES_DIGITAL';
export type AuditAction =
| 'create'
| 'update'
| 'delete'
| 'validate'
| 'approve'
| 'publish'
| 'archive'
| 'revert';
export type UserRole =
| 'tes'
| 'medico'
| 'formador'
| 'editor'
| 'admin';
// ============================================
// CONTENT ITEM (Base de todo contenido)
// ============================================
/**
* ContentItem: Entidad base para todo contenido del sistema
*
* Representa protocolos, guías, manuales, fármacos y checklists
* de forma unificada y extensible.
*/
export interface ContentItem {
// Identificación única
id: string; // UUID v4
type: ContentType; // Tipo de contenido
slug: string; // Slug para URLs (ej: "rcp-adulto-svb")
// Metadatos básicos
title: string; // Título completo
short_title?: string; // Título corto (para UI)
description?: string; // Descripción breve
// Clasificación clínica
clinical_context: ClinicalContext; // Contexto clínico principal
usage_type: UsageType; // Tipo de uso
priority: Priority; // Prioridad clínica
// Estado y validación
status: ContentStatus; // Estado del contenido
source_guideline: SourceGuideline; // Fuente clínica
source_year?: number; // Año de la guía fuente
source_url?: string; // URL de la guía fuente
// Validación clínica
validated_by?: string; // ID del validador
validated_at?: string; // ISO timestamp
validator_role?: UserRole; // Rol del validador
validation_expires_at?: string; // Fecha de expiración de validación
// Versionado
version: string; // Versión semántica (ej: "1.2.3")
latest_version: string; // Última versión disponible
// Contenido específico (JSON flexible según tipo)
content: ContentItemContent; // Contenido específico del tipo
// Relaciones
related_content_ids?: string[]; // IDs de contenido relacionado
related_protocol_ids?: string[]; // IDs de protocolos relacionados (si no es protocolo)
related_guide_ids?: string[]; // IDs de guías relacionadas (si no es guía)
related_manual_ids?: string[]; // IDs de manuales relacionados (si no es manual)
// Tags y categorización
tags: string[]; // Tags para búsqueda
category?: string; // Categoría temática
// Auditoría
created_by: string; // ID del creador
created_at: string; // ISO timestamp
updated_by?: string; // ID del último editor
updated_at: string; // ISO timestamp
// Metadatos adicionales
metadata?: Record<string, unknown>; // Metadatos flexibles
}
/**
* Contenido específico según tipo de ContentItem
*/
export type ContentItemContent =
| ProtocolContent
| GuideContent
| ManualContent
| DrugContent
| ChecklistContent;
// ============================================
// PROTOCOL CONTENT (Operativo)
// ============================================
export interface ProtocolContent {
// Pasos operativos
steps: ProtocolStep[];
// Checklist integrado
checklist?: {
enabled: boolean;
title?: string;
items: ChecklistItem[];
};
// Dosis inline
inline_doses?: InlineDose[];
// Herramientas de contexto
context_tools?: ContextTool[];
// Fuentes clínicas
clinical_sources?: ClinicalSource[];
// Campos de compatibilidad (legacy)
warnings?: string[];
key_points?: string[];
equipment?: string[];
drugs?: string[]; // Referencias a fármacos
// Metadatos específicos
age_group?: 'adulto' | 'pediatrico' | 'neonatal' | 'todos';
estimated_duration?: string; // Duración estimada (ej: "5-10 min")
}
export interface ProtocolStep {
order: number; // Orden del paso (1, 2, 3...)
text: string; // Texto del paso
critical?: boolean; // Si es paso crítico (no saltable)
equipment?: string[]; // Equipamiento necesario
time_estimate?: string; // Tiempo estimado (ej: "30-60s")
notes?: string; // Notas internas
}
export interface ChecklistItem {
id: string; // ID único del item
text: string; // Texto del item
order: number; // Orden
critical?: boolean; // Si es crítico
category?: string; // Categoría (ej: "Preparación", "Verificación")
}
export interface InlineDose {
drug_id: string; // ID del fármaco
drug_name: string; // Nombre del fármaco
adult_dose: string; // Dosis adulto
pediatric_dose?: string; // Dosis pediátrica
route: string; // Vía de administración
timing?: string; // Cuándo administrar
context?: string; // Contexto específico
}
export interface ContextTool {
id: string;
name: string;
type: 'calculator' | 'algorithm' | 'reference' | 'checklist';
url?: string;
description?: string;
}
export interface ClinicalSource {
organization: string; // ERC, SEMES, AHA, etc.
guideline: string; // Nombre de la guía
year: number; // Año
url?: string; // URL
section?: string; // Sección específica
}
// ============================================
// GUIDE CONTENT (Formativo)
// ============================================
export interface GuideContent {
// Secciones (siempre 8)
sections: GuideSection[];
// Relaciones
related_protocol_id?: string; // Protocolo operativo relacionado
related_manual_ids?: string[]; // Capítulos de manual relacionados
// Metadatos formativos
learning_objectives?: string[];
prerequisites?: string[];
target_audience?: string[];
estimated_time?: string; // Tiempo total estimado
}
export interface GuideSection {
numero: number; // 1-8
titulo: string;
markdown: string; // Contenido Markdown
estimated_time?: string; // Tiempo estimado de lectura
resources?: {
images?: string[]; // IDs de MediaResource
videos?: string[]; // IDs de MediaResource
links?: Array<{ title: string; url: string }>;
};
}
// ============================================
// MANUAL CONTENT (Referencia)
// ============================================
export interface ManualContent {
markdown: string; // Contenido Markdown completo
tags: string[]; // Tags para búsqueda
related_protocol_ids?: string[]; // IDs de protocolos relacionados
related_guide_ids?: string[]; // IDs de guías relacionadas
references?: ClinicalSource[]; // Referencias clínicas
// Estructura jerárquica
block?: string; // Bloque del manual (ej: "BLOQUE_01")
section?: string; // Sección dentro del bloque
order?: number; // Orden dentro del bloque
}
// ============================================
// DRUG CONTENT (Referencia)
// ============================================
export interface DrugContent {
generic_name: string;
trade_name: string;
category: string;
presentation: string;
// Dosis
adult_dose: string;
pediatric_dose?: string;
routes: string[]; // Vías de administración
dilution?: string;
// Indicaciones y contraindicaciones
indications: string[];
contraindications: string[];
side_effects?: string[];
antidote?: string;
// Notas
notes?: string[];
critical_points?: string[];
source?: string;
}
// ============================================
// CHECKLIST CONTENT (Operativo)
// ============================================
export interface ChecklistContent {
items: ChecklistItem[];
description?: string;
estimated_time?: string;
applicable_protocol_ids?: string[]; // IDs de protocolos donde se puede usar
tags?: string[];
}
// ============================================
// MEDIA RESOURCE (Imágenes y Vídeos)
// ============================================
/**
* MediaResource: Recurso multimedia (imagen o vídeo)
*
* Almacenado en Supabase Storage, referenciado por URL.
*/
export interface MediaResource {
// Identificación
id: string; // UUID v4
type: MediaType; // 'image' | 'video'
// Archivo
file_url: string; // URL completa del archivo (Supabase Storage)
thumbnail_url?: string; // URL del thumbnail (para vídeos)
filename: string; // Nombre del archivo original
path: string; // Ruta relativa (ej: "/assets/infografias/rcp/...")
// Metadatos
title: string; // Título del recurso
description?: string; // Descripción
alt_text: string; // Texto alternativo (accesibilidad)
caption?: string; // Caption opcional
// Clasificación
tags: string[]; // Tags para búsqueda
block?: string; // Bloque temático (ej: "bloque-0-fundamentos")
chapter?: string; // Capítulo relacionado
priority: Priority; // Prioridad del recurso
usage_type: UsageType[]; // ['operativo', 'formativo'] - dónde se usa
// Dimensiones (imágenes)
width?: number; // Ancho en píxeles
height?: number; // Alto en píxeles
format?: string; // 'png' | 'svg' | 'jpg' | 'webp'
file_size?: number; // Tamaño en bytes
// Duración (vídeos)
duration_seconds?: number; // Duración en segundos
video_format?: string; // 'mp4' | 'webm'
// Fuente
source?: string; // Fuente del recurso
attribution?: string; // Atribución si es necesario
// Estado
status: 'draft' | 'approved' | 'published';
// Auditoría
uploaded_by: string; // ID del usuario
uploaded_at: string; // ISO timestamp
updated_at: string; // ISO timestamp
}
// ============================================
// CONTENT RESOURCE ASSOCIATION
// ============================================
/**
* ContentResourceAssociation: Asociación entre contenido y recursos
*
* Define dónde y cómo se muestra un recurso en un contenido específico.
*/
export interface ContentResourceAssociation {
// Identificación
id: string; // UUID v4
content_item_id: string; // ID del ContentItem
media_resource_id: string; // ID del MediaResource
// Contexto de asociación
section?: string; // Sección específica (ej: "pasos", "checklist", "guia_seccion_3")
position?: number; // Posición en el contenido (orden)
placement?: 'inline' | 'before' | 'after' | 'modal'; // Dónde se muestra
caption?: string; // Caption específico para este contexto
// Metadatos
is_critical: boolean; // Si es obligatorio para el contenido
priority: Priority; // Prioridad de esta asociación
// Auditoría
created_at: string; // ISO timestamp
}
// ============================================
// CONTENT VERSION
// ============================================
/**
* ContentVersion: Versión histórica de un ContentItem
*
* Permite versionado semántico y rollback.
*/
export interface ContentVersion {
// Identificación
id: string; // UUID v4
content_item_id: string; // ID del ContentItem
version: string; // Versión semántica (ej: "1.2.3")
// Contenido
content: ContentItemContent; // Contenido de esta versión
// Metadatos
change_summary: string; // Resumen de cambios
is_breaking?: boolean; // Si es cambio breaking
created_by: string; // ID del creador
created_at: string; // ISO timestamp
// Estado
is_active: boolean; // Si es la versión activa
}
// ============================================
// AUDIT LOG
// ============================================
/**
* AuditLog: Registro de auditoría de todas las acciones
*
* Trazabilidad completa de cambios en el sistema.
*/
export interface AuditLog {
// Identificación
id: string; // UUID v4
entity_type: 'content_item' | 'media_resource' | 'association' | 'version';
entity_id: string; // ID de la entidad
// Acción
action: AuditAction; // Tipo de acción
user_id: string; // ID del usuario
user_role: UserRole; // Rol del usuario
// Metadatos
metadata?: Record<string, unknown>; // Metadatos adicionales (cambios, valores anteriores, etc.)
// Timestamp
timestamp: string; // ISO timestamp
}
// ============================================
// EXPORTS
// ============================================
export type {
ContentItem,
ContentItemContent,
ProtocolContent,
GuideContent,
ManualContent,
DrugContent,
ChecklistContent,
MediaResource,
ContentResourceAssociation,
ContentVersion,
AuditLog,
};

View file

@ -1,357 +0,0 @@
/**
* MODELO DE DATOS EXTENDIDO - ADMIN PANEL
*
* Este modelo extiende el modelo existente sin romper compatibilidad.
* Los tipos base (Procedure, Drug) se mantienen intactos.
*
* ESTRATEGIA: Content Pack como "override" del contenido local
*/
// ============================================
// TIPOS BASE (Compatibles con src/data/)
// ============================================
export type Priority = 'critico' | 'alto' | 'medio' | 'bajo';
export type AgeGroup = 'adulto' | 'pediatrico' | 'neonatal' | 'todos';
export type Category = 'soporte_vital' | 'patologias' | 'escena';
export type ContentType = 'protocol' | 'guide' | 'manual' | 'drug' | 'checklist' | 'resource';
export type ContentLevel = 'operativo' | 'formativo' | 'referencia';
export type ContentStatus = 'draft' | 'in_review' | 'approved' | 'published' | 'archived';
export type AdministrationRoute = 'IV' | 'IM' | 'SC' | 'IO' | 'Nebulizado' | 'SL' | 'Rectal' | 'Nasal';
// ============================================
// PROTOCOL (Operativo) - EXTENDIDO
// ============================================
export interface ProtocolStep {
order: number;
text: string;
critical?: boolean; // Paso crítico (no saltable)
equipment?: string[]; // Equipamiento necesario para este paso
timeEstimate?: string; // Tiempo estimado (ej: "30-60s")
notes?: string; // Notas internas para editores
}
export interface ProtocolChecklistItem {
id: string;
text: string;
order: number;
reusableChecklistId?: string; // Referencia a checklist reutilizable
}
export interface InlineDose {
drugId: string;
drugName: string;
adultDose: string;
pediatricDose?: string;
route: AdministrationRoute;
dilution?: string;
timing?: string; // Cuándo administrar (ej: "cada 3-5 min")
context?: string; // Contexto específico (ej: "en PCR", "en anafilaxia")
}
export interface ProtocolContextTool {
id: string;
name: string;
type: 'calculator' | 'algorithm' | 'reference' | 'checklist';
url?: string;
description?: string;
}
export interface ClinicalSource {
organization: string; // ERC, SEMES, AHA, etc.
guideline: string; // Nombre de la guía
year: number;
url?: string;
section?: string; // Sección específica
}
export interface ProtocolContent {
// Pasos rápidos (estructura mejorada)
pasosRapidos: ProtocolStep[];
// Checklist integrado
checklist?: {
enabled: boolean;
items: ProtocolChecklistItem[];
title?: string;
};
// Dosis inline
dosisInline?: InlineDose[];
// Herramientas de contexto
herramientasContexto?: ProtocolContextTool[];
// Fuentes clínicas
fuentes?: ClinicalSource[];
// Metadatos adicionales
warnings: string[];
keyPoints?: string[];
equipment?: string[];
drugs?: string[]; // Referencias a fármacos
// Versionado
version: number;
lastUpdated: string;
updatedBy?: string;
}
export interface Protocol extends BaseContentItem {
type: 'protocol';
level: 'operativo';
content: ProtocolContent;
// Campos específicos de protocolo
category: Category;
subcategory?: string;
priority: Priority;
ageGroup: AgeGroup;
}
// ============================================
// GUIDE (Formativo) - EXTENDIDO
// ============================================
export interface GuideSection {
numero: number; // 1-8
titulo: string;
markdown: string; // Contenido Markdown
resources?: {
images?: string[];
videos?: string[];
links?: Array<{ title: string; url: string }>;
};
estimatedTime?: string; // Tiempo estimado de lectura
}
export interface GuideRelation {
protocolId?: string; // Protocolo operativo relacionado
manualChapterId?: string; // Capítulo de manual relacionado
relatedGuideIds?: string[]; // Otras guías relacionadas
}
export interface GuideContent {
sections: GuideSection[]; // Siempre 8 secciones
relations: GuideRelation;
metadata?: {
learningObjectives?: string[];
prerequisites?: string[];
targetAudience?: string[];
};
}
export interface Guide extends BaseContentItem {
type: 'guide';
level: 'formativo';
content: GuideContent;
// Relaciones
protocoloOperativo?: {
titulo: string;
ruta: string;
};
}
// ============================================
// MANUAL CHAPTER (Referencia)
// ============================================
export interface ManualChapterContent {
markdown: string;
tags: string[]; // Tags para búsqueda y categorización
relatedProtocols?: string[]; // IDs de protocolos relacionados
relatedGuides?: string[]; // IDs de guías relacionadas
references?: ClinicalSource[];
}
export interface ManualChapter extends BaseContentItem {
type: 'manual';
level: 'referencia';
content: ManualChapterContent;
// Estructura jerárquica
block?: string; // Bloque del manual (ej: "BLOQUE_01")
section?: string; // Sección dentro del bloque
order?: number; // Orden dentro del bloque
}
// ============================================
// DRUG (Vademécum) - EXTENDIDO
// ============================================
export interface DrugFrequency {
context: string; // "PCR", "Anafilaxia", "Crisis asmática"
frequency: 'first_line' | 'second_line' | 'alternative' | 'rescue';
priority?: number; // Prioridad dentro del contexto (1 = más importante)
}
export interface DrugContext {
indication: string; // Indicación específica
dose: string; // Dosis para esta indicación
route: AdministrationRoute;
timing?: string; // Cuándo/cómo administrar
notes?: string[]; // Notas específicas para esta indicación
}
export interface DrugContent {
// Estructura normalizada
genericName: string;
tradeName: string;
category: string;
presentation: string;
// Dosis
adultDose: string;
pediatricDose?: string;
routes: AdministrationRoute[];
dilution?: string;
// Indicaciones y contraindicaciones
indications: string[];
contraindications: string[];
sideEffects?: string[];
antidote?: string;
// Nuevos campos
frecuenciaUso: DrugFrequency[]; // Frecuencia de uso por contexto
contextos: DrugContext[]; // Contextos específicos de uso
primeraLinea?: string[]; // Indicaciones donde es primera línea
segundaLinea?: string[]; // Indicaciones donde es segunda línea
// Campos existentes
notes?: string[];
criticalPoints?: string[];
source?: string;
}
export interface Drug extends BaseContentItem {
type: 'drug';
level: 'referencia';
content: DrugContent;
}
// ============================================
// CHECKLIST REUTILIZABLE
// ============================================
export interface ChecklistItem {
id: string;
text: string;
order: number;
critical?: boolean; // Item crítico (no saltable)
category?: string; // Categoría del item (ej: "Preparación", "Verificación")
}
export interface ChecklistContent {
items: ChecklistItem[];
description?: string;
estimatedTime?: string;
applicableProtocols?: string[]; // IDs de protocolos donde se puede usar
tags?: string[];
}
export interface ChecklistReusable extends BaseContentItem {
type: 'checklist';
level: 'operativo';
content: ChecklistContent;
}
// ============================================
// BASE CONTENT ITEM
// ============================================
export interface BaseContentItem {
id: string; // ID estable (inmutable)
type: ContentType;
level: ContentLevel;
// Metadatos básicos
title: string;
shortTitle?: string;
description?: string;
icon?: string;
// Versionado
version: number;
latestVersion: number;
// Estado y validación
status: ContentStatus;
validatedBy?: string;
validatedAt?: string;
clinicalSource?: string;
// Auditoría
createdBy?: string;
createdAt: string;
updatedBy?: string;
updatedAt: string;
// Relaciones
relations?: {
protocols?: string[];
guides?: string[];
manuals?: string[];
drugs?: string[];
checklists?: string[];
};
}
// ============================================
// CONTENT PACK (Para la app)
// ============================================
export interface ContentPack {
version: string; // Versión del pack (ej: "1.2.3")
timestamp: string; // ISO timestamp
hash: string; // Hash del contenido para verificar integridad
// Contenido
protocols?: Protocol[];
guides?: Guide[];
manuals?: ManualChapter[];
drugs?: Drug[];
checklists?: ChecklistReusable[];
// Metadatos
publishedBy?: string;
publishedAt?: string;
clinicalSource?: string;
notes?: string;
}
// ============================================
// API RESPONSES
// ============================================
export interface ContentListResponse {
items: BaseContentItem[];
total: number;
page: number;
pageSize: number;
}
export interface ContentVersion {
versionId: string;
version: number;
content: unknown; // Contenido de esta versión
status: ContentStatus;
changeSummary?: string;
createdBy: string;
createdAt: string;
validatedBy?: string;
validatedAt?: string;
}
export interface AuditLog {
logId: string;
contentItemId: string;
versionId?: string;
userId: string;
action: 'create' | 'update' | 'delete' | 'validate' | 'approve' | 'publish' | 'revert';
details?: Record<string, unknown>;
timestamp: string;
}

View file

@ -1,66 +0,0 @@
/**
* Admin Panel - EMERGES TES
*
* Panel de administración para gestionar contenido
*/
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { ProtectedRoute } from './components/auth/ProtectedRoute';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import ContentLibraryPage from './pages/ContentLibraryPage';
import ProtocolEditorPage from './pages/ProtocolEditorPage';
import ChecklistEditorPage from './pages/ChecklistEditorPage';
import GuideEditorPage from './pages/GuideEditorPage';
import DrugManagerPage from './pages/DrugManagerPage';
import DrugEditorPage from './pages/DrugEditorPage';
import ContentPackPage from './pages/ContentPackPage';
import MediaManagerPage from './pages/MediaManagerPage';
import ValidationPage from './pages/ValidationPage';
import AuditPage from './pages/AuditPage';
import Layout from './components/layout/Layout';
function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
{/* Login (público) */}
<Route path="/login" element={<LoginPage />} />
{/* Rutas protegidas */}
<Route
path="/*"
element={
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/content" element={<ContentLibraryPage />} />
<Route path="/content/protocol/:id?" element={<ProtocolEditorPage />} />
<Route path="/content/checklist/:id?" element={<ChecklistEditorPage />} />
<Route path="/content/guide/:id?" element={<GuideEditorPage />} />
<Route path="/content/drug/:id?" element={<DrugManagerPage />} />
<Route path="/drugs" element={<DrugManagerPage />} />
<Route path="/drugs/new" element={<DrugEditorPage />} />
<Route path="/drugs/:id" element={<DrugEditorPage />} />
<Route path="/drugs/:id/edit" element={<DrugEditorPage />} />
<Route path="/content-pack" element={<ContentPackPage />} />
<Route path="/media" element={<MediaManagerPage />} />
<Route path="/validation" element={<ValidationPage />} />
<Route path="/audit" element={<AuditPage />} />
</Routes>
</Layout>
</ProtectedRoute>
}
/>
</Routes>
</AuthProvider>
</BrowserRouter>
);
}
export default App;

View file

@ -1,43 +0,0 @@
/**
* Componente para proteger rutas que requieren autenticación
*/
import { Navigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredPermission?: string;
}
export function ProtectedRoute({ children, requiredPermission }: ProtectedRouteProps) {
const { user, isLoading, hasPermission } = useAuth();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-muted-foreground">Cargando...</div>
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
if (requiredPermission && !hasPermission(requiredPermission)) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h2 className="text-2xl font-bold mb-2">Acceso Denegado</h2>
<p className="text-muted-foreground">
No tienes permisos para acceder a esta sección.
</p>
</div>
</div>
);
}
return <>{children}</>;
}

View file

@ -1,404 +0,0 @@
/**
* Componente para gestionar recursos multimedia asociados a contenido
*/
import { useState, useEffect } from 'react';
import { Image, Video, Trash2, X, Search, Link2 } from 'lucide-react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
interface MediaResource {
id: string;
type: 'image' | 'video';
file_url: string;
title: string;
description?: string;
alt_text?: string;
thumbnail_url?: string;
}
interface ResourceAssociation {
id: string;
media_resource_id: string;
section: string;
position: number;
placement: string;
caption?: string;
is_critical: boolean;
priority: string;
type: 'image' | 'video';
file_url: string;
title: string;
description?: string;
alt_text?: string;
}
interface ResourcesManagerProps {
contentId: string;
resources: ResourceAssociation[];
onResourcesChange: (resources: ResourceAssociation[]) => void;
showSelector: boolean;
onCloseSelector: () => void;
}
export default function ResourcesManager({
contentId,
resources,
onResourcesChange,
showSelector,
onCloseSelector,
}: ResourcesManagerProps) {
const [availableResources, setAvailableResources] = useState<MediaResource[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedResource, setSelectedResource] = useState<MediaResource | null>(null);
const [associationData, setAssociationData] = useState({
section: 'general',
placement: 'inline',
caption: '',
is_critical: false,
priority: 'media',
});
// Cargar recursos asociados
useEffect(() => {
if (contentId) {
loadAssociatedResources();
}
}, [contentId]);
// Cargar recursos disponibles cuando se abre el selector
useEffect(() => {
if (showSelector) {
loadAvailableResources();
}
}, [showSelector, searchQuery]);
const loadAssociatedResources = async () => {
if (!contentId) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/content/${contentId}/resources`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
onResourcesChange(data.associations || []);
} catch (error) {
console.error('Error cargando recursos asociados:', error);
}
};
const loadAvailableResources = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('admin_token');
const params = new URLSearchParams({ page: '1', pageSize: '50' });
if (searchQuery) params.append('search', searchQuery);
const response = await fetch(`${API_URL}/api/media?${params}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setAvailableResources(data.items || []);
} catch (error) {
console.error('Error cargando recursos:', error);
} finally {
setIsLoading(false);
}
};
const handleAssociate = async () => {
if (!selectedResource || !contentId) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/content/${contentId}/resources`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
resource_id: selectedResource.id,
...associationData,
}),
});
if (response.ok) {
await loadAssociatedResources();
setSelectedResource(null);
setAssociationData({
section: 'general',
placement: 'inline',
caption: '',
is_critical: false,
priority: 'media',
});
onCloseSelector();
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Error al asociar recurso'}`);
}
} catch (error) {
console.error('Error asociando recurso:', error);
alert('Error al asociar recurso');
}
};
const handleRemove = async (associationId: string) => {
if (!confirm('¿Estás seguro de eliminar esta asociación?')) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(
`${API_URL}/api/content/${contentId}/resources/${associationId}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
}
);
if (response.ok) {
await loadAssociatedResources();
} else {
alert('Error al eliminar asociación');
}
} catch (error) {
console.error('Error eliminando asociación:', error);
alert('Error al eliminar asociación');
}
};
return (
<>
{/* Lista de recursos asociados */}
{resources.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No hay recursos asociados. Haz clic en "Asociar Recurso" para comenzar.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{resources.map((resource) => (
<div
key={resource.id}
className="border border-border rounded-lg overflow-hidden hover:shadow-lg transition-shadow"
>
<div className="aspect-video bg-muted flex items-center justify-center relative">
{resource.type === 'image' ? (
<img
src={`${API_URL}${resource.file_url}`}
alt={resource.alt_text || resource.title}
className="w-full h-full object-cover"
/>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Video className="w-12 h-12" />
<span className="text-sm">Vídeo</span>
</div>
)}
<div className="absolute top-2 right-2">
{resource.type === 'image' ? (
<Image className="w-4 h-4 text-white drop-shadow" />
) : (
<Video className="w-4 h-4 text-white drop-shadow" />
)}
</div>
</div>
<div className="p-4 space-y-2">
<h3 className="font-medium text-foreground truncate">{resource.title}</h3>
{resource.caption && (
<p className="text-sm text-muted-foreground line-clamp-2">{resource.caption}</p>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Sección: {resource.section}</span>
{resource.is_critical && (
<span className="px-2 py-0.5 bg-red-500/20 text-red-500 rounded text-xs">
Crítico
</span>
)}
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => window.open(`${API_URL}${resource.file_url}`, '_blank')}
className="flex-1 px-3 py-1.5 border border-border rounded-lg hover:bg-muted transition-colors text-sm flex items-center justify-center gap-1"
>
<Link2 className="w-3 h-3" />
Ver
</button>
<button
onClick={() => handleRemove(resource.id)}
className="px-3 py-1.5 border border-red-500/20 text-red-500 rounded-lg hover:bg-red-500/10 transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Modal selector de recursos */}
{showSelector && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-card border border-border rounded-xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-4 border-b border-border flex items-center justify-between">
<h3 className="text-xl font-semibold text-foreground">Seleccionar Recurso</h3>
<button
onClick={onCloseSelector}
className="p-2 hover:bg-muted rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Búsqueda */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar recursos..."
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{/* Lista de recursos */}
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : availableResources.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No se encontraron recursos
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{availableResources.map((resource) => (
<div
key={resource.id}
onClick={() => setSelectedResource(resource)}
className={`border rounded-lg overflow-hidden cursor-pointer transition-all ${
selectedResource?.id === resource.id
? 'border-primary ring-2 ring-primary'
: 'border-border hover:border-primary/50'
}`}
>
<div className="aspect-video bg-muted flex items-center justify-center relative">
{resource.type === 'image' ? (
<img
src={`${API_URL}${resource.file_url}`}
alt={resource.alt_text || resource.title}
className="w-full h-full object-cover"
/>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Video className="w-12 h-12" />
<span className="text-sm">Vídeo</span>
</div>
)}
</div>
<div className="p-2">
<p className="text-sm font-medium text-foreground truncate">
{resource.title}
</p>
</div>
</div>
))}
</div>
)}
{/* Formulario de asociación */}
{selectedResource && (
<div className="mt-4 p-4 bg-muted/50 border border-border rounded-lg space-y-3">
<h4 className="font-medium text-foreground">Configurar Asociación</h4>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-muted-foreground mb-1">Sección</label>
<select
value={associationData.section}
onChange={(e) =>
setAssociationData({ ...associationData, section: e.target.value })
}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-sm"
>
<option value="general">General</option>
<option value="pasos">Pasos</option>
<option value="checklist">Checklist</option>
<option value="dosis">Dosis</option>
<option value="header">Encabezado</option>
</select>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">Ubicación</label>
<select
value={associationData.placement}
onChange={(e) =>
setAssociationData({ ...associationData, placement: e.target.value })
}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-sm"
>
<option value="inline">Inline</option>
<option value="header">Header</option>
<option value="sidebar">Sidebar</option>
</select>
</div>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">Caption</label>
<input
type="text"
value={associationData.caption}
onChange={(e) =>
setAssociationData({ ...associationData, caption: e.target.value })
}
placeholder="Descripción opcional"
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-sm"
/>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={associationData.is_critical}
onChange={(e) =>
setAssociationData({ ...associationData, is_critical: e.target.checked })
}
className="form-checkbox"
/>
<span className="text-sm text-muted-foreground">Crítico</span>
</label>
</div>
</div>
)}
</div>
<div className="p-4 border-t border-border flex justify-end gap-2">
<button
onClick={onCloseSelector}
className="px-4 py-2 border border-border rounded-lg hover:bg-muted transition-colors"
>
Cancelar
</button>
<button
onClick={handleAssociate}
disabled={!selectedResource}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Asociar
</button>
</div>
</div>
</div>
)}
</>
);
}

View file

@ -1,157 +0,0 @@
/**
* Componente para mostrar historial de validación
*/
import { useState, useEffect } from 'react';
import { Clock, CheckCircle, XCircle, Send, FileText } from 'lucide-react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
interface ValidationHistoryItem {
id: string;
action: string;
timestamp: string;
metadata: Record<string, unknown>;
username?: string;
role?: string;
}
interface ValidationHistoryProps {
contentId: string;
}
export default function ValidationHistory({ contentId }: ValidationHistoryProps) {
const [history, setHistory] = useState<ValidationHistoryItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
if (isExpanded && contentId) {
loadHistory();
}
}, [isExpanded, contentId]);
const loadHistory = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/validation/history/${contentId}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setHistory(data.history || []);
} catch (error) {
console.error('Error cargando historial:', error);
} finally {
setIsLoading(false);
}
};
const getActionIcon = (action: string) => {
switch (action) {
case 'submit':
return Send;
case 'approve':
return CheckCircle;
case 'reject':
return XCircle;
case 'publish':
return FileText;
default:
return Clock;
}
};
const getActionColor = (action: string) => {
switch (action) {
case 'submit':
return 'text-blue-500';
case 'approve':
return 'text-green-500';
case 'reject':
return 'text-red-500';
case 'publish':
return 'text-purple-500';
default:
return 'text-muted-foreground';
}
};
const getActionLabel = (action: string) => {
const labels = {
submit: 'Enviado a revisión',
approve: 'Aprobado',
reject: 'Rechazado',
publish: 'Publicado',
};
return labels[action as keyof typeof labels] || action;
};
if (!contentId) return null;
return (
<div className="bg-muted/50 border border-border rounded-lg p-4">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between text-left"
>
<h3 className="font-medium text-foreground flex items-center gap-2">
<Clock className="w-4 h-4" />
Historial de Validación
</h3>
<span className="text-sm text-muted-foreground">
{history.length} evento{history.length !== 1 ? 's' : ''}
</span>
</button>
{isExpanded && (
<div className="mt-4 space-y-3">
{isLoading ? (
<div className="text-center text-muted-foreground py-4">
Cargando historial...
</div>
) : history.length === 0 ? (
<div className="text-center text-muted-foreground py-4">
No hay historial de validación
</div>
) : (
history.map((item) => {
const Icon = getActionIcon(item.action);
return (
<div
key={item.id}
className="flex items-start gap-3 p-3 bg-background rounded-lg border border-border"
>
<Icon className={`w-5 h-5 mt-0.5 ${getActionColor(item.action)}`} />
<div className="flex-1">
<div className="flex items-center justify-between">
<p className="font-medium text-foreground">
{getActionLabel(item.action)}
</p>
<p className="text-xs text-muted-foreground">
{new Date(item.timestamp).toLocaleString('es-ES')}
</p>
</div>
{item.username && (
<p className="text-sm text-muted-foreground mt-1">
Por {item.username} ({item.role})
</p>
)}
{item.metadata?.notes && (
<p className="text-sm text-muted-foreground mt-2 p-2 bg-muted rounded">
{item.metadata.notes}
</p>
)}
</div>
</div>
);
})
)}
</div>
)}
</div>
);
}

View file

@ -1,159 +0,0 @@
/**
* Layout principal del admin panel
*/
import { ReactNode } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import {
LayoutDashboard,
FileText,
BookOpen,
Pill,
CheckSquare,
FileSearch,
Package,
Image,
ShieldCheck,
LogOut,
Menu,
X,
} from 'lucide-react';
import { useState } from 'react';
interface LayoutProps {
children: ReactNode;
}
export default function Layout({ children }: LayoutProps) {
const { user, logout } = useAuth();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'Biblioteca', href: '/content', icon: FileText },
{ name: 'Vademécum', href: '/drugs', icon: Pill },
{ name: 'Recursos', href: '/media', icon: Image },
{ name: 'Content Pack', href: '/content-pack', icon: Package },
{ name: 'Validación', href: '/validation', icon: ShieldCheck },
{ name: 'Nuevo Checklist', href: '/content/checklist', icon: CheckSquare },
{ name: 'Auditoría', href: '/audit', icon: FileSearch },
];
return (
<div className="min-h-screen bg-background">
{/* Sidebar móvil */}
<div
className={`fixed inset-0 z-40 lg:hidden ${
sidebarOpen ? 'block' : 'hidden'
}`}
>
<div
className="fixed inset-0 bg-black/50"
onClick={() => setSidebarOpen(false)}
/>
<div className="fixed inset-y-0 left-0 w-64 bg-card border-r border-border">
<SidebarContent
navigation={navigation}
location={location}
user={user}
logout={logout}
onClose={() => setSidebarOpen(false)}
/>
</div>
</div>
{/* Sidebar desktop */}
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64">
<div className="flex flex-col w-64 bg-card border-r border-border">
<SidebarContent
navigation={navigation}
location={location}
user={user}
logout={logout}
/>
</div>
</div>
{/* Main content */}
<div className="lg:pl-64">
{/* Top bar */}
<div className="sticky top-0 z-30 bg-card border-b border-border px-4 py-3 flex items-center justify-between">
<button
onClick={() => setSidebarOpen(true)}
className="lg:hidden p-2 rounded-lg hover:bg-muted"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{user?.username} ({user?.role})
</span>
<button
onClick={logout}
className="p-2 rounded-lg hover:bg-muted text-muted-foreground"
>
<LogOut className="w-5 h-5" />
</button>
</div>
</div>
{/* Page content */}
<main>{children}</main>
</div>
</div>
);
}
function SidebarContent({
navigation,
location,
user,
logout,
onClose,
}: {
navigation: Array<{ name: string; href: string; icon: React.ComponentType }>;
location: { pathname: string };
user: { username?: string; role?: string } | null;
logout: () => void;
onClose?: () => void;
}) {
return (
<>
<div className="flex items-center justify-between p-4 border-b border-border">
<h1 className="text-xl font-bold text-foreground">EMERGES TES</h1>
{onClose && (
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-muted"
>
<X className="w-5 h-5" />
</button>
)}
</div>
<nav className="flex-1 p-4 space-y-1">
{navigation.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
onClick={onClose}
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-colors ${
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<Icon className="w-5 h-5" />
<span className="font-medium">{item.name}</span>
</Link>
);
})}
</nav>
</>
);
}

View file

@ -1,113 +0,0 @@
/**
* Contexto de autenticación
*/
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { authService } from '../services/auth';
import type { User, LoginRequest } from '../../shared/types/auth';
interface AuthContextType {
user: User | null;
token: string | null;
isLoading: boolean;
login: (credentials: LoginRequest) => Promise<void>;
logout: () => void;
hasPermission: (permission: string) => boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Cargar token y usuario desde localStorage al iniciar
useEffect(() => {
const storedToken = localStorage.getItem('admin_token');
const storedUser = localStorage.getItem('admin_user');
if (storedToken && storedUser) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
// Verificar que el token sigue siendo válido
authService.verifyToken(storedToken).catch(() => {
logout();
});
}
setIsLoading(false);
}, []);
const login = async (credentials: LoginRequest) => {
const response = await authService.login(credentials);
setToken(response.token);
setUser(response.user);
localStorage.setItem('admin_token', response.token);
localStorage.setItem('admin_user', JSON.stringify(response.user));
};
const logout = () => {
setToken(null);
setUser(null);
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
};
const hasPermission = (permission: string): boolean => {
if (!user) return false;
const rolePermissions: Record<string, string[]> = {
super_admin: ['*'],
editor_clinico: [
'content:read',
'content:write:protocol',
'content:write:drug',
'content:write:checklist',
'content:submit',
],
editor_formativo: [
'content:read',
'content:write:guide',
'content:write:manual',
'content:submit',
],
revisor: [
'content:read',
'content:validate',
'content:approve',
'audit:read',
],
viewer: ['content:read'],
};
const permissions = rolePermissions[user.role] || [];
return permissions.includes(permission) || permissions.includes('*');
};
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
login,
logout,
hasPermission,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View file

@ -1,92 +0,0 @@
/**
* Hook para obtener estadísticas de contenido
*/
import { useState, useEffect } from 'react';
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const api = axios.create({
baseURL: `${API_URL}/api`,
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor para añadir token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('admin_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export function useContentStats() {
const [stats, setStats] = useState({
protocols: 0,
protocolsPublished: 0,
guides: 0,
guidesPublished: 0,
drugs: 0,
drugsPublished: 0,
checklists: 0,
checklistsPublished: 0,
});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function fetchStats() {
try {
const response = await api.get('/stats/content');
const data = response.data;
console.log('📊 Estadísticas recibidas:', data);
// El backend ahora devuelve el formato esperado directamente
setStats({
protocols: data.protocols || 0,
protocolsPublished: data.protocolsPublished || 0,
guides: data.guides || 0,
guidesPublished: data.guidesPublished || 0,
drugs: data.drugs || 0,
drugsPublished: data.drugsPublished || 0,
checklists: data.checklists || 0,
checklistsPublished: data.checklistsPublished || 0,
});
console.log('✅ Estadísticas cargadas correctamente');
} catch (error: unknown) {
console.error('❌ Error cargando estadísticas:', error);
const err = error as { response?: { data?: unknown; status?: number }; message?: string };
console.error('Detalles del error:', err.response?.data || err.message);
console.error('Status:', err.response?.status);
// Si es error 401, el token puede estar expirado
if (error.response?.status === 401) {
console.warn('⚠️ Token expirado o inválido. Por favor, inicia sesión nuevamente.');
}
// Mantener valores por defecto (0)
setStats({
protocols: 0,
protocolsPublished: 0,
guides: 0,
guidesPublished: 0,
drugs: 0,
drugsPublished: 0,
checklists: 0,
checklistsPublished: 0,
});
} finally {
setIsLoading(false);
}
}
fetchStats();
}, []);
return { stats, isLoading };
}

View file

@ -1,34 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}

View file

@ -1,11 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -1,23 +0,0 @@
/**
* Página de Auditoría
*/
export default function AuditPage() {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Auditoría</h1>
<p className="text-muted-foreground mt-1">
Logs de cambios y versiones
</p>
</div>
<div className="bg-card border border-border rounded-xl p-6">
<p className="text-muted-foreground">
Funcionalidad en desarrollo...
</p>
</div>
</div>
);
}

View file

@ -1,482 +0,0 @@
/**
* Editor de Checklist Reutilizable
*
* Permite crear y editar checklists reutilizables (electrodos DESA, preparación IOT, etc.)
*/
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Save, Plus, Trash2, GripVertical, AlertCircle, CheckCircle, Eye, ArrowLeft } from 'lucide-react';
import { contentService } from '../services/content';
import { useAuth } from '../contexts/AuthContext';
import type { ChecklistReusable, ChecklistItem } from '../../shared/types/content';
export default function ChecklistEditorPage() {
const { id } = useParams<{ id?: string }>();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const isEdit = !!id;
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
// Estado del checklist
const [checklist, setChecklist] = useState<Partial<ChecklistReusable>>({
id: id || '',
type: 'checklist',
level: 'operativo',
title: '',
shortTitle: '',
description: '',
status: 'draft',
content: {
items: [],
description: '',
estimatedTime: '',
applicableProtocols: [],
tags: [],
},
});
// Cargar checklist existente
useEffect(() => {
if (isEdit && id) {
setIsLoading(true);
contentService
.getById(id)
.then((data: unknown) => {
setChecklist({
...data,
content: data.content || {
items: [],
description: '',
estimatedTime: '',
applicableProtocols: [],
tags: [],
},
});
})
.catch((error) => {
console.error('Error cargando checklist:', error);
setErrors({ general: 'Error al cargar el checklist' });
})
.finally(() => setIsLoading(false));
}
}, [id, isEdit]);
// Validación
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!checklist.id?.trim()) {
newErrors.id = 'ID es requerido';
}
if (!checklist.title?.trim()) {
newErrors.title = 'Título es requerido';
}
if (!checklist.content?.items || checklist.content.items.length === 0) {
newErrors.items = 'Debe tener al menos un item';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Guardar
const handleSave = async () => {
if (!validate()) {
return;
}
if (!hasPermission('content:write:checklist')) {
setErrors({ general: 'No tienes permisos para editar checklists' });
return;
}
setIsSaving(true);
try {
const payload = {
id: checklist.id,
type: 'checklist',
level: 'operativo',
title: checklist.title,
shortTitle: checklist.shortTitle,
description: checklist.description,
content: checklist.content,
status: checklist.status || 'draft',
};
if (isEdit) {
await contentService.update(id!, payload);
} else {
await contentService.create(payload);
}
navigate('/content');
} catch (error: unknown) {
console.error('Error guardando checklist:', error);
const err = error as { response?: { data?: { error?: string } } };
setErrors({ general: err.response?.data?.error || 'Error al guardar' });
} finally {
setIsSaving(false);
}
};
// Gestión de items
const addItem = () => {
const newItem: ChecklistItem = {
id: `item-${Date.now()}`,
text: '',
order: (checklist.content?.items?.length || 0) + 1,
critical: false,
};
setChecklist({
...checklist,
content: {
...checklist.content!,
items: [...(checklist.content?.items || []), newItem],
},
});
};
const removeItem = (itemId: string) => {
const items = checklist.content?.items?.filter((item) => item.id !== itemId) || [];
// Reordenar
items.forEach((item, index) => {
item.order = index + 1;
});
setChecklist({
...checklist,
content: {
...checklist.content!,
items,
},
});
};
const updateItem = (itemId: string, updates: Partial<ChecklistItem>) => {
const items = checklist.content?.items?.map((item) =>
item.id === itemId ? { ...item, ...updates } : item
) || [];
setChecklist({
...checklist,
content: {
...checklist.content!,
items,
},
});
};
const moveItem = (itemId: string, direction: 'up' | 'down') => {
const items = [...(checklist.content?.items || [])];
const index = items.findIndex((item) => item.id === itemId);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= items.length) return;
[items[index], items[newIndex]] = [items[newIndex], items[index]];
items.forEach((item, i) => {
item.order = i + 1;
});
setChecklist({
...checklist,
content: {
...checklist.content!,
items,
},
});
};
if (isLoading) {
return (
<div className="p-6">
<div className="text-muted-foreground">Cargando checklist...</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<button
onClick={() => navigate('/content')}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground mb-2"
>
<ArrowLeft className="w-4 h-4" />
<span>Volver</span>
</button>
<h1 className="text-3xl font-bold text-foreground">
{isEdit ? 'Editar Checklist' : 'Nuevo Checklist'}
</h1>
<p className="text-muted-foreground mt-1">
Crea y edita checklists reutilizables para protocolos
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowPreview(!showPreview)}
className="px-4 py-2 border border-border rounded-lg hover:bg-muted transition-colors flex items-center gap-2"
>
<Eye className="w-4 h-4" />
{showPreview ? 'Ocultar' : 'Vista'} Previa
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<Save className="w-4 h-4" />
{isSaving ? 'Guardando...' : 'Guardar'}
</button>
</div>
</div>
{errors.general && (
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4 flex items-center gap-2 text-destructive">
<AlertCircle className="w-5 h-5" />
<span>{errors.general}</span>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Formulario */}
<div className="space-y-6">
{/* Metadatos básicos */}
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Información Básica</h2>
<div>
<label className="block text-sm font-medium mb-1">
ID <span className="text-destructive">*</span>
</label>
<input
type="text"
value={checklist.id || ''}
onChange={(e) => setChecklist({ ...checklist, id: e.target.value })}
disabled={isEdit}
className="w-full px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="checklist-electrodos-desa"
/>
{errors.id && <p className="text-sm text-destructive mt-1">{errors.id}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">
Título <span className="text-destructive">*</span>
</label>
<input
type="text"
value={checklist.title || ''}
onChange={(e) => setChecklist({ ...checklist, title: e.target.value })}
className="w-full px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Colocación de Electrodos DESA"
/>
{errors.title && <p className="text-sm text-destructive mt-1">{errors.title}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">Título Corto</label>
<input
type="text"
value={checklist.shortTitle || ''}
onChange={(e) => setChecklist({ ...checklist, shortTitle: e.target.value })}
className="w-full px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Electrodos DESA"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Descripción</label>
<textarea
value={checklist.description || ''}
onChange={(e) => setChecklist({ ...checklist, description: e.target.value })}
rows={3}
className="w-full px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Checklist para la correcta colocación de electrodos..."
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Tiempo Estimado</label>
<input
type="text"
value={checklist.content?.estimatedTime || ''}
onChange={(e) =>
setChecklist({
...checklist,
content: { ...checklist.content!, estimatedTime: e.target.value },
})
}
className="w-full px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="30-60 segundos"
/>
</div>
</div>
{/* Items del Checklist */}
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-foreground">Items del Checklist</h2>
<button
onClick={addItem}
className="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2 text-sm"
>
<Plus className="w-4 h-4" />
Añadir Item
</button>
</div>
{errors.items && (
<p className="text-sm text-destructive">{errors.items}</p>
)}
<div className="space-y-3">
{checklist.content?.items?.map((item, index) => (
<div
key={item.id}
className="bg-muted/50 border border-border rounded-lg p-4 space-y-3"
>
<div className="flex items-start gap-3">
<div className="flex flex-col gap-1 pt-2">
<button
onClick={() => moveItem(item.id, 'up')}
disabled={index === 0}
className="p-1 hover:bg-muted rounded disabled:opacity-50"
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
</button>
<span className="text-xs text-muted-foreground text-center w-6">
{item.order}
</span>
<button
onClick={() => moveItem(item.id, 'down')}
disabled={index === (checklist.content?.items?.length || 0) - 1}
className="p-1 hover:bg-muted rounded disabled:opacity-50"
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
</button>
</div>
<div className="flex-1 space-y-2">
<input
type="text"
value={item.text}
onChange={(e) => updateItem(item.id, { text: e.target.value })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-sm"
placeholder="Texto del item..."
/>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={item.critical || false}
onChange={(e) => updateItem(item.id, { critical: e.target.checked })}
className="rounded"
/>
<span className="text-muted-foreground">Crítico</span>
</label>
<input
type="text"
value={item.category || ''}
onChange={(e) => updateItem(item.id, { category: e.target.value })}
className="flex-1 px-3 py-1.5 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-sm"
placeholder="Categoría (opcional)"
/>
</div>
</div>
<button
onClick={() => removeItem(item.id)}
className="p-2 text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
{(!checklist.content?.items || checklist.content.items.length === 0) && (
<div className="text-center py-8 text-muted-foreground">
<p>No hay items. Haz clic en "Añadir Item" para comenzar.</p>
</div>
)}
</div>
</div>
</div>
{/* Vista Previa */}
{showPreview && (
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Vista Previa</h2>
<div className="space-y-4">
<div>
<h3 className="font-semibold text-foreground mb-2">
{checklist.title || 'Título del Checklist'}
</h3>
{checklist.description && (
<p className="text-sm text-muted-foreground mb-4">{checklist.description}</p>
)}
{checklist.content?.estimatedTime && (
<p className="text-xs text-muted-foreground mb-4">
Tiempo estimado: {checklist.content.estimatedTime}
</p>
)}
</div>
<div className="space-y-2">
{checklist.content?.items?.map((item, index) => (
<div
key={item.id}
className={`flex items-center gap-4 p-3 rounded-lg border-2 ${
item.critical
? 'bg-red-500/10 border-red-500/30'
: 'bg-muted/50 border-border'
}`}
>
<div
className={`w-10 h-10 rounded-lg border-2 flex items-center justify-center flex-shrink-0 ${
item.critical
? 'bg-red-500 border-red-500 text-white'
: 'bg-card border-muted-foreground text-muted-foreground'
}`}
>
{index + 1}
</div>
<div className="flex-1">
<p className="font-medium text-foreground">{item.text || 'Item sin texto'}</p>
{item.category && (
<p className="text-xs text-muted-foreground mt-1">
Categoría: {item.category}
</p>
)}
</div>
{item.critical && (
<span className="px-2 py-1 bg-red-500/20 text-red-600 dark:text-red-400 rounded text-xs font-medium">
Crítico
</span>
)}
</div>
))}
{(!checklist.content?.items || checklist.content.items.length === 0) && (
<p className="text-sm text-muted-foreground text-center py-4">
Añade items para ver la vista previa
</p>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -1,368 +0,0 @@
/**
* Biblioteca de Contenido
*
* Vista tipo tabla con filtros para gestionar todo el contenido
*/
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Plus, Search, Filter, FileText, BookOpen, Pill, CheckSquare, Eye, Edit, Send } from 'lucide-react';
import { contentService } from '../services/content';
import { useAuth } from '../contexts/AuthContext';
import type { BaseContentItem, ContentType, ContentStatus } from '../../shared/types/content';
export default function ContentLibraryPage() {
const { hasPermission } = useAuth();
const [items, setItems] = useState<BaseContentItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
// Filtros
const [typeFilter, setTypeFilter] = useState<ContentType | 'all'>('all');
const [statusFilter, setStatusFilter] = useState<ContentStatus | 'all'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [submittingItem, setSubmittingItem] = useState<string | null>(null);
// Cargar contenido
const loadContent = async () => {
setIsLoading(true);
try {
const response = await contentService.list({
type: typeFilter !== 'all' ? typeFilter : undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
search: searchQuery || undefined,
page,
pageSize,
});
setItems(response.items || []);
setTotal(response.total || 0);
// Debug: mostrar en consola
if (response.items && response.items.length > 0) {
console.log(`✅ Cargados ${response.items.length} items de ${response.total} total`);
} else {
console.warn('⚠️ No se encontraron items. Verifica filtros y autenticación.');
}
} catch (error: unknown) {
console.error('❌ Error cargando contenido:', error);
const err = error as { response?: { data?: unknown; status?: number }; message?: string };
console.error('Detalles:', err.response?.data || err.message);
// Si es error 401, redirigir a login
if (err.response?.status === 401) {
console.warn('⚠️ Token expirado o inválido. Redirigiendo a login...');
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
window.location.href = '/login';
}
setItems([]);
setTotal(0);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadContent();
}, [typeFilter, statusFilter, searchQuery, page]);
const handleSubmitForReview = async (itemId: string) => {
if (!confirm('¿Enviar este contenido a revisión?')) return;
setSubmittingItem(itemId);
try {
const token = localStorage.getItem('admin_token');
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const response = await fetch(`${API_URL}/api/validation/submit/${itemId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({}),
});
if (response.ok) {
await loadContent();
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Error al enviar a revisión'}`);
}
} catch (error) {
console.error('Error enviando a revisión:', error);
alert('Error al enviar a revisión');
} finally {
setSubmittingItem(null);
}
};
const getTypeIcon = (type: ContentType) => {
switch (type) {
case 'protocol':
return FileText;
case 'guide':
return BookOpen;
case 'drug':
return Pill;
case 'checklist':
return CheckSquare;
default:
return FileText;
}
};
const getStatusColor = (status: ContentStatus) => {
switch (status) {
case 'published':
return 'bg-green-500/20 text-green-600 dark:text-green-400';
case 'approved':
return 'bg-blue-500/20 text-blue-600 dark:text-blue-400';
case 'in_review':
return 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400';
case 'draft':
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
default:
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
}
};
const getEditRoute = (item: BaseContentItem) => {
switch (item.type) {
case 'protocol':
return `/content/protocol/${item.id}`;
case 'checklist':
return `/content/checklist/${item.id}`;
case 'guide':
return `/content/guide/${item.id}`;
case 'drug':
return `/content/drug/${item.id}`;
default:
return '/content';
}
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Biblioteca de Contenido</h1>
<p className="text-muted-foreground mt-1">
Gestiona protocolos, guías, fármacos y checklists
</p>
</div>
<div className="flex gap-2">
{hasPermission('content:write:protocol') && (
<Link
to="/content/protocol"
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Nuevo Protocolo
</Link>
)}
{hasPermission('content:write:checklist') && (
<Link
to="/content/checklist"
className="px-4 py-2 border border-border rounded-lg hover:bg-muted transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Nuevo Checklist
</Link>
)}
</div>
</div>
{/* Filtros */}
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
<div className="flex flex-col md:flex-row gap-4">
{/* Búsqueda */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setPage(1);
}}
placeholder="Buscar por título..."
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Filtro por tipo */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" />
<select
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value as ContentType | 'all');
setPage(1);
}}
className="px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todos los tipos</option>
<option value="protocol">Protocolos</option>
<option value="guide">Guías</option>
<option value="drug">Fármacos</option>
<option value="checklist">Checklists</option>
</select>
</div>
{/* Filtro por estado */}
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value as ContentStatus | 'all');
setPage(1);
}}
className="px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todos los estados</option>
<option value="draft">Borrador</option>
<option value="in_review">En revisión</option>
<option value="approved">Aprobado</option>
<option value="published">Publicado</option>
</select>
</div>
</div>
{/* Tabla de contenido */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-muted-foreground">Cargando...</div>
) : items.length === 0 ? (
<div className="p-8 text-center space-y-2">
<p className="text-muted-foreground">
No se encontró contenido con los filtros seleccionados
</p>
<p className="text-sm text-muted-foreground">
Total en base de datos: {total} items
</p>
{(typeFilter !== 'all' || statusFilter !== 'all' || searchQuery) && (
<button
onClick={() => {
setTypeFilter('all');
setStatusFilter('all');
setSearchQuery('');
}}
className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
Limpiar filtros
</button>
)}
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Tipo</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Título</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Estado</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Versión</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Actualizado</th>
<th className="px-4 py-3 text-right text-sm font-medium text-foreground">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item) => {
const Icon = getTypeIcon(item.type);
return (
<tr key={item.id} className="hover:bg-muted/50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground capitalize">{item.type}</span>
</div>
</td>
<td className="px-4 py-3">
<div>
<div className="font-medium text-foreground">{item.title}</div>
{item.shortTitle && (
<div className="text-sm text-muted-foreground">{item.shortTitle}</div>
)}
</div>
</td>
<td className="px-4 py-3">
<span
className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(
item.status
)}`}
>
{item.status}
</span>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
v{item.version}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(item.updatedAt).toLocaleDateString('es-ES')}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
{hasPermission('content:read') && (
<Link
to={getEditRoute(item)}
className="p-2 hover:bg-muted rounded-lg transition-colors"
title="Ver/Editar"
>
<Eye className="w-4 h-4 text-muted-foreground" />
</Link>
)}
{hasPermission('content:submit') && item.status === 'draft' && (
<button
onClick={() => handleSubmitForReview(item.id)}
disabled={submittingItem === item.id}
className="p-2 hover:bg-muted rounded-lg transition-colors disabled:opacity-50"
title="Enviar a revisión"
>
<Send className={`w-4 h-4 text-muted-foreground ${submittingItem === item.id ? 'animate-pulse' : ''}`} />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Paginación */}
{total > pageSize && (
<div className="px-4 py-3 border-t border-border flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Mostrando {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} de {total}
</div>
<div className="flex gap-2">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="px-3 py-1.5 border border-border rounded-lg hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Anterior
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page * pageSize >= total}
className="px-3 py-1.5 border border-border rounded-lg hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Siguiente
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View file

@ -1,309 +0,0 @@
/**
* Página de gestión de Content Pack
*
* Permite generar, listar y descargar Content Packs
*/
import { useState, useEffect } from 'react';
import { Download, Package, RefreshCw, CheckCircle, AlertCircle } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
interface ContentPack {
filename: string;
version: string;
total_items: number;
generated_at: string;
hash: string;
size: number;
is_latest: boolean;
}
export default function ContentPackPage() {
const { hasPermission } = useAuth();
const [packs, setPacks] = useState<ContentPack[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [newVersion, setNewVersion] = useState('1.0.0');
const [includeDraft, setIncludeDraft] = useState(false);
const [notes, setNotes] = useState('');
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const loadPacks = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/admin/content-pack/list`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setPacks(data.packs || []);
} catch (error) {
console.error('Error cargando packs:', error);
setMessage({ type: 'error', text: 'Error al cargar Content Packs' });
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadPacks();
}, []);
const generatePack = async () => {
if (!newVersion) {
setMessage({ type: 'error', text: 'La versión es requerida' });
return;
}
setIsGenerating(true);
setMessage(null);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/admin/content-pack/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
version: newVersion,
includeDraft,
notes,
}),
});
const data = await response.json();
if (response.ok) {
setMessage({
type: 'success',
text: `Content Pack v${data.pack.version} generado exitosamente (${data.pack.total_items} items)`
});
setNewVersion('');
setNotes('');
await loadPacks();
} else {
setMessage({ type: 'error', text: data.error || 'Error al generar pack' });
}
} catch (error) {
console.error('Error generando pack:', error);
setMessage({ type: 'error', text: 'Error al generar Content Pack' });
} finally {
setIsGenerating(false);
}
};
const downloadPack = (pack: ContentPack) => {
const url = `${API_URL}/api/content-pack/${pack.version}.json`;
window.open(url, '_blank');
};
if (!hasPermission('content:read')) {
return (
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
No tienes permisos para ver esta página
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Content Pack</h1>
<p className="text-muted-foreground mt-1">
Genera y gestiona Content Packs para la app
</p>
</div>
{/* Mensaje */}
{message && (
<div
className={`p-4 rounded-lg border ${
message.type === 'success'
? 'bg-green-500/10 border-green-500/20 text-green-500'
: 'bg-red-500/10 border-red-500/20 text-red-500'
}`}
>
<div className="flex items-center gap-2">
{message.type === 'success' ? (
<CheckCircle className="w-5 h-5" />
) : (
<AlertCircle className="w-5 h-5" />
)}
<span>{message.text}</span>
</div>
</div>
)}
{/* Generar nuevo pack */}
{hasPermission('content:write') && (
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground flex items-center gap-2">
<Package className="w-5 h-5" />
Generar Nuevo Content Pack
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Versión (semver)
</label>
<input
type="text"
value={newVersion}
onChange={(e) => setNewVersion(e.target.value)}
placeholder="1.0.0"
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeDraft}
onChange={(e) => setIncludeDraft(e.target.checked)}
className="form-checkbox"
/>
<span className="text-sm text-muted-foreground">
Incluir borradores
</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Notas (opcional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Notas sobre esta versión..."
rows={2}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<button
onClick={generatePack}
disabled={isGenerating || !newVersion}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
{isGenerating ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Generando...
</>
) : (
<>
<Package className="w-4 h-4" />
Generar Pack
</>
)}
</button>
</div>
)}
{/* Lista de packs */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="text-xl font-semibold text-foreground flex items-center gap-2">
<Package className="w-5 h-5" />
Content Packs Generados
</h2>
<button
onClick={loadPacks}
disabled={isLoading}
className="p-2 hover:bg-muted rounded-lg transition-colors"
title="Actualizar"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
{isLoading ? (
<div className="p-8 text-center text-muted-foreground">Cargando...</div>
) : packs.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No hay Content Packs generados aún
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Versión
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Items
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Generado
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Hash
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Tamaño
</th>
<th className="px-4 py-3 text-right text-sm font-medium text-foreground">
Acciones
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{packs.map((pack) => (
<tr key={pack.filename} className="hover:bg-muted/50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">v{pack.version}</span>
{pack.is_latest && (
<span className="px-2 py-0.5 bg-primary/20 text-primary rounded text-xs font-medium">
Latest
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{pack.total_items} items
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(pack.generated_at).toLocaleString('es-ES')}
</td>
<td className="px-4 py-3">
<code className="text-xs text-muted-foreground font-mono">
{pack.hash.substring(0, 16)}...
</code>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{(pack.size / 1024).toFixed(2)} KB
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => downloadPack(pack)}
className="p-2 hover:bg-muted rounded-lg transition-colors"
title="Descargar"
>
<Download className="w-4 h-4 text-muted-foreground" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View file

@ -1,179 +0,0 @@
/**
* Dashboard principal
*/
import { useState, useEffect } from 'react';
import { useContentStats } from '../hooks/useContentStats';
import { FileText, BookOpen, Pill, CheckSquare, Users, Clock, ShieldCheck, AlertCircle } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { Link } from 'react-router-dom';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export default function DashboardPage() {
const { stats, isLoading } = useContentStats();
const { hasPermission } = useAuth();
const [validationStats, setValidationStats] = useState<Record<string, unknown> | null>(null);
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
if (hasPermission('content:validate')) {
loadValidationStats();
}
}, []);
const loadValidationStats = async () => {
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/stats/validation`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setValidationStats(data);
setPendingCount(data.pending || 0);
} catch (error) {
console.error('Error cargando estadísticas de validación:', error);
}
};
if (isLoading) {
return <div className="p-6">Cargando estadísticas...</div>;
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-1">
Resumen del contenido y actividad
</p>
</div>
{/* Notificación de contenido pendiente */}
{hasPermission('content:validate') && pendingCount > 0 && (
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-xl p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-yellow-500" />
<div>
<p className="font-medium text-foreground">
{pendingCount} contenido{pendingCount !== 1 ? 's' : ''} pendiente{pendingCount !== 1 ? 's' : ''} de validación
</p>
<p className="text-sm text-muted-foreground">
Revisa y aprueba el contenido en la página de Validación
</p>
</div>
</div>
<Link
to="/validation"
className="px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition-colors flex items-center gap-2"
>
<ShieldCheck className="w-4 h-4" />
Ir a Validación
</Link>
</div>
</div>
)}
{/* Estadísticas */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={FileText}
title="Protocolos"
value={stats.protocols}
subtitle={`${stats.protocolsPublished} publicados`}
/>
<StatCard
icon={BookOpen}
title="Guías"
value={stats.guides}
subtitle={`${stats.guidesPublished} publicadas`}
/>
<StatCard
icon={Pill}
title="Fármacos"
value={stats.drugs}
subtitle={`${stats.drugsPublished} publicados`}
/>
<StatCard
icon={CheckSquare}
title="Checklists"
value={stats.checklists}
subtitle={`${stats.checklistsPublished} publicados`}
/>
</div>
{/* Estadísticas de validación */}
{hasPermission('content:validate') && validationStats && (
<div className="bg-card border border-border rounded-xl p-6">
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
<ShieldCheck className="w-5 h-5" />
Estadísticas de Validación
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-muted-foreground">Pendientes</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.pending || 0}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Aprobados</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.byStatus?.approved || 0}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Tiempo Promedio</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.avgValidationTime
? `${validationStats.avgValidationTime} días`
: 'N/A'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Rechazos (30d)</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.rejectionsLast30Days || 0}
</p>
</div>
</div>
</div>
)}
{/* Actividad reciente */}
<div className="bg-card border border-border rounded-xl p-6">
<h2 className="text-xl font-semibold mb-4">Actividad Reciente</h2>
<div className="space-y-2 text-sm text-muted-foreground">
<p>Funcionalidad en desarrollo...</p>
</div>
</div>
</div>
);
}
function StatCard({
icon: Icon,
title,
value,
subtitle,
}: {
icon: React.ComponentType;
title: string;
value: number;
subtitle: string;
}) {
return (
<div className="bg-card border border-border rounded-xl p-6">
<div className="flex items-center justify-between mb-2">
<Icon className="w-5 h-5 text-muted-foreground" />
<span className="text-2xl font-bold text-foreground">{value}</span>
</div>
<h3 className="font-medium text-foreground">{title}</h3>
<p className="text-sm text-muted-foreground mt-1">{subtitle}</p>
</div>
);
}

View file

@ -1,647 +0,0 @@
/**
* Editor de Fármaco (Vademécum TES)
*
* Editor completo para crear/editar fármacos
* Basado en: docs/VADEMECUM_COMPLETO_TES.md
*/
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Save, X, Send, CheckCircle, Plus, Trash2 } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
interface DrugFormData {
generic_name: string;
trade_name?: string;
category: string;
line: 'first' | 'second';
frequency: 'high' | 'medium' | 'low';
presentation: string;
adult_dose: string;
pediatric_dose?: string;
routes: string[];
dilution?: string;
indications: string[];
contraindications: string[];
side_effects?: string;
antidote?: string;
notes: string[];
critical_points: string[];
source?: string;
status: 'draft' | 'in_review' | 'approved' | 'published' | 'archived';
}
const ROUTES_OPTIONS = ['IV', 'IO', 'IM', 'Subcutánea', 'Oral', 'Rectal', 'Intranasal', 'Nebulización', 'MDI'];
const CATEGORIES = [
'cardiovascular',
'respiratorio',
'neurologico',
'analgesico',
'fluidos',
'antidoto',
'hemostatico',
'diuretico',
'corticosteroide',
'antiepileptico',
'anestesico',
'metabolico',
'antiagregante',
];
export default function DrugEditorPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const isNew = id === 'new';
const [isLoading, setIsLoading] = useState(!isNew);
const [isSaving, setIsSaving] = useState(false);
const [formData, setFormData] = useState<DrugFormData>({
generic_name: '',
trade_name: '',
category: 'cardiovascular',
line: 'first',
frequency: 'high',
presentation: '',
adult_dose: '',
pediatric_dose: '',
routes: [],
dilution: '',
indications: [],
contraindications: [],
side_effects: '',
antidote: '',
notes: [],
critical_points: [],
source: '',
status: 'draft',
});
// Cargar fármaco si es edición
useEffect(() => {
if (!isNew && id) {
loadDrug(id);
}
}, [id, isNew]);
const loadDrug = async (drugId: string) => {
setIsLoading(true);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/drugs/${drugId}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const drug = await response.json();
setFormData({
generic_name: drug.generic_name || '',
trade_name: drug.trade_name || '',
category: drug.category || 'cardiovascular',
line: drug.line || 'first',
frequency: drug.frequency || 'high',
presentation: drug.presentation || '',
adult_dose: drug.adult_dose || '',
pediatric_dose: drug.pediatric_dose || '',
routes: drug.routes || [],
dilution: drug.dilution || '',
indications: drug.indications || [],
contraindications: drug.contraindications || [],
side_effects: drug.side_effects || '',
antidote: drug.antidote || '',
notes: drug.notes || [],
critical_points: drug.critical_points || [],
source: drug.source || '',
status: drug.status || 'draft',
});
} else {
alert('Error cargando fármaco');
navigate('/drugs');
}
} catch (error) {
console.error('Error cargando fármaco:', error);
alert('Error cargando fármaco');
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
setIsSaving(true);
try {
const token = localStorage.getItem('admin_token');
const url = isNew ? `${API_URL}/api/drugs` : `${API_URL}/api/drugs/${id}`;
const method = isNew ? 'POST' : 'PUT';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(formData),
});
if (response.ok) {
const data = await response.json();
if (isNew) {
navigate(`/drugs/${data.drug.id}/edit`);
} else {
alert('Fármaco guardado correctamente');
}
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Error al guardar'}\n${error.details?.join('\n') || ''}`);
}
} catch (error) {
console.error('Error guardando fármaco:', error);
alert('Error al guardar fármaco');
} finally {
setIsSaving(false);
}
};
const handleSubmit = async () => {
if (!confirm('¿Enviar este fármaco a revisión?')) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/drugs/${id}/submit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
alert('Fármaco enviado a revisión');
navigate('/drugs');
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Error al enviar a revisión'}`);
}
} catch (error) {
console.error('Error enviando a revisión:', error);
alert('Error al enviar a revisión');
}
};
const addArrayItem = (field: 'routes' | 'indications' | 'contraindications' | 'notes' | 'critical_points') => {
setFormData(prev => ({
...prev,
[field]: [...prev[field], ''],
}));
};
const updateArrayItem = (
field: 'routes' | 'indications' | 'contraindications' | 'notes' | 'critical_points',
index: number,
value: string
) => {
setFormData(prev => ({
...prev,
[field]: prev[field].map((item, i) => i === index ? value : item),
}));
};
const removeArrayItem = (
field: 'routes' | 'indications' | 'contraindications' | 'notes' | 'critical_points',
index: number
) => {
setFormData(prev => ({
...prev,
[field]: prev[field].filter((_, i) => i !== index),
}));
};
if (isLoading) {
return <div className="p-6">Cargando fármaco...</div>;
}
return (
<div className="p-6 space-y-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">
{isNew ? 'Nuevo Fármaco' : `Editar: ${formData.generic_name}`}
</h1>
<p className="text-muted-foreground mt-1">
{isNew ? 'Crear nuevo fármaco en el vademécum' : 'Editar información del fármaco'}
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => navigate('/drugs')}
className="px-4 py-2 bg-background border border-border rounded-lg hover:bg-muted transition-colors flex items-center gap-2"
>
<X className="w-4 h-4" />
Cancelar
</button>
{!isNew && hasPermission('content:submit') && formData.status === 'draft' && (
<button
onClick={handleSubmit}
className="px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors flex items-center gap-2"
>
<Send className="w-4 h-4" />
Enviar a Revisión
</button>
)}
{!isNew && hasPermission('validation:approve') && formData.status === 'in_review' && (
<button
onClick={async () => {
if (!confirm('¿Aprobar este fármaco?')) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/drugs/${id}/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ notes: '' }),
});
if (response.ok) {
alert('Fármaco aprobado');
navigate('/drugs');
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Error al aprobar'}`);
}
} catch (error) {
console.error('Error aprobando fármaco:', error);
alert('Error al aprobar fármaco');
}
}}
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors flex items-center gap-2"
>
<CheckCircle className="w-4 h-4" />
Aprobar
</button>
)}
{!isNew && hasPermission('content:publish') && formData.status === 'approved' && (
<button
onClick={async () => {
if (!confirm('¿Publicar este fármaco? (Requiere pediatric_dose)')) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/drugs/${id}/publish`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
alert('Fármaco publicado');
navigate('/drugs');
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Error al publicar'}`);
}
} catch (error) {
console.error('Error publicando fármaco:', error);
alert('Error al publicar fármaco');
}
}}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2"
>
<CheckCircle className="w-4 h-4" />
Publicar
</button>
)}
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<Save className="w-4 h-4" />
{isSaving ? 'Guardando...' : 'Guardar'}
</button>
</div>
</div>
{/* Información Básica */}
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Información Básica</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Nombre Genérico <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.generic_name}
onChange={(e) => setFormData(prev => ({ ...prev, generic_name: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: Adrenalina"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Nombre Comercial</label>
<input
type="text"
value={formData.trade_name}
onChange={(e) => setFormData(prev => ({ ...prev, trade_name: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: Adrenalina 1mg/1ml"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Categoría <span className="text-red-500">*</span>
</label>
<select
value={formData.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
{CATEGORIES.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Línea <span className="text-red-500">*</span>
</label>
<select
value={formData.line}
onChange={(e) => setFormData(prev => ({ ...prev, line: e.target.value as 'first' | 'second' }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="first">Primera línea</option>
<option value="second">Segunda línea</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Frecuencia <span className="text-red-500">*</span>
</label>
<select
value={formData.frequency}
onChange={(e) => setFormData(prev => ({ ...prev, frequency: e.target.value as 'high' | 'medium' | 'low' }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="high">Alta</option>
<option value="medium">Media</option>
<option value="low">Baja</option>
</select>
</div>
</div>
</div>
{/* Presentación y Dosificación */}
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Presentación y Dosificación</h2>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Presentación <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.presentation}
onChange={(e) => setFormData(prev => ({ ...prev, presentation: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: 1mg/1ml ampolla"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Dosis Adulto <span className="text-red-500">*</span>
</label>
<textarea
value={formData.adult_dose}
onChange={(e) => setFormData(prev => ({ ...prev, adult_dose: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
rows={2}
placeholder="Ej: 1mg IV/IO cada 3-5 min"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Dosis Pediátrica {formData.status === 'published' && <span className="text-red-500">*</span>}
</label>
<textarea
value={formData.pediatric_dose}
onChange={(e) => setFormData(prev => ({ ...prev, pediatric_dose: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
rows={2}
placeholder="Ej: 0.01mg/kg IV/IO"
/>
{formData.status === 'published' && !formData.pediatric_dose && (
<p className="text-xs text-red-500 mt-1">Obligatorio para publicar</p>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Vías de Administración</label>
<div className="flex flex-wrap gap-2">
{ROUTES_OPTIONS.map(route => (
<label key={route} className="flex items-center gap-2 px-3 py-2 bg-background border border-border rounded-lg cursor-pointer hover:bg-muted">
<input
type="checkbox"
checked={formData.routes.includes(route)}
onChange={(e) => {
if (e.target.checked) {
setFormData(prev => ({ ...prev, routes: [...prev.routes, route] }));
} else {
setFormData(prev => ({ ...prev, routes: prev.routes.filter(r => r !== route) }));
}
}}
className="rounded"
/>
<span className="text-sm">{route}</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Dilución</label>
<input
type="text"
value={formData.dilution}
onChange={(e) => setFormData(prev => ({ ...prev, dilution: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: Diluir en 20ml SF 0.9%"
/>
</div>
</div>
{/* Indicaciones y Contraindicaciones */}
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Indicaciones y Contraindicaciones</h2>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-foreground">Indicaciones</label>
<button
onClick={() => addArrayItem('indications')}
className="p-1 text-primary hover:bg-primary/10 rounded"
>
<Plus className="w-4 h-4" />
</button>
</div>
{formData.indications.map((indication, index) => (
<div key={index} className="flex gap-2 mb-2">
<input
type="text"
value={indication}
onChange={(e) => updateArrayItem('indications', index, e.target.value)}
className="flex-1 px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: Parada cardiorrespiratoria (RCP)"
/>
<button
onClick={() => removeArrayItem('indications', index)}
className="p-2 text-red-500 hover:bg-red-500/10 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-foreground">Contraindicaciones</label>
<button
onClick={() => addArrayItem('contraindications')}
className="p-1 text-primary hover:bg-primary/10 rounded"
>
<Plus className="w-4 h-4" />
</button>
</div>
{formData.contraindications.map((contraindication, index) => (
<div key={index} className="flex gap-2 mb-2">
<input
type="text"
value={contraindication}
onChange={(e) => updateArrayItem('contraindications', index, e.target.value)}
className="flex-1 px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: Hipertensión arterial severa"
/>
<button
onClick={() => removeArrayItem('contraindications', index)}
className="p-2 text-red-500 hover:bg-red-500/10 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Efectos Adversos</label>
<textarea
value={formData.side_effects}
onChange={(e) => setFormData(prev => ({ ...prev, side_effects: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
rows={3}
placeholder="Ej: Taquicardia, hipertensión, arritmias..."
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Antídoto</label>
<input
type="text"
value={formData.antidote}
onChange={(e) => setFormData(prev => ({ ...prev, antidote: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: Naloxona (para opioides)"
/>
</div>
</div>
{/* Información Específica TES */}
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Información Específica TES</h2>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-foreground">Notas</label>
<button
onClick={() => addArrayItem('notes')}
className="p-1 text-primary hover:bg-primary/10 rounded"
>
<Plus className="w-4 h-4" />
</button>
</div>
{formData.notes.map((note, index) => (
<div key={index} className="flex gap-2 mb-2">
<textarea
value={note}
onChange={(e) => updateArrayItem('notes', index, e.target.value)}
className="flex-1 px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
rows={2}
placeholder="Ej: En RCP, administrar cada 3-5 minutos"
/>
<button
onClick={() => removeArrayItem('notes', index)}
className="p-2 text-red-500 hover:bg-red-500/10 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-foreground">Puntos Críticos TES</label>
<button
onClick={() => addArrayItem('critical_points')}
className="p-1 text-primary hover:bg-primary/10 rounded"
>
<Plus className="w-4 h-4" />
</button>
</div>
{formData.critical_points.map((point, index) => (
<div key={index} className="flex gap-2 mb-2">
<textarea
value={point}
onChange={(e) => updateArrayItem('critical_points', index, e.target.value)}
className="flex-1 px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
rows={2}
placeholder="Ej: Verificar dosis según peso en pediatría"
/>
<button
onClick={() => removeArrayItem('critical_points', index)}
className="p-2 text-red-500 hover:bg-red-500/10 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">Fuente</label>
<input
type="text"
value={formData.source}
onChange={(e) => setFormData(prev => ({ ...prev, source: e.target.value }))}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: Manual TES Digital, ERC 2021"
/>
</div>
</div>
</div>
);
}

View file

@ -1,425 +0,0 @@
/**
* Manager de Vademécum TES
*
* Gestión completa de fármacos del vademécum
* Basado en: docs/VADEMECUM_COMPLETO_TES.md
*/
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Plus, Search, Filter, Pill, Eye, Edit, Send, CheckCircle, XCircle, Clock, FileSpreadsheet } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
interface Drug {
id: string;
slug: string;
generic_name: string;
trade_name?: string;
category: string;
line: 'first' | 'second';
frequency: 'high' | 'medium' | 'low';
presentation: string;
adult_dose: string;
pediatric_dose?: string;
routes: string[];
dilution?: string;
indications: string[];
contraindications: string[];
side_effects?: string;
antidote?: string;
notes: string[];
critical_points: string[];
source?: string;
status: 'draft' | 'submitted' | 'in_review' | 'approved' | 'published' | 'archived';
version: string;
created_at: string;
updated_at: string;
}
export default function DrugManagerPage() {
const { hasPermission } = useAuth();
const [drugs, setDrugs] = useState<Drug[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
// Filtros
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [lineFilter, setLineFilter] = useState<'first' | 'second' | 'all'>('all');
const [frequencyFilter, setFrequencyFilter] = useState<'high' | 'medium' | 'low' | 'all'>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [submittingId, setSubmittingId] = useState<string | null>(null);
// Cargar fármacos
const loadDrugs = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('admin_token');
const params = new URLSearchParams({
page: page.toString(),
limit: pageSize.toString(),
});
if (categoryFilter !== 'all') params.append('category', categoryFilter);
if (lineFilter !== 'all') params.append('line', lineFilter);
if (frequencyFilter !== 'all') params.append('frequency', frequencyFilter);
if (statusFilter !== 'all') params.append('status', statusFilter);
if (searchQuery) params.append('search', searchQuery);
const response = await fetch(`${API_URL}/api/drugs?${params}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setDrugs(data.drugs || []);
setTotal(data.pagination?.total || 0);
} else {
console.error('Error cargando fármacos');
}
} catch (error) {
console.error('Error cargando fármacos:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadDrugs();
}, [categoryFilter, lineFilter, frequencyFilter, statusFilter, searchQuery, page]);
const handleSubmit = async (drugId: string) => {
if (!confirm('¿Enviar este fármaco a revisión?')) return;
setSubmittingId(drugId);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/drugs/${drugId}/submit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
await loadDrugs();
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Error al enviar a revisión'}`);
}
} catch (error) {
console.error('Error enviando a revisión:', error);
alert('Error al enviar a revisión');
} finally {
setSubmittingId(null);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'published':
return 'bg-green-500/20 text-green-600 dark:text-green-400';
case 'approved':
return 'bg-blue-500/20 text-blue-600 dark:text-blue-400';
case 'in_review':
return 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400';
case 'draft':
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
case 'archived':
return 'bg-red-500/20 text-red-600 dark:text-red-400';
default:
return 'bg-gray-500/20 text-gray-600 dark:text-gray-400';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'published':
return CheckCircle;
case 'approved':
return CheckCircle;
case 'in_review':
return Clock;
case 'draft':
return Edit;
case 'archived':
return XCircle;
default:
return Edit;
}
};
const getFrequencyBadge = (frequency: string) => {
const colors = {
high: 'bg-red-500/20 text-red-600 dark:text-red-400',
medium: 'bg-orange-500/20 text-orange-600 dark:text-orange-400',
low: 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400',
};
return colors[frequency as keyof typeof colors] || 'bg-gray-500/20 text-gray-600';
};
const getLineBadge = (line: string) => {
return line === 'first'
? 'bg-purple-500/20 text-purple-600 dark:text-purple-400'
: 'bg-indigo-500/20 text-indigo-600 dark:text-indigo-400';
};
const categories = [
'cardiovascular',
'respiratorio',
'neurologico',
'analgesico',
'fluidos',
'antidoto',
'hemostatico',
'diuretico',
'corticosteroide',
'antiepileptico',
'anestesico',
'metabolico',
'antiagregante',
];
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Vademécum TES</h1>
<p className="text-muted-foreground mt-1">
Gestión de fármacos del vademécum (35 fármacos)
</p>
</div>
{hasPermission('content:create') && (
<Link
to="/drugs/new"
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Nuevo Fármaco
</Link>
)}
</div>
{/* Filtros */}
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
<div className="flex flex-wrap gap-4">
{/* Búsqueda avanzada */}
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Buscar por nombre genérico, comercial, categoría..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Filtro categoría */}
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todas las categorías</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
{/* Filtro línea */}
<select
value={lineFilter}
onChange={(e) => setLineFilter(e.target.value as any)}
className="px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todas las líneas</option>
<option value="first">Primera línea</option>
<option value="second">Segunda línea</option>
</select>
{/* Filtro frecuencia */}
<select
value={frequencyFilter}
onChange={(e) => setFrequencyFilter(e.target.value as any)}
className="px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todas las frecuencias</option>
<option value="high">Alta</option>
<option value="medium">Media</option>
<option value="low">Baja</option>
</select>
{/* Filtro estado */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todos los estados</option>
<option value="draft">Borrador</option>
<option value="in_review">En revisión</option>
<option value="approved">Aprobado</option>
<option value="published">Publicado</option>
<option value="archived">Archivado</option>
</select>
</div>
</div>
{/* Estadísticas rápidas */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<div className="text-sm text-muted-foreground">Total Fármacos</div>
<div className="text-2xl font-bold text-foreground">{total}</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="text-sm text-muted-foreground">Publicados</div>
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{drugs.filter(d => d.status === 'published').length}
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="text-sm text-muted-foreground">En Revisión</div>
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{drugs.filter(d => d.status === 'in_review').length}
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="text-sm text-muted-foreground">Borradores</div>
<div className="text-2xl font-bold text-gray-600 dark:text-gray-400">
{drugs.filter(d => d.status === 'draft').length}
</div>
</div>
</div>
{/* Tabla de fármacos */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-muted-foreground">Cargando fármacos...</div>
) : drugs.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">No se encontraron fármacos</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Fármaco</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Categoría</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Línea</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Frecuencia</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Estado</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Versión</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{drugs.map((drug) => {
const StatusIcon = getStatusIcon(drug.status);
return (
<tr key={drug.id} className="hover:bg-muted/30">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Pill className="w-4 h-4 text-muted-foreground" />
<div>
<div className="font-medium text-foreground">{drug.generic_name}</div>
{drug.trade_name && (
<div className="text-sm text-muted-foreground">{drug.trade_name}</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-foreground capitalize">{drug.category}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${getLineBadge(drug.line)}`}>
{drug.line === 'first' ? '1ª Línea' : '2ª Línea'}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium ${getFrequencyBadge(drug.frequency)}`}>
{drug.frequency === 'high' ? 'Alta' : drug.frequency === 'medium' ? 'Media' : 'Baja'}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-medium flex items-center gap-1 w-fit ${getStatusColor(drug.status)}`}>
<StatusIcon className="w-3 h-3" />
{drug.status}
</span>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{drug.version}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Link
to={`/drugs/${drug.id}`}
className="p-1.5 text-muted-foreground hover:text-foreground transition-colors"
title="Ver detalles"
>
<Eye className="w-4 h-4" />
</Link>
{hasPermission('content:edit') && (
<Link
to={`/drugs/${drug.id}/edit`}
className="p-1.5 text-muted-foreground hover:text-foreground transition-colors"
title="Editar"
>
<Edit className="w-4 h-4" />
</Link>
)}
{hasPermission('content:submit') && drug.status === 'draft' && (
<button
onClick={() => handleSubmit(drug.id)}
disabled={submittingId === drug.id}
className="p-1.5 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
title="Enviar a revisión"
>
<Send className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Paginación */}
{total > pageSize && (
<div className="px-4 py-3 border-t border-border flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Mostrando {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} de {total}
</div>
<div className="flex gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 bg-background border border-border rounded hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Anterior
</button>
<button
onClick={() => setPage(p => p + 1)}
disabled={page * pageSize >= total}
className="px-3 py-1 bg-background border border-border rounded hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Siguiente
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View file

@ -1,581 +0,0 @@
/**
* Editor de Guía Formativa
*
* Permite crear y editar guías formativas con:
* - 8 secciones configurables
* - Asociación de recursos multimedia
* - Enlaces a protocolos operativos
* - Exportación SCORM
*/
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Save,
Plus,
Trash2,
ArrowLeft,
BookOpen,
Image as ImageIcon,
Link2,
Eye,
Download,
Package,
} from 'lucide-react';
import { contentService } from '../services/content';
import { useAuth } from '../contexts/AuthContext';
import ResourcesManager from '../components/content/ResourcesManager';
import ValidationHistory from '../components/content/ValidationHistory';
interface GuideSection {
id: string;
title: string;
content: string; // Markdown
order: number;
}
export default function GuideEditorPage() {
const { id } = useParams<{ id?: string }>();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const isEdit = !!id;
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [activeTab, setActiveTab] = useState<'basic' | 'sections' | 'resources' | 'links'>('basic');
const [associatedResources, setAssociatedResources] = useState<any[]>([]);
const [showResourceSelector, setShowResourceSelector] = useState(false);
const [isGeneratingSCORM, setIsGeneratingSCORM] = useState(false);
const [scormMessage, setScormMessage] = useState<{ type: 'success' | 'error'; text: string; downloadUrl?: string } | null>(null);
// Estado de la guía
const [guide, setGuide] = useState({
id: id || '',
type: 'guide',
level: 'formativo',
title: '',
shortTitle: '',
description: '',
icono: 'book',
scormAvailable: false,
protocoloOperativo: null as string | null,
secciones: [] as GuideSection[],
status: 'draft',
priority: 'media',
});
// Cargar guía existente
useEffect(() => {
if (isEdit && id) {
setIsLoading(true);
contentService
.getById(id)
.then((data: any) => {
setGuide({
...data,
secciones: data.content?.secciones || [],
protocoloOperativo: data.content?.protocoloOperativo || null,
});
// Cargar recursos asociados
loadAssociatedResources(id);
})
.catch((error) => {
console.error('Error cargando guía:', error);
setErrors({ general: 'Error al cargar la guía' });
})
.finally(() => setIsLoading(false));
}
}, [id, isEdit]);
const loadAssociatedResources = async (contentId: string) => {
try {
const token = localStorage.getItem('admin_token');
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const response = await fetch(`${API_URL}/api/content/${contentId}/resources`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setAssociatedResources(data.associations || []);
} catch (error) {
console.error('Error cargando recursos asociados:', error);
}
};
const handleSave = async () => {
if (!guide.title) {
setErrors({ title: 'El título es requerido' });
return;
}
setIsSaving(true);
setErrors({});
try {
const contentData = {
icono: guide.icono,
scormAvailable: guide.scormAvailable,
secciones: guide.secciones,
protocoloOperativo: guide.protocoloOperativo,
};
if (isEdit && id) {
await contentService.update(id, {
title: guide.title,
shortTitle: guide.shortTitle,
description: guide.description,
level: guide.level,
status: guide.status,
priority: guide.priority,
content: contentData,
});
} else {
await contentService.create({
type: 'guide',
title: guide.title,
shortTitle: guide.shortTitle,
description: guide.description,
level: guide.level,
status: guide.status,
priority: guide.priority,
content: contentData,
});
}
navigate('/content');
} catch (error: any) {
console.error('Error guardando guía:', error);
setErrors({ general: error.message || 'Error al guardar la guía' });
} finally {
setIsSaving(false);
}
};
const handleGenerateSCORM = async () => {
if (!id) return;
setIsGeneratingSCORM(true);
setScormMessage(null);
try {
const token = localStorage.getItem('admin_token');
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const response = await fetch(`${API_URL}/api/scorm/generate/${id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ version: guide.version || '1.0.0' }),
});
const data = await response.json();
if (response.ok) {
setScormMessage({
type: 'success',
text: `Paquete SCORM generado exitosamente (${(data.size / 1024 / 1024).toFixed(2)} MB)`,
downloadUrl: `${API_URL}${data.downloadUrl}`,
});
} else {
setScormMessage({
type: 'error',
text: data.error || 'Error al generar paquete SCORM',
});
}
} catch (error) {
console.error('Error generando SCORM:', error);
setScormMessage({
type: 'error',
text: 'Error al generar paquete SCORM',
});
} finally {
setIsGeneratingSCORM(false);
}
};
const addSection = () => {
const newSection: GuideSection = {
id: `section-${Date.now()}`,
title: `Sección ${guide.secciones.length + 1}`,
content: '',
order: guide.secciones.length + 1,
};
setGuide({
...guide,
secciones: [...guide.secciones, newSection],
});
};
const updateSection = (sectionId: string, updates: Partial<GuideSection>) => {
setGuide({
...guide,
secciones: guide.secciones.map((s) =>
s.id === sectionId ? { ...s, ...updates } : s
),
});
};
const removeSection = (sectionId: string) => {
setGuide({
...guide,
secciones: guide.secciones.filter((s) => s.id !== sectionId),
});
};
const reorderSection = (sectionId: string, direction: 'up' | 'down') => {
const index = guide.secciones.findIndex((s) => s.id === sectionId);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= guide.secciones.length) return;
const newSections = [...guide.secciones];
[newSections[index], newSections[newIndex]] = [
newSections[newIndex],
newSections[index],
];
newSections.forEach((s, i) => {
s.order = i + 1;
});
setGuide({ ...guide, secciones: newSections });
};
if (isLoading) {
return (
<div className="p-6">
<div className="text-center text-muted-foreground">Cargando guía...</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<button
onClick={() => navigate('/content')}
className="mb-4 flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Volver
</button>
<h1 className="text-3xl font-bold text-foreground">
{isEdit ? 'Editar Guía' : 'Nueva Guía'}
</h1>
<p className="text-muted-foreground mt-1">
{isEdit ? 'Modifica la guía formativa' : 'Crea una nueva guía formativa'}
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowPreview(!showPreview)}
className="px-4 py-2 border border-border rounded-lg hover:bg-muted transition-colors flex items-center gap-2"
>
<Eye className="w-4 h-4" />
{showPreview ? 'Ocultar' : 'Mostrar'} Preview
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<Save className="w-4 h-4" />
{isSaving ? 'Guardando...' : 'Guardar'}
</button>
</div>
</div>
{errors.general && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
{errors.general}
</div>
)}
{scormMessage && (
<div
className={`p-4 rounded-lg border ${
scormMessage.type === 'success'
? 'bg-green-500/10 border-green-500/20 text-green-500'
: 'bg-red-500/10 border-red-500/20 text-red-500'
}`}
>
<div className="flex items-center justify-between">
<span>{scormMessage.text}</span>
{scormMessage.type === 'success' && (
<a
href={scormMessage.downloadUrl}
download
className="ml-4 px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600 transition-colors flex items-center gap-2 text-sm"
>
<Download className="w-4 h-4" />
Descargar
</a>
)}
<button
onClick={() => setScormMessage(null)}
className="ml-4 text-current opacity-70 hover:opacity-100"
>
×
</button>
</div>
</div>
)}
<div className="space-y-6">
{/* Tabs */}
<div className="bg-card border border-border rounded-xl p-2">
<div className="flex gap-2 overflow-x-auto">
{[
{ id: 'basic', label: 'Básico' },
{ id: 'sections', label: 'Secciones' },
{ id: 'resources', label: 'Recursos' },
{ id: 'links', label: 'Enlaces' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === tab.id
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Tab: Básico */}
{activeTab === 'basic' && (
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Título *
</label>
<input
type="text"
value={guide.title}
onChange={(e) => setGuide({ ...guide, title: e.target.value })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Ej: ABCDE Operativo"
/>
{errors.title && (
<p className="text-sm text-red-500 mt-1">{errors.title}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Título Corto
</label>
<input
type="text"
value={guide.shortTitle}
onChange={(e) => setGuide({ ...guide, shortTitle: e.target.value })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="ABCDE"
/>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Descripción
</label>
<textarea
value={guide.description}
onChange={(e) => setGuide({ ...guide, description: e.target.value })}
rows={3}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Descripción de la guía formativa"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Estado
</label>
<select
value={guide.status}
onChange={(e) => setGuide({ ...guide, status: e.target.value })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="draft">Borrador</option>
<option value="in_review">En Revisión</option>
<option value="approved">Aprobado</option>
<option value="published">Publicado</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Prioridad
</label>
<select
value={guide.priority}
onChange={(e) => setGuide({ ...guide, priority: e.target.value })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="critica">Crítica</option>
<option value="alta">Alta</option>
<option value="media">Media</option>
<option value="baja">Baja</option>
</select>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="scormAvailable"
checked={guide.scormAvailable}
onChange={(e) => setGuide({ ...guide, scormAvailable: e.target.checked })}
className="form-checkbox"
/>
<label htmlFor="scormAvailable" className="text-sm text-muted-foreground">
Disponible para exportación SCORM
</label>
</div>
</div>
)}
{/* Tab: Secciones */}
{activeTab === 'sections' && (
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-foreground">Secciones de la Guía</h2>
<button
onClick={addSection}
className="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2 text-sm"
>
<Plus className="w-4 h-4" />
Añadir Sección
</button>
</div>
<div className="space-y-4">
{guide.secciones.map((section, index) => (
<div
key={section.id}
className="bg-muted/50 border border-border rounded-lg p-4 space-y-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">
Sección {section.order}
</span>
<button
onClick={() => reorderSection(section.id, 'up')}
disabled={index === 0}
className="p-1 hover:bg-muted rounded disabled:opacity-50"
title="Mover arriba"
>
</button>
<button
onClick={() => reorderSection(section.id, 'down')}
disabled={index === guide.secciones.length - 1}
className="p-1 hover:bg-muted rounded disabled:opacity-50"
title="Mover abajo"
>
</button>
</div>
<button
onClick={() => removeSection(section.id)}
className="p-2 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">Título</label>
<input
type="text"
value={section.title}
onChange={(e) => updateSection(section.id, { title: e.target.value })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-sm"
placeholder="Título de la sección"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">
Contenido (Markdown)
</label>
<textarea
value={section.content}
onChange={(e) => updateSection(section.id, { content: e.target.value })}
rows={8}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-sm font-mono"
placeholder="Escribe el contenido en Markdown..."
/>
</div>
</div>
))}
{guide.secciones.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<p>No hay secciones. Haz clic en "Añadir Sección" para comenzar.</p>
</div>
)}
</div>
</div>
)}
{/* Tab: Recursos */}
{activeTab === 'resources' && (
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-foreground">Recursos Multimedia</h2>
<button
onClick={() => setShowResourceSelector(true)}
className="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2 text-sm"
>
<Plus className="w-4 h-4" />
Asociar Recurso
</button>
</div>
<ResourcesManager
contentId={id || ''}
resources={associatedResources}
onResourcesChange={setAssociatedResources}
showSelector={showResourceSelector}
onCloseSelector={() => setShowResourceSelector(false)}
/>
</div>
)}
{/* Tab: Enlaces */}
{activeTab === 'links' && (
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Enlaces a Protocolos</h2>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Protocolo Operativo Asociado
</label>
<input
type="text"
value={guide.protocoloOperativo || ''}
onChange={(e) => setGuide({ ...guide, protocoloOperativo: e.target.value || null })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="ID del protocolo (ej: rcp-adulto-svb)"
/>
<p className="text-xs text-muted-foreground mt-1">
ID del protocolo operativo relacionado con esta guía formativa
</p>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -1,112 +0,0 @@
/**
* Página de login
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { LogIn, AlertCircle } from 'lucide-react';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login({ email, password });
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.error || 'Error al iniciar sesión');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="bg-card border border-border rounded-xl p-8 space-y-6">
{/* Header */}
<div className="text-center space-y-2">
<div className="w-16 h-16 bg-primary/20 rounded-xl flex items-center justify-center mx-auto">
<LogIn className="w-8 h-8 text-primary" />
</div>
<h1 className="text-2xl font-bold text-foreground">
Admin Panel
</h1>
<p className="text-muted-foreground">
EMERGES TES - Gestión de Contenido
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-3 flex items-center gap-2 text-destructive">
<AlertCircle className="w-4 h-4" />
<span className="text-sm">{error}</span>
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="admin@emerges-tes.local"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Contraseña
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Iniciando sesión...' : 'Iniciar Sesión'}
</button>
</form>
{/* Credenciales por defecto */}
<div className="pt-4 border-t border-border">
<p className="text-xs text-muted-foreground text-center">
Credenciales por defecto:
</p>
<p className="text-xs text-muted-foreground text-center mt-1">
admin@emerges-tes.local / Admin123!
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,520 +0,0 @@
/**
* Gestor de Recursos Multimedia
*
* Permite subir, gestionar y asociar imágenes/vídeos a contenido
*/
import { useState, useEffect } from 'react';
import { Upload, Image, Video, Trash2, Search, Filter, Link2, AlertCircle } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
interface MediaResource {
id: string;
type: 'image' | 'video';
file_url: string;
title: string;
description?: string;
alt_text?: string;
tags?: string[];
priority: string;
status: string;
file_size: number;
format: string;
created_at: string;
}
export default function MediaManagerPage() {
const { hasPermission } = useAuth();
const [resources, setResources] = useState<MediaResource[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [typeFilter, setTypeFilter] = useState<'all' | 'image' | 'video'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [orphanedCount, setOrphanedCount] = useState(0);
const [loadError, setLoadError] = useState<string | null>(null);
const [showUpload, setShowUpload] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadValidationDetails, setUploadValidationDetails] = useState<{ path: string; message: string }[] | null>(null);
const [uploadData, setUploadData] = useState({
title: '',
description: '',
alt_text: '',
tags: '',
priority: 'media',
});
const loadResources = async () => {
setIsLoading(true);
setLoadError(null);
try {
const token = localStorage.getItem('admin_token');
const params = new URLSearchParams({
page: page.toString(),
pageSize: pageSize.toString(),
});
if (typeFilter !== 'all') params.append('type', typeFilter);
if (searchQuery) params.append('search', searchQuery);
const response = await fetch(`${API_URL}/api/media?${params}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
if (!response.ok) {
setLoadError(data.error || 'Error al cargar recursos');
return;
}
setResources(data.items || []);
setTotal(data.total || 0);
} catch (error) {
setLoadError('Error de conexión al cargar recursos');
} finally {
setIsLoading(false);
}
};
const loadOrphanedCount = async () => {
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/media/orphaned/list`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setOrphanedCount(data.total || 0);
} catch (error) {
console.error('Error cargando recursos huérfanos:', error);
}
};
useEffect(() => {
loadResources();
loadOrphanedCount();
}, [page, typeFilter, searchQuery]);
const handleUpload = async () => {
if (!uploadFile) return;
setIsUploading(true);
setUploadError(null);
setUploadValidationDetails(null);
try {
const token = localStorage.getItem('admin_token');
const formData = new FormData();
formData.append('file', uploadFile);
formData.append('title', uploadData.title || uploadFile.name);
formData.append('description', uploadData.description);
formData.append('alt_text', uploadData.alt_text);
formData.append('tags', uploadData.tags);
formData.append('priority', uploadData.priority);
const response = await fetch(`${API_URL}/api/media/upload`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
const data = await response.json();
if (response.ok) {
setShowUpload(false);
setUploadFile(null);
setUploadData({
title: '',
description: '',
alt_text: '',
tags: '',
priority: 'media',
});
await loadResources();
await loadOrphanedCount();
} else {
setUploadError(data.error || 'Error al subir archivo');
if (Array.isArray(data.details)) {
setUploadValidationDetails(data.details);
} else {
setUploadValidationDetails(null);
}
}
} catch (error) {
setUploadError('Error de conexión al subir archivo');
setUploadValidationDetails(null);
} finally {
setIsUploading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('¿Estás seguro de eliminar este recurso?')) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/media/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
await loadResources();
await loadOrphanedCount();
} else {
alert('Error al eliminar recurso');
}
} catch (error) {
console.error('Error eliminando recurso:', error);
alert('Error al eliminar recurso');
}
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
if (!hasPermission('content:read')) {
return (
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
No tienes permisos para ver esta página
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Recursos Multimedia</h1>
<p className="text-muted-foreground mt-1">
Gestiona imágenes y vídeos para el contenido
</p>
</div>
{hasPermission('content:write') && (
<button
onClick={() => setShowUpload(!showUpload)}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center gap-2"
>
<Upload className="w-4 h-4" />
Subir Recurso
</button>
)}
</div>
{/* Alerta recursos huérfanos */}
{orphanedCount > 0 && (
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-4 flex items-center gap-2 text-yellow-500">
<AlertCircle className="w-5 h-5" />
<span>
{orphanedCount} recurso{orphanedCount !== 1 ? 's' : ''} huérfano{orphanedCount !== 1 ? 's' : ''}
(sin asociar a contenido)
</span>
</div>
)}
{/* Formulario de upload */}
{showUpload && hasPermission('content:write') && (
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<h2 className="text-xl font-semibold text-foreground">Subir Nuevo Recurso</h2>
{uploadError && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 space-y-2" role="alert">
<p className="text-red-500 font-medium">{uploadError}</p>
{uploadValidationDetails && uploadValidationDetails.length > 0 && (
<ul className="text-sm text-red-500/90 list-disc list-inside">
{uploadValidationDetails.map((d, i) => (
<li key={i}>{d.path ? `${d.path}: ` : ''}{d.message}</li>
))}
</ul>
)}
</div>
)}
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Archivo *
</label>
<input
type="file"
accept="image/*,video/*"
onChange={(e) => setUploadFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Título
</label>
<input
type="text"
value={uploadData.title}
onChange={(e) => setUploadData({ ...uploadData, title: e.target.value })}
placeholder="Título del recurso"
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Prioridad
</label>
<select
value={uploadData.priority}
onChange={(e) => setUploadData({ ...uploadData, priority: e.target.value })}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="critica">Crítica</option>
<option value="alta">Alta</option>
<option value="media">Media</option>
<option value="baja">Baja</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Descripción
</label>
<textarea
value={uploadData.description}
onChange={(e) => setUploadData({ ...uploadData, description: e.target.value })}
placeholder="Descripción del recurso"
rows={2}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Alt Text (para imágenes)
</label>
<input
type="text"
value={uploadData.alt_text}
onChange={(e) => setUploadData({ ...uploadData, alt_text: e.target.value })}
placeholder="Texto alternativo"
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Tags (separados por comas)
</label>
<input
type="text"
value={uploadData.tags}
onChange={(e) => setUploadData({ ...uploadData, tags: e.target.value })}
placeholder="tag1, tag2, tag3"
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="flex gap-2">
<button
onClick={handleUpload}
disabled={!uploadFile || isUploading}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isUploading ? 'Subiendo...' : 'Subir'}
</button>
<button
onClick={() => {
setShowUpload(false);
setUploadFile(null);
setUploadError(null);
setUploadValidationDetails(null);
setUploadData({
title: '',
description: '',
alt_text: '',
tags: '',
priority: 'media',
});
}}
className="px-4 py-2 border border-border rounded-lg hover:bg-muted transition-colors"
>
Cancelar
</button>
</div>
</div>
)}
{/* Filtros */}
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setPage(1);
}}
placeholder="Buscar por título, descripción..."
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" />
<select
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value as 'all' | 'image' | 'video');
setPage(1);
}}
className="px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todos los tipos</option>
<option value="image">Imágenes</option>
<option value="video">Vídeos</option>
</select>
</div>
</div>
</div>
{/* Lista de recursos */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
{loadError ? (
<div className="p-8 text-center space-y-4">
<p className="text-red-500">{loadError}</p>
<button
onClick={() => loadResources()}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
Reintentar
</button>
</div>
) : isLoading ? (
<div className="p-8 text-center text-muted-foreground">Cargando...</div>
) : resources.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No se encontraron recursos con los filtros seleccionados
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
{resources.map((resource) => (
<div
key={resource.id}
className="border border-border rounded-lg overflow-hidden hover:shadow-lg transition-shadow"
>
<div className="aspect-video bg-muted flex items-center justify-center relative">
{resource.type === 'image' ? (
<img
src={`${API_URL}${resource.file_url}`}
alt={resource.alt_text || resource.title}
className="w-full h-full object-cover"
/>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Video className="w-12 h-12" />
<span className="text-sm">Vídeo</span>
</div>
)}
<div className="absolute top-2 right-2">
{resource.type === 'image' ? (
<Image className="w-4 h-4 text-white drop-shadow" />
) : (
<Video className="w-4 h-4 text-white drop-shadow" />
)}
</div>
</div>
<div className="p-4 space-y-2">
<h3 className="font-medium text-foreground truncate">{resource.title}</h3>
{resource.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{resource.description}
</p>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{formatFileSize(resource.file_size)}</span>
<span>{resource.format.toUpperCase()}</span>
</div>
{resource.tags && resource.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{resource.tags.slice(0, 3).map((tag, i) => (
<span
key={i}
className="px-2 py-0.5 bg-muted text-xs rounded text-muted-foreground"
>
{tag}
</span>
))}
</div>
)}
<div className="flex gap-2 pt-2">
<button
onClick={() => window.open(`${API_URL}${resource.file_url}`, '_blank')}
className="flex-1 px-3 py-1.5 border border-border rounded-lg hover:bg-muted transition-colors text-sm flex items-center justify-center gap-1"
>
<Link2 className="w-3 h-3" />
Ver
</button>
{hasPermission('content:write') && (
<button
onClick={() => handleDelete(resource.id)}
className="px-3 py-1.5 border border-red-500/20 text-red-500 rounded-lg hover:bg-red-500/10 transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
))}
</div>
{/* Paginación */}
{total > pageSize && (
<div className="px-4 py-3 border-t border-border flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Mostrando {((page - 1) * pageSize) + 1} - {Math.min(page * pageSize, total)} de {total} recursos
</div>
<div className="flex gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 border border-border rounded-lg hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Anterior
</button>
<span className="px-3 py-1 text-sm text-muted-foreground flex items-center">
Página {page} de {Math.ceil(total / pageSize)}
</span>
<button
onClick={() => setPage(p => Math.min(Math.ceil(total / pageSize), p + 1))}
disabled={page >= Math.ceil(total / pageSize)}
className="px-3 py-1 border border-border rounded-lg hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Siguiente
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,549 +0,0 @@
/**
* Página de Validación de Contenido
*
* Permite a revisores y validadores aprobar/rechazar contenido
*/
import { useState, useEffect } from 'react';
import { CheckCircle, XCircle, Send, Eye, Clock, AlertCircle, BarChart3 } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
interface PendingContent {
id: string;
type: string;
slug: string;
title: string;
shortTitle?: string;
description?: string;
status: string;
priority: string;
level: string;
created_at: string;
updated_at: string;
created_by_username?: string;
updated_by_username?: string;
}
export default function ValidationPage() {
const { hasPermission, user } = useAuth();
const navigate = useNavigate();
const [pendingItems, setPendingItems] = useState<PendingContent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [typeFilter, setTypeFilter] = useState<string>('all');
const [priorityFilter, setPriorityFilter] = useState<string>('all');
const [selectedItem, setSelectedItem] = useState<PendingContent | null>(null);
const [actionNotes, setActionNotes] = useState('');
const [publishOnApprove, setPublishOnApprove] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [validationStats, setValidationStats] = useState<any>(null);
const [isLoadingStats, setIsLoadingStats] = useState(false);
useEffect(() => {
loadPendingItems();
if (hasPermission('content:validate')) {
loadValidationStats();
}
}, [typeFilter, priorityFilter]);
const loadValidationStats = async () => {
setIsLoadingStats(true);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/stats/validation`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setValidationStats(data);
} catch (error) {
console.error('Error cargando estadísticas:', error);
} finally {
setIsLoadingStats(false);
}
};
const loadPendingItems = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('admin_token');
const params = new URLSearchParams();
if (typeFilter !== 'all') params.append('type', typeFilter);
if (priorityFilter !== 'all') params.append('priority', priorityFilter);
const response = await fetch(`${API_URL}/api/validation/pending?${params}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await response.json();
setPendingItems(data.items || []);
} catch (error) {
console.error('Error cargando contenido pendiente:', error);
setMessage({ type: 'error', text: 'Error al cargar contenido pendiente' });
} finally {
setIsLoading(false);
}
};
const handleApprove = async () => {
if (!selectedItem) return;
setIsProcessing(true);
setMessage(null);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/validation/approve/${selectedItem.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
notes: actionNotes,
publish: publishOnApprove,
}),
});
const data = await response.json();
if (response.ok) {
setMessage({ type: 'success', text: data.message });
setSelectedItem(null);
setActionNotes('');
setPublishOnApprove(false);
await loadPendingItems();
} else {
setMessage({ type: 'error', text: data.error || 'Error al aprobar contenido' });
}
} catch (error) {
console.error('Error aprobando contenido:', error);
setMessage({ type: 'error', text: 'Error al aprobar contenido' });
} finally {
setIsProcessing(false);
}
};
const handleReject = async () => {
if (!selectedItem) return;
if (!actionNotes.trim()) {
setMessage({ type: 'error', text: 'Las notas de rechazo son obligatorias' });
return;
}
setIsProcessing(true);
setMessage(null);
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`${API_URL}/api/validation/reject/${selectedItem.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
notes: actionNotes,
}),
});
const data = await response.json();
if (response.ok) {
setMessage({ type: 'success', text: data.message });
setSelectedItem(null);
setActionNotes('');
await loadPendingItems();
} else {
setMessage({ type: 'error', text: data.error || 'Error al rechazar contenido' });
}
} catch (error) {
console.error('Error rechazando contenido:', error);
setMessage({ type: 'error', text: 'Error al rechazar contenido' });
} finally {
setIsProcessing(false);
}
};
const getPriorityColor = (priority: string) => {
const colors = {
critica: 'bg-red-500/20 text-red-500',
alta: 'bg-orange-500/20 text-orange-500',
media: 'bg-yellow-500/20 text-yellow-500',
baja: 'bg-blue-500/20 text-blue-500',
};
return colors[priority as keyof typeof colors] || colors.media;
};
const getTypeLabel = (type: string) => {
const labels = {
protocol: 'Protocolo',
guide: 'Guía',
drug: 'Fármaco',
checklist: 'Checklist',
manual: 'Manual',
};
return labels[type as keyof typeof labels] || type;
};
if (!hasPermission('content:validate')) {
return (
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
No tienes permisos para validar contenido
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Validación de Contenido</h1>
<p className="text-muted-foreground mt-1">
Revisa y aprueba contenido pendiente de validación
</p>
</div>
{/* Estadísticas de Validación */}
{validationStats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Pendientes</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.pending || 0}
</p>
</div>
<Clock className="w-8 h-8 text-yellow-500 opacity-50" />
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Aprobados</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.byStatus?.approved || 0}
</p>
</div>
<CheckCircle className="w-8 h-8 text-green-500 opacity-50" />
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Tiempo Promedio</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.avgValidationTime
? `${validationStats.avgValidationTime} días`
: 'N/A'}
</p>
</div>
<AlertCircle className="w-8 h-8 text-blue-500 opacity-50" />
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Rechazos (30d)</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.rejectionsLast30Days || 0}
</p>
</div>
<XCircle className="w-8 h-8 text-red-500 opacity-50" />
</div>
</div>
</div>
)}
{message && (
<div
className={`p-4 rounded-lg border ${
message.type === 'success'
? 'bg-green-500/10 border-green-500/20 text-green-500'
: 'bg-red-500/10 border-red-500/20 text-red-500'
}`}
>
{message.text}
</div>
)}
{/* Estadísticas de Validación */}
{validationStats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Pendientes</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.pending || 0}
</p>
</div>
<Clock className="w-8 h-8 text-yellow-500 opacity-50" />
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Aprobados</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.byStatus?.approved || 0}
</p>
</div>
<CheckCircle className="w-8 h-8 text-green-500 opacity-50" />
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Tiempo Promedio</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.avgValidationTime
? `${validationStats.avgValidationTime} días`
: 'N/A'}
</p>
</div>
<AlertCircle className="w-8 h-8 text-blue-500 opacity-50" />
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Rechazos (30d)</p>
<p className="text-2xl font-bold text-foreground mt-1">
{validationStats.rejectionsLast30Days || 0}
</p>
</div>
<XCircle className="w-8 h-8 text-red-500 opacity-50" />
</div>
</div>
</div>
)}
{/* Filtros */}
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-muted-foreground mb-1">
Tipo
</label>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todos los tipos</option>
<option value="protocol">Protocolos</option>
<option value="guide">Guías</option>
<option value="drug">Fármacos</option>
<option value="checklist">Checklists</option>
</select>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-muted-foreground mb-1">
Prioridad
</label>
<select
value={priorityFilter}
onChange={(e) => setPriorityFilter(e.target.value)}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="all">Todas las prioridades</option>
<option value="critica">Crítica</option>
<option value="alta">Alta</option>
<option value="media">Media</option>
<option value="baja">Baja</option>
</select>
</div>
</div>
</div>
{/* Lista de contenido pendiente */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-muted-foreground">Cargando...</div>
) : pendingItems.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<CheckCircle className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No hay contenido pendiente de validación</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Tipo
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Título
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Prioridad
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Creado por
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">
Fecha
</th>
<th className="px-4 py-3 text-right text-sm font-medium text-foreground">
Acciones
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{pendingItems.map((item) => (
<tr key={item.id} className="hover:bg-muted/50 transition-colors">
<td className="px-4 py-3">
<span className="text-sm text-muted-foreground capitalize">
{getTypeLabel(item.type)}
</span>
</td>
<td className="px-4 py-3">
<div>
<div className="font-medium text-foreground">{item.title}</div>
{item.shortTitle && (
<div className="text-sm text-muted-foreground">{item.shortTitle}</div>
)}
</div>
</td>
<td className="px-4 py-3">
<span
className={`px-2 py-1 rounded text-xs font-medium ${getPriorityColor(
item.priority
)}`}
>
{item.priority}
</span>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{item.created_by_username || 'N/A'}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(item.created_at).toLocaleDateString('es-ES')}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => navigate(`/content/${item.type}/${item.id}`)}
className="p-2 hover:bg-muted rounded-lg transition-colors"
title="Ver/Editar"
>
<Eye className="w-4 h-4 text-muted-foreground" />
</button>
<button
onClick={() => setSelectedItem(item)}
className="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm flex items-center gap-2"
>
<CheckCircle className="w-4 h-4" />
Validar
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="px-4 py-3 border-t border-border bg-muted/30">
<div className="text-sm text-muted-foreground">
Total: {pendingItems.length} item{pendingItems.length !== 1 ? 's' : ''} pendiente
{pendingItems.length !== 1 ? 's' : ''}
</div>
</div>
</>
)}
</div>
{/* Modal de validación */}
{selectedItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-card border border-border rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-foreground">
Validar: {selectedItem.title}
</h2>
<button
onClick={() => {
setSelectedItem(null);
setActionNotes('');
setPublishOnApprove(false);
}}
className="p-2 hover:bg-muted rounded-lg transition-colors"
>
×
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Notas (opcional para aprobación, obligatorio para rechazo)
</label>
<textarea
value={actionNotes}
onChange={(e) => setActionNotes(e.target.value)}
rows={4}
className="w-full px-3 py-2 bg-background border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Añade notas sobre la validación..."
/>
</div>
{hasPermission('content:publish') && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="publishOnApprove"
checked={publishOnApprove}
onChange={(e) => setPublishOnApprove(e.target.checked)}
className="form-checkbox"
/>
<label htmlFor="publishOnApprove" className="text-sm text-muted-foreground">
Publicar automáticamente al aprobar
</label>
</div>
)}
<div className="flex gap-2 pt-4 border-t border-border">
<button
onClick={handleApprove}
disabled={isProcessing}
className="flex-1 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
<CheckCircle className="w-4 h-4" />
Aprobar
</button>
<button
onClick={handleReject}
disabled={isProcessing || !actionNotes.trim()}
className="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
<XCircle className="w-4 h-4" />
Rechazar
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -1,48 +0,0 @@
/**
* Servicio de autenticación
*/
import axios from 'axios';
import type { LoginRequest, LoginResponse, User } from '../../shared/types/auth';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const api = axios.create({
baseURL: `${API_URL}/api`,
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor para añadir token a las peticiones
api.interceptors.request.use((config) => {
const token = localStorage.getItem('admin_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export const authService = {
async login(credentials: LoginRequest): Promise<LoginResponse> {
const response = await api.post('/auth/login', credentials);
return response.data;
},
async getCurrentUser(): Promise<User> {
const response = await api.get('/auth/me');
return response.data.user;
},
async verifyToken(token: string): Promise<boolean> {
try {
const response = await api.get('/auth/me', {
headers: { Authorization: `Bearer ${token}` },
});
return response.status === 200;
} catch {
return false;
}
},
};

View file

@ -1,63 +0,0 @@
/**
* Servicio de contenido
*/
import axios from 'axios';
import type { BaseContentItem, ContentListResponse } from '../../shared/types/content';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const api = axios.create({
baseURL: `${API_URL}/api`,
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor para añadir token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('admin_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export const contentService = {
async list(params?: {
type?: string;
level?: string;
status?: string;
page?: number;
pageSize?: number;
search?: string;
}): Promise<ContentListResponse> {
const response = await api.get('/content', { params });
return response.data;
},
async getById(id: string): Promise<BaseContentItem> {
const response = await api.get(`/content/${id}`);
return response.data;
},
async create(data: any): Promise<BaseContentItem> {
const response = await api.post('/content', data);
return response.data;
},
async update(id: string, data: any): Promise<BaseContentItem> {
const response = await api.put(`/content/${id}`, data);
return response.data;
},
async getVersions(id: string): Promise<any[]> {
const response = await api.get(`/content/${id}/versions`);
return response.data.versions;
},
async validate(id: string, approved: boolean): Promise<void> {
await api.post(`/content/${id}/validate`, { approved });
},
};

View file

@ -1,34 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
border: "hsl(var(--border))",
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
},
},
},
plugins: [],
}

View file

@ -1,26 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "../shared"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -1,12 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View file

@ -1,16 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5174,
},
});

View file

@ -1,52 +0,0 @@
# ⚠️ CONFIGURACIÓN REQUERIDA
Para continuar con la FASE 1, necesitas configurar el password de PostgreSQL.
## Opción 1: Editar .env manualmente
```bash
cd backend
nano .env # o tu editor preferido
```
Completar la línea:
```
DB_PASSWORD=tu_password_postgres_aqui
```
## Opción 2: Si no tienes password (solo desarrollo local)
Si PostgreSQL está configurado sin password (trust authentication), puedes dejar vacío o usar:
```bash
cd backend
echo 'DB_PASSWORD=' >> .env
```
## Opción 3: Crear usuario específico (recomendado)
```bash
# Conectar como postgres
sudo -u postgres psql
# Crear usuario y base de datos
CREATE USER emerges_tes WITH PASSWORD 'password_seguro';
CREATE DATABASE emerges_tes OWNER emerges_tes;
GRANT ALL PRIVILEGES ON DATABASE emerges_tes TO emerges_tes;
\q
```
Luego en .env:
```
DB_USER=emerges_tes
DB_PASSWORD=password_seguro
```
## Verificar conexión
Después de configurar, probar:
```bash
cd backend
node -e "import('dotenv').then(d => d.default.config()); import('./config/database.js').then(m => m.testConnection())"
```

30
backend/Dockerfile Normal file
View file

@ -0,0 +1,30 @@
# Etapa 1: Construcción del backend
FROM node:18-alpine AS build-stage
WORKDIR /app
# Instalar dependencias
COPY package*.json ./
RUN npm install
# Copiar el resto de archivos y construir
COPY . .
RUN npm run build
# Etapa 2: Imagen para ejecución
FROM node:18-alpine
WORKDIR /app
# Copiar solo dependencias de producción y archivos construidos
COPY package*.json ./
RUN npm install --production
COPY --from=build-stage /app/dist/ ./dist/
# Exponer el puerto por defecto (3000)
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]

View file

@ -1,25 +0,0 @@
# Configuración de Variables de Entorno
Crear archivo `.env` en `backend/` con el siguiente contenido:
```env
# Base de Datos PostgreSQL
DB_HOST=localhost
DB_PORT=5432
DB_NAME=emerges_tes
DB_USER=postgres
DB_PASSWORD=tu_password_aqui
# API Server
API_PORT=3000
API_HOST=localhost
NODE_ENV=development
# JWT Secret (generar con: openssl rand -base64 32)
JWT_SECRET=tu_jwt_secret_muy_seguro_aqui
JWT_EXPIRES_IN=24h
# CORS (múltiples orígenes separados por comas)
CORS_ORIGINS=http://localhost:8096,http://localhost:5174,http://localhost:5173
```

View file

@ -1,47 +0,0 @@
# 🔧 INSTRUCCIONES: Crear Usuario PostgreSQL
El usuario `planetazuzu` no existe en PostgreSQL. Necesitas crearlo primero.
## Opción 1: Ejecutar Script SQL (Recomendado)
```bash
cd backend
sudo -u postgres psql -f scripts/create-user.sql
```
Este script:
- ✅ Crea el usuario según configuración (ver variables de entorno)
- ✅ Crea la base de datos `emerges_tes`
- ✅ Da todos los permisos necesarios
- ⚠️ **IMPORTANTE:** Configura `DB_USER` y `DB_PASSWORD` antes de ejecutar
## Opción 2: Manual (si prefieres)
```bash
sudo -u postgres psql
```
Luego ejecutar en psql (reemplaza 'TU_PASSWORD_SEGURO' con una contraseña segura):
```sql
CREATE USER tu_usuario WITH PASSWORD 'TU_PASSWORD_SEGURO';
CREATE DATABASE emerges_tes OWNER tu_usuario;
GRANT ALL PRIVILEGES ON DATABASE emerges_tes TO tu_usuario;
\c emerges_tes
CREATE SCHEMA IF NOT EXISTS emerges_content;
GRANT ALL ON SCHEMA emerges_content TO tu_usuario;
\q
```
## Después de crear el usuario
Verificar conexión:
```bash
cd backend
npm run verify
```
Si funciona, continuar con:
```bash
npm run db:create # Crear tablas
npm run migrate # Migrar contenido
```

View file

@ -1,102 +0,0 @@
# EMERGES TES - Backend API
Backend para gestión de contenido de EMERGES TES.
## 🚀 Inicio Rápido
### 1. Instalar dependencias
```bash
cd backend
npm install
```
### 2. Configurar variables de entorno
```bash
cp .env.example .env
# Editar .env con tus credenciales de PostgreSQL
```
### 3. Crear base de datos
```bash
npm run db:create
```
Este comando:
- Crea la base de datos `emerges_tes` si no existe
- Ejecuta todas las migraciones SQL
- Crea el esquema completo
### 4. Migrar contenido inicial
```bash
npm run migrate
```
Este comando migra el contenido de `src/data/*.ts` a PostgreSQL.
### 5. Iniciar servidor
```bash
npm run dev
```
El servidor estará disponible en `http://localhost:3000`
## 📁 Estructura
```
backend/
├── src/
│ ├── api/ # Endpoints de la API (FASE 2+)
│ ├── db/ # Utilidades de base de datos
│ ├── migrations/ # Migraciones de datos
│ └── utils/ # Utilidades
├── config/
│ └── database.js # Configuración de PostgreSQL
├── scripts/
│ ├── db-create.js # Crear BD y ejecutar migraciones
│ └── migrate-content.js # Migrar contenido TypeScript → PostgreSQL
└── package.json
```
## 🔧 Scripts Disponibles
- `npm run dev` - Iniciar servidor en modo desarrollo
- `npm start` - Iniciar servidor en producción
- `npm run db:create` - Crear base de datos y ejecutar migraciones
- `npm run migrate` - Migrar contenido desde TypeScript
## 📊 Estado de Implementación
### ✅ FASE 1: Infraestructura Base (Actual)
- ✅ Estructura de directorios
- ✅ Scripts SQL de creación de esquema
- ✅ Configuración de PostgreSQL
- ✅ Scripts de migración básicos
- ✅ Servidor Express básico
### ⏳ FASE 2: API REST (Próxima)
- Endpoints GET para lectura
- Sincronización de contenido
- Cache y optimización
### ⏳ FASE 3: Panel Admin (Futuro)
- Autenticación
- Editores de contenido
- Validación clínica
## 🔐 Seguridad
- Las credenciales de BD deben estar en `.env` (no commitear)
- JWT para autenticación (FASE 3)
- Validación de entrada en todos los endpoints
## 📝 Notas
- Este backend es independiente de la app React
- La app React solo LEE contenido (pull-only)
- El panel admin (futuro) será quien ESCRIBA contenido

View file

@ -1,82 +0,0 @@
# 🏗️ Estructura Clean Architecture - Backend
## 📁 Estructura de Carpetas
```
backend/src/
├── domain/ # 🎯 DOMAIN LAYER
│ ├── entities/ # Entidades de negocio
│ │ ├── ContentItem.ts
│ │ ├── Drug.ts
│ │ ├── GlossaryTerm.ts
│ │ ├── MediaResource.ts
│ │ ├── MedicalReview.ts
│ │ └── index.ts
│ │
│ ├── value-objects/ # Objetos de valor inmutables
│ │ ├── ContentStatus.ts
│ │ ├── ContentPriority.ts
│ │ └── index.ts
│ │
│ ├── services/ # Servicios de dominio (pendiente)
│ ├── repositories/ # Interfaces de repositorios
│ │ ├── IContentRepository.ts
│ │ ├── IDrugRepository.ts
│ │ ├── IGlossaryRepository.ts
│ │ ├── IMediaRepository.ts
│ │ ├── IReviewRepository.ts
│ │ └── index.ts
│ │
│ └── events/ # Eventos de dominio (pendiente)
├── application/ # 🔧 APPLICATION LAYER
│ ├── services/ # Servicios de aplicación (pendiente)
│ ├── use-cases/ # Casos de uso (pendiente)
│ └── dto/ # Data Transfer Objects (pendiente)
├── infrastructure/ # 🔌 INFRASTRUCTURE LAYER
│ ├── repositories/ # Implementaciones (pendiente)
│ ├── database/ # Acceso a BD (pendiente)
│ ├── storage/ # Almacenamiento (pendiente)
│ ├── cache/ # Caché (pendiente)
│ └── external/ # Servicios externos (pendiente)
├── presentation/ # 🌐 PRESENTATION LAYER
│ ├── routes/ # Rutas Express (existente)
│ ├── middleware/ # Middleware (existente)
│ └── validators/ # Validadores Zod (existente)
└── shared/ # 🔗 CÓDIGO COMPARTIDO
├── types/ # Tipos compartidos
├── errors/ # Errores compartidos
└── utils/ # Utilidades compartidas
```
## ✅ Estado Actual
### Completado (Ticket 1.1)
- ✅ Estructura de carpetas creada
- ✅ Interfaces de repositorios definidas
- ✅ Entidades de dominio creadas (tipos TypeScript)
- ✅ Value Objects creados (ContentStatus, ContentPriority)
- ✅ Tipos compartidos exportados
- ✅ Errores de dominio definidos
- ✅ Utilidades compartidas
### Pendiente
- ⏳ Implementaciones de repositorios (Infrastructure)
- ⏳ Servicios de aplicación (Application)
- ⏳ Casos de uso (Application)
- ⏳ Value Objects adicionales (DoseRange, PatientAge, etc.)
- ⏳ Servicios de dominio (Domain)
## 📝 Próximos Pasos
1. **Ticket 1.2:** Crear schemas Zod compartidos
2. **Ticket 1.3:** Refactorizar `drugs.ts` del frontend
3. **Ticket 1.4:** Refactorizar `procedures.ts` del frontend
4. **Ticket 1.5:** Eliminar duplicidades
---
**Última actualización:** 2025-01-25

View file

@ -1,59 +0,0 @@
/**
* Configuración de conexión a PostgreSQL
*
* FASE 1: Infraestructura Base
*
* IMPORTANTE: Usar variables de entorno para credenciales
*/
import pg from 'pg';
import dotenv from 'dotenv';
dotenv.config();
const { Pool } = pg;
/**
* Pool de conexiones a PostgreSQL
*/
export const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: process.env.DB_NAME || 'emerges_tes',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || '',
max: 20, // Máximo de conexiones en el pool
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
/**
* Test de conexión
*/
export async function testConnection(): Promise<boolean> {
try {
const result = await pool.query('SELECT NOW()');
console.log('✅ Conexión a PostgreSQL exitosa:', result.rows[0].now);
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('❌ Error conectando a PostgreSQL:', errorMessage);
return false;
}
}
/**
* Función helper para ejecutar queries
* Envuelve pool.query para mantener compatibilidad
*/
export async function query(text: string, params?: any[]): Promise<pg.QueryResult> {
return await pool.query(text, params);
}
/**
* Cerrar pool de conexiones
*/
export async function closePool(): Promise<void> {
await pool.end();
}

View file

@ -1,54 +0,0 @@
#!/bin/bash
# Script para crear usuario y base de datos PostgreSQL
# Ejecutar: bash crear-usuario-y-bd.sh
echo "🔧 Creando usuario y base de datos PostgreSQL..."
echo ""
# Copiar SQL a /tmp para que postgres pueda acceder
cat > /tmp/create-user-emerges.sql << 'SQL'
-- Crear usuario si no existe
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = '${DB_USER:-planetazuzu}') THEN
CREATE USER ${DB_USER:-planetazuzu} WITH PASSWORD '${DB_PASSWORD}';
RAISE NOTICE 'Usuario ${DB_USER:-planetazuzu} creado';
ELSE
RAISE NOTICE 'Usuario ${DB_USER:-planetazuzu} ya existe';
ALTER USER ${DB_USER:-planetazuzu} WITH PASSWORD '${DB_PASSWORD}';
END IF;
END
$$;
-- Crear base de datos si no existe
SELECT 'CREATE DATABASE emerges_tes OWNER planetazuzu'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'emerges_tes')\gexec
-- Dar permisos
GRANT ALL PRIVILEGES ON DATABASE emerges_tes TO planetazuzu;
-- Conectar a la base de datos y dar permisos en el esquema
\c emerges_tes
-- Crear esquema si no existe
CREATE SCHEMA IF NOT EXISTS emerges_content;
-- Dar permisos en el esquema
GRANT ALL ON SCHEMA emerges_content TO planetazuzu;
ALTER DEFAULT PRIVILEGES IN SCHEMA emerges_content GRANT ALL ON TABLES TO planetazuzu;
ALTER DEFAULT PRIVILEGES IN SCHEMA emerges_content GRANT ALL ON SEQUENCES TO planetazuzu;
\q
SQL
# Ejecutar con sudo
echo "Ejecutando SQL (requiere contraseña de sudo)..."
sudo -u postgres psql -f /tmp/create-user-emerges.sql
# Limpiar
rm -f /tmp/create-user-emerges.sql
echo ""
echo "✅ Proceso completado"
echo ""
echo "Verificar con: cd backend && npm run verify"

View file

@ -1,47 +0,0 @@
-- ============================================
-- MIGRACIÓN 001: Crear Schema y Tabla de Usuarios
-- ============================================
--
-- Crea el schema emerges_content y la tabla de usuarios
-- necesaria para autenticación del admin panel
--
-- Crear schema si no existe
CREATE SCHEMA IF NOT EXISTS emerges_content;
-- Tabla de usuarios
CREATE TABLE IF NOT EXISTS emerges_content.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'editor',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login TIMESTAMPTZ
);
-- Índices
CREATE INDEX IF NOT EXISTS idx_users_email ON emerges_content.users(email);
CREATE INDEX IF NOT EXISTS idx_users_role ON emerges_content.users(role);
CREATE INDEX IF NOT EXISTS idx_users_is_active ON emerges_content.users(is_active);
-- Trigger para actualizar updated_at
CREATE OR REPLACE FUNCTION emerges_content.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON emerges_content.users
FOR EACH ROW
EXECUTE FUNCTION emerges_content.update_updated_at_column();
-- Comentarios
COMMENT ON TABLE emerges_content.users IS 'Usuarios del sistema de administración';
COMMENT ON COLUMN emerges_content.users.role IS 'Rol del usuario: super_admin, editor_clinico, editor_formativo, revisor, viewer';

View file

@ -1,217 +0,0 @@
-- ============================================
-- MIGRACIÓN 002: Esquema de Vademécum TES (Drugs)
-- ============================================
--
-- Crea las tablas necesarias para el módulo de vademécum TES
-- Basado en: docs/VADEMECUM_COMPLETO_TES.md
--
-- IMPORTANTE: Este módulo es SOLO capa de REFERENCIA
-- NO modifica tablas existentes, solo añade nuevas
--
-- ============================================
-- ENUM: drug_line (Primera línea / Segunda línea)
-- ============================================
CREATE TYPE tes_content.drug_line AS ENUM (
'first', -- Primera línea (uso frecuente)
'second' -- Segunda línea (uso menos frecuente)
);
-- ============================================
-- ENUM: drug_frequency (Frecuencia de uso)
-- ============================================
CREATE TYPE tes_content.drug_frequency AS ENUM (
'high', -- Uso frecuente
'medium', -- Uso medio
'low' -- Uso poco frecuente
);
-- ============================================
-- TABLA: drugs
-- ============================================
CREATE TABLE tes_content.drugs (
-- Identificación
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
slug TEXT UNIQUE NOT NULL, -- Identificador legible único (ej: "adrenalina")
-- Información básica
generic_name TEXT NOT NULL, -- Nombre genérico (ej: "Adrenalina")
trade_name TEXT, -- Nombre comercial (ej: "Adrenalina 1mg/1ml")
-- Clasificación
category TEXT NOT NULL, -- Categoría farmacológica (ej: "cardiovascular", "respiratorio")
line tes_content.drug_line NOT NULL, -- Primera línea o segunda línea
frequency tes_content.drug_frequency NOT NULL, -- Frecuencia de uso
-- Presentación y dosificación
presentation TEXT NOT NULL, -- Presentación (ej: "1mg/1ml ampolla")
adult_dose TEXT NOT NULL, -- Dosis adulto (ej: "1mg IV/IO cada 3-5 min")
pediatric_dose TEXT, -- Dosis pediátrica (nullable pero validable)
routes TEXT[] DEFAULT '{}', -- Vías de administración (ej: ["IV", "IO", "IM"])
dilution TEXT, -- Dilución (si aplica)
-- Indicaciones y contraindicaciones
indications TEXT[] DEFAULT '{}', -- Indicaciones clínicas
contraindications TEXT[] DEFAULT '{}', -- Contraindicaciones
side_effects TEXT, -- Efectos adversos
antidote TEXT, -- Antídoto (si aplica)
-- Información específica TES
notes TEXT[] DEFAULT '{}', -- Notas importantes
critical_points TEXT[] DEFAULT '{}', -- Puntos críticos para TES
source TEXT, -- Fuente (ej: "Manual TES Digital")
-- Estado y validación
status tes_content.content_status NOT NULL DEFAULT 'draft',
-- Versionado
version TEXT NOT NULL DEFAULT '1.0.0',
latest_version TEXT NOT NULL DEFAULT '1.0.0',
current_version_id UUID, -- FK a drug_versions (versión actual)
-- Auditoría
created_by UUID NOT NULL REFERENCES tes_content.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID REFERENCES tes_content.users(id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_by UUID REFERENCES tes_content.users(id),
published_at TIMESTAMPTZ,
-- Metadatos adicionales
metadata JSONB DEFAULT '{}',
-- Constraints
CONSTRAINT valid_version_format CHECK (version ~ '^\d+\.\d+\.\d+$'),
CONSTRAINT valid_latest_version_format CHECK (latest_version ~ '^\d+\.\d+\.\d+$'),
CONSTRAINT pediatric_dose_required_when_published CHECK (
(status = 'published'::tes_content.content_status AND pediatric_dose IS NOT NULL) OR
(status != 'published'::tes_content.content_status)
)
);
-- ============================================
-- TABLA: drug_versions
-- ============================================
CREATE TABLE tes_content.drug_versions (
-- Identificación
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
drug_id UUID NOT NULL REFERENCES tes_content.drugs(id) ON DELETE CASCADE,
version TEXT NOT NULL, -- Versión semántica (ej: "1.2.3")
-- Snapshot completo del fármaco
drug_snapshot JSONB NOT NULL, -- Snapshot completo del fármaco en esta versión
-- Cambios
change_summary TEXT NOT NULL, -- Resumen de cambios
change_details JSONB, -- Detalles de cambios (campos modificados, valores antiguos/nuevos)
-- Tipo de cambio
change_type TEXT NOT NULL DEFAULT 'patch', -- 'major' | 'minor' | 'patch'
is_breaking BOOLEAN DEFAULT false, -- ¿Es cambio incompatible?
-- Auditoría
created_by UUID NOT NULL REFERENCES tes_content.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ,
published_by UUID REFERENCES tes_content.users(id),
-- Estado
is_active BOOLEAN NOT NULL DEFAULT false, -- ¿Es la versión activa?
-- Constraints
CONSTRAINT valid_version_format CHECK (version ~ '^\d+\.\d+\.\d+$'),
CONSTRAINT unique_drug_version UNIQUE (drug_id, version)
);
-- ============================================
-- ÍNDICES
-- ============================================
-- drugs
CREATE INDEX idx_drugs_slug ON tes_content.drugs(slug);
CREATE INDEX idx_drugs_category ON tes_content.drugs(category);
CREATE INDEX idx_drugs_line ON tes_content.drugs(line);
CREATE INDEX idx_drugs_frequency ON tes_content.drugs(frequency);
CREATE INDEX idx_drugs_status ON tes_content.drugs(status);
CREATE INDEX idx_drugs_generic_name ON tes_content.drugs USING GIN(to_tsvector('spanish', generic_name));
CREATE INDEX idx_drugs_published ON tes_content.drugs(status, updated_at DESC) WHERE status = 'published';
-- drug_versions
CREATE INDEX idx_drug_versions_drug_id ON tes_content.drug_versions(drug_id);
CREATE INDEX idx_drug_versions_version ON tes_content.drug_versions(version);
CREATE INDEX idx_drug_versions_active ON tes_content.drug_versions(is_active) WHERE is_active = true;
CREATE INDEX idx_drug_versions_created_at ON tes_content.drug_versions(created_at DESC);
-- ============================================
-- TRIGGERS
-- ============================================
-- Trigger para actualizar updated_at automáticamente
CREATE TRIGGER update_drugs_updated_at
BEFORE UPDATE ON tes_content.drugs
FOR EACH ROW
EXECUTE FUNCTION tes_content.update_updated_at_column();
-- ============================================
-- VISTAS ÚTILES
-- ============================================
-- Vista: Fármacos publicados
CREATE OR REPLACE VIEW tes_content.published_drugs AS
SELECT
d.id,
d.slug,
d.generic_name,
d.trade_name,
d.category,
d.line,
d.frequency,
d.presentation,
d.adult_dose,
d.pediatric_dose,
d.routes,
d.dilution,
d.indications,
d.contraindications,
d.side_effects,
d.antidote,
d.notes,
d.critical_points,
d.source,
d.version,
d.created_at,
d.updated_at
FROM tes_content.drugs d
WHERE d.status = 'published'::tes_content.content_status
AND d.version = d.latest_version;
-- Vista: Estadísticas de fármacos
CREATE OR REPLACE VIEW tes_content.drug_stats AS
SELECT
category,
line,
frequency,
status,
COUNT(*) as count,
COUNT(CASE WHEN status = 'published' THEN 1 END) as published_count
FROM tes_content.drugs
GROUP BY category, line, frequency, status;
-- ============================================
-- COMENTARIOS
-- ============================================
COMMENT ON TABLE tes_content.drugs IS 'Vademécum TES: Fármacos de referencia para técnicos en emergencias sanitarias';
COMMENT ON COLUMN tes_content.drugs.line IS 'Primera línea (uso frecuente) o segunda línea (uso menos frecuente)';
COMMENT ON COLUMN tes_content.drugs.frequency IS 'Frecuencia de uso: alta, media o baja';
COMMENT ON COLUMN tes_content.drugs.pediatric_dose IS 'Dosis pediátrica. Obligatoria cuando status = published';
COMMENT ON TABLE tes_content.drug_versions IS 'Versiones históricas de fármacos para versionado y rollback';
-- ============================================
-- FIN DE LA MIGRACIÓN
-- ============================================

View file

@ -1,267 +0,0 @@
-- ============================================
-- MIGRACIÓN 003: Esquema de Content Items (tes_content)
-- ============================================
--
-- Crea las tablas necesarias para content_items en tes_content
-- Unifica el schema para que Content Pack Generator funcione correctamente
--
-- IMPORTANTE: Este schema es compatible con el Content Pack Generator
-- que busca tes_content.content_items
--
-- Crear schema si no existe
CREATE SCHEMA IF NOT EXISTS tes_content;
-- Extensión para UUIDs
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================
-- ENUM: content_status
-- ============================================
CREATE TYPE tes_content.content_status AS ENUM (
'draft',
'in_review',
'approved',
'published',
'archived'
);
-- ============================================
-- ENUM: content_priority
-- ============================================
CREATE TYPE tes_content.content_priority AS ENUM (
'critica',
'alta',
'media',
'baja'
);
-- ============================================
-- TABLA: content_items
-- Propósito: Almacena todos los tipos de contenido (protocolos, guías, etc.)
-- ============================================
CREATE TABLE IF NOT EXISTS tes_content.content_items (
-- Identificación
id VARCHAR(100) PRIMARY KEY,
type VARCHAR(50) NOT NULL,
slug VARCHAR(200) UNIQUE NOT NULL,
level VARCHAR(50) NOT NULL,
-- Contenido
title VARCHAR(500) NOT NULL,
short_title VARCHAR(200),
description TEXT,
content JSONB NOT NULL,
content_markdown TEXT,
-- Metadatos
category VARCHAR(100),
subcategory VARCHAR(100),
priority tes_content.content_priority,
age_group VARCHAR(20),
clinical_context TEXT,
source_guideline VARCHAR(200),
-- Tags y clasificación
tags TEXT[],
-- Versionado
version INTEGER NOT NULL DEFAULT 1,
latest_version INTEGER NOT NULL DEFAULT 1,
-- Estado y validación
status tes_content.content_status NOT NULL DEFAULT 'draft',
validated_by UUID,
validated_at TIMESTAMPTZ,
clinical_source VARCHAR(200),
quality_score INTEGER CHECK (quality_score IS NULL OR (quality_score >= 0 AND quality_score <= 100)),
-- Revisión
reviewed_by UUID,
reviewed_at TIMESTAMPTZ,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID NOT NULL,
-- Constraints
CONSTRAINT chk_type CHECK (type IN ('protocol', 'guide', 'manual', 'checklist')),
CONSTRAINT chk_level CHECK (level IN ('operativo', 'formativo', 'referencia'))
);
-- Índices para content_items
CREATE INDEX IF NOT EXISTS idx_content_items_type ON tes_content.content_items(type);
CREATE INDEX IF NOT EXISTS idx_content_items_level ON tes_content.content_items(level);
CREATE INDEX IF NOT EXISTS idx_content_items_status ON tes_content.content_items(status);
CREATE INDEX IF NOT EXISTS idx_content_items_category ON tes_content.content_items(category);
CREATE INDEX IF NOT EXISTS idx_content_items_slug ON tes_content.content_items(slug);
CREATE INDEX IF NOT EXISTS idx_content_items_validated_at ON tes_content.content_items(validated_at);
CREATE INDEX IF NOT EXISTS idx_content_items_updated_at ON tes_content.content_items(updated_at);
CREATE INDEX IF NOT EXISTS idx_content_items_content_gin ON tes_content.content_items USING GIN (content);
CREATE INDEX IF NOT EXISTS idx_content_items_title_fts ON tes_content.content_items USING GIN (to_tsvector('spanish', title));
-- ============================================
-- TABLA: content_versions
-- Propósito: Historial de versiones de contenido
-- ============================================
CREATE TABLE IF NOT EXISTS tes_content.content_versions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
content_id VARCHAR(100) NOT NULL,
version INTEGER NOT NULL,
-- Snapshot del contenido
content JSONB NOT NULL,
content_markdown TEXT,
title VARCHAR(500) NOT NULL,
-- Metadatos de la versión
status tes_content.content_status NOT NULL,
validated_by UUID,
validated_at TIMESTAMPTZ,
clinical_source VARCHAR(200),
-- Cambios
change_summary TEXT,
changed_fields TEXT[],
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
-- Foreign key
CONSTRAINT fk_content_versions_content_id
FOREIGN KEY (content_id)
REFERENCES tes_content.content_items(id)
ON DELETE CASCADE,
-- Unique constraint
CONSTRAINT uq_content_versions_content_version
UNIQUE (content_id, version)
);
CREATE INDEX IF NOT EXISTS idx_content_versions_content_id ON tes_content.content_versions(content_id);
CREATE INDEX IF NOT EXISTS idx_content_versions_version ON tes_content.content_versions(version);
-- ============================================
-- TABLA: media_resources
-- Propósito: Recursos multimedia (imágenes, videos)
-- ============================================
CREATE TABLE IF NOT EXISTS tes_content.media_resources (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
type VARCHAR(50) NOT NULL,
path VARCHAR(500) NOT NULL,
filename VARCHAR(255) NOT NULL,
file_url VARCHAR(500),
thumbnail_url VARCHAR(500),
-- Metadatos
title VARCHAR(500),
description TEXT,
alt_text VARCHAR(500),
caption TEXT,
tags TEXT[],
-- Clasificación
block VARCHAR(100),
chapter VARCHAR(100),
priority tes_content.content_priority,
-- Propiedades del archivo
width INTEGER,
height INTEGER,
format VARCHAR(50),
file_size BIGINT,
duration_seconds INTEGER,
video_format VARCHAR(50),
-- Estado
status tes_content.content_status NOT NULL DEFAULT 'draft',
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID NOT NULL,
CONSTRAINT chk_media_type CHECK (type IN ('image', 'video', 'audio', 'document'))
);
CREATE INDEX IF NOT EXISTS idx_media_resources_type ON tes_content.media_resources(type);
CREATE INDEX IF NOT EXISTS idx_media_resources_status ON tes_content.media_resources(status);
CREATE INDEX IF NOT EXISTS idx_media_resources_tags ON tes_content.media_resources USING GIN (tags);
-- ============================================
-- TABLA: content_resource_associations
-- Propósito: Asociaciones entre contenido y recursos multimedia
-- ============================================
CREATE TABLE IF NOT EXISTS tes_content.content_resource_associations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
content_item_id VARCHAR(100) NOT NULL,
media_resource_id UUID NOT NULL,
-- Contexto de la asociación
section VARCHAR(100),
position INTEGER NOT NULL DEFAULT 0,
placement VARCHAR(50),
caption TEXT,
is_critical BOOLEAN NOT NULL DEFAULT false,
priority tes_content.content_priority,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
-- Foreign keys
CONSTRAINT fk_cra_content_item_id
FOREIGN KEY (content_item_id)
REFERENCES tes_content.content_items(id)
ON DELETE CASCADE,
CONSTRAINT fk_cra_media_resource_id
FOREIGN KEY (media_resource_id)
REFERENCES tes_content.media_resources(id)
ON DELETE CASCADE,
CONSTRAINT chk_placement CHECK (placement IN ('before', 'after', 'inline', 'sidebar'))
);
CREATE INDEX IF NOT EXISTS idx_cra_content_item_id ON tes_content.content_resource_associations(content_item_id);
CREATE INDEX IF NOT EXISTS idx_cra_media_resource_id ON tes_content.content_resource_associations(media_resource_id);
CREATE INDEX IF NOT EXISTS idx_cra_position ON tes_content.content_resource_associations(content_item_id, position);
-- ============================================
-- TABLA: audit_logs
-- Propósito: Registro de auditoría de cambios
-- ============================================
CREATE TABLE IF NOT EXISTS tes_content.audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id VARCHAR(100) NOT NULL,
action VARCHAR(50) NOT NULL,
changes JSONB,
metadata JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_action CHECK (action IN ('create', 'update', 'delete', 'submit', 'approve', 'reject', 'publish', 'archive'))
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON tes_content.audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON tes_content.audit_logs(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON tes_content.audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON tes_content.audit_logs(created_at);
-- ============================================
-- COMENTARIOS
-- ============================================
COMMENT ON SCHEMA tes_content IS 'Schema principal para contenido de TES';
COMMENT ON TABLE tes_content.content_items IS 'Tabla principal de contenido (protocolos, guías, etc.)';
COMMENT ON TABLE tes_content.content_versions IS 'Historial de versiones de contenido';
COMMENT ON TABLE tes_content.media_resources IS 'Recursos multimedia (imágenes, videos)';
COMMENT ON TABLE tes_content.content_resource_associations IS 'Asociaciones entre contenido y recursos multimedia';
COMMENT ON TABLE tes_content.audit_logs IS 'Registro de auditoría de cambios';

View file

@ -1,60 +0,0 @@
-- ============================================
-- MIGRACIÓN 004: Esquema de Glosario (tes_content)
-- ============================================
-- TICKET-007: Schema de BD para glosario
-- Crea la tabla glossary_terms en tes_content.
-- Reutiliza tes_content.content_status para estado.
--
-- Schema tes_content ya existe (003); content_status ya existe.
-- ============================================
-- TABLA: glossary_terms
-- Propósito: Términos del glosario médico (farmacológico, anatómico, clínico, procedural)
-- ============================================
CREATE TABLE IF NOT EXISTS tes_content.glossary_terms (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
term VARCHAR(200) NOT NULL,
abbreviation VARCHAR(50),
category VARCHAR(50) NOT NULL,
definition TEXT NOT NULL,
context VARCHAR(500),
examples TEXT[],
related_terms UUID[],
source VARCHAR(200),
status tes_content.content_status NOT NULL DEFAULT 'draft',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID,
CONSTRAINT chk_glossary_category CHECK (category IN ('pharmaceutical', 'anatomical', 'clinical', 'procedural'))
);
-- Índices
CREATE INDEX IF NOT EXISTS idx_glossary_terms_category ON tes_content.glossary_terms(category);
CREATE INDEX IF NOT EXISTS idx_glossary_terms_status ON tes_content.glossary_terms(status);
CREATE INDEX IF NOT EXISTS idx_glossary_terms_term_lower ON tes_content.glossary_terms(LOWER(term));
CREATE UNIQUE INDEX IF NOT EXISTS idx_glossary_terms_term_category ON tes_content.glossary_terms(LOWER(term), category);
CREATE INDEX IF NOT EXISTS idx_glossary_terms_updated_at ON tes_content.glossary_terms(updated_at);
-- Búsqueda full-text en term y definition
CREATE INDEX IF NOT EXISTS idx_glossary_terms_fts ON tes_content.glossary_terms
USING GIN (to_tsvector('spanish', term || ' ' || COALESCE(definition, '')));
-- Función updated_at en tes_content (idempotente)
CREATE OR REPLACE FUNCTION tes_content.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_glossary_terms_updated_at
BEFORE UPDATE ON tes_content.glossary_terms
FOR EACH ROW
EXECUTE FUNCTION tes_content.update_updated_at_column();
-- Comentarios
COMMENT ON TABLE tes_content.glossary_terms IS 'Términos del glosario médico (farmacológico, anatómico, clínico, procedural)';

View file

@ -1,26 +0,0 @@
/**
* Configuración de conexión a PostgreSQL
*
* FASE 1: Infraestructura Base
*
* IMPORTANTE: Usar variables de entorno para credenciales
*/
import pg from 'pg';
/**
* Pool de conexiones a PostgreSQL
*/
export declare const pool: pg.Pool;
/**
* Test de conexión
*/
export declare function testConnection(): Promise<boolean>;
/**
* Función helper para ejecutar queries
* Envuelve pool.query para mantener compatibilidad
*/
export declare function query(text: string, params?: any[]): Promise<pg.QueryResult>;
/**
* Cerrar pool de conexiones
*/
export declare function closePool(): Promise<void>;
//# sourceMappingURL=database.d.ts.map

View file

@ -1 +0,0 @@
{"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../config/database.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,MAAM,IAAI,CAAC;AAOpB;;GAEG;AACH,eAAO,MAAM,IAAI,SASf,CAAC;AAEH;;GAEG;AACH,wBAAsB,cAAc,IAAI,OAAO,CAAC,OAAO,CAAC,CAUvD;AAED;;;GAGG;AACH,wBAAsB,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC,EAAE,CAAC,WAAW,CAAC,CAEjF;AAED;;GAEG;AACH,wBAAsB,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAE/C"}

View file

@ -1,53 +0,0 @@
/**
* Configuración de conexión a PostgreSQL
*
* FASE 1: Infraestructura Base
*
* IMPORTANTE: Usar variables de entorno para credenciales
*/
import pg from 'pg';
import dotenv from 'dotenv';
dotenv.config();
const { Pool } = pg;
/**
* Pool de conexiones a PostgreSQL
*/
export const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: process.env.DB_NAME || 'emerges_tes',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || '',
max: 20, // Máximo de conexiones en el pool
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
/**
* Test de conexión
*/
export async function testConnection() {
try {
const result = await pool.query('SELECT NOW()');
console.log('✅ Conexión a PostgreSQL exitosa:', result.rows[0].now);
return true;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('❌ Error conectando a PostgreSQL:', errorMessage);
return false;
}
}
/**
* Función helper para ejecutar queries
* Envuelve pool.query para mantener compatibilidad
*/
export async function query(text, params) {
return await pool.query(text, params);
}
/**
* Cerrar pool de conexiones
*/
export async function closePool() {
await pool.end();
}
//# sourceMappingURL=database.js.map

View file

@ -1 +0,0 @@
{"version":3,"file":"database.js","sourceRoot":"","sources":["../../config/database.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,MAAM,IAAI,CAAC;AACpB,OAAO,MAAM,MAAM,QAAQ,CAAC;AAE5B,MAAM,CAAC,MAAM,EAAE,CAAC;AAEhB,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;AAEpB;;GAEG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC;IAC3B,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,OAAO,IAAI,WAAW;IACxC,IAAI,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,IAAI,MAAM,EAAE,EAAE,CAAC;IACjD,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,OAAO,IAAI,aAAa;IAC9C,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,OAAO,IAAI,UAAU;IACvC,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,EAAE;IACvC,GAAG,EAAE,EAAE,EAAE,kCAAkC;IAC3C,iBAAiB,EAAE,KAAK;IACxB,uBAAuB,EAAE,IAAI;CAC9B,CAAC,CAAC;AAEH;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc;IAClC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC;QAChD,OAAO,CAAC,GAAG,CAAC,kCAAkC,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;QACpE,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,YAAY,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,eAAe,CAAC;QAC9E,OAAO,CAAC,KAAK,CAAC,kCAAkC,EAAE,YAAY,CAAC,CAAC;QAChE,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,KAAK,CAAC,IAAY,EAAE,MAAc;IACtD,OAAO,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AACxC,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS;IAC7B,MAAM,IAAI,CAAC,GAAG,EAAE,CAAC;AACnB,CAAC"}

View file

@ -1,10 +0,0 @@
/**
* Configuración CORS Mejorada
* Limita orígenes incluso en desarrollo por seguridad
*/
import { CorsOptions } from 'cors';
/**
* Obtener configuración CORS basada en entorno
*/
export declare function getCorsConfig(): CorsOptions;
//# sourceMappingURL=cors.d.ts.map

View file

@ -1 +0,0 @@
{"version":3,"file":"cors.d.ts","sourceRoot":"","sources":["../../../src/config/cors.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,MAAM,CAAC;AAEnC;;GAEG;AACH,wBAAgB,aAAa,IAAI,WAAW,CAqD3C"}

View file

@ -1,57 +0,0 @@
/**
* Configuración CORS Mejorada
* Limita orígenes incluso en desarrollo por seguridad
*/
/**
* Obtener configuración CORS basada en entorno
*/
export function getCorsConfig() {
const isDevelopment = process.env.NODE_ENV !== 'production';
// Orígenes por defecto para desarrollo
const defaultDevOrigins = [
'http://localhost:8096',
'http://localhost:5174',
'http://localhost:5173',
];
// Obtener orígenes permitidos de env o usar defaults
const envOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map(origin => origin.trim())
: [];
const allowedOrigins = envOrigins.length > 0 ? envOrigins : defaultDevOrigins;
// En producción, CORS_ORIGINS es requerido
if (!isDevelopment && allowedOrigins.length === 0) {
console.error('❌ CRÍTICO: CORS_ORIGINS no configurado en producción');
console.error(' Configurar CORS_ORIGINS en .env con orígenes permitidos separados por coma');
console.error(' Ejemplo: CORS_ORIGINS=https://app.example.com,https://admin.example.com');
process.exit(1);
}
console.log(`✅ CORS configurado para ${isDevelopment ? 'desarrollo' : 'producción'}`);
console.log(` Orígenes permitidos: ${allowedOrigins.join(', ')}`);
return {
origin: (origin, callback) => {
// Permitir requests sin origen solo en desarrollo (mobile apps, Postman, etc.)
if (!origin) {
if (isDevelopment) {
return callback(null, true);
}
else {
return callback(new Error('Origen no proporcionado - requerido en producción'));
}
}
// Verificar si el origen está en la lista permitida
if (allowedOrigins.includes(origin)) {
callback(null, true);
}
else {
console.warn(`⚠️ Intento de acceso desde origen no permitido: ${origin}`);
callback(new Error(`Origen ${origin} no permitido por CORS`));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
exposedHeaders: ['X-Total-Count', 'X-Page-Count'],
maxAge: 86400, // 24 horas
};
}
//# sourceMappingURL=cors.js.map

View file

@ -1 +0,0 @@
{"version":3,"file":"cors.js","sourceRoot":"","sources":["../../../src/config/cors.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH;;GAEG;AACH,MAAM,UAAU,aAAa;IAC3B,MAAM,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,CAAC;IAE5D,uCAAuC;IACvC,MAAM,iBAAiB,GAAG;QACxB,uBAAuB;QACvB,uBAAuB;QACvB,uBAAuB;KACxB,CAAC;IAEF,qDAAqD;IACrD,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY;QACzC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QAClE,CAAC,CAAC,EAAE,CAAC;IAEP,MAAM,cAAc,GAAG,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,iBAAiB,CAAC;IAE9E,2CAA2C;IAC3C,IAAI,CAAC,aAAa,IAAI,cAAc,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAClD,OAAO,CAAC,KAAK,CAAC,sDAAsD,CAAC,CAAC;QACtE,OAAO,CAAC,KAAK,CAAC,+EAA+E,CAAC,CAAC;QAC/F,OAAO,CAAC,KAAK,CAAC,4EAA4E,CAAC,CAAC;QAC5F,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,OAAO,CAAC,GAAG,CAAC,2BAA2B,aAAa,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,YAAY,EAAE,CAAC,CAAC;IACtF,OAAO,CAAC,GAAG,CAAC,2BAA2B,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAEpE,OAAO;QACL,MAAM,EAAE,CAAC,MAA0B,EAAE,QAAsD,EAAE,EAAE;YAC7F,+EAA+E;YAC/E,IAAI,CAAC,MAAM,EAAE,CAAC;gBACZ,IAAI,aAAa,EAAE,CAAC;oBAClB,OAAO,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;gBAC9B,CAAC;qBAAM,CAAC;oBACN,OAAO,QAAQ,CAAC,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAC,CAAC;gBAClF,CAAC;YACH,CAAC;YAED,oDAAoD;YACpD,IAAI,cAAc,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;gBACpC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;YACvB,CAAC;iBAAM,CAAC;gBACN,OAAO,CAAC,IAAI,CAAC,oDAAoD,MAAM,EAAE,CAAC,CAAC;gBAC3E,QAAQ,CAAC,IAAI,KAAK,CAAC,UAAU,MAAM,wBAAwB,CAAC,CAAC,CAAC;YAChE,CAAC;QACH,CAAC;QACD,WAAW,EAAE,IAAI;QACjB,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,CAAC;QAC7D,cAAc,EAAE,CAAC,cAAc,EAAE,eAAe,EAAE,kBAAkB,CAAC;QACrE,cAAc,EAAE,CAAC,eAAe,EAAE,cAAc,CAAC;QACjD,MAAM,EAAE,KAAK,EAAE,WAAW;KAC3B,CAAC;AACJ,CAAC"}

View file

@ -1,33 +0,0 @@
/**
* Validación de Variables de Entorno
* Valida todas las variables requeridas al startup
*/
interface EnvConfig {
db: {
host: string;
port: number;
database: string;
user: string;
password: string;
};
jwt: {
secret: string;
expiresIn: string;
};
cors: {
origins: string[];
};
webhook: {
secret: string;
};
redis: {
url: string;
};
nodeEnv: string;
}
/**
* Validar variables de entorno requeridas
*/
export declare function validateEnv(): EnvConfig;
export {};
//# sourceMappingURL=env.d.ts.map

View file

@ -1 +0,0 @@
{"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../../../src/config/env.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,UAAU,SAAS;IACjB,EAAE,EAAE;QACF,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;QACjB,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;KAClB,CAAC;IACF,GAAG,EAAE;QACH,MAAM,EAAE,MAAM,CAAC;QACf,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;IACF,IAAI,EAAE;QACJ,OAAO,EAAE,MAAM,EAAE,CAAC;KACnB,CAAC;IACF,OAAO,EAAE;QACP,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,KAAK,EAAE;QACL,GAAG,EAAE,MAAM,CAAC;KACb,CAAC;IACF,OAAO,EAAE,MAAM,CAAC;CACjB;AAyBD;;GAEG;AACH,wBAAgB,WAAW,IAAI,SAAS,CAmEvC"}

View file

@ -1,94 +0,0 @@
/**
* Validación de Variables de Entorno
* Valida todas las variables requeridas al startup
*/
import dotenv from 'dotenv';
dotenv.config();
/**
* Variables de entorno requeridas por categoría
*/
const REQUIRED_ENV_VARS = {
database: [
'DB_HOST',
'DB_NAME',
'DB_USER',
'DB_PASSWORD',
],
security: [
'JWT_SECRET',
],
optional: [
'DB_PORT',
'JWT_EXPIRES_IN',
'CORS_ORIGINS',
'WEBHOOK_SECRET',
'REDIS_URL',
'NODE_ENV',
],
};
/**
* Validar variables de entorno requeridas
*/
export function validateEnv() {
const missing = [];
const warnings = [];
// Validar variables requeridas
Object.entries(REQUIRED_ENV_VARS).forEach(([category, vars]) => {
vars.forEach(key => {
if (!process.env[key]) {
if (category !== 'optional') {
missing.push(`${key} (${category})`);
}
else {
warnings.push(`${key} (${category})`);
}
}
});
});
// Validar formatos específicos
if (process.env.JWT_SECRET && process.env.JWT_SECRET.length < 32) {
missing.push('JWT_SECRET debe tener al menos 32 caracteres');
}
if (process.env.DB_PORT && isNaN(parseInt(process.env.DB_PORT, 10))) {
missing.push('DB_PORT debe ser un número válido');
}
// Mostrar advertencias
if (warnings.length > 0 && process.env.NODE_ENV === 'production') {
console.warn('⚠️ Variables opcionales no configuradas (recomendadas en producción):');
warnings.forEach(key => console.warn(` - ${key}`));
}
// Si faltan variables críticas, salir
if (missing.length > 0) {
console.error('❌ CRÍTICO: Variables de entorno faltantes o inválidas:');
missing.forEach(key => console.error(` - ${key}`));
console.error('\n⚠ Configura estas variables en el archivo .env\n');
process.exit(1);
}
console.log('✅ Variables de entorno validadas correctamente');
return {
db: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: process.env.DB_NAME || 'emerges_tes',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || '',
},
jwt: {
secret: process.env.JWT_SECRET || '',
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
},
cors: {
origins: process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
: [],
},
webhook: {
secret: process.env.WEBHOOK_SECRET || '',
},
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
},
nodeEnv: process.env.NODE_ENV || 'development',
};
}
//# sourceMappingURL=env.js.map

View file

@ -1 +0,0 @@
{"version":3,"file":"env.js","sourceRoot":"","sources":["../../../src/config/env.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,MAAM,MAAM,QAAQ,CAAC;AAC5B,MAAM,CAAC,MAAM,EAAE,CAAC;AA0BhB;;GAEG;AACH,MAAM,iBAAiB,GAAG;IACxB,QAAQ,EAAE;QACR,SAAS;QACT,SAAS;QACT,SAAS;QACT,aAAa;KACd;IACD,QAAQ,EAAE;QACR,YAAY;KACb;IACD,QAAQ,EAAE;QACR,SAAS;QACT,gBAAgB;QAChB,cAAc;QACd,gBAAgB;QAChB,WAAW;QACX,UAAU;KACX;CACF,CAAC;AAEF;;GAEG;AACH,MAAM,UAAU,WAAW;IACzB,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,MAAM,QAAQ,GAAa,EAAE,CAAC;IAE9B,+BAA+B;IAC/B,MAAM,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,EAAE,IAAI,CAAC,EAAE,EAAE;QAC7D,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE;YACjB,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;gBACtB,IAAI,QAAQ,KAAK,UAAU,EAAE,CAAC;oBAC5B,OAAO,CAAC,IAAI,CAAC,GAAG,GAAG,KAAK,QAAQ,GAAG,CAAC,CAAC;gBACvC,CAAC;qBAAM,CAAC;oBACN,QAAQ,CAAC,IAAI,CAAC,GAAG,GAAG,KAAK,QAAQ,GAAG,CAAC,CAAC;gBACxC,CAAC;YACH,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,+BAA+B;IAC/B,IAAI,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,MAAM,GAAG,EAAE,EAAE,CAAC;QACjE,OAAO,CAAC,IAAI,CAAC,8CAA8C,CAAC,CAAC;IAC/D,CAAC;IAED,IAAI,OAAO,CAAC,GAAG,CAAC,OAAO,IAAI,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC;QACpE,OAAO,CAAC,IAAI,CAAC,mCAAmC,CAAC,CAAC;IACpD,CAAC;IAED,uBAAuB;IACvB,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;QACjE,OAAO,CAAC,IAAI,CAAC,wEAAwE,CAAC,CAAC;QACvF,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;IACvD,CAAC;IAED,sCAAsC;IACtC,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACvB,OAAO,CAAC,KAAK,CAAC,wDAAwD,CAAC,CAAC;QACxE,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;QACrD,OAAO,CAAC,KAAK,CAAC,sDAAsD,CAAC,CAAC;QACtE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,OAAO,CAAC,GAAG,CAAC,gDAAgD,CAAC,CAAC;IAE9D,OAAO;QACL,EAAE,EAAE;YACF,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,OAAO,IAAI,WAAW;YACxC,IAAI,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,IAAI,MAAM,EAAE,EAAE,CAAC;YACjD,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,OAAO,IAAI,aAAa;YAC9C,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,OAAO,IAAI,UAAU;YACvC,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,EAAE;SACxC;QACD,GAAG,EAAE;YACH,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,EAAE;YACpC,SAAS,EAAE,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,KAAK;SAC/C;QACD,IAAI,EAAE;YACJ,OAAO,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY;gBAC/B,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;gBACxD,CAAC,CAAC,EAAE;SACP;QACD,OAAO,EAAE;YACP,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,EAAE;SACzC;QACD,KAAK,EAAE;YACL,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,SAAS,IAAI,wBAAwB;SACvD;QACD,OAAO,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,IAAI,aAAa;KAC/C,CAAC;AACJ,CAAC"}

View file

@ -1,16 +0,0 @@
/**
* Configuración de Seguridad
* Valida variables de entorno críticas al startup
*/
interface SecurityConfig {
JWT_SECRET: string;
JWT_EXPIRES_IN: string;
WEBHOOK_SECRET: string;
}
/**
* Validar configuración de seguridad
* Si alguna variable crítica falta, la app no arranca
*/
export declare function validateSecurityConfig(): SecurityConfig;
export {};
//# sourceMappingURL=security.d.ts.map

View file

@ -1 +0,0 @@
{"version":3,"file":"security.d.ts","sourceRoot":"","sources":["../../../src/config/security.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,UAAU,cAAc;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,cAAc,CA2CvD"}

View file

@ -1,52 +0,0 @@
/**
* Configuración de Seguridad
* Valida variables de entorno críticas al startup
*/
import dotenv from 'dotenv';
dotenv.config();
/**
* Validar configuración de seguridad
* Si alguna variable crítica falta, la app no arranca
*/
export function validateSecurityConfig() {
const errors = [];
// JWT_SECRET es crítico - debe existir y no ser el valor por defecto
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
errors.push('JWT_SECRET no está configurado en .env');
console.error('❌ CRÍTICO: JWT_SECRET no configurado');
console.error(' Generar con: openssl rand -base64 32');
}
else if (JWT_SECRET === 'emerges-tes-secret-key-change-in-production') {
errors.push('JWT_SECRET está usando el valor por defecto inseguro');
console.error('❌ CRÍTICO: JWT_SECRET usa valor por defecto inseguro');
console.error(' Generar un secret seguro con: openssl rand -base64 32');
console.error(' Y actualizarlo en .env');
}
else if (JWT_SECRET.length < 32) {
errors.push('JWT_SECRET debe tener al menos 32 caracteres');
console.error('❌ CRÍTICO: JWT_SECRET demasiado corto (mínimo 32 caracteres)');
}
// WEBHOOK_SECRET es crítico en producción
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
if (process.env.NODE_ENV === 'production' && !WEBHOOK_SECRET) {
errors.push('WEBHOOK_SECRET no configurado en producción');
console.error('❌ CRÍTICO: WEBHOOK_SECRET no configurado en producción');
}
// Si hay errores críticos, salir
if (errors.length > 0) {
console.error('\n🚨 ERRORES DE SEGURIDAD ENCONTRADOS:');
errors.forEach((error, index) => {
console.error(` ${index + 1}. ${error}`);
});
console.error('\n⚠ La aplicación no puede iniciarse con estos errores de seguridad.\n');
process.exit(1);
}
console.log('✅ Variables de seguridad validadas correctamente');
return {
JWT_SECRET: JWT_SECRET || '',
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '24h',
WEBHOOK_SECRET: WEBHOOK_SECRET || '',
};
}
//# sourceMappingURL=security.js.map

View file

@ -1 +0,0 @@
{"version":3,"file":"security.js","sourceRoot":"","sources":["../../../src/config/security.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,MAAM,MAAM,QAAQ,CAAC;AAC5B,MAAM,CAAC,MAAM,EAAE,CAAC;AAQhB;;;GAGG;AACH,MAAM,UAAU,sBAAsB;IACpC,MAAM,MAAM,GAAa,EAAE,CAAC;IAE5B,qEAAqE;IACrE,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC;IAC1C,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,CAAC,IAAI,CAAC,wCAAwC,CAAC,CAAC;QACtD,OAAO,CAAC,KAAK,CAAC,sCAAsC,CAAC,CAAC;QACtD,OAAO,CAAC,KAAK,CAAC,yCAAyC,CAAC,CAAC;IAC3D,CAAC;SAAM,IAAI,UAAU,KAAK,6CAA6C,EAAE,CAAC;QACxE,MAAM,CAAC,IAAI,CAAC,sDAAsD,CAAC,CAAC;QACpE,OAAO,CAAC,KAAK,CAAC,sDAAsD,CAAC,CAAC;QACtE,OAAO,CAAC,KAAK,CAAC,0DAA0D,CAAC,CAAC;QAC1E,OAAO,CAAC,KAAK,CAAC,2BAA2B,CAAC,CAAC;IAC7C,CAAC;SAAM,IAAI,UAAU,CAAC,MAAM,GAAG,EAAE,EAAE,CAAC;QAClC,MAAM,CAAC,IAAI,CAAC,8CAA8C,CAAC,CAAC;QAC5D,OAAO,CAAC,KAAK,CAAC,8DAA8D,CAAC,CAAC;IAChF,CAAC;IAED,0CAA0C;IAC1C,MAAM,cAAc,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC;IAClD,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,IAAI,CAAC,cAAc,EAAE,CAAC;QAC7D,MAAM,CAAC,IAAI,CAAC,6CAA6C,CAAC,CAAC;QAC3D,OAAO,CAAC,KAAK,CAAC,wDAAwD,CAAC,CAAC;IAC1E,CAAC;IAED,iCAAiC;IACjC,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtB,OAAO,CAAC,KAAK,CAAC,wCAAwC,CAAC,CAAC;QACxD,MAAM,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE;YAC9B,OAAO,CAAC,KAAK,CAAC,MAAM,KAAK,GAAG,CAAC,KAAK,KAAK,EAAE,CAAC,CAAC;QAC7C,CAAC,CAAC,CAAC;QACH,OAAO,CAAC,KAAK,CAAC,0EAA0E,CAAC,CAAC;QAC1F,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,OAAO,CAAC,GAAG,CAAC,kDAAkD,CAAC,CAAC;IAEhE,OAAO;QACL,UAAU,EAAE,UAAU,IAAI,EAAE;QAC5B,cAAc,EAAE,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,KAAK;QACnD,cAAc,EAAE,cAAc,IAAI,EAAE;KACrC,CAAC;AACJ,CAAC"}

View file

@ -1,35 +0,0 @@
/**
* ContentItem Entity
* Domain Layer - Entidad inmutable de dominio
*/
import type { ContentStatus } from '../value-objects/ContentStatus.js';
import type { ContentPriority } from '../value-objects/ContentPriority.js';
export type ContentType = 'protocol' | 'guide' | 'manual' | 'checklist';
export type ContentLevel = 'operativo' | 'formativo' | 'referencia';
export type AgeGroup = 'adulto' | 'pediatrico' | 'neonatal' | 'todos';
export interface ContentItem {
readonly id: string;
readonly type: ContentType;
readonly slug: string;
readonly level: ContentLevel;
readonly title: string;
readonly shortTitle?: string;
readonly description?: string;
readonly content: Record<string, unknown>;
readonly contentMarkdown?: string;
readonly category?: string;
readonly subcategory?: string;
readonly priority: ContentPriority;
readonly ageGroup?: AgeGroup;
readonly status: ContentStatus;
readonly version: number;
readonly latestVersion: number;
readonly validatedBy?: string;
readonly validatedAt?: Date;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly createdBy: string;
readonly updatedBy: string;
readonly tags?: readonly string[];
}
//# sourceMappingURL=ContentItem.d.ts.map

View file

@ -1 +0,0 @@
{"version":3,"file":"ContentItem.d.ts","sourceRoot":"","sources":["../../../../src/domain/entities/ContentItem.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AACvE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qCAAqC,CAAC;AAE3E,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,OAAO,GAAG,QAAQ,GAAG,WAAW,CAAC;AACxE,MAAM,MAAM,YAAY,GAAG,WAAW,GAAG,WAAW,GAAG,YAAY,CAAC;AACpE,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,YAAY,GAAG,UAAU,GAAG,OAAO,CAAC;AAEtE,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC1C,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,CAAC,EAAE,IAAI,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACnC"}

View file

@ -1,6 +0,0 @@
/**
* ContentItem Entity
* Domain Layer - Entidad inmutable de dominio
*/
export {};
//# sourceMappingURL=ContentItem.js.map

View file

@ -1 +0,0 @@
{"version":3,"file":"ContentItem.js","sourceRoot":"","sources":["../../../../src/domain/entities/ContentItem.ts"],"names":[],"mappings":"AAAA;;;GAGG"}

View file

@ -1,36 +0,0 @@
/**
* Drug Entity
* Domain Layer - Entidad inmutable de dominio
*/
import type { ContentStatus } from '../value-objects/ContentStatus.js';
export type DrugCategory = 'cardiovascular' | 'respiratorio' | 'neurologico' | 'analgesia' | 'oxigenoterapia' | 'otros';
export type AdministrationRoute = 'IV' | 'IM' | 'SC' | 'IO' | 'Nebulizado' | 'SL' | 'Rectal' | 'Nasal';
export interface Drug {
readonly id: string;
readonly slug: string;
readonly genericName: string;
readonly tradeName?: string;
readonly category: DrugCategory;
readonly line: 'first' | 'second';
readonly frequency: 'high' | 'medium' | 'low';
readonly presentation: string;
readonly adultDose: string;
readonly pediatricDose?: string;
readonly routes: readonly AdministrationRoute[];
readonly dilution?: string;
readonly indications: readonly string[];
readonly contraindications: readonly string[];
readonly sideEffects?: string;
readonly antidote?: string;
readonly notes: readonly string[];
readonly criticalPoints: readonly string[];
readonly source?: string;
readonly status: ContentStatus;
readonly version: string;
readonly latestVersion: string;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly createdBy: string;
readonly updatedBy?: string;
}
//# sourceMappingURL=Drug.d.ts.map

View file

@ -1 +0,0 @@
{"version":3,"file":"Drug.d.ts","sourceRoot":"","sources":["../../../../src/domain/entities/Drug.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAEvE,MAAM,MAAM,YAAY,GACpB,gBAAgB,GAChB,cAAc,GACd,aAAa,GACb,WAAW,GACX,gBAAgB,GAChB,OAAO,CAAC;AAEZ,MAAM,MAAM,mBAAmB,GAC3B,IAAI,GACJ,IAAI,GACJ,IAAI,GACJ,IAAI,GACJ,YAAY,GACZ,IAAI,GACJ,QAAQ,GACR,OAAO,CAAC;AAEZ,MAAM,WAAW,IAAI;IACnB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;IAChC,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAC;IAClC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC;IAC9C,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,MAAM,EAAE,SAAS,mBAAmB,EAAE,CAAC;IAChD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC,QAAQ,CAAC,iBAAiB,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9C,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B"}

View file

@ -1,6 +0,0 @@
/**
* Drug Entity
* Domain Layer - Entidad inmutable de dominio
*/
export {};
//# sourceMappingURL=Drug.js.map

View file

@ -1 +0,0 @@
{"version":3,"file":"Drug.js","sourceRoot":"","sources":["../../../../src/domain/entities/Drug.ts"],"names":[],"mappings":"AAAA;;;GAGG"}

View file

@ -1,23 +0,0 @@
/**
* GlossaryTerm Entity
* Domain Layer - Entidad inmutable de dominio
*/
import type { ContentStatus } from '../value-objects/ContentStatus.js';
export type GlossaryCategory = 'pharmaceutical' | 'anatomical' | 'clinical' | 'procedural';
export interface GlossaryTerm {
readonly id: string;
readonly term: string;
readonly abbreviation?: string;
readonly category: GlossaryCategory;
readonly definition: string;
readonly context?: string;
readonly examples?: readonly string[];
readonly relatedTerms?: readonly string[];
readonly source?: string;
readonly status: ContentStatus;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly createdBy: string;
readonly updatedBy?: string;
}
//# sourceMappingURL=GlossaryTerm.d.ts.map

View file

@ -1 +0,0 @@
{"version":3,"file":"GlossaryTerm.d.ts","sourceRoot":"","sources":["../../../../src/domain/entities/GlossaryTerm.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAEvE,MAAM,MAAM,gBAAgB,GACxB,gBAAgB,GAChB,YAAY,GACZ,UAAU,GACV,YAAY,CAAC;AAEjB,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,QAAQ,EAAE,gBAAgB,CAAC;IACpC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1C,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B"}

View file

@ -1,6 +0,0 @@
/**
* GlossaryTerm Entity
* Domain Layer - Entidad inmutable de dominio
*/
export {};
//# sourceMappingURL=GlossaryTerm.js.map

View file

@ -1 +0,0 @@
{"version":3,"file":"GlossaryTerm.js","sourceRoot":"","sources":["../../../../src/domain/entities/GlossaryTerm.ts"],"names":[],"mappings":"AAAA;;;GAGG"}

View file

@ -1,34 +0,0 @@
/**
* MediaResource Entity
* Domain Layer - Entidad inmutable de dominio
*/
import type { ContentStatus } from '../value-objects/ContentStatus.js';
import type { ContentPriority } from '../value-objects/ContentPriority.js';
export type MediaType = 'image' | 'video' | 'audio' | 'document';
export interface MediaResource {
readonly id: string;
readonly type: MediaType;
readonly path: string;
readonly filename: string;
readonly fileUrl: string;
readonly thumbnailUrl?: string;
readonly title?: string;
readonly description?: string;
readonly altText?: string;
readonly caption?: string;
readonly tags: readonly string[];
readonly block?: string;
readonly chapter?: string;
readonly priority: ContentPriority;
readonly width?: number;
readonly height?: number;
readonly format?: string;
readonly fileSize: number;
readonly durationSeconds?: number;
readonly status: ContentStatus;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly createdBy: string;
readonly updatedBy?: string;
}
//# sourceMappingURL=MediaResource.d.ts.map

View file

@ -1 +0,0 @@
{"version":3,"file":"MediaResource.d.ts","sourceRoot":"","sources":["../../../../src/domain/entities/MediaResource.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AACvE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qCAAqC,CAAC;AAE3E,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,GAAG,UAAU,CAAC;AAEjE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B"}

View file

@ -1,6 +0,0 @@
/**
* MediaResource Entity
* Domain Layer - Entidad inmutable de dominio
*/
export {};
//# sourceMappingURL=MediaResource.js.map

Some files were not shown because too many files have changed in this diff Show more