Compare commits
No commits in common. "5ba4a25182332497cfab36a5a022d7f089704ac9" and "0201f16cf4885e77ca622b468c3dc400f9a845f9" have entirely different histories.
5ba4a25182
...
0201f16cf4
88
.dockerignore
Executable file
88
.dockerignore
Executable file
|
|
@ -0,0 +1,88 @@
|
|||
# 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
Normal file → Executable file
58
.github/workflows/deploy.yml
vendored
Normal file → Executable file
|
|
@ -1,33 +1,61 @@
|
|||
name: Deploy Código 0
|
||||
name: Auto Deploy to Server
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: # Permite ejecutar manualmente
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
- name: Checkout código
|
||||
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: Deploy to VPS via Docker SSH
|
||||
- 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
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
passphrase: ${{ secrets.SSH_PASSPHRASE }} # Opcional si la clave tiene contraseña
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||
port: ${{ secrets.SERVER_PORT || 22 }}
|
||||
script: |
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,175 +0,0 @@
|
|||
# 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)*
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
# 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
Normal file → Executable file
60
.planning/PROJECT.md
Normal file → Executable file
|
|
@ -1,71 +1,49 @@
|
|||
# Código0 Nuevo
|
||||
# EMERGES TES - Limpieza y Arreglos
|
||||
|
||||
## What This Is
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Core Value
|
||||
|
||||
Provide medical professionals with instant, reliable access to emergency protocols, drug information, and clinical calculators in a fast, offline-capable web application.
|
||||
Mantener la funcionalidad existente mientras se mejora la calidad del código y se elimina deuda técnica.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Validated
|
||||
|
||||
- ✓ 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
|
||||
(None yet - ship to validate)
|
||||
|
||||
### Active
|
||||
|
||||
- [ ] 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)
|
||||
- [ ] 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
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- 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)
|
||||
- [ ] Nuevas funcionalidades - solo limpieza
|
||||
- [ ] Cambios de arquitectura mayores
|
||||
|
||||
## Context
|
||||
|
||||
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.
|
||||
Proyecto existente con:
|
||||
- Frontend: React 19 + TypeScript + Vite + Tailwind
|
||||
- Backend: Express + PostgreSQL + Clean Architecture
|
||||
- PWA para emergencias médicas
|
||||
|
||||
## Constraints
|
||||
|
||||
- **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)
|
||||
- **[Stack]**: Mantener tecnologías actuales — No cambiar framework
|
||||
- **[Funcionalidad]**: No romper features existentes — Todos los tests deben pasar
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Rationale | Outcome |
|
||||
|----------|-----------|---------|
|
||||
| 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 |
|
||||
| Modo interactivo | Preferimos aprobar cada paso | — Pending |
|
||||
|
||||
---
|
||||
*Last updated: 2026-03-13 after initialization*
|
||||
*Last updated: 2026-03-11 after initialization*
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
# 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*
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
# 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*
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
# 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*
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
# 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
Normal file → Executable file
11
.planning/config.json
Normal file → Executable file
|
|
@ -1,15 +1,14 @@
|
|||
{
|
||||
"mode": "yolo",
|
||||
"granularity": "Coarse (Recommended)",
|
||||
"mode": "interactive",
|
||||
"granularity": "standard",
|
||||
"parallelization": true,
|
||||
"commit_docs": true,
|
||||
"model_profile": "Balanced (Recommended)",
|
||||
"model_profile": "balanced",
|
||||
"workflow": {
|
||||
"research": true,
|
||||
"plan_check": true,
|
||||
"verifier": true,
|
||||
"nyquist_validation": true,
|
||||
"auto_advance": true,
|
||||
"_auto_chain_active": true
|
||||
"auto_advance": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# 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
3
.vscode/settings.json
vendored
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"git.ignoreLimitWarning": true
|
||||
}
|
||||
28
CONTEXT.md
28
CONTEXT.md
|
|
@ -1,28 +0,0 @@
|
|||
# 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.*
|
||||
64
Dockerfile
Executable file
64
Dockerfile
Executable file
|
|
@ -0,0 +1,64 @@
|
|||
# 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
Executable file
21
LICENSE
Executable file
|
|
@ -0,0 +1,21 @@
|
|||
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.
|
||||
8602
MANIFESTO_MEDIOS.json
Executable file
8602
MANIFESTO_MEDIOS.json
Executable file
File diff suppressed because it is too large
Load diff
30
PROGRESS.md
30
PROGRESS.md
|
|
@ -1,30 +0,0 @@
|
|||
# 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.
|
||||
249
README.md
Normal file → Executable file
249
README.md
Normal file → Executable file
|
|
@ -1,23 +1,236 @@
|
|||
# codigo0
|
||||
# EMERGES TES - Guía Digital de Protocolos de Emergencias 🏥
|
||||
|
||||
Asistente avanzado de referencia médica para Técnicos de Emergencias Sanitarias (TES).
|
||||
**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.
|
||||
|
||||
## 🚀 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)
|
||||
|
||||
## 📄 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:
|
||||
|
||||
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.
|
||||
## 👤 Autor
|
||||
**Javier Fernández** · [@planetazuzu](https://github.com/planetazuzu)
|
||||
TES · Developer · La Rioja 🇪🇸
|
||||
|
||||
---
|
||||
*codigo0 — 0 Errores. 0 Dudas.*
|
||||
|
||||
## 🎯 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.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Stack Tecnológico
|
||||
|
||||
### 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
|
||||
|
||||
### 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`)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 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**
|
||||
|
|
|
|||
130
admin-panel/DIAGNOSTICO.md
Executable file
130
admin-panel/DIAGNOSTICO.md
Executable file
|
|
@ -0,0 +1,130 @@
|
|||
# 🔍 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);
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
97
admin-panel/README.md
Executable file
97
admin-panel/README.md
Executable file
|
|
@ -0,0 +1,97 @@
|
|||
# 🎛️ 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
|
||||
```
|
||||
|
||||
14
admin-panel/index.html
Executable file
14
admin-panel/index.html
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
<!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>
|
||||
|
||||
6039
admin-panel/package-lock.json
generated
Executable file
6039
admin-panel/package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load diff
38
admin-panel/package.json
Executable file
38
admin-panel/package.json
Executable file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
78
admin-panel/shared/types/auth.ts
Executable file
78
admin-panel/shared/types/auth.ts
Executable file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* 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',
|
||||
],
|
||||
};
|
||||
|
||||
464
admin-panel/shared/types/content-canonical.ts
Executable file
464
admin-panel/shared/types/content-canonical.ts
Executable file
|
|
@ -0,0 +1,464 @@
|
|||
/**
|
||||
* 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,
|
||||
};
|
||||
|
||||
357
admin-panel/shared/types/content.ts
Executable file
357
admin-panel/shared/types/content.ts
Executable file
|
|
@ -0,0 +1,357 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
66
admin-panel/src/App.tsx
Executable file
66
admin-panel/src/App.tsx
Executable file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* 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;
|
||||
|
||||
43
admin-panel/src/components/auth/ProtectedRoute.tsx
Executable file
43
admin-panel/src/components/auth/ProtectedRoute.tsx
Executable file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* 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}</>;
|
||||
}
|
||||
|
||||
404
admin-panel/src/components/content/ResourcesManager.tsx
Executable file
404
admin-panel/src/components/content/ResourcesManager.tsx
Executable file
|
|
@ -0,0 +1,404 @@
|
|||
/**
|
||||
* 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
157
admin-panel/src/components/content/ValidationHistory.tsx
Executable file
157
admin-panel/src/components/content/ValidationHistory.tsx
Executable file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
159
admin-panel/src/components/layout/Layout.tsx
Executable file
159
admin-panel/src/components/layout/Layout.tsx
Executable file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
113
admin-panel/src/contexts/AuthContext.tsx
Executable file
113
admin-panel/src/contexts/AuthContext.tsx
Executable file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
92
admin-panel/src/hooks/useContentStats.ts
Executable file
92
admin-panel/src/hooks/useContentStats.ts
Executable file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
34
admin-panel/src/index.css
Executable file
34
admin-panel/src/index.css
Executable file
|
|
@ -0,0 +1,34 @@
|
|||
@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));
|
||||
}
|
||||
|
||||
11
admin-panel/src/main.tsx
Executable file
11
admin-panel/src/main.tsx
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
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>
|
||||
);
|
||||
|
||||
23
admin-panel/src/pages/AuditPage.tsx
Executable file
23
admin-panel/src/pages/AuditPage.tsx
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
482
admin-panel/src/pages/ChecklistEditorPage.tsx
Executable file
482
admin-panel/src/pages/ChecklistEditorPage.tsx
Executable file
|
|
@ -0,0 +1,482 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
368
admin-panel/src/pages/ContentLibraryPage.tsx
Executable file
368
admin-panel/src/pages/ContentLibraryPage.tsx
Executable file
|
|
@ -0,0 +1,368 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
309
admin-panel/src/pages/ContentPackPage.tsx
Executable file
309
admin-panel/src/pages/ContentPackPage.tsx
Executable file
|
|
@ -0,0 +1,309 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
179
admin-panel/src/pages/DashboardPage.tsx
Executable file
179
admin-panel/src/pages/DashboardPage.tsx
Executable file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
647
admin-panel/src/pages/DrugEditorPage.tsx
Executable file
647
admin-panel/src/pages/DrugEditorPage.tsx
Executable file
|
|
@ -0,0 +1,647 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
425
admin-panel/src/pages/DrugManagerPage.tsx
Executable file
425
admin-panel/src/pages/DrugManagerPage.tsx
Executable file
|
|
@ -0,0 +1,425 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
581
admin-panel/src/pages/GuideEditorPage.tsx
Executable file
581
admin-panel/src/pages/GuideEditorPage.tsx
Executable file
|
|
@ -0,0 +1,581 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
112
admin-panel/src/pages/LoginPage.tsx
Executable file
112
admin-panel/src/pages/LoginPage.tsx
Executable file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
520
admin-panel/src/pages/MediaManagerPage.tsx
Executable file
520
admin-panel/src/pages/MediaManagerPage.tsx
Executable file
|
|
@ -0,0 +1,520 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
1264
admin-panel/src/pages/ProtocolEditorPage.tsx
Executable file
1264
admin-panel/src/pages/ProtocolEditorPage.tsx
Executable file
File diff suppressed because it is too large
Load diff
549
admin-panel/src/pages/ValidationPage.tsx
Executable file
549
admin-panel/src/pages/ValidationPage.tsx
Executable file
|
|
@ -0,0 +1,549 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
48
admin-panel/src/services/auth.ts
Executable file
48
admin-panel/src/services/auth.ts
Executable file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
63
admin-panel/src/services/content.ts
Executable file
63
admin-panel/src/services/content.ts
Executable file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* 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 });
|
||||
},
|
||||
};
|
||||
|
||||
34
admin-panel/tailwind.config.js
Executable file
34
admin-panel/tailwind.config.js
Executable file
|
|
@ -0,0 +1,34 @@
|
|||
/** @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: [],
|
||||
}
|
||||
|
||||
26
admin-panel/tsconfig.json
Executable file
26
admin-panel/tsconfig.json
Executable file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"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" }]
|
||||
}
|
||||
|
||||
12
admin-panel/tsconfig.node.json
Executable file
12
admin-panel/tsconfig.node.json
Executable file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
16
admin-panel/vite.config.ts
Executable file
16
admin-panel/vite.config.ts
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
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,
|
||||
},
|
||||
});
|
||||
|
||||
52
backend/CONFIGURAR_PASSWORD.md
Executable file
52
backend/CONFIGURAR_PASSWORD.md
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
# ⚠️ 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())"
|
||||
```
|
||||
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
# 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"]
|
||||
25
backend/ENV_TEMPLATE.md
Executable file
25
backend/ENV_TEMPLATE.md
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
# 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
|
||||
```
|
||||
|
||||
47
backend/INSTRUCCIONES_CREAR_USUARIO.md
Executable file
47
backend/INSTRUCCIONES_CREAR_USUARIO.md
Executable file
|
|
@ -0,0 +1,47 @@
|
|||
# 🔧 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
|
||||
```
|
||||
102
backend/README.md
Executable file
102
backend/README.md
Executable file
|
|
@ -0,0 +1,102 @@
|
|||
# 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
|
||||
|
||||
82
backend/README_ESTRUCTURA.md
Executable file
82
backend/README_ESTRUCTURA.md
Executable file
|
|
@ -0,0 +1,82 @@
|
|||
# 🏗️ 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
|
||||
59
backend/config/database.ts
Executable file
59
backend/config/database.ts
Executable file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
54
backend/crear-usuario-y-bd.sh
Executable file
54
backend/crear-usuario-y-bd.sh
Executable file
|
|
@ -0,0 +1,54 @@
|
|||
#!/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"
|
||||
47
backend/database/migrations/001_create_auth_schema.sql
Executable file
47
backend/database/migrations/001_create_auth_schema.sql
Executable file
|
|
@ -0,0 +1,47 @@
|
|||
-- ============================================
|
||||
-- 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';
|
||||
|
||||
217
backend/database/migrations/002_create_drugs_schema.sql
Executable file
217
backend/database/migrations/002_create_drugs_schema.sql
Executable file
|
|
@ -0,0 +1,217 @@
|
|||
-- ============================================
|
||||
-- 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
|
||||
-- ============================================
|
||||
|
||||
267
backend/database/migrations/003_create_content_items_schema.sql
Executable file
267
backend/database/migrations/003_create_content_items_schema.sql
Executable file
|
|
@ -0,0 +1,267 @@
|
|||
-- ============================================
|
||||
-- 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';
|
||||
|
||||
60
backend/database/migrations/004_create_glossary_schema.sql
Executable file
60
backend/database/migrations/004_create_glossary_schema.sql
Executable file
|
|
@ -0,0 +1,60 @@
|
|||
-- ============================================
|
||||
-- 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)';
|
||||
26
backend/dist/config/database.d.ts
vendored
Executable file
26
backend/dist/config/database.d.ts
vendored
Executable file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* 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
|
||||
1
backend/dist/config/database.d.ts.map
vendored
Executable file
1
backend/dist/config/database.d.ts.map
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
53
backend/dist/config/database.js
vendored
Executable file
53
backend/dist/config/database.js
vendored
Executable file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* 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
|
||||
1
backend/dist/config/database.js.map
vendored
Executable file
1
backend/dist/config/database.js.map
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
10
backend/dist/src/config/cors.d.ts
vendored
Executable file
10
backend/dist/src/config/cors.d.ts
vendored
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* 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
|
||||
1
backend/dist/src/config/cors.d.ts.map
vendored
Executable file
1
backend/dist/src/config/cors.d.ts.map
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
57
backend/dist/src/config/cors.js
vendored
Executable file
57
backend/dist/src/config/cors.js
vendored
Executable file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* 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
|
||||
1
backend/dist/src/config/cors.js.map
vendored
Executable file
1
backend/dist/src/config/cors.js.map
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
33
backend/dist/src/config/env.d.ts
vendored
Executable file
33
backend/dist/src/config/env.d.ts
vendored
Executable file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* 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
|
||||
1
backend/dist/src/config/env.d.ts.map
vendored
Executable file
1
backend/dist/src/config/env.d.ts.map
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
94
backend/dist/src/config/env.js
vendored
Executable file
94
backend/dist/src/config/env.js
vendored
Executable file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* 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
|
||||
1
backend/dist/src/config/env.js.map
vendored
Executable file
1
backend/dist/src/config/env.js.map
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
16
backend/dist/src/config/security.d.ts
vendored
Executable file
16
backend/dist/src/config/security.d.ts
vendored
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* 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
|
||||
1
backend/dist/src/config/security.d.ts.map
vendored
Executable file
1
backend/dist/src/config/security.d.ts.map
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
52
backend/dist/src/config/security.js
vendored
Executable file
52
backend/dist/src/config/security.js
vendored
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* 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
|
||||
1
backend/dist/src/config/security.js.map
vendored
Executable file
1
backend/dist/src/config/security.js.map
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
35
backend/dist/src/domain/entities/ContentItem.d.ts
vendored
Executable file
35
backend/dist/src/domain/entities/ContentItem.d.ts
vendored
Executable file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* 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
|
||||
1
backend/dist/src/domain/entities/ContentItem.d.ts.map
vendored
Executable file
1
backend/dist/src/domain/entities/ContentItem.d.ts.map
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
6
backend/dist/src/domain/entities/ContentItem.js
vendored
Executable file
6
backend/dist/src/domain/entities/ContentItem.js
vendored
Executable file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* ContentItem Entity
|
||||
* Domain Layer - Entidad inmutable de dominio
|
||||
*/
|
||||
export {};
|
||||
//# sourceMappingURL=ContentItem.js.map
|
||||
1
backend/dist/src/domain/entities/ContentItem.js.map
vendored
Executable file
1
backend/dist/src/domain/entities/ContentItem.js.map
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"ContentItem.js","sourceRoot":"","sources":["../../../../src/domain/entities/ContentItem.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|
||||
36
backend/dist/src/domain/entities/Drug.d.ts
vendored
Executable file
36
backend/dist/src/domain/entities/Drug.d.ts
vendored
Executable file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* 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
|
||||
1
backend/dist/src/domain/entities/Drug.d.ts.map
vendored
Executable file
1
backend/dist/src/domain/entities/Drug.d.ts.map
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
6
backend/dist/src/domain/entities/Drug.js
vendored
Executable file
6
backend/dist/src/domain/entities/Drug.js
vendored
Executable file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Drug Entity
|
||||
* Domain Layer - Entidad inmutable de dominio
|
||||
*/
|
||||
export {};
|
||||
//# sourceMappingURL=Drug.js.map
|
||||
1
backend/dist/src/domain/entities/Drug.js.map
vendored
Executable file
1
backend/dist/src/domain/entities/Drug.js.map
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"Drug.js","sourceRoot":"","sources":["../../../../src/domain/entities/Drug.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|
||||
23
backend/dist/src/domain/entities/GlossaryTerm.d.ts
vendored
Executable file
23
backend/dist/src/domain/entities/GlossaryTerm.d.ts
vendored
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* 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
|
||||
1
backend/dist/src/domain/entities/GlossaryTerm.d.ts.map
vendored
Executable file
1
backend/dist/src/domain/entities/GlossaryTerm.d.ts.map
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
6
backend/dist/src/domain/entities/GlossaryTerm.js
vendored
Executable file
6
backend/dist/src/domain/entities/GlossaryTerm.js
vendored
Executable file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* GlossaryTerm Entity
|
||||
* Domain Layer - Entidad inmutable de dominio
|
||||
*/
|
||||
export {};
|
||||
//# sourceMappingURL=GlossaryTerm.js.map
|
||||
1
backend/dist/src/domain/entities/GlossaryTerm.js.map
vendored
Executable file
1
backend/dist/src/domain/entities/GlossaryTerm.js.map
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"GlossaryTerm.js","sourceRoot":"","sources":["../../../../src/domain/entities/GlossaryTerm.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|
||||
34
backend/dist/src/domain/entities/MediaResource.d.ts
vendored
Executable file
34
backend/dist/src/domain/entities/MediaResource.d.ts
vendored
Executable file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* 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
|
||||
1
backend/dist/src/domain/entities/MediaResource.d.ts.map
vendored
Executable file
1
backend/dist/src/domain/entities/MediaResource.d.ts.map
vendored
Executable file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
6
backend/dist/src/domain/entities/MediaResource.js
vendored
Executable file
6
backend/dist/src/domain/entities/MediaResource.js
vendored
Executable file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* 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
Loading…
Reference in a new issue