feat: Mejorar la gestión de plantillas de prueba y agregar validaciones para la composición de preguntas
This commit is contained in:
@@ -4,6 +4,7 @@ use axum::{
|
|||||||
http::{StatusCode, HeaderMap},
|
http::{StatusCode, HeaderMap},
|
||||||
};
|
};
|
||||||
use common::models::Course;
|
use common::models::Course;
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -26,16 +27,19 @@ async fn validate_api_key(headers: &HeaderMap, pool: &PgPool) -> Result<Uuid, St
|
|||||||
pub async fn create_course_external(
|
pub async fn create_course_external(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(payload): Json<serde_json::Value>,
|
Json(payload): Json<ExternalCreateCoursePayload>,
|
||||||
) -> Result<Json<Course>, StatusCode> {
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
let org_id = validate_api_key(&headers, &pool).await?;
|
let org_id = validate_api_key(&headers, &pool).await?;
|
||||||
|
|
||||||
// We reuse the internal logic but with the org_id from the API key
|
// We reuse the internal logic but with the org_id from the API key
|
||||||
// We need to provide a mock claims for handlers::create_course or refactor it.
|
// We need to provide a mock claims for handlers::create_course or refactor it.
|
||||||
// Simplifying for now: direct DB call or calling handlers with constructed context.
|
// Simplifying for now: direct DB call or calling handlers with constructed context.
|
||||||
|
|
||||||
let title = payload.get("title").and_then(|t| t.as_str()).ok_or(StatusCode::BAD_REQUEST)?;
|
let title = payload.title.trim();
|
||||||
let description = payload.get("description").and_then(|d| d.as_str());
|
if title.is_empty() {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
let description = payload.description.as_deref();
|
||||||
|
|
||||||
let course = sqlx::query_as::<_, Course>(
|
let course = sqlx::query_as::<_, Course>(
|
||||||
"INSERT INTO courses (organization_id, title, description, instructor_id, pacing_mode)
|
"INSERT INTO courses (organization_id, title, description, instructor_id, pacing_mode)
|
||||||
@@ -44,7 +48,7 @@ pub async fn create_course_external(
|
|||||||
.bind(org_id)
|
.bind(org_id)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(description)
|
.bind(description)
|
||||||
.bind(payload.get("pacing_mode").and_then(|p| p.as_str()).unwrap_or("self_paced"))
|
.bind(payload.pacing_mode.as_deref().unwrap_or("self_paced"))
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -52,7 +56,230 @@ pub async fn create_course_external(
|
|||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Json(course))
|
let selected_template_id = resolve_template_id(
|
||||||
|
&pool,
|
||||||
|
org_id,
|
||||||
|
payload.template_id,
|
||||||
|
payload.template_level.as_deref(),
|
||||||
|
payload.template_course_type.as_deref(),
|
||||||
|
payload.template_test_type.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut lesson_id: Option<Uuid> = None;
|
||||||
|
if let Some(template_id) = selected_template_id {
|
||||||
|
lesson_id = Some(
|
||||||
|
create_course_lesson_and_apply_template(
|
||||||
|
&pool,
|
||||||
|
org_id,
|
||||||
|
course.id,
|
||||||
|
template_id,
|
||||||
|
payload.module_title.as_deref().unwrap_or("Evaluaciones"),
|
||||||
|
payload.lesson_title.as_deref().unwrap_or("Evaluación inicial"),
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"course": course,
|
||||||
|
"template_applied": selected_template_id.is_some(),
|
||||||
|
"template_id": selected_template_id,
|
||||||
|
"lesson_id": lesson_id,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ExternalCreateCoursePayload {
|
||||||
|
pub title: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub pacing_mode: Option<String>,
|
||||||
|
// Optional direct template selection
|
||||||
|
pub template_id: Option<Uuid>,
|
||||||
|
// Optional fallback selection by level/course type/test type
|
||||||
|
pub template_level: Option<String>,
|
||||||
|
pub template_course_type: Option<String>,
|
||||||
|
pub template_test_type: Option<String>,
|
||||||
|
pub module_title: Option<String>,
|
||||||
|
pub lesson_title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_template_id(
|
||||||
|
pool: &PgPool,
|
||||||
|
org_id: Uuid,
|
||||||
|
direct_template_id: Option<Uuid>,
|
||||||
|
level: Option<&str>,
|
||||||
|
course_type: Option<&str>,
|
||||||
|
test_type: Option<&str>,
|
||||||
|
) -> Result<Option<Uuid>, StatusCode> {
|
||||||
|
if let Some(template_id) = direct_template_id {
|
||||||
|
let exists: Option<Uuid> = sqlx::query_scalar(
|
||||||
|
"SELECT id FROM test_templates WHERE id = $1 AND organization_id = $2 AND is_active = true"
|
||||||
|
)
|
||||||
|
.bind(template_id)
|
||||||
|
.bind(org_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if exists.is_none() {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
return Ok(Some(template_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if level.is_none() && course_type.is_none() && test_type.is_none() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected: Option<Uuid> = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT id
|
||||||
|
FROM test_templates
|
||||||
|
WHERE organization_id = $1
|
||||||
|
AND is_active = true
|
||||||
|
AND ($2::course_level IS NULL OR level = $2::course_level)
|
||||||
|
AND ($3::course_type IS NULL OR course_type = $3::course_type)
|
||||||
|
AND ($4::test_type IS NULL OR test_type = $4::test_type)
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(org_id)
|
||||||
|
.bind(level)
|
||||||
|
.bind(course_type)
|
||||||
|
.bind(test_type)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_course_lesson_and_apply_template(
|
||||||
|
pool: &PgPool,
|
||||||
|
org_id: Uuid,
|
||||||
|
course_id: Uuid,
|
||||||
|
template_id: Uuid,
|
||||||
|
module_title: &str,
|
||||||
|
lesson_title: &str,
|
||||||
|
) -> Result<Uuid, StatusCode> {
|
||||||
|
let module_id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
INSERT INTO modules (course_id, organization_id, title, position)
|
||||||
|
VALUES ($1, $2, $3, 1)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(org_id)
|
||||||
|
.bind(module_title)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let lesson_id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
INSERT INTO lessons (module_id, organization_id, title, content_type, position, is_graded, max_attempts, allow_retry)
|
||||||
|
VALUES ($1, $2, $3, 'quiz', 1, true, 1, false)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(module_id)
|
||||||
|
.bind(org_id)
|
||||||
|
.bind(lesson_title)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let template: (Uuid, String, String, i32, i32, i32, Option<String>) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT id, name, test_type::text, duration_minutes, passing_score, total_points, instructions
|
||||||
|
FROM test_templates
|
||||||
|
WHERE id = $1 AND organization_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(template_id)
|
||||||
|
.bind(org_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
|
let template_questions: Vec<(Uuid, String, String, Option<serde_json::Value>, Option<serde_json::Value>, Option<String>, i32)> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT id, question_type, question_text, options, correct_answer, explanation, points
|
||||||
|
FROM test_template_questions
|
||||||
|
WHERE template_id = $1
|
||||||
|
ORDER BY section_id, question_order
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(template_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if template.2 == "CA" && template_questions.len() < 4 {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if template.2 != "CA" && template_questions.len() != 1 {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
let questions_json: Vec<serde_json::Value> = template_questions
|
||||||
|
.iter()
|
||||||
|
.map(|q| {
|
||||||
|
json!({
|
||||||
|
"id": q.0.to_string(),
|
||||||
|
"type": q.1,
|
||||||
|
"question": q.2,
|
||||||
|
"options": q.3,
|
||||||
|
"correct": q.4,
|
||||||
|
"explanation": q.5,
|
||||||
|
"points": q.6,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let quiz_data = json!({
|
||||||
|
"questions": questions_json,
|
||||||
|
"template_id": template.0.to_string(),
|
||||||
|
"template_name": template.1,
|
||||||
|
"test_type": template.2,
|
||||||
|
"duration_minutes": template.3,
|
||||||
|
"passing_score": template.4,
|
||||||
|
"total_points": template.5,
|
||||||
|
"instructions": template.6,
|
||||||
|
"max_attempts": 1,
|
||||||
|
"show_feedback": true,
|
||||||
|
"permanent_history": true,
|
||||||
|
});
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE lessons
|
||||||
|
SET content_type = 'quiz',
|
||||||
|
metadata = $1,
|
||||||
|
is_graded = true,
|
||||||
|
max_attempts = 1,
|
||||||
|
allow_retry = false,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2 AND organization_id = $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&quiz_data)
|
||||||
|
.bind(lesson_id)
|
||||||
|
.bind(org_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let _ = sqlx::query("SELECT increment_template_usage($1)")
|
||||||
|
.bind(template_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(lesson_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_course_external(
|
pub async fn get_course_external(
|
||||||
|
|||||||
@@ -555,6 +555,21 @@ pub async fn apply_template_to_lesson(
|
|||||||
return Err((StatusCode::BAD_REQUEST, "Template has no questions".to_string()));
|
return Err((StatusCode::BAD_REQUEST, "Template has no questions".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Business rules for template composition.
|
||||||
|
if matches!(template.test_type, TestType::CA) && template_questions.len() < 4 {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Las plantillas CA deben tener minimo 4 preguntas".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matches!(template.test_type, TestType::CA) && template_questions.len() != 1 {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Las plantillas MWT, MOT, FOT y FWT deben tener exactamente 1 pregunta".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Build quiz_data JSON from template questions
|
// Build quiz_data JSON from template questions
|
||||||
let questions_json: Vec<serde_json::Value> = template_questions
|
let questions_json: Vec<serde_json::Value> = template_questions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1452,17 +1467,29 @@ pub async fn generate_questions_with_rag(
|
|||||||
(shuffled_options, new_correct_idx)
|
(shuffled_options, new_correct_idx)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert to TestTemplateQuestion format
|
// Convert to TestTemplateQuestion format and skip invalid LLM entries
|
||||||
let generated_questions: Vec<TestTemplateQuestion> = questions_data
|
let generated_questions: Vec<TestTemplateQuestion> = questions_data
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(idx, q)| {
|
.filter_map(|(idx, q)| {
|
||||||
let question_type_value = q
|
let question_type_value = q
|
||||||
.get("question_type")
|
.get("question_type")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or(&requested_question_type)
|
.unwrap_or(&requested_question_type)
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
|
let question_text = q
|
||||||
|
.get("question_text")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if question_text.is_empty() || question_text.eq_ignore_ascii_case("question") {
|
||||||
|
tracing::warn!("Skipping invalid generated question with empty placeholder text: {:?}", q);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
// Get original options and correct answer
|
// Get original options and correct answer
|
||||||
let original_options: Vec<String> = q
|
let original_options: Vec<String> = q
|
||||||
.get("options")
|
.get("options")
|
||||||
@@ -1483,6 +1510,10 @@ pub async fn generate_questions_with_rag(
|
|||||||
|
|
||||||
let (options, correct_answer, options_shuffled) = match question_type_value.as_str() {
|
let (options, correct_answer, options_shuffled) = match question_type_value.as_str() {
|
||||||
"multiple-choice" => {
|
"multiple-choice" => {
|
||||||
|
if original_options.len() < 2 {
|
||||||
|
tracing::warn!("Skipping invalid multiple-choice question without enough options: {:?}", q);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if !original_options.is_empty() && original_correct_idx.is_some() {
|
if !original_options.is_empty() && original_correct_idx.is_some() {
|
||||||
let correct_idx = original_correct_idx.unwrap();
|
let correct_idx = original_correct_idx.unwrap();
|
||||||
if correct_idx < original_options.len() {
|
if correct_idx < original_options.len() {
|
||||||
@@ -1510,10 +1541,23 @@ pub async fn generate_questions_with_rag(
|
|||||||
.or(q.get("correct"))
|
.or(q.get("correct"))
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.map(|v| if v { json!(0) } else { json!(1) });
|
.map(|v| if v { json!(0) } else { json!(1) });
|
||||||
|
if bool_answer.is_none() {
|
||||||
|
tracing::warn!("Skipping invalid true-false question without boolean correct answer: {:?}", q);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
(Some(json!(["True", "False"])), bool_answer, false)
|
(Some(json!(["True", "False"])), bool_answer, false)
|
||||||
}
|
}
|
||||||
"matching" => {
|
"matching" => {
|
||||||
let pairs = q.get("pairs").cloned().or_else(|| q.get("options").cloned());
|
let pairs = q.get("pairs").cloned().or_else(|| q.get("options").cloned());
|
||||||
|
let is_valid = pairs
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| !arr.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !is_valid {
|
||||||
|
tracing::warn!("Skipping invalid matching question without pairs: {:?}", q);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
(pairs.clone(), pairs, false)
|
(pairs.clone(), pairs, false)
|
||||||
}
|
}
|
||||||
"ordering" => {
|
"ordering" => {
|
||||||
@@ -1523,10 +1567,33 @@ pub async fn generate_questions_with_rag(
|
|||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| q.get("correct_answer").cloned())
|
.or_else(|| q.get("correct_answer").cloned())
|
||||||
.or_else(|| q.get("correct").cloned());
|
.or_else(|| q.get("correct").cloned());
|
||||||
|
let has_items = items
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| arr.len() >= 2)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let has_order = order
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| !arr.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !has_items || !has_order {
|
||||||
|
tracing::warn!("Skipping invalid ordering question without items/order: {:?}", q);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
(items, order, false)
|
(items, order, false)
|
||||||
}
|
}
|
||||||
"fill-in-the-blanks" => {
|
"fill-in-the-blanks" => {
|
||||||
let blanks = q.get("blanks").cloned();
|
let blanks = q.get("blanks").cloned();
|
||||||
|
let has_blanks = blanks
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| !arr.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !has_blanks {
|
||||||
|
tracing::warn!("Skipping invalid fill-in-the-blanks question without blanks array: {:?}", q);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
(blanks.clone(), blanks, false)
|
(blanks.clone(), blanks, false)
|
||||||
}
|
}
|
||||||
_ => (
|
_ => (
|
||||||
@@ -1536,13 +1603,13 @@ pub async fn generate_questions_with_rag(
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
TestTemplateQuestion {
|
Some(TestTemplateQuestion {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
template_id: Uuid::nil(),
|
template_id: Uuid::nil(),
|
||||||
section_id: None,
|
section_id: None,
|
||||||
question_order: idx as i32,
|
question_order: idx as i32,
|
||||||
question_type: question_type_value,
|
question_type: question_type_value,
|
||||||
question_text: q.get("question_text").and_then(|v| v.as_str()).unwrap_or("Question").to_string(),
|
question_text,
|
||||||
options,
|
options,
|
||||||
correct_answer,
|
correct_answer,
|
||||||
explanation: q.get("explanation").and_then(|v| v.as_str()).map(String::from),
|
explanation: q.get("explanation").and_then(|v| v.as_str()).map(String::from),
|
||||||
@@ -1555,7 +1622,7 @@ pub async fn generate_questions_with_rag(
|
|||||||
"options_shuffled": options_shuffled,
|
"options_shuffled": options_shuffled,
|
||||||
})),
|
})),
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ interface Question {
|
|||||||
question_order: number;
|
question_order: number;
|
||||||
question_type: QuestionType;
|
question_type: QuestionType;
|
||||||
question_text: string;
|
question_text: string;
|
||||||
options?: string[];
|
options?: unknown;
|
||||||
correct_answer?: unknown;
|
correct_answer?: unknown;
|
||||||
explanation?: string;
|
explanation?: string;
|
||||||
points: number;
|
points: number;
|
||||||
@@ -27,11 +27,12 @@ interface Question {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TestTemplateFormProps {
|
interface TestTemplateFormProps {
|
||||||
|
templateId?: string;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFormProps) {
|
export default function TestTemplateForm({ templateId, onSuccess, onCancel }: TestTemplateFormProps) {
|
||||||
const [formData, setFormData] = useState<CreateTestTemplatePayload>({
|
const [formData, setFormData] = useState<CreateTestTemplatePayload>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -62,6 +63,7 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
|
|||||||
const [selectedCourseId, setSelectedCourseId] = useState<number | ''>('');
|
const [selectedCourseId, setSelectedCourseId] = useState<number | ''>('');
|
||||||
const [loadingPlans, setLoadingPlans] = useState(false);
|
const [loadingPlans, setLoadingPlans] = useState(false);
|
||||||
const [loadingCourses, setLoadingCourses] = useState(false);
|
const [loadingCourses, setLoadingCourses] = useState(false);
|
||||||
|
const isEditing = Boolean(templateId);
|
||||||
|
|
||||||
// Load MySQL plans on mount
|
// Load MySQL plans on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,6 +102,86 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
|
|||||||
loadCourses();
|
loadCourses();
|
||||||
}, [selectedPlanId]);
|
}, [selectedPlanId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!templateId) return;
|
||||||
|
|
||||||
|
const loadTemplateForEdit = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const data = await cmsApi.getTestTemplate(templateId);
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
name: data.template.name,
|
||||||
|
description: data.template.description || '',
|
||||||
|
mysql_course_id: data.template.mysql_course_id,
|
||||||
|
level: data.template.level,
|
||||||
|
course_type: data.template.course_type,
|
||||||
|
test_type: data.template.test_type,
|
||||||
|
duration_minutes: data.template.duration_minutes,
|
||||||
|
passing_score: data.template.passing_score,
|
||||||
|
total_points: data.template.total_points,
|
||||||
|
instructions: data.template.instructions || '',
|
||||||
|
template_data: data.template.template_data || { sections: [], questions: [] },
|
||||||
|
tags: data.template.tags || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
setSections(
|
||||||
|
(data.sections || []).map((section) => ({
|
||||||
|
id: section.id,
|
||||||
|
title: section.title,
|
||||||
|
description: section.description || '',
|
||||||
|
section_order: section.section_order,
|
||||||
|
points: section.points,
|
||||||
|
instructions: section.instructions || '',
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
setQuestions(
|
||||||
|
(data.questions || []).map((question) => {
|
||||||
|
const allowedTypes: QuestionType[] = [
|
||||||
|
'multiple-choice',
|
||||||
|
'true-false',
|
||||||
|
'short-answer',
|
||||||
|
'essay',
|
||||||
|
'matching',
|
||||||
|
'ordering',
|
||||||
|
'fill-in-the-blanks',
|
||||||
|
'audio-response',
|
||||||
|
];
|
||||||
|
const normalizedType = allowedTypes.includes(question.question_type)
|
||||||
|
? question.question_type
|
||||||
|
: 'multiple-choice';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: question.id,
|
||||||
|
section_id: question.section_id,
|
||||||
|
question_order: question.question_order,
|
||||||
|
question_type: normalizedType,
|
||||||
|
question_text: question.question_text,
|
||||||
|
options: question.options,
|
||||||
|
correct_answer: question.correct_answer,
|
||||||
|
explanation: question.explanation || '',
|
||||||
|
points: question.points,
|
||||||
|
metadata: question.metadata,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.template.mysql_course_id) {
|
||||||
|
setSelectedCourseId(data.template.mysql_course_id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load template for edit:', error);
|
||||||
|
alert('No se pudo cargar la plantilla para editar');
|
||||||
|
onCancel?.();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTemplateForEdit();
|
||||||
|
}, [templateId, onCancel]);
|
||||||
|
|
||||||
// Handle course selection - store mysql_course_id (preferred approach)
|
// Handle course selection - store mysql_course_id (preferred approach)
|
||||||
const handleCourseSelect = (courseId: number | '') => {
|
const handleCourseSelect = (courseId: number | '') => {
|
||||||
setSelectedCourseId(courseId);
|
setSelectedCourseId(courseId);
|
||||||
@@ -123,6 +205,19 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Business rules by test type:
|
||||||
|
// - CA must have at least 4 questions.
|
||||||
|
// - MWT/MOT/FOT/FWT must have exactly 1 question.
|
||||||
|
if (formData.test_type === 'CA' && questions.length < 4) {
|
||||||
|
alert('Las plantillas CA deben tener minimo 4 preguntas.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.test_type !== 'CA' && questions.length !== 1) {
|
||||||
|
alert('Las plantillas MWT, MOT, FOT y FWT deben tener exactamente 1 pregunta.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate: either mysql_course_id OR level+course_type must be provided
|
// Validate: either mysql_course_id OR level+course_type must be provided
|
||||||
if (!formData.mysql_course_id && (!formData.level || !formData.course_type)) {
|
if (!formData.mysql_course_id && (!formData.level || !formData.course_type)) {
|
||||||
alert('Debes seleccionar un curso de MySQL o especificar nivel y tipo de curso manualmente');
|
alert('Debes seleccionar un curso de MySQL o especificar nivel y tipo de curso manualmente');
|
||||||
@@ -132,24 +227,58 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
|
|||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
// Primero crear la plantilla
|
let targetTemplateId = templateId;
|
||||||
const template = await cmsApi.createTestTemplate(formData);
|
|
||||||
|
if (isEditing && templateId) {
|
||||||
|
await cmsApi.updateTestTemplate(templateId, {
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description,
|
||||||
|
mysql_course_id: formData.mysql_course_id,
|
||||||
|
level: formData.level,
|
||||||
|
course_type: formData.course_type,
|
||||||
|
test_type: formData.test_type,
|
||||||
|
duration_minutes: formData.duration_minutes,
|
||||||
|
passing_score: formData.passing_score,
|
||||||
|
total_points: formData.total_points,
|
||||||
|
instructions: formData.instructions,
|
||||||
|
template_data: formData.template_data,
|
||||||
|
tags: formData.tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingData = await cmsApi.getTestTemplate(templateId);
|
||||||
|
|
||||||
|
for (const existingQuestion of existingData.questions) {
|
||||||
|
await cmsApi.deleteTemplateQuestion(templateId, existingQuestion.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const existingSection of existingData.sections) {
|
||||||
|
await cmsApi.deleteTemplateSection(templateId, existingSection.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const template = await cmsApi.createTestTemplate(formData);
|
||||||
|
targetTemplateId = template.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetTemplateId) {
|
||||||
|
throw new Error('No se pudo determinar la plantilla a guardar');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionIdMap = new Map<string, string>();
|
||||||
|
|
||||||
// Luego agregar secciones
|
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
await cmsApi.createTemplateSection(template.id, {
|
const createdSection = await cmsApi.createTemplateSection(targetTemplateId, {
|
||||||
title: section.title,
|
title: section.title,
|
||||||
description: section.description,
|
description: section.description,
|
||||||
section_order: section.section_order,
|
section_order: section.section_order,
|
||||||
points: section.points,
|
points: section.points,
|
||||||
instructions: section.instructions,
|
instructions: section.instructions,
|
||||||
});
|
});
|
||||||
|
sectionIdMap.set(section.id, createdSection.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finalmente agregar preguntas
|
|
||||||
for (const question of questions) {
|
for (const question of questions) {
|
||||||
await cmsApi.createTemplateQuestion(template.id, {
|
await cmsApi.createTemplateQuestion(targetTemplateId, {
|
||||||
section_id: question.section_id,
|
section_id: question.section_id ? sectionIdMap.get(question.section_id) : undefined,
|
||||||
question_order: question.question_order,
|
question_order: question.question_order,
|
||||||
question_type: question.question_type,
|
question_type: question.question_type,
|
||||||
question_text: question.question_text,
|
question_text: question.question_text,
|
||||||
@@ -161,7 +290,7 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
alert('Plantilla creada exitosamente');
|
alert(isEditing ? 'Plantilla actualizada exitosamente' : 'Plantilla creada exitosamente');
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create template:', error);
|
console.error('Failed to create template:', error);
|
||||||
@@ -319,6 +448,8 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
|
|||||||
'essay': 'Ensayo',
|
'essay': 'Ensayo',
|
||||||
'matching': 'Emparejamiento',
|
'matching': 'Emparejamiento',
|
||||||
'ordering': 'Ordenar',
|
'ordering': 'Ordenar',
|
||||||
|
'fill-in-the-blanks': 'Completar espacios',
|
||||||
|
'audio-response': 'Respuesta de audio',
|
||||||
};
|
};
|
||||||
return labels[type] || type;
|
return labels[type] || type;
|
||||||
};
|
};
|
||||||
@@ -329,8 +460,12 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 sticky top-0 bg-white z-10">
|
<div className="flex items-center justify-between p-6 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-gray-900">Nueva Plantilla de Prueba</h2>
|
<h2 className="text-xl font-bold text-gray-900">
|
||||||
<p className="text-sm text-gray-500">Crea preguntas y secciones para tu evaluación</p>
|
{isEditing ? 'Editar Plantilla de Prueba' : 'Nueva Plantilla de Prueba'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{isEditing ? 'Modifica preguntas y secciones de tu evaluación' : 'Crea preguntas y secciones para tu evaluación'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
@@ -763,7 +898,24 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={question.question_type}
|
value={question.question_type}
|
||||||
onChange={(e) => handleUpdateQuestion(question.id, { question_type: e.target.value as QuestionType })}
|
onChange={(e) => {
|
||||||
|
const nextType = e.target.value as QuestionType;
|
||||||
|
const updates: Partial<Question> = { question_type: nextType };
|
||||||
|
|
||||||
|
if (nextType === 'true-false') {
|
||||||
|
updates.options = ['Verdadero', 'Falso'];
|
||||||
|
updates.correct_answer =
|
||||||
|
typeof question.correct_answer === 'number' ? question.correct_answer : 0;
|
||||||
|
} else if (
|
||||||
|
nextType === 'multiple-choice' &&
|
||||||
|
!Array.isArray(question.options)
|
||||||
|
) {
|
||||||
|
updates.options = ['Opción 1', 'Opción 2', 'Opción 3', 'Opción 4'];
|
||||||
|
updates.correct_answer = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpdateQuestion(question.id, updates);
|
||||||
|
}}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="multiple-choice">Opción Múltiple</option>
|
<option value="multiple-choice">Opción Múltiple</option>
|
||||||
@@ -772,6 +924,8 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
|
|||||||
<option value="essay">Ensayo</option>
|
<option value="essay">Ensayo</option>
|
||||||
<option value="matching">Emparejamiento</option>
|
<option value="matching">Emparejamiento</option>
|
||||||
<option value="ordering">Ordenar</option>
|
<option value="ordering">Ordenar</option>
|
||||||
|
<option value="fill-in-the-blanks">Completar espacios</option>
|
||||||
|
<option value="audio-response">Respuesta de audio</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -810,7 +964,7 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
|
|||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
Opciones (marca la correcta)
|
Opciones (marca la correcta)
|
||||||
</label>
|
</label>
|
||||||
{question.options?.map((option, oIdx) => (
|
{(Array.isArray(question.options) ? question.options : []).map((option, oIdx) => (
|
||||||
<div key={oIdx} className="flex items-center gap-2">
|
<div key={oIdx} className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -821,11 +975,13 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={option}
|
value={String(option ?? '')}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newOptions = [...(question.options || [])];
|
const currentOptions = Array.isArray(question.options)
|
||||||
newOptions[oIdx] = e.target.value;
|
? [...question.options]
|
||||||
handleUpdateQuestion(question.id, { options: newOptions });
|
: [];
|
||||||
|
currentOptions[oIdx] = e.target.value;
|
||||||
|
handleUpdateQuestion(question.id, { options: currentOptions });
|
||||||
}}
|
}}
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
placeholder={`Opción ${oIdx + 1}`}
|
placeholder={`Opción ${oIdx + 1}`}
|
||||||
@@ -833,7 +989,10 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newOptions = question.options?.filter((_, idx) => idx !== oIdx);
|
const currentOptions = Array.isArray(question.options)
|
||||||
|
? question.options
|
||||||
|
: [];
|
||||||
|
const newOptions = currentOptions.filter((_, idx) => idx !== oIdx);
|
||||||
handleUpdateQuestion(question.id, { options: newOptions });
|
handleUpdateQuestion(question.id, { options: newOptions });
|
||||||
}}
|
}}
|
||||||
className="p-2 text-red-600 hover:bg-red-50 rounded"
|
className="p-2 text-red-600 hover:bg-red-50 rounded"
|
||||||
@@ -844,9 +1003,17 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
|
|||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleUpdateQuestion(question.id, {
|
onClick={() => {
|
||||||
options: [...(question.options || []), `Opción ${(question.options?.length || 0) + 1}`]
|
const currentOptions = Array.isArray(question.options)
|
||||||
})}
|
? question.options
|
||||||
|
: [];
|
||||||
|
handleUpdateQuestion(question.id, {
|
||||||
|
options: [
|
||||||
|
...currentOptions,
|
||||||
|
`Opción ${currentOptions.length + 1}`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}}
|
||||||
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
|
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
cmsApi,
|
cmsApi,
|
||||||
questionBankApi,
|
questionBankApi,
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
TestTemplateFilters,
|
TestTemplateFilters,
|
||||||
TestType,
|
TestType,
|
||||||
Course,
|
Course,
|
||||||
|
Module,
|
||||||
|
Lesson,
|
||||||
MySqlPlan,
|
MySqlPlan,
|
||||||
MySqlCourse,
|
MySqlCourse,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
@@ -26,7 +28,6 @@ export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate
|
|||||||
const [filters, setFilters] = useState<TestTemplateFilters>({});
|
const [filters, setFilters] = useState<TestTemplateFilters>({});
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
const [filterPlans, setFilterPlans] = useState<MySqlPlan[]>([]);
|
const [filterPlans, setFilterPlans] = useState<MySqlPlan[]>([]);
|
||||||
const [filterCourses, setFilterCourses] = useState<MySqlCourse[]>([]);
|
const [filterCourses, setFilterCourses] = useState<MySqlCourse[]>([]);
|
||||||
const [filterPlanId, setFilterPlanId] = useState<number | ''>('');
|
const [filterPlanId, setFilterPlanId] = useState<number | ''>('');
|
||||||
@@ -35,25 +36,18 @@ export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate
|
|||||||
const [showApplyModal, setShowApplyModal] = useState(false);
|
const [showApplyModal, setShowApplyModal] = useState(false);
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<TestTemplate | null>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<TestTemplate | null>(null);
|
||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
|
const [selectedTargetCourseId, setSelectedTargetCourseId] = useState<string>('');
|
||||||
const [applyPlans, setApplyPlans] = useState<MySqlPlan[]>([]);
|
|
||||||
const [applyCourses, setApplyCourses] = useState<MySqlCourse[]>([]);
|
|
||||||
const [applyPlanId, setApplyPlanId] = useState<number | ''>('');
|
|
||||||
const [applyCourseId, setApplyCourseId] = useState<number | ''>('');
|
|
||||||
const [applyCoursesError, setApplyCoursesError] = useState('');
|
|
||||||
|
|
||||||
const [selectedLesson, setSelectedLesson] = useState<string>('');
|
const [selectedLesson, setSelectedLesson] = useState<string>('');
|
||||||
const [applying, setApplying] = useState(false);
|
const [applying, setApplying] = useState(false);
|
||||||
|
|
||||||
const resetApplyState = () => {
|
const resetApplyState = () => {
|
||||||
setSelectedTemplate(null);
|
setSelectedTemplate(null);
|
||||||
setApplyPlanId('');
|
setSelectedTargetCourseId('');
|
||||||
setApplyCourseId('');
|
|
||||||
setApplyCoursesError('');
|
|
||||||
setSelectedLesson('');
|
setSelectedLesson('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadTemplates = async () => {
|
const loadTemplates = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await cmsApi.listTestTemplates(filters);
|
const data = await cmsApi.listTestTemplates(filters);
|
||||||
@@ -63,88 +57,31 @@ export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadTemplates();
|
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTemplates();
|
||||||
|
}, [loadTemplates]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
questionBankApi.getMySQLPlans().then(setFilterPlans).catch(() => {});
|
questionBankApi.getMySQLPlans().then(setFilterPlans).catch(() => {});
|
||||||
questionBankApi.getMySQLPlans().then(setApplyPlans).catch(() => {});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filterPlanId) {
|
if (!filterPlanId) {
|
||||||
setFilterCourses([]);
|
setFilterCourses([]);
|
||||||
setFilterCourseId('');
|
setFilterCourseId('');
|
||||||
setFilters((f) => ({ ...f, mysql_course_id: undefined }));
|
setFilters((prev) => ({ ...prev, mysql_course_id: undefined }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
questionBankApi.getMySQLCoursesByPlan(filterPlanId).then(setFilterCourses).catch(() => {});
|
|
||||||
}, [filterPlanId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!applyPlanId) {
|
|
||||||
setApplyCourses([]);
|
|
||||||
setApplyCourseId('');
|
|
||||||
setApplyCoursesError('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
questionBankApi
|
questionBankApi
|
||||||
.getMySQLCoursesByPlan(applyPlanId)
|
.getMySQLCoursesByPlan(filterPlanId)
|
||||||
.then((courses) => {
|
.then(setFilterCourses)
|
||||||
setApplyCourses(courses);
|
.catch(() => {
|
||||||
setApplyCoursesError('');
|
setFilterCourses([]);
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to load MySQL courses by plan:', error);
|
|
||||||
setApplyCourses([]);
|
|
||||||
setApplyCoursesError('No se pudieron cargar los cursos para este plan.');
|
|
||||||
});
|
});
|
||||||
}, [applyPlanId]);
|
}, [filterPlanId]);
|
||||||
|
|
||||||
const getCourseTypeFromPlan = (planName: string): 'intensive' | 'regular' => {
|
|
||||||
const planLower = (planName || '').toLowerCase();
|
|
||||||
return planLower.includes('intensive') || planLower.includes('intensivo') ? 'intensive' : 'regular';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCourseLevelFromMysql = (nivelCurso?: number, planNombre?: string): string => {
|
|
||||||
if (typeof nivelCurso === 'number') {
|
|
||||||
if (nivelCurso >= 1 && nivelCurso <= 2) return 'beginner';
|
|
||||||
if (nivelCurso >= 3 && nivelCurso <= 4) return 'beginner_1';
|
|
||||||
if (nivelCurso >= 5 && nivelCurso <= 6) return 'beginner_2';
|
|
||||||
if (nivelCurso >= 7 && nivelCurso <= 8) return 'intermediate';
|
|
||||||
if (nivelCurso >= 9 && nivelCurso <= 10) return 'intermediate_1';
|
|
||||||
if (nivelCurso >= 11 && nivelCurso <= 12) return 'intermediate_2';
|
|
||||||
return 'advanced';
|
|
||||||
}
|
|
||||||
|
|
||||||
const planLower = (planNombre || '').toLowerCase();
|
|
||||||
if (planLower.includes('basic') || planLower.includes('beginner')) return 'beginner';
|
|
||||||
if (planLower.includes('intermediate') || planLower.includes('intermedio')) return 'intermediate';
|
|
||||||
return 'advanced';
|
|
||||||
};
|
|
||||||
|
|
||||||
const matchingPlatformCourses = useMemo(() => {
|
|
||||||
if (!applyCourseId) return [] as Course[];
|
|
||||||
const mysqlCourse = applyCourses.find((c) => c.idCursos === applyCourseId);
|
|
||||||
if (!mysqlCourse) return [] as Course[];
|
|
||||||
|
|
||||||
const expectedLevel = getCourseLevelFromMysql(mysqlCourse.NivelCurso, mysqlCourse.NombrePlan);
|
|
||||||
const expectedType = getCourseTypeFromPlan(mysqlCourse.NombrePlan);
|
|
||||||
|
|
||||||
return courses.filter((course) => {
|
|
||||||
const courseLevel = String((course as { level?: string }).level || '').toLowerCase();
|
|
||||||
const courseType = String((course as { course_type?: string }).course_type || '').toLowerCase();
|
|
||||||
return courseLevel === expectedLevel && courseType === expectedType;
|
|
||||||
});
|
|
||||||
}, [applyCourseId, applyCourses, courses]);
|
|
||||||
|
|
||||||
const selectedPlatformCourseId = useMemo(() => {
|
|
||||||
if (matchingPlatformCourses.length === 0) return '';
|
|
||||||
return matchingPlatformCourses[0].id;
|
|
||||||
}, [matchingPlatformCourses]);
|
|
||||||
|
|
||||||
const filteredTemplates = useMemo(() => {
|
const filteredTemplates = useMemo(() => {
|
||||||
if (!searchTerm.trim()) return templates;
|
if (!searchTerm.trim()) return templates;
|
||||||
@@ -172,10 +109,8 @@ export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate
|
|||||||
const handleApplyTemplate = async (template: TestTemplate) => {
|
const handleApplyTemplate = async (template: TestTemplate) => {
|
||||||
setSelectedTemplate(template);
|
setSelectedTemplate(template);
|
||||||
setShowApplyModal(true);
|
setShowApplyModal(true);
|
||||||
setApplyPlanId('');
|
setSelectedTargetCourseId('');
|
||||||
setApplyCourseId('');
|
|
||||||
setSelectedLesson('');
|
setSelectedLesson('');
|
||||||
setApplyCoursesError('');
|
|
||||||
|
|
||||||
cmsApi.getCourses()
|
cmsApi.getCourses()
|
||||||
.then(setCourses)
|
.then(setCourses)
|
||||||
@@ -183,8 +118,8 @@ export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleApplyToLesson = async () => {
|
const handleApplyToLesson = async () => {
|
||||||
if (!selectedTemplate || !selectedPlatformCourseId || !selectedLesson) {
|
if (!selectedTemplate || !selectedTargetCourseId || !selectedLesson) {
|
||||||
alert('Selecciona plan, curso y lección');
|
alert('Selecciona curso y lección');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,65 +396,27 @@ export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">1. Programa de Estudios</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">1. Curso OpenCCB</label>
|
||||||
<select
|
<select
|
||||||
value={applyPlanId}
|
value={selectedTargetCourseId}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setApplyPlanId(e.target.value ? Number(e.target.value) : '');
|
setSelectedTargetCourseId(e.target.value);
|
||||||
setApplyCourseId('');
|
|
||||||
setSelectedLesson('');
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="">Selecciona un programa...</option>
|
|
||||||
{applyPlans.map((p) => (
|
|
||||||
<option key={p.idPlanDeEstudios} value={p.idPlanDeEstudios}>{p.NombrePlan}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{applyPlanId && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">2. Curso</label>
|
|
||||||
<select
|
|
||||||
value={applyCourseId}
|
|
||||||
onChange={(e) => {
|
|
||||||
setApplyCourseId(e.target.value ? Number(e.target.value) : '');
|
|
||||||
setSelectedLesson('');
|
setSelectedLesson('');
|
||||||
}}
|
}}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Selecciona un curso...</option>
|
<option value="">Selecciona un curso...</option>
|
||||||
{applyCourses.map((c) => (
|
{courses.map((course) => (
|
||||||
<option key={c.idCursos} value={c.idCursos}>{c.NombreCurso}</option>
|
<option key={course.id} value={course.id}>{course.title}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{applyCoursesError && (
|
|
||||||
<p className="text-xs text-red-600 mt-1">{applyCoursesError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{applyCourseId && (
|
{selectedTargetCourseId && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">3. Curso destino detectado</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">2. Lección</label>
|
||||||
{matchingPlatformCourses.length > 0 ? (
|
|
||||||
<div className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-sm text-gray-700">
|
|
||||||
{matchingPlatformCourses[0].title}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-full px-3 py-2 border border-red-200 rounded-lg bg-red-50 text-sm text-red-700">
|
|
||||||
No se encontró un curso de plataforma compatible para este curso SAM.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedPlatformCourseId && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">4. Lección</label>
|
|
||||||
<LessonSelector
|
<LessonSelector
|
||||||
courseId={selectedPlatformCourseId}
|
courseId={selectedTargetCourseId}
|
||||||
selectedLesson={selectedLesson}
|
selectedLesson={selectedLesson}
|
||||||
onSelect={setSelectedLesson}
|
onSelect={setSelectedLesson}
|
||||||
/>
|
/>
|
||||||
@@ -573,7 +470,7 @@ function LessonSelector({
|
|||||||
selectedLesson: string;
|
selectedLesson: string;
|
||||||
onSelect: (lessonId: string) => void;
|
onSelect: (lessonId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [modules, setModules] = useState<any[]>([]);
|
const [modules, setModules] = useState<Module[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -610,7 +507,7 @@ function LessonSelector({
|
|||||||
<option value="">Selecciona una lección...</option>
|
<option value="">Selecciona una lección...</option>
|
||||||
{modules.map((mod) => (
|
{modules.map((mod) => (
|
||||||
<optgroup key={mod.id} label={mod.title}>
|
<optgroup key={mod.id} label={mod.title}>
|
||||||
{(mod.lessons || []).map((lesson: any) => (
|
{(mod.lessons || []).map((lesson: Lesson) => (
|
||||||
<option key={lesson.id} value={lesson.id}>
|
<option key={lesson.id} value={lesson.id}>
|
||||||
{lesson.title}
|
{lesson.title}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
Reference in New Issue
Block a user