feat: Implement SAM structure mirroring in PostgreSQL for study plans and courses
- Added functionality to save study plans and courses in SAM format to PostgreSQL. - Updated SQL queries to reflect SAM-native column names and handle conflicts appropriately. - Introduced new fields in the Asset model for English level and SAM identifiers. - Enhanced the TestTemplateForm component to manage linked assets and shared materials. - Created a new AdminSharedMaterialsPage for uploading ZIP files of shared materials. - Added migrations to create SAM mirror tables and update the assets table with new columns.
This commit is contained in:
+9
-4
@@ -1,22 +1,27 @@
|
||||
# Custom nginx configuration for OpenCCB Studio
|
||||
# This overrides the default location block to route API requests correctly
|
||||
|
||||
# Allow large ZIP uploads (RAG bulk import can exceed 2GB).
|
||||
client_max_body_size 4096m;
|
||||
client_body_timeout 1800s;
|
||||
|
||||
# API routes that need to go to port 3001
|
||||
# Prefer the explicit `/cms-api/*` prefix for frontend fetches. This avoids collisions
|
||||
# with Next.js pages like `/courses` and `/admin` that share the same host.
|
||||
location /cms-api/ {
|
||||
rewrite ^/cms-api/(.*)$ /$1 break;
|
||||
proxy_pass http://openccb-studio:3001;
|
||||
proxy_request_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
proxy_set_header Connection "";
|
||||
proxy_http_version 1.1;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
send_timeout 600s;
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_read_timeout 3600s;
|
||||
send_timeout 3600s;
|
||||
}
|
||||
|
||||
location /lms-api/ {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE assets
|
||||
ADD COLUMN IF NOT EXISTS english_level TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_org_english_level
|
||||
ON assets (organization_id, english_level);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE assets
|
||||
ADD COLUMN IF NOT EXISTS sam_plan_id INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS sam_course_id INTEGER;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_org_sam_plan_course
|
||||
ON assets (organization_id, sam_plan_id, sam_course_id);
|
||||
@@ -0,0 +1,73 @@
|
||||
CREATE TABLE IF NOT EXISTS sam_study_plans (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
idPlanDeEstudios INTEGER NOT NULL,
|
||||
Nombre TEXT NOT NULL,
|
||||
Activo BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (organization_id, idPlanDeEstudios)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sam_study_plans_org_activo
|
||||
ON sam_study_plans (organization_id, Activo);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sam_courses (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
idCursos INTEGER NOT NULL,
|
||||
idPlanDeEstudios INTEGER NOT NULL,
|
||||
NombreCurso TEXT NOT NULL,
|
||||
NivelCurso INTEGER,
|
||||
Duracion DOUBLE PRECISION,
|
||||
Activo BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (organization_id, idCursos),
|
||||
FOREIGN KEY (organization_id, idPlanDeEstudios)
|
||||
REFERENCES sam_study_plans (organization_id, idPlanDeEstudios)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sam_courses_org_plan
|
||||
ON sam_courses (organization_id, idPlanDeEstudios, Activo);
|
||||
|
||||
-- Backfill from existing metadata tables when available.
|
||||
INSERT INTO sam_study_plans (organization_id, idPlanDeEstudios, Nombre, Activo)
|
||||
SELECT
|
||||
organization_id,
|
||||
mysql_id,
|
||||
name,
|
||||
COALESCE(is_active, TRUE)
|
||||
FROM mysql_study_plans
|
||||
ON CONFLICT (organization_id, idPlanDeEstudios) DO UPDATE SET
|
||||
Nombre = EXCLUDED.Nombre,
|
||||
Activo = EXCLUDED.Activo,
|
||||
updated_at = NOW();
|
||||
|
||||
INSERT INTO sam_courses (
|
||||
organization_id,
|
||||
idCursos,
|
||||
idPlanDeEstudios,
|
||||
NombreCurso,
|
||||
NivelCurso,
|
||||
Duracion,
|
||||
Activo
|
||||
)
|
||||
SELECT
|
||||
mc.organization_id,
|
||||
mc.mysql_id,
|
||||
msp.mysql_id,
|
||||
mc.name,
|
||||
mc.level,
|
||||
mc.duracion,
|
||||
COALESCE(mc.is_active, TRUE)
|
||||
FROM mysql_courses mc
|
||||
JOIN mysql_study_plans msp ON msp.id = mc.study_plan_id
|
||||
ON CONFLICT (organization_id, idCursos) DO UPDATE SET
|
||||
idPlanDeEstudios = EXCLUDED.idPlanDeEstudios,
|
||||
NombreCurso = EXCLUDED.NombreCurso,
|
||||
NivelCurso = EXCLUDED.NivelCurso,
|
||||
Duracion = EXCLUDED.Duracion,
|
||||
Activo = EXCLUDED.Activo,
|
||||
updated_at = NOW();
|
||||
@@ -19,6 +19,7 @@ use uuid::Uuid;
|
||||
use std::env;
|
||||
use std::path::Path as StdPath;
|
||||
use tokio::process::Command;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AssetUploadResponse {
|
||||
@@ -49,6 +50,9 @@ pub struct AssetZipImportResponse {
|
||||
pub struct AssetFilters {
|
||||
pub mimetype: Option<String>,
|
||||
pub course_id: Option<Uuid>,
|
||||
pub english_level: Option<String>,
|
||||
pub sam_plan_id: Option<i32>,
|
||||
pub sam_course_id: Option<i32>,
|
||||
pub search: Option<String>,
|
||||
pub page: Option<u32>,
|
||||
pub limit: Option<u32>,
|
||||
@@ -239,6 +243,9 @@ pub async fn upload_asset(
|
||||
let mut data = Vec::new();
|
||||
let mut mimetype = String::new();
|
||||
let mut course_id: Option<Uuid> = None;
|
||||
let mut english_level: Option<String> = None;
|
||||
let mut sam_plan_id: Option<i32> = None;
|
||||
let mut sam_course_id: Option<i32> = None;
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
@@ -263,6 +270,25 @@ pub async fn upload_asset(
|
||||
course_id = Some(id);
|
||||
}
|
||||
}
|
||||
} else if name == "english_level" {
|
||||
if let Ok(txt) = field.text().await {
|
||||
let value = txt.trim();
|
||||
if !value.is_empty() {
|
||||
english_level = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
} else if name == "sam_plan_id" {
|
||||
if let Ok(txt) = field.text().await {
|
||||
if let Ok(id) = txt.trim().parse::<i32>() {
|
||||
sam_plan_id = Some(id);
|
||||
}
|
||||
}
|
||||
} else if name == "sam_course_id" {
|
||||
if let Ok(txt) = field.text().await {
|
||||
if let Ok(id) = txt.trim().parse::<i32>() {
|
||||
sam_course_id = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,14 +365,17 @@ pub async fn upload_asset(
|
||||
// Record in DB
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO assets (id, organization_id, uploaded_by, course_id, filename, storage_path, mimetype, size_bytes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
INSERT INTO assets (id, organization_id, uploaded_by, course_id, english_level, sam_plan_id, sam_course_id, filename, storage_path, mimetype, size_bytes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
"#,
|
||||
)
|
||||
.bind(asset_id)
|
||||
.bind(org_ctx.id)
|
||||
.bind(claims.sub)
|
||||
.bind(course_id)
|
||||
.bind(&english_level)
|
||||
.bind(sam_plan_id)
|
||||
.bind(sam_course_id)
|
||||
.bind(&stored_filename)
|
||||
.bind(&db_storage_path)
|
||||
.bind(&stored_mimetype)
|
||||
@@ -386,6 +415,21 @@ pub async fn list_assets(
|
||||
param_index += 1;
|
||||
}
|
||||
|
||||
if filters.english_level.is_some() {
|
||||
query.push_str(&format!(" AND english_level = ${}", param_index));
|
||||
param_index += 1;
|
||||
}
|
||||
|
||||
if filters.sam_plan_id.is_some() {
|
||||
query.push_str(&format!(" AND sam_plan_id = ${}", param_index));
|
||||
param_index += 1;
|
||||
}
|
||||
|
||||
if filters.sam_course_id.is_some() {
|
||||
query.push_str(&format!(" AND sam_course_id = ${}", param_index));
|
||||
param_index += 1;
|
||||
}
|
||||
|
||||
if filters.search.is_some() {
|
||||
query.push_str(&format!(" AND filename ILIKE ${}", param_index));
|
||||
param_index += 1;
|
||||
@@ -403,6 +447,18 @@ pub async fn list_assets(
|
||||
sql_query = sql_query.bind(cid);
|
||||
}
|
||||
|
||||
if let Some(level) = &filters.english_level {
|
||||
sql_query = sql_query.bind(level);
|
||||
}
|
||||
|
||||
if let Some(plan_id) = filters.sam_plan_id {
|
||||
sql_query = sql_query.bind(plan_id);
|
||||
}
|
||||
|
||||
if let Some(course_id) = filters.sam_course_id {
|
||||
sql_query = sql_query.bind(course_id);
|
||||
}
|
||||
|
||||
if let Some(search) = &filters.search {
|
||||
sql_query = sql_query.bind(format!("%{}%", search));
|
||||
}
|
||||
@@ -551,11 +607,14 @@ pub async fn import_assets_zip(
|
||||
State(pool): State<PgPool>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<AssetZipImportResponse>, (StatusCode, String)> {
|
||||
let mut zip_data = Vec::new();
|
||||
let mut zip_temp_path: Option<String> = None;
|
||||
let mut course_id: Option<Uuid> = None;
|
||||
let mut english_level: Option<String> = None;
|
||||
let mut sam_plan_id: Option<i32> = None;
|
||||
let mut sam_course_id: Option<i32> = None;
|
||||
let mut ingest_rag = false;
|
||||
|
||||
while let Some(field) = multipart
|
||||
while let Some(mut field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
|
||||
@@ -563,11 +622,32 @@ pub async fn import_assets_zip(
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
|
||||
if name == "file" {
|
||||
zip_data = field
|
||||
.bytes()
|
||||
let temp_name = format!("uploads/tmp/import-{}.zip", Uuid::new_v4());
|
||||
tokio::fs::create_dir_all("uploads/tmp")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.to_vec();
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create temp dir: {}", e)))?;
|
||||
|
||||
let mut temp_file = tokio::fs::File::create(&temp_name)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create temp zip file: {}", e)))?;
|
||||
|
||||
while let Some(chunk) = field
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read upload chunk: {}", e)))?
|
||||
{
|
||||
temp_file
|
||||
.write_all(&chunk)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to write temp zip file: {}", e)))?;
|
||||
}
|
||||
|
||||
temp_file
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to flush temp zip file: {}", e)))?;
|
||||
|
||||
zip_temp_path = Some(temp_name);
|
||||
} else if name == "course_id" {
|
||||
if let Ok(txt) = field.text().await {
|
||||
if let Ok(id) = Uuid::parse_str(txt.trim()) {
|
||||
@@ -579,17 +659,46 @@ pub async fn import_assets_zip(
|
||||
let v = txt.trim().to_lowercase();
|
||||
ingest_rag = v == "1" || v == "true" || v == "yes";
|
||||
}
|
||||
} else if name == "english_level" {
|
||||
if let Ok(txt) = field.text().await {
|
||||
let value = txt.trim();
|
||||
if !value.is_empty() {
|
||||
english_level = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
} else if name == "sam_plan_id" {
|
||||
if let Ok(txt) = field.text().await {
|
||||
if let Ok(id) = txt.trim().parse::<i32>() {
|
||||
sam_plan_id = Some(id);
|
||||
}
|
||||
}
|
||||
} else if name == "sam_course_id" {
|
||||
if let Ok(txt) = field.text().await {
|
||||
if let Ok(id) = txt.trim().parse::<i32>() {
|
||||
sam_course_id = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if zip_data.is_empty() {
|
||||
let zip_path = match zip_temp_path {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
return Err((StatusCode::BAD_REQUEST, "No ZIP file uploaded".to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let zip_file = std::fs::File::open(&zip_path)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to open temp zip file: {}", e)))?;
|
||||
|
||||
let mut archive = zip::ZipArchive::new(zip_file)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid ZIP file".to_string()))?;
|
||||
|
||||
if archive.is_empty() {
|
||||
let _ = tokio::fs::remove_file(&zip_path).await;
|
||||
return Err((StatusCode::BAD_REQUEST, "No ZIP file uploaded".to_string()));
|
||||
}
|
||||
|
||||
let reader = std::io::Cursor::new(zip_data);
|
||||
let mut archive = zip::ZipArchive::new(reader)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid ZIP file".to_string()))?;
|
||||
|
||||
let mut imported_assets = 0usize;
|
||||
let mut rag_ingested_assets = 0usize;
|
||||
let mut rag_chunks_ingested = 0usize;
|
||||
@@ -725,14 +834,17 @@ pub async fn import_assets_zip(
|
||||
|
||||
let insert_result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO assets (id, organization_id, uploaded_by, course_id, filename, storage_path, mimetype, size_bytes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
INSERT INTO assets (id, organization_id, uploaded_by, course_id, english_level, sam_plan_id, sam_course_id, filename, storage_path, mimetype, size_bytes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
"#,
|
||||
)
|
||||
.bind(asset_id)
|
||||
.bind(org_ctx.id)
|
||||
.bind(claims.sub)
|
||||
.bind(course_id)
|
||||
.bind(&english_level)
|
||||
.bind(sam_plan_id)
|
||||
.bind(sam_course_id)
|
||||
.bind(&stored_filename)
|
||||
.bind(&db_storage_path)
|
||||
.bind(&mimetype)
|
||||
@@ -753,6 +865,9 @@ pub async fn import_assets_zip(
|
||||
organization_id: org_ctx.id,
|
||||
uploaded_by: Some(claims.sub),
|
||||
course_id,
|
||||
english_level: english_level.clone(),
|
||||
sam_plan_id,
|
||||
sam_course_id,
|
||||
filename: stored_filename.clone(),
|
||||
storage_path: db_storage_path.clone(),
|
||||
mimetype: mimetype.clone(),
|
||||
@@ -820,6 +935,8 @@ pub async fn import_assets_zip(
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tokio::fs::remove_file(&zip_path).await;
|
||||
|
||||
Ok(Json(AssetZipImportResponse {
|
||||
imported_assets,
|
||||
rag_ingested_assets,
|
||||
|
||||
@@ -103,6 +103,24 @@ pub async fn save_mysql_courses_and_plans(
|
||||
let course_type = calculate_course_type(&plan.nombre_plan);
|
||||
tracing::debug!("Saving study plan: {} (ID: {})", plan.nombre_plan, plan.id_plan_de_estudios);
|
||||
|
||||
// Mirror SAM structure in PostgreSQL using SAM-native column names.
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO sam_study_plans (organization_id, idPlanDeEstudios, Nombre, Activo)
|
||||
VALUES ($1, $2, $3, TRUE)
|
||||
ON CONFLICT (organization_id, idPlanDeEstudios) DO UPDATE SET
|
||||
Nombre = EXCLUDED.Nombre,
|
||||
Activo = EXCLUDED.Activo,
|
||||
updated_at = NOW()
|
||||
"#
|
||||
)
|
||||
.bind(org_id)
|
||||
.bind(plan.id_plan_de_estudios)
|
||||
.bind(&plan.nombre_plan)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save SAM study plan mirror: {}", e))?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO mysql_study_plans (mysql_id, organization_id, name, course_type)
|
||||
@@ -129,6 +147,31 @@ pub async fn save_mysql_courses_and_plans(
|
||||
let level_calculated = calculate_course_level(course.nivel_curso);
|
||||
tracing::debug!("Saving course: {} (ID: {}, Plan ID: {})", course.nombre_curso, course.id_cursos, course.id_plan_de_estudios);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO sam_courses (
|
||||
organization_id, idCursos, idPlanDeEstudios, NombreCurso, NivelCurso, Duracion, Activo
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, TRUE)
|
||||
ON CONFLICT (organization_id, idCursos) DO UPDATE SET
|
||||
idPlanDeEstudios = EXCLUDED.idPlanDeEstudios,
|
||||
NombreCurso = EXCLUDED.NombreCurso,
|
||||
NivelCurso = EXCLUDED.NivelCurso,
|
||||
Duracion = EXCLUDED.Duracion,
|
||||
Activo = EXCLUDED.Activo,
|
||||
updated_at = NOW()
|
||||
"#,
|
||||
)
|
||||
.bind(org_id)
|
||||
.bind(course.id_cursos)
|
||||
.bind(course.id_plan_de_estudios)
|
||||
.bind(&course.nombre_curso)
|
||||
.bind(course.nivel_curso)
|
||||
.bind(course.duracion)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save SAM course mirror: {}", e))?;
|
||||
|
||||
// Get study_plan_id from mysql_study_plans
|
||||
let study_plan_id: i32 = sqlx::query_scalar(
|
||||
"SELECT id FROM mysql_study_plans WHERE mysql_id = $1 AND organization_id = $2"
|
||||
@@ -820,15 +863,15 @@ pub async fn get_mysql_plans(
|
||||
Org(org_ctx): Org,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<MySqlPlanInfo>>, (StatusCode, String)> {
|
||||
// Fetch all study plans from PostgreSQL
|
||||
// Read from SAM mirror in PostgreSQL with SAM-native fields.
|
||||
let plans: Vec<MySqlPlanInfo> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
mysql_id as id_plan_de_estudios,
|
||||
name as nombre_plan
|
||||
FROM mysql_study_plans
|
||||
WHERE organization_id = $1 AND is_active = true
|
||||
ORDER BY name
|
||||
idPlanDeEstudios AS id_plan_de_estudios,
|
||||
Nombre AS nombre_plan
|
||||
FROM sam_study_plans
|
||||
WHERE organization_id = $1 AND Activo = TRUE
|
||||
ORDER BY Nombre
|
||||
"#
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
@@ -845,22 +888,25 @@ pub async fn get_mysql_courses_by_plan(
|
||||
State(pool): State<PgPool>,
|
||||
Query(filters): Query<MySqlCoursesFilters>,
|
||||
) -> Result<Json<Vec<MySqlCourseInfo>>, (StatusCode, String)> {
|
||||
// Fetch courses filtered by plan from PostgreSQL
|
||||
// Read from SAM mirror in PostgreSQL with SAM-native fields.
|
||||
let courses: Vec<MySqlCourseInfo> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
c.mysql_id as id_cursos,
|
||||
c.name as nombre_curso,
|
||||
c.level as nivel_curso,
|
||||
sp.mysql_id as id_plan_de_estudios,
|
||||
sp.name as nombre_plan,
|
||||
c.duracion::double precision as duracion
|
||||
FROM mysql_courses c
|
||||
JOIN mysql_study_plans sp ON c.study_plan_id = sp.id
|
||||
c.idCursos AS id_cursos,
|
||||
c.NombreCurso AS nombre_curso,
|
||||
c.NivelCurso AS nivel_curso,
|
||||
c.idPlanDeEstudios AS id_plan_de_estudios,
|
||||
p.Nombre AS nombre_plan,
|
||||
c.Duracion AS duracion
|
||||
FROM sam_courses c
|
||||
JOIN sam_study_plans p
|
||||
ON p.organization_id = c.organization_id
|
||||
AND p.idPlanDeEstudios = c.idPlanDeEstudios
|
||||
WHERE c.organization_id = $1
|
||||
AND c.is_active = true
|
||||
AND sp.mysql_id = $2
|
||||
ORDER BY c.level
|
||||
AND c.Activo = TRUE
|
||||
AND p.Activo = TRUE
|
||||
AND c.idPlanDeEstudios = $2
|
||||
ORDER BY c.NivelCurso
|
||||
"#
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
|
||||
@@ -344,6 +344,9 @@ pub struct Asset {
|
||||
pub organization_id: Uuid,
|
||||
pub uploaded_by: Option<Uuid>,
|
||||
pub course_id: Option<Uuid>,
|
||||
pub english_level: Option<String>,
|
||||
pub sam_plan_id: Option<i32>,
|
||||
pub sam_course_id: Option<i32>,
|
||||
pub filename: String,
|
||||
pub storage_path: String,
|
||||
pub mimetype: String,
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ArrowLeft,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
Mic
|
||||
Mic,
|
||||
FileArchive
|
||||
} from "lucide-react";
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -22,6 +23,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
{ icon: LayoutDashboard, label: "Dashboard", href: "/admin" },
|
||||
{ icon: Building2, label: "Organizations", href: "/admin" },
|
||||
{ icon: Users, label: "Users", href: "/admin/users" },
|
||||
{ icon: FileArchive, label: "Material Compartido", href: "/admin/materials" },
|
||||
{ icon: Mic, label: "Audio Evaluations", href: "/admin/audio-evaluations" },
|
||||
{ icon: ClipboardList, label: "Audit Logs", href: "/admin/audit" },
|
||||
{ icon: Activity, label: "System Tasks", href: "/admin/tasks" },
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { cmsApi, questionBankApi, MySqlPlan, MySqlCourse } from '@/lib/api';
|
||||
import { Upload, Database, FileArchive, CheckCircle2, AlertTriangle } from 'lucide-react';
|
||||
|
||||
export default function AdminSharedMaterialsPage() {
|
||||
const [zipFile, setZipFile] = useState<File | null>(null);
|
||||
const [ingestRag, setIngestRag] = useState(true);
|
||||
const [englishLevel, setEnglishLevel] = useState('');
|
||||
const [plans, setPlans] = useState<MySqlPlan[]>([]);
|
||||
const [courses, setCourses] = useState<MySqlCourse[]>([]);
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<number | ''>('');
|
||||
const [selectedCourseId, setSelectedCourseId] = useState<number | ''>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<{
|
||||
imported_assets: number;
|
||||
rag_ingested_assets: number;
|
||||
rag_chunks_ingested: number;
|
||||
failed_entries: string[];
|
||||
} | null>(null);
|
||||
|
||||
const canUpload = useMemo(() => Boolean(zipFile) && !loading, [zipFile, loading]);
|
||||
|
||||
React.useEffect(() => {
|
||||
questionBankApi.getMySQLPlans().then(setPlans).catch(() => setPlans([]));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedPlanId) {
|
||||
setCourses([]);
|
||||
setSelectedCourseId('');
|
||||
return;
|
||||
}
|
||||
questionBankApi.getMySQLCoursesByPlan(selectedPlanId).then(setCourses).catch(() => setCourses([]));
|
||||
}, [selectedPlanId]);
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!zipFile) {
|
||||
alert('Selecciona un archivo ZIP primero.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
const response = await cmsApi.importAssetsZip(
|
||||
zipFile,
|
||||
ingestRag,
|
||||
undefined,
|
||||
englishLevel || undefined,
|
||||
selectedPlanId || undefined,
|
||||
selectedCourseId || undefined,
|
||||
);
|
||||
setResult(response);
|
||||
alert('Importacion ZIP finalizada.');
|
||||
} catch (error) {
|
||||
console.error('ZIP import failed:', error);
|
||||
const msg = error instanceof Error ? error.message : 'Error al importar ZIP';
|
||||
alert(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-slate-900 dark:text-white">Material Compartido y RAG</h1>
|
||||
<p className="text-slate-600 dark:text-gray-400 mt-2">
|
||||
Sube ZIPs desde Admin para dejar contenido global disponible en todas las plantillas y cursos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/[0.02] p-6 space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-600 dark:text-indigo-400">
|
||||
<FileArchive className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-bold text-slate-900 dark:text-white">Importar ZIP de Materiales</h2>
|
||||
<p className="text-xs text-slate-500 dark:text-gray-500">Se cargan a biblioteca compartida (sin curso especifico).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-gray-300">Archivo ZIP</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(e) => setZipFile(e.target.files?.[0] || null)}
|
||||
className="block w-full text-sm text-slate-700 dark:text-gray-200 file:mr-4 file:rounded-lg file:border-0 file:bg-indigo-600 file:px-4 file:py-2 file:font-semibold file:text-white hover:file:bg-indigo-500"
|
||||
/>
|
||||
{zipFile && (
|
||||
<p className="text-xs text-slate-500 dark:text-gray-400">
|
||||
Seleccionado: {zipFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ingestRag}
|
||||
onChange={(e) => setIngestRag(e.target.checked)}
|
||||
/>
|
||||
<span className="font-medium">Ingerir automaticamente en RAG al importar</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-gray-300">Plan de Estudios (SAM)</label>
|
||||
<select
|
||||
value={selectedPlanId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? Number(e.target.value) : '';
|
||||
setSelectedPlanId(value);
|
||||
setSelectedCourseId('');
|
||||
}}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Seleccionar plan</option>
|
||||
{plans.map((p) => (
|
||||
<option key={p.idPlanDeEstudios} value={p.idPlanDeEstudios}>{p.NombrePlan}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-gray-300">Curso (SAM)</label>
|
||||
<select
|
||||
value={selectedCourseId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? Number(e.target.value) : '';
|
||||
setSelectedCourseId(value);
|
||||
const selected = courses.find((c) => c.idCursos === value);
|
||||
if (selected?.NivelCurso !== undefined && selected?.NivelCurso !== null) {
|
||||
const n = selected.NivelCurso;
|
||||
if (n <= 2) setEnglishLevel('beginner_1');
|
||||
else if (n <= 4) setEnglishLevel('beginner_2');
|
||||
else if (n <= 6) setEnglishLevel('intermediate_1');
|
||||
else if (n <= 8) setEnglishLevel('intermediate_2');
|
||||
else if (n <= 10) setEnglishLevel('advanced_1');
|
||||
else setEnglishLevel('advanced_2');
|
||||
}
|
||||
}}
|
||||
disabled={!selectedPlanId}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm disabled:opacity-60"
|
||||
>
|
||||
<option value="">Seleccionar curso</option>
|
||||
{courses.map((c) => (
|
||||
<option key={c.idCursos} value={c.idCursos}>{c.NombreCurso}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-gray-300">Nivel de Ingles para este ZIP</label>
|
||||
<select
|
||||
value={englishLevel}
|
||||
onChange={(e) => setEnglishLevel(e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Sin nivel (general)</option>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="beginner_1">Beginner 1</option>
|
||||
<option value="beginner_2">Beginner 2</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
<option value="intermediate_1">Intermediate 1</option>
|
||||
<option value="intermediate_2">Intermediate 2</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
<option value="advanced_1">Advanced 1</option>
|
||||
<option value="advanced_2">Advanced 2</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
disabled={!canUpload}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white hover:bg-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{loading ? 'Importando...' : 'Importar ZIP Compartido'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/[0.02] p-6 space-y-4">
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-white">Resultado de la Importacion</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="rounded-lg border border-slate-200 dark:border-white/10 p-4">
|
||||
<div className="flex items-center gap-2 text-slate-700 dark:text-gray-300">
|
||||
<Database className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Assets importados</span>
|
||||
</div>
|
||||
<p className="text-2xl font-black mt-2">{result.imported_assets}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 dark:border-white/10 p-4">
|
||||
<div className="flex items-center gap-2 text-slate-700 dark:text-gray-300">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Assets ingeridos RAG</span>
|
||||
</div>
|
||||
<p className="text-2xl font-black mt-2">{result.rag_ingested_assets}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 dark:border-white/10 p-4">
|
||||
<div className="flex items-center gap-2 text-slate-700 dark:text-gray-300">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Chunks RAG</span>
|
||||
</div>
|
||||
<p className="text-2xl font-black mt-2">{result.rag_chunks_ingested}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.failed_entries.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div className="flex items-center gap-2 text-amber-800 mb-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span className="text-sm font-semibold">Entradas con error</span>
|
||||
</div>
|
||||
<ul className="text-xs text-amber-900 space-y-1 max-h-40 overflow-y-auto">
|
||||
{result.failed_entries.map((entry, idx) => (
|
||||
<li key={`${entry}-${idx}`}>- {entry}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { cmsApi, questionBankApi, CreateTestTemplatePayload, CourseLevel, CourseType, TestType, QuestionType, MySqlPlan, MySqlCourse } from '@/lib/api';
|
||||
import { cmsApi, questionBankApi, CreateTestTemplatePayload, CourseLevel, CourseType, TestType, QuestionType, MySqlPlan, MySqlCourse, Asset } from '@/lib/api';
|
||||
import { X, Save, Plus, Trash2, Sparkles, ChevronDown, ChevronUp, Copy, GripVertical, Edit2 } from 'lucide-react';
|
||||
|
||||
interface Section {
|
||||
@@ -32,6 +32,39 @@ interface TestTemplateFormProps {
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
interface TemplateLinkedAsset {
|
||||
id: string;
|
||||
filename?: string;
|
||||
mimetype?: string;
|
||||
}
|
||||
|
||||
const readLinkedAssetsFromTemplateData = (templateData: unknown): TemplateLinkedAsset[] => {
|
||||
if (!templateData || typeof templateData !== 'object') return [];
|
||||
|
||||
const data = templateData as Record<string, unknown>;
|
||||
const raw = data.selected_assets;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
return raw
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return { id: item };
|
||||
}
|
||||
if (item && typeof item === 'object') {
|
||||
const obj = item as Record<string, unknown>;
|
||||
if (typeof obj.id === 'string') {
|
||||
return {
|
||||
id: obj.id,
|
||||
filename: typeof obj.filename === 'string' ? obj.filename : undefined,
|
||||
mimetype: typeof obj.mimetype === 'string' ? obj.mimetype : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((asset): asset is TemplateLinkedAsset => Boolean(asset));
|
||||
};
|
||||
|
||||
export default function TestTemplateForm({ templateId, onSuccess, onCancel }: TestTemplateFormProps) {
|
||||
const [formData, setFormData] = useState<CreateTestTemplatePayload>({
|
||||
name: '',
|
||||
@@ -63,6 +96,15 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te
|
||||
const [selectedCourseId, setSelectedCourseId] = useState<number | ''>('');
|
||||
const [loadingPlans, setLoadingPlans] = useState(false);
|
||||
const [loadingCourses, setLoadingCourses] = useState(false);
|
||||
const [sharedAssets, setSharedAssets] = useState<Asset[]>([]);
|
||||
const [selectedLinkedAssets, setSelectedLinkedAssets] = useState<TemplateLinkedAsset[]>([]);
|
||||
const [loadingSharedAssets, setLoadingSharedAssets] = useState(false);
|
||||
const [assetSearch, setAssetSearch] = useState('');
|
||||
const [selectedAssetLevel, setSelectedAssetLevel] = useState<string>('');
|
||||
const [selectedAssetPlanId, setSelectedAssetPlanId] = useState<number | ''>('');
|
||||
const [assetPlans, setAssetPlans] = useState<MySqlPlan[]>([]);
|
||||
const [assetCourses, setAssetCourses] = useState<MySqlCourse[]>([]);
|
||||
const [selectedAssetCourseId, setSelectedAssetCourseId] = useState<number | ''>('');
|
||||
const isEditing = Boolean(templateId);
|
||||
|
||||
// Load MySQL plans on mount
|
||||
@@ -81,6 +123,39 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te
|
||||
loadPlans();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
questionBankApi.getMySQLPlans().then(setAssetPlans).catch(() => setAssetPlans([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAssetPlanId) {
|
||||
setAssetCourses([]);
|
||||
setSelectedAssetCourseId('');
|
||||
return;
|
||||
}
|
||||
questionBankApi.getMySQLCoursesByPlan(selectedAssetPlanId).then(setAssetCourses).catch(() => setAssetCourses([]));
|
||||
}, [selectedAssetPlanId]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSharedAssets = async () => {
|
||||
try {
|
||||
setLoadingSharedAssets(true);
|
||||
const assets = await cmsApi.getAssets({
|
||||
english_level: selectedAssetLevel || undefined,
|
||||
sam_plan_id: selectedAssetPlanId || undefined,
|
||||
sam_course_id: selectedAssetCourseId || undefined,
|
||||
});
|
||||
setSharedAssets(assets.filter((asset) => !asset.course_id));
|
||||
} catch (error) {
|
||||
console.error('Failed to load shared assets:', error);
|
||||
} finally {
|
||||
setLoadingSharedAssets(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSharedAssets();
|
||||
}, [selectedAssetLevel, selectedAssetPlanId, selectedAssetCourseId]);
|
||||
|
||||
// Load courses when plan is selected
|
||||
useEffect(() => {
|
||||
const loadCourses = async () => {
|
||||
@@ -125,6 +200,10 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te
|
||||
tags: data.template.tags || [],
|
||||
});
|
||||
|
||||
if (data.template.level) {
|
||||
setSelectedAssetLevel(data.template.level);
|
||||
}
|
||||
|
||||
setSections(
|
||||
(data.sections || []).map((section) => ({
|
||||
id: section.id,
|
||||
@@ -170,6 +249,8 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te
|
||||
if (data.template.mysql_course_id) {
|
||||
setSelectedCourseId(data.template.mysql_course_id);
|
||||
}
|
||||
|
||||
setSelectedLinkedAssets(readLinkedAssetsFromTemplateData(data.template.template_data));
|
||||
} catch (error) {
|
||||
console.error('Failed to load template for edit:', error);
|
||||
alert('No se pudo cargar la plantilla para editar');
|
||||
@@ -214,23 +295,32 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
const templateDataObject: Record<string, unknown> =
|
||||
formData.template_data && typeof formData.template_data === 'object'
|
||||
? { ...(formData.template_data as Record<string, unknown>) }
|
||||
: {};
|
||||
|
||||
templateDataObject.selected_assets = selectedLinkedAssets;
|
||||
|
||||
const payloadBase = {
|
||||
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: templateDataObject,
|
||||
tags: formData.tags,
|
||||
};
|
||||
|
||||
let targetTemplateId = templateId;
|
||||
|
||||
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,
|
||||
});
|
||||
await cmsApi.updateTestTemplate(templateId, payloadBase);
|
||||
|
||||
const existingData = await cmsApi.getTestTemplate(templateId);
|
||||
|
||||
@@ -242,7 +332,7 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te
|
||||
await cmsApi.deleteTemplateSection(templateId, existingSection.id);
|
||||
}
|
||||
} else {
|
||||
const template = await cmsApi.createTestTemplate(formData);
|
||||
const template = await cmsApi.createTestTemplate(payloadBase);
|
||||
targetTemplateId = template.id;
|
||||
}
|
||||
|
||||
@@ -427,6 +517,28 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleLinkedAsset = (asset: Asset) => {
|
||||
setSelectedLinkedAssets((prev) => {
|
||||
const exists = prev.some((selected) => selected.id === asset.id);
|
||||
if (exists) {
|
||||
return prev.filter((selected) => selected.id !== asset.id);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ id: asset.id, filename: asset.filename, mimetype: asset.mimetype },
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
const filteredSharedAssets = sharedAssets.filter((asset) => {
|
||||
if (!assetSearch.trim()) return true;
|
||||
const q = assetSearch.toLowerCase();
|
||||
return (
|
||||
asset.filename.toLowerCase().includes(q) ||
|
||||
asset.mimetype.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const getQuestionTypeLabel = (type: QuestionType) => {
|
||||
const labels: Record<QuestionType, string> = {
|
||||
'multiple-choice': 'Opción Múltiple',
|
||||
@@ -576,6 +688,99 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-emerald-50 p-4 rounded-lg border border-emerald-200">
|
||||
<h4 className="text-sm font-medium text-emerald-900 mb-2">
|
||||
Material Compartido para esta Plantilla
|
||||
</h4>
|
||||
<p className="text-xs text-emerald-700 mb-3">
|
||||
Este material queda vinculado a la plantilla y disponible para instructores al reutilizarla.
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={assetSearch}
|
||||
onChange={(e) => setAssetSearch(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-emerald-300 rounded-lg focus:ring-2 focus:ring-emerald-500 bg-white mb-3"
|
||||
placeholder="Buscar material por nombre o tipo..."
|
||||
/>
|
||||
|
||||
<select
|
||||
value={selectedAssetLevel}
|
||||
onChange={(e) => setSelectedAssetLevel(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-emerald-300 rounded-lg focus:ring-2 focus:ring-emerald-500 bg-white mb-3"
|
||||
>
|
||||
<option value="">Todos los niveles</option>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="beginner_1">Beginner 1</option>
|
||||
<option value="beginner_2">Beginner 2</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
<option value="intermediate_1">Intermediate 1</option>
|
||||
<option value="intermediate_2">Intermediate 2</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
<option value="advanced_1">Advanced 1</option>
|
||||
<option value="advanced_2">Advanced 2</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedAssetPlanId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? Number(e.target.value) : '';
|
||||
setSelectedAssetPlanId(value);
|
||||
setSelectedAssetCourseId('');
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-emerald-300 rounded-lg focus:ring-2 focus:ring-emerald-500 bg-white mb-3"
|
||||
>
|
||||
<option value="">Todos los planes SAM</option>
|
||||
{assetPlans.map((p) => (
|
||||
<option key={p.idPlanDeEstudios} value={p.idPlanDeEstudios}>{p.NombrePlan}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedAssetCourseId}
|
||||
onChange={(e) => setSelectedAssetCourseId(e.target.value ? Number(e.target.value) : '')}
|
||||
disabled={!selectedAssetPlanId}
|
||||
className="w-full px-3 py-2 border border-emerald-300 rounded-lg focus:ring-2 focus:ring-emerald-500 bg-white mb-3 disabled:opacity-60"
|
||||
>
|
||||
<option value="">Todos los cursos SAM</option>
|
||||
{assetCourses.map((c) => (
|
||||
<option key={c.idCursos} value={c.idCursos}>{c.NombreCurso}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="max-h-52 overflow-y-auto bg-white border border-emerald-200 rounded-lg divide-y divide-emerald-100">
|
||||
{loadingSharedAssets && (
|
||||
<p className="text-xs text-emerald-700 p-3">Cargando materiales...</p>
|
||||
)}
|
||||
{!loadingSharedAssets && filteredSharedAssets.length === 0 && (
|
||||
<p className="text-xs text-emerald-700 p-3">No hay materiales compartidos disponibles.</p>
|
||||
)}
|
||||
{!loadingSharedAssets && filteredSharedAssets.map((asset) => {
|
||||
const checked = selectedLinkedAssets.some((a) => a.id === asset.id);
|
||||
return (
|
||||
<label key={asset.id} className="flex items-start gap-3 p-3 cursor-pointer hover:bg-emerald-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => handleToggleLinkedAsset(asset)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{asset.filename}</p>
|
||||
<p className="text-xs text-gray-600">{asset.mimetype}</p>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedLinkedAssets.length > 0 && (
|
||||
<p className="text-xs text-emerald-800 mt-2">
|
||||
{selectedLinkedAssets.length} material(es) vinculados a la plantilla.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
|
||||
@@ -155,6 +155,13 @@ export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const getLinkedMaterialsCount = (template: TestTemplate): number => {
|
||||
const data = template.template_data;
|
||||
if (!data || typeof data !== 'object') return 0;
|
||||
const selected = (data as Record<string, unknown>).selected_assets;
|
||||
return Array.isArray(selected) ? selected.length : 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
@@ -326,6 +333,10 @@ export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate
|
||||
<Tag className="w-4 h-4" />
|
||||
<span>Puntos totales: {template.total_points}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>Materiales vinculados: {getLinkedMaterialsCount(template)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{template.tags && template.tags.length > 0 && (
|
||||
@@ -392,6 +403,10 @@ export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate
|
||||
<span className="text-blue-700">Aprobación:</span>
|
||||
<span className="ml-2 font-medium">{selectedTemplate.passing_score}%</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-blue-700">Materiales vinculados:</span>
|
||||
<span className="ml-2 font-medium">{getLinkedMaterialsCount(selectedTemplate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,12 +13,13 @@ const getApiBaseUrl = (defaultPort: string, envVar?: string) => {
|
||||
const hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
|
||||
// Producción - dominios específicos (fallback sin prefijo)
|
||||
// Producción - usar prefijo explícito para evitar colisiones con rutas de Next.js.
|
||||
// Ejemplo: /question-bank (página) vs /question-bank/mysql-plans (API).
|
||||
if (hostname === STUDIO_DOMAIN) {
|
||||
return `${protocol}//${STUDIO_DOMAIN}`;
|
||||
return `${protocol}//${STUDIO_DOMAIN}/cms-api`;
|
||||
}
|
||||
if (hostname === LEARNING_DOMAIN) {
|
||||
return `${protocol}//${LEARNING_DOMAIN}`;
|
||||
return `${protocol}//${LEARNING_DOMAIN}/cms-api`;
|
||||
}
|
||||
|
||||
// Desarrollo local
|
||||
@@ -599,6 +600,7 @@ export interface Asset {
|
||||
organization_id: string;
|
||||
uploaded_by: string | null;
|
||||
course_id: string | null;
|
||||
english_level?: string | null;
|
||||
filename: string;
|
||||
storage_path: string;
|
||||
mimetype: string;
|
||||
@@ -609,6 +611,9 @@ export interface Asset {
|
||||
export interface AssetFilters {
|
||||
mimetype?: string;
|
||||
course_id?: string;
|
||||
english_level?: string;
|
||||
sam_plan_id?: number;
|
||||
sam_course_id?: number;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
@@ -948,6 +953,9 @@ export const cmsApi = {
|
||||
if (filters) {
|
||||
if (filters.mimetype) params.append('mimetype', filters.mimetype);
|
||||
if (filters.course_id) params.append('course_id', filters.course_id);
|
||||
if (filters.english_level) params.append('english_level', filters.english_level);
|
||||
if (filters.sam_plan_id) params.append('sam_plan_id', String(filters.sam_plan_id));
|
||||
if (filters.sam_course_id) params.append('sam_course_id', String(filters.sam_course_id));
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
if (filters.page) params.append('page', filters.page.toString());
|
||||
if (filters.limit) params.append('limit', filters.limit.toString());
|
||||
@@ -959,12 +967,22 @@ export const cmsApi = {
|
||||
deleteAsset: (id: string): Promise<void> => apiFetch(`/api/assets/${id}`, { method: 'DELETE' }),
|
||||
ingestAssetForRag: (id: string): Promise<AssetRagIngestResult> =>
|
||||
apiFetch(`/api/assets/${id}/ingest-rag`, { method: 'POST' }),
|
||||
importAssetsZip: (file: File, ingestRag = false, courseId?: string): Promise<AssetZipImportResult> => {
|
||||
importAssetsZip: (
|
||||
file: File,
|
||||
ingestRag = false,
|
||||
courseId?: string,
|
||||
englishLevel?: string,
|
||||
samPlanId?: number,
|
||||
samCourseId?: number,
|
||||
): Promise<AssetZipImportResult> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('ingest_rag', ingestRag ? 'true' : 'false');
|
||||
if (courseId) formData.append('course_id', courseId);
|
||||
if (englishLevel) formData.append('english_level', englishLevel);
|
||||
if (samPlanId) formData.append('sam_plan_id', String(samPlanId));
|
||||
if (samCourseId) formData.append('sam_course_id', String(samCourseId));
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${API_BASE_URL}/api/assets/import-zip`);
|
||||
@@ -990,11 +1008,21 @@ export const cmsApi = {
|
||||
xhr.send(formData);
|
||||
});
|
||||
},
|
||||
uploadAsset: (file: File, onProgress?: (pct: number) => void, courseId?: string): Promise<UploadResponse> => {
|
||||
uploadAsset: (
|
||||
file: File,
|
||||
onProgress?: (pct: number) => void,
|
||||
courseId?: string,
|
||||
englishLevel?: string,
|
||||
samPlanId?: number,
|
||||
samCourseId?: number,
|
||||
): Promise<UploadResponse> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (courseId) formData.append('course_id', courseId);
|
||||
if (englishLevel) formData.append('english_level', englishLevel);
|
||||
if (samPlanId) formData.append('sam_plan_id', String(samPlanId));
|
||||
if (samCourseId) formData.append('sam_course_id', String(samCourseId));
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${API_BASE_URL}/api/assets/upload`);
|
||||
|
||||
Reference in New Issue
Block a user