feat: add PluginBlock component for rendering external web components in sandboxed iframes
feat: implement PluginsPage for managing plugins with create, toggle, and delete functionalities feat: create PedagogicalAnalyticsPage for displaying course analytics including quality metrics, discrimination index, and curricular suggestions Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { lmsApi, OrgPlugin, CreatePluginPayload } from "@/lib/api";
|
||||
import {
|
||||
Puzzle,
|
||||
Plus,
|
||||
Trash2,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Modal: Crear plugin
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function CreatePluginModal({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onCreated: (p: OrgPlugin) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<CreatePluginPayload>({
|
||||
name: "",
|
||||
description: "",
|
||||
component_url: "",
|
||||
icon_url: "",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.component_url.startsWith("https://")) {
|
||||
setError("La URL del componente debe comenzar con https://");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const plugin = await lmsApi.createPlugin({
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
component_url: form.component_url,
|
||||
icon_url: form.icon_url || undefined,
|
||||
});
|
||||
onCreated(plugin);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Error al crear plugin");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl w-full max-w-lg border border-black/10 dark:border-white/10">
|
||||
<div className="flex items-center justify-between p-6 border-b border-black/5 dark:border-white/5">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Puzzle className="w-5 h-5 text-indigo-500" />
|
||||
Registrar Plugin
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-black/40 dark:text-white/40 hover:text-black dark:hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={(e) => void handleSubmit(e)} className="p-6 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Nombre *</label>
|
||||
<input
|
||||
required
|
||||
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Mi Plugin Interactivo"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Descripción</label>
|
||||
<input
|
||||
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={form.description}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="Descripción breve del plugin"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">URL del Web Component *</label>
|
||||
<input
|
||||
required
|
||||
type="url"
|
||||
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 font-mono"
|
||||
value={form.component_url}
|
||||
onChange={e => setForm(f => ({ ...f, component_url: e.target.value }))}
|
||||
placeholder="https://mi-plugin.ejemplo.com/component"
|
||||
/>
|
||||
<p className="text-xs text-black/40 dark:text-white/40">Solo se permiten URLs HTTPS. El componente se cargará en un iframe sandboxed.</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">URL del Icono</label>
|
||||
<input
|
||||
type="url"
|
||||
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 font-mono"
|
||||
value={form.icon_url}
|
||||
onChange={e => setForm(f => ({ ...f, icon_url: e.target.value }))}
|
||||
placeholder="https://…/icon.svg (opcional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-sm text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg text-sm text-black/60 dark:text-white/60 hover:bg-black/5 dark:hover:bg-white/5 transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-lg text-sm bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{saving && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||
Registrar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tarjeta de Plugin
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function PluginCard({
|
||||
plugin,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
plugin: OrgPlugin;
|
||||
onToggle: (id: string, enabled: boolean) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
const handleToggle = async () => {
|
||||
setToggling(true);
|
||||
await onToggle(plugin.id, !plugin.enabled);
|
||||
setToggling(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`¿Eliminar el plugin "${plugin.name}"? Esta acción no se puede deshacer.`)) return;
|
||||
setDeleting(true);
|
||||
await onDelete(plugin.id);
|
||||
setDeleting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-start gap-4 p-4 rounded-xl border transition-all ${plugin.enabled ? "border-black/10 dark:border-white/10 bg-white/60 dark:bg-white/5" : "border-black/5 dark:border-white/5 bg-black/2 dark:bg-white/2 opacity-60"}`}>
|
||||
{/* Icono */}
|
||||
<div className="w-10 h-10 rounded-lg bg-indigo-100 dark:bg-indigo-900/30 flex items-center justify-center shrink-0">
|
||||
{plugin.icon_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={plugin.icon_url} alt="" className="w-6 h-6 object-contain" />
|
||||
) : (
|
||||
<Puzzle className="w-5 h-5 text-indigo-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{plugin.name}</span>
|
||||
{plugin.enabled ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">Activo</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-black/5 dark:bg-white/5 text-black/40 dark:text-white/40">Inactivo</span>
|
||||
)}
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="text-xs text-black/50 dark:text-white/50 mt-0.5">{plugin.description}</p>
|
||||
)}
|
||||
<a
|
||||
href={plugin.component_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-indigo-500 hover:text-indigo-700 dark:hover:text-indigo-300 mt-1 transition-colors font-mono truncate max-w-xs"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 shrink-0" />
|
||||
{plugin.component_url}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Acciones */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => void handleToggle()}
|
||||
disabled={toggling}
|
||||
title={plugin.enabled ? "Deshabilitar" : "Habilitar"}
|
||||
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{plugin.enabled
|
||||
? <ToggleRight className="w-5 h-5 text-green-500" />
|
||||
: <ToggleLeft className="w-5 h-5 text-black/30 dark:text-white/30" />
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleting}
|
||||
title="Eliminar"
|
||||
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-black/30 dark:text-white/30 hover:text-red-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Página principal
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PluginsPage() {
|
||||
const [plugins, setPlugins] = useState<OrgPlugin[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await lmsApi.listPlugins();
|
||||
setPlugins(data);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Error al cargar plugins");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const handleToggle = async (id: string, enabled: boolean) => {
|
||||
try {
|
||||
const updated = await lmsApi.updatePlugin(id, { enabled });
|
||||
setPlugins(prev => prev.map(p => p.id === id ? updated : p));
|
||||
} catch {
|
||||
setError("Error al actualizar el plugin");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await lmsApi.deletePlugin(id);
|
||||
setPlugins(prev => prev.filter(p => p.id !== id));
|
||||
} catch {
|
||||
setError("Error al eliminar el plugin");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreated = (plugin: OrgPlugin) => {
|
||||
setPlugins(prev => [...prev, plugin]);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const active = plugins.filter(p => p.enabled);
|
||||
const inactive = plugins.filter(p => !p.enabled);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-10 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Puzzle className="w-6 h-6 text-indigo-500" />
|
||||
Ecosistema de Plugins
|
||||
</h1>
|
||||
<p className="text-sm text-black/40 dark:text-white/40 mt-1">
|
||||
Registra y gestiona Web Components externos que se muestran como bloques en las lecciones.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
className="p-2 rounded-lg bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Registrar Plugin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-red-700 dark:text-red-300">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vacío */}
|
||||
{!loading && plugins.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-black/30 dark:text-white/30">
|
||||
<Puzzle className="w-12 h-12" />
|
||||
<p className="text-sm text-center">Aún no hay plugins registrados.<br />Haz clic en <strong>Registrar Plugin</strong> para añadir el primero.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugins activos */}
|
||||
{active.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-black/40 dark:text-white/40 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-green-500" />
|
||||
Activos ({active.length})
|
||||
</h2>
|
||||
{active.map(p => (
|
||||
<PluginCard
|
||||
key={p.id}
|
||||
plugin={p}
|
||||
onToggle={(id, en) => void handleToggle(id, en)}
|
||||
onDelete={(id) => void handleDelete(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugins inactivos */}
|
||||
{inactive.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-black/40 dark:text-white/40">
|
||||
Inactivos ({inactive.length})
|
||||
</h2>
|
||||
{inactive.map(p => (
|
||||
<PluginCard
|
||||
key={p.id}
|
||||
plugin={p}
|
||||
onToggle={(id, en) => void handleToggle(id, en)}
|
||||
onDelete={(id) => void handleDelete(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="rounded-xl border border-black/5 dark:border-white/5 bg-black/2 dark:bg-white/2 p-4 text-xs text-black/40 dark:text-white/40 space-y-1">
|
||||
<p className="font-semibold text-black/50 dark:text-white/50">¿Cómo funciona?</p>
|
||||
<p>1. Registra la URL HTTPS del Web Component externo.</p>
|
||||
<p>2. En el Editor de Lecciones añade un bloque de tipo <code className="font-mono bg-black/5 dark:bg-white/5 px-1 rounded">plugin</code> y selecciona el plugin.</p>
|
||||
<p>3. El componente se carga en un <code className="font-mono bg-black/5 dark:bg-white/5 px-1 rounded">iframe sandbox</code> — los scripts externos nunca acceden al DOM de OpenCCB.</p>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<CreatePluginModal onClose={() => setShowModal(false)} onCreated={handleCreated} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,11 @@ export default function BackgroundTasksPage() {
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
<div className="text-[10px] text-gray-400 mt-1 font-medium">{progress}% completo</div>
|
||||
{(task.processed_items > 0 || task.failed_items > 0) && (
|
||||
<div className="text-[10px] text-gray-400 font-medium">
|
||||
✓ {task.processed_items} / ✗ {task.failed_items}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -79,7 +84,21 @@ export default function BackgroundTasksPage() {
|
||||
return <span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Queued</span>;
|
||||
case 'failed':
|
||||
case 'error':
|
||||
return <span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Failed</span>;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Failed</span>
|
||||
{task.error_message && (
|
||||
<div className="text-[10px] text-red-500 max-w-[180px] leading-tight" title={task.error_message}>
|
||||
{task.error_message.length > 60 ? task.error_message.slice(0, 60) + '…' : task.error_message}
|
||||
</div>
|
||||
)}
|
||||
{task.task_type !== 'lesson_transcription' && (task.processed_items > 0 || task.failed_items > 0) && (
|
||||
<div className="text-[10px] text-gray-400 font-medium">
|
||||
✓ {task.processed_items} ok / ✗ {task.failed_items} error
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'completed':
|
||||
return <span className="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs font-semibold w-fit">Completed</span>;
|
||||
case 'idle':
|
||||
@@ -179,7 +198,17 @@ export default function BackgroundTasksPage() {
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{task.task_type === 'lesson_transcription' && (
|
||||
{(task.task_type === 'lesson_transcription' && (task.status === 'queued' || task.status === 'processing')) && (
|
||||
<button
|
||||
onClick={() => handleCancel(task.id)}
|
||||
disabled={actionLoading === task.id}
|
||||
className="inline-flex items-center px-3 py-1.5 border border-red-200 text-xs font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === task.id ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <XCircle className="w-3 h-3 mr-1" />}
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{(task.task_type === 'zip_rag_import' && (task.status === 'queued' || task.status === 'processing')) && (
|
||||
<button
|
||||
onClick={() => handleCancel(task.id)}
|
||||
disabled={actionLoading === task.id}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
lmsApi,
|
||||
CourseQualityMetrics,
|
||||
CourseDiscriminationReport,
|
||||
CurricularSuggestionsReport,
|
||||
CurricularSuggestion,
|
||||
LessonQualityMetric,
|
||||
QuizDiscriminationItem,
|
||||
} from "@/lib/api";
|
||||
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Star,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers de formato
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function pct(v: number) {
|
||||
return `${(v * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function discriminationLabel(d: number): { label: string; color: string } {
|
||||
if (d >= 0.4) return { label: "Excelente", color: "text-green-600 dark:text-green-400" };
|
||||
if (d >= 0.3) return { label: "Buena", color: "text-blue-600 dark:text-blue-400" };
|
||||
if (d >= 0.2) return { label: "Aceptable", color: "text-yellow-600 dark:text-yellow-400" };
|
||||
return { label: "Revisar", color: "text-red-600 dark:text-red-400" };
|
||||
}
|
||||
|
||||
function severityIcon(severity: string) {
|
||||
switch (severity) {
|
||||
case "high": return <AlertTriangle className="w-4 h-4 text-red-500 shrink-0" />;
|
||||
case "medium": return <Info className="w-4 h-4 text-yellow-500 shrink-0" />;
|
||||
case "positive": return <Star className="w-4 h-4 text-green-500 shrink-0" />;
|
||||
default: return <Info className="w-4 h-4 text-blue-500 shrink-0" />;
|
||||
}
|
||||
}
|
||||
|
||||
function severityBadge(severity: string) {
|
||||
const map: Record<string, string> = {
|
||||
high: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
||||
medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300",
|
||||
info: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
positive: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
high: "Alta prioridad",
|
||||
medium: "Atención",
|
||||
info: "Información",
|
||||
positive: "Destacado",
|
||||
};
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${map[severity] ?? map.info}`}>
|
||||
{labels[severity] ?? severity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Barra horizontal proporcional
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Bar({ value, max = 1, colorClass = "bg-indigo-500" }: { value: number; max?: number; colorClass?: string }) {
|
||||
const pctVal = Math.min(100, Math.max(0, (value / max) * 100));
|
||||
return (
|
||||
<div className="w-full h-2 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all duration-500 ${colorClass}`} style={{ width: `${pctVal}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tabs
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Tab = "quality" | "discrimination" | "suggestions";
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "quality", label: "Métricas de Calidad", icon: <BarChart3 className="w-4 h-4" /> },
|
||||
{ id: "discrimination", label: "Índice de Discriminación", icon: <TrendingDown className="w-4 h-4" /> },
|
||||
{ id: "suggestions", label: "Sugerencias Curriculares", icon: <Lightbulb className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sección: Métricas de Calidad
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function QualityPanel({ data }: { data: CourseQualityMetrics }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-black/50 dark:text-white/50">
|
||||
{data.enrolled} alumno{data.enrolled !== 1 ? "s" : ""} inscrito{data.enrolled !== 1 ? "s" : ""} · {data.lessons.length} lección{data.lessons.length !== 1 ? "es" : ""}
|
||||
</p>
|
||||
|
||||
{data.lessons.length === 0 && (
|
||||
<p className="text-sm text-black/40 dark:text-white/40 italic">Sin datos de entregas todavía.</p>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-separate border-spacing-y-1">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-black/40 dark:text-white/40 uppercase tracking-wider">
|
||||
<th className="pb-2 pr-4">#</th>
|
||||
<th className="pb-2 pr-4">Lección</th>
|
||||
<th className="pb-2 pr-4">Completitud</th>
|
||||
<th className="pb-2 pr-4">Puntaje Medio</th>
|
||||
<th className="pb-2 pr-4">Fallo</th>
|
||||
<th className="pb-2 pr-4">Intentos Prom.</th>
|
||||
<th className="pb-2">Sin entregar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.lessons.map((l: LessonQualityMetric) => (
|
||||
<tr key={l.lesson_id} className="bg-white/50 dark:bg-white/5 rounded-lg">
|
||||
<td className="py-3 px-3 rounded-l-lg text-black/30 dark:text-white/30 w-8">{l.position}</td>
|
||||
<td className="py-3 pr-4 font-medium max-w-[200px] truncate" title={l.lesson_title}>{l.lesson_title}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="space-y-1">
|
||||
<span className="font-mono">{pct(l.completion_rate)}</span>
|
||||
<Bar value={l.completion_rate} colorClass={l.completion_rate < 0.4 ? "bg-red-400" : "bg-indigo-500"} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="space-y-1">
|
||||
<span className="font-mono">{pct(l.avg_score)}</span>
|
||||
<Bar value={l.avg_score} colorClass={l.avg_score < 0.5 ? "bg-orange-400" : l.avg_score > 0.9 ? "bg-green-400" : "bg-sky-500"} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-mono">
|
||||
<span className={l.failure_rate > 0.4 ? "text-red-500 font-semibold" : ""}>{pct(l.failure_rate)}</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-mono">{l.avg_attempts.toFixed(1)}</td>
|
||||
<td className="py-3 px-3 rounded-r-lg font-mono">
|
||||
<span className={l.abandonment_count > 0 ? "text-yellow-600 dark:text-yellow-400" : "text-black/30 dark:text-white/30"}>{l.abandonment_count}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sección: Índice de Discriminación
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function DiscriminationPanel({ data }: { data: CourseDiscriminationReport }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-black/50 dark:text-white/50">
|
||||
El índice mide si los alumnos con mejor rendimiento global aciertan más esta pregunta. Un índice ≥ 0.4 es excelente.
|
||||
</p>
|
||||
|
||||
{data.items.length === 0 && (
|
||||
<p className="text-sm text-black/40 dark:text-white/40 italic">
|
||||
No hay datos de <code>block_scores</code> en los metadatos de entregas todavía.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-separate border-spacing-y-1">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-black/40 dark:text-white/40 uppercase tracking-wider">
|
||||
<th className="pb-2 pr-4">Lección</th>
|
||||
<th className="pb-2 pr-4">Bloque</th>
|
||||
<th className="pb-2 pr-4">Índice Discriminación</th>
|
||||
<th className="pb-2 pr-4">Facilidad</th>
|
||||
<th className="pb-2">Muestra</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((item: QuizDiscriminationItem) => {
|
||||
const { label, color } = discriminationLabel(item.discrimination_index);
|
||||
return (
|
||||
<tr key={`${item.lesson_id}-${item.block_id}`} className="bg-white/50 dark:bg-white/5 rounded-lg">
|
||||
<td className="py-3 px-3 rounded-l-lg font-medium max-w-[180px] truncate" title={item.lesson_title}>{item.lesson_title}</td>
|
||||
<td className="py-3 pr-4 font-mono text-xs text-black/50 dark:text-white/50 max-w-[120px] truncate">{item.block_id}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono">{item.discrimination_index.toFixed(2)}</span>
|
||||
<span className={`text-xs font-semibold ${color}`}>{label}</span>
|
||||
</div>
|
||||
<Bar value={item.discrimination_index} max={1} colorClass={item.discrimination_index >= 0.3 ? "bg-green-500" : item.discrimination_index >= 0.2 ? "bg-yellow-400" : "bg-red-400"} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="space-y-1">
|
||||
<span className="font-mono">{pct(item.facility_index)}</span>
|
||||
<Bar value={item.facility_index} colorClass="bg-sky-500" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3 rounded-r-lg font-mono text-black/40 dark:text-white/40">{item.sample_size}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sección: Sugerencias Curriculares
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SuggestionsPanel({ data }: { data: CurricularSuggestionsReport }) {
|
||||
if (data.suggestions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-black/30 dark:text-white/30">
|
||||
<CheckCircle2 className="w-10 h-10" />
|
||||
<p className="text-sm">No hay sugerencias en este momento. El curso tiene métricas saludables o aún pocos datos.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const high = data.suggestions.filter(s => s.severity === "high");
|
||||
const medium = data.suggestions.filter(s => s.severity === "medium");
|
||||
const others = data.suggestions.filter(s => s.severity !== "high" && s.severity !== "medium");
|
||||
|
||||
const groups = [
|
||||
{ label: "Alta prioridad", items: high },
|
||||
{ label: "Atención", items: medium },
|
||||
{ label: "Otras observaciones", items: others },
|
||||
].filter(g => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{groups.map(group => (
|
||||
<div key={group.label} className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-black/40 dark:text-white/40">{group.label}</h3>
|
||||
{group.items.map((s: CurricularSuggestion, i: number) => (
|
||||
<div key={i} className="flex gap-3 p-4 rounded-xl bg-white/50 dark:bg-white/5 border border-black/5 dark:border-white/5">
|
||||
{severityIcon(s.severity)}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium truncate">{s.lesson_title}</span>
|
||||
{severityBadge(s.severity)}
|
||||
</div>
|
||||
<p className="text-sm text-black/60 dark:text-white/60">{s.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Página principal
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PedagogicalAnalyticsPage() {
|
||||
const { id } = useParams() as { id: string };
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>("quality");
|
||||
const [quality, setQuality] = useState<CourseQualityMetrics | null>(null);
|
||||
const [discrimination, setDiscrimination] = useState<CourseDiscriminationReport | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<CurricularSuggestionsReport | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [q, d, s] = await Promise.all([
|
||||
lmsApi.getCourseQualityMetrics(id),
|
||||
lmsApi.getCourseDiscriminationIndex(id),
|
||||
lmsApi.getCourseSuggestions(id),
|
||||
]);
|
||||
setQuality(q);
|
||||
setDiscrimination(d);
|
||||
setSuggestions(s);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Error al cargar análisis pedagógico");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
return (
|
||||
<CourseEditorLayout activeTab="pedagogical">
|
||||
<div className="max-w-5xl mx-auto px-4 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<TrendingUp className="w-6 h-6 text-indigo-500" />
|
||||
Análisis Pedagógico Profundo
|
||||
</h1>
|
||||
<p className="text-sm text-black/40 dark:text-white/40 mt-1">
|
||||
Métricas de calidad, índice de discriminación y sugerencias de mejora curricular.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 p-1 bg-black/5 dark:bg-white/5 rounded-xl w-fit">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === tab.id
|
||||
? "bg-white dark:bg-white/10 shadow-sm text-indigo-600 dark:text-indigo-400"
|
||||
: "text-black/50 dark:text-white/50 hover:text-black/80 dark:hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="rounded-2xl border border-black/5 dark:border-white/5 bg-white/30 dark:bg-white/5 p-6">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20 text-black/30 dark:text-white/30">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mr-2" />
|
||||
Cargando análisis...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === "quality" && quality && (
|
||||
<QualityPanel data={quality} />
|
||||
)}
|
||||
{!loading && activeTab === "discrimination" && discrimination && (
|
||||
<DiscriminationPanel data={discrimination} />
|
||||
)}
|
||||
{!loading && activeTab === "suggestions" && suggestions && (
|
||||
<SuggestionsPanel data={suggestions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CourseEditorLayout>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,8 @@ type TabKey =
|
||||
| "team"
|
||||
| "peer-reviews"
|
||||
| "students"
|
||||
| "sessions";
|
||||
| "sessions"
|
||||
| "pedagogical";
|
||||
|
||||
interface CourseEditorLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -114,6 +115,7 @@ export default function CourseEditorLayout({
|
||||
icon: TrendingUp,
|
||||
tabs: [
|
||||
{ key: "analytics", label: "Analíticas", icon: BarChart2, href: `/courses/${id}/analytics` },
|
||||
{ key: "pedagogical", label: "Análisis Pedagógico", icon: TrendingUp, href: `/courses/${id}/analytics/pedagogical` },
|
||||
{ key: "settings", label: "Configuración", icon: Settings, href: `/courses/${id}/settings` },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -672,6 +672,47 @@ export interface CourseAnalytics {
|
||||
}[];
|
||||
}
|
||||
|
||||
// Análisis Pedagógico Profundo (Fase 34)
|
||||
export interface LessonQualityMetric {
|
||||
lesson_id: string;
|
||||
lesson_title: string;
|
||||
position: number;
|
||||
completion_rate: number;
|
||||
avg_attempts: number;
|
||||
avg_score: number;
|
||||
failure_rate: number;
|
||||
abandonment_count: number;
|
||||
}
|
||||
export interface CourseQualityMetrics {
|
||||
course_id: string;
|
||||
enrolled: number;
|
||||
lessons: LessonQualityMetric[];
|
||||
}
|
||||
export interface QuizDiscriminationItem {
|
||||
lesson_id: string;
|
||||
lesson_title: string;
|
||||
block_id: string;
|
||||
discrimination_index: number;
|
||||
facility_index: number;
|
||||
sample_size: number;
|
||||
}
|
||||
export interface CourseDiscriminationReport {
|
||||
course_id: string;
|
||||
items: QuizDiscriminationItem[];
|
||||
}
|
||||
export interface CurricularSuggestion {
|
||||
lesson_id: string;
|
||||
lesson_title: string;
|
||||
kind: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
}
|
||||
export interface CurricularSuggestionsReport {
|
||||
course_id: string;
|
||||
suggestions: CurricularSuggestion[];
|
||||
}
|
||||
|
||||
|
||||
export interface CohortData {
|
||||
period: string;
|
||||
count: number;
|
||||
@@ -1769,6 +1810,24 @@ export const lmsApi = {
|
||||
method: 'GET',
|
||||
query: { days, limit }
|
||||
}, true),
|
||||
|
||||
// Análisis Pedagógico Profundo (Fase 34)
|
||||
getCourseQualityMetrics: (courseId: string): Promise<CourseQualityMetrics> =>
|
||||
apiFetch(`/courses/${courseId}/pedagogical/quality-metrics`, {}, true),
|
||||
getCourseDiscriminationIndex: (courseId: string): Promise<CourseDiscriminationReport> =>
|
||||
apiFetch(`/courses/${courseId}/pedagogical/discrimination-index`, {}, true),
|
||||
getCourseSuggestions: (courseId: string): Promise<CurricularSuggestionsReport> =>
|
||||
apiFetch(`/courses/${courseId}/pedagogical/suggestions`, {}, true),
|
||||
|
||||
// Fase 35: Ecosistema de Plugins
|
||||
listPlugins: (): Promise<OrgPlugin[]> =>
|
||||
apiFetch('/plugins', {}, true),
|
||||
createPlugin: (payload: CreatePluginPayload): Promise<OrgPlugin> =>
|
||||
apiFetch('/plugins', { method: 'POST', body: JSON.stringify(payload) }, true),
|
||||
updatePlugin: (id: string, payload: UpdatePluginPayload): Promise<OrgPlugin> =>
|
||||
apiFetch(`/plugins/${id}`, { method: 'PUT', body: JSON.stringify(payload) }, true),
|
||||
deletePlugin: (id: string): Promise<void> =>
|
||||
apiFetch(`/plugins/${id}`, { method: 'DELETE' }, true),
|
||||
};
|
||||
|
||||
export interface Meeting {
|
||||
@@ -1916,6 +1975,35 @@ export interface AiDataEthicsSummaryResponse {
|
||||
events: AiDataEthicsEventItem[];
|
||||
}
|
||||
|
||||
// Fase 35: Ecosistema de Plugins
|
||||
export interface OrgPlugin {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
component_url: string;
|
||||
icon_url: string | null;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
export interface CreatePluginPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
component_url: string;
|
||||
icon_url?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
export interface UpdatePluginPayload {
|
||||
name?: string;
|
||||
description?: string;
|
||||
component_url?: string;
|
||||
icon_url?: string;
|
||||
config?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface LtiDeepLinkingContentItem {
|
||||
type: 'ltiResourceLink';
|
||||
title?: string;
|
||||
@@ -1942,6 +2030,9 @@ export interface BackgroundTask {
|
||||
task_type: 'lesson_transcription' | 'lesson_image' | 'course_image' | 'zip_rag_import';
|
||||
status: 'idle' | 'queued' | 'processing' | 'failed' | 'completed' | 'error';
|
||||
progress: number;
|
||||
processed_items: number;
|
||||
failed_items: number;
|
||||
error_message?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user