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:
2026-04-06 17:04:36 -04:00
parent eea456cd95
commit 7f9b9d69ae
12 changed files with 795 additions and 59 deletions
+3 -1
View File
@@ -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" },
+231
View File
@@ -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>
);
}