feat: add progress tracking for course completion metrics
- Introduced a new module for progress tracking in the LMS service. - Implemented `calculate_course_completion` function to compute total lessons, completed lessons, and progress percentage for a user in a specific course. - Updated the main.rs file to include the new progress tracking module. - Enhanced the Excel import functionality in the Question Bank to support various question types and improved error handling. - Added a new dependency on the `xlsx` library for handling Excel files in the frontend. - Modified the course settings page to include a branded certificate template with additional organization details. - Updated the package.json and package-lock.json files to include the new `xlsx` dependency. - Changed the default state for ingestRag in the Admin Shared Materials page to true.
This commit is contained in:
@@ -105,3 +105,6 @@ NEXT_PUBLIC_STUDIO_DOMAIN=studio.norteamericano.com
|
|||||||
NEXT_PUBLIC_LEARNING_DOMAIN=learning.norteamericano.com
|
NEXT_PUBLIC_LEARNING_DOMAIN=learning.norteamericano.com
|
||||||
NEXT_PUBLIC_CMS_API_URL=https://studio.norteamericano.com
|
NEXT_PUBLIC_CMS_API_URL=https://studio.norteamericano.com
|
||||||
NEXT_PUBLIC_LMS_API_URL=https://learning.norteamericano.com
|
NEXT_PUBLIC_LMS_API_URL=https://learning.norteamericano.com
|
||||||
|
ZIP_IMPORT_MAX_UPLOAD_BYTES=4294967296
|
||||||
|
ZIP_IMPORT_MAX_ENTRY_BYTES=1073741824
|
||||||
|
ZIP_IMPORT_MAX_TOTAL_BYTES=17179869184
|
||||||
|
|||||||
+4
-4
@@ -74,10 +74,10 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Fase 22: Estabilidad y Funcionalidades Pendientes 🛠️ (En Ejecución)
|
## Fase 22: Estabilidad y Funcionalidades Pendientes 🛠️ (En Ejecución)
|
||||||
- [ ] **Generación de Certificados Premium**: Mejorar UI de configuración de templates en Studio.
|
- [x] **Generación de Certificados Premium**: Mejorar UI de configuración de templates en Studio.
|
||||||
- [ ] **Tracking de Progreso Atómico**: Reemplazar hardcodes por cálculo real de completitud.
|
- [x] **Tracking de Progreso Atómico**: Reemplazar hardcodes por cálculo real de completitud.
|
||||||
- [ ] **Notificaciones de Foros**: Implementar despacho de alertas vía SMTP.
|
- [x] **Notificaciones de Foros**: Implementar despacho de alertas vía SMTP.
|
||||||
- [ ] **Importación Masiva (Excel)**: Finalizar soporte para Question Bank.
|
- [x] **Importación Masiva (Excel)**: Finalizar soporte para Question Bank.
|
||||||
|
|
||||||
## Fase 23 - 27: Infraestructura Crítica 📋 (Planificado)
|
## Fase 23 - 27: Infraestructura Crítica 📋 (Planificado)
|
||||||
- [ ] **Integración SMTP**: Password reset, notificaciones transaccionales y de marketing.
|
- [ ] **Integración SMTP**: Password reset, notificaciones transaccionales y de marketing.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use common::models::{
|
|||||||
LessonDependency,
|
LessonDependency,
|
||||||
};
|
};
|
||||||
use crate::external_db::MySqlPool;
|
use crate::external_db::MySqlPool;
|
||||||
|
use crate::progress_tracking::{CourseCompletionMetrics, calculate_course_completion};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use tokio::time::{Duration, timeout};
|
use tokio::time::{Duration, timeout};
|
||||||
@@ -1340,6 +1341,7 @@ pub async fn get_user_enrollments(
|
|||||||
WHERE ug.user_id = e.user_id
|
WHERE ug.user_id = e.user_id
|
||||||
AND ug.course_id = e.course_id
|
AND ug.course_id = e.course_id
|
||||||
AND l.is_graded = true
|
AND l.is_graded = true
|
||||||
|
AND (ug.score * 100.0) >= COALESCE(l.passing_percentage::double precision, 60.0)
|
||||||
) graded ON TRUE
|
) graded ON TRUE
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT COUNT(DISTINCT li.lesson_id)::int AS ungraded_done
|
SELECT COUNT(DISTINCT li.lesson_id)::int AS ungraded_done
|
||||||
@@ -1530,31 +1532,26 @@ pub async fn submit_lesson_score(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Lógica de detección de finalización de curso
|
// Lógica de detección de finalización de curso
|
||||||
let total_lessons: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM lessons WHERE module_id IN (SELECT id FROM modules WHERE course_id = $1)")
|
if let Ok(course_completion) = calculate_course_completion(&pool, payload.user_id, payload.course_id).await {
|
||||||
.bind(payload.course_id)
|
if course_completion.completed {
|
||||||
.fetch_one(&pool).await.unwrap_or(0);
|
webhook_service
|
||||||
|
.dispatch(
|
||||||
let completed_lessons: i64 = sqlx::query_scalar(
|
org_ctx.id,
|
||||||
"SELECT COUNT(*) FROM user_grades WHERE organization_id = $1 AND user_id = $2 AND course_id = $3",
|
"course.completed",
|
||||||
)
|
&serde_json::json!({
|
||||||
.bind(org_ctx.id)
|
"user_id": payload.user_id,
|
||||||
.bind(payload.user_id)
|
"course_id": payload.course_id,
|
||||||
.bind(payload.course_id)
|
"progress_percentage": course_completion.progress_percentage
|
||||||
.fetch_one(&pool)
|
}),
|
||||||
.await
|
)
|
||||||
.unwrap_or(0);
|
.await;
|
||||||
|
}
|
||||||
if total_lessons > 0 && completed_lessons >= total_lessons {
|
} else {
|
||||||
webhook_service
|
tracing::warn!(
|
||||||
.dispatch(
|
"No se pudo calcular la completitud real del curso {} para el usuario {}",
|
||||||
org_ctx.id,
|
payload.course_id,
|
||||||
"course.completed",
|
payload.user_id
|
||||||
&serde_json::json!({
|
);
|
||||||
"user_id": payload.user_id,
|
|
||||||
"course_id": payload.course_id
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(grade))
|
Ok(Json(grade))
|
||||||
@@ -1799,49 +1796,25 @@ pub async fn get_student_progress_stats(
|
|||||||
) -> Result<Json<common::models::ProgressStats>, (StatusCode, String)> {
|
) -> Result<Json<common::models::ProgressStats>, (StatusCode, String)> {
|
||||||
let user_id = claims.sub;
|
let user_id = claims.sub;
|
||||||
|
|
||||||
// 1. Lecciones totales
|
let course_completion = calculate_course_completion(&pool, user_id, course_id)
|
||||||
let total_lessons: i64 = sqlx::query_scalar(
|
.await
|
||||||
"SELECT COUNT(*) FROM lessons WHERE organization_id = $1 AND module_id IN (SELECT id FROM modules WHERE course_id = $2)"
|
.unwrap_or_else(|e| {
|
||||||
)
|
tracing::warn!(
|
||||||
.bind(org_ctx.id)
|
"No se pudo calcular el progreso del curso {} para el usuario {}: {}",
|
||||||
.bind(course_id)
|
course_id,
|
||||||
.fetch_one(&pool)
|
user_id,
|
||||||
.await
|
e
|
||||||
.unwrap_or(0);
|
);
|
||||||
|
CourseCompletionMetrics {
|
||||||
|
total_lessons: 0,
|
||||||
|
completed_lessons: 0,
|
||||||
|
progress_percentage: 0.0,
|
||||||
|
completed: false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Lecciones completadas
|
let total_lessons = course_completion.total_lessons;
|
||||||
let completed_lessons: i64 = sqlx::query_scalar(
|
let completed_lessons = course_completion.completed_lessons;
|
||||||
r#"
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM lessons l
|
|
||||||
JOIN modules m ON m.id = l.module_id
|
|
||||||
WHERE m.course_id = $2
|
|
||||||
AND l.organization_id = $3
|
|
||||||
AND (
|
|
||||||
(l.is_graded = true AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM user_grades ug
|
|
||||||
WHERE ug.user_id = $1
|
|
||||||
AND ug.course_id = $2
|
|
||||||
AND ug.lesson_id = l.id
|
|
||||||
))
|
|
||||||
OR
|
|
||||||
(l.is_graded = false AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM lesson_interactions li
|
|
||||||
WHERE li.user_id = $1
|
|
||||||
AND li.lesson_id = l.id
|
|
||||||
AND li.event_type = 'complete'
|
|
||||||
))
|
|
||||||
)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(course_id)
|
|
||||||
.bind(org_ctx.id)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
// 3. Progreso diario (Últimos 30 días)
|
// 3. Progreso diario (Últimos 30 días)
|
||||||
let daily_completions = sqlx::query_as::<_, common::models::DailyProgress>(
|
let daily_completions = sqlx::query_as::<_, common::models::DailyProgress>(
|
||||||
@@ -1888,11 +1861,7 @@ pub async fn get_student_progress_stats(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let progress_percentage = if total_lessons > 0 {
|
let progress_percentage = course_completion.progress_percentage as f32;
|
||||||
(completed_lessons as f32 / total_lessons as f32) * 100.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(common::models::ProgressStats {
|
Ok(Json(common::models::ProgressStats {
|
||||||
total_lessons,
|
total_lessons,
|
||||||
|
|||||||
@@ -27,6 +27,55 @@ pub struct UpdateAnnouncementPayload {
|
|||||||
pub is_pinned: Option<bool>,
|
pub is_pinned: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_instructor_or_admin(role: &str) -> bool {
|
||||||
|
role == "instructor" || role == "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_lms_role(role: &str) -> &'static str {
|
||||||
|
match role {
|
||||||
|
"admin" => "admin",
|
||||||
|
"instructor" => "instructor",
|
||||||
|
_ => "student",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_announcement_author_exists(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
organization_id: Uuid,
|
||||||
|
role: &str,
|
||||||
|
) -> Result<(), (StatusCode, String)> {
|
||||||
|
let exists = sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let synthetic_email = format!("user-{}@openccb.local", user_id);
|
||||||
|
let synthetic_name = format!("Usuario {}", &user_id.to_string()[..8]);
|
||||||
|
let normalized_role = normalize_lms_role(role);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (id, email, password_hash, full_name, organization_id, role)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(&synthetic_email)
|
||||||
|
.bind("external-auth")
|
||||||
|
.bind(&synthetic_name)
|
||||||
|
.bind(organization_id)
|
||||||
|
.bind(normalized_role)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("No se pudo provisionar usuario LMS: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ========== MANEJADORES ==========
|
// ========== MANEJADORES ==========
|
||||||
|
|
||||||
pub async fn list_announcements(
|
pub async fn list_announcements(
|
||||||
@@ -75,20 +124,15 @@ pub async fn create_announcement(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<CreateAnnouncementPayload>,
|
Json(payload): Json<CreateAnnouncementPayload>,
|
||||||
) -> Result<Json<CourseAnnouncement>, (StatusCode, String)> {
|
) -> Result<Json<CourseAnnouncement>, (StatusCode, String)> {
|
||||||
// Verificar si el usuario es instructor o administrador
|
if !is_instructor_or_admin(&claims.role) {
|
||||||
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
|
|
||||||
.bind(claims.sub)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
|
|
||||||
|
|
||||||
if user.0 != "instructor" && user.0 != "admin" {
|
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
"Solo los instructores pueden crear anuncios".to_string(),
|
"Solo los instructores pueden crear anuncios".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_announcement_author_exists(&pool, claims.sub, org_ctx.id, &claims.role).await?;
|
||||||
|
|
||||||
let mut tx = pool
|
let mut tx = pool
|
||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
@@ -198,14 +242,7 @@ pub async fn update_announcement(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<UpdateAnnouncementPayload>,
|
Json(payload): Json<UpdateAnnouncementPayload>,
|
||||||
) -> Result<Json<CourseAnnouncement>, (StatusCode, String)> {
|
) -> Result<Json<CourseAnnouncement>, (StatusCode, String)> {
|
||||||
// Verificar si el usuario es instructor o administrador
|
if !is_instructor_or_admin(&claims.role) {
|
||||||
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
|
|
||||||
.bind(claims.sub)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
|
|
||||||
|
|
||||||
if user.0 != "instructor" && user.0 != "admin" {
|
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
"Solo los instructores pueden actualizar anuncios".to_string(),
|
"Solo los instructores pueden actualizar anuncios".to_string(),
|
||||||
@@ -250,14 +287,7 @@ pub async fn delete_announcement(
|
|||||||
Path(announcement_id): Path<Uuid>,
|
Path(announcement_id): Path<Uuid>,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
// Verificar si el usuario es instructor o administrador
|
if !is_instructor_or_admin(&claims.role) {
|
||||||
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
|
|
||||||
.bind(claims.sub)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Usuario no encontrado".to_string()))?;
|
|
||||||
|
|
||||||
if user.0 != "instructor" && user.0 != "admin" {
|
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
"Solo los instructores pueden eliminar anuncios".to_string(),
|
"Solo los instructores pueden eliminar anuncios".to_string(),
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ use axum::{
|
|||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
use crate::progress_tracking::{CourseCompletionMetrics, calculate_course_completion};
|
||||||
use common::auth::Claims;
|
use common::auth::Claims;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Sha256, Digest};
|
use sha2::{Sha256, Digest};
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
|
use std::env;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
// Macro para usar json!() sin importar serde_json
|
// Macro para usar json!() sin importar serde_json
|
||||||
@@ -50,6 +52,45 @@ struct CertificateRecord {
|
|||||||
metadata: serde_json::Value,
|
metadata: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_certificate_asset_url(path: &str) -> String {
|
||||||
|
if path.starts_with("http://") || path.starts_with("https://") {
|
||||||
|
return path.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let cms_base_url = env::var("NEXT_PUBLIC_CMS_API_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "/cms-api".to_string());
|
||||||
|
let cms_base_url = cms_base_url.trim_end_matches('/');
|
||||||
|
|
||||||
|
if let Some(without_scheme) = path.strip_prefix("s3://") {
|
||||||
|
if let Some((bucket, key)) = without_scheme.split_once('/') {
|
||||||
|
return format!(
|
||||||
|
"{}/api/assets/s3-proxy/{}/{}",
|
||||||
|
cms_base_url,
|
||||||
|
bucket,
|
||||||
|
key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = if let Some(stripped) = path.strip_prefix("uploads/") {
|
||||||
|
format!("/assets/{}", stripped)
|
||||||
|
} else if let Some(stripped) = path.strip_prefix("/uploads/") {
|
||||||
|
format!("/assets/{}", stripped)
|
||||||
|
} else if path.starts_with("/assets/") {
|
||||||
|
path.to_string()
|
||||||
|
} else if path.starts_with("assets/") {
|
||||||
|
format!("/{}", path)
|
||||||
|
} else if path.starts_with('/') {
|
||||||
|
path.to_string()
|
||||||
|
} else {
|
||||||
|
format!("/{}", path)
|
||||||
|
};
|
||||||
|
|
||||||
|
format!("{}{}", cms_base_url, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
// ============= Handlers =============
|
// ============= Handlers =============
|
||||||
|
|
||||||
/// GET /courses/{id}/certificate
|
/// GET /courses/{id}/certificate
|
||||||
@@ -170,7 +211,7 @@ pub async fn get_certificate(
|
|||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"error": "No has completado este curso aún",
|
"error": "No has completado este curso aún",
|
||||||
"progress": course_completion.progress,
|
"progress": course_completion.progress_percentage,
|
||||||
"required": 100.0
|
"required": 100.0
|
||||||
})),
|
})),
|
||||||
));
|
));
|
||||||
@@ -257,7 +298,7 @@ pub async fn issue_certificate(
|
|||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"error": "No has completado este curso aún",
|
"error": "No has completado este curso aún",
|
||||||
"progress": course_completion.progress,
|
"progress": course_completion.progress_percentage,
|
||||||
"required": 100.0
|
"required": 100.0
|
||||||
})),
|
})),
|
||||||
));
|
));
|
||||||
@@ -326,98 +367,19 @@ pub async fn verify_certificate(
|
|||||||
|
|
||||||
// ============= Funciones Internas =============
|
// ============= Funciones Internas =============
|
||||||
|
|
||||||
struct CourseCompletion {
|
|
||||||
completed: bool,
|
|
||||||
progress: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_course_completion(
|
async fn check_course_completion(
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
course_id: Uuid,
|
course_id: Uuid,
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
) -> Result<CourseCompletion, (StatusCode, Json<serde_json::Value>)> {
|
) -> Result<CourseCompletionMetrics, (StatusCode, Json<serde_json::Value>)> {
|
||||||
// Obtener todas las lecciones graduables del curso
|
calculate_course_completion(pool, user_id, course_id)
|
||||||
let gradable_lessons = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT l.id, l.is_graded, l.passing_percentage
|
|
||||||
FROM lessons l
|
|
||||||
JOIN modules m ON m.id = l.module_id
|
|
||||||
WHERE m.course_id = $1 AND l.is_graded = true
|
|
||||||
"#
|
|
||||||
)
|
|
||||||
.bind(course_id)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e: sqlx::Error| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Error al obtener lecciones graduables: {}", e);
|
tracing::error!("Error al calcular completitud del curso: {}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(json!({"error": "Error interno del servidor"})),
|
Json(json!({"error": "Error interno del servidor"})),
|
||||||
)
|
)
|
||||||
})?;
|
|
||||||
|
|
||||||
if gradable_lessons.is_empty() {
|
|
||||||
// Si no hay lecciones graduables, considerar completado si está inscrito
|
|
||||||
let enrolled = sqlx::query(
|
|
||||||
"SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2"
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(course_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e: sqlx::Error| {
|
|
||||||
tracing::error!("Error al verificar inscripción: {}", e);
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(json!({"error": "Error interno del servidor"})),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
return Ok(CourseCompletion {
|
|
||||||
completed: enrolled.is_some(),
|
|
||||||
progress: if enrolled.is_some() { 100.0 } else { 0.0 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contar lecciones aprobadas
|
|
||||||
let total_lessons = gradable_lessons.len() as f64;
|
|
||||||
let mut passed_lessons = 0.0;
|
|
||||||
|
|
||||||
for lesson in gradable_lessons {
|
|
||||||
let passing_pct = lesson.get::<Option<i32>, _>("passing_percentage").unwrap_or(60) as f64;
|
|
||||||
let lesson_id: Uuid = lesson.get("id");
|
|
||||||
|
|
||||||
let best_score = sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT MAX(score_percentage) as max_score
|
|
||||||
FROM grades
|
|
||||||
WHERE user_id = $1 AND lesson_id = $2
|
|
||||||
"#
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(lesson_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e: sqlx::Error| {
|
|
||||||
tracing::error!("Error al obtener calificaciones: {}", e);
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(json!({"error": "Error interno del servidor"})),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if let Some(row) = best_score {
|
|
||||||
if row.get::<Option<f64>, _>("max_score").unwrap_or(0.0) >= passing_pct {
|
|
||||||
passed_lessons += 1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let progress = (passed_lessons / total_lessons) * 100.0;
|
|
||||||
|
|
||||||
Ok(CourseCompletion {
|
|
||||||
completed: progress >= 100.0,
|
|
||||||
progress,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +431,7 @@ async fn issue_certificate_internal(
|
|||||||
let organization_id: Uuid = course_row.get("organization_id");
|
let organization_id: Uuid = course_row.get("organization_id");
|
||||||
let org_data = sqlx::query(
|
let org_data = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT name, certificate_template
|
SELECT name, platform_name, logo_url, primary_color, secondary_color, certificate_template
|
||||||
FROM organizations
|
FROM organizations
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#
|
"#
|
||||||
@@ -492,7 +454,27 @@ async fn issue_certificate_internal(
|
|||||||
.or_else(|| org_data.as_ref().and_then(|o| o.get::<Option<String>, _>("certificate_template")))
|
.or_else(|| org_data.as_ref().and_then(|o| o.get::<Option<String>, _>("certificate_template")))
|
||||||
.unwrap_or_else(|| get_default_certificate_template());
|
.unwrap_or_else(|| get_default_certificate_template());
|
||||||
|
|
||||||
let _org_name = org_data.as_ref().map(|o| o.get::<String, _>("name")).unwrap_or_else(|| "OpenCCB".to_string());
|
let organization_name = org_data
|
||||||
|
.as_ref()
|
||||||
|
.map(|o| o.get::<String, _>("name"))
|
||||||
|
.unwrap_or_else(|| "OpenCCB".to_string());
|
||||||
|
let platform_name = org_data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|o| o.get::<Option<String>, _>("platform_name"))
|
||||||
|
.unwrap_or_else(|| organization_name.clone());
|
||||||
|
let logo_url = org_data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|o| o.get::<Option<String>, _>("logo_url"))
|
||||||
|
.map(|value| resolve_certificate_asset_url(&value))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let primary_color = org_data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|o| o.get::<Option<String>, _>("primary_color"))
|
||||||
|
.unwrap_or_else(|| "#2563eb".to_string());
|
||||||
|
let secondary_color = org_data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|o| o.get::<Option<String>, _>("secondary_color"))
|
||||||
|
.unwrap_or_else(|| "#7c3aed".to_string());
|
||||||
|
|
||||||
// Reemplazar variables en el template
|
// Reemplazar variables en el template
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
@@ -501,6 +483,11 @@ async fn issue_certificate_internal(
|
|||||||
.replace("{{course_title}}", &course_title)
|
.replace("{{course_title}}", &course_title)
|
||||||
.replace("{{date}}", &now.format("%d/%m/%Y").to_string())
|
.replace("{{date}}", &now.format("%d/%m/%Y").to_string())
|
||||||
.replace("{{score}}", "Aprobado")
|
.replace("{{score}}", "Aprobado")
|
||||||
|
.replace("{{organization_name}}", &organization_name)
|
||||||
|
.replace("{{platform_name}}", &platform_name)
|
||||||
|
.replace("{{logo_url}}", &logo_url)
|
||||||
|
.replace("{{primary_color}}", &primary_color)
|
||||||
|
.replace("{{secondary_color}}", &secondary_color)
|
||||||
.replace("{{verification_code}}", "VER-PLACEHOLDER");
|
.replace("{{verification_code}}", "VER-PLACEHOLDER");
|
||||||
|
|
||||||
// Generar código de verificación único
|
// Generar código de verificación único
|
||||||
@@ -519,16 +506,12 @@ async fn issue_certificate_internal(
|
|||||||
let certificate_hash = format!("{:x}", hasher.finalize());
|
let certificate_hash = format!("{:x}", hasher.finalize());
|
||||||
|
|
||||||
// Obtener progreso final para metadata
|
// Obtener progreso final para metadata
|
||||||
let course_completion = check_course_completion(user_id, course_id, pool).await
|
let course_completion = check_course_completion(user_id, course_id, pool).await?;
|
||||||
.unwrap_or(CourseCompletion {
|
|
||||||
completed: true,
|
|
||||||
progress: 100.0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Insertar certificado en BD
|
// Insertar certificado en BD
|
||||||
let metadata = serde_json::json!({
|
let metadata = serde_json::json!({
|
||||||
"completion_date": now.to_rfc3339(),
|
"completion_date": now.to_rfc3339(),
|
||||||
"final_score": course_completion.progress,
|
"final_score": course_completion.progress_percentage,
|
||||||
"organization_id": organization_id.to_string(),
|
"organization_id": organization_id.to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,42 @@ fn parse_bool(value: &str) -> bool {
|
|||||||
normalized == "1" || normalized == "true" || normalized == "yes"
|
normalized == "1" || normalized == "true" || normalized == "yes"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_learning_base_url() -> String {
|
||||||
|
let domain = env::var("NEXT_PUBLIC_LEARNING_DOMAIN")
|
||||||
|
.or_else(|_| env::var("LEARNING_DOMAIN"))
|
||||||
|
.unwrap_or_else(|_| "localhost:3003".to_string())
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if domain.starts_with("http://") || domain.starts_with("https://") {
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scheme = if domain.contains("localhost") || domain.contains("127.0.0.1") {
|
||||||
|
"http"
|
||||||
|
} else {
|
||||||
|
"https"
|
||||||
|
};
|
||||||
|
|
||||||
|
format!("{}://{}", scheme, domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_discussion_thread_url(course_id: Uuid) -> String {
|
||||||
|
format!("{}/courses/{}#discussions", build_learning_base_url(), course_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_organization_name(pool: &PgPool, organization_id: Uuid) -> String {
|
||||||
|
sqlx::query_scalar::<_, String>("SELECT name FROM organizations WHERE id = $1")
|
||||||
|
.bind(organization_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "OpenCCB".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn load_env_smtp_config() -> Result<SmtpConfig, String> {
|
fn load_env_smtp_config() -> Result<SmtpConfig, String> {
|
||||||
let enabled = env::var("SMTP_ENABLED").map(|v| parse_bool(&v)).unwrap_or(false);
|
let enabled = env::var("SMTP_ENABLED").map(|v| parse_bool(&v)).unwrap_or(false);
|
||||||
let host = env::var("SMTP_HOST").map_err(|_| "SMTP_HOST no está configurado".to_string())?;
|
let host = env::var("SMTP_HOST").map_err(|_| "SMTP_HOST no está configurado".to_string())?;
|
||||||
@@ -429,6 +465,8 @@ pub async fn create_thread(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<CreateThreadPayload>,
|
Json(payload): Json<CreateThreadPayload>,
|
||||||
) -> Result<Json<DiscussionThread>, (StatusCode, String)> {
|
) -> Result<Json<DiscussionThread>, (StatusCode, String)> {
|
||||||
|
let organization_name = load_organization_name(&pool, org_ctx.id).await;
|
||||||
|
let thread_url = build_discussion_thread_url(course_id);
|
||||||
let author_name: Option<String> = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1")
|
let author_name: Option<String> = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1")
|
||||||
.bind(claims.sub)
|
.bind(claims.sub)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
@@ -502,7 +540,7 @@ pub async fn create_thread(
|
|||||||
thread.content.clone()
|
thread.content.clone()
|
||||||
})
|
})
|
||||||
.bind("forum_thread")
|
.bind("forum_thread")
|
||||||
.bind(format!("/courses/{}#discussions", course_id))
|
.bind(thread_url.clone())
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -512,8 +550,8 @@ pub async fn create_thread(
|
|||||||
variables.insert("thread_title", thread.title.clone());
|
variables.insert("thread_title", thread.title.clone());
|
||||||
variables.insert("author_name", author_display.clone());
|
variables.insert("author_name", author_display.clone());
|
||||||
variables.insert("message_content", thread.content.clone());
|
variables.insert("message_content", thread.content.clone());
|
||||||
variables.insert("thread_url", format!("/courses/{}#discussions", course_id));
|
variables.insert("thread_url", thread_url);
|
||||||
variables.insert("organization_name", "OpenCCB".to_string()); // TODO: obtener de org
|
variables.insert("organization_name", organization_name);
|
||||||
|
|
||||||
send_forum_email_notifications(&pool, org_ctx.id, &instructor_recipients, "forum_thread", &variables).await;
|
send_forum_email_notifications(&pool, org_ctx.id, &instructor_recipients, "forum_thread", &variables).await;
|
||||||
|
|
||||||
@@ -687,6 +725,7 @@ pub async fn create_post(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<CreatePostPayload>,
|
Json(payload): Json<CreatePostPayload>,
|
||||||
) -> Result<Json<DiscussionPost>, (StatusCode, String)> {
|
) -> Result<Json<DiscussionPost>, (StatusCode, String)> {
|
||||||
|
let organization_name = load_organization_name(&pool, org_ctx.id).await;
|
||||||
// Verificar si el hilo está bloqueado
|
// Verificar si el hilo está bloqueado
|
||||||
let thread =
|
let thread =
|
||||||
sqlx::query_as::<_, (bool, String, Uuid, Uuid)>(
|
sqlx::query_as::<_, (bool, String, Uuid, Uuid)>(
|
||||||
@@ -702,6 +741,8 @@ pub async fn create_post(
|
|||||||
return Err((StatusCode::FORBIDDEN, "El hilo está bloqueado".to_string()));
|
return Err((StatusCode::FORBIDDEN, "El hilo está bloqueado".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let thread_url = build_discussion_thread_url(thread.2);
|
||||||
|
|
||||||
let author_name: Option<String> = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1")
|
let author_name: Option<String> = sqlx::query_scalar("SELECT full_name FROM users WHERE id = $1")
|
||||||
.bind(claims.sub)
|
.bind(claims.sub)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
@@ -782,7 +823,7 @@ pub async fn create_post(
|
|||||||
post.content.clone()
|
post.content.clone()
|
||||||
})
|
})
|
||||||
.bind("forum_reply")
|
.bind("forum_reply")
|
||||||
.bind(format!("/courses/{}#discussions", thread.2))
|
.bind(thread_url.clone())
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -792,8 +833,8 @@ pub async fn create_post(
|
|||||||
variables.insert("thread_title", thread.1.clone());
|
variables.insert("thread_title", thread.1.clone());
|
||||||
variables.insert("author_name", author_display.clone());
|
variables.insert("author_name", author_display.clone());
|
||||||
variables.insert("message_content", post.content.clone());
|
variables.insert("message_content", post.content.clone());
|
||||||
variables.insert("thread_url", format!("/courses/{}#discussions", thread.2));
|
variables.insert("thread_url", thread_url);
|
||||||
variables.insert("organization_name", "OpenCCB".to_string()); // TODO: obtener de org
|
variables.insert("organization_name", organization_name);
|
||||||
|
|
||||||
send_forum_email_notifications(&pool, org_ctx.id, &recipients, "forum_reply", &variables).await;
|
send_forum_email_notifications(&pool, org_ctx.id, &recipients, "forum_reply", &variables).await;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ mod handlers_peer_review;
|
|||||||
mod handlers_embeddings;
|
mod handlers_embeddings;
|
||||||
mod handlers_faq;
|
mod handlers_faq;
|
||||||
mod handlers_certificates;
|
mod handlers_certificates;
|
||||||
|
mod progress_tracking;
|
||||||
mod lti;
|
mod lti;
|
||||||
mod jwks;
|
mod jwks;
|
||||||
mod predictive;
|
mod predictive;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CourseCompletionMetrics {
|
||||||
|
pub total_lessons: i64,
|
||||||
|
pub completed_lessons: i64,
|
||||||
|
pub progress_percentage: f64,
|
||||||
|
pub completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn calculate_course_completion(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
course_id: Uuid,
|
||||||
|
) -> Result<CourseCompletionMetrics, sqlx::Error> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
COALESCE(totals.total_lessons, 0)::bigint AS total_lessons,
|
||||||
|
LEAST(
|
||||||
|
COALESCE(totals.total_lessons, 0)::bigint,
|
||||||
|
COALESCE(graded.graded_done, 0)::bigint + COALESCE(ungraded.ungraded_done, 0)::bigint
|
||||||
|
) AS completed_lessons
|
||||||
|
FROM (SELECT 1) seed
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*)::bigint AS total_lessons
|
||||||
|
FROM lessons l
|
||||||
|
JOIN modules m ON m.id = l.module_id
|
||||||
|
WHERE m.course_id = $2
|
||||||
|
) totals ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(DISTINCT ug.lesson_id)::bigint AS graded_done
|
||||||
|
FROM user_grades ug
|
||||||
|
JOIN lessons l ON l.id = ug.lesson_id
|
||||||
|
JOIN modules m ON m.id = l.module_id
|
||||||
|
WHERE ug.user_id = $1
|
||||||
|
AND m.course_id = $2
|
||||||
|
AND l.is_graded = true
|
||||||
|
AND (ug.score * 100.0) >= COALESCE(l.passing_percentage::double precision, 60.0)
|
||||||
|
) graded ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(DISTINCT li.lesson_id)::bigint AS ungraded_done
|
||||||
|
FROM lesson_interactions li
|
||||||
|
JOIN lessons l ON l.id = li.lesson_id
|
||||||
|
JOIN modules m ON m.id = l.module_id
|
||||||
|
WHERE li.user_id = $1
|
||||||
|
AND li.event_type = 'complete'
|
||||||
|
AND m.course_id = $2
|
||||||
|
AND l.is_graded = false
|
||||||
|
) ungraded ON TRUE
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(course_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let total_lessons = row.get::<i64, _>("total_lessons");
|
||||||
|
let completed_lessons = row.get::<i64, _>("completed_lessons");
|
||||||
|
let progress_percentage = if total_lessons > 0 {
|
||||||
|
(completed_lessons as f64 / total_lessons as f64) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(CourseCompletionMetrics {
|
||||||
|
total_lessons,
|
||||||
|
completed_lessons,
|
||||||
|
progress_percentage,
|
||||||
|
completed: total_lessons > 0 && completed_lessons >= total_lessons,
|
||||||
|
})
|
||||||
|
}
|
||||||
Generated
+122
-2
@@ -18,7 +18,8 @@
|
|||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"tailwind-merge": "^2.3.0"
|
"tailwind-merge": "^2.3.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
@@ -939,6 +940,7 @@
|
|||||||
"version": "18.3.27",
|
"version": "18.3.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1013,6 +1015,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
|
||||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.50.0",
|
"@typescript-eslint/scope-manager": "8.50.0",
|
||||||
"@typescript-eslint/types": "8.50.0",
|
"@typescript-eslint/types": "8.50.0",
|
||||||
@@ -1496,6 +1499,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1512,6 +1516,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@@ -1947,6 +1960,19 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -2008,6 +2034,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz",
|
||||||
"integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==",
|
"integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chevrotain/cst-dts-gen": "11.1.2",
|
"@chevrotain/cst-dts-gen": "11.1.2",
|
||||||
"@chevrotain/gast": "11.1.2",
|
"@chevrotain/gast": "11.1.2",
|
||||||
@@ -2078,6 +2105,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -2136,6 +2172,18 @@
|
|||||||
"layout-base": "^1.0.0"
|
"layout-base": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2180,6 +2228,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
|
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
|
||||||
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
|
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
@@ -2589,6 +2638,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@@ -3104,6 +3154,7 @@
|
|||||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -3266,6 +3317,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -3684,6 +3736,15 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/framer-motion": {
|
"node_modules/framer-motion": {
|
||||||
"version": "11.18.2",
|
"version": "11.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
||||||
@@ -4704,6 +4765,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -6196,6 +6258,7 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -6348,6 +6411,7 @@
|
|||||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -6492,6 +6556,7 @@
|
|||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -6503,6 +6568,7 @@
|
|||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -6589,7 +6655,8 @@
|
|||||||
"node_modules/redux": {
|
"node_modules/redux": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
@@ -7045,6 +7112,18 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stable-hash": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -7521,6 +7600,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -7707,6 +7787,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -8079,6 +8160,24 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -8188,6 +8287,27 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"tailwind-merge": "^2.3.0"
|
"tailwind-merge": "^2.3.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Upload, Database, FileArchive, CheckCircle2, AlertTriangle, Scissors }
|
|||||||
|
|
||||||
export default function AdminSharedMaterialsPage() {
|
export default function AdminSharedMaterialsPage() {
|
||||||
const [zipFile, setZipFile] = useState<File | null>(null);
|
const [zipFile, setZipFile] = useState<File | null>(null);
|
||||||
const [ingestRag, setIngestRag] = useState(false);
|
const [ingestRag, setIngestRag] = useState(true);
|
||||||
const [englishLevel, setEnglishLevel] = useState('');
|
const [englishLevel, setEnglishLevel] = useState('');
|
||||||
const [plans, setPlans] = useState<MySqlPlan[]>([]);
|
const [plans, setPlans] = useState<MySqlPlan[]>([]);
|
||||||
const [courses, setCourses] = useState<MySqlCourse[]>([]);
|
const [courses, setCourses] = useState<MySqlCourse[]>([]);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { cmsApi, Course } from "@/lib/api";
|
import { cmsApi, Course, Organization, getImageUrl } from "@/lib/api";
|
||||||
import { Save, Settings as SettingsIcon, BookOpen, Calendar, Clock, Download, Upload, Copy, Wand2 } from "lucide-react";
|
import { Save, Settings as SettingsIcon, BookOpen, Calendar, Clock, Download, Upload, Copy, Wand2 } from "lucide-react";
|
||||||
|
|
||||||
const DEFAULT_CERTIFICATE_TEMPLATE = `
|
const DEFAULT_CERTIFICATE_TEMPLATE = `
|
||||||
@@ -55,12 +55,54 @@ const MINIMAL_CERTIFICATE_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const BRANDED_CERTIFICATE_TEMPLATE = `
|
||||||
|
<div style="width: 900px; height: 620px; padding: 24px; box-sizing: border-box; font-family: 'Inter', 'Segoe UI', sans-serif; background: linear-gradient(145deg, {{primary_color}}, {{secondary_color}}); color: #0f172a;">
|
||||||
|
<div style="height: 100%; border-radius: 28px; background: rgba(255,255,255,0.94); padding: 48px 56px; box-sizing: border-box; display: flex; flex-direction: column; justify-content: space-between; box-shadow: 0 24px 80px rgba(15, 23, 42, 0.18);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 24px;">
|
||||||
|
<div>
|
||||||
|
<p style="margin: 0 0 10px 0; font-size: 12px; letter-spacing: 0.24em; text-transform: uppercase; color: {{secondary_color}};">{{platform_name}}</p>
|
||||||
|
<h1 style="margin: 0; font-size: 50px; line-height: 1; color: #0f172a;">Premium Certificate</h1>
|
||||||
|
<p style="margin: 12px 0 0 0; color: #475569; font-size: 16px;">Issued by {{organization_name}}</p>
|
||||||
|
</div>
|
||||||
|
<div style="min-width: 96px; min-height: 96px; border-radius: 24px; background: #fff; border: 1px solid rgba(15, 23, 42, 0.08); display: flex; align-items: center; justify-content: center; padding: 12px; box-sizing: border-box;">
|
||||||
|
<img src="{{logo_url}}" alt="{{organization_name}}" style="max-width: 100%; max-height: 72px; object-fit: contain;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p style="margin: 0 0 14px 0; text-transform: uppercase; letter-spacing: 0.22em; font-size: 12px; color: #64748b;">Awarded to</p>
|
||||||
|
<p style="margin: 0 0 18px 0; font-size: 42px; font-weight: 800; color: #0f172a;">{{student_name}}</p>
|
||||||
|
<p style="margin: 0 0 10px 0; font-size: 16px; color: #475569;">for successfully completing</p>
|
||||||
|
<p style="margin: 0; font-size: 30px; font-weight: 700; color: {{primary_color}};">{{course_title}}</p>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; gap: 32px; align-items: flex-end;">
|
||||||
|
<div>
|
||||||
|
<p style="margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.18em; color: #64748b;">Completion</p>
|
||||||
|
<p style="margin: 0; font-size: 18px; font-weight: 700; color: #0f172a;">{{date}}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p style="margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.18em; color: #64748b;">Final Result</p>
|
||||||
|
<p style="margin: 0; font-size: 18px; font-weight: 700; color: #0f172a;">{{score}}</p>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<p style="margin: 0 0 8px 0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.18em; color: #64748b;">Verification</p>
|
||||||
|
<p style="margin: 0; font-size: 14px; font-weight: 700; color: #0f172a;">{{verification_code}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
const TEMPLATE_VARIABLES = [
|
const TEMPLATE_VARIABLES = [
|
||||||
"{{student_name}}",
|
{ token: "{{student_name}}", label: "Estudiante", description: "Nombre completo del estudiante." },
|
||||||
"{{course_title}}",
|
{ token: "{{course_title}}", label: "Curso", description: "Título del curso completado." },
|
||||||
"{{date}}",
|
{ token: "{{date}}", label: "Fecha", description: "Fecha de emisión del certificado." },
|
||||||
"{{score}}",
|
{ token: "{{score}}", label: "Resultado", description: "Resultado o puntaje final." },
|
||||||
"{{verification_code}}",
|
{ token: "{{verification_code}}", label: "Verificación", description: "Código público de verificación." },
|
||||||
|
{ token: "{{organization_name}}", label: "Organización", description: "Nombre legal de la organización." },
|
||||||
|
{ token: "{{platform_name}}", label: "Plataforma", description: "Nombre comercial de la plataforma." },
|
||||||
|
{ token: "{{primary_color}}", label: "Color primario", description: "Color primario de branding." },
|
||||||
|
{ token: "{{secondary_color}}", label: "Color secundario", description: "Color secundario de branding." },
|
||||||
|
{ token: "{{logo_url}}", label: "Logo", description: "URL pública del logo organizacional." },
|
||||||
];
|
];
|
||||||
|
|
||||||
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
||||||
@@ -71,6 +113,7 @@ export default function CourseSettingsPage() {
|
|||||||
const { id } = useParams() as { id: string };
|
const { id } = useParams() as { id: string };
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [course, setCourse] = useState<Course | null>(null);
|
const [course, setCourse] = useState<Course | null>(null);
|
||||||
|
const [organization, setOrganization] = useState<Organization | null>(null);
|
||||||
const [passingPercentage, setPassingPercentage] = useState(70);
|
const [passingPercentage, setPassingPercentage] = useState(70);
|
||||||
const [certificateTemplate, setCertificateTemplate] = useState("");
|
const [certificateTemplate, setCertificateTemplate] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -83,7 +126,7 @@ export default function CourseSettingsPage() {
|
|||||||
const [price, setPrice] = useState(0);
|
const [price, setPrice] = useState(0);
|
||||||
const [currency, setCurrency] = useState("USD");
|
const [currency, setCurrency] = useState("USD");
|
||||||
const [previewStudentName, setPreviewStudentName] = useState("Jane Doe");
|
const [previewStudentName, setPreviewStudentName] = useState("Jane Doe");
|
||||||
const [previewScore, setPreviewScore] = useState("95");
|
const [previewScore, setPreviewScore] = useState("95%");
|
||||||
const [templateWarning, setTemplateWarning] = useState<string | null>(null);
|
const [templateWarning, setTemplateWarning] = useState<string | null>(null);
|
||||||
|
|
||||||
const buildPreviewCertificate = () => {
|
const buildPreviewCertificate = () => {
|
||||||
@@ -91,11 +134,16 @@ export default function CourseSettingsPage() {
|
|||||||
.replace(/{{student_name}}/g, previewStudentName || "Jane Doe")
|
.replace(/{{student_name}}/g, previewStudentName || "Jane Doe")
|
||||||
.replace(/{{course_title}}/g, course?.title || "Demo Course")
|
.replace(/{{course_title}}/g, course?.title || "Demo Course")
|
||||||
.replace(/{{date}}/g, new Date().toLocaleDateString())
|
.replace(/{{date}}/g, new Date().toLocaleDateString())
|
||||||
.replace(/{{score}}/g, previewScore || "95")
|
.replace(/{{score}}/g, previewScore || "95%")
|
||||||
.replace(/{{verification_code}}/g, "OPENCCB-VERIFY-2026");
|
.replace(/{{verification_code}}/g, "OPENCCB-VERIFY-2026")
|
||||||
|
.replace(/{{organization_name}}/g, organization?.name || "OpenCCB")
|
||||||
|
.replace(/{{platform_name}}/g, organization?.platform_name || organization?.name || "OpenCCB")
|
||||||
|
.replace(/{{primary_color}}/g, organization?.primary_color || "#2563eb")
|
||||||
|
.replace(/{{secondary_color}}/g, organization?.secondary_color || "#7c3aed")
|
||||||
|
.replace(/{{logo_url}}/g, organization?.logo_url ? getImageUrl(organization.logo_url) : "https://placehold.co/240x96?text=Logo");
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyTemplatePreset = (preset: "default" | "modern" | "minimal") => {
|
const applyTemplatePreset = (preset: "default" | "modern" | "minimal" | "branded") => {
|
||||||
if (preset === "modern") {
|
if (preset === "modern") {
|
||||||
setCertificateTemplate(MODERN_CERTIFICATE_TEMPLATE);
|
setCertificateTemplate(MODERN_CERTIFICATE_TEMPLATE);
|
||||||
return;
|
return;
|
||||||
@@ -104,6 +152,10 @@ export default function CourseSettingsPage() {
|
|||||||
setCertificateTemplate(MINIMAL_CERTIFICATE_TEMPLATE);
|
setCertificateTemplate(MINIMAL_CERTIFICATE_TEMPLATE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (preset === "branded") {
|
||||||
|
setCertificateTemplate(BRANDED_CERTIFICATE_TEMPLATE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCertificateTemplate(DEFAULT_CERTIFICATE_TEMPLATE);
|
setCertificateTemplate(DEFAULT_CERTIFICATE_TEMPLATE);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,14 +177,22 @@ export default function CourseSettingsPage() {
|
|||||||
setTemplateWarning(`Faltan variables clave: ${missingCore.join(", ")}`);
|
setTemplateWarning(`Faltan variables clave: ${missingCore.join(", ")}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (certificateTemplate.includes("{{logo_url}}") && !organization?.logo_url) {
|
||||||
|
setTemplateWarning("La plantilla usa {{logo_url}}, pero la organización no tiene logo configurado.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setTemplateWarning(null);
|
setTemplateWarning(null);
|
||||||
}, [certificateTemplate]);
|
}, [certificateTemplate, organization?.logo_url]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCourse = async () => {
|
const fetchCourse = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await cmsApi.getCourse(id);
|
const [data, orgData] = await Promise.all([
|
||||||
|
cmsApi.getCourse(id),
|
||||||
|
cmsApi.getOrganization(),
|
||||||
|
]);
|
||||||
setCourse(data);
|
setCourse(data);
|
||||||
|
setOrganization(orgData);
|
||||||
setPassingPercentage(data.passing_percentage || 70);
|
setPassingPercentage(data.passing_percentage || 70);
|
||||||
setCertificateTemplate(data.certificate_template || DEFAULT_CERTIFICATE_TEMPLATE);
|
setCertificateTemplate(data.certificate_template || DEFAULT_CERTIFICATE_TEMPLATE);
|
||||||
setPacingMode(data.pacing_mode || "self_paced");
|
setPacingMode(data.pacing_mode || "self_paced");
|
||||||
@@ -451,19 +511,25 @@ export default function CourseSettingsPage() {
|
|||||||
>
|
>
|
||||||
Minimal
|
Minimal
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => applyTemplatePreset("branded")}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-[10px] font-black uppercase tracking-widest hover:border-blue-500/40 transition-colors"
|
||||||
|
>
|
||||||
|
Premium
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-xs font-black text-slate-500 dark:text-gray-400 uppercase tracking-[0.2em]">Variables</label>
|
<label className="block text-xs font-black text-slate-500 dark:text-gray-400 uppercase tracking-[0.2em]">Variables</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{TEMPLATE_VARIABLES.map((token) => (
|
{TEMPLATE_VARIABLES.map(({ token, label, description }) => (
|
||||||
<div key={token} className="flex items-center gap-1 bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-lg px-2 py-1">
|
<div key={token} className="flex items-center gap-1 bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-lg px-2 py-1" title={description}>
|
||||||
<button
|
<button
|
||||||
onClick={() => insertVariable(token)}
|
onClick={() => insertVariable(token)}
|
||||||
className="text-[10px] font-black text-blue-600 dark:text-blue-400 hover:text-blue-700"
|
className="text-[10px] font-black text-blue-600 dark:text-blue-400 hover:text-blue-700"
|
||||||
>
|
>
|
||||||
{token}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => copyVariable(token)}
|
onClick={() => copyVariable(token)}
|
||||||
@@ -484,6 +550,33 @@ export default function CourseSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{organization && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
|
<div className="rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/5 p-4 shadow-sm">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Organización</p>
|
||||||
|
<p className="mt-2 text-sm font-bold text-slate-800 dark:text-white">{organization.name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/5 p-4 shadow-sm">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Plataforma</p>
|
||||||
|
<p className="mt-2 text-sm font-bold text-slate-800 dark:text-white">{organization.platform_name || organization.name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/5 p-4 shadow-sm">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Color primario</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<span className="h-4 w-4 rounded-full border border-slate-200" style={{ backgroundColor: organization.primary_color || "#2563eb" }} />
|
||||||
|
<p className="text-sm font-bold text-slate-800 dark:text-white">{organization.primary_color || "#2563eb"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/5 p-4 shadow-sm">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Color secundario</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<span className="h-4 w-4 rounded-full border border-slate-200" style={{ backgroundColor: organization.secondary_color || "#7c3aed" }} />
|
||||||
|
<p className="text-sm font-bold text-slate-800 dark:text-white">{organization.secondary_color || "#7c3aed"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-black text-slate-700 dark:text-gray-300 uppercase tracking-wider">HTML Template</label>
|
<label className="block text-sm font-black text-slate-700 dark:text-gray-300 uppercase tracking-wider">HTML Template</label>
|
||||||
@@ -517,6 +610,9 @@ export default function CourseSettingsPage() {
|
|||||||
placeholder="Score"
|
placeholder="Score"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-500 dark:text-gray-400 font-medium">
|
||||||
|
La previsualización usa el branding actual de la organización y resuelve el logo con la misma lógica pública usada por la plataforma.
|
||||||
|
</p>
|
||||||
<div className="w-full h-[400px] bg-white rounded-xl overflow-hidden relative group border border-slate-200 shadow-sm">
|
<div className="w-full h-[400px] bg-white rounded-xl overflow-hidden relative group border border-slate-200 shadow-sm">
|
||||||
<iframe
|
<iframe
|
||||||
srcDoc={buildPreviewCertificate()}
|
srcDoc={buildPreviewCertificate()}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { questionBankApi } from '@/lib/api';
|
import * as XLSX from 'xlsx';
|
||||||
|
import { CreateQuestionBankPayload, QuestionBankType, questionBankApi } from '@/lib/api';
|
||||||
import { X, Upload, FileSpreadsheet, Check, AlertCircle, Download } from 'lucide-react';
|
import { X, Upload, FileSpreadsheet, Check, AlertCircle, Download } from 'lucide-react';
|
||||||
|
|
||||||
interface ExcelImportModalProps {
|
interface ExcelImportModalProps {
|
||||||
@@ -12,9 +13,134 @@ interface ExcelImportModalProps {
|
|||||||
export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportModalProps) {
|
export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportModalProps) {
|
||||||
const [excelFile, setExcelFile] = useState<File | null>(null);
|
const [excelFile, setExcelFile] = useState<File | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [result, setResult] = useState<any>(null);
|
const [result, setResult] = useState<{ imported: number; skipped: number; error?: string } | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const toQuestionType = (value: string): QuestionBankType | null => {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
const mapping: Record<string, QuestionBankType> = {
|
||||||
|
'multiple-choice': 'multiple-choice',
|
||||||
|
'multiple choice': 'multiple-choice',
|
||||||
|
'mcq': 'multiple-choice',
|
||||||
|
'true-false': 'true-false',
|
||||||
|
'true false': 'true-false',
|
||||||
|
'boolean': 'true-false',
|
||||||
|
'short-answer': 'short-answer',
|
||||||
|
'short answer': 'short-answer',
|
||||||
|
'essay': 'essay',
|
||||||
|
'matching': 'matching',
|
||||||
|
'ordering': 'ordering',
|
||||||
|
'fill-in-the-blanks': 'fill-in-the-blanks',
|
||||||
|
'fill in the blanks': 'fill-in-the-blanks',
|
||||||
|
'audio-response': 'audio-response',
|
||||||
|
'audio response': 'audio-response',
|
||||||
|
'hotspot': 'hotspot',
|
||||||
|
'code-lab': 'code-lab',
|
||||||
|
'code lab': 'code-lab',
|
||||||
|
};
|
||||||
|
return mapping[normalized] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseUnknownJson = (value: string): unknown => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/^-?\d+$/.test(trimmed)) {
|
||||||
|
return Number.parseInt(trimmed, 10);
|
||||||
|
}
|
||||||
|
if (/^-?\d+\.\d+$/.test(trimmed)) {
|
||||||
|
return Number.parseFloat(trimmed);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseOptions = (raw: string): unknown => {
|
||||||
|
const parsed = parseUnknownJson(raw);
|
||||||
|
if (Array.isArray(parsed)) return parsed;
|
||||||
|
if (typeof parsed === 'string') {
|
||||||
|
const pieces = parsed
|
||||||
|
.split(',')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return pieces.length > 0 ? pieces : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseExcelRows = async (file: File): Promise<CreateQuestionBankPayload[]> => {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const workbook = XLSX.read(buffer, { type: 'array' });
|
||||||
|
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||||
|
if (!firstSheet) return [];
|
||||||
|
|
||||||
|
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(firstSheet, {
|
||||||
|
defval: '',
|
||||||
|
raw: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((row) => {
|
||||||
|
const getField = (name: string): string => {
|
||||||
|
const direct = row[name];
|
||||||
|
if (direct !== undefined) return String(direct).trim();
|
||||||
|
const lowerName = name.toLowerCase();
|
||||||
|
const foundKey = Object.keys(row).find((k) => k.trim().toLowerCase() === lowerName);
|
||||||
|
return foundKey ? String(row[foundKey]).trim() : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const questionText = getField('question_text');
|
||||||
|
const questionTypeRaw = getField('question_type');
|
||||||
|
const questionType = toQuestionType(questionTypeRaw);
|
||||||
|
if (!questionText || !questionType) return null;
|
||||||
|
|
||||||
|
const optionsRaw = getField('options');
|
||||||
|
const correctAnswerRaw = getField('correct_answer');
|
||||||
|
const explanation = getField('explanation') || undefined;
|
||||||
|
const difficultyRaw = getField('difficulty').toLowerCase();
|
||||||
|
const difficulty = ['easy', 'medium', 'hard'].includes(difficultyRaw) ? difficultyRaw : 'medium';
|
||||||
|
const tagsRaw = getField('tags');
|
||||||
|
const pointsRaw = getField('points');
|
||||||
|
|
||||||
|
let options = parseOptions(optionsRaw);
|
||||||
|
let correctAnswer = parseUnknownJson(correctAnswerRaw);
|
||||||
|
|
||||||
|
if (questionType === 'true-false') {
|
||||||
|
options = ['Verdadero', 'Falso'];
|
||||||
|
if (typeof correctAnswer === 'string') {
|
||||||
|
const lower = correctAnswer.toLowerCase();
|
||||||
|
correctAnswer = lower === 'verdadero' || lower === 'true' ? 0 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = tagsRaw
|
||||||
|
? tagsRaw
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const pointsNum = Number.parseInt(pointsRaw, 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
question_text: questionText,
|
||||||
|
question_type: questionType,
|
||||||
|
options,
|
||||||
|
correct_answer: correctAnswer,
|
||||||
|
explanation,
|
||||||
|
difficulty,
|
||||||
|
tags,
|
||||||
|
points: Number.isFinite(pointsNum) && pointsNum > 0 ? pointsNum : 1,
|
||||||
|
} as CreateQuestionBankPayload;
|
||||||
|
})
|
||||||
|
.filter((item): item is CreateQuestionBankPayload => item !== null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
@@ -37,39 +163,61 @@ export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportMod
|
|||||||
setUploading(true);
|
setUploading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const formData = new FormData();
|
const payloads = await parseExcelRows(excelFile);
|
||||||
formData.append('file', excelFile);
|
if (payloads.length === 0) {
|
||||||
|
throw new Error('No se encontraron filas válidas en el archivo. Verifica columnas y tipos.');
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/question-bank/import-excel`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Error al importar');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
let imported = 0;
|
||||||
setResult(data);
|
let skipped = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const payload of payloads) {
|
||||||
|
try {
|
||||||
|
await questionBankApi.create(payload);
|
||||||
|
imported += 1;
|
||||||
|
} catch (e) {
|
||||||
|
skipped += 1;
|
||||||
|
if (errors.length < 5) {
|
||||||
|
errors.push((e as Error).message || 'Error desconocido al crear pregunta');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult({
|
||||||
|
imported,
|
||||||
|
skipped,
|
||||||
|
error: errors.length > 0 ? errors.join(' | ') : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error('Excel import failed:', err);
|
console.error('Excel import failed:', err);
|
||||||
setError(err.message || 'Error al importar');
|
setError((err as Error)?.message || 'Error al importar');
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadTemplate = () => {
|
const downloadTemplate = () => {
|
||||||
// Create a simple template explanation
|
const csv = [
|
||||||
alert('Descargando plantilla...\n\nColumnas requeridas:\n1. question_text - Texto de la pregunta\n2. question_type - multiple-choice, true-false, etc.\n3. options - ["A","B","C","D"]\n4. correct_answer - 0, 1, 2, o 3\n5. explanation - Explicación (opcional)\n6. difficulty - easy, medium, hard\n7. tags - tag1,tag2,tag3');
|
'question_text,question_type,options,correct_answer,explanation,difficulty,tags,points',
|
||||||
|
'What color is the sky?,multiple-choice,"[""Blue"",""Green"",""Red"",""Yellow""]",0,"The sky appears blue due to Rayleigh scattering",easy,"science,colors",1',
|
||||||
|
'The sun rises in the east.,true-false,"[""Verdadero"",""Falso""]",0,"The sun always rises in the east",easy,"geography",1',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'question_bank_template.csv';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user