feat(cms/studio): agregar plantillas de curso reutilizables
- backend: CRUD básico de course templates y endpoint para crear curso desde plantilla - migration: tabla course_templates con datos JSON del curso base - frontend: nueva pantalla /course-templates para guardar y aplicar plantillas - navegación: acceso desde menú Cursos
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
-- Course templates for reusable course blueprints
|
||||||
|
CREATE TABLE IF NOT EXISTS course_templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
source_course_id UUID REFERENCES courses(id) ON DELETE SET NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
template_data JSONB NOT NULL,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_course_templates_org_created
|
||||||
|
ON course_templates (organization_id, created_at DESC);
|
||||||
@@ -3931,6 +3931,271 @@ pub async fn import_course(
|
|||||||
Ok(Json(new_course))
|
Ok(Json(new_course))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct CourseTemplateSummary {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub source_course_id: Option<Uuid>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateCourseTemplatePayload {
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ApplyCourseTemplatePayload {
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_course_templates(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
_claims: Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<Vec<CourseTemplateSummary>>, StatusCode> {
|
||||||
|
let templates = sqlx::query_as::<_, CourseTemplateSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT id, name, description, source_course_id, created_at, updated_at
|
||||||
|
FROM course_templates
|
||||||
|
WHERE organization_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(templates))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_course_template_from_course(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(course_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<CreateCourseTemplatePayload>,
|
||||||
|
) -> Result<Json<CourseTemplateSummary>, StatusCode> {
|
||||||
|
if claims.role != "admin" && claims.role != "instructor" {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
let exists = sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM courses WHERE id = $1 AND organization_id = $2)",
|
||||||
|
)
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = exporter::get_course_data(&pool, course_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Template export failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let template_data = serde_json::to_value(&data).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let template = sqlx::query_as::<_, CourseTemplateSummary>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO course_templates (
|
||||||
|
organization_id, source_course_id, name, description, template_data, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, name, description, source_course_id, created_at, updated_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(payload.name)
|
||||||
|
.bind(payload.description)
|
||||||
|
.bind(template_data)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
&pool,
|
||||||
|
org_ctx.id,
|
||||||
|
claims.sub,
|
||||||
|
"COURSE_TEMPLATE_CREATED",
|
||||||
|
"CourseTemplate",
|
||||||
|
template.id,
|
||||||
|
serde_json::json!({ "source_course_id": course_id }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Json(template))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn apply_course_template(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(template_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<ApplyCourseTemplatePayload>,
|
||||||
|
) -> Result<Json<Course>, StatusCode> {
|
||||||
|
if claims.role != "admin" && claims.role != "instructor" {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
let template_data = sqlx::query_scalar::<_, serde_json::Value>(
|
||||||
|
"SELECT template_data FROM course_templates WHERE id = $1 AND organization_id = $2",
|
||||||
|
)
|
||||||
|
.bind(template_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let exported: exporter::CourseExport = serde_json::from_value(template_data)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let mut tx = pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let new_title = payload
|
||||||
|
.title
|
||||||
|
.unwrap_or_else(|| format!("{} (Desde plantilla)", exported.course.title));
|
||||||
|
|
||||||
|
let new_course = sqlx::query_as::<_, Course>(
|
||||||
|
"INSERT INTO courses (
|
||||||
|
organization_id, instructor_id, title, pacing_mode, description,
|
||||||
|
passing_percentage, certificate_template, start_date, end_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING *",
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.bind(new_title)
|
||||||
|
.bind(exported.course.pacing_mode)
|
||||||
|
.bind(exported.course.description)
|
||||||
|
.bind(exported.course.passing_percentage)
|
||||||
|
.bind(exported.course.certificate_template)
|
||||||
|
.bind(exported.course.start_date)
|
||||||
|
.bind(exported.course.end_date)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let mut cat_map = std::collections::HashMap::new();
|
||||||
|
for old_cat in exported.grading_categories {
|
||||||
|
let new_cat = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||||
|
"INSERT INTO grading_categories (organization_id, course_id, name, weight, drop_count)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *",
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(new_course.id)
|
||||||
|
.bind(old_cat.name)
|
||||||
|
.bind(old_cat.weight)
|
||||||
|
.bind(old_cat.drop_count)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
cat_map.insert(old_cat.id, new_cat.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for module_data in exported.modules {
|
||||||
|
let new_module = sqlx::query_as::<_, Module>(
|
||||||
|
"INSERT INTO modules (course_id, organization_id, title, position)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *",
|
||||||
|
)
|
||||||
|
.bind(new_course.id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(module_data.module.title)
|
||||||
|
.bind(module_data.module.position)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
for lesson in module_data.lessons {
|
||||||
|
let new_cat_id = lesson
|
||||||
|
.grading_category_id
|
||||||
|
.and_then(|id| cat_map.get(&id))
|
||||||
|
.cloned();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO lessons (
|
||||||
|
module_id, organization_id, title, content_type,
|
||||||
|
content_url, position, is_graded, metadata, summary,
|
||||||
|
transcription, grading_category_id, max_attempts
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
|
||||||
|
)
|
||||||
|
.bind(new_module.id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(lesson.title)
|
||||||
|
.bind(lesson.content_type)
|
||||||
|
.bind(lesson.content_url)
|
||||||
|
.bind(lesson.position)
|
||||||
|
.bind(lesson.is_graded)
|
||||||
|
.bind(lesson.metadata)
|
||||||
|
.bind(lesson.summary)
|
||||||
|
.bind(lesson.transcription)
|
||||||
|
.bind(new_cat_id)
|
||||||
|
.bind(lesson.max_attempts)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
&pool,
|
||||||
|
org_ctx.id,
|
||||||
|
claims.sub,
|
||||||
|
"COURSE_TEMPLATE_APPLIED",
|
||||||
|
"Course",
|
||||||
|
new_course.id,
|
||||||
|
serde_json::json!({ "template_id": template_id }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Json(new_course))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_course_template(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(template_id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
if claims.role != "admin" && claims.role != "instructor" {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = sqlx::query("DELETE FROM course_templates WHERE id = $1 AND organization_id = $2")
|
||||||
|
.bind(template_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn check_course_access(
|
pub async fn check_course_access(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
course_id: Uuid,
|
course_id: Uuid,
|
||||||
|
|||||||
@@ -222,6 +222,13 @@ async fn main() {
|
|||||||
.route("/courses/generate", post(handlers::generate_course))
|
.route("/courses/generate", post(handlers::generate_course))
|
||||||
.route("/courses/{id}/export", get(handlers::export_course))
|
.route("/courses/{id}/export", get(handlers::export_course))
|
||||||
.route("/courses/import", post(handlers::import_course))
|
.route("/courses/import", post(handlers::import_course))
|
||||||
|
.route("/course-templates", get(handlers::list_course_templates))
|
||||||
|
.route(
|
||||||
|
"/course-templates/from-course/{id}",
|
||||||
|
post(handlers::create_course_template_from_course),
|
||||||
|
)
|
||||||
|
.route("/course-templates/{id}/apply", post(handlers::apply_course_template))
|
||||||
|
.route("/course-templates/{id}", delete(handlers::delete_course_template))
|
||||||
.route("/grading", post(handlers::create_grading_category))
|
.route("/grading", post(handlers::create_grading_category))
|
||||||
.route("/grading/{id}", delete(handlers::delete_grading_category))
|
.route("/grading/{id}", delete(handlers::delete_grading_category))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import PageLayout from "@/components/PageLayout";
|
||||||
|
import { cmsApi, Course, CourseTemplateSummary } from "@/lib/api";
|
||||||
|
import { BookOpen, Plus, Trash2, Wand2 } from "lucide-react";
|
||||||
|
|
||||||
|
export default function CourseTemplatesPage() {
|
||||||
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
|
const [templates, setTemplates] = useState<CourseTemplateSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [sourceCourseId, setSourceCourseId] = useState("");
|
||||||
|
const [templateName, setTemplateName] = useState("");
|
||||||
|
const [templateDescription, setTemplateDescription] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const [instantiatingId, setInstantiatingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const sourceCourse = useMemo(
|
||||||
|
() => courses.find((c) => c.id === sourceCourseId),
|
||||||
|
[courses, sourceCourseId]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [coursesData, templatesData] = await Promise.all([
|
||||||
|
cmsApi.getCourses(),
|
||||||
|
cmsApi.listCourseTemplates(),
|
||||||
|
]);
|
||||||
|
setCourses(coursesData);
|
||||||
|
setTemplates(templatesData);
|
||||||
|
if (!sourceCourseId && coursesData.length > 0) {
|
||||||
|
setSourceCourseId(coursesData[0].id);
|
||||||
|
setTemplateName(`${coursesData[0].title} - Base`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load course templates page", error);
|
||||||
|
alert("No se pudieron cargar los datos de plantillas de curso");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleCreateTemplate() {
|
||||||
|
if (!sourceCourseId || !templateName.trim()) {
|
||||||
|
alert("Selecciona un curso y nombre de plantilla");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await cmsApi.createCourseTemplateFromCourse(
|
||||||
|
sourceCourseId,
|
||||||
|
templateName.trim(),
|
||||||
|
templateDescription.trim() || undefined
|
||||||
|
);
|
||||||
|
setTemplateDescription("");
|
||||||
|
await loadData();
|
||||||
|
alert("Plantilla de curso creada");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create course template", error);
|
||||||
|
alert("No se pudo crear la plantilla de curso");
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInstantiateTemplate(template: CourseTemplateSummary) {
|
||||||
|
const customTitle = prompt(
|
||||||
|
"Título del nuevo curso (opcional):",
|
||||||
|
`${template.name} - Copia`
|
||||||
|
);
|
||||||
|
|
||||||
|
setInstantiatingId(template.id);
|
||||||
|
try {
|
||||||
|
const created = await cmsApi.applyCourseTemplate(
|
||||||
|
template.id,
|
||||||
|
customTitle && customTitle.trim().length > 0 ? customTitle.trim() : undefined
|
||||||
|
);
|
||||||
|
alert(`Curso creado desde plantilla: ${created.title}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to instantiate template", error);
|
||||||
|
alert("No se pudo crear el curso desde la plantilla");
|
||||||
|
} finally {
|
||||||
|
setInstantiatingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteTemplate(template: CourseTemplateSummary) {
|
||||||
|
const ok = confirm(`¿Eliminar plantilla \"${template.name}\"?`);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cmsApi.deleteCourseTemplate(template.id);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete template", error);
|
||||||
|
alert("No se pudo eliminar la plantilla");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout
|
||||||
|
title="Plantillas de Curso"
|
||||||
|
description="Crea una plantilla base de curso y genera nuevos cursos reutilizando estructura y evaluaciones."
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-1 rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/5 p-5 space-y-4">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 dark:text-white">Nueva plantilla</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-slate-700 dark:text-slate-300">Curso base</label>
|
||||||
|
<select
|
||||||
|
value={sourceCourseId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSourceCourseId(e.target.value);
|
||||||
|
const c = courses.find((x) => x.id === e.target.value);
|
||||||
|
if (c) setTemplateName(`${c.title} - Base`);
|
||||||
|
}}
|
||||||
|
className="mt-1 w-full rounded-xl border border-slate-200 dark:border-white/10 bg-white dark:bg-black/20 px-3 py-2"
|
||||||
|
>
|
||||||
|
{courses.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-slate-700 dark:text-slate-300">Nombre de plantilla</label>
|
||||||
|
<input
|
||||||
|
value={templateName}
|
||||||
|
onChange={(e) => setTemplateName(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-xl border border-slate-200 dark:border-white/10 bg-white dark:bg-black/20 px-3 py-2"
|
||||||
|
placeholder="Ej. Inglés A1 - Base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-slate-700 dark:text-slate-300">Descripción</label>
|
||||||
|
<textarea
|
||||||
|
value={templateDescription}
|
||||||
|
onChange={(e) => setTemplateDescription(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-xl border border-slate-200 dark:border-white/10 bg-white dark:bg-black/20 px-3 py-2"
|
||||||
|
rows={4}
|
||||||
|
placeholder="Opcional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCreateTemplate}
|
||||||
|
disabled={creating || !sourceCourseId || !templateName.trim()}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white font-semibold"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{creating ? "Creando..." : "Guardar como plantilla"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{sourceCourse && (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Se usará la estructura de: <strong>{sourceCourse.title}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2 rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/5 p-5">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 dark:text-white mb-4">Plantillas disponibles</h2>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-slate-500">Cargando...</p>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<p className="text-slate-500">No hay plantillas de curso todavía.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{templates.map((t) => (
|
||||||
|
<div key={t.id} className="rounded-xl border border-slate-200 dark:border-white/10 p-4 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen size={16} className="text-blue-600" />
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-white">{t.name}</h3>
|
||||||
|
</div>
|
||||||
|
{t.description ? (
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-300 mt-1">{t.description}</p>
|
||||||
|
) : null}
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
Creada: {new Date(t.created_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleInstantiateTemplate(t)}
|
||||||
|
disabled={instantiatingId === t.id}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg px-3 py-2 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white text-sm font-semibold"
|
||||||
|
>
|
||||||
|
<Wand2 size={14} />
|
||||||
|
{instantiatingId === t.id ? "Creando..." : "Crear curso"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteTemplate(t)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg px-3 py-2 bg-red-600 hover:bg-red-500 text-white text-sm font-semibold"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -111,6 +111,14 @@ export function Navbar() {
|
|||||||
<FileQuestion className="w-4 h-4" />
|
<FileQuestion className="w-4 h-4" />
|
||||||
Plantillas de Pruebas
|
Plantillas de Pruebas
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/course-templates"
|
||||||
|
className={DROPDOWN_ITEM}
|
||||||
|
onClick={() => setCoursesOpen(false)}
|
||||||
|
>
|
||||||
|
<BookOpen className="w-4 h-4" />
|
||||||
|
Plantillas de Curso
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -908,6 +908,21 @@ export const cmsApi = {
|
|||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Course Templates
|
||||||
|
listCourseTemplates: (): Promise<CourseTemplateSummary[]> => apiFetch('/course-templates'),
|
||||||
|
createCourseTemplateFromCourse: (courseId: string, name: string, description?: string): Promise<CourseTemplateSummary> =>
|
||||||
|
apiFetch(`/course-templates/from-course/${courseId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, description })
|
||||||
|
}),
|
||||||
|
applyCourseTemplate: (templateId: string, title?: string): Promise<Course> =>
|
||||||
|
apiFetch(`/course-templates/${templateId}/apply`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ title })
|
||||||
|
}),
|
||||||
|
deleteCourseTemplate: (templateId: string): Promise<void> =>
|
||||||
|
apiFetch(`/course-templates/${templateId}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
deleteCourse: (id: string): Promise<void> => apiFetch(`/courses/${id}`, { method: 'DELETE' }),
|
deleteCourse: (id: string): Promise<void> => apiFetch(`/courses/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
async generateCourse(prompt: string, targetOrgId?: string): Promise<Course> {
|
async generateCourse(prompt: string, targetOrgId?: string): Promise<Course> {
|
||||||
@@ -1414,6 +1429,15 @@ export interface LtiDeepLinkingContentItem {
|
|||||||
[key: string]: string | number | boolean | object | undefined | null;
|
[key: string]: string | number | boolean | object | undefined | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CourseTemplateSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
source_course_id?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackgroundTask {
|
export interface BackgroundTask {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user