feat: add PluginBlock component for rendering external web components in sandboxed iframes
feat: implement PluginsPage for managing plugins with create, toggle, and delete functionalities feat: create PedagogicalAnalyticsPage for displaying course analytics including quality metrics, discrimination index, and curricular suggestions Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
+10
-11
@@ -96,19 +96,19 @@
|
||||
- [x] **Ética de Datos**: Herramientas para transparencia en el uso de datos por los modelos de IA local. *(MVP backend + UI Studio: endpoint protegido `/ai/data-ethics/summary` con métricas de uso, eventos recientes y campos almacenados; panel Admin en `/admin/data-ethics`.)*
|
||||
|
||||
### Fase 33: Aprendizaje Colaborativo Síncrono 🤝
|
||||
- [ ] **Pizarras Compartidas**: Espacio de dibujo colaborativo integrado en lecciones.
|
||||
- [x] **Pizarras Compartidas**: Espacio de dibujo colaborativo integrado en lecciones. *(MVP completo: backend REST + UI Experience con polling, autosave debounce 1.5s, control de conflictos optimista `revision`/`409` y panel de resolución con diff local vs remoto.)*
|
||||
- [ ] **Edición Multiusuario**: Soporte para documentos compartidos en tiempo real (tipo Google Docs).
|
||||
- [ ] **Salas de Estudio**: Grupos efímeros para resolución de dudas grupales por video.
|
||||
|
||||
### Fase 34: Análisis Pedagógico Profundo 📊
|
||||
- [ ] **Métricas de Calidad**: Análisis automático de la efectividad de las lecciones generadas.
|
||||
- [ ] **Índice de Discriminación**: Estadísticas sobre qué preguntas de quiz discriminan mejor el conocimiento.
|
||||
- [ ] **Sugerencias Curriculares**: IA recomendando cambios en la estructura del curso basada en el rendimiento real.
|
||||
- [x] **Métricas de Calidad**: Análisis automático de la efectividad de las lecciones (completion_rate, failure_rate, abandonment, avg_attempts). *(Backend `GET /courses/{id}/pedagogical/quality-metrics` + UI Studio con barras proporcionales.)*
|
||||
- [x] **Índice de Discriminación**: Estadísticas sobre qué preguntas de quiz discriminan mejor el conocimiento. *(Backend `GET /courses/{id}/pedagogical/discrimination-index` con agrupación por `metadata.block_scores` + clasificación Excelente/Buena/Aceptable/Revisar.)*
|
||||
- [x] **Sugerencias Curriculares**: Reglas automáticas (5 tipos) recomendando cambios en la estructura del curso basada en rendimiento real. *(Backend `GET /courses/{id}/pedagogical/suggestions` + panel Studio con severidad alta/media/info/positivo.)*
|
||||
|
||||
### Fase 35: Ecosistema de Plugins 🔌
|
||||
- [ ] **Arquitectura Modular**: Sistema para que desarrolladores externos agreguen nuevos "Content Blocks".
|
||||
- [ ] **Soporte para Web Components**: Permitir la inclusión de herramientas interactivas externas de forma segura.
|
||||
- [ ] **OpenCCB Market**: Galería interna para descargar y habilitar extensiones.
|
||||
- [x] **Arquitectura Modular**: Tabla `org_plugins` con CRUD completo en `cms-service` (`GET /plugins`, `POST /plugins`, `PUT /plugins/{id}`, `DELETE /plugins/{id}`). Validación HTTPS obligatoria.
|
||||
- [x] **Soporte para Web Components**: Bloque `plugin` en Experience carga el componente en `<iframe sandbox>` seguro con postMessage para config; sin acceso al DOM de OpenCCB.
|
||||
- [x] **OpenCCB Market**: Galería en Studio (`/admin/plugins`) con toggle habilitado/deshabilitado, registro de nuevos plugins y tarjetas con estado visual.
|
||||
|
||||
### Fase 36: LTI 1.3 Tool Consumer 🔗
|
||||
- [ ] **Consumo de herramientas externas**: Capacidad de embeber laboratorios externos (ej: MATLAB, Labster) dentro de OpenCCB.
|
||||
@@ -118,7 +118,6 @@
|
||||
|
||||
**Estado Actual**: Plataforma madura con IA generativa integrada, arquitectura Premium Single-Tenant, búsqueda semántica y monetización operativa.
|
||||
**Próximas Prioridades**:
|
||||
1. Finalización de **Certificados y Progreso Real**.
|
||||
2. Despliegue de **Infraestructura SMTP** para comunicación global.
|
||||
3. Validación en producción de **Ética de Datos** (panel + endpoint).
|
||||
4. Endurecimiento de **señales de riesgo IA** (reglas + umbrales + métricas).
|
||||
1. Implementar **Ecosistema de Plugins** (Fase 35): tabla `course_plugins`, CRUD en Studio, renderizador seguro de Web Components en Experience.
|
||||
2. Evaluar paso de polling a **WebSocket/SSE** en Pizarras Compartidas una vez validado uso real.
|
||||
3. Extender el modelo colaborativo a **Edición Multiusuario** de documentos.
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS lesson_collaborative_canvases (
|
||||
lesson_id UUID PRIMARY KEY REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
canvas_state JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_collaborative_canvases_org_id
|
||||
ON lesson_collaborative_canvases (organization_id);
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE lesson_collaborative_canvases
|
||||
ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Fase 35: Ecosistema de Plugins
|
||||
-- Tabla de plugins registrados por organización
|
||||
|
||||
CREATE TABLE IF NOT EXISTS org_plugins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
component_url TEXT NOT NULL,
|
||||
icon_url TEXT,
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_org_plugins_org_id ON org_plugins(organization_id);
|
||||
|
||||
-- Trigger para updated_at automático
|
||||
CREATE OR REPLACE FUNCTION update_org_plugins_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_org_plugins_updated_at
|
||||
BEFORE UPDATE ON org_plugins
|
||||
FOR EACH ROW EXECUTE FUNCTION update_org_plugins_updated_at();
|
||||
@@ -15,9 +15,12 @@ pub struct BackgroundTask {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub course_title: Option<String>,
|
||||
pub task_type: String, // 'transcription'
|
||||
pub task_type: String,
|
||||
pub status: String,
|
||||
pub progress: i32,
|
||||
pub processed_items: i64,
|
||||
pub failed_items: i64,
|
||||
pub error_message: Option<String>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
@@ -25,7 +28,8 @@ pub async fn get_background_tasks(
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<BackgroundTask>>, (StatusCode, String)> {
|
||||
let query = r#"
|
||||
SELECT id, title, course_title, task_type, status, progress, updated_at
|
||||
SELECT id, title, course_title, task_type, status, progress,
|
||||
processed_items, failed_items, error_message, updated_at
|
||||
FROM (
|
||||
SELECT
|
||||
l.id,
|
||||
@@ -34,6 +38,9 @@ pub async fn get_background_tasks(
|
||||
'lesson_transcription' as task_type,
|
||||
l.transcription_status as status,
|
||||
0 as progress,
|
||||
0::bigint as processed_items,
|
||||
0::bigint as failed_items,
|
||||
NULL::text as error_message,
|
||||
l.updated_at
|
||||
FROM lessons l
|
||||
JOIN modules m ON l.module_id = m.id
|
||||
@@ -49,6 +56,9 @@ pub async fn get_background_tasks(
|
||||
t.task_type,
|
||||
t.status,
|
||||
t.progress,
|
||||
t.processed_items::bigint,
|
||||
t.failed_items::bigint,
|
||||
t.error_message,
|
||||
t.updated_at
|
||||
FROM background_tasks t
|
||||
WHERE t.task_type = 'zip_rag_import'
|
||||
@@ -289,13 +299,39 @@ pub async fn cancel_task(
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Only cancel transcription in lessons
|
||||
let _ = sqlx::query(
|
||||
"UPDATE lessons SET transcription_status = 'idle' WHERE id = $1"
|
||||
// Try to cancel a transcription lesson first
|
||||
let lesson_result = sqlx::query(
|
||||
"UPDATE lessons SET transcription_status = 'idle' WHERE id = $1 AND transcription_status IN ('queued', 'processing')"
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
if lesson_result.rows_affected() > 0 {
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
// Try to cancel a zip_rag_import background task
|
||||
let task_result = sqlx::query(
|
||||
r#"
|
||||
UPDATE background_tasks
|
||||
SET status = 'failed',
|
||||
error_message = 'Cancelado manualmente',
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND task_type = 'zip_rag_import'
|
||||
AND status IN ('queued', 'processing')
|
||||
"#
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if task_result.rows_affected() > 0 {
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
Ok(StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use common::middleware::Org;
|
||||
use common::auth::Claims;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tipos
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OrgPlugin {
|
||||
pub id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub component_url: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub config: serde_json::Value,
|
||||
pub enabled: bool,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatePluginPayload {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub component_url: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub config: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdatePluginPayload {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub component_url: Option<String>,
|
||||
pub icon_url: Option<String>,
|
||||
pub config: Option<serde_json::Value>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GET /plugins — listar plugins de la org
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn list_plugins(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<OrgPlugin>>, (StatusCode, String)> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, organization_id, name, description, component_url, icon_url, config, enabled, created_at, updated_at
|
||||
FROM org_plugins
|
||||
WHERE organization_id = $1
|
||||
ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let plugins = rows
|
||||
.into_iter()
|
||||
.map(|r| OrgPlugin {
|
||||
id: r.get("id"),
|
||||
organization_id: r.get("organization_id"),
|
||||
name: r.get("name"),
|
||||
description: r.get("description"),
|
||||
component_url: r.get("component_url"),
|
||||
icon_url: r.get("icon_url"),
|
||||
config: r.get("config"),
|
||||
enabled: r.get("enabled"),
|
||||
created_at: r.get("created_at"),
|
||||
updated_at: r.get("updated_at"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(plugins))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GET /plugins/enabled — solo plugins activos (usado por Experience)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn list_enabled_plugins(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<OrgPlugin>>, (StatusCode, String)> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, organization_id, name, description, component_url, icon_url, config, enabled, created_at, updated_at
|
||||
FROM org_plugins
|
||||
WHERE organization_id = $1 AND enabled = TRUE
|
||||
ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let plugins = rows
|
||||
.into_iter()
|
||||
.map(|r| OrgPlugin {
|
||||
id: r.get("id"),
|
||||
organization_id: r.get("organization_id"),
|
||||
name: r.get("name"),
|
||||
description: r.get("description"),
|
||||
component_url: r.get("component_url"),
|
||||
icon_url: r.get("icon_url"),
|
||||
config: r.get("config"),
|
||||
enabled: r.get("enabled"),
|
||||
created_at: r.get("created_at"),
|
||||
updated_at: r.get("updated_at"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(plugins))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// POST /plugins — crear plugin
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn create_plugin(
|
||||
Org(org_ctx): Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<CreatePluginPayload>,
|
||||
) -> Result<(StatusCode, Json<OrgPlugin>), (StatusCode, String)> {
|
||||
// Validación básica de URL (solo https permitido para componentes externos)
|
||||
if !payload.component_url.starts_with("https://") {
|
||||
return Err((
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"component_url debe usar HTTPS".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO org_plugins (organization_id, name, description, component_url, icon_url, config)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, organization_id, name, description, component_url, icon_url, config, enabled, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(&payload.name)
|
||||
.bind(payload.description.as_deref().unwrap_or(""))
|
||||
.bind(&payload.component_url)
|
||||
.bind(&payload.icon_url)
|
||||
.bind(payload.config.unwrap_or(serde_json::json!({})))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(OrgPlugin {
|
||||
id: row.get("id"),
|
||||
organization_id: row.get("organization_id"),
|
||||
name: row.get("name"),
|
||||
description: row.get("description"),
|
||||
component_url: row.get("component_url"),
|
||||
icon_url: row.get("icon_url"),
|
||||
config: row.get("config"),
|
||||
enabled: row.get("enabled"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
})))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PUT /plugins/{id} — actualizar plugin
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn update_plugin(
|
||||
Org(org_ctx): Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(plugin_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdatePluginPayload>,
|
||||
) -> Result<Json<OrgPlugin>, (StatusCode, String)> {
|
||||
// Verificar que pertenece a esta org
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM org_plugins WHERE id = $1 AND organization_id = $2)",
|
||||
)
|
||||
.bind(plugin_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !exists {
|
||||
return Err((StatusCode::NOT_FOUND, "Plugin no encontrado".to_string()));
|
||||
}
|
||||
|
||||
// Validar URL si se actualiza
|
||||
if let Some(url) = &payload.component_url {
|
||||
if !url.starts_with("https://") {
|
||||
return Err((
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"component_url debe usar HTTPS".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
UPDATE org_plugins SET
|
||||
name = COALESCE($3, name),
|
||||
description = COALESCE($4, description),
|
||||
component_url = COALESCE($5, component_url),
|
||||
icon_url = COALESCE($6, icon_url),
|
||||
config = COALESCE($7, config),
|
||||
enabled = COALESCE($8, enabled)
|
||||
WHERE id = $1 AND organization_id = $2
|
||||
RETURNING id, organization_id, name, description, component_url, icon_url, config, enabled, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(plugin_id)
|
||||
.bind(org_ctx.id)
|
||||
.bind(&payload.name)
|
||||
.bind(&payload.description)
|
||||
.bind(&payload.component_url)
|
||||
.bind(&payload.icon_url)
|
||||
.bind(&payload.config)
|
||||
.bind(payload.enabled)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(OrgPlugin {
|
||||
id: row.get("id"),
|
||||
organization_id: row.get("organization_id"),
|
||||
name: row.get("name"),
|
||||
description: row.get("description"),
|
||||
component_url: row.get("component_url"),
|
||||
icon_url: row.get("icon_url"),
|
||||
config: row.get("config"),
|
||||
enabled: row.get("enabled"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
}))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DELETE /plugins/{id} — eliminar plugin
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn delete_plugin(
|
||||
Org(org_ctx): Org,
|
||||
_claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(plugin_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM org_plugins WHERE id = $1 AND organization_id = $2",
|
||||
)
|
||||
.bind(plugin_id)
|
||||
.bind(org_ctx.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, "Plugin no encontrado".to_string()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ mod handlers_question_bank;
|
||||
mod handlers_admin;
|
||||
mod handlers_embeddings;
|
||||
mod handlers_sam;
|
||||
mod handlers_plugins;
|
||||
mod webhooks;
|
||||
|
||||
use axum::{
|
||||
@@ -548,6 +549,19 @@ async fn main() {
|
||||
"/admin/users/{user_id}/token-limit/check",
|
||||
get(handlers_admin::check_user_token_limit),
|
||||
)
|
||||
// Fase 35: Ecosistema de Plugins
|
||||
.route(
|
||||
"/plugins",
|
||||
get(handlers_plugins::list_plugins).post(handlers_plugins::create_plugin),
|
||||
)
|
||||
.route(
|
||||
"/plugins/enabled",
|
||||
get(handlers_plugins::list_enabled_plugins),
|
||||
)
|
||||
.route(
|
||||
"/plugins/{id}",
|
||||
put(handlers_plugins::update_plugin).delete(handlers_plugins::delete_plugin),
|
||||
)
|
||||
.route_layer(middleware::from_fn(
|
||||
common::middleware::org_extractor_middleware,
|
||||
))
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS lesson_collaborative_canvases (
|
||||
lesson_id UUID PRIMARY KEY REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
canvas_state JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lesson_collaborative_canvases_org_id
|
||||
ON lesson_collaborative_canvases (organization_id);
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE lesson_collaborative_canvases
|
||||
ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Fase 35: Ecosistema de Plugins (lms-service mirror)
|
||||
CREATE TABLE IF NOT EXISTS org_plugins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
component_url TEXT NOT NULL,
|
||||
icon_url TEXT,
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_org_plugins_org_id ON org_plugins(organization_id);
|
||||
@@ -1434,6 +1434,278 @@ pub async fn get_lesson_content(
|
||||
Ok(Json(lesson))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CollaborativeCanvasResponse {
|
||||
pub lesson_id: Uuid,
|
||||
pub canvas_state: serde_json::Value,
|
||||
pub revision: i64,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateCollaborativeCanvasPayload {
|
||||
pub canvas_state: serde_json::Value,
|
||||
pub expected_revision: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UpdateCollaborativeCanvasResponse {
|
||||
pub lesson_id: Uuid,
|
||||
pub revision: i64,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn get_lesson_collaborative_canvas(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<CollaborativeCanvasResponse>, StatusCode> {
|
||||
let lesson_exists = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
"get_lesson_collaborative_canvas: failed to validate lesson {} in org {}: {}",
|
||||
id,
|
||||
org_ctx.id,
|
||||
e
|
||||
);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
if !lesson_exists {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CanvasRow {
|
||||
canvas_state: serde_json::Value,
|
||||
revision: i64,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
let canvas = sqlx::query_as::<_, CanvasRow>(
|
||||
r#"
|
||||
SELECT canvas_state, revision, updated_at
|
||||
FROM lesson_collaborative_canvases
|
||||
WHERE lesson_id = $1 AND organization_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
"get_lesson_collaborative_canvas: failed to fetch canvas for lesson {}: {}",
|
||||
id,
|
||||
e
|
||||
);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let response = if let Some(canvas) = canvas {
|
||||
CollaborativeCanvasResponse {
|
||||
lesson_id: id,
|
||||
canvas_state: canvas.canvas_state,
|
||||
revision: canvas.revision,
|
||||
updated_at: Some(canvas.updated_at),
|
||||
}
|
||||
} else {
|
||||
CollaborativeCanvasResponse {
|
||||
lesson_id: id,
|
||||
canvas_state: json!({}),
|
||||
revision: 0,
|
||||
updated_at: None,
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
pub async fn update_lesson_collaborative_canvas(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateCollaborativeCanvasPayload>,
|
||||
) -> Result<Json<UpdateCollaborativeCanvasResponse>, (StatusCode, String)> {
|
||||
let lesson_exists = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
"update_lesson_collaborative_canvas: failed to validate lesson {} in org {}: {}",
|
||||
id,
|
||||
org_ctx.id,
|
||||
e
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Error validando lección para canvas colaborativo".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !lesson_exists {
|
||||
return Err((StatusCode::NOT_FOUND, "Lección no encontrada".to_string()));
|
||||
}
|
||||
|
||||
if let Some(expected_revision) = payload.expected_revision {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RevisionRow {
|
||||
revision: i64,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
let updated = sqlx::query_as::<_, RevisionRow>(
|
||||
r#"
|
||||
UPDATE lesson_collaborative_canvases
|
||||
SET
|
||||
canvas_state = $3,
|
||||
updated_by = $4,
|
||||
updated_at = NOW(),
|
||||
revision = revision + 1
|
||||
WHERE lesson_id = $1
|
||||
AND organization_id = $2
|
||||
AND revision = $5
|
||||
RETURNING revision, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(org_ctx.id)
|
||||
.bind(&payload.canvas_state)
|
||||
.bind(claims.sub)
|
||||
.bind(expected_revision)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
"update_lesson_collaborative_canvas: optimistic update failed for lesson {}: {}",
|
||||
id,
|
||||
e
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Error actualizando canvas colaborativo".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(row) = updated {
|
||||
return Ok(Json(UpdateCollaborativeCanvasResponse {
|
||||
lesson_id: id,
|
||||
revision: row.revision,
|
||||
updated_at: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
if expected_revision == 0 {
|
||||
let inserted = sqlx::query_as::<_, RevisionRow>(
|
||||
r#"
|
||||
INSERT INTO lesson_collaborative_canvases (
|
||||
lesson_id,
|
||||
organization_id,
|
||||
canvas_state,
|
||||
updated_by,
|
||||
revision,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, 1, NOW())
|
||||
ON CONFLICT (lesson_id) DO NOTHING
|
||||
RETURNING revision, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(org_ctx.id)
|
||||
.bind(&payload.canvas_state)
|
||||
.bind(claims.sub)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
"update_lesson_collaborative_canvas: insert-on-zero failed for lesson {}: {}",
|
||||
id,
|
||||
e
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Error creando canvas colaborativo".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(row) = inserted {
|
||||
return Ok(Json(UpdateCollaborativeCanvasResponse {
|
||||
lesson_id: id,
|
||||
revision: row.revision,
|
||||
updated_at: row.updated_at,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
"Conflicto de edición: el canvas fue actualizado por otro usuario. Recarga y vuelve a intentar.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RevisionRow {
|
||||
revision: i64,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, RevisionRow>(
|
||||
r#"
|
||||
INSERT INTO lesson_collaborative_canvases (
|
||||
lesson_id,
|
||||
organization_id,
|
||||
canvas_state,
|
||||
updated_by,
|
||||
revision,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, 1, NOW())
|
||||
ON CONFLICT (lesson_id)
|
||||
DO UPDATE SET
|
||||
canvas_state = EXCLUDED.canvas_state,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = NOW(),
|
||||
revision = lesson_collaborative_canvases.revision + 1
|
||||
RETURNING revision, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(org_ctx.id)
|
||||
.bind(&payload.canvas_state)
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
"update_lesson_collaborative_canvas: failed to upsert canvas for lesson {}: {}",
|
||||
id,
|
||||
e
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Error guardando canvas colaborativo".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(UpdateCollaborativeCanvasResponse {
|
||||
lesson_id: id,
|
||||
revision: row.revision,
|
||||
updated_at: row.updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_user_enrollments(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use common::middleware::Org;
|
||||
use serde::Serialize;
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Structs de respuesta
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LessonQualityMetric {
|
||||
pub lesson_id: Uuid,
|
||||
pub lesson_title: String,
|
||||
pub position: i32,
|
||||
/// % de inscritos que entregaron al menos una vez
|
||||
pub completion_rate: f64,
|
||||
/// Promedio de intentos por alumno que entregó
|
||||
pub avg_attempts: f64,
|
||||
/// Promedio de puntaje (0–1)
|
||||
pub avg_score: f64,
|
||||
/// % de alumnos que reprobaron en todos sus intentos (score < 0.6)
|
||||
pub failure_rate: f64,
|
||||
/// Número de alumnos que nunca interactuaron con la lección
|
||||
pub abandonment_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CourseQualityMetrics {
|
||||
pub course_id: Uuid,
|
||||
pub enrolled: i64,
|
||||
pub lessons: Vec<LessonQualityMetric>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct QuizDiscriminationItem {
|
||||
pub lesson_id: Uuid,
|
||||
pub lesson_title: String,
|
||||
/// Texto truncado de la pregunta (block_id como proxy)
|
||||
pub block_id: String,
|
||||
/// Correlación punto-biserial approx: diferencia de score medio
|
||||
/// entre alumnos que respondieron bien vs mal esa pregunta
|
||||
pub discrimination_index: f64,
|
||||
/// % de alumnos que acertaron esta pregunta
|
||||
pub facility_index: f64,
|
||||
pub sample_size: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CourseDiscriminationReport {
|
||||
pub course_id: Uuid,
|
||||
pub items: Vec<QuizDiscriminationItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CurricularSuggestion {
|
||||
pub lesson_id: Uuid,
|
||||
pub lesson_title: String,
|
||||
pub kind: &'static str,
|
||||
pub message: String,
|
||||
pub severity: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CurricularSuggestionsReport {
|
||||
pub course_id: Uuid,
|
||||
pub suggestions: Vec<CurricularSuggestion>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Endpoint 1: Métricas de Calidad
|
||||
// GET /courses/{id}/pedagogical/quality-metrics
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_lesson_quality_metrics(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<CourseQualityMetrics>, (StatusCode, String)> {
|
||||
let enrolled: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM enrollments WHERE course_id = $1 AND organization_id = $2",
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if enrolled == 0 {
|
||||
return Ok(Json(CourseQualityMetrics {
|
||||
course_id,
|
||||
enrolled: 0,
|
||||
lessons: vec![],
|
||||
}));
|
||||
}
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
l.id AS lesson_id,
|
||||
l.title AS lesson_title,
|
||||
l.position,
|
||||
|
||||
-- Alumnos que entregaron al menos una vez
|
||||
COUNT(DISTINCT g.user_id)::float8 / NULLIF($3::float8, 0) AS completion_rate,
|
||||
|
||||
-- Promedio de intentos entre quienes entregaron
|
||||
COALESCE(AVG(g.attempts_count)::float8, 0) AS avg_attempts,
|
||||
|
||||
-- Puntaje medio
|
||||
COALESCE(AVG(g.score)::float8, 0) AS avg_score,
|
||||
|
||||
-- Tasa de fallo (todos los intentos con score < 0.6)
|
||||
(
|
||||
SELECT COALESCE(
|
||||
COUNT(DISTINCT g2.user_id)::float8 / NULLIF(COUNT(DISTINCT g.user_id)::float8, 0),
|
||||
0
|
||||
)
|
||||
FROM user_grades g2
|
||||
WHERE g2.lesson_id = l.id
|
||||
AND g2.organization_id = $2
|
||||
AND g2.score < 0.6
|
||||
) AS failure_rate,
|
||||
|
||||
-- Alumnos inscritos que NUNCA enviaron esta lección
|
||||
(
|
||||
$3 - COUNT(DISTINCT g.user_id)
|
||||
) AS abandonment_count
|
||||
|
||||
FROM lessons l
|
||||
JOIN modules m ON l.module_id = m.id
|
||||
LEFT JOIN user_grades g
|
||||
ON g.lesson_id = l.id AND g.organization_id = $2
|
||||
WHERE m.course_id = $1
|
||||
AND l.organization_id = $2
|
||||
GROUP BY l.id, l.title, l.position
|
||||
ORDER BY l.position
|
||||
"#,
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.bind(enrolled)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let lessons = rows
|
||||
.into_iter()
|
||||
.map(|row| LessonQualityMetric {
|
||||
lesson_id: row.get("lesson_id"),
|
||||
lesson_title: row.get("lesson_title"),
|
||||
position: row.get("position"),
|
||||
completion_rate: row.get::<f64, _>("completion_rate"),
|
||||
avg_attempts: row.get::<f64, _>("avg_attempts"),
|
||||
avg_score: row.get::<f64, _>("avg_score"),
|
||||
failure_rate: row.get::<f64, _>("failure_rate"),
|
||||
abandonment_count: row.get::<i64, _>("abandonment_count"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(CourseQualityMetrics {
|
||||
course_id,
|
||||
enrolled,
|
||||
lessons,
|
||||
}))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Endpoint 2: Índice de Discriminación de preguntas
|
||||
// GET /courses/{id}/pedagogical/discrimination-index
|
||||
//
|
||||
// Usa punto-biserial simplificado: compara el puntaje global del curso entre
|
||||
// alumnos que acertaron cada bloque vs los que fallaron.
|
||||
// La columna metadata->>'block_scores' guarda un objeto {block_id: score_0_1}.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_quiz_discrimination_index(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<CourseDiscriminationReport>, (StatusCode, String)> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
g.lesson_id,
|
||||
l.title AS lesson_title,
|
||||
bs.key AS block_id,
|
||||
(bs.value::text::float8) AS block_score,
|
||||
g.score AS overall_score
|
||||
FROM user_grades g
|
||||
JOIN lessons l ON l.id = g.lesson_id
|
||||
JOIN LATERAL jsonb_each(
|
||||
CASE
|
||||
WHEN g.metadata ? 'block_scores'
|
||||
THEN g.metadata -> 'block_scores'
|
||||
ELSE '{}'::jsonb
|
||||
END
|
||||
) AS bs(key, value) ON TRUE
|
||||
WHERE g.course_id = $1
|
||||
AND g.organization_id = $2
|
||||
AND (bs.value::text) ~ '^[0-9]+\.?[0-9]*$'
|
||||
"#,
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(Json(CourseDiscriminationReport {
|
||||
course_id,
|
||||
items: vec![],
|
||||
}));
|
||||
}
|
||||
|
||||
struct BlockStats {
|
||||
lesson_id: Uuid,
|
||||
lesson_title: String,
|
||||
block_id: String,
|
||||
pass_scores: Vec<f64>,
|
||||
fail_scores: Vec<f64>,
|
||||
pass_count: i64,
|
||||
total: i64,
|
||||
}
|
||||
|
||||
let mut map: HashMap<String, BlockStats> = HashMap::new();
|
||||
for row in &rows {
|
||||
let lesson_id: Uuid = row.get("lesson_id");
|
||||
let lesson_title: String = row.get("lesson_title");
|
||||
let block_id: String = row.get("block_id");
|
||||
let block_score: f64 = row.get("block_score");
|
||||
let overall_score: f64 = row.get("overall_score");
|
||||
|
||||
let key = format!("{}/{}", lesson_id, block_id);
|
||||
let entry = map.entry(key).or_insert_with(|| BlockStats {
|
||||
lesson_id,
|
||||
lesson_title: lesson_title.clone(),
|
||||
block_id: block_id.clone(),
|
||||
pass_scores: vec![],
|
||||
fail_scores: vec![],
|
||||
pass_count: 0,
|
||||
total: 0,
|
||||
});
|
||||
entry.total += 1;
|
||||
if block_score >= 0.6 {
|
||||
entry.pass_count += 1;
|
||||
entry.pass_scores.push(overall_score);
|
||||
} else {
|
||||
entry.fail_scores.push(overall_score);
|
||||
}
|
||||
}
|
||||
|
||||
let mean = |v: &[f64]| -> f64 {
|
||||
if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 }
|
||||
};
|
||||
|
||||
let mut items: Vec<QuizDiscriminationItem> = map
|
||||
.into_values()
|
||||
.filter(|s| s.total >= 3)
|
||||
.map(|s| {
|
||||
let facility = s.pass_count as f64 / s.total as f64;
|
||||
let discrimination = mean(&s.pass_scores) - mean(&s.fail_scores);
|
||||
QuizDiscriminationItem {
|
||||
lesson_id: s.lesson_id,
|
||||
lesson_title: s.lesson_title,
|
||||
block_id: s.block_id,
|
||||
discrimination_index: (discrimination * 100.0).round() / 100.0,
|
||||
facility_index: (facility * 100.0).round() / 100.0,
|
||||
sample_size: s.total,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
items.sort_by(|a, b| a.discrimination_index.partial_cmp(&b.discrimination_index).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
Ok(Json(CourseDiscriminationReport { course_id, items }))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Endpoint 3: Sugerencias Curriculares con reglas basadas en datos
|
||||
// GET /courses/{id}/pedagogical/suggestions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_curricular_suggestions(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
Path(course_id): Path<Uuid>,
|
||||
) -> Result<Json<CurricularSuggestionsReport>, (StatusCode, String)> {
|
||||
let enrolled: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM enrollments WHERE course_id = $1 AND organization_id = $2",
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if enrolled < 5 {
|
||||
return Ok(Json(CurricularSuggestionsReport {
|
||||
course_id,
|
||||
suggestions: vec![],
|
||||
}));
|
||||
}
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
l.id AS lesson_id,
|
||||
l.title AS lesson_title,
|
||||
l.position,
|
||||
COUNT(DISTINCT g.user_id)::float8 / $3::float8 AS completion_rate,
|
||||
COALESCE(AVG(g.score)::float8, 0) AS avg_score,
|
||||
COALESCE(AVG(g.attempts_count)::float8, 0) AS avg_attempts,
|
||||
-- Tasa de abandono
|
||||
($3 - COUNT(DISTINCT g.user_id))::float8 / $3::float8 AS abandonment_rate
|
||||
FROM lessons l
|
||||
JOIN modules m ON l.module_id = m.id
|
||||
LEFT JOIN user_grades g
|
||||
ON g.lesson_id = l.id AND g.organization_id = $2
|
||||
WHERE m.course_id = $1
|
||||
AND l.organization_id = $2
|
||||
GROUP BY l.id, l.title, l.position
|
||||
ORDER BY l.position
|
||||
"#,
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.bind(enrolled)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let mut suggestions: Vec<CurricularSuggestion> = vec![];
|
||||
|
||||
for row in &rows {
|
||||
let lesson_id: Uuid = row.get("lesson_id");
|
||||
let lesson_title: String = row.get("lesson_title");
|
||||
let completion_rate: f64 = row.get("completion_rate");
|
||||
let avg_score: f64 = row.get("avg_score");
|
||||
let avg_attempts: f64 = row.get("avg_attempts");
|
||||
let abandonment_rate: f64 = row.get("abandonment_rate");
|
||||
|
||||
// Regla 1: Alto abandono sin completar
|
||||
if abandonment_rate > 0.50 {
|
||||
suggestions.push(CurricularSuggestion {
|
||||
lesson_id,
|
||||
lesson_title: lesson_title.clone(),
|
||||
kind: "high_abandonment",
|
||||
message: format!(
|
||||
"{:.0}% de los alumnos no completan esta lección. Considera dividirla en partes más cortas o revisar si el contenido es demasiado extenso.",
|
||||
abandonment_rate * 100.0
|
||||
),
|
||||
severity: "high",
|
||||
});
|
||||
}
|
||||
|
||||
// Regla 2: Puntaje promedio muy bajo con muchos intentos → dificultad excesiva
|
||||
if avg_score < 0.45 && avg_attempts > 2.0 && completion_rate > 0.3 {
|
||||
suggestions.push(CurricularSuggestion {
|
||||
lesson_id,
|
||||
lesson_title: lesson_title.clone(),
|
||||
kind: "excessive_difficulty",
|
||||
message: format!(
|
||||
"Puntaje promedio de {:.0}% tras {:.1} intentos. La lección puede ser excesivamente difícil; revisa las preguntas o añade material de apoyo previo.",
|
||||
avg_score * 100.0,
|
||||
avg_attempts
|
||||
),
|
||||
severity: "high",
|
||||
});
|
||||
}
|
||||
|
||||
// Regla 3: Puntaje muy alto con pocos intentos → podría ser demasiado fácil
|
||||
if avg_score > 0.95 && avg_attempts < 1.2 && completion_rate > 0.5 {
|
||||
suggestions.push(CurricularSuggestion {
|
||||
lesson_id,
|
||||
lesson_title: lesson_title.clone(),
|
||||
kind: "too_easy",
|
||||
message: format!(
|
||||
"Puntaje promedio de {:.0}% en el primer intento. Esta lección puede no estar generando aprendizaje real; considera aumentar la complejidad o añadir preguntas de análisis.",
|
||||
avg_score * 100.0
|
||||
),
|
||||
severity: "info",
|
||||
});
|
||||
}
|
||||
|
||||
// Regla 4: Baja tasa de finalización en lección no final → posible bloqueo
|
||||
if completion_rate < 0.30 && abandonment_rate < 0.50 {
|
||||
suggestions.push(CurricularSuggestion {
|
||||
lesson_id,
|
||||
lesson_title: lesson_title.clone(),
|
||||
kind: "low_completion",
|
||||
message: format!(
|
||||
"Solo {:.0}% de alumnos completa esta lección. Si no hay abandonos, puede haber un bloqueo técnico o de prerrequisitos. Verifica dependencias y contenido de la lección.",
|
||||
completion_rate * 100.0
|
||||
),
|
||||
severity: "medium",
|
||||
});
|
||||
}
|
||||
|
||||
// Regla 5: Muchos reintentos con buen puntaje final → la práctica funciona, destacar
|
||||
if avg_attempts > 3.0 && avg_score > 0.75 {
|
||||
suggestions.push(CurricularSuggestion {
|
||||
lesson_id,
|
||||
lesson_title: lesson_title.clone(),
|
||||
kind: "effective_practice",
|
||||
message: format!(
|
||||
"Alumnos usan en promedio {:.1} intentos y llegan a {:.0}%. Esta lección fomenta el aprendizaje por práctica de forma efectiva.",
|
||||
avg_attempts,
|
||||
avg_score * 100.0
|
||||
),
|
||||
severity: "positive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
suggestions.sort_by_key(|s| match s.severity {
|
||||
"high" => 0,
|
||||
"medium" => 1,
|
||||
"info" => 2,
|
||||
"positive" => 3,
|
||||
_ => 4,
|
||||
});
|
||||
|
||||
Ok(Json(CurricularSuggestionsReport {
|
||||
course_id,
|
||||
suggestions,
|
||||
}))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod db_util;
|
||||
mod handlers;
|
||||
mod handlers_announcements;
|
||||
mod handlers_pedagogical;
|
||||
mod handlers_email;
|
||||
mod handlers_scorm;
|
||||
mod handlers_search;
|
||||
@@ -161,6 +162,11 @@ async fn main() {
|
||||
.route("/courses/{id}/outline", get(handlers::get_course_outline))
|
||||
.route("/courses/{id}/progress-stats", get(handlers::get_student_progress_stats))
|
||||
.route("/lessons/{id}", get(handlers::get_lesson_content))
|
||||
.route(
|
||||
"/lessons/{id}/collaborative-canvas",
|
||||
get(handlers::get_lesson_collaborative_canvas)
|
||||
.put(handlers::update_lesson_collaborative_canvas),
|
||||
)
|
||||
.route("/lessons/{id}/bookmark", post(handlers::toggle_bookmark))
|
||||
.route("/bookmarks", get(handlers::get_user_bookmarks))
|
||||
.route("/grades", post(handlers::submit_lesson_score))
|
||||
@@ -258,6 +264,19 @@ async fn main() {
|
||||
"/ai/data-ethics/summary",
|
||||
get(handlers_data_ethics::get_data_ethics_summary),
|
||||
)
|
||||
// Análisis Pedagógico Profundo (Fase 34)
|
||||
.route(
|
||||
"/courses/{id}/pedagogical/quality-metrics",
|
||||
get(handlers_pedagogical::get_lesson_quality_metrics),
|
||||
)
|
||||
.route(
|
||||
"/courses/{id}/pedagogical/discrimination-index",
|
||||
get(handlers_pedagogical::get_quiz_discrimination_index),
|
||||
)
|
||||
.route(
|
||||
"/courses/{id}/pedagogical/suggestions",
|
||||
get(handlers_pedagogical::get_curricular_suggestions),
|
||||
)
|
||||
// Moderación humana para FAQ basada en chats de alumnos
|
||||
.route(
|
||||
"/faq/review/import-candidates",
|
||||
|
||||
@@ -22,10 +22,12 @@ import RolePlayingPlayer from "@/components/blocks/RolePlayingPlayer";
|
||||
import PeerReviewPlayer from "@/components/blocks/PeerReviewPlayer";
|
||||
import MermaidViewer from "@/components/blocks/MermaidViewer";
|
||||
import ScormPlayer from "@/components/blocks/ScormPlayer";
|
||||
import PluginBlock from "@/components/blocks/PluginBlock";
|
||||
import InteractiveTranscript from "@/components/InteractiveTranscript";
|
||||
import AITutor from "@/components/AITutor";
|
||||
import LessonLockedView from "@/components/LessonLockedView";
|
||||
import StudentNotes from "@/components/StudentNotes";
|
||||
import CollaborativeWhiteboard from "@/components/CollaborativeWhiteboard";
|
||||
import { ListMusic, StickyNote } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
export default function LessonPlayerPage({ params }: { params: { id: string, lessonId: string } }) {
|
||||
@@ -522,6 +524,15 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
||||
);
|
||||
case 'mermaid':
|
||||
return <MermaidViewer block={block} />;
|
||||
case 'plugin':
|
||||
return (
|
||||
<PluginBlock
|
||||
pluginId={block.id}
|
||||
name={block.title || 'Plugin'}
|
||||
componentUrl={block.component_url || ''}
|
||||
config={block.config as Record<string, unknown> | undefined}
|
||||
/>
|
||||
);
|
||||
case 'scorm':
|
||||
return (
|
||||
<ScormPlayer
|
||||
@@ -608,6 +619,10 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-12 border-t border-black/5 dark:border-white/5 animate-in fade-in slide-in-from-bottom-8 duration-1000">
|
||||
<CollaborativeWhiteboard lessonId={params.lessonId} />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
|
||||
import { lmsApi, CollaborativeCanvasState } from "@/lib/api";
|
||||
import { AlertTriangle, CheckCircle, Loader2, RefreshCw, Save, Trash2 } from "lucide-react";
|
||||
|
||||
type ConflictInfo = {
|
||||
localStrokes: Stroke[];
|
||||
remoteStrokes: Stroke[];
|
||||
remoteRevision: number;
|
||||
remoteUpdatedAt: string;
|
||||
};
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
type Stroke = {
|
||||
points: Point[];
|
||||
color: string;
|
||||
width: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
lessonId: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CANVAS: CollaborativeCanvasState = { strokes: [] };
|
||||
|
||||
function toStrokeArray(state: CollaborativeCanvasState): Stroke[] {
|
||||
if (!Array.isArray(state.strokes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return state.strokes
|
||||
.map((stroke) => {
|
||||
const points = Array.isArray(stroke.points)
|
||||
? stroke.points
|
||||
.filter((p): p is Point => typeof p?.x === "number" && typeof p?.y === "number")
|
||||
.map((p) => ({ x: p.x, y: p.y }))
|
||||
: [];
|
||||
|
||||
return {
|
||||
points,
|
||||
color: typeof stroke.color === "string" ? stroke.color : "#1f2937",
|
||||
width: typeof stroke.width === "number" ? stroke.width : 2,
|
||||
};
|
||||
})
|
||||
.filter((stroke) => stroke.points.length > 1);
|
||||
}
|
||||
|
||||
export default function CollaborativeWhiteboard({ lessonId }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [strokes, setStrokes] = useState<Stroke[]>([]);
|
||||
const [draftStroke, setDraftStroke] = useState<Stroke | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [lastSavedAt, setLastSavedAt] = useState<string | null>(null);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [conflict, setConflict] = useState<ConflictInfo | null>(null);
|
||||
|
||||
const isDrawing = useRef(false);
|
||||
|
||||
const allStrokes = useMemo(() => {
|
||||
return draftStroke ? [...strokes, draftStroke] : strokes;
|
||||
}, [strokes, draftStroke]);
|
||||
|
||||
const resizeCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!canvas || !wrapper) return;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
|
||||
canvas.width = Math.max(1, Math.floor(rect.width * scale));
|
||||
canvas.height = Math.max(1, Math.floor(340 * scale));
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = "340px";
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.scale(scale, scale);
|
||||
}, []);
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (const stroke of allStrokes) {
|
||||
if (stroke.points.length < 2) continue;
|
||||
ctx.beginPath();
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = stroke.color;
|
||||
ctx.lineWidth = stroke.width;
|
||||
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
|
||||
for (let i = 1; i < stroke.points.length; i += 1) {
|
||||
ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}, [allStrokes]);
|
||||
|
||||
useEffect(() => {
|
||||
resizeCanvas();
|
||||
draw();
|
||||
}, [resizeCanvas, draw]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
resizeCanvas();
|
||||
draw();
|
||||
};
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [draw, resizeCanvas]);
|
||||
|
||||
const getPoint = (event: PointerEvent<HTMLCanvasElement>): Point | null => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const loadCanvas = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await lmsApi.getLessonCollaborativeCanvas(lessonId);
|
||||
const loadedStrokes = toStrokeArray(data.canvas_state || DEFAULT_CANVAS);
|
||||
setStrokes(loadedStrokes);
|
||||
setDraftStroke(null);
|
||||
setLastSavedAt(data.updated_at || null);
|
||||
setRevision(data.revision || 0);
|
||||
setDirty(false);
|
||||
} catch (e) {
|
||||
console.error("Error loading collaborative canvas", e);
|
||||
setError("No se pudo cargar la pizarra colaborativa.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [lessonId]);
|
||||
|
||||
const saveCanvas = useCallback(async (force = false) => {
|
||||
if (saving || loading || isDrawing.current) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const expectedRev = force ? undefined : revision;
|
||||
try {
|
||||
const result = await lmsApi.updateLessonCollaborativeCanvas(lessonId, { strokes }, expectedRev);
|
||||
setLastSavedAt(result.updated_at);
|
||||
setRevision(result.revision);
|
||||
setDirty(false);
|
||||
setConflict(null);
|
||||
} catch (e) {
|
||||
console.error("Error saving collaborative canvas", e);
|
||||
if (e instanceof Error && e.message.toLowerCase().includes("conflicto")) {
|
||||
// Fetch remote state to show diff panel
|
||||
try {
|
||||
const remote = await lmsApi.getLessonCollaborativeCanvas(lessonId);
|
||||
setConflict({
|
||||
localStrokes: strokes,
|
||||
remoteStrokes: toStrokeArray(remote.canvas_state || DEFAULT_CANVAS),
|
||||
remoteRevision: remote.revision || 0,
|
||||
remoteUpdatedAt: remote.updated_at ?? new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
setError("Conflicto detectado y no se pudo obtener el estado remoto. Recarga manualmente.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
setError("No se pudo guardar la pizarra. Intenta nuevamente.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [lessonId, loading, revision, saving, strokes]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCanvas();
|
||||
}, [loadCanvas]);
|
||||
|
||||
const acceptRemote = useCallback(() => {
|
||||
if (!conflict) return;
|
||||
setStrokes(conflict.remoteStrokes);
|
||||
setRevision(conflict.remoteRevision);
|
||||
setLastSavedAt(conflict.remoteUpdatedAt);
|
||||
setDirty(false);
|
||||
setConflict(null);
|
||||
setError(null);
|
||||
}, [conflict]);
|
||||
|
||||
const forceLocal = useCallback(() => {
|
||||
setConflict(null);
|
||||
void saveCanvas(true);
|
||||
}, [saveCanvas]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty || loading || saving || isDrawing.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
void saveCanvas();
|
||||
}, 1500);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [dirty, loading, saveCanvas, saving, strokes]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
if (dirty || isDrawing.current) return;
|
||||
try {
|
||||
const data = await lmsApi.getLessonCollaborativeCanvas(lessonId);
|
||||
const serverStamp = data.updated_at || null;
|
||||
if (serverStamp !== lastSavedAt) {
|
||||
setStrokes(toStrokeArray(data.canvas_state || DEFAULT_CANVAS));
|
||||
setLastSavedAt(serverStamp);
|
||||
setRevision(data.revision || 0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Polling collaborative canvas failed", e);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [dirty, lastSavedAt, lessonId]);
|
||||
|
||||
return (
|
||||
<section className="space-y-4 rounded-3xl border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">Pizarra colaborativa (MVP)</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Dibuja ideas rápidas de la lección y compártelas con tu grupo.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStrokes([]);
|
||||
setDraftStroke(null);
|
||||
setDirty(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" /> Limpiar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveCanvas()}
|
||||
disabled={saving || (!dirty && !loading)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 hover:bg-blue-100 disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />} Guardar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadCanvas}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-gray-50 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-100 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} /> Recargar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs font-semibold text-red-600">{error}</p>}
|
||||
|
||||
{conflict && (
|
||||
<div className="rounded-2xl border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-600 p-4 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-black text-amber-800 dark:text-amber-300">
|
||||
Conflicto de edición detectado
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
|
||||
Otro usuario guardó mientras editabas. Elige qué versión conservar:
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-[11px]">
|
||||
<div className="rounded-xl border border-blue-200 bg-blue-50 dark:bg-blue-900/20 p-3">
|
||||
<p className="font-black text-blue-800 dark:text-blue-300 mb-1">Tu versión (local)</p>
|
||||
<p className="text-blue-700 dark:text-blue-400">
|
||||
{conflict.localStrokes.length} trazos
|
||||
</p>
|
||||
<p className="text-blue-500 dark:text-blue-500 mt-1">No guardada</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-green-200 bg-green-50 dark:bg-green-900/20 p-3">
|
||||
<p className="font-black text-green-800 dark:text-green-300 mb-1">Versión del servidor</p>
|
||||
<p className="text-green-700 dark:text-green-400">
|
||||
{conflict.remoteStrokes.length} trazos · rev. {conflict.remoteRevision}
|
||||
</p>
|
||||
<p className="text-green-500 dark:text-green-500 mt-1">
|
||||
{new Date(conflict.remoteUpdatedAt).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={forceLocal}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-blue-300 bg-blue-100 px-3 py-1.5 text-xs font-semibold text-blue-800 hover:bg-blue-200 disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />}
|
||||
Guardar mi versión
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={acceptRemote}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-green-300 bg-green-100 px-3 py-1.5 text-xs font-semibold text-green-800 hover:bg-green-200"
|
||||
>
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Usar versión del servidor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={wrapperRef} className="w-full overflow-hidden rounded-2xl border border-black/10 bg-white">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="touch-none"
|
||||
onPointerDown={(event) => {
|
||||
const point = getPoint(event);
|
||||
if (!point) return;
|
||||
isDrawing.current = true;
|
||||
setDraftStroke({ points: [point], color: "#1f2937", width: 2 });
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!isDrawing.current) return;
|
||||
const point = getPoint(event);
|
||||
if (!point) return;
|
||||
setDraftStroke((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, points: [...prev.points, point] };
|
||||
});
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
if (!isDrawing.current) return;
|
||||
isDrawing.current = false;
|
||||
setDraftStroke((prev) => {
|
||||
if (!prev || prev.points.length < 2) return null;
|
||||
setStrokes((current) => [...current, prev]);
|
||||
setDirty(true);
|
||||
return null;
|
||||
});
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
if (!isDrawing.current) return;
|
||||
isDrawing.current = false;
|
||||
setDraftStroke((prev) => {
|
||||
if (!prev || prev.points.length < 2) return null;
|
||||
setStrokes((current) => [...current, prev]);
|
||||
setDirty(true);
|
||||
return null;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||||
<span>Trazos: {strokes.length}</span>
|
||||
<span>
|
||||
{saving
|
||||
? "Guardando..."
|
||||
: dirty
|
||||
? "Cambios sin guardar (autosave en 1.5s)"
|
||||
: "Sin cambios pendientes"}
|
||||
</span>
|
||||
<span>Revision: {revision}</span>
|
||||
<span>Última sincronización: {lastSavedAt ? new Date(lastSavedAt).toLocaleTimeString() : "nunca"}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Puzzle, AlertTriangle, ExternalLink } from "lucide-react";
|
||||
|
||||
interface PluginBlockProps {
|
||||
pluginId: string;
|
||||
name: string;
|
||||
componentUrl: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderiza un Web Component externo dentro de un iframe sandboxed.
|
||||
* El sandbox permite scripts y same-origin pero bloquea navegación superior,
|
||||
* formularios externos y acceso a cámara/micrófono sin permiso explícito.
|
||||
*/
|
||||
export default function PluginBlock({ pluginId, name, componentUrl, config = {} }: PluginBlockProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Solo permitir HTTPS
|
||||
const isSecure = componentUrl.startsWith("https://");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSecure) {
|
||||
setError("Este plugin no puede cargarse: la URL debe usar HTTPS.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Enviar config al iframe cuando cargue vía postMessage
|
||||
const handleLoad = () => {
|
||||
setLoaded(true);
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: "OPENCCB_PLUGIN_CONFIG", pluginId, config },
|
||||
new URL(componentUrl).origin
|
||||
);
|
||||
};
|
||||
|
||||
const iframe = iframeRef.current;
|
||||
if (iframe) {
|
||||
iframe.addEventListener("load", handleLoad);
|
||||
return () => iframe.removeEventListener("load", handleLoad);
|
||||
}
|
||||
}, [componentUrl, config, isSecure, pluginId]);
|
||||
|
||||
if (!isSecure) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-red-700 dark:text-red-300">
|
||||
<AlertTriangle className="w-5 h-5 shrink-0" />
|
||||
<span>El plugin <strong>{name}</strong> no puede cargarse: URL no segura.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-black/10 dark:border-white/10 overflow-hidden bg-white dark:bg-black/20">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-black/5 dark:border-white/5 bg-black/2 dark:bg-white/3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Puzzle className="w-4 h-4 text-indigo-500" />
|
||||
{name}
|
||||
</div>
|
||||
<a
|
||||
href={componentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-black/30 dark:text-white/30 hover:text-black/60 dark:hover:text-white/60 flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Abrir
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{!loaded && (
|
||||
<div className="flex items-center justify-center h-48 text-black/30 dark:text-white/30 text-sm animate-pulse">
|
||||
Cargando plugin…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Iframe sandboxed */}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={componentUrl}
|
||||
title={name}
|
||||
className={`w-full transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0 h-0"}`}
|
||||
style={{ minHeight: loaded ? "400px" : "0px", border: "none" }}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
loading="lazy"
|
||||
onError={() => setError("No se pudo cargar el plugin.")}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export interface QuizQuestion {
|
||||
|
||||
export interface Block {
|
||||
id: string;
|
||||
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab' | 'scorm';
|
||||
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab' | 'scorm' | 'plugin';
|
||||
title: string;
|
||||
content?: string;
|
||||
url?: string;
|
||||
@@ -228,6 +228,21 @@ export interface Block {
|
||||
// SCORM/xAPI fields
|
||||
launch_url?: string;
|
||||
metadata?: any;
|
||||
// Plugin fields
|
||||
component_url?: string;
|
||||
}
|
||||
|
||||
export interface OrgPlugin {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
component_url: string;
|
||||
icon_url: string | null;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TrackXapiPayload {
|
||||
@@ -295,6 +310,28 @@ export interface LessonDependency {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CollaborativeCanvasState {
|
||||
strokes?: Array<{
|
||||
points: Array<{ x: number; y: number }>;
|
||||
color?: string;
|
||||
width?: number;
|
||||
}>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CollaborativeCanvas {
|
||||
lesson_id: string;
|
||||
canvas_state: CollaborativeCanvasState;
|
||||
revision: number;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface CollaborativeCanvasUpdateResult {
|
||||
lesson_id: string;
|
||||
revision: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserGrade {
|
||||
id: string;
|
||||
user_id: string;
|
||||
@@ -846,6 +883,24 @@ export const lmsApi = {
|
||||
return apiFetch(`/lessons/${id}`);
|
||||
},
|
||||
|
||||
async getLessonCollaborativeCanvas(lessonId: string): Promise<CollaborativeCanvas> {
|
||||
return apiFetch(`/lessons/${lessonId}/collaborative-canvas`);
|
||||
},
|
||||
|
||||
async updateLessonCollaborativeCanvas(
|
||||
lessonId: string,
|
||||
canvasState: CollaborativeCanvasState,
|
||||
expectedRevision?: number
|
||||
): Promise<CollaborativeCanvasUpdateResult> {
|
||||
return apiFetch(`/lessons/${lessonId}/collaborative-canvas`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
canvas_state: canvasState,
|
||||
expected_revision: expectedRevision,
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
async register(payload: AuthPayload): Promise<AuthResponse> {
|
||||
return apiFetch('/auth/register', {
|
||||
method: 'POST',
|
||||
@@ -1278,5 +1333,10 @@ export const lmsApi = {
|
||||
},
|
||||
async verifyCertificate(code: string): Promise<any> {
|
||||
return apiFetch(`/certificates/verify/${code}`);
|
||||
}
|
||||
},
|
||||
|
||||
// Fase 35: Plugins
|
||||
getEnabledPlugins(): Promise<OrgPlugin[]> {
|
||||
return apiFetch('/plugins/enabled', {}, true);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { lmsApi, OrgPlugin, CreatePluginPayload } from "@/lib/api";
|
||||
import {
|
||||
Puzzle,
|
||||
Plus,
|
||||
Trash2,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Modal: Crear plugin
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function CreatePluginModal({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onCreated: (p: OrgPlugin) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<CreatePluginPayload>({
|
||||
name: "",
|
||||
description: "",
|
||||
component_url: "",
|
||||
icon_url: "",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.component_url.startsWith("https://")) {
|
||||
setError("La URL del componente debe comenzar con https://");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const plugin = await lmsApi.createPlugin({
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
component_url: form.component_url,
|
||||
icon_url: form.icon_url || undefined,
|
||||
});
|
||||
onCreated(plugin);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Error al crear plugin");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl w-full max-w-lg border border-black/10 dark:border-white/10">
|
||||
<div className="flex items-center justify-between p-6 border-b border-black/5 dark:border-white/5">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Puzzle className="w-5 h-5 text-indigo-500" />
|
||||
Registrar Plugin
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-black/40 dark:text-white/40 hover:text-black dark:hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={(e) => void handleSubmit(e)} className="p-6 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Nombre *</label>
|
||||
<input
|
||||
required
|
||||
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Mi Plugin Interactivo"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Descripción</label>
|
||||
<input
|
||||
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={form.description}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="Descripción breve del plugin"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">URL del Web Component *</label>
|
||||
<input
|
||||
required
|
||||
type="url"
|
||||
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 font-mono"
|
||||
value={form.component_url}
|
||||
onChange={e => setForm(f => ({ ...f, component_url: e.target.value }))}
|
||||
placeholder="https://mi-plugin.ejemplo.com/component"
|
||||
/>
|
||||
<p className="text-xs text-black/40 dark:text-white/40">Solo se permiten URLs HTTPS. El componente se cargará en un iframe sandboxed.</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">URL del Icono</label>
|
||||
<input
|
||||
type="url"
|
||||
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 font-mono"
|
||||
value={form.icon_url}
|
||||
onChange={e => setForm(f => ({ ...f, icon_url: e.target.value }))}
|
||||
placeholder="https://…/icon.svg (opcional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-sm text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg text-sm text-black/60 dark:text-white/60 hover:bg-black/5 dark:hover:bg-white/5 transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-lg text-sm bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{saving && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||
Registrar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tarjeta de Plugin
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function PluginCard({
|
||||
plugin,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
plugin: OrgPlugin;
|
||||
onToggle: (id: string, enabled: boolean) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
const handleToggle = async () => {
|
||||
setToggling(true);
|
||||
await onToggle(plugin.id, !plugin.enabled);
|
||||
setToggling(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`¿Eliminar el plugin "${plugin.name}"? Esta acción no se puede deshacer.`)) return;
|
||||
setDeleting(true);
|
||||
await onDelete(plugin.id);
|
||||
setDeleting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-start gap-4 p-4 rounded-xl border transition-all ${plugin.enabled ? "border-black/10 dark:border-white/10 bg-white/60 dark:bg-white/5" : "border-black/5 dark:border-white/5 bg-black/2 dark:bg-white/2 opacity-60"}`}>
|
||||
{/* Icono */}
|
||||
<div className="w-10 h-10 rounded-lg bg-indigo-100 dark:bg-indigo-900/30 flex items-center justify-center shrink-0">
|
||||
{plugin.icon_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={plugin.icon_url} alt="" className="w-6 h-6 object-contain" />
|
||||
) : (
|
||||
<Puzzle className="w-5 h-5 text-indigo-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{plugin.name}</span>
|
||||
{plugin.enabled ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">Activo</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-black/5 dark:bg-white/5 text-black/40 dark:text-white/40">Inactivo</span>
|
||||
)}
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="text-xs text-black/50 dark:text-white/50 mt-0.5">{plugin.description}</p>
|
||||
)}
|
||||
<a
|
||||
href={plugin.component_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-indigo-500 hover:text-indigo-700 dark:hover:text-indigo-300 mt-1 transition-colors font-mono truncate max-w-xs"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 shrink-0" />
|
||||
{plugin.component_url}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Acciones */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => void handleToggle()}
|
||||
disabled={toggling}
|
||||
title={plugin.enabled ? "Deshabilitar" : "Habilitar"}
|
||||
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{plugin.enabled
|
||||
? <ToggleRight className="w-5 h-5 text-green-500" />
|
||||
: <ToggleLeft className="w-5 h-5 text-black/30 dark:text-white/30" />
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleting}
|
||||
title="Eliminar"
|
||||
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-black/30 dark:text-white/30 hover:text-red-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Página principal
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PluginsPage() {
|
||||
const [plugins, setPlugins] = useState<OrgPlugin[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await lmsApi.listPlugins();
|
||||
setPlugins(data);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Error al cargar plugins");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const handleToggle = async (id: string, enabled: boolean) => {
|
||||
try {
|
||||
const updated = await lmsApi.updatePlugin(id, { enabled });
|
||||
setPlugins(prev => prev.map(p => p.id === id ? updated : p));
|
||||
} catch {
|
||||
setError("Error al actualizar el plugin");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await lmsApi.deletePlugin(id);
|
||||
setPlugins(prev => prev.filter(p => p.id !== id));
|
||||
} catch {
|
||||
setError("Error al eliminar el plugin");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreated = (plugin: OrgPlugin) => {
|
||||
setPlugins(prev => [...prev, plugin]);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const active = plugins.filter(p => p.enabled);
|
||||
const inactive = plugins.filter(p => !p.enabled);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-10 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Puzzle className="w-6 h-6 text-indigo-500" />
|
||||
Ecosistema de Plugins
|
||||
</h1>
|
||||
<p className="text-sm text-black/40 dark:text-white/40 mt-1">
|
||||
Registra y gestiona Web Components externos que se muestran como bloques en las lecciones.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
className="p-2 rounded-lg bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Registrar Plugin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-red-700 dark:text-red-300">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vacío */}
|
||||
{!loading && plugins.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-black/30 dark:text-white/30">
|
||||
<Puzzle className="w-12 h-12" />
|
||||
<p className="text-sm text-center">Aún no hay plugins registrados.<br />Haz clic en <strong>Registrar Plugin</strong> para añadir el primero.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugins activos */}
|
||||
{active.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-black/40 dark:text-white/40 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-green-500" />
|
||||
Activos ({active.length})
|
||||
</h2>
|
||||
{active.map(p => (
|
||||
<PluginCard
|
||||
key={p.id}
|
||||
plugin={p}
|
||||
onToggle={(id, en) => void handleToggle(id, en)}
|
||||
onDelete={(id) => void handleDelete(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugins inactivos */}
|
||||
{inactive.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-black/40 dark:text-white/40">
|
||||
Inactivos ({inactive.length})
|
||||
</h2>
|
||||
{inactive.map(p => (
|
||||
<PluginCard
|
||||
key={p.id}
|
||||
plugin={p}
|
||||
onToggle={(id, en) => void handleToggle(id, en)}
|
||||
onDelete={(id) => void handleDelete(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="rounded-xl border border-black/5 dark:border-white/5 bg-black/2 dark:bg-white/2 p-4 text-xs text-black/40 dark:text-white/40 space-y-1">
|
||||
<p className="font-semibold text-black/50 dark:text-white/50">¿Cómo funciona?</p>
|
||||
<p>1. Registra la URL HTTPS del Web Component externo.</p>
|
||||
<p>2. En el Editor de Lecciones añade un bloque de tipo <code className="font-mono bg-black/5 dark:bg-white/5 px-1 rounded">plugin</code> y selecciona el plugin.</p>
|
||||
<p>3. El componente se carga en un <code className="font-mono bg-black/5 dark:bg-white/5 px-1 rounded">iframe sandbox</code> — los scripts externos nunca acceden al DOM de OpenCCB.</p>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<CreatePluginModal onClose={() => setShowModal(false)} onCreated={handleCreated} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,11 @@ export default function BackgroundTasksPage() {
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
<div className="text-[10px] text-gray-400 mt-1 font-medium">{progress}% completo</div>
|
||||
{(task.processed_items > 0 || task.failed_items > 0) && (
|
||||
<div className="text-[10px] text-gray-400 font-medium">
|
||||
✓ {task.processed_items} / ✗ {task.failed_items}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -79,7 +84,21 @@ export default function BackgroundTasksPage() {
|
||||
return <span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Queued</span>;
|
||||
case 'failed':
|
||||
case 'error':
|
||||
return <span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Failed</span>;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Failed</span>
|
||||
{task.error_message && (
|
||||
<div className="text-[10px] text-red-500 max-w-[180px] leading-tight" title={task.error_message}>
|
||||
{task.error_message.length > 60 ? task.error_message.slice(0, 60) + '…' : task.error_message}
|
||||
</div>
|
||||
)}
|
||||
{task.task_type !== 'lesson_transcription' && (task.processed_items > 0 || task.failed_items > 0) && (
|
||||
<div className="text-[10px] text-gray-400 font-medium">
|
||||
✓ {task.processed_items} ok / ✗ {task.failed_items} error
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'completed':
|
||||
return <span className="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Completed</span>;
|
||||
case 'idle':
|
||||
@@ -179,7 +198,17 @@ export default function BackgroundTasksPage() {
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{task.task_type === 'lesson_transcription' && (
|
||||
{(task.task_type === 'lesson_transcription' && (task.status === 'queued' || task.status === 'processing')) && (
|
||||
<button
|
||||
onClick={() => handleCancel(task.id)}
|
||||
disabled={actionLoading === task.id}
|
||||
className="inline-flex items-center px-3 py-1.5 border border-red-200 text-xs font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === task.id ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <XCircle className="w-3 h-3 mr-1" />}
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{(task.task_type === 'zip_rag_import' && (task.status === 'queued' || task.status === 'processing')) && (
|
||||
<button
|
||||
onClick={() => handleCancel(task.id)}
|
||||
disabled={actionLoading === task.id}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
lmsApi,
|
||||
CourseQualityMetrics,
|
||||
CourseDiscriminationReport,
|
||||
CurricularSuggestionsReport,
|
||||
CurricularSuggestion,
|
||||
LessonQualityMetric,
|
||||
QuizDiscriminationItem,
|
||||
} from "@/lib/api";
|
||||
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Star,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers de formato
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function pct(v: number) {
|
||||
return `${(v * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function discriminationLabel(d: number): { label: string; color: string } {
|
||||
if (d >= 0.4) return { label: "Excelente", color: "text-green-600 dark:text-green-400" };
|
||||
if (d >= 0.3) return { label: "Buena", color: "text-blue-600 dark:text-blue-400" };
|
||||
if (d >= 0.2) return { label: "Aceptable", color: "text-yellow-600 dark:text-yellow-400" };
|
||||
return { label: "Revisar", color: "text-red-600 dark:text-red-400" };
|
||||
}
|
||||
|
||||
function severityIcon(severity: string) {
|
||||
switch (severity) {
|
||||
case "high": return <AlertTriangle className="w-4 h-4 text-red-500 shrink-0" />;
|
||||
case "medium": return <Info className="w-4 h-4 text-yellow-500 shrink-0" />;
|
||||
case "positive": return <Star className="w-4 h-4 text-green-500 shrink-0" />;
|
||||
default: return <Info className="w-4 h-4 text-blue-500 shrink-0" />;
|
||||
}
|
||||
}
|
||||
|
||||
function severityBadge(severity: string) {
|
||||
const map: Record<string, string> = {
|
||||
high: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
||||
medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300",
|
||||
info: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
positive: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
high: "Alta prioridad",
|
||||
medium: "Atención",
|
||||
info: "Información",
|
||||
positive: "Destacado",
|
||||
};
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${map[severity] ?? map.info}`}>
|
||||
{labels[severity] ?? severity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Barra horizontal proporcional
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Bar({ value, max = 1, colorClass = "bg-indigo-500" }: { value: number; max?: number; colorClass?: string }) {
|
||||
const pctVal = Math.min(100, Math.max(0, (value / max) * 100));
|
||||
return (
|
||||
<div className="w-full h-2 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all duration-500 ${colorClass}`} style={{ width: `${pctVal}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tabs
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Tab = "quality" | "discrimination" | "suggestions";
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "quality", label: "Métricas de Calidad", icon: <BarChart3 className="w-4 h-4" /> },
|
||||
{ id: "discrimination", label: "Índice de Discriminación", icon: <TrendingDown className="w-4 h-4" /> },
|
||||
{ id: "suggestions", label: "Sugerencias Curriculares", icon: <Lightbulb className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sección: Métricas de Calidad
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function QualityPanel({ data }: { data: CourseQualityMetrics }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-black/50 dark:text-white/50">
|
||||
{data.enrolled} alumno{data.enrolled !== 1 ? "s" : ""} inscrito{data.enrolled !== 1 ? "s" : ""} · {data.lessons.length} lección{data.lessons.length !== 1 ? "es" : ""}
|
||||
</p>
|
||||
|
||||
{data.lessons.length === 0 && (
|
||||
<p className="text-sm text-black/40 dark:text-white/40 italic">Sin datos de entregas todavía.</p>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-separate border-spacing-y-1">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-black/40 dark:text-white/40 uppercase tracking-wider">
|
||||
<th className="pb-2 pr-4">#</th>
|
||||
<th className="pb-2 pr-4">Lección</th>
|
||||
<th className="pb-2 pr-4">Completitud</th>
|
||||
<th className="pb-2 pr-4">Puntaje Medio</th>
|
||||
<th className="pb-2 pr-4">Fallo</th>
|
||||
<th className="pb-2 pr-4">Intentos Prom.</th>
|
||||
<th className="pb-2">Sin entregar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.lessons.map((l: LessonQualityMetric) => (
|
||||
<tr key={l.lesson_id} className="bg-white/50 dark:bg-white/5 rounded-lg">
|
||||
<td className="py-3 px-3 rounded-l-lg text-black/30 dark:text-white/30 w-8">{l.position}</td>
|
||||
<td className="py-3 pr-4 font-medium max-w-[200px] truncate" title={l.lesson_title}>{l.lesson_title}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="space-y-1">
|
||||
<span className="font-mono">{pct(l.completion_rate)}</span>
|
||||
<Bar value={l.completion_rate} colorClass={l.completion_rate < 0.4 ? "bg-red-400" : "bg-indigo-500"} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="space-y-1">
|
||||
<span className="font-mono">{pct(l.avg_score)}</span>
|
||||
<Bar value={l.avg_score} colorClass={l.avg_score < 0.5 ? "bg-orange-400" : l.avg_score > 0.9 ? "bg-green-400" : "bg-sky-500"} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-mono">
|
||||
<span className={l.failure_rate > 0.4 ? "text-red-500 font-semibold" : ""}>{pct(l.failure_rate)}</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-mono">{l.avg_attempts.toFixed(1)}</td>
|
||||
<td className="py-3 px-3 rounded-r-lg font-mono">
|
||||
<span className={l.abandonment_count > 0 ? "text-yellow-600 dark:text-yellow-400" : "text-black/30 dark:text-white/30"}>{l.abandonment_count}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sección: Índice de Discriminación
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function DiscriminationPanel({ data }: { data: CourseDiscriminationReport }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-black/50 dark:text-white/50">
|
||||
El índice mide si los alumnos con mejor rendimiento global aciertan más esta pregunta. Un índice ≥ 0.4 es excelente.
|
||||
</p>
|
||||
|
||||
{data.items.length === 0 && (
|
||||
<p className="text-sm text-black/40 dark:text-white/40 italic">
|
||||
No hay datos de <code>block_scores</code> en los metadatos de entregas todavía.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-separate border-spacing-y-1">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-black/40 dark:text-white/40 uppercase tracking-wider">
|
||||
<th className="pb-2 pr-4">Lección</th>
|
||||
<th className="pb-2 pr-4">Bloque</th>
|
||||
<th className="pb-2 pr-4">Índice Discriminación</th>
|
||||
<th className="pb-2 pr-4">Facilidad</th>
|
||||
<th className="pb-2">Muestra</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((item: QuizDiscriminationItem) => {
|
||||
const { label, color } = discriminationLabel(item.discrimination_index);
|
||||
return (
|
||||
<tr key={`${item.lesson_id}-${item.block_id}`} className="bg-white/50 dark:bg-white/5 rounded-lg">
|
||||
<td className="py-3 px-3 rounded-l-lg font-medium max-w-[180px] truncate" title={item.lesson_title}>{item.lesson_title}</td>
|
||||
<td className="py-3 pr-4 font-mono text-xs text-black/50 dark:text-white/50 max-w-[120px] truncate">{item.block_id}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono">{item.discrimination_index.toFixed(2)}</span>
|
||||
<span className={`text-xs font-semibold ${color}`}>{label}</span>
|
||||
</div>
|
||||
<Bar value={item.discrimination_index} max={1} colorClass={item.discrimination_index >= 0.3 ? "bg-green-500" : item.discrimination_index >= 0.2 ? "bg-yellow-400" : "bg-red-400"} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="space-y-1">
|
||||
<span className="font-mono">{pct(item.facility_index)}</span>
|
||||
<Bar value={item.facility_index} colorClass="bg-sky-500" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3 rounded-r-lg font-mono text-black/40 dark:text-white/40">{item.sample_size}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sección: Sugerencias Curriculares
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SuggestionsPanel({ data }: { data: CurricularSuggestionsReport }) {
|
||||
if (data.suggestions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-black/30 dark:text-white/30">
|
||||
<CheckCircle2 className="w-10 h-10" />
|
||||
<p className="text-sm">No hay sugerencias en este momento. El curso tiene métricas saludables o aún pocos datos.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const high = data.suggestions.filter(s => s.severity === "high");
|
||||
const medium = data.suggestions.filter(s => s.severity === "medium");
|
||||
const others = data.suggestions.filter(s => s.severity !== "high" && s.severity !== "medium");
|
||||
|
||||
const groups = [
|
||||
{ label: "Alta prioridad", items: high },
|
||||
{ label: "Atención", items: medium },
|
||||
{ label: "Otras observaciones", items: others },
|
||||
].filter(g => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{groups.map(group => (
|
||||
<div key={group.label} className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-black/40 dark:text-white/40">{group.label}</h3>
|
||||
{group.items.map((s: CurricularSuggestion, i: number) => (
|
||||
<div key={i} className="flex gap-3 p-4 rounded-xl bg-white/50 dark:bg-white/5 border border-black/5 dark:border-white/5">
|
||||
{severityIcon(s.severity)}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium truncate">{s.lesson_title}</span>
|
||||
{severityBadge(s.severity)}
|
||||
</div>
|
||||
<p className="text-sm text-black/60 dark:text-white/60">{s.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Página principal
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PedagogicalAnalyticsPage() {
|
||||
const { id } = useParams() as { id: string };
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>("quality");
|
||||
const [quality, setQuality] = useState<CourseQualityMetrics | null>(null);
|
||||
const [discrimination, setDiscrimination] = useState<CourseDiscriminationReport | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<CurricularSuggestionsReport | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [q, d, s] = await Promise.all([
|
||||
lmsApi.getCourseQualityMetrics(id),
|
||||
lmsApi.getCourseDiscriminationIndex(id),
|
||||
lmsApi.getCourseSuggestions(id),
|
||||
]);
|
||||
setQuality(q);
|
||||
setDiscrimination(d);
|
||||
setSuggestions(s);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Error al cargar análisis pedagógico");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
return (
|
||||
<CourseEditorLayout activeTab="pedagogical">
|
||||
<div className="max-w-5xl mx-auto px-4 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<TrendingUp className="w-6 h-6 text-indigo-500" />
|
||||
Análisis Pedagógico Profundo
|
||||
</h1>
|
||||
<p className="text-sm text-black/40 dark:text-white/40 mt-1">
|
||||
Métricas de calidad, índice de discriminación y sugerencias de mejora curricular.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 p-1 bg-black/5 dark:bg-white/5 rounded-xl w-fit">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === tab.id
|
||||
? "bg-white dark:bg-white/10 shadow-sm text-indigo-600 dark:text-indigo-400"
|
||||
: "text-black/50 dark:text-white/50 hover:text-black/80 dark:hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="rounded-2xl border border-black/5 dark:border-white/5 bg-white/30 dark:bg-white/5 p-6">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20 text-black/30 dark:text-white/30">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mr-2" />
|
||||
Cargando análisis...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === "quality" && quality && (
|
||||
<QualityPanel data={quality} />
|
||||
)}
|
||||
{!loading && activeTab === "discrimination" && discrimination && (
|
||||
<DiscriminationPanel data={discrimination} />
|
||||
)}
|
||||
{!loading && activeTab === "suggestions" && suggestions && (
|
||||
<SuggestionsPanel data={suggestions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CourseEditorLayout>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,8 @@ type TabKey =
|
||||
| "team"
|
||||
| "peer-reviews"
|
||||
| "students"
|
||||
| "sessions";
|
||||
| "sessions"
|
||||
| "pedagogical";
|
||||
|
||||
interface CourseEditorLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -114,6 +115,7 @@ export default function CourseEditorLayout({
|
||||
icon: TrendingUp,
|
||||
tabs: [
|
||||
{ key: "analytics", label: "Analíticas", icon: BarChart2, href: `/courses/${id}/analytics` },
|
||||
{ key: "pedagogical", label: "Análisis Pedagógico", icon: TrendingUp, href: `/courses/${id}/analytics/pedagogical` },
|
||||
{ key: "settings", label: "Configuración", icon: Settings, href: `/courses/${id}/settings` },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -672,6 +672,47 @@ export interface CourseAnalytics {
|
||||
}[];
|
||||
}
|
||||
|
||||
// Análisis Pedagógico Profundo (Fase 34)
|
||||
export interface LessonQualityMetric {
|
||||
lesson_id: string;
|
||||
lesson_title: string;
|
||||
position: number;
|
||||
completion_rate: number;
|
||||
avg_attempts: number;
|
||||
avg_score: number;
|
||||
failure_rate: number;
|
||||
abandonment_count: number;
|
||||
}
|
||||
export interface CourseQualityMetrics {
|
||||
course_id: string;
|
||||
enrolled: number;
|
||||
lessons: LessonQualityMetric[];
|
||||
}
|
||||
export interface QuizDiscriminationItem {
|
||||
lesson_id: string;
|
||||
lesson_title: string;
|
||||
block_id: string;
|
||||
discrimination_index: number;
|
||||
facility_index: number;
|
||||
sample_size: number;
|
||||
}
|
||||
export interface CourseDiscriminationReport {
|
||||
course_id: string;
|
||||
items: QuizDiscriminationItem[];
|
||||
}
|
||||
export interface CurricularSuggestion {
|
||||
lesson_id: string;
|
||||
lesson_title: string;
|
||||
kind: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
}
|
||||
export interface CurricularSuggestionsReport {
|
||||
course_id: string;
|
||||
suggestions: CurricularSuggestion[];
|
||||
}
|
||||
|
||||
|
||||
export interface CohortData {
|
||||
period: string;
|
||||
count: number;
|
||||
@@ -1769,6 +1810,24 @@ export const lmsApi = {
|
||||
method: 'GET',
|
||||
query: { days, limit }
|
||||
}, true),
|
||||
|
||||
// Análisis Pedagógico Profundo (Fase 34)
|
||||
getCourseQualityMetrics: (courseId: string): Promise<CourseQualityMetrics> =>
|
||||
apiFetch(`/courses/${courseId}/pedagogical/quality-metrics`, {}, true),
|
||||
getCourseDiscriminationIndex: (courseId: string): Promise<CourseDiscriminationReport> =>
|
||||
apiFetch(`/courses/${courseId}/pedagogical/discrimination-index`, {}, true),
|
||||
getCourseSuggestions: (courseId: string): Promise<CurricularSuggestionsReport> =>
|
||||
apiFetch(`/courses/${courseId}/pedagogical/suggestions`, {}, true),
|
||||
|
||||
// Fase 35: Ecosistema de Plugins
|
||||
listPlugins: (): Promise<OrgPlugin[]> =>
|
||||
apiFetch('/plugins', {}, true),
|
||||
createPlugin: (payload: CreatePluginPayload): Promise<OrgPlugin> =>
|
||||
apiFetch('/plugins', { method: 'POST', body: JSON.stringify(payload) }, true),
|
||||
updatePlugin: (id: string, payload: UpdatePluginPayload): Promise<OrgPlugin> =>
|
||||
apiFetch(`/plugins/${id}`, { method: 'PUT', body: JSON.stringify(payload) }, true),
|
||||
deletePlugin: (id: string): Promise<void> =>
|
||||
apiFetch(`/plugins/${id}`, { method: 'DELETE' }, true),
|
||||
};
|
||||
|
||||
export interface Meeting {
|
||||
@@ -1916,6 +1975,35 @@ export interface AiDataEthicsSummaryResponse {
|
||||
events: AiDataEthicsEventItem[];
|
||||
}
|
||||
|
||||
// Fase 35: Ecosistema de Plugins
|
||||
export interface OrgPlugin {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
component_url: string;
|
||||
icon_url: string | null;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
export interface CreatePluginPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
component_url: string;
|
||||
icon_url?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
export interface UpdatePluginPayload {
|
||||
name?: string;
|
||||
description?: string;
|
||||
component_url?: string;
|
||||
icon_url?: string;
|
||||
config?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface LtiDeepLinkingContentItem {
|
||||
type: 'ltiResourceLink';
|
||||
title?: string;
|
||||
@@ -1942,6 +2030,9 @@ export interface BackgroundTask {
|
||||
task_type: 'lesson_transcription' | 'lesson_image' | 'course_image' | 'zip_rag_import';
|
||||
status: 'idle' | 'queued' | 'processing' | 'failed' | 'completed' | 'error';
|
||||
progress: number;
|
||||
processed_items: number;
|
||||
failed_items: number;
|
||||
error_message?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user