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:
@@ -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